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 index 5fb6ed2..2759bf1 100644 --- a/docs/plans/NEO-104-implementation-plan.md +++ b/docs/plans/NEO-104-implementation-plan.md @@ -51,9 +51,17 @@ ## Acceptance criteria checklist -- [ ] Defeating required NPCs accumulates progress in any order (via `TryMarkTargetDefeated` after activation). -- [ ] Completion predicate true only when all required ids defeated (`IsAllRequiredTargetsDefeated`). -- [ ] Second completion attempt on same player+encounter is idempotent no-op at store layer (`TryMarkCompleted` returns `false`). +- [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. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs index 6a10470..23a09a3 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs @@ -202,4 +202,27 @@ 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"); + // Act + var progressStore = factory.Services.GetRequiredService(); + var completionStore = factory.Services.GetRequiredService(); + 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.NotNull(progressStore); + Assert.NotNull(completionStore); + 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..cd30419 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs @@ -0,0 +1,135 @@ +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 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..291ae26 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs @@ -0,0 +1,146 @@ +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.TryGetDefinition(encounterId, out var definition)) + { + return false; + } + + if (!progressStore.TryGetProgress(playerId, encounterId, 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.SetEquals(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..fc15de9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs @@ -0,0 +1,20 @@ +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. + /// + 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..ee124da --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs @@ -0,0 +1,55 @@ +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); + return key.Length > 0 && 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/`.