diff --git a/bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru b/bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru new file mode 100644 index 0000000..f99a661 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (reward delivery store NEO-126) + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-126 registers IRewardDeliveryStore (in-memory prototype). No quest reward delivery HTTP API in this story — use this request to confirm the host started after store DI wiring. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index 262265b..06e8032 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -67,3 +67,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r **NEO-43:** When applying scripted **skill XP** from a mission/quest reward bundle, call **`MissionRewardSkillXpGrant.GrantFromMissionReward`** (`server/NeonSprawl.Server/Game/Skills/`) — fixed **`sourceKind: mission_reward`**, **`skillId`** and **`amount`** from reward data — [NEO-43 implementation plan](../../plans/NEO-43-implementation-plan.md). **NEO-107 (E5.M3 producer hook):** On encounter completion grant commit, **`EncounterCompletionOperations.TryCompleteAndGrant`** records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** (idempotency key **`{playerId}:{encounterId}`**). Future E7.M2 **`QuestRewardBundle`** router consumes this record for **`reward_delivery`** quest credit beyond prototype **`contract_handoff_token`** item loot — comment-only hook stub; no runtime router yet. See [NEO-107 implementation plan](../../plans/NEO-107-implementation-plan.md); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). + +**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Router apply and quest-state wiring land in NEO-127/NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 5d9701e..17aa0be 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M2 | Planned | **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). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Idempotent **`IRewardDeliveryStore`**; wire **`RewardRouterOperations`** on quest complete; extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [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-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | +| E7.M2 | Planned | **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). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Wire **`RewardRouterOperations`** on quest complete; extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [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-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 | --- diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md index a406529..caefe88 100644 --- a/docs/plans/E7M2-prototype-backlog.md +++ b/docs/plans/E7M2-prototype-backlog.md @@ -146,8 +146,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c **Acceptance criteria** -- [ ] `TryRecord` idempotent on same key. -- [ ] Store readable summary for HTTP projection. +- [x] `TryRecord` idempotent on same key. +- [x] Store readable summary for HTTP projection. **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-126-implementation-plan.md b/docs/plans/NEO-126-implementation-plan.md new file mode 100644 index 0000000..eab7e30 --- /dev/null +++ b/docs/plans/NEO-126-implementation-plan.md @@ -0,0 +1,168 @@ +# NEO-126 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-126 | +| **Title** | E7M2-03: Reward delivery store (`IRewardDeliveryStore`) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-126/e7m2-03-reward-delivery-store-irewarddeliverystore | +| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-03** | +| **Branch** | `NEO-126-e7m2-reward-delivery-store` | +| **Precursor** | [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) **`Done`** — bundle schemas + catalog rows · [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) **`Done`** — server catalog load + E7M2 gates | +| **Pattern** | [NEO-107](NEO-107-implementation-plan.md) — `IEncounterCompleteEventStore` / `InMemoryEncounterCompleteEventStore` idempotent `TryRecord` + `TryGet` | +| **Blocks** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) (`RewardRouterOperations` apply path) | +| **Client counterpart** | none — infrastructure-only ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-03--reward-delivery-store-irewarddeliverystore)) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Postgres persistence** | Implement NEO-29-style Postgres split now or in-memory only? | **In-memory only** — mirror `IEncounterCompleteEventStore`; NEO-126 AC is store idempotency + readable summary, not cross-restart durability; Postgres deferred until a story needs persisted delivery rows. | **Adopted** — in-memory only | + +**Additional defaults (no kickoff question — settled by backlog / precedent):** + +- **Idempotency key:** `{normalizedPlayerId}:quest_complete:{normalizedQuestId}` per [E7M2 kickoff decisions](E7M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults) and [E7.M2 module](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). +- **Namespace:** `Game/Rewards/` — delivery is cross-cutting (quest completion v1; encounter consumer later); keeps `Game/Quests/` for state machine only. +- **Grant summary rows:** dedicated `RewardItemGrantApplied` + `RewardSkillXpGrantApplied` on the event (do not reuse `EncounterGrantApplied` — quest bundles include skill XP lines). +- **Store key:** composite `playerId\0questId` via `RewardDeliveryIds.MakeStoreKey` for `TryGet(playerId, questId)`; event carries explicit **`IdempotencyKey`** string for router audit. +- **Dev `TryClear`:** optional on `InMemoryRewardDeliveryStore` only (Bruno / test reset parity with `InMemoryEncounterCompleteEventStore`). + +## Goal, scope, and out-of-scope + +**Goal:** Durable idempotency record for **`RewardDeliveryEvent`** per player + quest completion. + +**In scope (from Linear + [E7M2-03](E7M2-prototype-backlog.md#e7m2-03--reward-delivery-store-irewarddeliverystore)):** + +- `IRewardDeliveryStore`, in-memory implementation. +- Idempotency key **`{playerId}:quest_complete:{questId}`**; store granted item + skill XP summary for audit/read projection. +- Unit tests (AAA): first delivery records; replay returns existing event without double-apply (`TryRecord` → `false`). +- `server/README.md` store section. +- DI registration in `Program.cs`. + +**Out of scope (from Linear):** + +- Router apply logic ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) E7M2-04). +- Quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) E7M2-05). +- HTTP `completionRewardSummary` projection ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06). +- Postgres migration / `PostgresRewardDeliveryStore` (deferred per kickoff answer). +- Godot client. + +## Acceptance criteria checklist + +- [x] `TryRecord` idempotent on same key. +- [x] Store readable summary for HTTP projection. + +## Implementation reconciliation (shipped) + +- **Types:** `RewardDeliveryEvent`, `RewardItemGrantApplied`, `RewardSkillXpGrantApplied`, `RewardDeliveryIds` in `Game/Rewards/`. +- **Store:** `IRewardDeliveryStore` + `InMemoryRewardDeliveryStore` (`TryRecord`, `TryGet`, dev `TryClear`). +- **DI:** `AddRewardDeliveryStore()` in `Program.cs` — in-memory singleton only (Postgres deferred per kickoff). +- **Tests:** 5 AAA tests in `InMemoryRewardDeliveryStoreTests` (idempotency incl. mixed-case replay, fail-closed, grant summary, host DI resolve). +- **Docs:** `server/README.md` reward delivery store section; E7M2-03 backlog AC checked; alignment register updated. +- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru` (host boot smoke; pre-commit gate for `Program.cs`). + +## Technical approach + +### 1. Event + grant summary types (`Game/Rewards/`) + +**`RewardDeliveryEvent`** (readonly record struct): + +| Field | Role | +|-------|------| +| **`PlayerId`** | Normalized player id. | +| **`QuestId`** | Normalized quest id. | +| **`DeliveredAt`** | UTC timestamp at record commit (from router/`TimeProvider` in NEO-127). | +| **`GrantedItems`** | Snapshot of applied item rows (`RewardItemGrantApplied`: `ItemId`, `Quantity`). | +| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`RewardSkillXpGrantApplied`: `SkillId`, `Amount`). | +| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`**. | + +Defensive copy on `TryRecord` (`.ToArray()` on lists) — same as `InMemoryEncounterCompleteEventStore`. + +### 2. `RewardDeliveryIds` + +Static helper (mirror `QuestProgressIds` / `EncounterProgressIds`): + +- `NormalizePlayerId` / `NormalizeQuestId` — trim + lowercase; empty on whitespace. +- `MakeIdempotencyKey(playerId, questId)` → `{p}:quest_complete:{q}` or empty when invalid. +- `MakeStoreKey(playerId, questId)` → `{p}\0{q}` for dictionary lookup. + +### 3. `IRewardDeliveryStore` + +```csharp +bool TryRecord(RewardDeliveryEvent deliveryEvent); +bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); +``` + +First `TryRecord` returns `true`; replays for the same player+quest return `false`. Invalid ids fail closed (`false`). + +### 4. `InMemoryRewardDeliveryStore` + +- `ConcurrentDictionary` + per-key locks — same threading model as `InMemoryEncounterCompleteEventStore`. +- `TryClear(playerId, questId)` on concrete type only (dev fixture). + +### 5. DI + +**`RewardDeliveryServiceCollectionExtensions.AddRewardDeliveryStore`:** + +- `services.AddSingleton();` +- Call from **`Program.cs`** after quest catalog / quest state registration. + +No connection-string branch until Postgres story. + +### 6. Tests (`InMemoryRewardDeliveryStoreTests`) + +Mirror `InMemoryEncounterCompleteEventStoreTests`: + +- **`TryRecord_ShouldBeIdempotent_AtStoreLayer`** — first `true`, second `false`; `TryGet` returns original `DeliveredAt` + grant snapshots. +- **`TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty`** +- **`TryRecord_ShouldFailClosed_WhenQuestIdIsEmpty`** +- **`TryGet_ShouldExposeReadableGrantSummary`** — item + skill XP rows preserved in order for future HTTP mapper (NEO-129). + +Optional host smoke: assert `WebApplicationFactory` resolves `IRewardDeliveryStore` as `InMemoryRewardDeliveryStore` (same pattern as `EncounterDefinitionRegistryTests`). + +### 7. Docs + +- **`server/README.md`** — new **Reward delivery store (NEO-126)** section: interface, event fields, idempotency key, in-memory prototype policy, pointer to NEO-127 router consumer. +- **`documentation_and_implementation_alignment.md`** — E7.M2 row note: delivery store landed (NEO-126). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Rewards/RewardItemGrantApplied.cs` | Item grant snapshot on a delivery event. | +| `server/NeonSprawl.Server/Game/Rewards/RewardSkillXpGrantApplied.cs` | Skill XP grant snapshot on a delivery event. | +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Idempotent delivery payload (quest completion v1). | +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs` | Id normalization, store key, idempotency key helper. | +| `server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs` | Store contract (`TryRecord`, `TryGet`). | +| `server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs` | Thread-safe in-memory implementation + dev `TryClear`. | +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryServiceCollectionExtensions.cs` | DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | AAA idempotency + fail-closed + summary tests. | +| `docs/plans/NEO-126-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Program.cs` | Register `AddRewardDeliveryStore()`. | +| `server/README.md` | Document `IRewardDeliveryStore`, event shape, idempotency key. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — delivery store (NEO-126). | + +## Tests + +| Target | Coverage | +|--------|----------| +| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | Idempotent `TryRecord`; fail-closed empty ids; `TryGet` returns item + skill XP summary suitable for HTTP projection. | +| **Optional** — extend an existing host/DI test file | `IRewardDeliveryStore` resolves as `InMemoryRewardDeliveryStore` from `WebApplicationFactory`. | +| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter InMemoryRewardDeliveryStoreTests`; boot dev server — no behavior change until NEO-127 wires router. | + +## Open questions / risks + +| Question or risk | Agent recommendation | Status | +|------------------|----------------------|--------| +| Postgres deferred vs backlog “optional migration” wording | Kickoff adopted in-memory only; add Postgres when restart durability is required (likely before production or a dedicated persistence story). | **adopted** | +| Encounter deliveries reuse same store | Out of scope — quest completion keys only in NEO-126; encounter path stays on `IEncounterCompleteEventStore` until a follow-on unifies consumers. | **adopted** | +| Empty grant arrays on event | Allow empty lists when bundle has only one grant kind; router (NEO-127) populates from apply outcome. | **adopted** | +| HTTP DTO mapping | NEO-129 maps `GrantedItems` / `GrantedSkillXp` → `completionRewardSummary`; no API changes in NEO-126. | **deferred** | + +None beyond the above. diff --git a/docs/reviews/2026-06-07-NEO-126.md b/docs/reviews/2026-06-07-NEO-126.md new file mode 100644 index 0000000..231753a --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-126.md @@ -0,0 +1,54 @@ +# Code review — NEO-126 (E7M2-03) + +**Date:** 2026-06-07 +**Scope:** Branch `NEO-126-e7m2-reward-delivery-store` vs `origin/main` — commits `b20d9c1` … `2a8fe31` +**Base:** `origin/main` +**Follow-up:** Suggestions below are **done** (strikethrough + **Done.**). + +## Verdict + +**Approve with nits** — store contract, threading model, and tests mirror the NEO-107 encounter-event precedent; plan and backlog AC are satisfied; risk is low (infrastructure-only, no router or HTTP surface yet). + +## Summary + +NEO-126 adds **`IRewardDeliveryStore`** and **`InMemoryRewardDeliveryStore`** under `Game/Rewards/` for idempotent quest-completion reward delivery records. **`RewardDeliveryEvent`** carries item and skill XP grant snapshots plus a stable **`{playerId}:quest_complete:{questId}`** idempotency key; **`RewardDeliveryIds`** handles normalization and composite store keys. DI registers an in-memory singleton via **`AddRewardDeliveryStore()`** in **`Program.cs`**. Five AAA unit tests cover idempotency, fail-closed empty ids, grant summary readability, and host DI resolution; Bruno adds a health smoke for **`Program.cs`** pre-commit. Docs (implementation plan, E7M2-03 backlog AC, alignment register, **`server/README.md`**) are updated. No player-visible behavior until NEO-127 wires the router. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-126-implementation-plan.md` | **Matches** — kickoff (in-memory only), acceptance criteria checked, reconciliation accurate. | +| `docs/plans/E7M2-prototype-backlog.md` (E7M2-03) | **Matches** — AC checkboxes complete; infrastructure-only client counterpart noted. | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-126 implementation anchor added (review follow-up). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-03 delivery store; row correctly stays **Planned** until NEO-132 capstone. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required for this slice. | +| `server/README.md` | **Matches** — reward delivery store section documents interface, event fields, idempotency key, NEO-127/NEO-129 pointers. | +| Full-stack epic decomposition | **N/A** — E7M2-03 is infrastructure-only; no client counterpart required. | + +**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate. + +## Blocking issues + +None. + +## Suggestions + +1. ~~**E7.M2 module anchor** — Add a short **Implementation anchor (server)** bullet for NEO-126 in `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` (parity with the NEO-125 server-load and NEO-107 encounter-store paragraphs) so decomposition readers do not rely only on the alignment register and README.~~ **Done.** NEO-126 paragraph under **Implementation anchor (server)** in `E7_M2_RewardAndUnlockRouter.md`. + +2. ~~**Case-normalization idempotency test** — `MakeStoreKey` lowercases ids, but no test proves a second `TryRecord` with different casing of the same player/quest is rejected. A one-liner extension of `TryRecord_ShouldBeIdempotent_AtStoreLayer` (e.g. replay with `PlayerId = "DEV-LOCAL-1"`) would lock that behavior for NEO-127 replays.~~ **Done.** Third replay with mixed-case ids in `InMemoryRewardDeliveryStoreTests.TryRecord_ShouldBeIdempotent_AtStoreLayer`. + +## Nits + +- Nit: `TryRecord` does not validate that `deliveryEvent.IdempotencyKey` matches `RewardDeliveryIds.MakeIdempotencyKey(...)` — same as `InMemoryEncounterCompleteEventStore`; NEO-127 router should populate both consistently. +- Nit: `TryClear` is untested; acceptable for dev-only API (encounter store also omits a `TryClear` test). +- Nit: Stored `PlayerId` / `QuestId` on the event are not rewritten to normalized form at record time; lookup normalizes via `MakeStoreKey`. Router (NEO-127) should pass normalized ids per plan table. + +## Verification + +```bash +dotnet test server/NeonSprawl.Server.Tests --filter InMemoryRewardDeliveryStoreTests +``` + +Optional before merge: + +- Boot dev server; confirm `/health` 200 after `AddRewardDeliveryStore()` wiring (Bruno: `bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru`). diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs new file mode 100644 index 0000000..8cf000a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Rewards; + +public sealed class InMemoryRewardDeliveryStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string QuestId = "prototype_quest_operator_chain"; + private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryRecord_ShouldBeIdempotent_AtStoreLayer() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + var deliveryEvent = CreatePrototypeEvent(); + // Act + var first = store.TryRecord(deliveryEvent); + var second = store.TryRecord(deliveryEvent with { DeliveredAt = DeliveredAt.AddHours(1) }); + var third = store.TryRecord( + deliveryEvent with + { + PlayerId = "DEV-LOCAL-1", + QuestId = "PROTOTYPE_QUEST_OPERATOR_CHAIN", + }); + // Assert + Assert.True(first); + Assert.False(second); + Assert.False(third); + Assert.True(store.TryGet(PlayerId, QuestId, out var stored)); + Assert.Equal(deliveryEvent.IdempotencyKey, stored.IdempotencyKey); + Assert.Equal(DeliveredAt, stored.DeliveredAt); + Assert.Single(stored.GrantedItems); + Assert.Single(stored.GrantedSkillXp); + } + + [Fact] + public void TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + var invalidEvent = CreatePrototypeEvent() with { PlayerId = " " }; + // Act + var recorded = store.TryRecord(invalidEvent); + // Assert + Assert.False(recorded); + Assert.False(store.TryGet(" ", QuestId, out _)); + } + + [Fact] + public void TryRecord_ShouldFailClosed_WhenQuestIdIsEmpty() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + var invalidEvent = CreatePrototypeEvent() with { QuestId = " " }; + // Act + var recorded = store.TryRecord(invalidEvent); + // Assert + Assert.False(recorded); + Assert.False(store.TryGet(PlayerId, " ", out _)); + } + + [Fact] + public void TryGet_ShouldExposeReadableGrantSummary() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + var deliveryEvent = CreatePrototypeEvent(); + // Act + var recorded = store.TryRecord(deliveryEvent); + var found = store.TryGet(PlayerId, QuestId, out var stored); + // Assert + Assert.True(recorded); + Assert.True(found); + Assert.Equal("survey_drone_kit", stored.GrantedItems[0].ItemId); + Assert.Equal(1, stored.GrantedItems[0].Quantity); + Assert.Equal("salvage", stored.GrantedSkillXp[0].SkillId); + Assert.Equal(50, stored.GrantedSkillXp[0].Amount); + } + + [Fact] + public async Task Host_ShouldResolveRewardDeliveryStoreFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + var deliveryStore = factory.Services.GetRequiredService(); + // Act + var recorded = deliveryStore.TryRecord(CreatePrototypeEvent()); + var found = deliveryStore.TryGet(PlayerId, QuestId, out var stored); + // Assert + Assert.IsType(deliveryStore); + Assert.True(recorded); + Assert.True(found); + Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey); + } + + private static RewardDeliveryEvent CreatePrototypeEvent() => + new( + PlayerId, + QuestId, + DeliveredAt, + [new RewardItemGrantApplied("survey_drone_kit", 1)], + [new RewardSkillXpGrantApplied("salvage", 50)], + RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId)); +} diff --git a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs new file mode 100644 index 0000000..aa8d579 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs @@ -0,0 +1,10 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// Idempotent per-player quest completion reward delivery record (NEO-126). +public interface IRewardDeliveryStore +{ + /// First record returns true; replays for the same player+quest return false. + bool TryRecord(RewardDeliveryEvent deliveryEvent); + + bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); +} diff --git a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs new file mode 100644 index 0000000..c5a6107 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Rewards; + +/// Thread-safe in-memory quest completion reward delivery records (NEO-126). +public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore +{ + private readonly ConcurrentDictionary eventsByKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + /// + public bool TryRecord(RewardDeliveryEvent deliveryEvent) + { + var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.QuestId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + if (eventsByKey.ContainsKey(key)) + { + return false; + } + + eventsByKey[key] = deliveryEvent with + { + GrantedItems = deliveryEvent.GrantedItems.ToArray(), + GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(), + }; + return true; + } + } + + /// + public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) + { + deliveryEvent = default; + var key = RewardDeliveryIds.MakeStoreKey(playerId, questId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + return eventsByKey.TryGetValue(key, out deliveryEvent); + } + } + + /// Dev fixture only — removes one player+quest delivery row (Bruno / test reset). + public bool TryClear(string playerId, string questId) + { + var key = RewardDeliveryIds.MakeStoreKey(playerId, questId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + var removed = eventsByKey.TryRemove(key, out _); + keyLocks.TryRemove(key, out _); + return removed; + } + } +} diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs new file mode 100644 index 0000000..b241a2b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// +/// Idempotent quest completion reward delivery record (NEO-126). +/// Persisted via after router apply succeeds (NEO-127). +/// +public readonly record struct RewardDeliveryEvent( + string PlayerId, + string QuestId, + DateTimeOffset DeliveredAt, + IReadOnlyList GrantedItems, + IReadOnlyList GrantedSkillXp, + string IdempotencyKey); diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs new file mode 100644 index 0000000..665b9fe --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs @@ -0,0 +1,46 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// Id normalization and composite keys for reward delivery stores (NEO-126). +public static class RewardDeliveryIds +{ + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizePlayerId(string? playerId) + { + var trimmed = playerId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + return trimmed.ToLowerInvariant(); + } + + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId); + + /// Stable idempotency key for quest completion delivery. + public static string MakeIdempotencyKey(string? playerId, string? questId) + { + var p = NormalizePlayerId(playerId); + var q = NormalizeQuestId(questId); + if (p.Length == 0 || q.Length == 0) + { + return string.Empty; + } + + return $"{p}:quest_complete:{q}"; + } + + /// Composite store key for + . + public static string MakeStoreKey(string? playerId, string? questId) + { + var p = NormalizePlayerId(playerId); + var q = NormalizeQuestId(questId); + if (p.Length == 0 || q.Length == 0) + { + return string.Empty; + } + + return $"{p}\0{q}"; + } +} diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryServiceCollectionExtensions.cs new file mode 100644 index 0000000..683897d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// Registers quest completion reward delivery persistence (NEO-126). +public static class RewardDeliveryServiceCollectionExtensions +{ + /// Prototype uses in-memory singleton; Postgres deferred until a persistence story lands. + public static IServiceCollection AddRewardDeliveryStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardItemGrantApplied.cs b/server/NeonSprawl.Server/Game/Rewards/RewardItemGrantApplied.cs new file mode 100644 index 0000000..f156679 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardItemGrantApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// One item grant applied on a successful quest completion reward delivery (NEO-126). +public readonly record struct RewardItemGrantApplied(string ItemId, int Quantity); diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardSkillXpGrantApplied.cs b/server/NeonSprawl.Server/Game/Rewards/RewardSkillXpGrantApplied.cs new file mode 100644 index 0000000..8a7cede --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardSkillXpGrantApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// One skill XP grant applied on a successful quest completion reward delivery (NEO-126). +public readonly record struct RewardSkillXpGrantApplied(string SkillId, int Amount); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 9db9831..3aebc75 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -10,6 +10,7 @@ using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Targeting; @@ -30,6 +31,7 @@ builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddPlayerQuestStateStore(builder.Configuration); +builder.Services.AddRewardDeliveryStore(); builder.Services.AddThreatStateStore(); builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddPlayerCombatHealthStore(); diff --git a/server/README.md b/server/README.md index 179a294..5439d16 100644 --- a/server/README.md +++ b/server/README.md @@ -265,6 +265,25 @@ Comment-only hook sites in **`QuestStateOperations`** for future E9.M1 catalog e Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: [NEO-121 implementation plan](../../docs/plans/NEO-121-implementation-plan.md). +## Reward delivery store (NEO-126) + +**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully (router apply lands in NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**). + +| Field | Role | +|-------|------| +| **`PlayerId`**, **`QuestId`** | Normalized keys (trim + lowercase). | +| **`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. | +| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. | + +**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). + +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 definitions (NEO-103) **`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.