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

15 KiB
Raw Permalink Blame History

NEO-149 — E7M4-06: ContractCompletionOperations + encounter wiring

Linear: NEO-149
Branch: NEO-149-e7m4-06-contract-completion-encounter-wiring
Backlog: E7M4-pre-production-backlog.mdE7M4-06
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-128-implementation-plan.md — deliver-then-mark + idempotent replay; NEO-147-implementation-plan.mdContractGeneratorOperations orchestrator shape; NEO-148-implementation-plan.mdTryDeliverContractCompletion + outcome audit split
Precursors: NEO-147 DoneContractGeneratorOperations.TryIssue; NEO-148 DoneRewardRouterOperations.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.TryCompleteOnEncounterClear static orchestrator in Game/Contracts/.
  • ContractCompletionReasonCodes + ContractCompletionOperationResult envelope.
  • Wire from EncounterCompletionOperations success path (after encounter loot + quest wiring).
  • Pass contract dependencies through EncounterCombatWiringAbilityCastApi (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, 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).

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:

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: TryIssueTryCompleteAndGrant → 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.