14 KiB
NEO-149 — E7M4-06: ContractCompletionOperations + encounter wiring
Linear: NEO-149
Branch: NEO-149-e7m4-06-contract-completion-encounter-wiring
Backlog: E7M4-pre-production-backlog.md — E7M4-06
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-128-implementation-plan.md — deliver-then-mark + idempotent replay; NEO-147-implementation-plan.md — ContractGeneratorOperations orchestrator shape; NEO-148-implementation-plan.md — TryDeliverContractCompletion + outcome audit split
Precursors: NEO-147 Done — ContractGeneratorOperations.TryIssue; NEO-148 Done — RewardRouterOperations.TryDeliverContractCompletion
Blocks: NEO-151 (E7M4-08 HTTP contract projections + Bruno issue/clear loop)
Client counterpart: NEO-153, 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 kickoff decisions table, 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.TryCompleteOnEncounterClearstatic orchestrator inGame/Contracts/.ContractCompletionReasonCodes+ContractCompletionOperationResultenvelope.- Wire from
EncounterCompletionOperationssuccess 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→ appendContractOutcomeRow. - 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 deliveryTryClearfor test/Bruno reset loops. server/README.mdcontract 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).
ContractEconomyValidationat issue (NEO-150 / E7M4-07).- Godot HUD (NEO-153 / NEO-154).
- E9.M1 telemetry ingest (comment-only hook sites optional).
Client counterpart: NEO-153, 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:
- Normalize
playerId+encounterId; denyinvalid_idswhen empty. 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).- Load template via
templateRegistry.TryGetDefinition(active.TemplateId); denyunknown_templatewhen missing. - Normalize template
encounterTemplateIdvs clearedencounterId— mismatch ⇒ success no-op (encounter_mismatch). - If active snapshot status is already
completed(defensive) ⇒ idempotent success. RewardRouterOperations.TryDeliverContractCompletionwith templateCompletionRewardBundle.- On delivery deny ⇒ return deny with router
reasonCode; instance staysactive. instanceStore.TryMarkCompletewithtimeProvider.GetUtcNow(); handle store replay idempotency (completed snapshot ⇒ success).- Build
ContractOutcomeRowfrom delivery grants +ContractOutcomeIds.MakeIdempotencyKey;outcomeStore.TryAppend(best-effort if duplicate idempotency key after race — treat as success when instance already completed). - Comment-only
contract_completetelemetry 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:
ContractCompletionOperations.TryCompleteOnEncounterClear(
normalizedPlayerId,
normalizedEncounterId,
/* contract + router deps */);
Extend EncounterCombatWiring.TryProcessCastOutcome and AbilityCastApi to resolve and pass:
IContractInstanceStoreIContractOutcomeStoreIContractTemplateRegistry
(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.mdSlice 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.
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 — contract panel after E7M4-08 HTTP. NEO-154 — Godot capstone. This story is server-only; Bruno verification of the full player loop lands with NEO-151.