diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index a67f963..a5a0c6b 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) → **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) → **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 @@ -86,6 +86,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin **HTTP read model landed ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + DTOs in `Game/Encounters/`; nested **`rewardTable`** summary via **`IRewardTableDefinitionRegistry`**. Plan: [NEO-103 implementation plan](../../plans/NEO-103-implementation-plan.md); [server README — Encounter definitions](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. +**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). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 7f4270b..37a4f73 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/`. 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)). **Runtime/engine (NEO-104+) not started.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [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). 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)). **Grants + combat/HTTP wire (NEO-105+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [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 94b94cf..a176cb3 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/`. **Runtime/engine (NEO-104+) not started.** +**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). **Grants + combat/HTTP wire (NEO-105+) outstanding.** ### Epic 6 — PvP Security diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index 88ae71b..bc77183 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -185,9 +185,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **Acceptance criteria** -- [ ] Defeating required NPCs accumulates progress in any order. -- [ ] Completion predicate true only when all required ids defeated. -- [ ] Second completion attempt on same player+encounter is idempotent no-op at store layer. +- [x] Defeating required NPCs accumulates progress in any order. +- [x] Completion predicate true only when all required ids defeated. +- [x] Second completion attempt on same player+encounter is idempotent no-op at store layer. + +**Landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** `IEncounterProgressStore` + `IEncounterCompletionStore` + `EncounterProgressOperations`; in-memory DI; AAA unit tests; plan [NEO-104-implementation-plan.md](NEO-104-implementation-plan.md). --- diff --git a/docs/plans/NEO-104-implementation-plan.md b/docs/plans/NEO-104-implementation-plan.md new file mode 100644 index 0000000..f50c538 --- /dev/null +++ b/docs/plans/NEO-104-implementation-plan.md @@ -0,0 +1,155 @@ +# NEO-104 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-104 | +| **Title** | E5M3-05: Encounter progress + completion stores | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-104/e5m3-05-encounter-progress-completion-stores | +| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-05** | +| **Branch** | `NEO-104-encounter-progress-completion-stores` | +| **Precursor** | [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) — `GET /game/world/encounter-definitions` (**landed on `main`**) | +| **Pattern** | [NEO-61](NEO-61-implementation-plan.md) / `InMemoryResourceNodeInstanceStore` — keyed in-memory store + static operations; [NEO-44](NEO-44-implementation-plan.md) — gig progression store DI | +| **Blocks** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompletionOperations` + inventory reward apply | +| **Client counterpart** | None this story — player-visible encounter HUD starts at [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) `GET /game/players/{id}/encounter-progress` | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Storage backend** | In-memory only vs Postgres when configured? | **In-memory only** — E5M3 kickoff default; session prototype. | **Adopted** — in-memory only | +| **Progress vs completion** | Single store vs split? | **Split stores** — progress: `started` + `defeatedNpcInstanceIds`; completion: idempotent `completedAt`; operations expose `IsAllRequiredTargetsDefeated`. | **Adopted** — split stores | +| **Encounter binding** | Explicit `encounterId` vs auto-resolve from NPC? | **Auto-resolve** — scan `IEncounterDefinitionRegistry` for encounter whose `requiredNpcInstanceIds` contains the NPC. | **Adopted** — auto-resolve from `npcInstanceId` | +| **Combat / HTTP wiring** | Wire `AbilityCastApi` or HTTP on this story? | **Defer** — E5M3-07 / E5M3-08; NEO-104 ships stores + operations + unit tests only. | **Adopted** (implicit — not asked; backlog out-of-scope) | +| **Activation before defeat** | Auto-activate on `TryMarkTargetDefeated` vs require prior `TryActivateOnFirstEngagement`? | **Require activation** — `TryMarkTargetDefeated` no-ops when not started; activation is separate (NEO-106 wires first damaging cast). | **Adopted** (plan default; backlog activation hook) | + +## Goal, scope, and out-of-scope + +**Goal:** Server-owned per-player encounter **activation**, **defeat progress** (order-independent subset of `requiredNpcInstanceIds`), and an **idempotent completion flag** at the store layer — plus **`EncounterProgressOperations`** that resolve encounters from NPC ids via **`IEncounterDefinitionRegistry`**. + +**In scope (from Linear + [E5M3-05](E5M3-prototype-backlog.md#e5m3-05--encounter-progress--completion-stores)):** + +- **`IEncounterProgressStore`** — keyed by **`(playerId, encounterId)`**; fields: **`started`** (bool), **`defeatedNpcInstanceIds`** (set, normalized lowercase). +- **`IEncounterCompletionStore`** — **`TryMarkCompleted`**, **`IsCompleted`**, **`TryGetCompletedAt`**; second **`TryMarkCompleted`** is no-op (`false`). +- **`InMemoryEncounterProgressStore`**, **`InMemoryEncounterCompletionStore`** — thread-safe `ConcurrentDictionary` + per-key locks (mirror gathering/NPC stores). +- **`EncounterProgressOperations`**: + - **`TryActivateOnFirstEngagement(playerId, npcInstanceId, …)`** — resolve encounter; set `started` if not started and not completed. + - **`TryMarkTargetDefeated(playerId, npcInstanceId, …)`** — requires started + not completed; add NPC to defeated set if in encounter’s required list; idempotent re-mark of same NPC returns success without duplicate progress. + - **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — compares defeated set to registry `requiredNpcInstanceIds` (set equality). +- DI registration in **`EncounterCatalogServiceCollectionExtensions`** or new **`EncounterProgressServiceCollectionExtensions`**. +- **`InMemoryWebApplicationFactory`** — register in-memory encounter stores (remove any Postgres placeholder if added later). +- Unit tests (AAA) with injected fake stores / registry. +- `server/README.md` encounter progress section (brief). + +**Out of scope (from Linear + backlog):** + +- Inventory grants, **`EncounterCompleteEvent`**, **`EncounterCompletionOperations`** ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)). +- Combat defeat / ability-cast wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)). +- HTTP **`GET /game/players/{id}/encounter-progress`** ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)). +- Postgres persistence, Godot, Bruno, `docs/manual-qa/NEO-104.md`. + +## Acceptance criteria checklist + +- [x] Defeating required NPCs accumulates progress in any order (via `TryMarkTargetDefeated` after activation). +- [x] Completion predicate true only when all required ids defeated (`IsAllRequiredTargetsDefeated`). +- [x] Second completion attempt on same player+encounter is idempotent no-op at store layer (`TryMarkCompleted` returns `false`). + +## Implementation reconciliation (shipped) + +- **Stores:** `IEncounterProgressStore` / `InMemoryEncounterProgressStore`; `IEncounterCompletionStore` / `InMemoryEncounterCompletionStore`; `EncounterProgressIds` normalization. +- **Operations:** `EncounterProgressOperations` — `TryResolveEncounterForNpc`, `TryActivateOnFirstEngagement`, `TryMarkTargetDefeated`, `IsAllRequiredTargetsDefeated`. +- **DI:** registered in `AddEncounterAndRewardCatalogs`. +- **Tests:** `InMemoryEncounterProgressStoreTests`, `InMemoryEncounterCompletionStoreTests`, `EncounterProgressOperationsTests`; host DI resolve in `EncounterDefinitionRegistryTests`. +- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated. +- **Plan file list:** `Program.cs` and `InMemoryWebApplicationFactory.cs` unchanged — stores register in existing `AddEncounterAndRewardCatalogs` (already invoked at startup); always in-memory (no Postgres test-factory swap). + +## Technical approach + +1. **Snapshots / keys** + - **`EncounterProgressSnapshot`**: `PlayerId`, `EncounterId`, `Started`, `DefeatedNpcInstanceIds` (read-only set). + - **`EncounterPlayerEncounterKey`**: normalize `playerId` + `encounterId` with trim + lowercase (match registry / position-state id normalization). + - NPC ids normalized the same way when stored and compared. + +2. **`IEncounterProgressStore`** + - `TryGetProgress(playerId, encounterId, out snapshot)` — missing row ⇒ not started, empty defeated set. + - `TryActivate(playerId, encounterId)` — creates row with `started: true`, empty defeated set; returns `true` on first activation, `false` if already started (idempotent) or if completion store says completed. + - `TryAddDefeatedTarget(playerId, encounterId, npcInstanceId)` — requires existing started row; add to set; return `true` if newly added, `false` if duplicate NPC or not started. + +3. **`IEncounterCompletionStore`** + - `bool IsCompleted(playerId, encounterId)` + - `bool TryMarkCompleted(playerId, encounterId, DateTimeOffset completedAt)` — `true` first time, `false` if already completed. + - `bool TryGetCompletedAt(…, out DateTimeOffset completedAt)` + +4. **`EncounterProgressOperations`** (static, mirror `GatherOperations` / `AggroOperations`) + - **`TryResolveEncounterForNpc(npcInstanceId, registry, out encounterId, out definition)`** — scan `GetDefinitionsInIdOrder()` for first encounter where `requiredNpcInstanceIds` contains normalized NPC (prototype: single `prototype_combat_pocket`). + - **`TryActivateOnFirstEngagement`**: resolve encounter → check completion store → `TryActivate` on progress store. + - **`TryMarkTargetDefeated`**: resolve encounter → deny if completed → deny if not started → validate NPC in required list → `TryAddDefeatedTarget`. + - **`IsAllRequiredTargetsDefeated`**: load progress + definition; compare required set to defeated set (order-independent). + - Inject **`TimeProvider`** only where completion store needs timestamps (completion store accepts `completedAt` from caller; NEO-105 passes `TimeProvider.GetUtcNow()`). + +5. **DI** + - `services.AddSingleton();` + - `services.AddSingleton();` + - Extend **`AddEncounterAndRewardCatalogs`** or add **`AddEncounterProgressStores`** called from **`Program.cs`** after encounter catalogs. + +6. **Reserved hook** + - XML comment on **`TryActivateOnFirstEngagement`**: `// TODO(E9.M1): encounter_start telemetry` (activates E5M3-07 hook site). + +7. **Docs** + - `server/README.md` — stores + operations, note combat/HTTP deferred. + - Update [E5_M3](E5_M3_EncounterAndRewardTables.md) implementation snapshot when landed. + +### Prototype encounter behavior (frozen) + +| Step | Expected | +|------|----------| +| Activate | `TryActivateOnFirstEngagement(devPlayer, prototype_npc_melee)` → started, 0/3 defeated | +| Defeat 2 | Mark melee + ranged → `IsAllRequiredTargetsDefeated` false | +| Defeat 3 | Mark elite → `IsAllRequiredTargetsDefeated` true | +| Complete store | `TryMarkCompleted` once → true; second → false | +| Order | elite → melee → ranged also reaches all-defeated | + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressSnapshot.cs` | Immutable progress row for store read model. | +| `server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs` | Progress store contract. | +| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs` | Thread-safe in-memory implementation. | +| `server/NeonSprawl.Server/Game/Encounters/IEncounterCompletionStore.cs` | Completion store contract. | +| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs` | Thread-safe in-memory implementation. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs` | Static orchestration: resolve encounter, activate, mark defeat, all-defeated predicate. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs` | AAA unit tests: activation, any-order defeats, all-defeated predicate, completion idempotency. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterProgressStoreTests.cs` | AAA store tests: activate idempotency, duplicate defeat, not-started deny. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompletionStoreTests.cs` | AAA completion idempotency tests. | +| `docs/plans/NEO-104-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | Register `IEncounterProgressStore` + `IEncounterCompletionStore` singletons (or call new extension from here). | +| `server/NeonSprawl.Server/Program.cs` | Ensure encounter progress stores registered at startup (if split extension, invoke it). | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Swap in test in-memory encounter stores; strip production registrations in test host. | +| `server/README.md` | Document encounter progress/completion stores and operations; defer HTTP/combat to NEO-106/108. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — progress/completion stores (NEO-104). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-104 stores when landed. | +| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-05 AC when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `InMemoryEncounterProgressStoreTests.cs` | **Arrange:** store. **Act/Assert:** first `TryActivate` true; second false; `TryAddDefeatedTarget` without activate false; duplicate NPC add false; two distinct NPCs in set. | +| `InMemoryEncounterCompletionStoreTests.cs` | **Arrange:** store + fixed `completedAt`. **Act:** `TryMarkCompleted` twice. **Assert:** first true, second false; `IsCompleted` true; `TryGetCompletedAt` matches. | +| `EncounterProgressOperationsTests.cs` | **Arrange:** real `EncounterDefinitionRegistry` from test factory catalog + in-memory stores. **Act/Assert:** activate on `prototype_npc_melee`; defeat three NPCs in permuted order → `IsAllRequiredTargetsDefeated` true only after third; partial progress false; `TryMarkTargetDefeated` before activate false; `TryMarkCompleted` idempotent; progress blocked when already completed. | +| `EncounterDefinitionRegistryTests.cs` (optional host test) | **Arrange:** `InMemoryWebApplicationFactory`. **Assert:** resolves `IEncounterProgressStore` + `IEncounterCompletionStore` from DI. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Multiple encounters sharing one NPC** | First match in `GetDefinitionsInIdOrder()` wins — acceptable for prototype (single encounter). Document in operations XML. | **adopted** | +| **Mark defeat after encounter completed** | `TryMarkTargetDefeated` returns false — fail closed. | **adopted** | +| **Postgres follow-on** | Defer — new migration + store pair in a future issue when session persistence is required. | **deferred** | diff --git a/docs/reviews/2026-05-31-NEO-104.md b/docs/reviews/2026-05-31-NEO-104.md new file mode 100644 index 0000000..02dcbf4 --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-104.md @@ -0,0 +1,60 @@ +# Code review — NEO-104 (E5M3-05) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-104-encounter-progress-completion-stores` vs `aa81914` (merge-base on `main`) — commits `727ce47` … `aaccbbb` +**Base:** `aa81914` (main at branch point, post NEO-103 merge) + +## Verdict + +**Approve with nits** + +## Summary + +NEO-104 adds server-owned per-player encounter **activation**, **defeat progress** (order-independent subset of `requiredNpcInstanceIds`), and an **idempotent completion flag** at the store layer, plus static **`EncounterProgressOperations`** that auto-resolve encounters from NPC ids via **`IEncounterDefinitionRegistry`**. Implementation mirrors established patterns (`InMemoryResourceNodeInstanceStore` per-key locks, `GatherOperations` / `AggroOperations` static orchestration): split **`IEncounterProgressStore`** / **`IEncounterCompletionStore`**, `EncounterProgressIds` normalization, DI in **`AddEncounterAndRewardCatalogs`**, telemetry hook comment on activation, and ten new AAA unit tests plus a host DI smoke in **`EncounterDefinitionRegistryTests`**. Docs (plan, backlog, alignment register, module register, E5.M3 snapshot, `server/README.md`) are updated. Combat cast and HTTP read are correctly deferred to NEO-106 / NEO-108; no client counterpart required. Risk is low: in-memory stores with no player-visible surface until NEO-108 / NEO-110. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-104-implementation-plan.md` | **Matches** — split stores, operations API, in-memory DI, deferred combat/HTTP, kickoff decisions recorded, acceptance checklist checked, reconciliation section accurate. | +| `docs/plans/E5M3-prototype-backlog.md` (E5M3-05) | **Matches** — acceptance criteria checked; landed note cites plan. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Summary **Status** marks **E5M3-05 / NEO-104** landed; **Implementation snapshot** progress bullet added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-104** stores; **NEO-105+** grants/combat/HTTP still outstanding. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-104** store landing; **NEO-105+** outstanding. | +| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 via NEO-108. | +| `server/README.md` | **Matches** — encounter progress section documents stores, operations, and deferred NEO-106/108 wiring. | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Plan file list vs shipped diff** — The plan lists **`InMemoryWebApplicationFactory.cs`** and **`Program.cs`** as files to modify; neither changed. This is acceptable: stores register in the existing **`AddEncounterAndRewardCatalogs`** extension (already invoked at startup), and encounter stores are always in-memory (no Postgres swap needed in the test factory, unlike position/inventory stores). Consider a one-line note in the plan **Implementation reconciliation** that factory/Program edits were N/A.~~ **Done.** Note added to plan **Implementation reconciliation**. + +2. ~~**Completion guard on `TryActivate`** — The plan describes **`TryActivate`** returning `false` when the completion store marks the encounter done. Gating lives only in **`EncounterProgressOperations.TryActivateOnFirstEngagement`**; a direct **`IEncounterProgressStore.TryActivate`** call after completion would still create a started row. Acceptable while NEO-106/105 route exclusively through operations; add a brief XML note on **`IEncounterProgressStore.TryActivate`** that completion gating is the caller's responsibility if direct store use is anticipated.~~ **Done.** XML on **`IEncounterProgressStore.TryActivate`**. + +3. ~~**Optional operations coverage** — Store and core happy-path operations are well covered. Consider adding AAA tests for: **`TryActivateOnFirstEngagement`** returns `false` when encounter is already completed; resolve/activate returns `false` for an NPC id not in any encounter definition.~~ **Done.** Two new tests in **`EncounterProgressOperationsTests`** plus mixed-case **`IsAllRequiredTargetsDefeated`** coverage. + +4. ~~**`IsAllRequiredTargetsDefeated` id normalization** — The method passes **`encounterId`** to case-sensitive **`TryGetDefinition`**. NEO-102 precedent documents that only **`TryNormalizeKnown`** lowercases. Internal callers today use catalog **`row.Id`** (lowercase); NEO-108 HTTP should normalize before calling — optionally harden now with **`TryNormalizeKnown`** or **`EncounterProgressIds.NormalizeEncounterId`** + lookup for defense in depth.~~ **Done.** **`TryNormalizeKnown`** before lookup and progress read. + +## Nits + +- ~~Nit: **`Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds`** — **`Assert.NotNull`** on values from **`GetRequiredService`** is redundant (missing registration throws). Minor AAA nit: DI resolution fits **Arrange**; **Act** could be only **`TryActivate`** / **`TryMarkCompleted`**.~~ **Done.** + +- ~~Nit: **`InMemoryEncounterCompletionStore.IsCompleted`** reads **`ConcurrentDictionary`** without the per-key lock used by **`TryMarkCompleted`** / **`TryGetCompletedAt`**. Benign for prototype bool checks; align with lock if strict read-your-writes under concurrency matters later.~~ **Done.** **`IsCompleted`** uses per-key lock. + +## Verification + +```bash +# NEO-104-focused (10 new tests + host DI smoke — passed locally) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterProgressOperations|FullyQualifiedName~InMemoryEncounterProgressStore|FullyQualifiedName~InMemoryEncounterCompletionStore|Host_ShouldResolveEncounterProgressStoresFromDi" + +# Broader encounter slice (catalog + registries + HTTP + progress) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" + +# CI parity +python3 scripts/validate_content.py +``` + +**Local run:** 52/52 `Encounters` filter tests passed. diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs index 6a10470..9af8db9 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs @@ -202,4 +202,25 @@ public class EncounterDefinitionRegistryTests Assert.Single(encounterList); Assert.Equal(PrototypeCombatPocket, encounterList[0].Id); } + + [Fact] + public async Task Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + var progressStore = factory.Services.GetRequiredService(); + var completionStore = factory.Services.GetRequiredService(); + // Act + var activated = progressStore.TryActivate("dev-local-1", PrototypeCombatPocket); + var completed = completionStore.TryMarkCompleted( + "dev-local-1", + PrototypeCombatPocket, + new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero)); + // Assert + Assert.True(activated); + Assert.True(completed); + Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket)); + } } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs new file mode 100644 index 0000000..6a99eaf --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs @@ -0,0 +1,219 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Encounters; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class EncounterProgressOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string EncounterId = "prototype_combat_pocket"; + private const string MeleeNpc = "prototype_npc_melee"; + private const string RangedNpc = "prototype_npc_ranged"; + private const string EliteNpc = "prototype_npc_elite"; + + [Fact] + public async Task TryActivateOnFirstEngagement_ShouldStartEncounter_WhenNpcIsRequired() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + // Act + var activated = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + MeleeNpc, + registry, + progress, + completion); + // Assert + Assert.True(activated); + Assert.True(progress.TryGetProgress(PlayerId, EncounterId, out var snapshot)); + Assert.True(snapshot.Started); + Assert.Empty(snapshot.DefeatedNpcInstanceIds); + Assert.False(EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress)); + } + + [Fact] + public async Task TryMarkTargetDefeated_ShouldReturnFalse_WhenNotActivated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + // Act + var marked = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + MeleeNpc, + registry, + progress, + completion); + // Assert + Assert.False(marked); + } + + [Fact] + public async Task IsAllRequiredTargetsDefeated_ShouldBecomeTrue_AfterThirdDefeatInAnyOrder() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + _ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, EliteNpc, registry, progress, completion); + // Act + _ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, EliteNpc, registry, progress, completion); + _ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion); + var afterTwo = EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress); + _ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, RangedNpc, registry, progress, completion); + var afterThree = EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress); + // Assert + Assert.False(afterTwo); + Assert.True(afterThree); + } + + [Fact] + public async Task TryMarkTargetDefeated_ShouldBeIdempotent_WhenNpcAlreadyDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + _ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion); + _ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion); + // Act + var secondMark = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + MeleeNpc, + registry, + progress, + completion); + // Assert + Assert.True(secondMark); + Assert.True(progress.TryGetProgress(PlayerId, EncounterId, out var snapshot)); + Assert.Single(snapshot.DefeatedNpcInstanceIds); + } + + [Fact] + public async Task TryMarkTargetDefeated_ShouldReturnFalse_WhenEncounterAlreadyCompleted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + _ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion); + _ = completion.TryMarkCompleted(PlayerId, EncounterId, DateTimeOffset.UtcNow); + // Act + var marked = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + MeleeNpc, + registry, + progress, + completion); + // Assert + Assert.False(marked); + } + + [Fact] + public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenEncounterAlreadyCompleted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + _ = completion.TryMarkCompleted(PlayerId, EncounterId, DateTimeOffset.UtcNow); + // Act + var activated = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + MeleeNpc, + registry, + progress, + completion); + // Assert + Assert.False(activated); + Assert.False(progress.TryGetProgress(PlayerId, EncounterId, out _)); + } + + [Fact] + public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenNpcNotInAnyEncounter() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + // Act + var activated = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + "prototype_npc_unknown", + registry, + progress, + completion); + // Assert + Assert.False(activated); + } + + [Fact] + public async Task IsAllRequiredTargetsDefeated_ShouldReturnTrue_WhenEncounterIdHasMixedCase() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + var completion = factory.Services.GetRequiredService(); + _ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion); + _ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion); + _ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, RangedNpc, registry, progress, completion); + _ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, EliteNpc, registry, progress, completion); + // Act + var allDefeated = EncounterProgressOperations.IsAllRequiredTargetsDefeated( + PlayerId, + " Prototype_Combat_Pocket ", + registry, + progress); + // Assert + Assert.True(allDefeated); + } + + [Fact] + public async Task IsAllRequiredTargetsDefeated_ShouldReturnTrue_WhenDefeatedSetIsSupersetOfRequired() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var progress = factory.Services.GetRequiredService(); + _ = progress.TryActivate(PlayerId, EncounterId); + _ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc); + _ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, RangedNpc); + _ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, EliteNpc); + _ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, "extra_non_required_npc"); + // Act + var allDefeated = EncounterProgressOperations.IsAllRequiredTargetsDefeated( + PlayerId, + EncounterId, + registry, + progress); + // Assert + Assert.True(allDefeated); + } + + [Fact] + public async Task TryMarkCompleted_ShouldReturnFalseOnSecondAttempt_WhenUsingCompletionStoreDirectly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var completion = factory.Services.GetRequiredService(); + var completedAt = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero); + // Act + var first = completion.TryMarkCompleted(PlayerId, EncounterId, completedAt); + var second = completion.TryMarkCompleted(PlayerId, EncounterId, completedAt); + // Assert + Assert.True(first); + Assert.False(second); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompletionStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompletionStoreTests.cs new file mode 100644 index 0000000..875643c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompletionStoreTests.cs @@ -0,0 +1,28 @@ +using NeonSprawl.Server.Game.Encounters; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class InMemoryEncounterCompletionStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string EncounterId = "prototype_combat_pocket"; + private static readonly DateTimeOffset CompletedAt = new(2026, 5, 31, 18, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryMarkCompleted_ShouldBeIdempotent_AtStoreLayer() + { + // Arrange + var store = new InMemoryEncounterCompletionStore(); + // Act + var first = store.TryMarkCompleted(PlayerId, EncounterId, CompletedAt); + var second = store.TryMarkCompleted(PlayerId, EncounterId, CompletedAt.AddHours(1)); + var hasCompletedAt = store.TryGetCompletedAt(PlayerId, EncounterId, out var storedAt); + // Assert + Assert.True(first); + Assert.False(second); + Assert.True(store.IsCompleted(PlayerId, EncounterId)); + Assert.True(hasCompletedAt); + Assert.Equal(CompletedAt, storedAt); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterProgressStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterProgressStoreTests.cs new file mode 100644 index 0000000..9a6e52a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterProgressStoreTests.cs @@ -0,0 +1,60 @@ +using NeonSprawl.Server.Game.Encounters; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class InMemoryEncounterProgressStoreTests +{ + private const string PlayerId = "dev-local-1"; + private const string EncounterId = "prototype_combat_pocket"; + private const string MeleeNpc = "prototype_npc_melee"; + private const string RangedNpc = "prototype_npc_ranged"; + + [Fact] + public void TryActivate_ShouldReturnTrueThenFalse_WhenCalledTwice() + { + // Arrange + var store = new InMemoryEncounterProgressStore(); + // Act + var first = store.TryActivate(PlayerId, EncounterId); + var second = store.TryActivate(PlayerId, EncounterId); + // Assert + Assert.True(first); + Assert.False(second); + Assert.True(store.TryGetProgress(PlayerId, EncounterId, out var progress)); + Assert.True(progress.Started); + Assert.Empty(progress.DefeatedNpcInstanceIds); + } + + [Fact] + public void TryAddDefeatedTarget_ShouldReturnFalse_WhenEncounterNotActivated() + { + // Arrange + var store = new InMemoryEncounterProgressStore(); + // Act + var added = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc); + // Assert + Assert.False(added); + Assert.False(store.TryGetProgress(PlayerId, EncounterId, out _)); + } + + [Fact] + public void TryAddDefeatedTarget_ShouldReturnFalseOnDuplicate_AndTrueForSecondNpc() + { + // Arrange + var store = new InMemoryEncounterProgressStore(); + _ = store.TryActivate(PlayerId, EncounterId); + // Act + var firstAdd = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc); + var duplicate = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc); + var secondAdd = store.TryAddDefeatedTarget(PlayerId, EncounterId, RangedNpc); + // Assert + Assert.True(firstAdd); + Assert.False(duplicate); + Assert.True(secondAdd); + Assert.True(store.TryGetProgress(PlayerId, EncounterId, out var progress)); + Assert.Equal(2, progress.DefeatedNpcInstanceIds.Count); + Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds); + Assert.Contains(RangedNpc, progress.DefeatedNpcInstanceIds); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs index e396b1e..9c3fdaf 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs @@ -76,6 +76,9 @@ public static class EncounterCatalogServiceCollectionExtensions services.AddSingleton(sp => new RewardTableDefinitionRegistry(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressIds.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressIds.cs new file mode 100644 index 0000000..f104d87 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressIds.cs @@ -0,0 +1,36 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Id normalization for encounter progress/completion stores (NEO-104). +public static class EncounterProgressIds +{ + /// 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 NormalizeEncounterId(string? encounterId) => NormalizePlayerId(encounterId); + + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizeNpcInstanceId(string? npcInstanceId) => NormalizePlayerId(npcInstanceId); + + /// Composite store key for + . + public static string MakeProgressKey(string? playerId, string? encounterId) + { + var p = NormalizePlayerId(playerId); + var e = NormalizeEncounterId(encounterId); + if (p.Length == 0 || e.Length == 0) + { + return string.Empty; + } + + return $"{p}\0{e}"; + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs new file mode 100644 index 0000000..10128e5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs @@ -0,0 +1,147 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Encounter activation and defeat-all progress orchestration (NEO-104). +/// Combat cast wiring: (NEO-106). +/// +public static class EncounterProgressOperations +{ + /// + /// Resolves the first encounter (id order) whose requiredNpcInstanceIds contains . + /// When multiple encounters share an NPC, the earliest id wins. + /// + public static bool TryResolveEncounterForNpc( + string npcInstanceId, + IEncounterDefinitionRegistry encounterRegistry, + out string encounterId, + out EncounterDefRow definition) + { + encounterId = string.Empty; + definition = null!; + var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId); + if (npcKey.Length == 0) + { + return false; + } + + foreach (var row in encounterRegistry.GetDefinitionsInIdOrder()) + { + if (row.RequiredNpcInstanceIds.Any(id => + string.Equals( + EncounterProgressIds.NormalizeNpcInstanceId(id), + npcKey, + StringComparison.Ordinal))) + { + encounterId = row.Id; + definition = row; + return true; + } + } + + return false; + } + + /// + /// Activates encounter progress on first engagement with a required NPC. + /// No-op when already started, completed, or NPC is not part of any encounter. + /// + public static bool TryActivateOnFirstEngagement( + string playerId, + string npcInstanceId, + IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore) + { + if (!TryResolveEncounterForNpc(npcInstanceId, encounterRegistry, out var encounterId, out _)) + { + return false; + } + + if (completionStore.IsCompleted(playerId, encounterId)) + { + return false; + } + + // --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_start` --- + // TODO(E9.M1): catalog emit — on first activation for player+encounter (NEO-106 wires cast path). + return progressStore.TryActivate(playerId, encounterId); + } + + /// + /// Records one required NPC defeat. Requires prior activation; idempotent when the NPC was already marked defeated. + /// + public static bool TryMarkTargetDefeated( + string playerId, + string npcInstanceId, + IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore) + { + if (!TryResolveEncounterForNpc(npcInstanceId, encounterRegistry, out var encounterId, out var definition)) + { + return false; + } + + if (completionStore.IsCompleted(playerId, encounterId)) + { + return false; + } + + var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId); + if (!definition.RequiredNpcInstanceIds.Any(id => + string.Equals(EncounterProgressIds.NormalizeNpcInstanceId(id), npcKey, StringComparison.Ordinal))) + { + return false; + } + + if (!progressStore.TryGetProgress(playerId, encounterId, out var progress) || !progress.Started) + { + return false; + } + + if (progress.DefeatedNpcInstanceIds.Contains(npcKey)) + { + return true; + } + + return progressStore.TryAddDefeatedTarget(playerId, encounterId, npcInstanceId); + } + + /// + /// true when every requiredNpcInstanceIds entry is in the defeated set for a started encounter. + /// + public static bool IsAllRequiredTargetsDefeated( + string playerId, + string encounterId, + IEncounterDefinitionRegistry encounterRegistry, + IEncounterProgressStore progressStore) + { + if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) || + !encounterRegistry.TryGetDefinition(normalizedEncounterId, out var definition)) + { + return false; + } + + if (!progressStore.TryGetProgress(playerId, normalizedEncounterId, out var progress) || !progress.Started) + { + return false; + } + + var required = new HashSet(StringComparer.Ordinal); + foreach (var id in definition.RequiredNpcInstanceIds) + { + var normalized = EncounterProgressIds.NormalizeNpcInstanceId(id); + if (normalized.Length > 0) + { + required.Add(normalized); + } + } + + if (required.Count == 0) + { + return false; + } + + return required.IsSubsetOf(progress.DefeatedNpcInstanceIds); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterProgressSnapshot.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressSnapshot.cs new file mode 100644 index 0000000..4d1161b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressSnapshot.cs @@ -0,0 +1,17 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Read model for per-player encounter defeat progress (NEO-104). +public sealed class EncounterProgressSnapshot( + string playerId, + string encounterId, + bool started, + IReadOnlySet defeatedNpcInstanceIds) +{ + public string PlayerId { get; } = playerId; + + public string EncounterId { get; } = encounterId; + + public bool Started { get; } = started; + + public IReadOnlySet DefeatedNpcInstanceIds { get; } = defeatedNpcInstanceIds; +} diff --git a/server/NeonSprawl.Server/Game/Encounters/IEncounterCompletionStore.cs b/server/NeonSprawl.Server/Game/Encounters/IEncounterCompletionStore.cs new file mode 100644 index 0000000..91e35a4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/IEncounterCompletionStore.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Idempotent per-player encounter completion flag (NEO-104; rewards applied in NEO-105). +public interface IEncounterCompletionStore +{ + bool IsCompleted(string playerId, string encounterId); + + /// First mark returns true; replays return false. + bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt); + + bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs b/server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs new file mode 100644 index 0000000..1d1ebc3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs @@ -0,0 +1,22 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Per-player encounter activation and defeat-target progress (NEO-104). +public interface IEncounterProgressStore +{ + /// Returns false when no row exists for the player+encounter pair. + bool TryGetProgress(string playerId, string encounterId, out EncounterProgressSnapshot snapshot); + + /// + /// Creates a started row with an empty defeated set. + /// Returns true on first activation; false when already started. + /// Does not consult — callers that must block activation after completion + /// (e.g. ) must gate before calling this method. + /// + bool TryActivate(string playerId, string encounterId); + + /// + /// Adds one defeated NPC id to an existing started row. + /// Returns true when newly added; false when duplicate, not started, or invalid ids. + /// + bool TryAddDefeatedTarget(string playerId, string encounterId, string npcInstanceId); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs new file mode 100644 index 0000000..656f674 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs @@ -0,0 +1,63 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Thread-safe in-memory encounter completion flags (NEO-104). +public sealed class InMemoryEncounterCompletionStore : IEncounterCompletionStore +{ + private readonly ConcurrentDictionary completedAtByKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + /// + public bool IsCompleted(string playerId, string encounterId) + { + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + return completedAtByKey.ContainsKey(key); + } + } + + /// + public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) + { + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + if (completedAtByKey.ContainsKey(key)) + { + return false; + } + + completedAtByKey[key] = completedAt; + return true; + } + } + + /// + public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) + { + completedAt = default; + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + return completedAtByKey.TryGetValue(key, out completedAt); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs new file mode 100644 index 0000000..3adf4c4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Thread-safe in-memory encounter progress; empty at startup (NEO-104). +public sealed class InMemoryEncounterProgressStore : IEncounterProgressStore +{ + private sealed class ProgressRow(bool started, HashSet defeatedNpcInstanceIds) + { + public bool Started { get; } = started; + + public HashSet DefeatedNpcInstanceIds { get; } = defeatedNpcInstanceIds; + } + + private readonly ConcurrentDictionary byKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + /// + public bool TryGetProgress(string playerId, string encounterId, out EncounterProgressSnapshot snapshot) + { + snapshot = default!; + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + if (!byKey.TryGetValue(key, out var row)) + { + return false; + } + + snapshot = new EncounterProgressSnapshot( + EncounterProgressIds.NormalizePlayerId(playerId), + EncounterProgressIds.NormalizeEncounterId(encounterId), + row.Started, + new HashSet(row.DefeatedNpcInstanceIds, StringComparer.Ordinal)); + return true; + } + } + + /// + public bool TryActivate(string playerId, string encounterId) + { + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + if (key.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + if (byKey.TryGetValue(key, out var existing) && existing.Started) + { + return false; + } + + byKey[key] = new ProgressRow( + started: true, + defeatedNpcInstanceIds: new HashSet(StringComparer.Ordinal)); + return true; + } + } + + /// + public bool TryAddDefeatedTarget(string playerId, string encounterId, string npcInstanceId) + { + var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); + var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId); + if (key.Length == 0 || npcKey.Length == 0) + { + return false; + } + + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + if (!byKey.TryGetValue(key, out var row) || !row.Started) + { + return false; + } + + return row.DefeatedNpcInstanceIds.Add(npcKey); + } + } +} diff --git a/server/README.md b/server/README.md index 20b416d..7a2e3c0 100644 --- a/server/README.md +++ b/server/README.md @@ -141,6 +141,18 @@ On success, **Information** logs include the resolved encounters directory path, curl -sS -i "http://localhost:5253/game/world/encounter-definitions" ``` +## Encounter progress + completion stores (NEO-104) + +Per-player encounter state is server-owned in **`IEncounterProgressStore`** (activation + defeated required NPC ids) and **`IEncounterCompletionStore`** (idempotent **`completedAt`** flag after rewards — marking used by NEO-105). Prototype uses in-memory singletons registered in **`AddEncounterAndRewardCatalogs`**. + +**`EncounterProgressOperations`** (static): + +- **`TryActivateOnFirstEngagement(playerId, npcInstanceId, …)`** — resolves encounter from NPC via **`IEncounterDefinitionRegistry`**, starts progress when not completed. +- **`TryMarkTargetDefeated(…)`** — requires prior activation; accumulates defeats in any order; idempotent per NPC. +- **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — set equality vs catalog **`requiredNpcInstanceIds`**. + +Combat cast wiring (**`AbilityCastApi`**) and **`GET /game/players/{id}/encounter-progress`** land in NEO-106 / NEO-108. Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md). + ## NPC behavior definitions (NEO-90) **`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.