Merge pull request #190 from ViPro-Technologies/NEO-149-e7m4-06-contract-completion-encounter-wiring
NEO-149: E7M4-06 contract completion on encounter clearpull/191/head
commit
2cf8bcd0b9
|
|
@ -0,0 +1,35 @@
|
||||||
|
meta {
|
||||||
|
name: Reset contract instance via quest fixture (NEO-149)
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/__dev/quest-fixture
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"resetContractInstanceIds": [
|
||||||
|
"ci_00000000000000000000000000000000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-149: quest-fixture accepts resetContractInstanceIds — clears contract instance, contract_completion delivery row, and outcome audit for each id (idempotent when absent).
|
||||||
|
Full issue → clear → GET Bruno loop lands in NEO-151 when contract HTTP ships.
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("status 200", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applied true", function () {
|
||||||
|
expect(res.getBody().applied).to.equal(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ meta {
|
||||||
}
|
}
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). No inventory POST shortcuts for spine materials.
|
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). Deny envelopes return empty inputsConsumed/outputsGranted arrays. No inventory POST shortcuts for spine materials.
|
||||||
}
|
}
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,20 @@ async function resetPrototypeFactionStanding(bru) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetContractInstances(bru, contractInstanceIds) {
|
||||||
|
const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru);
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
|
||||||
|
{ schemaVersion: 1, resetContractInstanceIds: contractInstanceIds },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
if (response.status !== 200 || response.data?.applied !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`quest-fixture resetContractInstanceIds failed: ${response.status} ${JSON.stringify(response.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function assertAllPrototypeQuestsNotStarted(bru) {
|
async function assertAllPrototypeQuestsNotStarted(bru) {
|
||||||
const { baseUrl, playerId } = resolveConfig(bru);
|
const { baseUrl, playerId } = resolveConfig(bru);
|
||||||
const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, {
|
const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, {
|
||||||
|
|
@ -111,6 +125,7 @@ module.exports = {
|
||||||
resetGatherIntroQuestProgress,
|
resetGatherIntroQuestProgress,
|
||||||
resetGatherIntroSpine,
|
resetGatherIntroSpine,
|
||||||
resetPrototypeFactionStanding,
|
resetPrototypeFactionStanding,
|
||||||
|
resetContractInstances,
|
||||||
clearInventory,
|
clearInventory,
|
||||||
assertAllPrototypeQuestsNotStarted,
|
assertAllPrototypeQuestsNotStarted,
|
||||||
ALL_PROTOTYPE_QUEST_IDS,
|
ALL_PROTOTYPE_QUEST_IDS,
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
|
||||||
|
|
||||||
**Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127).
|
**Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127).
|
||||||
|
|
||||||
|
**Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149).
|
||||||
|
|
||||||
## Source anchors
|
## Source anchors
|
||||||
|
|
||||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
|
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,179 @@
|
||||||
|
# NEO-149 — E7M4-06: ContractCompletionOperations + encounter wiring
|
||||||
|
|
||||||
|
**Linear:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)
|
||||||
|
**Branch:** `NEO-149-e7m4-06-contract-completion-encounter-wiring`
|
||||||
|
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-06**
|
||||||
|
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
|
||||||
|
**Pattern:** [NEO-128-implementation-plan.md](NEO-128-implementation-plan.md) — deliver-then-mark + idempotent replay; [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) — `ContractGeneratorOperations` orchestrator shape; [NEO-148-implementation-plan.md](NEO-148-implementation-plan.md) — `TryDeliverContractCompletion` + outcome audit split
|
||||||
|
**Precursors:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) **`Done`** — `ContractGeneratorOperations.TryIssue`; [NEO-148](https://linear.app/neon-sprawl/issue/NEO-148) **`Done`** — `RewardRouterOperations.TryDeliverContractCompletion`
|
||||||
|
**Blocks:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) (E7M4-08 HTTP contract projections + Bruno issue/clear loop)
|
||||||
|
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153), [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) (blocked by E7M4-08; no Godot work in this story)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Encounter clear completes a matching **active** contract instance and triggers the template **`completionRewardBundle`** payout. First-time encounter loot (E5.M3) remains unchanged and runs **before** contract payout.
|
||||||
|
|
||||||
|
## Kickoff clarifications
|
||||||
|
|
||||||
|
**No blocking decisions needed.** Linear AC, [E7M4-06 backlog scope](E7M4-pre-production-backlog.md#e7m4-06--contractcompletionoperations--encounter-wiring), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-128/NEO-147/NEO-148 code settle:
|
||||||
|
|
||||||
|
| Topic | Decision | Evidence |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| Grant order vs encounter loot | **Encounter loot first**, contract payout second — contract wiring runs only after `TryCompleteAndGrant` commits encounter grants + completion mark | Backlog E7M4-06 in-scope wiring note; `EncounterCompletionOperations` already applies reward-table grants before downstream hooks |
|
||||||
|
| Deliver-then-mark | **`TryDeliverContractCompletion`** then **`IContractInstanceStore.TryMarkComplete`**; on delivery deny, instance stays **`active`** | NEO-128 quest precedent; README reward router § contract wiring note |
|
||||||
|
| Outcome audit | **`IContractOutcomeStore.TryAppend`** after successful delivery + mark; grant snapshots from **`RewardDeliveryResult`** / **`RewardDeliveryEvent`** | NEO-146 open-questions table; outcome store requires existing instance row |
|
||||||
|
| Encounter template match | Compare cleared **`encounterId`** to template **`encounterTemplateId`** on the player's active instance | Backlog “match encounterTemplateId”; prototype binds **`prototype_combat_pocket`** |
|
||||||
|
| No active / wrong encounter | **Structured no-op success** — do not fail encounter completion | Mirror quest wiring best-effort; encounter already committed |
|
||||||
|
| Idempotent replay | When instance already **`completed`**, return success without re-delivering (router **`already_delivered`** path if invoked) | NEO-128 + store **`TryMarkComplete`** replay semantics |
|
||||||
|
| Wiring call site | **`EncounterCompletionOperations.TryCompleteAndGrant`** after **`QuestObjectiveWiring.TryProcessEncounterCompleteSuccess`** | Same hook point as quest credit; `EncounterCombatWiring` unchanged except extended dependency pass-through |
|
||||||
|
| Best-effort | Contract deny does **not** roll back encounter loot or completion mark | Encounter grants committed before contract hook; quest wiring precedent |
|
||||||
|
| Bruno smoke (`issue → clear → GET`) | **Deferred to NEO-151** — no contract HTTP routes yet | E7M4-08 scope; this story covers orchestrator + encounter wiring + integration tests |
|
||||||
|
| Contract fixture reset | Add contract delivery **`TryClear`** helper for Bruno/dev loops (NEO-148 deferred item) | NEO-148 plan open questions |
|
||||||
|
| Telemetry | **Comment-only** `contract_complete` hook in orchestrator — E7M4-09 / NEO-152 | Backlog E7M4-09 |
|
||||||
|
|
||||||
|
## Scope and out-of-scope
|
||||||
|
|
||||||
|
**In scope (from Linear + backlog):**
|
||||||
|
|
||||||
|
- **`ContractCompletionOperations.TryCompleteOnEncounterClear`** static orchestrator in `Game/Contracts/`.
|
||||||
|
- **`ContractCompletionReasonCodes`** + **`ContractCompletionOperationResult`** envelope.
|
||||||
|
- Wire from **`EncounterCompletionOperations`** success path (after encounter loot + quest wiring).
|
||||||
|
- Pass contract dependencies through **`EncounterCombatWiring`** → **`AbilityCastApi`** (mirror quest store injection).
|
||||||
|
- Flow: resolve active instance → match encounter → deliver bundle → mark instance **`completed`** → append **`ContractOutcomeRow`**.
|
||||||
|
- Integration tests: issue → encounter clear → instance **`completed`** + delivery row + outcome row; clear without contract unchanged; wrong encounter no-op; encounter first-time loot idempotency unchanged.
|
||||||
|
- Extend **`QuestFixtureOperations`** (or adjacent dev fixture) with contract delivery **`TryClear`** for test/Bruno reset loops.
|
||||||
|
- `server/README.md` contract completion operations section; E7.M4 module anchor.
|
||||||
|
|
||||||
|
**Out of scope (from Linear + backlog):**
|
||||||
|
|
||||||
|
- HTTP **`POST …/contracts/issue`**, contract GET projections (NEO-151 / E7M4-08).
|
||||||
|
- Bruno smoke requiring HTTP (NEO-151).
|
||||||
|
- **`ContractEconomyValidation`** at issue (NEO-150 / E7M4-07).
|
||||||
|
- Godot HUD (NEO-153 / NEO-154).
|
||||||
|
- E9.M1 telemetry ingest (comment-only hook sites optional).
|
||||||
|
|
||||||
|
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153), [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) — server HTTP must land in NEO-151 first.
|
||||||
|
|
||||||
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
|
- [x] Active contract completes when bound encounter clears.
|
||||||
|
- [x] Encounter first-time loot idempotency unchanged (E5.M3).
|
||||||
|
|
||||||
|
## Implementation reconciliation (shipped)
|
||||||
|
|
||||||
|
- **Types:** `ContractCompletionReasonCodes`, `ContractCompletionOperationResult`.
|
||||||
|
- **Orchestrator:** `ContractCompletionOperations.TryCompleteOnEncounterClear` — deliver-then-mark, outcome append, no-op on mismatch/no active.
|
||||||
|
- **Wiring:** `EncounterCompletionOperations` → contract hook after quest wiring; `EncounterCombatWiring` / `AbilityCastApi` pass contract DI.
|
||||||
|
- **Fixture:** `QuestFixtureRequest.resetContractInstanceIds` clears instance + delivery + outcome rows.
|
||||||
|
- **Tests:** 5 unit + 1 integration + 1 encounter wiring test; full suite **904** tests green.
|
||||||
|
- **Docs:** `server/README.md` contract completion section; E7.M4 module anchor.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
### 1. Types (`Game/Contracts/`)
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| **`ContractCompletionReasonCodes`** | Stable codes: **`no_active_contract`**, **`encounter_mismatch`**, **`unknown_template`**, **`player_not_writable`**, **`invalid_ids`**; passthrough router codes on delivery deny (e.g. **`inventory_full`**, **`already_delivered`**) |
|
||||||
|
| **`ContractCompletionOperationResult`** | `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, optional **`ContractOutcomeRow`** |
|
||||||
|
|
||||||
|
### 2. `ContractCompletionOperations.TryCompleteOnEncounterClear`
|
||||||
|
|
||||||
|
**Inputs:** `playerId`, `encounterId`, injected `IContractInstanceStore`, `IContractOutcomeStore`, `IContractTemplateRegistry`, reward-router dependencies (same set as **`QuestStateOperations.TryMarkComplete`**), `TimeProvider`.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Normalize **`playerId`** + **`encounterId`**; deny **`invalid_ids`** when empty.
|
||||||
|
2. **`instanceStore.TryGetActiveForPlayer`** — no active row ⇒ success no-op (**`no_active_contract`**, or null reason for silent no-op — prefer explicit reason in tests, null in wiring if best-effort silent).
|
||||||
|
3. Load template via **`templateRegistry.TryGetDefinition(active.TemplateId)`**; deny **`unknown_template`** when missing.
|
||||||
|
4. Normalize template **`encounterTemplateId`** vs cleared **`encounterId`** — mismatch ⇒ success no-op (**`encounter_mismatch`**).
|
||||||
|
5. If active snapshot status is already **`completed`** (defensive) ⇒ idempotent success.
|
||||||
|
6. **`RewardRouterOperations.TryDeliverContractCompletion`** with template **`CompletionRewardBundle`**.
|
||||||
|
7. On delivery deny ⇒ return deny with router **`reasonCode`**; instance stays **`active`**.
|
||||||
|
8. **`instanceStore.TryMarkComplete`** with **`timeProvider.GetUtcNow()`**; handle store replay idempotency (completed snapshot ⇒ success).
|
||||||
|
9. Build **`ContractOutcomeRow`** from delivery grants + **`ContractOutcomeIds.MakeIdempotencyKey`**; **`outcomeStore.TryAppend`** (best-effort if duplicate idempotency key after race — treat as success when instance already completed).
|
||||||
|
10. Comment-only **`contract_complete`** telemetry hook site (NEO-152).
|
||||||
|
|
||||||
|
**No-op vs deny:** Structural preconditions that mean “this encounter clear is not a contract objective” (**no active**, **encounter mismatch**) return **`success: true`** so encounter completion is never blocked. Delivery/store failures return **`success: false`**.
|
||||||
|
|
||||||
|
### 3. Encounter wiring
|
||||||
|
|
||||||
|
In **`EncounterCompletionOperations.TryCompleteAndGrant`**, after encounter event record + **`QuestObjectiveWiring.TryProcessEncounterCompleteSuccess`**, call:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ContractCompletionOperations.TryCompleteOnEncounterClear(
|
||||||
|
normalizedPlayerId,
|
||||||
|
normalizedEncounterId,
|
||||||
|
/* contract + router deps */);
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend **`EncounterCombatWiring.TryProcessCastOutcome`** and **`AbilityCastApi`** to resolve and pass:
|
||||||
|
|
||||||
|
- **`IContractInstanceStore`**
|
||||||
|
- **`IContractOutcomeStore`**
|
||||||
|
- **`IContractTemplateRegistry`**
|
||||||
|
|
||||||
|
(Mirror existing quest registry / progress store injection.)
|
||||||
|
|
||||||
|
### 4. Dev fixture reset (NEO-148 follow-on)
|
||||||
|
|
||||||
|
Extend **`QuestFixtureOperations`** reset body (or parallel contract fixture helper) to clear contract delivery rows via **`IRewardDeliveryStore.TryClear(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId)`** when resetting contract test state — enables future Bruno loops in NEO-151.
|
||||||
|
|
||||||
|
### 5. Documentation
|
||||||
|
|
||||||
|
- **`server/README.md`**: new **Contract completion operations (NEO-149)** section — orchestrator flow, reason codes, deliver-then-mark, outcome append, encounter wiring order.
|
||||||
|
- Update **`E7_M4_ContractMissionGenerator.md`** Slice 4 anchor when shipped.
|
||||||
|
|
||||||
|
## Files to add
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionReasonCodes.cs` | Stable deny / no-op reason codes |
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperationResult.cs` | Orchestrator result envelope |
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperations.cs` | **`TryCompleteOnEncounterClear`** orchestrator |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs` | Unit tests — match/mismatch, deliver-then-mark, idempotent replay, delivery deny leaves active |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs` | DI path: issue → encounter complete → completed instance + delivery + outcome |
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Call contract completion after quest wiring on successful encounter grant |
|
||||||
|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Pass contract store/registry deps to **`TryCompleteAndGrant`** |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Resolve contract DI services for combat wiring |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Update dependency resolution + assert encounter loot unchanged when contract completes |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs` | Pass contract deps; optional end-to-end issue → cast → contract completed |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs` | Contract delivery **`TryClear`** on fixture reset (NEO-148 deferred) |
|
||||||
|
| `server/README.md` | Contract completion operations + wiring order docs |
|
||||||
|
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-149 completion wiring anchor when shipped |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `ContractCompletionOperationsTests.cs` | **`TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter`** — issue via generator helper, complete on **`prototype_combat_pocket`**, assert **`completed`**, delivery store row, outcome row, bundle grants |
|
||||||
|
| Same | **`TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract`** — encounter clear succeeds structurally; no delivery row |
|
||||||
|
| Same | **`TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch`** — active contract bound to combat pocket; clear different encounter id ⇒ still **`active`** |
|
||||||
|
| Same | **`TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies`** — full inventory / deny path; instance stays **`active`**, no outcome row |
|
||||||
|
| Same | **`TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed`** — second call after complete returns success without double grant |
|
||||||
|
| `ContractCompletionOperationsIntegrationTests.cs` | Full DI: **`TryIssue`** → **`TryCompleteAndGrant`** → stores consistent via **`InMemoryWebApplicationFactory`** |
|
||||||
|
| `EncounterCompletionOperationsTests.cs` | **`TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated`** still passes with contract deps wired; add case with active contract asserting **both** encounter loot (×10 scrap + token) **and** contract bundle (×5 scrap + salvage XP) |
|
||||||
|
| `EncounterCombatWiringTests.cs` | Update all **`TryProcessCastOutcome`** call sites with contract deps (compile fix) |
|
||||||
|
|
||||||
|
All new tests follow AAA with **`// Arrange` / `// Act` / `// Assert`** labels per [csharp-style](../.cursor/rules/csharp-style.md).
|
||||||
|
|
||||||
|
Manual Godot QA: **none** (server engine; capstone NEO-154). Bruno HTTP loop: **NEO-151**.
|
||||||
|
|
||||||
|
## Open questions / risks
|
||||||
|
|
||||||
|
| Question / risk | Agent recommendation | Status |
|
||||||
|
|-----------------|----------------------|--------|
|
||||||
|
| Contract issued after encounter already cleared | Prototype flow is issue-then-clear; edge case leaves contract **`active`** until player can re-trigger (not possible in v1) — document in README; no v1 fix | `adopted` |
|
||||||
|
| Outcome append fails after mark complete | Treat duplicate idempotency append as success when instance **`completed`**; log at debug if needed | `adopted` |
|
||||||
|
| Large signature growth on encounter completion | Accept for prototype — mirror quest deps already on **`TryCompleteAndGrant`**; refactor to context object only if a follow-on story demands | `deferred` |
|
||||||
|
| Bruno smoke in backlog vs no HTTP | Integration tests + fixture reset in this story; Bruno **`issue → clear → GET`** in **NEO-151** | `adopted` |
|
||||||
|
| **`contract_complete` telemetry | Comment-only hook in orchestrator; NEO-152 fills payload | `deferred` |
|
||||||
|
|
||||||
|
## Client counterpart
|
||||||
|
|
||||||
|
[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after E7M4-08 HTTP. [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) — Godot capstone. This story is server-only; Bruno verification of the full player loop lands with NEO-151.
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Code review — NEO-149 contract completion encounter wiring
|
||||||
|
|
||||||
|
**Date:** 2026-06-27
|
||||||
|
**Scope:** Branch `NEO-149-e7m4-06-contract-completion-encounter-wiring` — commits `8d9d02f`..`eb6a4fe` vs `origin/main`
|
||||||
|
**Base:** `origin/main`
|
||||||
|
**Follow-up:** Re-review after code-review follow-ups (`68ba267`–`eb6a4fe`).
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
NEO-149 adds `ContractCompletionOperations.TryCompleteOnEncounterClear` — deliver-then-mark contract completion on encounter clear, following the NEO-128 quest precedent and NEO-148 router. Encounter wiring invokes the hook after encounter loot and quest objective wiring; contract payout failure does not block encounter success. Contract store/registry dependencies are plumbed through `EncounterCombatWiring` and `AbilityCastApi`. `QuestFixtureRequest.resetContractInstanceIds` clears instance, delivery, and outcome rows for Bruno/dev reset loops.
|
||||||
|
|
||||||
|
**Follow-up commits** address all prior review suggestions: E7.M4 alignment register updated, `PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided` added, `TryMarkComplete` failure path returns `player_not_writable` / `instance_not_active` / `instance_not_found` instead of `invalid_ids`, and idempotent replay test refactored with `IssueAndCompleteOnce`. Ancillary analyzer fixes in encounter wiring (debug log guard) and craft API (`CraftResult` XML docs, `MapIoRows` return type, deny empty IO arrays test) keep the branch clean; craft changes are orthogonal to contract completion but low risk.
|
||||||
|
|
||||||
|
Seven contract-related tests (5 unit + 1 integration + 1 encounter wiring) plus 1 quest-fixture API test cover the story. Full suite: **906** tests passed locally.
|
||||||
|
|
||||||
|
## Documentation checked
|
||||||
|
|
||||||
|
| Path | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `docs/plans/NEO-149-implementation-plan.md` | **Matches** — AC checklist complete; shipped reconciliation aligns with diff (test count still cites 904 — minor drift) |
|
||||||
|
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-06) | **Matches** — orchestrator + encounter wiring in scope; HTTP Bruno loop deferred to NEO-151 |
|
||||||
|
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-149 completion wiring anchor added |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row includes **E7M4-06 / NEO-149** |
|
||||||
|
| `server/README.md` | **Matches** — contract completion operations section, wiring order, reason codes, fixture reset |
|
||||||
|
| Client counterpart (NEO-153, NEO-154) | **N/A** — explicitly out of scope; no prototype-complete claim |
|
||||||
|
|
||||||
|
Register/tracking table updated for E7.M4 (NEO-149).
|
||||||
|
|
||||||
|
## Blocking issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Suggestions
|
||||||
|
|
||||||
|
1. ~~**Update implementation alignment register** — Add **E7M4-06 / NEO-149 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (orchestrator + encounter wiring + quest-fixture reset), consistent with NEO-144–NEO-148 entries.~~ **Done.** E7.M4 row + plan link updated (`68ba267`).
|
||||||
|
|
||||||
|
2. ~~**Quest-fixture test for contract reset** — `QuestFixtureOperations` now clears contract instance, delivery, and outcome rows via `resetContractInstanceIds`, but `QuestFixtureApiTests` has no parallel to `PostQuestFixture_ShouldClearRewardDeliveryAndAudit_WhenResetQuestIdsProvided`. A test that issues a contract, completes it, resets via fixture, and asserts stores are empty would lock the NEO-148 follow-on reset path.~~ **Done.** `PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided` (`68ba267`).
|
||||||
|
|
||||||
|
3. ~~**`TryMarkComplete` failure reason code** — When delivery succeeds but `TryMarkComplete` fails without leaving a completed snapshot, the orchestrator returns `invalid_ids`. `QuestStateOperations.TryMarkComplete` uses richer fallback codes. Consider aligning deny semantics.~~ **Done.** Final deny uses `player_not_writable`, `instance_not_active`, or `instance_not_found` (`68ba267`).
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
- Nit: Idempotent replay after successful completion returns `no_active_contract` (instance no longer active) rather than hitting the defensive `completed` snapshot branch. Behavior is correct — no double grant — but the reason code differs from the plan’s “completed replay” wording. **Deferred** — acceptable for prototype.
|
||||||
|
- ~~Nit: `TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed` performs success assertions on the first completion inside **Arrange**; prefer moving first-call verification to **Assert** after **Act** for strict AAA, or extract a shared setup helper.~~ **Done.** `IssueAndCompleteOnce` setup helper (`68ba267`).
|
||||||
|
- Nit: Plan lists optional `EncounterCombatWiring` end-to-end issue → cast → contract completed; only compile-fix call-site updates landed. **Deferred** — integration + encounter completion tests cover the path.
|
||||||
|
- Nit: Bruno `Reset contract instance via quest fixture.bru` uses placeholder `ci_000…` id — fine until NEO-151 wires issue → clear → GET.
|
||||||
|
- Nit: Follow-up commits include craft API analyzer/doc fixes unrelated to E7M4-06 scope; harmless but could have been a separate chore commit.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test NeonSprawl.sln
|
||||||
|
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractCompletionOperations|FullyQualifiedName~QuestFixtureApiTests.PostQuestFixture_ShouldClearContractStores"
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual Godot QA: none (server engine per plan). Bruno issue → clear → GET loop: NEO-151.
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
public sealed class ContractCompletionOperationsIntegrationTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
||||||
|
private const string EncounterId = "prototype_combat_pocket";
|
||||||
|
private const string MeleeNpc = "prototype_npc_melee";
|
||||||
|
private const string RangedNpc = "prototype_npc_ranged";
|
||||||
|
private const string EliteNpc = "prototype_npc_elite";
|
||||||
|
private const string SeedBucket = "2026-06-22";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryIssueThenEncounterComplete_ShouldMarkContractCompleted_WhenResolvedFromDi()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
_ = await client.GetAsync("/health");
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var issued = ContractGeneratorOperations.TryIssue(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket,
|
||||||
|
zoneDifficultyBand: null,
|
||||||
|
deps.TemplateRegistry,
|
||||||
|
deps.InstanceStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.TimeProvider);
|
||||||
|
Assert.True(issued.Success);
|
||||||
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
||||||
|
DefeatAllRequiredTargets(deps);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var encounterResult = EncounterCompletionOperations.TryCompleteAndGrant(
|
||||||
|
PlayerId,
|
||||||
|
EncounterId,
|
||||||
|
deps.EncounterRegistry,
|
||||||
|
deps.RewardTableRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
deps.CompletionStore,
|
||||||
|
deps.EventStore,
|
||||||
|
deps.ItemRegistry,
|
||||||
|
deps.InventoryStore,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.QuestProgressStore,
|
||||||
|
deps.SkillRegistry,
|
||||||
|
deps.SkillProgressionStore,
|
||||||
|
deps.LevelCurve,
|
||||||
|
deps.PerkUnlockEngine,
|
||||||
|
deps.PerkStore,
|
||||||
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.AuditStore,
|
||||||
|
deps.InstanceStore,
|
||||||
|
deps.OutcomeStore,
|
||||||
|
deps.TemplateRegistry,
|
||||||
|
deps.TimeProvider);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(encounterResult.Success);
|
||||||
|
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance));
|
||||||
|
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
|
||||||
|
Assert.True(
|
||||||
|
deps.DeliveryStore.TryGet(
|
||||||
|
PlayerId,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
instanceId,
|
||||||
|
out _));
|
||||||
|
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
|
||||||
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||||
|
Assert.Equal(15, CountBagItem(inventory!, "scrap_metal_bulk"));
|
||||||
|
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DefeatAllRequiredTargets(IntegrationTestDependencies deps)
|
||||||
|
{
|
||||||
|
foreach (var npcId in new[] { MeleeNpc, RangedNpc, EliteNpc })
|
||||||
|
{
|
||||||
|
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
|
||||||
|
PlayerId,
|
||||||
|
npcId,
|
||||||
|
deps.EncounterRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
deps.CompletionStore);
|
||||||
|
_ = EncounterProgressOperations.TryMarkTargetDefeated(
|
||||||
|
PlayerId,
|
||||||
|
npcId,
|
||||||
|
deps.EncounterRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
deps.CompletionStore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
foreach (var slot in snapshot.BagSlots)
|
||||||
|
{
|
||||||
|
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
total += slot.Quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntegrationTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
return new IntegrationTestDependencies(
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IContractOutcomeStore>(),
|
||||||
|
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IEncounterProgressStore>(),
|
||||||
|
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||||
|
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||||
|
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||||
|
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||||
|
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||||
|
services.GetRequiredService<ISkillLevelCurve>(),
|
||||||
|
services.GetRequiredService<PerkUnlockEngine>(),
|
||||||
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
services.GetRequiredService<TimeProvider>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record IntegrationTestDependencies(
|
||||||
|
IContractTemplateRegistry TemplateRegistry,
|
||||||
|
IContractInstanceStore InstanceStore,
|
||||||
|
IContractOutcomeStore OutcomeStore,
|
||||||
|
IEncounterDefinitionRegistry EncounterRegistry,
|
||||||
|
IRewardTableDefinitionRegistry RewardTableRegistry,
|
||||||
|
IEncounterProgressStore ProgressStore,
|
||||||
|
IEncounterCompletionStore CompletionStore,
|
||||||
|
IEncounterCompleteEventStore EventStore,
|
||||||
|
IItemDefinitionRegistry ItemRegistry,
|
||||||
|
IPlayerInventoryStore InventoryStore,
|
||||||
|
IQuestDefinitionRegistry QuestRegistry,
|
||||||
|
IPlayerQuestStateStore QuestProgressStore,
|
||||||
|
ISkillDefinitionRegistry SkillRegistry,
|
||||||
|
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||||
|
ISkillLevelCurve LevelCurve,
|
||||||
|
PerkUnlockEngine PerkUnlockEngine,
|
||||||
|
IPlayerPerkStateStore PerkStore,
|
||||||
|
IRewardDeliveryStore DeliveryStore,
|
||||||
|
IFactionStandingStore StandingStore,
|
||||||
|
IReputationDeltaStore AuditStore,
|
||||||
|
TimeProvider TimeProvider);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
public sealed class ContractCompletionOperationsTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
||||||
|
private const string EncounterId = "prototype_combat_pocket";
|
||||||
|
private const string MismatchEncounterId = "prototype_encounter_other";
|
||||||
|
private const string SeedBucket = "2026-06-22";
|
||||||
|
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var issued = IssuePrototypeContract(deps);
|
||||||
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
||||||
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = CompleteOnEncounterClear(deps, EncounterId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.ReasonCode);
|
||||||
|
Assert.NotNull(result.Snapshot);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Completed, result.Snapshot!.Status);
|
||||||
|
Assert.NotNull(result.Outcome);
|
||||||
|
Assert.True(
|
||||||
|
deps.DeliveryStore.TryGet(
|
||||||
|
PlayerId,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
instanceId,
|
||||||
|
out var delivery));
|
||||||
|
Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, delivery.SourceKind);
|
||||||
|
Assert.Equal(instanceId, delivery.ContractInstanceId);
|
||||||
|
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
|
||||||
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||||
|
Assert.Equal(
|
||||||
|
CountBagItem(beforeInventory!, "scrap_metal_bulk") + 5,
|
||||||
|
CountBagItem(afterInventory!, "scrap_metal_bulk"));
|
||||||
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||||
|
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
|
||||||
|
Assert.Equal(15, salvageXp);
|
||||||
|
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
|
||||||
|
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var stored));
|
||||||
|
Assert.Equal(ContractInstanceStatus.Completed, stored.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = CompleteOnEncounterClear(deps, EncounterId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, result.ReasonCode);
|
||||||
|
Assert.Null(result.Snapshot);
|
||||||
|
Assert.Null(result.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var issued = IssuePrototypeContract(deps);
|
||||||
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = CompleteOnEncounterClear(deps, MismatchEncounterId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(ContractCompletionReasonCodes.EncounterMismatch, result.ReasonCode);
|
||||||
|
Assert.NotNull(result.Snapshot);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot!.Status);
|
||||||
|
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
|
||||||
|
Assert.Equal(instanceId, active.ContractInstanceId);
|
||||||
|
Assert.False(
|
||||||
|
deps.DeliveryStore.TryGet(
|
||||||
|
PlayerId,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
instanceId,
|
||||||
|
out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var issued = IssuePrototypeContract(deps);
|
||||||
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
||||||
|
FillBag(deps);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = CompleteOnEncounterClear(deps, EncounterId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
|
||||||
|
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
|
||||||
|
Assert.Equal(instanceId, active.ContractInstanceId);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, active.Status);
|
||||||
|
Assert.Empty(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var instanceId = IssueAndCompleteOnce(deps);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var replay = CompleteOnEncounterClear(deps, EncounterId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(replay.Success);
|
||||||
|
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, replay.ReasonCode);
|
||||||
|
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance));
|
||||||
|
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
|
||||||
|
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
|
||||||
|
Assert.True(
|
||||||
|
deps.DeliveryStore.TryGet(
|
||||||
|
PlayerId,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
instanceId,
|
||||||
|
out _));
|
||||||
|
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string IssueAndCompleteOnce(CompletionTestDependencies deps)
|
||||||
|
{
|
||||||
|
var issued = IssuePrototypeContract(deps);
|
||||||
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
||||||
|
var first = CompleteOnEncounterClear(deps, EncounterId);
|
||||||
|
Assert.True(first.Success);
|
||||||
|
Assert.Null(first.ReasonCode);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Completed, first.Snapshot!.Status);
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContractIssueOperationResult IssuePrototypeContract(CompletionTestDependencies deps) =>
|
||||||
|
ContractGeneratorOperations.TryIssue(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket,
|
||||||
|
zoneDifficultyBand: null,
|
||||||
|
deps.TemplateRegistry,
|
||||||
|
deps.InstanceStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.TimeProvider);
|
||||||
|
|
||||||
|
private static ContractCompletionOperationResult CompleteOnEncounterClear(
|
||||||
|
CompletionTestDependencies deps,
|
||||||
|
string encounterId) =>
|
||||||
|
ContractCompletionOperations.TryCompleteOnEncounterClear(
|
||||||
|
PlayerId,
|
||||||
|
encounterId,
|
||||||
|
deps.InstanceStore,
|
||||||
|
deps.OutcomeStore,
|
||||||
|
deps.TemplateRegistry,
|
||||||
|
deps.ItemRegistry,
|
||||||
|
deps.InventoryStore,
|
||||||
|
deps.SkillRegistry,
|
||||||
|
deps.SkillProgressionStore,
|
||||||
|
deps.LevelCurve,
|
||||||
|
deps.PerkUnlockEngine,
|
||||||
|
deps.PerkStore,
|
||||||
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.AuditStore,
|
||||||
|
deps.TimeProvider);
|
||||||
|
|
||||||
|
private static void FillBag(CompletionTestDependencies deps)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
|
||||||
|
{
|
||||||
|
var add = PlayerInventoryOperations.TryAddStack(
|
||||||
|
PlayerId,
|
||||||
|
"survey_drone_kit",
|
||||||
|
quantity: 1,
|
||||||
|
deps.ItemRegistry,
|
||||||
|
deps.InventoryStore);
|
||||||
|
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
foreach (var slot in snapshot.BagSlots)
|
||||||
|
{
|
||||||
|
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
total += slot.Quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
return new CompletionTestDependencies(
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IContractOutcomeStore>(),
|
||||||
|
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||||
|
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||||
|
services.GetRequiredService<ISkillLevelCurve>(),
|
||||||
|
services.GetRequiredService<PerkUnlockEngine>(),
|
||||||
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
new FakeTimeProvider(CompletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CompletionTestDependencies(
|
||||||
|
IContractTemplateRegistry TemplateRegistry,
|
||||||
|
IContractInstanceStore InstanceStore,
|
||||||
|
IContractOutcomeStore OutcomeStore,
|
||||||
|
IItemDefinitionRegistry ItemRegistry,
|
||||||
|
IPlayerInventoryStore InventoryStore,
|
||||||
|
ISkillDefinitionRegistry SkillRegistry,
|
||||||
|
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||||
|
ISkillLevelCurve LevelCurve,
|
||||||
|
PerkUnlockEngine PerkUnlockEngine,
|
||||||
|
IPlayerPerkStateStore PerkStore,
|
||||||
|
IRewardDeliveryStore DeliveryStore,
|
||||||
|
IFactionStandingStore StandingStore,
|
||||||
|
IReputationDeltaStore AuditStore,
|
||||||
|
TimeProvider TimeProvider);
|
||||||
|
}
|
||||||
|
|
@ -266,6 +266,28 @@ public sealed class PlayerCraftApiTests
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapResponse_ShouldReturnEmptyIoArrays_WhenCraftDenied()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var result = new CraftResult(
|
||||||
|
Success: false,
|
||||||
|
ReasonCode: CraftReasonCodes.UnknownRecipe,
|
||||||
|
InputsConsumed: [],
|
||||||
|
OutputsGranted: [],
|
||||||
|
XpGrantSummary: null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = PlayerCraftApi.MapResponse(result);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.Equal(CraftReasonCodes.UnknownRecipe, response.ReasonCode);
|
||||||
|
Assert.Empty(response.InputsConsumed);
|
||||||
|
Assert.Empty(response.OutputsGranted);
|
||||||
|
Assert.Null(response.XpGrantSummary);
|
||||||
|
}
|
||||||
|
|
||||||
private static void SeedStack(InMemoryWebApplicationFactory factory, string itemId, int quantity)
|
private static void SeedStack(InMemoryWebApplicationFactory factory, string itemId, int quantity)
|
||||||
{
|
{
|
||||||
var itemRegistry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
|
var itemRegistry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
|
@ -49,6 +50,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -88,6 +92,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -131,6 +138,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -172,6 +182,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -206,6 +219,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
|
||||||
|
|
||||||
|
|
@ -232,6 +248,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -272,6 +291,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -321,6 +343,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -367,6 +392,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -397,6 +425,9 @@ public sealed class EncounterCombatWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -455,7 +486,10 @@ public sealed class EncounterCombatWiringTests
|
||||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
services.GetRequiredService<IFactionStandingStore>(),
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
services.GetRequiredService<IReputationDeltaStore>());
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IContractOutcomeStore>(),
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||||
|
|
@ -489,5 +523,8 @@ public sealed class EncounterCombatWiringTests
|
||||||
IPlayerPerkStateStore PerkStore,
|
IPlayerPerkStateStore PerkStore,
|
||||||
IRewardDeliveryStore DeliveryStore,
|
IRewardDeliveryStore DeliveryStore,
|
||||||
IFactionStandingStore StandingStore,
|
IFactionStandingStore StandingStore,
|
||||||
IReputationDeltaStore AuditStore);
|
IReputationDeltaStore AuditStore,
|
||||||
|
IContractInstanceStore ContractInstanceStore,
|
||||||
|
IContractOutcomeStore ContractOutcomeStore,
|
||||||
|
IContractTemplateRegistry ContractTemplateRegistry);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
|
@ -19,6 +20,67 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
private const string RangedNpc = "prototype_npc_ranged";
|
private const string RangedNpc = "prototype_npc_ranged";
|
||||||
private const string EliteNpc = "prototype_npc_elite";
|
private const string EliteNpc = "prototype_npc_elite";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteAndGrant_ShouldApplyContractBundle_WhenActiveContractMatchesEncounter()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var issued = ContractGeneratorOperations.TryIssue(
|
||||||
|
PlayerId,
|
||||||
|
"prototype_contract_clear_combat_pocket",
|
||||||
|
"2026-06-22",
|
||||||
|
zoneDifficultyBand: null,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
TimeProvider.System);
|
||||||
|
Assert.True(issued.Success);
|
||||||
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
||||||
|
DefeatAllRequiredTargets(deps);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = EncounterCompletionOperations.TryCompleteAndGrant(
|
||||||
|
PlayerId,
|
||||||
|
EncounterId,
|
||||||
|
deps.EncounterRegistry,
|
||||||
|
deps.RewardTableRegistry,
|
||||||
|
deps.ProgressStore,
|
||||||
|
deps.CompletionStore,
|
||||||
|
deps.EventStore,
|
||||||
|
deps.ItemRegistry,
|
||||||
|
deps.InventoryStore,
|
||||||
|
deps.QuestRegistry,
|
||||||
|
deps.QuestProgressStore,
|
||||||
|
deps.SkillRegistry,
|
||||||
|
deps.SkillProgressionStore,
|
||||||
|
deps.LevelCurve,
|
||||||
|
deps.PerkUnlockEngine,
|
||||||
|
deps.PerkStore,
|
||||||
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||||
|
Assert.Equal(15, CountBagItem(inventory!, "scrap_metal_bulk"));
|
||||||
|
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
|
||||||
|
Assert.True(deps.ContractInstanceStore.TryGet(PlayerId, instanceId, out var instance));
|
||||||
|
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
|
||||||
|
Assert.True(
|
||||||
|
deps.DeliveryStore.TryGet(
|
||||||
|
PlayerId,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
instanceId,
|
||||||
|
out _));
|
||||||
|
Assert.Single(deps.ContractOutcomeStore.GetOutcomesForInstance(instanceId));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
|
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
|
||||||
{
|
{
|
||||||
|
|
@ -49,6 +111,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -100,6 +165,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
|
||||||
|
|
||||||
|
|
@ -124,6 +192,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -190,6 +261,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -232,6 +306,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -276,6 +353,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -318,6 +398,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -358,6 +441,9 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -428,7 +514,10 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
services.GetRequiredService<IFactionStandingStore>(),
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
services.GetRequiredService<IReputationDeltaStore>());
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IContractOutcomeStore>(),
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||||
|
|
@ -476,7 +565,10 @@ public sealed class EncounterCompletionOperationsTests
|
||||||
IPlayerPerkStateStore PerkStore,
|
IPlayerPerkStateStore PerkStore,
|
||||||
IRewardDeliveryStore DeliveryStore,
|
IRewardDeliveryStore DeliveryStore,
|
||||||
IFactionStandingStore StandingStore,
|
IFactionStandingStore StandingStore,
|
||||||
IReputationDeltaStore AuditStore);
|
IReputationDeltaStore AuditStore,
|
||||||
|
IContractInstanceStore ContractInstanceStore,
|
||||||
|
IContractOutcomeStore ContractOutcomeStore,
|
||||||
|
IContractTemplateRegistry ContractTemplateRegistry);
|
||||||
|
|
||||||
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
|
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@ using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Quests;
|
using NeonSprawl.Server.Game.Quests;
|
||||||
using NeonSprawl.Server.Game.Rewards;
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using NeonSprawl.Server.Tests;
|
using NeonSprawl.Server.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -271,4 +275,84 @@ public sealed class QuestFixtureApiTests
|
||||||
Assert.False(deliveryStore.TryGet(DevPlayer, ChainQuestId, out _));
|
Assert.False(deliveryStore.TryGet(DevPlayer, ChainQuestId, out _));
|
||||||
Assert.Empty(auditStore.GetDeltasForPlayer(DevPlayer));
|
Assert.Empty(auditStore.GetDeltasForPlayer(DevPlayer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var templateRegistry = factory.Services.GetRequiredService<IContractTemplateRegistry>();
|
||||||
|
var instanceStore = factory.Services.GetRequiredService<IContractInstanceStore>();
|
||||||
|
var outcomeStore = factory.Services.GetRequiredService<IContractOutcomeStore>();
|
||||||
|
var deliveryStore = factory.Services.GetRequiredService<IRewardDeliveryStore>();
|
||||||
|
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
|
||||||
|
var itemRegistry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
|
||||||
|
var inventoryStore = factory.Services.GetRequiredService<IPlayerInventoryStore>();
|
||||||
|
var skillRegistry = factory.Services.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
|
var skillProgressionStore = factory.Services.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
|
var levelCurve = factory.Services.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
var perkUnlockEngine = factory.Services.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
var perkStore = factory.Services.GetRequiredService<IPlayerPerkStateStore>();
|
||||||
|
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
|
||||||
|
var timeProvider = factory.Services.GetRequiredService<TimeProvider>();
|
||||||
|
const string templateId = "prototype_contract_clear_combat_pocket";
|
||||||
|
const string encounterId = "prototype_combat_pocket";
|
||||||
|
var issued = ContractGeneratorOperations.TryIssue(
|
||||||
|
DevPlayer,
|
||||||
|
templateId,
|
||||||
|
"2026-06-22",
|
||||||
|
zoneDifficultyBand: null,
|
||||||
|
templateRegistry,
|
||||||
|
instanceStore,
|
||||||
|
standingStore,
|
||||||
|
timeProvider);
|
||||||
|
Assert.True(issued.Success);
|
||||||
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
||||||
|
var completed = ContractCompletionOperations.TryCompleteOnEncounterClear(
|
||||||
|
DevPlayer,
|
||||||
|
encounterId,
|
||||||
|
instanceStore,
|
||||||
|
outcomeStore,
|
||||||
|
templateRegistry,
|
||||||
|
itemRegistry,
|
||||||
|
inventoryStore,
|
||||||
|
skillRegistry,
|
||||||
|
skillProgressionStore,
|
||||||
|
levelCurve,
|
||||||
|
perkUnlockEngine,
|
||||||
|
perkStore,
|
||||||
|
deliveryStore,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
timeProvider);
|
||||||
|
Assert.True(completed.Success);
|
||||||
|
Assert.True(
|
||||||
|
deliveryStore.TryGet(
|
||||||
|
DevPlayer,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
instanceId,
|
||||||
|
out _));
|
||||||
|
Assert.Single(outcomeStore.GetOutcomesForInstance(instanceId));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var reset = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
new QuestFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
|
||||||
|
ResetContractInstanceIds = [instanceId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
|
||||||
|
Assert.False(instanceStore.TryGet(DevPlayer, instanceId, out _));
|
||||||
|
Assert.False(
|
||||||
|
deliveryStore.TryGet(
|
||||||
|
DevPlayer,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
instanceId,
|
||||||
|
out _));
|
||||||
|
Assert.Empty(outcomeStore.GetOutcomesForInstance(instanceId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
|
@ -44,7 +45,7 @@ public sealed class QuestObjectiveWiringTests
|
||||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
|
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
|
||||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
3 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather + 25,
|
(3 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather) + 25,
|
||||||
deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
|
deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
|
||||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent));
|
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent));
|
||||||
Assert.Single(deliveryEvent.GrantedSkillXp);
|
Assert.Single(deliveryEvent.GrantedSkillXp);
|
||||||
|
|
@ -270,6 +271,9 @@ public sealed class QuestObjectiveWiringTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
deps.TimeProvider);
|
deps.TimeProvider);
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
}
|
}
|
||||||
|
|
@ -459,6 +463,9 @@ public sealed class QuestObjectiveWiringTests
|
||||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
services.GetRequiredService<IFactionStandingStore>(),
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
services.GetRequiredService<IReputationDeltaStore>(),
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IContractOutcomeStore>(),
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,5 +490,8 @@ public sealed class QuestObjectiveWiringTests
|
||||||
IRewardDeliveryStore DeliveryStore,
|
IRewardDeliveryStore DeliveryStore,
|
||||||
IFactionStandingStore StandingStore,
|
IFactionStandingStore StandingStore,
|
||||||
IReputationDeltaStore AuditStore,
|
IReputationDeltaStore AuditStore,
|
||||||
|
IContractInstanceStore ContractInstanceStore,
|
||||||
|
IContractOutcomeStore ContractOutcomeStore,
|
||||||
|
IContractTemplateRegistry ContractTemplateRegistry,
|
||||||
TimeProvider TimeProvider);
|
TimeProvider TimeProvider);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
|
@ -412,6 +413,9 @@ public sealed class QuestProgressApiTests
|
||||||
deps.DeliveryStore,
|
deps.DeliveryStore,
|
||||||
deps.StandingStore,
|
deps.StandingStore,
|
||||||
deps.AuditStore,
|
deps.AuditStore,
|
||||||
|
deps.ContractInstanceStore,
|
||||||
|
deps.ContractOutcomeStore,
|
||||||
|
deps.ContractTemplateRegistry,
|
||||||
deps.TimeProvider);
|
deps.TimeProvider);
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
}
|
}
|
||||||
|
|
@ -552,6 +556,9 @@ public sealed class QuestProgressApiTests
|
||||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
services.GetRequiredService<IFactionStandingStore>(),
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
services.GetRequiredService<IReputationDeltaStore>(),
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IContractOutcomeStore>(),
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||||
TimeProvider.System);
|
TimeProvider.System);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -576,5 +583,8 @@ public sealed class QuestProgressApiTests
|
||||||
IRewardDeliveryStore DeliveryStore,
|
IRewardDeliveryStore DeliveryStore,
|
||||||
IFactionStandingStore StandingStore,
|
IFactionStandingStore StandingStore,
|
||||||
IReputationDeltaStore AuditStore,
|
IReputationDeltaStore AuditStore,
|
||||||
|
IContractInstanceStore ContractInstanceStore,
|
||||||
|
IContractOutcomeStore ContractOutcomeStore,
|
||||||
|
IContractTemplateRegistry ContractTemplateRegistry,
|
||||||
TimeProvider TimeProvider);
|
TimeProvider TimeProvider);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using NeonSprawl.Server.Diagnostics;
|
using NeonSprawl.Server.Diagnostics;
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Gigs;
|
using NeonSprawl.Server.Game.Gigs;
|
||||||
|
|
@ -93,6 +94,9 @@ public static class AbilityCastApi
|
||||||
IRewardDeliveryStore deliveryStore,
|
IRewardDeliveryStore deliveryStore,
|
||||||
IFactionStandingStore standingStore,
|
IFactionStandingStore standingStore,
|
||||||
IReputationDeltaStore auditStore,
|
IReputationDeltaStore auditStore,
|
||||||
|
IContractInstanceStore contractInstanceStore,
|
||||||
|
IContractOutcomeStore contractOutcomeStore,
|
||||||
|
IContractTemplateRegistry contractTemplateRegistry,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
TimeProvider clock) =>
|
TimeProvider clock) =>
|
||||||
{
|
{
|
||||||
|
|
@ -302,6 +306,9 @@ public static class AbilityCastApi
|
||||||
deliveryStore,
|
deliveryStore,
|
||||||
standingStore,
|
standingStore,
|
||||||
auditStore,
|
auditStore,
|
||||||
|
contractInstanceStore,
|
||||||
|
contractOutcomeStore,
|
||||||
|
contractTemplateRegistry,
|
||||||
clock,
|
clock,
|
||||||
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));
|
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-internal contract completion resolution envelope (NEO-149).
|
||||||
|
/// HTTP DTOs via E7M4-08 (NEO-151).
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ContractCompletionOperationResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReasonCode,
|
||||||
|
ContractInstanceState? Snapshot,
|
||||||
|
ContractOutcomeRow? Outcome);
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-authoritative contract completion on encounter clear (NEO-149).
|
||||||
|
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152).
|
||||||
|
/// </summary>
|
||||||
|
public static class ContractCompletionOperations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When the player has an active contract bound to <paramref name="encounterId"/>, delivers the template
|
||||||
|
/// completion bundle then marks the instance complete and appends an outcome audit row.
|
||||||
|
/// </summary>
|
||||||
|
public static ContractCompletionOperationResult TryCompleteOnEncounterClear(
|
||||||
|
string playerId,
|
||||||
|
string encounterId,
|
||||||
|
IContractInstanceStore instanceStore,
|
||||||
|
IContractOutcomeStore outcomeStore,
|
||||||
|
IContractTemplateRegistry templateRegistry,
|
||||||
|
IItemDefinitionRegistry itemRegistry,
|
||||||
|
IPlayerInventoryStore inventoryStore,
|
||||||
|
ISkillDefinitionRegistry skillRegistry,
|
||||||
|
IPlayerSkillProgressionStore skillProgressionStore,
|
||||||
|
ISkillLevelCurve levelCurve,
|
||||||
|
PerkUnlockEngine perkUnlockEngine,
|
||||||
|
IPlayerPerkStateStore perkStore,
|
||||||
|
IRewardDeliveryStore deliveryStore,
|
||||||
|
IFactionStandingStore standingStore,
|
||||||
|
IReputationDeltaStore auditStore,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId);
|
||||||
|
var normalizedEncounterId = NormalizeEncounterId(encounterId);
|
||||||
|
if (normalizedPlayerId.Length == 0 || normalizedEncounterId.Length == 0)
|
||||||
|
{
|
||||||
|
return Deny(ContractCompletionReasonCodes.InvalidIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot))
|
||||||
|
{
|
||||||
|
return NoOp(ContractCompletionReasonCodes.NoActiveContract);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!templateRegistry.TryGetDefinition(activeSnapshot.TemplateId, out var template))
|
||||||
|
{
|
||||||
|
return Deny(ContractCompletionReasonCodes.UnknownTemplate, activeSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EncounterMatchesTemplate(normalizedEncounterId, template.EncounterTemplateId))
|
||||||
|
{
|
||||||
|
return NoOp(ContractCompletionReasonCodes.EncounterMismatch, activeSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSnapshot.Status == ContractInstanceStatus.Completed)
|
||||||
|
{
|
||||||
|
return Success(activeSnapshot, TryGetExistingOutcome(outcomeStore, activeSnapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
|
||||||
|
{
|
||||||
|
return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
var delivery = RewardRouterOperations.TryDeliverContractCompletion(
|
||||||
|
normalizedPlayerId,
|
||||||
|
activeSnapshot.ContractInstanceId,
|
||||||
|
template.CompletionRewardBundle,
|
||||||
|
itemRegistry,
|
||||||
|
inventoryStore,
|
||||||
|
skillRegistry,
|
||||||
|
skillProgressionStore,
|
||||||
|
levelCurve,
|
||||||
|
perkUnlockEngine,
|
||||||
|
perkStore,
|
||||||
|
deliveryStore,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
if (!delivery.Success)
|
||||||
|
{
|
||||||
|
return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidContractInstanceId, activeSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedAt = timeProvider.GetUtcNow();
|
||||||
|
if (instanceStore.TryMarkComplete(
|
||||||
|
normalizedPlayerId,
|
||||||
|
activeSnapshot.ContractInstanceId,
|
||||||
|
completedAt,
|
||||||
|
out var completedSnapshot))
|
||||||
|
{
|
||||||
|
var outcome = AppendOutcome(
|
||||||
|
outcomeStore,
|
||||||
|
completedSnapshot,
|
||||||
|
delivery,
|
||||||
|
completedAt);
|
||||||
|
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_complete` ---
|
||||||
|
// TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns below without hook.
|
||||||
|
// Planned payload: playerId, contractInstanceId, templateId, encounterId, completedAt, idempotencyKey.
|
||||||
|
return Success(completedSnapshot, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedSnapshot?.Status == ContractInstanceStatus.Completed)
|
||||||
|
{
|
||||||
|
return Success(completedSnapshot, TryGetExistingOutcome(outcomeStore, completedSnapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instanceStore.TryGet(
|
||||||
|
normalizedPlayerId,
|
||||||
|
activeSnapshot.ContractInstanceId,
|
||||||
|
out var afterAttempt) &&
|
||||||
|
afterAttempt.Status == ContractInstanceStatus.Completed)
|
||||||
|
{
|
||||||
|
return Success(afterAttempt, TryGetExistingOutcome(outcomeStore, afterAttempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
|
||||||
|
{
|
||||||
|
return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instanceStore.TryGet(
|
||||||
|
normalizedPlayerId,
|
||||||
|
activeSnapshot.ContractInstanceId,
|
||||||
|
out var currentAfterFailedMark))
|
||||||
|
{
|
||||||
|
return Deny(ContractInstanceReasonCodes.InstanceNotActive, currentAfterFailedMark);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Deny(ContractInstanceReasonCodes.InstanceNotFound, activeSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContractOutcomeRow? AppendOutcome(
|
||||||
|
IContractOutcomeStore outcomeStore,
|
||||||
|
ContractInstanceState snapshot,
|
||||||
|
RewardDeliveryResult delivery,
|
||||||
|
DateTimeOffset recordedAt)
|
||||||
|
{
|
||||||
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
|
||||||
|
snapshot.PlayerId,
|
||||||
|
snapshot.ContractInstanceId);
|
||||||
|
if (idempotencyKey.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = new ContractOutcomeRow(
|
||||||
|
MakeOutcomeRowId(idempotencyKey),
|
||||||
|
snapshot.ContractInstanceId,
|
||||||
|
snapshot.PlayerId,
|
||||||
|
idempotencyKey,
|
||||||
|
delivery.GrantedItems,
|
||||||
|
delivery.GrantedSkillXp,
|
||||||
|
delivery.GrantedReputation,
|
||||||
|
recordedAt);
|
||||||
|
|
||||||
|
if (outcomeStore.TryAppend(row))
|
||||||
|
{
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
|
||||||
|
? existing
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContractOutcomeRow? TryGetExistingOutcome(
|
||||||
|
IContractOutcomeStore outcomeStore,
|
||||||
|
ContractInstanceState snapshot)
|
||||||
|
{
|
||||||
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
|
||||||
|
snapshot.PlayerId,
|
||||||
|
snapshot.ContractInstanceId);
|
||||||
|
return idempotencyKey.Length > 0 &&
|
||||||
|
outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
|
||||||
|
? existing
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MakeOutcomeRowId(string idempotencyKey) => $"{idempotencyKey}:outcome";
|
||||||
|
|
||||||
|
private static bool EncounterMatchesTemplate(string normalizedEncounterId, string templateEncounterId) =>
|
||||||
|
string.Equals(
|
||||||
|
normalizedEncounterId,
|
||||||
|
NormalizeEncounterId(templateEncounterId),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static string NormalizeEncounterId(string? encounterId) =>
|
||||||
|
encounterId?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
private static ContractCompletionOperationResult NoOp(
|
||||||
|
string reasonCode,
|
||||||
|
ContractInstanceState? snapshot = null) =>
|
||||||
|
new(true, reasonCode, snapshot, null);
|
||||||
|
|
||||||
|
private static ContractCompletionOperationResult Success(
|
||||||
|
ContractInstanceState snapshot,
|
||||||
|
ContractOutcomeRow? outcome) =>
|
||||||
|
new(true, null, snapshot, outcome);
|
||||||
|
|
||||||
|
private static ContractCompletionOperationResult Deny(
|
||||||
|
string reasonCode,
|
||||||
|
ContractInstanceState? snapshot = null) =>
|
||||||
|
new(false, reasonCode, snapshot, null);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Stable reason codes for <see cref="ContractCompletionOperations.TryCompleteOnEncounterClear"/> (NEO-149).</summary>
|
||||||
|
public static class ContractCompletionReasonCodes
|
||||||
|
{
|
||||||
|
public const string InvalidIds = ContractInstanceReasonCodes.InvalidIds;
|
||||||
|
|
||||||
|
public const string PlayerNotWritable = ContractInstanceReasonCodes.PlayerNotWritable;
|
||||||
|
|
||||||
|
public const string NoActiveContract = "no_active_contract";
|
||||||
|
|
||||||
|
public const string EncounterMismatch = "encounter_mismatch";
|
||||||
|
|
||||||
|
public const string UnknownTemplate = "unknown_template";
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,12 @@ namespace NeonSprawl.Server.Game.Crafting;
|
||||||
/// Server-internal craft resolution envelope (NEO-69); promoted to wire JSON via <see cref="CraftResponse"/> (NEO-70).
|
/// Server-internal craft resolution envelope (NEO-69); promoted to wire JSON via <see cref="CraftResponse"/> (NEO-70).
|
||||||
/// NEO-71 telemetry hook sites live in <see cref="CraftOperations.TryCraft"/> (success path + centralized <c>Deny</c> helper).
|
/// NEO-71 telemetry hook sites live in <see cref="CraftOperations.TryCraft"/> (success path + centralized <c>Deny</c> helper).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="InputsConsumed">Scaled inputs removed on success; empty when denied.</param>
|
|
||||||
/// <param name="OutputsGranted">Scaled outputs added on success; empty when denied.</param>
|
|
||||||
/// <param name="XpGrantSummary">Refine skill XP applied on success; null when denied.</param>
|
|
||||||
public readonly record struct CraftResult(
|
public readonly record struct CraftResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
string? ReasonCode,
|
string? ReasonCode,
|
||||||
|
/// <summary>Scaled inputs removed on success; empty when denied.</summary>
|
||||||
IReadOnlyList<CraftIoApplied> InputsConsumed,
|
IReadOnlyList<CraftIoApplied> InputsConsumed,
|
||||||
|
/// <summary>Scaled outputs added on success; empty when denied.</summary>
|
||||||
IReadOnlyList<CraftIoApplied> OutputsGranted,
|
IReadOnlyList<CraftIoApplied> OutputsGranted,
|
||||||
|
/// <summary>Refine skill XP applied on success; null when denied.</summary>
|
||||||
CraftXpGrantSummary? XpGrantSummary);
|
CraftXpGrantSummary? XpGrantSummary);
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,11 @@ public static class PlayerCraftApi
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<CraftIoAppliedJson> MapIoRows(IReadOnlyList<CraftIoApplied> rows)
|
private static CraftIoAppliedJson[] MapIoRows(IReadOnlyList<CraftIoApplied> rows)
|
||||||
{
|
{
|
||||||
if (rows.Count == 0)
|
if (rows.Count == 0)
|
||||||
{
|
{
|
||||||
return Array.Empty<CraftIoAppliedJson>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var mapped = new CraftIoAppliedJson[rows.Count];
|
var mapped = new CraftIoAppliedJson[rows.Count];
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
@ -38,6 +40,9 @@ public static class EncounterCombatWiring
|
||||||
IRewardDeliveryStore deliveryStore,
|
IRewardDeliveryStore deliveryStore,
|
||||||
IFactionStandingStore standingStore,
|
IFactionStandingStore standingStore,
|
||||||
IReputationDeltaStore auditStore,
|
IReputationDeltaStore auditStore,
|
||||||
|
IContractInstanceStore contractInstanceStore,
|
||||||
|
IContractOutcomeStore contractOutcomeStore,
|
||||||
|
IContractTemplateRegistry contractTemplateRegistry,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
ILogger? logger = null)
|
ILogger? logger = null)
|
||||||
{
|
{
|
||||||
|
|
@ -104,15 +109,22 @@ public static class EncounterCombatWiring
|
||||||
deliveryStore,
|
deliveryStore,
|
||||||
standingStore,
|
standingStore,
|
||||||
auditStore,
|
auditStore,
|
||||||
|
contractInstanceStore,
|
||||||
|
contractOutcomeStore,
|
||||||
|
contractTemplateRegistry,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
|
|
||||||
if (!completion.Success)
|
if (!completion.Success)
|
||||||
{
|
{
|
||||||
logger?.LogDebug(
|
if (logger?.IsEnabled(LogLevel.Debug) is true)
|
||||||
"Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})",
|
{
|
||||||
playerId,
|
logger.LogDebug(
|
||||||
encounterId,
|
"Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})",
|
||||||
completion.ReasonCode);
|
playerId,
|
||||||
|
encounterId,
|
||||||
|
completion.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
@ -41,6 +42,9 @@ public static class EncounterCompletionOperations
|
||||||
IRewardDeliveryStore deliveryStore,
|
IRewardDeliveryStore deliveryStore,
|
||||||
IFactionStandingStore standingStore,
|
IFactionStandingStore standingStore,
|
||||||
IReputationDeltaStore auditStore,
|
IReputationDeltaStore auditStore,
|
||||||
|
IContractInstanceStore contractInstanceStore,
|
||||||
|
IContractOutcomeStore contractOutcomeStore,
|
||||||
|
IContractTemplateRegistry contractTemplateRegistry,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
|
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
|
||||||
|
|
@ -167,6 +171,24 @@ public static class EncounterCompletionOperations
|
||||||
auditStore,
|
auditStore,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
|
|
||||||
|
_ = ContractCompletionOperations.TryCompleteOnEncounterClear(
|
||||||
|
normalizedPlayerId,
|
||||||
|
normalizedEncounterId,
|
||||||
|
contractInstanceStore,
|
||||||
|
contractOutcomeStore,
|
||||||
|
contractTemplateRegistry,
|
||||||
|
itemRegistry,
|
||||||
|
inventoryStore,
|
||||||
|
skillRegistry,
|
||||||
|
skillProgressionStore,
|
||||||
|
levelCurve,
|
||||||
|
perkUnlockEngine,
|
||||||
|
perkStore,
|
||||||
|
deliveryStore,
|
||||||
|
standingStore,
|
||||||
|
auditStore,
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
return new EncounterCompletionResult(
|
return new EncounterCompletionResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
ReasonCode: null,
|
ReasonCode: null,
|
||||||
|
|
@ -190,7 +212,7 @@ public static class EncounterCompletionOperations
|
||||||
|
|
||||||
private static void CompensatingRemoveGrants(
|
private static void CompensatingRemoveGrants(
|
||||||
string playerId,
|
string playerId,
|
||||||
IReadOnlyList<EncounterGrantApplied> grants,
|
List<EncounterGrantApplied> grants,
|
||||||
IItemDefinitionRegistry itemRegistry,
|
IItemDefinitionRegistry itemRegistry,
|
||||||
IPlayerInventoryStore inventoryStore)
|
IPlayerInventoryStore inventoryStore)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Rewards;
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
|
@ -15,6 +16,7 @@ public static class QuestFixtureApi
|
||||||
IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
|
IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
|
||||||
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
|
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
|
||||||
IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore,
|
IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore,
|
||||||
|
IContractInstanceStore contractInstanceStore, IContractOutcomeStore contractOutcomeStore,
|
||||||
TimeProvider timeProvider) =>
|
TimeProvider timeProvider) =>
|
||||||
{
|
{
|
||||||
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
|
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
|
||||||
|
|
@ -37,6 +39,8 @@ public static class QuestFixtureApi
|
||||||
standingStore,
|
standingStore,
|
||||||
deliveryStore,
|
deliveryStore,
|
||||||
auditStore,
|
auditStore,
|
||||||
|
contractInstanceStore,
|
||||||
|
contractOutcomeStore,
|
||||||
timeProvider))
|
timeProvider))
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ public sealed class QuestFixtureRequest
|
||||||
/// <summary>Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0).</summary>
|
/// <summary>Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0).</summary>
|
||||||
[JsonPropertyName("resetFactionIds")]
|
[JsonPropertyName("resetFactionIds")]
|
||||||
public IReadOnlyList<string> ResetFactionIds { get; init; } = [];
|
public IReadOnlyList<string> ResetFactionIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Contract instance ids to clear from instance, delivery, and outcome stores (dev Bruno reset).</summary>
|
||||||
|
[JsonPropertyName("resetContractInstanceIds")]
|
||||||
|
public IReadOnlyList<string> ResetContractInstanceIds { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST response for quest fixture apply.</summary>
|
/// <summary>POST response for quest fixture apply.</summary>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
namespace NeonSprawl.Server.Game.Quests;
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Rewards;
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
|
||||||
|
|
@ -19,11 +20,14 @@ public static class QuestFixtureOperations
|
||||||
IFactionStandingStore standingStore,
|
IFactionStandingStore standingStore,
|
||||||
IRewardDeliveryStore deliveryStore,
|
IRewardDeliveryStore deliveryStore,
|
||||||
IReputationDeltaStore auditStore,
|
IReputationDeltaStore auditStore,
|
||||||
|
IContractInstanceStore instanceStore,
|
||||||
|
IContractOutcomeStore outcomeStore,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
if (body.CompletedQuestIds.Count == 0 &&
|
if (body.CompletedQuestIds.Count == 0 &&
|
||||||
body.ResetQuestIds.Count == 0 &&
|
body.ResetQuestIds.Count == 0 &&
|
||||||
body.ResetFactionIds.Count == 0)
|
body.ResetFactionIds.Count == 0 &&
|
||||||
|
body.ResetContractInstanceIds.Count == 0)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +73,33 @@ public static class QuestFixtureOperations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var contractInstanceId in body.ResetContractInstanceIds)
|
||||||
|
{
|
||||||
|
var normalizedInstanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
|
||||||
|
if (normalizedInstanceId.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deliveryStore.TryClear(
|
||||||
|
playerId,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
normalizedInstanceId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceStore.TryClearInstance(playerId, normalizedInstanceId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outcomeStore.TryClearForInstance(normalizedInstanceId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var questId in body.CompletedQuestIds)
|
foreach (var questId in body.CompletedQuestIds)
|
||||||
{
|
{
|
||||||
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,37 @@ Completed instances are immutable (no status regression). Player, template, and
|
||||||
|
|
||||||
Economy band-cap lint at issue time lands in **NEO-150** (E7M4-07). HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md).
|
Economy band-cap lint at issue time lands in **NEO-150** (E7M4-07). HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md).
|
||||||
|
|
||||||
|
## Contract completion operations (NEO-149)
|
||||||
|
|
||||||
|
**`ContractCompletionOperations.TryCompleteOnEncounterClear`** completes an **`active`** contract when the cleared encounter matches the template **`encounterTemplateId`**. Call this orchestrator (or rely on encounter wiring) — not stores or the reward router directly.
|
||||||
|
|
||||||
|
**Wiring order:** **`EncounterCompletionOperations.TryCompleteAndGrant`** applies first-time encounter loot (E5.M3), records **`EncounterCompleteEvent`**, runs **`QuestObjectiveWiring`**, then invokes contract completion. Encounter success is never blocked by contract payout failure.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. **`TryGetActiveForPlayer`** — no active row ⇒ success no-op (**`no_active_contract`**).
|
||||||
|
2. Load template; deny **`unknown_template`** when missing.
|
||||||
|
3. Encounter id mismatch ⇒ success no-op (**`encounter_mismatch`**).
|
||||||
|
4. **`RewardRouterOperations.TryDeliverContractCompletion`** (deliver-then-mark).
|
||||||
|
5. On delivery deny, instance stays **`active`**; router **`reasonCode`** passthrough (e.g. **`inventory_full`**).
|
||||||
|
6. **`IContractInstanceStore.TryMarkComplete`** then **`IContractOutcomeStore.TryAppend`** with grant snapshots from the delivery result.
|
||||||
|
|
||||||
|
**Reason codes:**
|
||||||
|
|
||||||
|
| Code | When |
|
||||||
|
|------|------|
|
||||||
|
| **`no_active_contract`** | Success no-op — player has no active instance |
|
||||||
|
| **`encounter_mismatch`** | Success no-op — cleared encounter is not the template objective |
|
||||||
|
| **`unknown_template`** | Active instance references a missing template id |
|
||||||
|
| **`invalid_ids`**, **`player_not_writable`** | Normalization / write gate failures |
|
||||||
|
| *(router passthrough)* | Delivery deny — e.g. **`inventory_full`**, **`already_delivered`** |
|
||||||
|
|
||||||
|
**Dev fixture reset:** **`POST …/__dev/quest-fixture`** accepts optional **`resetContractInstanceIds`** — clears contract instance, **`contract_completion`** delivery row, and outcome audit for each id (NEO-149).
|
||||||
|
|
||||||
|
**Prototype edge case:** issuing a contract after the bound encounter is already cleared leaves the instance **`active`** with no re-trigger in v1 — prototype flow is issue-then-clear.
|
||||||
|
|
||||||
|
HTTP contract GET/issue projections land in **NEO-151** (E7M4-08). Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md).
|
||||||
|
|
||||||
## Contract outcome store (NEO-146)
|
## Contract outcome store (NEO-146)
|
||||||
|
|
||||||
Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**.
|
Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**.
|
||||||
|
|
@ -491,7 +522,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest and
|
||||||
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
|
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
|
||||||
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
|
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
|
||||||
|
|
||||||
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations`** will call **`TryDeliverContractCompletion`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md).
|
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — invoked from **`EncounterCompletionOperations`** after encounter loot + quest wiring; deliver-then-mark via **`TryDeliverContractCompletion`**, outcome append via **`IContractOutcomeStore`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md), [NEO-149](../../docs/plans/NEO-149-implementation-plan.md).
|
||||||
|
|
||||||
### Reward telemetry hooks (NEO-130)
|
### Reward telemetry hooks (NEO-130)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue