neon-sprawl/docs/plans/NEO-149-implementation-plan.md

180 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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.