diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index 72f6ded..87c5dc1 100644 --- a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md +++ b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M3 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | +| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring landed · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) event record landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | ## Purpose @@ -88,10 +88,12 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin **Progress + completion stores landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** (in-memory) + **`EncounterProgressOperations`** — activation, order-independent defeat tracking, idempotent completion flag; combat/HTTP wiring deferred to NEO-106/108. Plan: [NEO-104 implementation plan](../../plans/NEO-104-implementation-plan.md); [server README — Encounter progress](../../../server/README.md#encounter-progress--completion-stores-neo-104). -**Completion + inventory grants landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations.TryCompleteAndGrant`** + **`EncounterCompleteEvent`** result payload — reward-table fixed grants via **`PlayerInventoryOperations`**, fail-closed inventory deny, idempotent completion mark; event persistence deferred to NEO-107. Plan: [NEO-105 implementation plan](../../plans/NEO-105-implementation-plan.md); [server README — Encounter completion](../../../server/README.md#encounter-completion--inventory-grants-neo-105). +**Completion + inventory grants landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations.TryCompleteAndGrant`** + **`EncounterCompleteEvent`** result payload — reward-table fixed grants via **`PlayerInventoryOperations`**, fail-closed inventory deny, idempotent completion mark; event record persisted via **`IEncounterCompleteEventStore`** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)). Plan: [NEO-105 implementation plan](../../plans/NEO-105-implementation-plan.md); [server README — Encounter completion](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **Combat wiring landed ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring.TryProcessCastOutcome`** on **`AbilityCastApi`** — activate on first damaging hit, mark defeats, invoke completion grants when all required NPCs defeated; NEO-44 gig XP preserved. Per-player progress HTTP deferred to NEO-108. Plan: [NEO-106 implementation plan](../../plans/NEO-106-implementation-plan.md); [server README — Encounter combat wiring](../../../server/README.md#encounter-combat-wiring-neo-106). +**Complete event record landed ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + in-memory implementation — idempotent **`EncounterCompleteEvent`** record on successful grant commit; E7.M2 **`reward_delivery`** hook stub (comments only). Plan: [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). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index 2d26fef..376f5b4 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -48,3 +48,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r ## Implementation anchor (server) **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). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 7599060..0906eb1 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -58,7 +58,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | 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 | In Progress | **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). 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). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **HTTP progress read (NEO-108+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [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 | +| E5.M3 | In Progress | **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). 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). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **HTTP progress read (NEO-108+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [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 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index dc260fd..71f0b22 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../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 ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../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, per-archetype catalog max HP ([NEO-91 plan](../../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–NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)–[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. -**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../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/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **HTTP progress read (NEO-108+) outstanding.** +**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../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/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **NEO-107 landed:** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107 plan](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **HTTP progress read (NEO-108+) outstanding.** ### Epic 6 — PvP Security diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index e3036b2..a0447fd 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -287,9 +287,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **Acceptance criteria** -- [ ] Completion path emits/records event exactly once per player+encounter. -- [ ] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract. -- [ ] No runtime behavior beyond event record + comments. +- [x] Completion path emits/records event exactly once per player+encounter. +- [x] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract. +- [x] No runtime behavior beyond event record + comments. + +**Landed ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + in-memory implementation; record on **`TryCompleteAndGrant`** success; E7.M2 **`reward_delivery`** hook comments. Plan: [NEO-107-implementation-plan.md](NEO-107-implementation-plan.md). --- diff --git a/docs/plans/NEO-107-implementation-plan.md b/docs/plans/NEO-107-implementation-plan.md new file mode 100644 index 0000000..e1050d8 --- /dev/null +++ b/docs/plans/NEO-107-implementation-plan.md @@ -0,0 +1,155 @@ +# NEO-107 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-107 | +| **Title** | E5M3-09: EncounterCompleteEvent + E7.M2 quest-credit hook stub | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-107/e5m3-09-encountercompleteevent-e7m2-quest-credit-hook-stub | +| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-09** | +| **Branch** | `NEO-107-encounter-complete-event-e7m2-hook` | +| **Precursor** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompleteEvent` result payload + `TryCompleteAndGrant` (**landed on `main`**) · [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) — combat wiring invokes completion (**landed on `main`**) | +| **Pattern** | [NEO-104](NEO-104-implementation-plan.md) — split in-memory stores + per-key locks; [NEO-105](NEO-105-implementation-plan.md) — event type + idempotency key already defined | +| **Blocks** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — client encounter HUD (indirect; needs event record for downstream parity) | +| **Client counterpart** | None this story — server-only event record + hook stub; player-visible HUD is [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) | + +## Kickoff clarifications + +**No blocking clarifications needed.** NEO-105 kickoff adopted the split: NEO-105 returns `EncounterCompleteEvent` in `EncounterCompletionResult`; NEO-107 persists the record and expands the E7.M2 hook. E5M3 backlog defaults fix in-memory storage, once-per-player+encounter idempotency, and comment-only E7.M2 behavior (no `QuestRewardBundle` router). NEO-106 wires all completion through `TryCompleteAndGrant` — a single record site preserves atomicity with `TryMarkCompleted`. + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Event store shape** | Dedicated store vs extend `IEncounterCompletionStore`? | **Dedicated `IEncounterCompleteEventStore`** — completion flag is boolean+timestamp; event carries grant summary + idempotency key (NEO-104 split-store precedent). | **Adopted** (backlog + NEO-105) | +| **Record site** | Inside `TryCompleteAndGrant` vs caller after success? | **Inside `TryCompleteAndGrant` after `TryMarkCompleted` succeeds** — same completion commit boundary; NEO-106 / cast path need no duplicate orchestration. | **Adopted** (NEO-105 deferral note) | +| **Storage backend** | In-memory vs Postgres? | **In-memory only** — E5M3 kickoff default (NEO-104). | **Adopted** | +| **E7.M2 runtime** | Stub router call vs comments only? | **Comments only** — document `QuestRewardBundle` / `RewardDeliveryEvent` consumer contract; no quest state machine ([E5M3-09](E5M3-prototype-backlog.md#e5m3-09--encountercompleteevent--e7m2-quest-credit-hook-stub)). | **Adopted** | +| **HTTP / Godot** | Expose event on GET or client? | **Defer** — [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) projects grant summary from stores; this story records only. | **Adopted** (backlog out-of-scope) | + +## Goal, scope, and out-of-scope + +**Goal:** Persist **`EncounterCompleteEvent`** exactly once per player+encounter when completion grants commit, and expand the **E7.M2** quest-credit hook stub documenting the future **`QuestRewardBundle`** consumer contract. + +**In scope (from Linear + [E5M3-09](E5M3-prototype-backlog.md#e5m3-09--encountercompleteevent--e7m2-quest-credit-hook-stub)):** + +- **`IEncounterCompleteEventStore`** + **`InMemoryEncounterCompleteEventStore`** — idempotent **`TryRecord(EncounterCompleteEvent)`** keyed by **`(playerId, encounterId)`** / **`IdempotencyKey`**; **`TryGet`** for tests and NEO-108 read path. +- Wire store into **`EncounterCompletionOperations.TryCompleteAndGrant`** — record on success after completion mark; replay / deny paths do not record. +- Expand **`// TODO(E7.M2)`** hook in completion path with **`reward_delivery`** vocabulary and pointer to [E7_M2_RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **`QuestRewardBundle`** / **`RewardDeliveryEvent`** consumer contract (prototype: quest credit beyond **`contract_handoff_token`** item loot). +- Pass **`IEncounterCompleteEventStore`** through **`EncounterCombatWiring`** → **`AbilityCastApi`** DI chain. +- Unit tests (AAA): first completion records event once; second completion does not duplicate record; deny paths leave store empty. +- `server/README.md` encounter-complete-event section; E5.M3 module snapshot + E7.M2 implementation anchor. + +**Out of scope (from Linear + backlog):** + +- Production **`QuestRewardBundle`** apply, quest state machine, **`RewardDeliveryEvent`** type implementation ([E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) full router). +- **`GET /game/players/{id}/encounter-progress`** projection ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)). +- Telemetry **`encounter_complete`** ingest ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)). +- Godot HUD — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**. +- Postgres persistence, Bruno, `docs/manual-qa/NEO-107.md`. + +## Acceptance criteria checklist + +- [x] Completion path records **`EncounterCompleteEvent`** exactly once per player+encounter. +- [x] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract (comments + module cross-link). +- [x] No runtime behavior beyond event record + comments. + +## Implementation reconciliation (shipped) + +- **Store:** `IEncounterCompleteEventStore` + `InMemoryEncounterCompleteEventStore` — idempotent `TryRecord` / `TryGet` keyed by `(playerId, encounterId)`. +- **Wiring:** `EncounterCompletionOperations.TryCompleteAndGrant` records event after successful completion mark; `EncounterCombatWiring` + `AbilityCastApi` pass store through DI. +- **Hook:** Expanded E7.M2 `reward_delivery` TODO comments; [E7_M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) implementation anchor. +- **Tests:** `InMemoryEncounterCompleteEventStoreTests`; extended `EncounterCompletionOperationsTests`, `EncounterCombatWiringTests`, `EncounterDefinitionRegistryTests`. +- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog + module dependency register updated. + +## Technical approach + +1. **`IEncounterCompleteEventStore`** + - **`bool TryRecord(EncounterCompleteEvent completeEvent)`** — `true` on first insert for normalized **`(playerId, encounterId)`**; `false` if already recorded (idempotent no-op). + - **`bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent)`** — read back stored event for tests / future HTTP. + +2. **`InMemoryEncounterCompleteEventStore`** + - Key: **`EncounterProgressIds.MakeProgressKey(playerId, encounterId)`** (same as progress/completion stores). + - Value: full **`EncounterCompleteEvent`** struct (including **`GrantedItems`** snapshot at record time). + - Thread-safe: **`ConcurrentDictionary`** + per-key locks (mirror **`InMemoryEncounterCompletionStore`**). + +3. **`EncounterCompletionOperations.TryCompleteAndGrant`** + - Add parameter **`IEncounterCompleteEventStore completeEventStore`**. + - After **`TryMarkCompleted`** succeeds and **`EncounterCompleteEvent`** is built: + - Call **`completeEventStore.TryRecord(completeEvent)`**. + - If **`TryRecord`** returns `false` (unexpected after fresh mark — invariant violation / race), **do not rollback** grants or completion flag; treat as idempotent success (event already present for this key). Document in README. + - Replace terse TODO with expanded E7.M2 hook block: + + ```csharp + // TODO(E7.M2): reward_delivery — QuestRewardBundle router consumes EncounterCompleteEvent + // (playerId, encounterId, idempotencyKey, grantedItemSummary) → idempotent RewardDeliveryEvent. + // Prototype quest credit: contract_handoff_token item loot + this event record; full router: E7.M2. + ``` + +4. **DI** + - Register in **`EncounterCatalogServiceCollectionExtensions.AddEncounterAndRewardCatalogs`**: + - **`services.AddSingleton();`** + +5. **Call chain updates** + - **`EncounterCombatWiring.TryProcessCastOutcome`** — resolve **`IEncounterCompleteEventStore`** from deps; pass to **`TryCompleteAndGrant`**. + - **`AbilityCastApi`** — inject **`IEncounterCompleteEventStore`** into wiring call (alongside existing encounter/inventory deps). + +6. **Docs** + - **`server/README.md`** — new subsection under encounter completion: event store, idempotency, E7.M2 deferral. + - **`E5_M3_EncounterAndRewardTables.md`** — implementation snapshot for E5M3-09. + - **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor: **`EncounterCompleteEvent`** producer + future consumer hook. + - Backlog / alignment register / module dependency register — note NEO-107 when landed. + +### Prototype completion → event flow (frozen) + +| Step | Expected | +|------|----------| +| First **`TryCompleteAndGrant`** success | Completion store marked; **`TryRecord`** stores event with scrap ×10 + token ×1 summary | +| Second **`TryCompleteAndGrant`** | Deny **`already_completed`** before record; event store unchanged | +| Inventory deny / **`not_ready`** | No completion mark; event store empty for that encounter | +| Cast path (NEO-106) | Third defeat → same record via **`TryCompleteAndGrant`** | + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs` | Injectable event persistence contract. | +| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs` | Thread-safe in-memory implementation (NEO-107). | +| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs` | AAA store idempotency + TryGet tests. | +| `docs/plans/NEO-107-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Accept event store; **`TryRecord`** on success; expand E7.M2 hook comments. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs` | Update XML — persistence landed on NEO-107. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Pass **`IEncounterCompleteEventStore`** into **`TryCompleteAndGrant`**. | +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject event store for wiring call. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | Register **`IEncounterCompleteEventStore`** singleton. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Assert event recorded once; replay / deny paths leave store empty; update **`ResolveDependencies`**. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs` | Assert event store populated after third-defeat wiring completion. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs` | Host DI resolves **`IEncounterCompleteEventStore`** (if not already covered). | +| `server/README.md` | Document event store + E7.M2 hook stub; remove NEO-107 deferral notes. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — E5M3-09 event persistence. | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Implementation anchor — **`EncounterCompleteEvent`** producer hook. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-107 event record when landed. | +| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-107 event store when landed. | +| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-09 AC when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `InMemoryEncounterCompleteEventStoreTests.cs` | **Arrange:** prototype **`EncounterCompleteEvent`**. **Act/Assert:** first **`TryRecord`** → `true` + **`TryGet`** round-trip; second **`TryRecord`** same key → `false`; empty key / invalid ids fail closed. | +| `EncounterCompletionOperationsTests.cs` | **Extend existing grant-once test:** assert **`TryGet`** returns event matching **`CompleteEvent`**; **`already_completed`** / **`not_ready`** / **`inventory_full`** paths assert store still empty (or unchanged count). | +| `EncounterCombatWiringTests.cs` | **Extend third-defeat completion test:** assert event store has record after wiring success. | +| `EncounterDefinitionRegistryTests.cs` | **Assert:** host startup resolves **`IEncounterCompleteEventStore`** as **`InMemoryEncounterCompleteEventStore`**. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Record after mark ordering** | Record only after **`TryMarkCompleted`** succeeds — event mirrors committed completion. | **adopted** | +| **TryRecord false after fresh mark** | Treat as idempotent (event already there); no grant rollback — same key invariant as completion store. | **adopted** | +| **NEO-108 grant summary** | HTTP read may use event store **`GrantedItems`** or completion + inventory delta — defer projection design to NEO-108; store exposes **`TryGet`**. | **deferred** | +| **Postgres follow-on** | Defer — session persistence migration is a future issue. | **deferred** | diff --git a/docs/reviews/2026-05-31-NEO-107.md b/docs/reviews/2026-05-31-NEO-107.md new file mode 100644 index 0000000..74d86e9 --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-107.md @@ -0,0 +1,56 @@ +# Code review — NEO-107 (E5M3-09) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-107-encounter-complete-event-e7m2-hook` vs `856dc8d` (merge-base on `main`) — commits `986cf55` … `555c323` +**Base:** `856dc8d` (main at branch point, post NEO-106 merge) + +## Verdict + +**Approve with nits** + +## Summary + +NEO-107 adds **`IEncounterCompleteEventStore`** and **`InMemoryEncounterCompleteEventStore`** — a dedicated, idempotent per-player+encounter record for **`EncounterCompleteEvent`**, mirroring the NEO-104 split-store pattern (ConcurrentDictionary + per-key locks). **`EncounterCompletionOperations.TryCompleteAndGrant`** records the event immediately after a successful **`TryMarkCompleted`**, with expanded E7.M2 **`reward_delivery`** hook comments and no runtime router behavior. The store is registered in **`AddEncounterAndRewardCatalogs`** and threaded through **`EncounterCombatWiring`** → **`AbilityCastApi`**. Tests cover store idempotency, all completion deny paths (store stays empty), wiring third-defeat success, and host DI resolution. Docs (plan, backlog E5M3-09, E5.M3 snapshot, E7.M2 anchor, alignment register, module register, `server/README.md`) are updated. HTTP projection and Godot HUD remain correctly deferred to NEO-108 / NEO-110. Risk is low: append-only record on an already-idempotent completion path; **`TryRecord`** false-after-fresh-mark is documented as benign idempotent success. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-107-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; frozen completion→event flow table reflected in code and tests. | +| `docs/plans/E5M3-prototype-backlog.md` (E5M3-09) | **Matches** — acceptance criteria checked; landed note cites plan. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — implementation snapshot bullet for NEO-107 added; summary **Status** row includes **E5M3-09 / NEO-107 landed**; NEO-105 bullet cross-links NEO-107 persistence (review follow-up). | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-107 producer hook anchor added with correct consumer contract vocabulary. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-107** event record. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-107** event store landed. | +| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110** via NEO-108; no prototype slice completion claim. | +| `server/README.md` | **Matches** — new **Encounter complete event record (NEO-107)** section is accurate; NEO-105 section updated to reflect persistence. | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**E5.M3 summary Status row** — Update the module summary table (line 10) to include **E5M3-09 / NEO-107** alongside the other landed slices so the header matches the implementation snapshot bullets below.~~ **Done.** Status row updated in `E5_M3_EncounterAndRewardTables.md`. + +2. ~~**Stale NEO-105 deferral line** — In `E5_M3_EncounterAndRewardTables.md`, the NEO-105 bullet still reads “event persistence deferred to NEO-107.” Reword to point at the NEO-107 snapshot (or drop the deferral phrase) now that persistence is landed.~~ **Done.** NEO-105 bullet now cross-links **`IEncounterCompleteEventStore`** (NEO-107). + +3. ~~**Empty encounter id fail-closed test** — Plan test table lists “empty key / invalid ids fail closed.” `InMemoryEncounterCompleteEventStoreTests` covers whitespace **`PlayerId`** only; a symmetric case with empty **`EncounterId`** would close the gap (store layer only).~~ **Done.** Added `TryRecord_ShouldFailClosed_WhenEncounterIdIsEmpty`; split empty **`PlayerId`** into its own test. + +## Nits + +- ~~Nit: **`InMemoryEncounterCompleteEventStoreTests`** — both test methods call **`TryGet`** under **Act**; move verification reads to **Assert** for full AAA per `csharp-style.md`.~~ **Done.** **`TryGet`** moved to **Assert** in all store tests. + +- Nit: **`EncounterDefinitionRegistryTests`** resolves **`IEncounterCompleteEventStore`** in **Arrange** but only asserts **`IsType`** — sufficient for DI smoke; no functional **`TryRecord`** assertion needed unless you want parity with progress/completion store tests. + +## Verification + +```bash +# NEO-107-focused (73 tests — passed locally) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" + +# CI parity +python3 scripts/validate_content.py +``` + +**Local run:** 73/73 `Encounters` filter tests passed. diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs index 16fb3bb..deeda5d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -31,6 +31,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -59,6 +60,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -91,6 +93,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -98,6 +101,8 @@ public sealed class EncounterCombatWiringTests // Assert deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.True(deps.CompleteEventStore.TryGet(PlayerId, EncounterId, out var storedEvent)); + Assert.Equal($"{PlayerId}:{EncounterId}", storedEvent.IdempotencyKey); Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk")); Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token")); } @@ -119,6 +124,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -142,6 +148,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -157,6 +164,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -186,6 +194,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -224,6 +233,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -259,6 +269,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -278,6 +289,7 @@ public sealed class EncounterCombatWiringTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.CompleteEventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -326,6 +338,7 @@ public sealed class EncounterCombatWiringTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); } @@ -349,6 +362,7 @@ public sealed class EncounterCombatWiringTests IRewardTableDefinitionRegistry RewardTableRegistry, IEncounterProgressStore ProgressStore, IEncounterCompletionStore CompletionStore, + IEncounterCompleteEventStore CompleteEventStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore); } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 70a847a..8d3183c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -31,6 +31,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.EventStore, deps.ItemRegistry, deps.InventoryStore, timeProvider); @@ -49,6 +50,9 @@ public sealed class EncounterCompletionOperationsTests Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey); Assert.NotNull(result.CompletedAt); Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent)); + Assert.Equal(result.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey); + Assert.Equal(2, storedEvent.GrantedItems.Count); Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk")); Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token")); } @@ -68,6 +72,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.EventStore, deps.ItemRegistry, deps.InventoryStore, timeProvider); @@ -81,6 +86,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.EventStore, deps.ItemRegistry, deps.InventoryStore, timeProvider); @@ -98,6 +104,8 @@ public sealed class EncounterCompletionOperationsTests Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token")); Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk")); Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token")); + Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent)); + Assert.Equal(first.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey); } [Fact] @@ -134,6 +142,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.EventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -143,6 +152,7 @@ public sealed class EncounterCompletionOperationsTests Assert.False(result.Success); Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); } @@ -164,6 +174,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.EventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -173,6 +184,7 @@ public sealed class EncounterCompletionOperationsTests Assert.False(result.Success); Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk")); Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token")); @@ -196,6 +208,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, failingCompletionStore, + deps.EventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -207,6 +220,7 @@ public sealed class EncounterCompletionOperationsTests Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk")); Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token")); + Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); } [Fact] @@ -226,6 +240,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.EventStore, deps.ItemRegistry, inventoryStore, TimeProvider.System); @@ -237,6 +252,7 @@ public sealed class EncounterCompletionOperationsTests Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token")); + Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); } [Fact] @@ -254,6 +270,7 @@ public sealed class EncounterCompletionOperationsTests deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, + deps.EventStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); @@ -261,6 +278,7 @@ public sealed class EncounterCompletionOperationsTests // Assert Assert.False(result.Success); Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode); + Assert.False(deps.EventStore.TryGet(PlayerId, "unknown_encounter", out _)); } private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps) @@ -313,6 +331,7 @@ public sealed class EncounterCompletionOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); } @@ -350,6 +369,7 @@ public sealed class EncounterCompletionOperationsTests IRewardTableDefinitionRegistry RewardTableRegistry, IEncounterProgressStore ProgressStore, IEncounterCompletionStore CompletionStore, + IEncounterCompleteEventStore EventStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore); diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs index 9af8db9..32a9add 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs @@ -212,6 +212,7 @@ public class EncounterDefinitionRegistryTests _ = await client.GetAsync("/health"); var progressStore = factory.Services.GetRequiredService(); var completionStore = factory.Services.GetRequiredService(); + var completeEventStore = factory.Services.GetRequiredService(); // Act var activated = progressStore.TryActivate("dev-local-1", PrototypeCombatPocket); var completed = completionStore.TryMarkCompleted( @@ -222,5 +223,6 @@ public class EncounterDefinitionRegistryTests Assert.True(activated); Assert.True(completed); Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket)); + Assert.IsType(completeEventStore); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs new file mode 100644 index 0000000..8db7ede --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs @@ -0,0 +1,67 @@ +using NeonSprawl.Server.Game.Encounters; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class InMemoryEncounterCompleteEventStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string EncounterId = "prototype_combat_pocket"; + private static readonly DateTimeOffset CompletedAt = new(2026, 5, 31, 20, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryRecord_ShouldBeIdempotent_AtStoreLayer() + { + // Arrange + var store = new InMemoryEncounterCompleteEventStore(); + var completeEvent = CreatePrototypeEvent(); + // Act + var first = store.TryRecord(completeEvent); + var second = store.TryRecord(completeEvent with { CompletedAt = CompletedAt.AddHours(1) }); + // Assert + Assert.True(first); + Assert.False(second); + Assert.True(store.TryGet(PlayerId, EncounterId, out var stored)); + Assert.Equal(completeEvent.IdempotencyKey, stored.IdempotencyKey); + Assert.Equal(CompletedAt, stored.CompletedAt); + Assert.Equal(2, stored.GrantedItems.Count); + } + + [Fact] + public void TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty() + { + // Arrange + var store = new InMemoryEncounterCompleteEventStore(); + var invalidEvent = CreatePrototypeEvent() with { PlayerId = " " }; + // Act + var recorded = store.TryRecord(invalidEvent); + // Assert + Assert.False(recorded); + Assert.False(store.TryGet(" ", EncounterId, out _)); + } + + [Fact] + public void TryRecord_ShouldFailClosed_WhenEncounterIdIsEmpty() + { + // Arrange + var store = new InMemoryEncounterCompleteEventStore(); + var invalidEvent = CreatePrototypeEvent() with { EncounterId = " " }; + // Act + var recorded = store.TryRecord(invalidEvent); + // Assert + Assert.False(recorded); + Assert.False(store.TryGet(PlayerId, " ", out _)); + } + + private static EncounterCompleteEvent CreatePrototypeEvent() => + new( + PlayerId, + EncounterId, + "prototype_combat_pocket_clear", + [ + new EncounterGrantApplied("scrap_metal_bulk", 10), + new EncounterGrantApplied("contract_handoff_token", 1), + ], + CompletedAt, + $"{PlayerId}:{EncounterId}"); +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 7d3f255..2765880 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -75,6 +75,7 @@ public static class AbilityCastApi IRewardTableDefinitionRegistry rewardTableRegistry, IEncounterProgressStore encounterProgressStore, IEncounterCompletionStore encounterCompletionStore, + IEncounterCompleteEventStore encounterCompleteEventStore, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, ILoggerFactory loggerFactory, @@ -273,6 +274,7 @@ public static class AbilityCastApi rewardTableRegistry, encounterProgressStore, encounterCompletionStore, + encounterCompleteEventStore, itemRegistry, inventoryStore, clock, diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs index 9c3fdaf..9a6fbe0 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs @@ -78,6 +78,7 @@ public static class EncounterCatalogServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs index e9575ac..3072c58 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -20,6 +20,7 @@ public static class EncounterCombatWiring IRewardTableDefinitionRegistry rewardTableRegistry, IEncounterProgressStore progressStore, IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, TimeProvider timeProvider, @@ -75,6 +76,7 @@ public static class EncounterCombatWiring rewardTableRegistry, progressStore, completionStore, + completeEventStore, itemRegistry, inventoryStore, timeProvider); diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs index e3a18ac..3b4e238 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs @@ -2,7 +2,8 @@ namespace NeonSprawl.Server.Game.Encounters; /// /// Internal completion payload returned on successful encounter grant (NEO-105). -/// Persistence and E7.M2 quest-credit routing land in NEO-107. +/// Persisted via on success (NEO-107). +/// E7.M2 quest-credit routing consumes this record (hook stub on NEO-107). /// public readonly record struct EncounterCompleteEvent( string PlayerId, diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs index 01ed842..7bb2136 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -21,6 +21,7 @@ public static class EncounterCompletionOperations IRewardTableDefinitionRegistry rewardTableRegistry, IEncounterProgressStore progressStore, IEncounterCompletionStore completionStore, + IEncounterCompleteEventStore completeEventStore, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, TimeProvider timeProvider) @@ -107,9 +108,6 @@ public static class EncounterCompletionOperations return Deny(EncounterCompletionReasonCodes.AlreadyCompleted); } - // TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook). - // TODO(E9.M1): catalog emit — encounter_complete on successful grant commit (NEO-109). - var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId); var completeEvent = new EncounterCompleteEvent( normalizedPlayerId, @@ -119,6 +117,13 @@ public static class EncounterCompletionOperations completedAt, MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId)); + _ = completeEventStore.TryRecord(completeEvent); + + // TODO(E7.M2): reward_delivery — QuestRewardBundle router consumes EncounterCompleteEvent + // (playerId, encounterId, idempotencyKey, grantedItemSummary) → idempotent RewardDeliveryEvent. + // Prototype quest credit: contract_handoff_token item loot + this event record; full router: E7.M2. + // TODO(E9.M1): catalog emit — encounter_complete on successful grant commit (NEO-109). + return new EncounterCompletionResult( Success: true, ReasonCode: null, diff --git a/server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs b/server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs new file mode 100644 index 0000000..a7f8b27 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs @@ -0,0 +1,10 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Idempotent per-player encounter completion event record (NEO-107). +public interface IEncounterCompleteEventStore +{ + /// First record returns true; replays for the same player+encounter return false. + bool TryRecord(EncounterCompleteEvent completeEvent); + + bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs new file mode 100644 index 0000000..bd7236d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs @@ -0,0 +1,51 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Thread-safe in-memory encounter completion event records (NEO-107). +public sealed class InMemoryEncounterCompleteEventStore : IEncounterCompleteEventStore +{ + private readonly ConcurrentDictionary eventsByKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + /// + public bool TryRecord(EncounterCompleteEvent completeEvent) + { + var key = EncounterProgressIds.MakeProgressKey(completeEvent.PlayerId, completeEvent.EncounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + if (eventsByKey.ContainsKey(key)) + { + return false; + } + + eventsByKey[key] = completeEvent with + { + GrantedItems = completeEvent.GrantedItems.ToArray(), + }; + return true; + } + } + + /// + public bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent) + { + completeEvent = default; + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + return eventsByKey.TryGetValue(key, out completeEvent); + } + } +} diff --git a/server/README.md b/server/README.md index 4a05c76..0f3d515 100644 --- a/server/README.md +++ b/server/README.md @@ -157,7 +157,7 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act ## Encounter completion + inventory grants (NEO-105) -**`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward table’s **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. Returns internal **`EncounterCompleteEvent`** on success (persistence + E7.M2 hook: NEO-107). +**`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward table’s **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. On success, records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** and returns the same payload in **`EncounterCompletionResult`**. | Reason code | When | |-------------|------| @@ -170,6 +170,22 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** from **`prototype_combat_pocket_clear`**. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Wired from **`AbilityCastApi`** via **`EncounterCombatWiring`** (NEO-106). Plan: [NEO-105 implementation plan](../../docs/plans/NEO-105-implementation-plan.md). +## Encounter complete event record (NEO-107) + +**`IEncounterCompleteEventStore`** persists **`EncounterCompleteEvent`** exactly once per player+encounter when grants commit successfully. Prototype uses **`InMemoryEncounterCompleteEventStore`** (registered in **`AddEncounterAndRewardCatalogs`** alongside progress/completion stores). + +| Field | Role | +|-------|------| +| **`PlayerId`**, **`EncounterId`** | Normalized keys (same as progress/completion stores). | +| **`RewardTableId`** | Catalog reward table applied. | +| **`GrantedItems`** | Snapshot of successful fixed grants at commit time. | +| **`CompletedAt`** | UTC timestamp from **`TimeProvider`** at completion mark. | +| **`IdempotencyKey`** | Stable **`{playerId}:{encounterId}`** — future **`RewardDeliveryEvent`** dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md). | + +**E7.M2 hook (comment-only):** after **`TryRecord`**, **`EncounterCompletionOperations`** documents the future **`QuestRewardBundle`** / **`reward_delivery`** consumer — prototype quest credit is **`contract_handoff_token`** item loot plus this event record; full router deferred to E7.M2. If **`TryRecord`** returns `false` after a fresh completion mark (unexpected race), grants and completion flag are retained; treat as idempotent success. + +Plan: [NEO-107 implementation plan](../../docs/plans/NEO-107-implementation-plan.md). + ## Encounter combat wiring (NEO-106) On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring.TryProcessCastOutcome`** (best-effort; does not change cast JSON):