From 8d9d02fde56670f3441adc81e0c66d9fccade387 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 21:43:46 -0400 Subject: [PATCH] NEO-149: add E7M4-06 implementation plan for contract completion wiring Kickoff plan for ContractCompletionOperations orchestrator, encounter hook after E5.M3 loot, and integration test coverage; Bruno HTTP loop deferred to NEO-151. --- docs/plans/NEO-149-implementation-plan.md | 170 ++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/plans/NEO-149-implementation-plan.md diff --git a/docs/plans/NEO-149-implementation-plan.md b/docs/plans/NEO-149-implementation-plan.md new file mode 100644 index 0000000..aef0a3a --- /dev/null +++ b/docs/plans/NEO-149-implementation-plan.md @@ -0,0 +1,170 @@ +# 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 + +- [ ] Active contract completes when bound encounter clears. +- [ ] Encounter first-time loot idempotency unchanged (E5.M3). + +## 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.