13 KiB
NEO-148 — E7M4-05: Extend RewardRouterOperations for contract completion bundles
Linear: NEO-148
Branch: NEO-148-e7m4-05-reward-router-contract-completion
Backlog: E7M4-pre-production-backlog.md — E7M4-05
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-127-implementation-plan.md — grant order + compensating rollback + TryRecord race mirror; NEO-138-implementation-plan.md — reputation grants + store snapshot extension
Precursors: NEO-145 Done — contract template catalog load; NEO-146 Done — ContractOutcomeIds.MakeIdempotencyKey, grant snapshot types
Blocks: NEO-149 (E7M4-06 ContractCompletionOperations + encounter wiring)
Client counterpart: NEO-153 (blocked by E7M4-08; no Godot work in this story)
Goal
Contract completion payouts reuse E7.M2 delivery idempotency with contract-specific keys ({playerId}:contract_complete:{contractInstanceId}).
Kickoff clarifications
No blocking decisions needed. Linear AC, E7M4-05 backlog scope, E7M4 kickoff decisions table, and landed NEO-127/NEO-138/NEO-146 code settle:
| Topic | Decision | Evidence |
|---|---|---|
| Idempotency key | ContractOutcomeIds.MakeIdempotencyKey → {playerId}:contract_complete:{contractInstanceId} |
E7M4 kickoff table; NEO-146 shipped helper |
| Bundle DTO | Reuse QuestRewardBundleRow on ContractTemplateRow.CompletionRewardBundle |
NEO-145 loader; prototype freeze table |
| Grant kinds + order | Items → skill XP → reputation; compensating rollback on partial deny and TryRecord race loss |
E7M4-05 backlog “mirrors E7.M2 quest path”; NEO-127/NEO-138 |
| Delivery store | Extend IRewardDeliveryStore / RewardDeliveryEvent with contract_completion source kind |
E7M4-05 backlog in-scope list |
| Outcome audit store | Out of scope — IContractOutcomeStore.TryAppend is E7M4-06 orchestrator responsibility |
NEO-146 open-questions table (“orchestrator fills from router result”) |
| Reputation source | ReputationDeltaSourceKinds.ContractCompletion + contractInstanceId as sourceId |
NEO-138 QuestCompletion precedent |
| Skill XP source kind | mission_reward (same as quest completion) |
Prototype contract freeze; NEO-127 |
| Router shape | TryDeliverContractCompletion public entry + private shared grant-apply core extracted from quest path |
Avoid ~200-line duplication while keeping TryDeliverQuestCompletion signature stable |
| Store API | Source-kind-aware TryGet/TryRecord/TryClear; existing quest methods remain thin wrappers |
Backlog “extend store for contract source kind”; backward compat for NEO-126 callers |
IContractOutcomeStore replay |
Router idempotency uses IRewardDeliveryStore only; outcome store is separate audit trail |
E7M4-05 vs E7M4-06 scope split |
| Postgres delivery store | Deferred — in-memory only (same as quest delivery today) | NEO-126/NEO-138 precedent |
| Encounter / HTTP wiring | Out of scope | E7M4-06, E7M4-08 |
Scope and out-of-scope
In scope (from Linear + backlog):
RewardRouterOperations.TryDeliverContractCompletion— same grant kinds as quest completion with contract idempotency key.- Extend
IRewardDeliveryStore,RewardDeliveryEvent,RewardDeliveryIds,InMemoryRewardDeliveryStoreforcontract_completionsource kind. - Add
ReputationDeltaSourceKinds.ContractCompletion. - Compensating rollback policy mirrors E7.M2 quest path (items, skill XP, reputation, perk revert, race loser).
- Integration tests: first delivery, idempotent replay, unknown item/faction deny.
server/README.mdreward router + delivery store sections.
Out of scope (from Linear + backlog):
ContractCompletionOperations+ encounter wiring (NEO-149 / E7M4-06).IContractOutcomeStore.TryAppendfrom router (orchestrator in E7M4-06).- HTTP contract projections (NEO-151 / E7M4-08).
- E9.M1 telemetry ingest (comment-only hook sites optional; not required by AC).
- Godot client (NEO-153+).
Client counterpart: NEO-153 — contract HUD after E7M4-08.
Acceptance criteria checklist
- First-time contract complete applies bundle exactly once per instance id.
- Replay returns
already_deliveredwithout double grant.
Implementation reconciliation (shipped)
- Types:
RewardDeliverySourceKinds;RewardDeliveryEvent.SourceKind+ContractInstanceId;RewardDeliveryReasonCodes.InvalidContractInstanceId;ReputationDeltaSourceKinds.ContractCompletion. - Store:
IRewardDeliveryStoresource-kind-awareTryGet/TryClear;InMemoryRewardDeliveryStorekeys{playerId}\0{sourceKind}\0{sourceId}. - Router:
TryDeliverContractCompletion+ private sharedTryDeliverBundlecore; quest path unchanged at public API. - Tests: 5 contract router tests + 1 store isolation test; full suite 897 tests green.
- Docs:
server/README.mddelivery store + router sections; E7.M4 module anchor.
Technical approach
1. Types (Game/Rewards/)
| Type | Change |
|---|---|
RewardDeliverySourceKinds (new) |
Stable strings: quest_completion, contract_completion |
RewardDeliveryEvent |
Add SourceKind; add ContractInstanceId (empty for quest rows); quest rows keep QuestId populated |
RewardDeliveryIds |
Add NormalizeContractInstanceId (delegate to ContractInstanceIds); MakeContractStoreKey(playerId, contractInstanceId); extend MakeStoreKey to include source kind {p}\0{kind}\0{id} |
RewardDeliveryReasonCodes |
Add invalid_contract_instance_id |
IRewardDeliveryStore |
Add TryGet(playerId, sourceKind, sourceId, …), TryRecord, TryClear overloads; keep existing quest methods as wrappers calling quest_completion |
InMemoryRewardDeliveryStore |
Index by source-kind-aware store key; quest + contract rows isolated |
Add ReputationDeltaSourceKinds.ContractCompletion = "contract_completion" in Game/Factions/.
2. RewardRouterOperations
New public method:
public static RewardDeliveryResult TryDeliverContractCompletion(
string playerId,
string contractInstanceId,
QuestRewardBundleRow? bundle,
/* same dependency injection as TryDeliverQuestCompletion */)
Flow (mirror quest):
- Normalize
playerId+contractInstanceId; deny on empty ids. deliveryStore.TryGet(playerId, contract_completion, contractInstanceId)— idempotent replay →already_deliveredsuccess without re-grant.- Resolve bundle grant lists (null → empty).
- Apply items → skill XP → reputation with
ContractOutcomeIds.MakeIdempotencyKeyandReputationDeltaSourceKinds.ContractCompletion+contractInstanceIdas repsourceId. - Build
RewardDeliveryEventwithSourceKind = contract_completion,ContractInstanceId, emptyQuestId, grant snapshots, idempotency key. TryRecord; on race loss, compensating rollback all grant types and return winner event.
Refactor: Extract private TryDeliverBundleCore (or equivalent) parameterized by delivery context (source kind, source id, idempotency key factory, rep source kind/id) so quest and contract paths share rollback/apply logic. TryDeliverQuestCompletion remains a thin wrapper — no signature change for callers.
3. Prototype test fixtures
Use frozen template bundle from prototype_contract_clear_combat_pocket:
scrap_metal_bulk×5,salvage15 XP (no rep in prototype contract).
Contract instance id: synthetic test value (e.g. prototype_contract_clear_combat_pocket:test-seed-001) — no generator dependency required for router unit tests.
4. Documentation
server/README.md: extend reward delivery store table withSourceKind+ contract key; addTryDeliverContractCompletionsection under reward router with reason codes (reuse quest passthrough set +invalid_contract_instance_id).- Update
E7_M4_ContractMissionGenerator.mdSlice 4 anchor when shipped (router landed).
Files to add
| Path | Purpose |
|---|---|
server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs |
Stable quest_completion / contract_completion source kind constants |
(No other new source files expected — router + store extensions land in existing files.)
Files to modify
| Path | Rationale |
|---|---|
server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs |
Add TryDeliverContractCompletion; extract shared grant-apply core |
server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs |
Source-kind-aware TryGet/TryRecord/TryClear overloads |
server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs |
Add SourceKind, ContractInstanceId |
server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs |
Contract instance normalization + source-kind store keys |
server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs |
invalid_contract_instance_id |
server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs |
Source-kind-aware indexing |
server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs |
ContractCompletion constant |
server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs |
Contract delivery happy path, replay, deny tests |
server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs |
Contract source kind idempotency at store layer |
server/README.md |
Delivery store + router contract completion docs |
docs/decomposition/modules/E7_M4_ContractMissionGenerator.md |
Slice 4 router anchor when shipped |
Tests
| File | Coverage |
|---|---|
server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs |
TryDeliverContractCompletion_ShouldApplyPrototypeBundle_WhenFirstDelivery — items + skill XP applied once; store record with contract_completion key |
| Same | TryDeliverContractCompletion_ShouldReturnAlreadyDelivered_WhenReplayed — second call success + already_delivered; inventory/xp unchanged |
| Same | TryDeliverContractCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord — rep grant with unknown faction; no delivery row |
| Same | TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord — unknown item / full bag deny (backlog “unknown item/faction deny”) |
| Same | TryDeliverContractCompletion_ShouldDenyInvalidContractInstanceId_WhenEmpty — fail-closed id validation |
server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs |
Contract TryRecord idempotency + source-kind isolation from quest rows |
All new tests follow AAA with // Arrange / // Act / // Assert labels per csharp-style.
Manual Godot QA: none (server engine; capstone NEO-154).
Open questions / risks
| Question / risk | Agent recommendation | Status |
|---|---|---|
| Shared-core refactor touches quest path | Keep TryDeliverQuestCompletion as thin wrapper; run full existing RewardRouterOperationsTests suite unchanged |
adopted |
| Quest vs contract store key collision | Prefix store key with SourceKind — ids already namespaced (prototype_quest_* vs generated instance ids) |
adopted |
| Rep grants on future contract templates | Apply path ready; prototype template has no rep rows — test unknown-faction deny with synthetic bundle | adopted |
IContractOutcomeStore vs delivery store duplication |
Delivery store = grant idempotency (E7.M2 pattern); outcome store = audit append from E7M4-06 orchestrator only | adopted |
| Contract delivery fixture reset | QuestFixtureOperations clears quest rows only today; add contract TryClear(playerId, contract_completion, instanceId) when E7M4-06+ Bruno loops land — track on NEO-149 |
deferred |
| Postgres store key migration | Pre-NEO-148 quest keys {playerId}\0{questId} → {playerId}\0{sourceKind}\0{sourceId}; migrate or document at Postgres delivery persistence |
deferred |
Client counterpart
NEO-153 — contract panel after E7M4-08 HTTP. This story is server-only; Bruno/HTTP contract loop verification lands in E7M4-06+.