diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md
index 1988986..323a356 100644
--- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md
+++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md
@@ -72,6 +72,8 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127).
+**NEO-148 (E7.M4 contract reuse):** **`RewardRouterOperations.TryDeliverContractCompletion`** reuses the same **`IRewardDeliveryStore`** and shared **`TryDeliverBundle`** core for contract template **`completionRewardBundle`** rows (idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, **`SourceKind = contract_completion`**). See [NEO-148 implementation plan](../../plans/NEO-148-implementation-plan.md); [E7.M4 — ContractMissionGenerator](E7_M4_ContractMissionGenerator.md).
+
**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).
**NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-implementation-plan.md); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
@@ -81,3 +83,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-131 (E7M2-08 client HUD):** Godot **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection paints **`completionRewardSummary`** grant lines when a quest newly becomes **`completed`** in-session (parse via **`quest_progress_client.gd`**). See [NEO-131 implementation plan](../../plans/NEO-131-implementation-plan.md); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131).
**NEO-132 (E7M2-09 client capstone):** playable four-quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); economy HUD refresh on completion transition via **`quest_completion_reward_transition`** signal. Epic 7 Slice 2 client capstone complete.
+
+**NEO-148 (E7.M4 contract router reuse):** [E7.M4](E7_M4_ContractMissionGenerator.md) **`TryDeliverContractCompletion`** extends the same **`IRewardDeliveryStore`** + shared grant-apply core with **`contract_completion`** source kind and key **`{playerId}:contract_complete:{contractInstanceId}`** — see [NEO-148 implementation plan](../../plans/NEO-148-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127).
diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md
index bfc7d6d..3bdf964 100644
--- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md
+++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md
@@ -59,6 +59,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147).
+**Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127).
+
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md
index 27136a5..b04993b 100644
--- a/docs/decomposition/modules/documentation_and_implementation_alignment.md
+++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md
@@ -62,7 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 |
| E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 |
| E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`NEO-143` manual QA](../../manual-qa/NEO-143.md); [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143); plan [NEO-143](../../plans/NEO-143-implementation-plan.md). **Epic 7 Slice 3 client capstone complete.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 |
-| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 |
+| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 |
---
diff --git a/docs/plans/NEO-148-implementation-plan.md b/docs/plans/NEO-148-implementation-plan.md
new file mode 100644
index 0000000..9aaeda3
--- /dev/null
+++ b/docs/plans/NEO-148-implementation-plan.md
@@ -0,0 +1,172 @@
+# NEO-148 — E7M4-05: Extend RewardRouterOperations for contract completion bundles
+
+**Linear:** [NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)
+**Branch:** `NEO-148-e7m4-05-reward-router-contract-completion`
+**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-05**
+**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
+**Pattern:** [NEO-127-implementation-plan.md](NEO-127-implementation-plan.md) — grant order + compensating rollback + `TryRecord` race mirror; [NEO-138-implementation-plan.md](NEO-138-implementation-plan.md) — reputation grants + store snapshot extension
+**Precursors:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) **`Done`** — contract template catalog load; [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) **`Done`** — `ContractOutcomeIds.MakeIdempotencyKey`, grant snapshot types
+**Blocks:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) (E7M4-06 `ContractCompletionOperations` + encounter wiring)
+**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/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-pre-production-backlog.md#e7m4-05--extend-rewardrouteroperations-for-contract-completion-bundles), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), 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`**, **`InMemoryRewardDeliveryStore`** for **`contract_completion`** source 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.md` reward router + delivery store sections.
+
+**Out of scope (from Linear + backlog):**
+
+- **`ContractCompletionOperations`** + encounter wiring (NEO-149 / E7M4-06).
+- **`IContractOutcomeStore.TryAppend`** from 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](https://linear.app/neon-sprawl/issue/NEO-153) — contract HUD after E7M4-08.
+
+## Acceptance criteria checklist
+
+- [x] First-time contract complete applies bundle exactly once per instance id.
+- [x] Replay returns **`already_delivered`** without double grant.
+
+## Implementation reconciliation (shipped)
+
+- **Types:** `RewardDeliverySourceKinds`; `RewardDeliveryEvent.SourceKind` + `ContractInstanceId`; `RewardDeliveryReasonCodes.InvalidContractInstanceId`; `ReputationDeltaSourceKinds.ContractCompletion`.
+- **Store:** `IRewardDeliveryStore` source-kind-aware `TryGet`/`TryClear`; `InMemoryRewardDeliveryStore` keys `{playerId}\0{sourceKind}\0{sourceId}`.
+- **Router:** `TryDeliverContractCompletion` + private shared `TryDeliverBundle` core; quest path unchanged at public API.
+- **Tests:** 5 contract router tests + 1 store isolation test; full suite **897** tests green.
+- **Docs:** `server/README.md` delivery 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:**
+
+```csharp
+public static RewardDeliveryResult TryDeliverContractCompletion(
+ string playerId,
+ string contractInstanceId,
+ QuestRewardBundleRow? bundle,
+ /* same dependency injection as TryDeliverQuestCompletion */)
+```
+
+**Flow** (mirror quest):
+
+1. Normalize **`playerId`** + **`contractInstanceId`**; deny on empty ids.
+2. **`deliveryStore.TryGet(playerId, contract_completion, contractInstanceId)`** — idempotent replay → **`already_delivered`** success without re-grant.
+3. Resolve bundle grant lists (null → empty).
+4. Apply items → skill XP → reputation with **`ContractOutcomeIds.MakeIdempotencyKey`** and **`ReputationDeltaSourceKinds.ContractCompletion`** + **`contractInstanceId`** as rep **`sourceId`**.
+5. Build **`RewardDeliveryEvent`** with **`SourceKind = contract_completion`**, **`ContractInstanceId`**, empty **`QuestId`**, grant snapshots, idempotency key.
+6. **`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**, **`salvage` 15 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 with **`SourceKind`** + contract key; add **`TryDeliverContractCompletion`** section under reward router with reason codes (reuse quest passthrough set + **`invalid_contract_instance_id`**).
+- Update **`E7_M4_ContractMissionGenerator.md`** Slice 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](../.cursor/rules/csharp-style.md).
+
+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](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after E7M4-08 HTTP. This story is server-only; Bruno/HTTP contract loop verification lands in E7M4-06+.
diff --git a/docs/reviews/2026-06-27-NEO-148.md b/docs/reviews/2026-06-27-NEO-148.md
new file mode 100644
index 0000000..95db1c5
--- /dev/null
+++ b/docs/reviews/2026-06-27-NEO-148.md
@@ -0,0 +1,56 @@
+# Code review — NEO-148 contract completion reward router
+
+**Date:** 2026-06-27
+**Scope:** Branch `NEO-148-e7m4-05-reward-router-contract-completion` — commits `b20d7d0`..`76ce194` vs `origin/main`
+**Base:** `origin/main`
+
+## Verdict
+
+**Approve with nits**
+
+## Summary
+
+NEO-148 extends the E7.M2 reward delivery stack for contract completion: `RewardDeliverySourceKinds`, source-kind-aware `IRewardDeliveryStore` / `InMemoryRewardDeliveryStore` keys, `RewardDeliveryEvent.SourceKind` + `ContractInstanceId`, `ReputationDeltaSourceKinds.ContractCompletion`, and `RewardRouterOperations.TryDeliverContractCompletion` with a shared private `TryDeliverBundle` core extracted from the quest path. Grant order, compensating rollback, and `TryRecord` race handling mirror NEO-127/NEO-138. Five contract router tests plus one store isolation test cover happy path, idempotent replay, faction deny, inventory deny, and invalid instance id. Implementation plan reconciliation, `server/README.md`, and E7.M4 module anchor are updated. Server-only slice; no client work expected. Full suite: **897** tests passed locally.
+
+## Documentation checked
+
+| Path | Result |
+|------|--------|
+| `docs/plans/NEO-148-implementation-plan.md` | **Matches** — AC checklist marked complete; shipped reconciliation aligns with diff |
+| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-05) | **Matches** — router + store extension, orchestrator/outcome store out of scope |
+| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-148 router anchor added |
+| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-148 contract reuse anchor added |
+| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row includes NEO-148 |
+| `server/README.md` | **Matches** — delivery store fields, `TryGet` overloads, contract router + reason codes |
+| Client counterpart (NEO-153) | **N/A** — explicitly out of scope; no prototype-complete claim |
+
+Register/tracking table updated for E7.M4 (NEO-148).
+
+## Blocking issues
+
+None.
+
+## Suggestions
+
+1. ~~**Update implementation alignment register** — Add **E7M4-05 / NEO-148 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (router + source-kind store extension), consistent with NEO-144–NEO-147 entries.~~ **Done.**
+
+2. ~~**Optional E7.M2 module cross-ref** — A one-line anchor on `E7_M2_RewardAndUnlockRouter.md` noting contract completion reuse via `TryDeliverContractCompletion` (NEO-148) would help readers who land on E7.M2 first.~~ **Done.**
+
+3. ~~**Store key format note for future Postgres** — Quest store keys changed from `{playerId}\0{questId}` to `{playerId}\0{sourceKind}\0{sourceId}`. Harmless for in-memory prototype (process restart clears state). When Postgres delivery persistence lands, document or migrate old rows explicitly so idempotent replay cannot double-grant after upgrade.~~ **Done.** (`server/README.md` + plan open questions)
+
+4. ~~**Contract fixture reset (follow-on)** — `QuestFixtureOperations` still clears quest rows via `TryClear(playerId, questId)` only. When E7M4-06+ adds Bruno contract loops, contract delivery rows will need a parallel reset path — track on NEO-149 or fixture story, not blocking here.~~ **Done.** (deferred to NEO-149 in plan open questions)
+
+## Nits
+
+- Nit: Plan test table lists “unknown item / full bag deny”; `TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord` covers full bag only. Shared `TryDeliverBundle` item path is quest-tested for inventory deny; an explicit `invalid_item` contract test would lock naming to AC but is low value given shared core.
+- Nit: NEO-130 telemetry hook comments in `TryDeliverBundle` still reference `questId` in payload notes; fine until contract telemetry wiring — consider generalizing when E9.M1 touches contract completion.
+- Nit: Plan mentions `MakeContractStoreKey`; implementation uses `MakeStoreKey(playerId, sourceKind, sourceId)` instead — equivalent, no action needed.
+
+## Verification
+
+```bash
+dotnet test NeonSprawl.sln
+dotnet test NeonSprawl.sln --filter "FullyQualifiedName~RewardRouterOperationsTests|FullyQualifiedName~InMemoryRewardDeliveryStoreTests"
+```
+
+Manual Godot QA: none (server engine per plan).
diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs
index e39699b..8530dbe 100644
--- a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests;
using Xunit;
@@ -124,6 +125,49 @@ public sealed class InMemoryRewardDeliveryStoreTests
Assert.True(cleared);
}
+ [Fact]
+ public void TryRecord_ShouldIsolateContractRows_FromQuestRowsWithSameSourceId()
+ {
+ // Arrange
+ var store = new InMemoryRewardDeliveryStore();
+ const string sharedSourceId = "prototype_shared_source_id";
+ var questEvent = new RewardDeliveryEvent(
+ PlayerId,
+ sharedSourceId,
+ DeliveredAt,
+ [new RewardItemGrantApplied("survey_drone_kit", 1)],
+ [],
+ [],
+ RewardDeliveryIds.MakeIdempotencyKey(PlayerId, sharedSourceId));
+ var contractEvent = new RewardDeliveryEvent(
+ PlayerId,
+ QuestId: string.Empty,
+ DeliveredAt,
+ [new RewardItemGrantApplied("scrap_metal_bulk", 5)],
+ [],
+ [],
+ ContractOutcomeIds.MakeIdempotencyKey(PlayerId, sharedSourceId),
+ RewardDeliverySourceKinds.ContractCompletion,
+ sharedSourceId);
+ // Act
+ var questRecorded = store.TryRecord(questEvent);
+ var contractRecorded = store.TryRecord(contractEvent);
+ // Assert
+ Assert.True(questRecorded);
+ Assert.True(contractRecorded);
+ Assert.True(store.TryGet(PlayerId, sharedSourceId, out var storedQuest));
+ Assert.True(
+ store.TryGet(
+ PlayerId,
+ RewardDeliverySourceKinds.ContractCompletion,
+ sharedSourceId,
+ out var storedContract));
+ Assert.Equal(RewardDeliverySourceKinds.QuestCompletion, storedQuest.SourceKind);
+ Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, storedContract.SourceKind);
+ Assert.Equal("survey_drone_kit", storedQuest.GrantedItems[0].ItemId);
+ Assert.Equal("scrap_metal_bulk", storedContract.GrantedItems[0].ItemId);
+ }
+
private static RewardDeliveryEvent CreatePrototypeEvent() =>
new(
PlayerId,
diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs
index 673f377..7333d49 100644
--- a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
@@ -18,6 +19,7 @@ public sealed class RewardRouterOperationsTests
private const string OperatorChainQuestId = "prototype_quest_operator_chain";
private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
+ private const string ContractInstanceId = "ci_test_contract_completion_001";
private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1";
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero);
@@ -653,6 +655,181 @@ public sealed class RewardRouterOperationsTests
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
}
+ [Fact]
+ public async Task TryDeliverContractCompletion_ShouldApplyPrototypeBundle_WhenFirstDelivery()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var deps = ResolveDependencies(factory);
+ var bundle = PrototypeContractCompletionBundle();
+ var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
+ deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
+ var scrapBefore = CountBagItem(inventoryBefore!, "scrap_metal_bulk");
+
+ // Act
+ var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Null(result.ReasonCode);
+ Assert.Single(result.GrantedItems);
+ Assert.Equal("scrap_metal_bulk", result.GrantedItems[0].ItemId);
+ Assert.Equal(5, result.GrantedItems[0].Quantity);
+ Assert.Single(result.GrantedSkillXp);
+ Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId);
+ Assert.Equal(15, result.GrantedSkillXp[0].Amount);
+ Assert.Empty(result.GrantedReputation);
+ Assert.True(
+ deps.DeliveryStore.TryGet(
+ PlayerId,
+ RewardDeliverySourceKinds.ContractCompletion,
+ ContractInstanceId,
+ out var stored));
+ Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, stored.SourceKind);
+ Assert.Equal(ContractInstanceId, stored.ContractInstanceId);
+ Assert.Equal(
+ ContractOutcomeIds.MakeIdempotencyKey(PlayerId, ContractInstanceId),
+ stored.IdempotencyKey);
+ deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
+ Assert.Equal(scrapBefore + 5, CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
+ var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
+ var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
+ Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
+ Assert.Equal(salvageBefore + 15, salvageXp);
+ }
+
+ [Fact]
+ public async Task TryDeliverContractCompletion_ShouldReturnAlreadyDelivered_WhenReplayed()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var deps = ResolveDependencies(factory);
+ var bundle = PrototypeContractCompletionBundle();
+ deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
+ var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
+ var scrapBefore = CountBagItem(inventoryBefore!, "scrap_metal_bulk");
+ var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
+
+ // Act
+ var first = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
+ var second = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
+
+ // Assert
+ Assert.True(first.Success);
+ Assert.Null(first.ReasonCode);
+ Assert.True(second.Success);
+ Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
+ deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
+ Assert.Equal(scrapBefore + 5, CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
+ var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
+ Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
+ Assert.Equal(salvageBefore + 15, salvageAfter);
+ }
+
+ [Fact]
+ public async Task TryDeliverContractCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var deps = ResolveDependencies(factory);
+ var bundle = new QuestRewardBundleRow(
+ [],
+ [],
+ [new ReputationGrantRow("prototype_faction_unknown", 10)]);
+
+ // Act
+ var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
+ Assert.False(
+ deps.DeliveryStore.TryGet(
+ PlayerId,
+ RewardDeliverySourceKinds.ContractCompletion,
+ ContractInstanceId,
+ out _));
+ }
+
+ [Fact]
+ public async Task TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var deps = ResolveDependencies(factory);
+ FillBag(deps);
+ var bundle = PrototypeContractCompletionBundle();
+
+ // Act
+ var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
+ Assert.False(
+ deps.DeliveryStore.TryGet(
+ PlayerId,
+ RewardDeliverySourceKinds.ContractCompletion,
+ ContractInstanceId,
+ out _));
+ }
+
+ [Fact]
+ public async Task TryDeliverContractCompletion_ShouldDenyInvalidContractInstanceId_WhenEmpty()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var deps = ResolveDependencies(factory);
+ var bundle = PrototypeContractCompletionBundle();
+
+ // Act
+ var result = RewardRouterOperations.TryDeliverContractCompletion(
+ PlayerId,
+ " ",
+ bundle,
+ deps.ItemRegistry,
+ deps.InventoryStore,
+ deps.SkillRegistry,
+ deps.SkillProgressionStore,
+ deps.LevelCurve,
+ deps.PerkUnlockEngine,
+ deps.PerkStore,
+ deps.DeliveryStore,
+ deps.StandingStore,
+ deps.AuditStore,
+ TimeProvider.System);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Equal(RewardDeliveryReasonCodes.InvalidContractInstanceId, result.ReasonCode);
+ }
+
+ private static QuestRewardBundleRow PrototypeContractCompletionBundle() =>
+ new(
+ [new RewardGrantRow("scrap_metal_bulk", 5)],
+ [new QuestSkillXpGrantRow("salvage", 15)]);
+
+ private static RewardDeliveryResult DeliverContract(
+ RewardRouterTestDependencies deps,
+ string contractInstanceId,
+ QuestRewardBundleRow? bundle,
+ TimeProvider timeProvider) =>
+ RewardRouterOperations.TryDeliverContractCompletion(
+ PlayerId,
+ contractInstanceId,
+ bundle,
+ deps.ItemRegistry,
+ deps.InventoryStore,
+ deps.SkillRegistry,
+ deps.SkillProgressionStore,
+ deps.LevelCurve,
+ deps.PerkUnlockEngine,
+ deps.PerkStore,
+ deps.DeliveryStore,
+ deps.StandingStore,
+ deps.AuditStore,
+ timeProvider);
+
private static RewardDeliveryResult Deliver(
RewardRouterTestDependencies deps,
string questId,
@@ -755,7 +932,10 @@ public sealed class RewardRouterOperationsTests
return false;
}
- public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
+ public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) =>
+ TryGet(playerId, RewardDeliverySourceKinds.QuestCompletion, questId, out deliveryEvent);
+
+ public bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent)
{
if (!recordAttempted)
{
@@ -768,6 +948,8 @@ public sealed class RewardRouterOperationsTests
}
public bool TryClear(string playerId, string questId) => true;
+
+ public bool TryClear(string playerId, string sourceKind, string sourceId) => true;
}
/// Second forward rep audit append fails; rollback appends still succeed.
diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs
index 873a50d..64b080e 100644
--- a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs
+++ b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs
@@ -4,4 +4,6 @@ namespace NeonSprawl.Server.Game.Factions;
public static class ReputationDeltaSourceKinds
{
public const string QuestCompletion = "quest_completion";
+
+ public const string ContractCompletion = "contract_completion";
}
diff --git a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs
index f7c4860..523faea 100644
--- a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs
@@ -1,16 +1,20 @@
namespace NeonSprawl.Server.Game.Rewards;
-/// Idempotent per-player quest completion reward delivery record (NEO-126).
+/// Idempotent per-player reward delivery records (NEO-126).
public interface IRewardDeliveryStore
{
- /// First record returns true; replays for the same player+quest return false.
+ /// First record returns true; replays for the same player+source return false.
bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
+ bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent);
+
///
/// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset.
/// Idempotent when the row is already absent.
///
bool TryClear(string playerId, string questId);
+
+ bool TryClear(string playerId, string sourceKind, string sourceId);
}
diff --git a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs
index 2ecc621..383abf1 100644
--- a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs
@@ -2,7 +2,7 @@ using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Rewards;
-/// Thread-safe in-memory quest completion reward delivery records (NEO-126).
+/// Thread-safe in-memory reward delivery records (NEO-126).
public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
{
private readonly ConcurrentDictionary eventsByKey = new(StringComparer.Ordinal);
@@ -12,7 +12,7 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
///
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
{
- var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.QuestId);
+ var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent);
if (key.Length == 0)
{
return false;
@@ -36,10 +36,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
}
///
- public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
+ public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) =>
+ TryGet(playerId, RewardDeliverySourceKinds.QuestCompletion, questId, out deliveryEvent);
+
+ ///
+ public bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent)
{
deliveryEvent = default;
- var key = RewardDeliveryIds.MakeStoreKey(playerId, questId);
+ var key = RewardDeliveryIds.MakeStoreKey(playerId, sourceKind, sourceId);
if (key.Length == 0)
{
return false;
@@ -51,10 +55,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
}
}
- /// Dev fixture only — removes one player+quest delivery row (Bruno / test reset).
- public bool TryClear(string playerId, string questId)
+ ///
+ public bool TryClear(string playerId, string questId) =>
+ TryClear(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
+
+ /// Dev fixture only — removes one player+source delivery row (Bruno / test reset).
+ public bool TryClear(string playerId, string sourceKind, string sourceId)
{
- var key = RewardDeliveryIds.MakeStoreKey(playerId, questId);
+ var key = RewardDeliveryIds.MakeStoreKey(playerId, sourceKind, sourceId);
if (key.Length == 0)
{
return false;
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs
index 15c4eeb..a71dc97 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs
@@ -1,9 +1,9 @@
namespace NeonSprawl.Server.Game.Rewards;
///
-/// Idempotent quest completion reward delivery record (NEO-126).
+/// Idempotent reward delivery record (NEO-126).
/// Persisted via after router apply succeeds (NEO-127).
-/// E9.M1 reward_delivery hook site: TryDeliverQuestCompletion (NEO-130).
+/// E9.M1 reward_delivery hook site: (NEO-130).
///
public readonly record struct RewardDeliveryEvent(
string PlayerId,
@@ -12,4 +12,6 @@ public readonly record struct RewardDeliveryEvent(
IReadOnlyList GrantedItems,
IReadOnlyList GrantedSkillXp,
IReadOnlyList GrantedReputation,
- string IdempotencyKey);
+ string IdempotencyKey,
+ string SourceKind = RewardDeliverySourceKinds.QuestCompletion,
+ string ContractInstanceId = "");
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs
index cc018fe..5d50f0e 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs
@@ -1,3 +1,4 @@
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Game.Rewards;
@@ -20,6 +21,10 @@ public static class RewardDeliveryIds
/// Trim + lowercase; empty when input is null/whitespace.
public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId);
+ /// Trim + lowercase; empty when input is null/whitespace.
+ public static string NormalizeContractInstanceId(string? contractInstanceId) =>
+ ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
+
/// Stable idempotency key for quest completion delivery.
public static string MakeIdempotencyKey(string? playerId, string? questId)
{
@@ -49,16 +54,47 @@ public static class RewardDeliveryIds
public static string MakeReputationRollbackDeltaId(string reputationDeltaId) =>
reputationDeltaId.Length == 0 ? string.Empty : $"{reputationDeltaId}:rollback";
- /// Composite store key for + .
- public static string MakeStoreKey(string? playerId, string? questId)
+ /// Composite store key for quest completion delivery.
+ public static string MakeStoreKey(string? playerId, string? questId) =>
+ MakeStoreKey(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
+
+ /// Composite store key for + + source id.
+ public static string MakeStoreKey(string? playerId, string? sourceKind, string? sourceId)
{
var p = NormalizePlayerId(playerId);
- var q = NormalizeQuestId(questId);
- if (p.Length == 0 || q.Length == 0)
+ var kind = NormalizeSourceKind(sourceKind);
+ var id = NormalizeSourceId(sourceKind, sourceId);
+ if (p.Length == 0 || kind.Length == 0 || id.Length == 0)
{
return string.Empty;
}
- return $"{p}\0{q}";
+ return $"{p}\0{kind}\0{id}";
}
+
+ /// Resolves the source id field for a persisted delivery event.
+ public static string GetSourceId(in RewardDeliveryEvent deliveryEvent) =>
+ string.Equals(deliveryEvent.SourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.Ordinal)
+ ? NormalizeContractInstanceId(deliveryEvent.ContractInstanceId)
+ : NormalizeQuestId(deliveryEvent.QuestId);
+
+ /// Composite store key for a persisted delivery event.
+ public static string MakeStoreKey(in RewardDeliveryEvent deliveryEvent) =>
+ MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.SourceKind, GetSourceId(deliveryEvent));
+
+ private static string NormalizeSourceKind(string? sourceKind)
+ {
+ var trimmed = sourceKind?.Trim();
+ if (string.IsNullOrEmpty(trimmed))
+ {
+ return string.Empty;
+ }
+
+ return trimmed.ToLowerInvariant();
+ }
+
+ private static string NormalizeSourceId(string? sourceKind, string? sourceId) =>
+ string.Equals(sourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.OrdinalIgnoreCase)
+ ? NormalizeContractInstanceId(sourceId)
+ : NormalizeQuestId(sourceId);
}
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs
index 9e40cf2..73603f6 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs
@@ -14,6 +14,8 @@ public static class RewardDeliveryReasonCodes
public const string InvalidQuestId = "invalid_quest_id";
+ public const string InvalidContractInstanceId = "invalid_contract_instance_id";
+
public const string InventoryStoreMissing = "inventory_store_missing";
public const string ProgressionStoreMissing = "progression_store_missing";
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs
new file mode 100644
index 0000000..63b52a9
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs
@@ -0,0 +1,9 @@
+namespace NeonSprawl.Server.Game.Rewards;
+
+/// Stable source-kind values for (NEO-148).
+public static class RewardDeliverySourceKinds
+{
+ public const string QuestCompletion = "quest_completion";
+
+ public const string ContractCompletion = "contract_completion";
+}
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs
index c2f989b..9e259b3 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs
@@ -1,3 +1,4 @@
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
@@ -8,7 +9,7 @@ using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Rewards;
///
-/// Applies quest completion grants idempotently (NEO-127).
+/// Applies quest and contract completion grants idempotently (NEO-127, NEO-148).
/// Quest-state wiring: (NEO-128).
/// Reputation grants: (NEO-138).
/// E9.M1 telemetry hook sites: (NEO-130).
@@ -54,7 +55,105 @@ public static class RewardRouterOperations
return Deny(RewardDeliveryReasonCodes.InvalidQuestId);
}
- if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var existingEvent))
+ return TryDeliverBundle(
+ playerId,
+ bundle,
+ itemRegistry,
+ inventoryStore,
+ skillRegistry,
+ skillProgressionStore,
+ levelCurve,
+ perkUnlockEngine,
+ perkStore,
+ deliveryStore,
+ standingStore,
+ auditStore,
+ timeProvider,
+ new DeliveryTarget(
+ normalizedPlayerId,
+ RewardDeliverySourceKinds.QuestCompletion,
+ normalizedQuestId,
+ RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId),
+ ReputationDeltaSourceKinds.QuestCompletion,
+ normalizedQuestId,
+ normalizedQuestId,
+ string.Empty));
+ }
+
+ ///
+ /// Applies item, skill XP, and reputation rows for a contract completion and records
+ /// when grants succeed (NEO-148).
+ ///
+ public static RewardDeliveryResult TryDeliverContractCompletion(
+ string playerId,
+ string contractInstanceId,
+ QuestRewardBundleRow? bundle,
+ IItemDefinitionRegistry itemRegistry,
+ IPlayerInventoryStore inventoryStore,
+ ISkillDefinitionRegistry skillRegistry,
+ IPlayerSkillProgressionStore skillProgressionStore,
+ ISkillLevelCurve levelCurve,
+ PerkUnlockEngine perkUnlockEngine,
+ IPlayerPerkStateStore perkStore,
+ IRewardDeliveryStore deliveryStore,
+ IFactionStandingStore standingStore,
+ IReputationDeltaStore auditStore,
+ TimeProvider timeProvider)
+ {
+ var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId);
+ if (normalizedPlayerId.Length == 0)
+ {
+ return Deny(RewardDeliveryReasonCodes.InvalidPlayerId);
+ }
+
+ var normalizedInstanceId = RewardDeliveryIds.NormalizeContractInstanceId(contractInstanceId);
+ if (normalizedInstanceId.Length == 0)
+ {
+ return Deny(RewardDeliveryReasonCodes.InvalidContractInstanceId);
+ }
+
+ return TryDeliverBundle(
+ playerId,
+ bundle,
+ itemRegistry,
+ inventoryStore,
+ skillRegistry,
+ skillProgressionStore,
+ levelCurve,
+ perkUnlockEngine,
+ perkStore,
+ deliveryStore,
+ standingStore,
+ auditStore,
+ timeProvider,
+ new DeliveryTarget(
+ normalizedPlayerId,
+ RewardDeliverySourceKinds.ContractCompletion,
+ normalizedInstanceId,
+ ContractOutcomeIds.MakeIdempotencyKey(normalizedPlayerId, normalizedInstanceId),
+ ReputationDeltaSourceKinds.ContractCompletion,
+ normalizedInstanceId,
+ string.Empty,
+ normalizedInstanceId));
+ }
+
+ private static RewardDeliveryResult TryDeliverBundle(
+ string playerId,
+ QuestRewardBundleRow? bundle,
+ IItemDefinitionRegistry itemRegistry,
+ IPlayerInventoryStore inventoryStore,
+ ISkillDefinitionRegistry skillRegistry,
+ IPlayerSkillProgressionStore skillProgressionStore,
+ ISkillLevelCurve levelCurve,
+ PerkUnlockEngine perkUnlockEngine,
+ IPlayerPerkStateStore perkStore,
+ IRewardDeliveryStore deliveryStore,
+ IFactionStandingStore standingStore,
+ IReputationDeltaStore auditStore,
+ TimeProvider timeProvider,
+ DeliveryTarget target)
+ {
+ if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var existingEvent))
{
return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
@@ -62,7 +161,6 @@ public static class RewardRouterOperations
var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows;
var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows;
var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows;
- var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId);
if (itemGrants.Count > 0)
{
@@ -128,8 +226,7 @@ public static class RewardRouterOperations
{
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -150,8 +247,7 @@ public static class RewardRouterOperations
{
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -187,14 +283,14 @@ public static class RewardRouterOperations
var appliedReputation = new List(reputationGrants.Count);
foreach (var grant in reputationGrants)
{
- var deltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId);
+ var deltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var repOutcome = ReputationOperations.TryApplyDelta(
playerId,
grant.FactionId,
grant.Amount,
deltaId,
- ReputationDeltaSourceKinds.QuestCompletion,
- normalizedQuestId,
+ target.ReputationSourceKind,
+ target.ReputationSourceId,
standingStore,
auditStore,
timeProvider);
@@ -203,8 +299,7 @@ public static class RewardRouterOperations
{
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -227,13 +322,15 @@ public static class RewardRouterOperations
var deliveredAt = timeProvider.GetUtcNow();
var deliveryEvent = new RewardDeliveryEvent(
- normalizedPlayerId,
- normalizedQuestId,
+ target.NormalizedPlayerId,
+ target.QuestId,
deliveredAt,
appliedItems,
appliedSkillXp,
appliedReputation,
- idempotencyKey);
+ target.IdempotencyKey,
+ target.SourceKind,
+ target.ContractInstanceId);
if (deliveryStore.TryRecord(deliveryEvent))
{
@@ -251,8 +348,7 @@ public static class RewardRouterOperations
// EncounterCompletionOperations when TryMarkCompleted returns false).
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -267,7 +363,7 @@ public static class RewardRouterOperations
auditStore,
timeProvider);
- if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
+ if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var racedEvent))
{
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
@@ -277,8 +373,7 @@ public static class RewardRouterOperations
private static void CompensatingRevertAll(
string playerId,
- string normalizedQuestId,
- string idempotencyKey,
+ DeliveryTarget target,
IReadOnlyList appliedItems,
IReadOnlyList appliedSkillXp,
HashSet perksUnlockedByThisCall,
@@ -295,8 +390,7 @@ public static class RewardRouterOperations
{
CompensatingRevertReputation(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedReputation,
standingStore,
auditStore,
@@ -318,8 +412,7 @@ public static class RewardRouterOperations
private static void CompensatingRevertReputation(
string playerId,
- string normalizedQuestId,
- string idempotencyKey,
+ DeliveryTarget target,
IReadOnlyList grants,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
@@ -333,15 +426,15 @@ public static class RewardRouterOperations
continue;
}
- var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId);
+ var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var rollbackDeltaId = RewardDeliveryIds.MakeReputationRollbackDeltaId(forwardDeltaId);
_ = ReputationOperations.TryApplyDelta(
playerId,
grant.FactionId,
-grant.Amount,
rollbackDeltaId,
- ReputationDeltaSourceKinds.QuestCompletion,
- normalizedQuestId,
+ target.ReputationSourceKind,
+ target.ReputationSourceId,
standingStore,
auditStore,
timeProvider);
@@ -428,4 +521,14 @@ public static class RewardRouterOperations
GrantedReputation: deliveryEvent.GrantedReputation,
DeliveryEvent: deliveryEvent,
DeliveredAt: deliveryEvent.DeliveredAt);
+
+ private readonly record struct DeliveryTarget(
+ string NormalizedPlayerId,
+ string SourceKind,
+ string SourceId,
+ string IdempotencyKey,
+ string ReputationSourceKind,
+ string ReputationSourceId,
+ string QuestId,
+ string ContractInstanceId);
}
diff --git a/server/README.md b/server/README.md
index 5b39d9e..aa9b8d0 100644
--- a/server/README.md
+++ b/server/README.md
@@ -451,39 +451,47 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan:
## Reward delivery store (NEO-126)
-**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**).
+**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+source when completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127, NEO-148). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**).
| Field | Role |
|-------|------|
-| **`PlayerId`**, **`QuestId`** | Normalized keys (trim + lowercase). |
+| **`PlayerId`** | Normalized key (trim + lowercase). |
+| **`SourceKind`** | **`quest_completion`** (NEO-126) or **`contract_completion`** (NEO-148). |
+| **`QuestId`** | Set for quest deliveries; empty for contract deliveries. |
+| **`ContractInstanceId`** | Set for contract deliveries (NEO-148); empty for quest deliveries. |
| **`DeliveredAt`** | UTC timestamp at delivery record commit. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) at commit time. |
| **`GrantedReputation`** | Snapshot of applied reputation rows (`factionId`, `amount`) at commit time (NEO-138). |
-| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. |
+| **`IdempotencyKey`** | Stable dedupe key — quest: **`{playerId}:quest_complete:{questId}`**; contract: **`{playerId}:contract_complete:{contractInstanceId}`**. |
**Store interface:**
-- **`TryRecord`** — first delivery returns `true`; replays for the same player+quest return `false` without overwriting the snapshot.
-- **`TryGet`** — read one delivery event for HTTP **`completionRewardSummary`** projection (NEO-129).
+- **`TryRecord`** — first delivery returns `true`; replays for the same player+source return `false` without overwriting the snapshot.
+- **`TryGet(playerId, questId)`** — quest completion lookup for HTTP **`completionRewardSummary`** projection (NEO-129).
+- **`TryGet(playerId, sourceKind, sourceId)`** — source-kind-aware lookup (contract completion in NEO-148).
-Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest completion rewards use this store only. Postgres persistence deferred. Plan: [NEO-126 implementation plan](../../docs/plans/NEO-126-implementation-plan.md).
+Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest and contract completion rewards use this store. Contract outcome audit rows live separately on **`IContractOutcomeStore`** (NEO-146). Postgres persistence deferred. Plans: [NEO-126](../../docs/plans/NEO-126-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md).
+
+**Store key format (NEO-148):** in-memory rows are keyed by **`{playerId}\0{sourceKind}\0{sourceId}`** (pre-NEO-148 quest-only keys used **`{playerId}\0{questId}`**). Harmless for the in-memory prototype — process restart clears state. When Postgres delivery persistence lands, document or migrate legacy quest rows explicitly so idempotent replay cannot double-grant after upgrade.
## Reward router (NEO-127)
**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows), **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), and **`ReputationOperations.TryApplyDelta`** with **`ReputationDeltaSourceKinds.QuestCompletion`** (reputation rows — NEO-138), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**.
+**`RewardRouterOperations.TryDeliverContractCompletion`** (NEO-148) mirrors the quest path for contract template **`completionRewardBundle`** rows with idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, **`ReputationDeltaSourceKinds.ContractCompletion`**, and **`SourceKind = contract_completion`** on the delivery event.
+
| Reason code | When |
|-------------|------|
-| **`already_delivered`** | Success replay — store already has a delivery for this player+quest (no re-grant). |
-| **`invalid_player_id`**, **`invalid_quest_id`** | Normalized id empty after trim. |
+| **`already_delivered`** | Success replay — store already has a delivery for this player+source (no re-grant). |
+| **`invalid_player_id`**, **`invalid_quest_id`**, **`invalid_contract_instance_id`** | Normalized id empty after trim. |
| **`inventory_store_missing`** | Player inventory store could not read/write. |
| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. |
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
-Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md).
+Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations`** will call **`TryDeliverContractCompletion`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md).
### Reward telemetry hooks (NEO-130)