diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index f53e626..1738afd 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M2 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | +| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | | **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | ## Purpose @@ -83,6 +83,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j **NPC instance registry (NEO-91):** **`PrototypeNpcRegistry`** in `server/NeonSprawl.Server/Game/Npc/` — instance ids **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** with behavior bindings + world anchors; **`ICombatEntityHealthStore`** uses catalog **`maxHp`** per instance. Plan: [NEO-91 implementation plan](../../plans/NEO-91-implementation-plan.md). **Breaking:** Godot constants migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97). +**Threat / aggro store (NEO-92):** **`IThreatStateStore`** + **`AggroOperations`** in `server/NeonSprawl.Server/Game/Npc/` — first damaging cast acquires holder; leash break clears holder (move + cast hooks). HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-92 implementation plan](../../plans/NEO-92-implementation-plan.md). + **NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation 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/`. ## Risks and telemetry diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 8c7c41b..be10ffb 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -57,7 +57,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | 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 | In Progress | **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). **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); client capstone **NEO-98**. 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), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | In Progress | **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). **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); client capstone **NEO-98**. 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), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index a6037d8..10b0198 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -208,7 +208,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d - `IThreatStateStore` + in-memory implementation keyed by NPC instance id. - `AggroOperations.TryAcquire` / `TryClearOnLeash` using player **`PositionState`** + registry anchors. -- Hook from **`CombatOperations.TryResolve`** on successful NPC damage (player → NPC). +- Hook from **`AbilityCastApi`** after successful resolve when **`DamageDealt > 0`** (player → NPC). - Unit + integration tests (AAA). **Out of scope** @@ -217,9 +217,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d **Acceptance criteria** -- [ ] First damaging cast on NPC sets aggro holder to casting player id. -- [ ] Holder clears when player leaves `leashRadius` (deterministic distance check). -- [ ] Re-aggro inside radius after clear follows same first-hit rule. +- [x] First damaging cast on NPC sets aggro holder to casting player id. +- [x] Holder clears when player leaves `leashRadius` (deterministic distance check). +- [x] Re-aggro inside radius after clear follows same first-hit rule. + +**Landed ([NEO-92](https://linear.app/neon-sprawl/issue/NEO-92)):** **`IThreatStateStore`** + **`AggroOperations`** — acquire on damaging cast, leash clear on move/cast; plan [NEO-92-implementation-plan.md](NEO-92-implementation-plan.md). --- diff --git a/docs/plans/NEO-92-implementation-plan.md b/docs/plans/NEO-92-implementation-plan.md new file mode 100644 index 0000000..76a8f43 --- /dev/null +++ b/docs/plans/NEO-92-implementation-plan.md @@ -0,0 +1,161 @@ +# NEO-92 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-92 | +| **Title** | E5M2-06: AggroRule engine + `IThreatStateStore` | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-92/e5m2-06-aggrorule-engine-ithreatstatestore | +| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-06** | +| **Branch** | `NEO-92` | +| **Precursor** | [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) — `PrototypeNpcRegistry` + combat-target migration (**Done** on `main`) | +| **Pattern** | [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) — in-memory session store + DI; [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast-layer player correlation | +| **Blocks** | [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) — NPC behavior state machine (needs aggro holder) | +| **Client counterpart** | None for this slice — threat projection lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) (`npc-runtime-snapshot`); Bruno + automated tests verify server behavior | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Aggro acquire hook** | Where to run acquire after a damaging cast? | **`AbilityCastApi` after successful `TryResolve` when `DamageDealt > 0`** — player id at cast layer (NEO-30); keep `CombatOperations` engine-pure. | **User:** `AbilityCastApi`. | +| **Leash clear trigger** | When to invoke `TryClearOnLeash` at runtime? | **Player move (`POST /move` + `/move-stream`) and before acquire on cast** — deterministic clear without NEO-93 snapshot poll. | **User:** move + cast. | +| **Re-aggro model** | Proximity auto-aggro vs first-hit in NEO-92? | **First damaging cast only** — E5M2-06 AC; defer proximity auto-aggro to NEO-93. | **User:** first-hit only. | +| **Holder semantics** | Behavior when NPC already has a holder? | **No-op** — first hit wins until leash clear (prototype single-player). | **User:** no-op. | + +## Goal, scope, and out-of-scope + +**Goal:** Deterministic aggro — NPC acquires a **holder** (`playerId`) on the **first damaging player cast**; holder **clears** when that player leaves the behavior catalog **`leashRadius`** from the NPC world anchor. + +**In scope (from Linear + [E5M2-06](E5M2-prototype-backlog.md#e5m2-06--aggrorule-engine--ithreatstatestore)):** + +- **`IThreatStateStore`** + in-memory implementation keyed by NPC instance id. +- **`AggroOperations.TryAcquire`** / **`TryClearOnLeash`** using player **`PositionState`**, **`PrototypeNpcRegistry`** anchors, and **`INpcBehaviorDefinitionRegistry`** `leashRadius`. +- Hook acquire from **`AbilityCastApi`** after successful combat resolve with **`DamageDealt > 0`**. +- Wire leash clear on **`PositionStateApi`** move accept paths and before acquire on cast. +- Extend dev **`combat-targets-fixture`** to reset threat rows (Bruno isolation). +- Unit + integration tests (AAA). + +**Out of scope (from Linear + backlog):** + +- NPC attack loop, telegraphs, behavior state machine ([NEO-93](https://linear.app/neon-sprawl/issue/NEO-93)). +- HTTP DTO projection of threat state ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). +- Proximity auto-aggro inside `aggroRadius` without a damaging cast. +- Godot / client HUD ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)). +- `docs/manual-qa/NEO-92.md` — server-only; Bruno + automated tests (NEO-91 pattern). + +## Acceptance criteria checklist + +- [x] First damaging cast on NPC sets aggro holder to casting player id. +- [x] Holder clears when player leaves `leashRadius` (deterministic horizontal distance check). +- [x] Re-aggro inside radius after clear follows same first-hit rule (no proximity auto-aggro). + +## Technical approach + +1. **Threat store (`Game/Npc/`)** + - **`ThreatStateSnapshot`**: `AggroHolderPlayerId` (`string?`, lowercase trimmed route id or `null` when empty). + - **`IThreatStateStore`**: `TryGet(npcInstanceId, out snapshot)`, `TrySetHolder(npcInstanceId, playerId?)`, `TryClearHolder(npcInstanceId)` (or single `TrySetHolder` with null). + - **`InMemoryThreatStateStore`**: thread-safe lazy rows for known **`PrototypeNpcRegistry`** ids only; unknown ids fail `TryGet`/`TrySet`. + - **`ThreatStateServiceCollectionExtensions.AddThreatStateStore`**: register in-memory singleton (prototype session store — no Postgres). + +2. **`AggroOperations` (static, `Game/Npc/`)** + - **`TryAcquire(npcInstanceId, playerId, threatStore)`** → `bool` (true when holder was empty and now set): + - Normalize ids (trim + lowercase). + - Gate npc id via **`PrototypeNpcRegistry.TryGet`**. + - If current holder non-empty → **no-op**, return `false`. + - Else set holder to `playerId`, return `true`. + - **`TryClearOnLeashForPlayer(playerId, positions, behaviorRegistry, threatStore)`**: + - Load player position via **`IPositionStateStore.TryGetPosition`**; no-op if missing. + - For each prototype NPC instance id, if holder == `playerId`: + - Resolve behavior def → **`LeashRadius`**. + - Compare **`HorizontalReach.HorizontalDistance`** (player X/Z vs NPC anchor X/Z). + - Clear holder when distance **>** `leashRadius` (inclusive boundary still leashed — mirrors lock-radius tests). + - **`TryClearOnLeashForNpc(...)`** (optional internal helper for unit tests): single-NPC variant. + +3. **Cast hook (`AbilityCastApi`)** + - Inject **`IThreatStateStore`**. + - After combat success and **before** cooldown commit: + - If **`combatResult.DamageDealt > 0`**: call **`TryClearOnLeashForPlayer`** then **`TryAcquire(lookupKey, id.Trim(), threatStore)`**. + - Zero-damage abilities (guard/dash) skip acquire. + +4. **Move hook (`PositionStateApi`)** + - Inject **`IThreatStateStore`** + **`INpcBehaviorDefinitionRegistry`** on **`POST /move`** and **`POST /move-stream`** success paths. + - After **`TryApplyMoveTarget`** / stream apply succeeds, call **`TryClearOnLeashForPlayer`** for route `id`. + +5. **Dev fixture (`CombatTargetFixtureApi`)** + - Inject **`IThreatStateStore`**; after HP reset loop, clear all prototype NPC threat holders (fresh Bruno runs). + +6. **DI wiring** + - Call **`AddThreatStateStore()`** from **`Program.cs`** (or co-locate with NPC catalog extension). + - No change to **`CombatOperations`** signature — aggro stays outside the combat engine. + +7. **Docs** + - **`server/README.md`** — brief threat/aggro section: first-hit acquire, leash clear triggers, fixture reset, no HTTP read yet (NEO-94). + - Reconcile [E5M2-prototype-backlog.md](E5M2-prototype-backlog.md) E5M2-06 checkboxes when landed. + +### Leash radii (frozen catalog, for test expectations) + +| NPC instance | Behavior def | `leashRadius` | Anchor (X, Z) | +|--------------|--------------|---------------|---------------| +| `prototype_npc_melee` | `prototype_melee_pressure` | 16.0 | (-3, -3) | +| `prototype_npc_ranged` | `prototype_ranged_control` | 20.0 | (3, 3) | +| `prototype_npc_elite` | `prototype_elite_mini_boss` | 18.0 | (0, 0) | + +**Example (melee):** dev player at origin `(0, 0, 0)` is ~4.24 m from melee anchor — inside leash 16. Move to `(20, 0, 20)` → distance ~32.5 > 16 → holder clears. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Npc/ThreatStateSnapshot.cs` | Immutable aggro holder snapshot for one NPC instance. | +| `server/NeonSprawl.Server/Game/Npc/IThreatStateStore.cs` | Store contract keyed by NPC instance id. | +| `server/NeonSprawl.Server/Game/Npc/InMemoryThreatStateStore.cs` | Thread-safe in-memory prototype implementation. | +| `server/NeonSprawl.Server/Game/Npc/ThreatStateServiceCollectionExtensions.cs` | DI registration for `IThreatStateStore`. | +| `server/NeonSprawl.Server/Game/Npc/AggroOperations.cs` | `TryAcquire` + `TryClearOnLeashForPlayer` deterministic ops. | +| `server/NeonSprawl.Server.Tests/Game/Npc/AggroOperationsTests.cs` | AAA unit tests: acquire, no-op when held, leash clear, re-acquire after clear. | +| `server/NeonSprawl.Server.Tests/Game/Npc/InMemoryThreatStateStoreTests.cs` | AAA store tests: lazy rows, unknown id, clear/set. | +| `server/NeonSprawl.Server.Tests/Game/Npc/AggroThreatIntegrationTests.cs` | HTTP integration: cast acquire + move beyond leash clears holder via injected store. | +| `docs/plans/NEO-92-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Program.cs` | Register `AddThreatStateStore()`. | +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject threat store; leash clear + acquire after damaging resolve. | +| `server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs` | Leash clear on successful move + move-stream. | +| `server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs` | Reset threat holders in dev fixture alongside HP reset. | +| `server/README.md` | Document aggro acquire/clear behavior and fixture reset. | +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Assert holder set after pulse cast; zero-damage guard skips acquire. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs` | Move beyond leash clears aggro holder (cast setup helper). | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | Threat store landed note when complete. | +| `docs/plans/E5M2-prototype-backlog.md` | E5M2-06 acceptance checkboxes + landed note when complete. | + +## Tests + +| File | Coverage | +|------|----------| +| `AggroOperationsTests.cs` | First acquire sets holder; second acquire no-op; clear when distance > leashRadius; no clear when inside; re-acquire after clear on next TryAcquire. | +| `InMemoryThreatStateStoreTests.cs` | Unknown npc id fails; set/get/clear; thread-safe spot check if trivial. | +| `AggroThreatIntegrationTests.cs` | End-to-end: bind pulse → lock melee → cast → store has `dev-local-1`; move far → holder cleared; cast again → re-acquire. | +| `AbilityCastApiTests.cs` | Damaging cast sets holder (via store resolve from factory); guard cast leaves holder empty. | +| `MoveCommandApiTests.cs` | After aggro via cast, validated move outside leash clears holder. | + +No Bruno changes required for AC (internal store not on wire); optional Bruno note in README only. No `docs/manual-qa/NEO-92.md`. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|---------------------|--------| +| **Linear blockedBy NEO-91** | Proceed — NEO-91 **Done** on `main`. | **adopted** | +| **No HTTP read surface** | Accept — NEO-94 projects holder; tests inject `IThreatStateStore` from factory. | **adopted** | +| **Leash vs lock radius confusion** | Use catalog **`leashRadius`** for aggro clear; **`LockRadius` (6.0)** remains targeting-only. | **adopted** | +| **Multi-player holder steal** | Out of prototype scope; no-op when holder set (kickoff). | **adopted** | +| **Proximity re-aggro in NEO-93** | NEO-92 first-hit only; state machine may add radius checks later — do not implement proximity acquire here. | **deferred** | + +## Reconciliation (implementation) + +- **`IThreatStateStore`**, **`InMemoryThreatStateStore`**, and **`AggroOperations`** landed in `Game/Npc/`. +- Acquire wired in **`AbilityCastApi`** after damaging resolve; leash clear on **`PositionStateApi`** move paths and before acquire. +- Dev **`combat-targets-fixture`** clears threat holders alongside HP reset. +- Unit + integration tests cover acquire, leash clear, re-acquire, guard skip, and fixture reset. diff --git a/docs/reviews/2026-05-27-NEO-92.md b/docs/reviews/2026-05-27-NEO-92.md new file mode 100644 index 0000000..37f853d --- /dev/null +++ b/docs/reviews/2026-05-27-NEO-92.md @@ -0,0 +1,55 @@ +# Code review — NEO-92 (E5M2-06) + +**Date:** 2026-05-27 +**Scope:** Branch `NEO-92` vs `main` — NEO-92 aggro store implementation, plan/docs, and code review +**Base:** `main` + +**Follow-up:** Code review suggestions addressed (docs alignment, backlog hook bullet, exact-leash-boundary test). + +## Verdict + +**Approve with nits** + +## Summary + +This branch adds the prototype aggro spine for Epic 5 Slice 2: an in-memory `IThreatStateStore`, deterministic `AggroOperations` (first-hit acquire, leash-radius clear), and wiring in `AbilityCastApi`, `PositionStateApi`, and the dev combat-target fixture. The design keeps `CombatOperations` engine-pure and matches kickoff decisions (cast-layer acquire, move + pre-acquire leash clear, no proximity re-aggro). Test coverage is solid — unit, HTTP integration, cast guard skip, move clear, and fixture reset — and the filtered NEO-92-related test run passed locally. Risk is low for merge; remaining gaps are documentation tracking and a few optional test/doc polish items. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-92-implementation-plan.md` | **Matches** — store, ops, hooks, fixture, tests, and reconciliation section align with the diff. | +| `docs/plans/E5M2-prototype-backlog.md` (E5M2-06) | **Matches** — AC checkboxes, landed note, and in-scope hook bullet updated to `AbilityCastApi`. | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — E5M2-06 landed note and threat-store section present. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row includes **NEO-92 landed** note and plan link. | +| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server-owned aggro holder; no client projection in this slice (NEO-94 deferred). | +| `server/README.md` | **Matches** — threat/aggro section documents acquire, leash clear, fixture reset, and NEO-94 deferral. | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Update `documentation_and_implementation_alignment.md` E5.M2 row** — Add **NEO-92 landed** (`IThreatStateStore`, `AggroOperations`, cast/move/fixture hooks) and link `NEO-92-implementation-plan.md` in the References column, consistent with NEO-87–NEO-91 entries. The implementation plan and E5_M2 module doc already claim this update.~~ **Done.** E5.M2 row updated with NEO-92 landed note and References link. +2. ~~**Align E5M2-06 backlog “In scope” hook bullet** — Change “Hook from `CombatOperations.TryResolve`” to **`AbilityCastApi` after successful resolve when `DamageDealt > 0`** so the backlog matches the adopted kickoff decision and avoids misleading NEO-93 readers.~~ **Done.** E5M2-06 in-scope bullet updated. +3. ~~**Add an exact-leash-boundary unit test** — Plan calls out inclusive boundary (`distance > leashRadius` keeps holder). Existing tests use clearly inside/outside positions; a case at `distance == leashRadius` would lock that invariant alongside `HorizontalReach` conventions.~~ **Done.** `TryClearOnLeashForPlayer_ShouldKeepHolder_WhenPlayerAtExactLeashRadius` in `AggroOperationsTests.cs`. + +## Nits + +- ~~Nit: Branch mixes five unrelated `chore:` commits (`docs/dev/local-mlx-cursor.md`, README pointer) with NEO-92 work. Consider splitting or noting in the PR description so reviewers can focus on the aggro diff.~~ **Done.** MLX guide removed from NEO-92 branch (lives on `docs/local-mlx-cursor`); history rewritten to NEO-92-only commits. +- Nit: `AbilityCastApiTests` still names helpers `LockPrototypeTargetAlphaAsync` while locking `prototype_npc_melee` — harmless legacy naming from NEO-91 migration. +- Nit: Plan mentioned an optional thread-safety spot check for `InMemoryThreatStateStore`; acceptable to defer for three fixed prototype ids. + +## Verification + +```bash +# NEO-92-focused tests (passed 2026-05-27) +dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~Npc|FullyQualifiedName~MoveCommandApiTests.PostMove_ShouldClearAggro|FullyQualifiedName~AbilityCastApiTests.PostAbilityCast_ShouldSetAggro|FullyQualifiedName~AbilityCastApiTests.PostAbilityCast_ShouldReturnCombatResolution_WhenZeroDamage|FullyQualifiedName~CombatTargetFixtureApiTests" + +# Full server suite before merge +dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +Manual: no Bruno changes required (store not on wire). Confirm dev fixture still resets HP + aggro between Bruno runs if exercising combat spines locally. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index f61a95a..1c5af70 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Text; +using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Gigs; @@ -568,6 +569,28 @@ public sealed class AbilityCastApiTests Assert.Equal(0, body.CombatResolution!.DamageDealt); Assert.Equal(100, body.CombatResolution.TargetRemainingHp); Assert.False(body.CombatResolution.TargetDefeated); + var threatStore = factory.Services.GetRequiredService(); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat); + Assert.Null(threat.AggroHolderPlayerId); + } + + [Fact] + public async Task PostAbilityCast_ShouldSetAggroHolder_WhenDamagingCastAccepted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var threatStore = factory.Services.GetRequiredService(); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + + // Act + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", PulseCastRequest()); + response.EnsureSuccessStatusCode(); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat); + + // Assert + Assert.Equal("dev-local-1", threat.AggroHolderPlayerId); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index f3d748f..b7d7a56 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -20,7 +20,9 @@ public sealed class CombatTargetFixtureApiTests await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var store = factory.Services.GetRequiredService(); + var threatStore = factory.Services.GetRequiredService(); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); // Act var response = await client.PostAsJsonAsync( @@ -38,6 +40,8 @@ public sealed class CombatTargetFixtureApiTests store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee); Assert.Equal(100, melee.CurrentHp); Assert.False(melee.Defeated); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat); + Assert.Null(threat.AggroHolderPlayerId); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/AggroOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/AggroOperationsTests.cs new file mode 100644 index 0000000..bdd64e6 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/AggroOperationsTests.cs @@ -0,0 +1,123 @@ +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +public sealed class AggroOperationsTests +{ + private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) + { + var store = new InMemoryPositionStateStore(Options.Create(new GamePositionOptions + { + DevPlayerId = "dev-local-1", + DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z }, + })); + return store; + } + + [Fact] + public void TryAcquire_ShouldSetHolder_WhenRowEmpty() + { + // Arrange + var threatStore = new InMemoryThreatStateStore(); + // Act + var acquired = AggroOperations.TryAcquire( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + threatStore); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.True(acquired); + Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId); + } + + [Fact] + public void TryAcquire_ShouldNoOp_WhenHolderAlreadySet() + { + // Arrange + var threatStore = new InMemoryThreatStateStore(); + _ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore); + // Act + var secondAcquire = AggroOperations.TryAcquire( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + threatStore); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.False(secondAcquire); + Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId); + } + + [Fact] + public void TryClearOnLeashForPlayer_ShouldClearHolder_WhenPlayerBeyondLeashRadius() + { + // Arrange + var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var positions = CreatePositionStoreAt(0, 0, 0); + _ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore); + positions.TryApplyMoveTarget("dev-local-1", 12, 0, 12, out _); + // Act + AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.Null(snapshot.AggroHolderPlayerId); + } + + [Fact] + public void TryClearOnLeashForPlayer_ShouldKeepHolder_WhenPlayerInsideLeashRadius() + { + // Arrange + var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var positions = CreatePositionStoreAt(0, 0, 0); + _ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore); + positions.TryApplyMoveTarget("dev-local-1", 1, 0, 1, out _); + // Act + AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId); + } + + [Fact] + public void TryClearOnLeashForPlayer_ShouldKeepHolder_WhenPlayerAtExactLeashRadius() + { + // Arrange + var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var positions = CreatePositionStoreAt(0, 0, 0); + _ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore); + // Melee anchor (-3, -3); leash 16.0 — X = 13 is exactly 16 m horizontal from anchor. + positions.TryApplyMoveTarget("dev-local-1", 13, 0, -3, out _); + // Act + AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId); + } + + [Fact] + public void TryAcquire_ShouldSetHolderAgain_AfterLeashClear() + { + // Arrange + var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var positions = CreatePositionStoreAt(0, 0, 0); + _ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore); + positions.TryApplyMoveTarget("dev-local-1", 12, 0, 12, out _); + AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore); + positions.TryApplyMoveTarget("dev-local-1", 0, 0, 0, out _); + // Act + var reacquired = AggroOperations.TryAcquire( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + threatStore); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.True(reacquired); + Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/AggroThreatIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/AggroThreatIntegrationTests.cs new file mode 100644 index 0000000..fb33a1c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/AggroThreatIntegrationTests.cs @@ -0,0 +1,113 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +/// HTTP integration tests for NEO-92 aggro acquire + leash clear wiring. +public sealed class AggroThreatIntegrationTests +{ + private static async Task BindSlot0PulseAsync(HttpClient client) + { + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }], + }); + post.EnsureSuccessStatusCode(); + } + + private static async Task LockMeleeAsync(HttpClient client) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, + }); + response.EnsureSuccessStatusCode(); + } + + private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/move-stream", + new MoveStreamRequest + { + SchemaVersion = MoveStreamRequest.CurrentSchemaVersion, + Targets = [new PositionVector { X = x, Y = y, Z = z }], + }); + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task CastThenMoveBeyondLeash_ShouldAcquireThenClearAggroHolder() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var threatStore = factory.Services.GetRequiredService(); + await BindSlot0PulseAsync(client); + await LockMeleeAsync(client); + + // Act + var castResponse = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, + }); + castResponse.EnsureSuccessStatusCode(); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterCast); + await TeleportDevPlayerAsync(client, 12, 0, 12); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterMove); + + // Assert + Assert.Equal("dev-local-1", afterCast.AggroHolderPlayerId); + Assert.Null(afterMove.AggroHolderPlayerId); + } + + [Fact] + public async Task ReacquireAfterLeashClear_ShouldSetHolderOnNextDamagingCast() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + var threatStore = factory.Services.GetRequiredService(); + await BindSlot0PulseAsync(client); + await LockMeleeAsync(client); + var cast = new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, + }; + var firstCast = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + firstCast.EnsureSuccessStatusCode(); + await TeleportDevPlayerAsync(client, 12, 0, 12); + await TeleportDevPlayerAsync(client, 0, 0, 0); + await LockMeleeAsync(client); + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + + // Act + var secondCast = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + secondCast.EnsureSuccessStatusCode(); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterSecondCast); + + // Assert + Assert.Equal("dev-local-1", afterSecondCast.AggroHolderPlayerId); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryThreatStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryThreatStateStoreTests.cs new file mode 100644 index 0000000..70b5492 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryThreatStateStoreTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +public sealed class InMemoryThreatStateStoreTests +{ + [Fact] + public void TryGet_ShouldLazyInitializeEmptyHolder_ForKnownNpcInstance() + { + // Arrange + var store = new InMemoryThreatStateStore(); + // Act + var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.True(found); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.NpcInstanceId); + Assert.Null(snapshot.AggroHolderPlayerId); + } + + [Fact] + public void TryGet_ShouldReturnFalse_ForUnknownNpcInstance() + { + // Arrange + var store = new InMemoryThreatStateStore(); + // Act + var found = store.TryGet("prototype_target_alpha", out var snapshot); + // Assert + Assert.False(found); + Assert.Equal(default, snapshot); + } + + [Fact] + public void TrySetHolder_ShouldPersistLowercaseHolder_AndClearOnNull() + { + // Arrange + var store = new InMemoryThreatStateStore(); + // Act + var set = store.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, " Dev-Local-1 "); + store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var withHolder); + var cleared = store.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); + store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterClear); + // Assert + Assert.True(set); + Assert.Equal("dev-local-1", withHolder.AggroHolderPlayerId); + Assert.True(cleared); + Assert.Null(afterClear.AggroHolderPlayerId); + } + + [Fact] + public void TrySetHolder_ShouldReturnFalse_ForUnknownNpcInstance() + { + // Arrange + var store = new InMemoryThreatStateStore(); + // Act + var set = store.TrySetHolder("unknown_npc", "dev-local-1"); + // Assert + Assert.False(set); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs index 583ac24..18fabe1 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs @@ -3,7 +3,11 @@ using System.Net.Http.Json; using System.Text; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Tests; using Xunit; namespace NeonSprawl.Server.Tests.Game.PositionState; @@ -186,4 +190,52 @@ public class MoveCommandApiTests var text = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain("reasonCode", text, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public async Task PostMove_ShouldClearAggroHolder_WhenPlayerMovesBeyondLeashRadius() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var threatStore = factory.Services.GetRequiredService(); + await client.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }], + }); + await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, + }); + var cast = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, + }); + cast.EnsureSuccessStatusCode(); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterCast); + var cmd = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 12, Y = 0, Z = 12 }, + }; + + // Act + var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd); + threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterMove); + + // Assert + Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode); + Assert.Equal("dev-local-1", afterCast.AggroHolderPlayerId); + Assert.Null(afterMove.AggroHolderPlayerId); + } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index cb18bda..323d5b5 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -64,6 +64,8 @@ public static class AbilityCastApi IPlayerAbilityCooldownStore cooldowns, IAbilityDefinitionRegistry abilities, ICombatEntityHealthStore healthStore, + IThreatStateStore threatStore, + INpcBehaviorDefinitionRegistry behaviorRegistry, IPlayerGigProgressionStore gigStore, ILoggerFactory loggerFactory, TimeProvider clock) => @@ -223,6 +225,16 @@ public static class AbilityCastApi normalizedRequest); } + if (combatResult.DamageDealt > 0) + { + AggroOperations.TryClearOnLeashForPlayer( + id, + positions, + behaviorRegistry, + threatStore); + AggroOperations.TryAcquire(lookupKey, id, threatStore); + } + cooldowns.StartCooldown( id, body.SlotIndex, diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 4caee23..0d3b4ac 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -9,7 +9,7 @@ public static class CombatTargetFixtureApi { app.MapPost( "/game/__dev/combat-targets-fixture", - (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore) => + (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore) => { if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) { @@ -24,6 +24,8 @@ public static class CombatTargetFixtureApi } } + AggroOperations.ClearAllPrototypeHolders(threatStore); + return Results.Json( new CombatTargetFixtureResponse { diff --git a/server/NeonSprawl.Server/Game/Npc/AggroOperations.cs b/server/NeonSprawl.Server/Game/Npc/AggroOperations.cs new file mode 100644 index 0000000..6190cea --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/AggroOperations.cs @@ -0,0 +1,108 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.World; + +namespace NeonSprawl.Server.Game.Npc; + +/// Deterministic prototype aggro acquire and leash-clear rules (NEO-92). +public static class AggroOperations +{ + /// + /// Sets aggro holder to when the NPC row is empty. + /// Returns true only when holder was empty and is now set. + /// + public static bool TryAcquire(string npcInstanceId, string playerId, IThreatStateStore threatStore) + { + var npcKey = NormalizeId(npcInstanceId); + var playerKey = NormalizeId(playerId); + if (npcKey.Length == 0 || playerKey.Length == 0 || !PrototypeNpcRegistry.TryGet(npcKey, out _)) + { + return false; + } + + if (!threatStore.TryGet(npcKey, out var current)) + { + return false; + } + + if (!string.IsNullOrEmpty(current.AggroHolderPlayerId)) + { + return false; + } + + return threatStore.TrySetHolder(npcKey, playerKey); + } + + /// + /// Clears aggro on every prototype NPC instance where is holder + /// and horizontal distance from the NPC anchor exceeds catalog leashRadius. + /// + public static void TryClearOnLeashForPlayer( + string playerId, + IPositionStateStore positions, + INpcBehaviorDefinitionRegistry behaviorRegistry, + IThreatStateStore threatStore) + { + var playerKey = NormalizeId(playerId); + if (playerKey.Length == 0 || !positions.TryGetPosition(playerKey, out var playerSnap)) + { + return; + } + + foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + TryClearOnLeashForNpc( + npcInstanceId, + playerKey, + in playerSnap, + behaviorRegistry, + threatStore); + } + } + + /// Clears aggro for one NPC when the holder is outside that NPC's leash radius. + public static void TryClearOnLeashForNpc( + string npcInstanceId, + string playerIdLowercase, + in PositionSnapshot playerSnap, + INpcBehaviorDefinitionRegistry behaviorRegistry, + IThreatStateStore threatStore) + { + var npcKey = NormalizeId(npcInstanceId); + if (npcKey.Length == 0 || + !PrototypeNpcRegistry.TryGet(npcKey, out var entry) || + !threatStore.TryGet(npcKey, out var threat) || + string.IsNullOrEmpty(threat.AggroHolderPlayerId) || + !string.Equals(threat.AggroHolderPlayerId, playerIdLowercase, StringComparison.Ordinal)) + { + return; + } + + if (!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior)) + { + return; + } + + var distance = HorizontalReach.HorizontalDistance( + playerSnap.X, + playerSnap.Z, + entry.X, + entry.Z); + + if (distance > behavior.LeashRadius) + { + threatStore.TryClearHolder(npcKey); + } + } + + /// Clears aggro holders on all prototype NPC instances (dev fixture). + public static void ClearAllPrototypeHolders(IThreatStateStore threatStore) + { + foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + threatStore.TryClearHolder(npcInstanceId); + } + } + + private static string NormalizeId(string? raw) => + raw?.Trim().ToLowerInvariant() ?? string.Empty; +} diff --git a/server/NeonSprawl.Server/Game/Npc/IThreatStateStore.cs b/server/NeonSprawl.Server/Game/Npc/IThreatStateStore.cs new file mode 100644 index 0000000..603f7b7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/IThreatStateStore.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Session in-memory aggro holder store keyed by prototype NPC instance id (NEO-92). +/// HTTP projection lands in NEO-94; NEO-93 reads holder for behavior state transitions. +/// +public interface IThreatStateStore +{ + /// Reads threat for a known prototype NPC instance. Lazy-initializes an empty holder row. + bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out ThreatStateSnapshot snapshot); + + /// Sets or clears the aggro holder for a known prototype NPC instance. + bool TrySetHolder(string? npcInstanceId, string? playerIdLowercaseOrNull); + + /// Clears the aggro holder for a known prototype NPC instance. + bool TryClearHolder(string? npcInstanceId); +} diff --git a/server/NeonSprawl.Server/Game/Npc/InMemoryThreatStateStore.cs b/server/NeonSprawl.Server/Game/Npc/InMemoryThreatStateStore.cs new file mode 100644 index 0000000..2a5ca80 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/InMemoryThreatStateStore.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Npc; + +/// Thread-safe in-memory aggro holders for prototype NPC instances (NEO-92). +public sealed class InMemoryThreatStateStore : IThreatStateStore +{ + private readonly ConcurrentDictionary holderByNpcId = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public bool TryGet(string? npcInstanceId, out ThreatStateSnapshot snapshot) + { + var key = NormalizeNpcInstanceId(npcInstanceId); + if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _)) + { + snapshot = default; + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + if (!holderByNpcId.TryGetValue(key, out var holder)) + { + holder = null; + holderByNpcId[key] = holder; + } + + snapshot = new ThreatStateSnapshot(key, holder); + return true; + } + } + + /// + public bool TrySetHolder(string? npcInstanceId, string? playerIdLowercaseOrNull) + { + var key = NormalizeNpcInstanceId(npcInstanceId); + if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _)) + { + return false; + } + + var normalizedHolder = NormalizePlayerId(playerIdLowercaseOrNull); + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + holderByNpcId[key] = normalizedHolder; + return true; + } + } + + /// + public bool TryClearHolder(string? npcInstanceId) => TrySetHolder(npcInstanceId, null); + + private static string NormalizeNpcInstanceId(string? npcInstanceId) => + npcInstanceId?.Trim().ToLowerInvariant() ?? string.Empty; + + private static string? NormalizePlayerId(string? playerId) + { + if (string.IsNullOrWhiteSpace(playerId)) + { + return null; + } + + return playerId.Trim().ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/ThreatStateServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/ThreatStateServiceCollectionExtensions.cs new file mode 100644 index 0000000..1a3b354 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/ThreatStateServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Registers prototype threat/aggro state store (NEO-92). +public static class ThreatStateServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddThreatStateStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/ThreatStateSnapshot.cs b/server/NeonSprawl.Server/Game/Npc/ThreatStateSnapshot.cs new file mode 100644 index 0000000..6d21c01 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/ThreatStateSnapshot.cs @@ -0,0 +1,6 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Per-NPC aggro holder snapshot (NEO-92). +/// Lowercase prototype NPC instance id. +/// Lowercase route player id holding aggro, or when empty. +public readonly record struct ThreatStateSnapshot(string NpcInstanceId, string? AggroHolderPlayerId); diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs index e27a3a9..9ff2f65 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.PositionState; @@ -28,7 +29,7 @@ public static class PositionStateApi app.MapPost( "/game/players/{id}/move", - (string id, MoveCommandRequest? body, IPositionStateStore store, IOptions gameOptions) => + (string id, MoveCommandRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions gameOptions) => { if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion) { @@ -62,6 +63,8 @@ public static class PositionStateApi return Results.NotFound(); } + AggroOperations.TryClearOnLeashForPlayer(id, store, behaviorRegistry, threatStore); + var response = new PositionStateResponse { SchemaVersion = PositionStateResponse.CurrentSchemaVersion, @@ -74,7 +77,7 @@ public static class PositionStateApi app.MapPost( "/game/players/{id}/move-stream", - (string id, MoveStreamRequest? body, IPositionStateStore store, IOptions gameOptions) => + (string id, MoveStreamRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions gameOptions) => { if (body is null || body.SchemaVersion != MoveStreamRequest.CurrentSchemaVersion) { @@ -121,6 +124,8 @@ public static class PositionStateApi lastSnap = snap; } + AggroOperations.TryClearOnLeashForPlayer(id, store, behaviorRegistry, threatStore); + var response = new PositionStateResponse { SchemaVersion = PositionStateResponse.CurrentSchemaVersion, diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 9178f58..e0a4e1d 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); +builder.Services.AddThreatStateStore(); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); diff --git a/server/README.md b/server/README.md index d51bf23..80d588c 100644 --- a/server/README.md +++ b/server/README.md @@ -152,7 +152,20 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m curl -sS -i "http://localhost:5253/game/world/combat-targets" ``` -**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. +**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`** and clears all prototype aggro holders (**NEO-92**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. + +## Threat / aggro state (NEO-92) + +Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`InMemoryThreatStateStore`**, keyed by **`PrototypeNpcRegistry`** instance id. Each row stores an optional **`AggroHolderPlayerId`** (lowercase route player id). + +| Rule | Behavior | +|------|----------| +| **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. | +| **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. | +| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. | +| **HTTP read** | Not exposed yet — **`GET /game/world/npc-runtime-snapshot`** lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). | + +Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md). ## Combat engine (NEO-81)