diff --git a/client/README.md b/client/README.md index 431dbce..f5036ad 100644 --- a/client/README.md +++ b/client/README.md @@ -284,7 +284,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. -**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. +**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. **Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor. diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index 3f511da..6e879e3 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -14,6 +14,15 @@ extends Node3D ## [signal zoom_band_changed] when schema exists; optional occlusion/perf counters per module doc. signal zoom_band_changed(new_index: int, distance: float) +## Accumulated vertical pan before one discrete zoom band step (macOS trackpad). +const PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD: float = 1.25 + +## Accumulated log(factor) before one pinch zoom band step. +const MAGNIFY_ZOOM_LOG_ACCUMULATOR_THRESHOLD: float = 0.15 + +## Minimum ms between trackpad-driven zoom band steps (avoids flick bursts). +const TRACKPAD_ZOOM_STEP_COOLDOWN_MSEC: int = 150 + const CameraStateScript := preload("res://scripts/camera_state.gd") const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd") const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd") @@ -49,6 +58,9 @@ var _state = null var _smoothed_eye: Vector3 = Vector3.ZERO var _orbit_yaw_rad: float = 0.0 var _zoom_band_index: int = 0 +var _pan_zoom_accumulator: float = 0.0 +var _magnify_zoom_accumulator: float = 0.0 +var _last_trackpad_zoom_step_msec: int = -1_000_000 var _warned_missing_follow_target: bool = false ## Keys: [code]int[/code] [method Object.get_instance_id] of the occluder body. Values: Array of ## {mesh, surface, original} restore entries. @@ -128,12 +140,64 @@ func _exit_tree() -> void: func _unhandled_input(event: InputEvent) -> void: if event.is_echo(): return + if event is InputEventPanGesture: + var pan: InputEventPanGesture = event as InputEventPanGesture + var pan_result: Dictionary = consume_pan_zoom_accumulator(_pan_zoom_accumulator, pan.delta) + _pan_zoom_accumulator = float(pan_result.get("accumulator", _pan_zoom_accumulator)) + _try_apply_trackpad_zoom_step(int(pan_result.get("step", 0))) + return + if event is InputEventMagnifyGesture: + var mag: InputEventMagnifyGesture = event as InputEventMagnifyGesture + var mag_result: Dictionary = consume_magnify_zoom_accumulator( + _magnify_zoom_accumulator, mag.factor + ) + _magnify_zoom_accumulator = float(mag_result.get("accumulator", _magnify_zoom_accumulator)) + _try_apply_trackpad_zoom_step(int(mag_result.get("step", 0))) + return if event.is_action_pressed("camera_zoom_in"): _apply_zoom_step(-1) elif event.is_action_pressed("camera_zoom_out"): _apply_zoom_step(1) +func _try_apply_trackpad_zoom_step(step: int) -> void: + if step == 0: + return + var now_msec: int = Time.get_ticks_msec() + if now_msec - _last_trackpad_zoom_step_msec < TRACKPAD_ZOOM_STEP_COOLDOWN_MSEC: + return + _last_trackpad_zoom_step_msec = now_msec + _apply_zoom_step(step) + + +## Accumulates trackpad pan delta; emits at most **one** band step per call. +static func consume_pan_zoom_accumulator( + accumulator: float, delta: Vector2, threshold: float = PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD +) -> Dictionary: + if absf(delta.y) <= absf(delta.x): + return {"accumulator": accumulator, "step": 0} + var next: float = accumulator + delta.y + if next <= -threshold: + return {"accumulator": next + threshold, "step": -1} + if next >= threshold: + return {"accumulator": next - threshold, "step": 1} + return {"accumulator": next, "step": 0} + + +## Accumulates pinch log-scale; emits at most **one** band step per call. +static func consume_magnify_zoom_accumulator( + accumulator: float, factor: float, threshold: float = MAGNIFY_ZOOM_LOG_ACCUMULATOR_THRESHOLD +) -> Dictionary: + if factor <= 0.0: + return {"accumulator": accumulator, "step": 0} + var next: float = accumulator + log(factor) + if next >= threshold: + return {"accumulator": next - threshold, "step": -1} + if next <= -threshold: + return {"accumulator": next + threshold, "step": 1} + return {"accumulator": next, "step": 0} + + ## **Near** = lower band index. Zoom in → closer → decrease index (clamped). func _apply_zoom_step(delta_bands: int) -> void: if not _zoom_config_valid(): diff --git a/client/test/isometric_follow_camera_test.gd b/client/test/isometric_follow_camera_test.gd index c709394..6d1ca34 100644 --- a/client/test/isometric_follow_camera_test.gd +++ b/client/test/isometric_follow_camera_test.gd @@ -138,3 +138,45 @@ func test_occluder_override_key_is_valid_false_after_free() -> void: var ok: bool = IsoScript.occluder_override_key_is_valid(n) # Assert assert_that(ok).is_false() + + +func test_consume_pan_zoom_accumulator_ignores_horizontal_pan() -> void: + # Arrange + # Act + var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.2, 0.0)) + # Assert + assert_that(int(result.get("step", -9))).is_equal(0) + assert_that(float(result.get("accumulator", -1.0))).is_equal(0.0) + + +func test_consume_pan_zoom_accumulator_requires_threshold_before_step() -> void: + # Arrange + # Act + var partial: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.0, -0.4)) + var complete: Dictionary = IsoScript.consume_pan_zoom_accumulator( + float(partial.get("accumulator", 0.0)), Vector2(0.0, -0.9) + ) + # Assert + assert_that(int(partial.get("step", -9))).is_equal(0) + assert_that(int(complete.get("step", -9))).is_equal(-1) + + +func test_consume_pan_zoom_accumulator_emits_one_step_per_call_even_for_large_delta() -> void: + # Arrange + # Act + var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.0, -5.0)) + # Assert + assert_that(int(result.get("step", -9))).is_equal(-1) + assert_that(float(result.get("accumulator", 0.0))).is_equal(-3.75) + + +func test_consume_magnify_zoom_accumulator_requires_threshold_before_step() -> void: + # Arrange + # Act + var partial: Dictionary = IsoScript.consume_magnify_zoom_accumulator(0.0, 1.05) + var complete: Dictionary = IsoScript.consume_magnify_zoom_accumulator( + float(partial.get("accumulator", 0.0)), 1.12 + ) + # Assert + assert_that(int(partial.get("step", -9))).is_equal(0) + assert_that(int(complete.get("step", -9))).is_equal(-1) diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 1738afd..225f05b 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-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) | +| **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-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine 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 @@ -85,6 +85,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j **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 runtime state machine (NEO-93):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** in `server/NeonSprawl.Server/Game/Npc/` — lazy tick advance for **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**; holder sync from **`IThreatStateStore`**; HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-93 implementation plan](../../plans/NEO-93-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 be10ffb..c6e8f5b 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). **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 | +| 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). **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). **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), [NEO-93](../../plans/NEO-93-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 10b0198..5a36305 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -231,7 +231,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d **In scope** -- `NpcRuntimeOperations.AdvanceAll` (or per-id) called from snapshot GET handler with monotonic clock delta cap. +- `NpcRuntimeOperations.AdvanceAll` (or per-id) — engine + DI only in NEO-93; [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) invokes from **`GET /game/world/npc-runtime-snapshot`** with monotonic clock delta cap. - State transitions emit internal events for telemetry hooks (E5M2-10). - Unit tests with injected clock for deterministic transitions. @@ -241,9 +241,12 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d **Acceptance criteria** -- [ ] Aggro'd NPC enters telegraph after `attackCooldownSeconds` elapses from last attack. -- [ ] Windup duration matches `telegraphWindupSeconds` from behavior def. -- [ ] Idle NPC with no aggro holder does not telegraph. +- [x] Aggro'd NPC enters telegraph after `attackCooldownSeconds` elapses from last attack. +- [x] Windup duration matches `telegraphWindupSeconds` from behavior def. +- [x] Idle NPC with no aggro holder does not telegraph. +- [x] Full transition chain **`idle` → `aggro` → `telegraph_windup` → `attack_execute` → `recover`** reachable with holder set (deterministic injected clock). + +**Landed ([NEO-93](https://linear.app/neon-sprawl/issue/NEO-93)):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven state machine, lazy tick with delta cap; plan [NEO-93-implementation-plan.md](NEO-93-implementation-plan.md). --- diff --git a/docs/plans/NEO-93-implementation-plan.md b/docs/plans/NEO-93-implementation-plan.md new file mode 100644 index 0000000..a7ec07f --- /dev/null +++ b/docs/plans/NEO-93-implementation-plan.md @@ -0,0 +1,162 @@ +# NEO-93 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-93 | +| **Title** | E5M2-07: NPC behavior state machine + lazy tick advance | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-93/e5m2-07-npc-behavior-state-machine-lazy-tick-advance | +| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-07** | +| **Branch** | `NEO-93-npc-behavior-state-machine-lazy-tick` | +| **Precursor** | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) — `IThreatStateStore` + aggro acquire/leash clear (**Done** on `main`); [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) — `INpcBehaviorDefinitionRegistry` (**Done** on `main`) | +| **Pattern** | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) — in-memory session store + static ops + DI; no HTTP wire until downstream story | +| **Blocks** | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) — snapshot GET + DTO projection; [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) — telemetry hook sites | +| **Client counterpart** | None for this slice — runtime projection lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94); Godot poll + HUD in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Proximity auto-aggro** | Add idle→aggro when player enters `aggroRadius` without a damaging cast? | **Holder-only** — drive idle→aggro from `IThreatStateStore` only (NEO-92 first-hit acquire); defer proximity to a follow-up issue. | **User:** holder-only. | +| **Lazy tick wiring** | Wire `AdvanceAll` to HTTP in NEO-93? | **Engine + DI only** — no HTTP; [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) invokes `AdvanceAll` from `GET /game/world/npc-runtime-snapshot`. | **User:** engine-only. | +| **attack_execute semantics** | Apply player damage in NEO-93? | **Instant pass-through** — transition attack_execute → recover in the same advance step; no `IPlayerCombatHealthStore` ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). | **Adopted** — E5M2-09 owns damage. | +| **First telegraph after acquire** | Wait full `attackCooldownSeconds` or telegraph immediately? | **Wait full cooldown** — E5M2-07 AC: “enters telegraph after `attackCooldownSeconds` elapses from last attack”; treat first cycle as cooldown starting at aggro entry. | **Adopted** — backlog AC. | +| **Recover phase** | Duration when no catalog field? | **`recover` is the post-attack cooldown wait** — hold until `attackCooldownSeconds` elapses, then → `telegraph_windup` while holder remains. | **Adopted** — closes idle→aggro→telegraph→attack→recover loop. | +| **Telemetry events** | Emit `npc_state_transition` now? | **Comment-only hook sites** at transition points; full instrumentation in [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96). | **Adopted** — E5M2-10 scope. | + +## Goal, scope, and out-of-scope + +**Goal:** Server-owned NPC behavior state machine with **lazy tick advance** — prototype states **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**, with phase durations from the behavior catalog. + +**In scope (from Linear + [E5M2-07](E5M2-prototype-backlog.md#e5m2-07--npc-behavior-state-machine--lazy-tick-advance)):** + +- `INpcRuntimeStateStore` + in-memory implementation for all **`PrototypeNpcRegistry`** instance ids. +- `NpcRuntimeOperations.AdvanceAll` (monotonic clock, per-advance **delta cap**) reading **`IThreatStateStore`** + **`INpcBehaviorDefinitionRegistry`**. +- Holder sync: empty holder → **`idle`**; holder set while **`idle`** → **`aggro`** (no proximity acquire). +- Catalog-driven timings: **`attackCooldownSeconds`**, **`telegraphWindupSeconds`**. +- Internal active-telegraph snapshot while in **`telegraph_windup`** (for NEO-94 DTO mapping). +- Comment-only telemetry hook sites at state transitions. +- Extend dev **`combat-targets-fixture`** to reset runtime rows. +- Unit tests with injected **`TimeProvider`** / controlled advance deltas (AAA). + +**Out of scope (from Linear + backlog):** + +- HTTP DTO projection — **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). +- Player damage on attack complete ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). +- Proximity auto-aggro inside **`aggroRadius`** without damaging cast. +- Godot / client HUD ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)). +- `docs/manual-qa/NEO-93.md` — server-only; unit tests verify AC (NEO-92 pattern). + +## Acceptance criteria checklist + +- [x] Aggro'd NPC enters telegraph after `attackCooldownSeconds` elapses from last attack (or from aggro entry on first cycle). +- [x] Windup duration matches `telegraphWindupSeconds` from behavior def. +- [x] Idle NPC with no aggro holder does not telegraph. +- [x] Full transition chain **`idle` → `aggro` → `telegraph_windup` → `attack_execute` → `recover`** is reachable with holder set (deterministic injected clock). + +## Implementation reconciliation (shipped) + +- **`NpcBehaviorState`**, **`INpcRuntimeStateStore`**, **`InMemoryNpcRuntimeStateStore`**, and **`NpcRuntimeOperations.AdvanceAll`** landed in `Game/Npc/`. +- Holder-only idle→aggro sync from **`IThreatStateStore`**; no proximity auto-aggro. +- **`attack_execute`** chains instantly to **`recover`** within the same advance step (no player damage — NEO-95). +- Dev **`combat-targets-fixture`** resets runtime rows alongside HP + threat. +- **25** NEO-93-focused tests green (`NpcRuntimeOperationsTests`, `InMemoryNpcRuntimeStateStoreTests`, fixture extension). + +## Technical approach + +### State machine (per prototype NPC instance) + +| State | Enter when | Exit when | +|-------|------------|-----------| +| **`idle`** | No aggro holder | Holder set → **`aggro`** (phase start = now) | +| **`aggro`** | Holder set from **`idle`**, or after **`recover`** cooldown (optional shortcut: recover → telegraph directly) | Elapsed ≥ **`attackCooldownSeconds`** → **`telegraph_windup`** | +| **`telegraph_windup`** | Cooldown complete while holder set | Elapsed ≥ **`telegraphWindupSeconds`** → **`attack_execute`** | +| **`attack_execute`** | Windup complete | Same advance step → **`recover`** (no damage in NEO-93) | +| **`recover`** | Attack step complete | Elapsed ≥ **`attackCooldownSeconds`** → **`telegraph_windup`** | + +**Holder cleared** (leash break / fixture reset) from any state → **`idle`**, clear active telegraph. + +**No proximity acquire:** `AdvanceAll` never sets holders; only reads **`IThreatStateStore`**. + +### Lazy tick advance + +1. **`NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds = 5.0)`** + - Compute `delta = min(nowUtc - runtimeStore.LastAdvancedUtc, maxDeltaSeconds)`; skip when `delta <= 0`. + - Update **`LastAdvancedUtc = nowUtc`** after processing all instances. + - For each **`PrototypeNpcRegistry`** id: load threat holder + behavior def + runtime row; apply holder sync then phase transitions consuming **`delta`** (may multi-step within one advance when delta spans phases — loop until delta exhausted or waiting on timer). + +2. **`INpcRuntimeStateStore`** + - **`DateTimeOffset LastAdvancedUtc`** (lazy-init to `DateTimeOffset.MinValue` so first advance applies full delta). + - **`TryGet(npcInstanceId, out NpcRuntimeStateSnapshot)`** — `BehaviorState`, `PhaseStartedUtc`, optional **`ActiveTelegraph`** (`TelegraphId`, `WindupStartedUtc`). + - **`TryResetPrototypeRow(npcInstanceId)`** / **`ResetAllPrototypeRows()`** for fixture. + +3. **`NpcBehaviorState`** enum — wire string values: `idle`, `aggro`, `telegraph_windup`, `attack_execute`, `recover` (stable for NEO-94 JSON). + +4. **DI** — **`AddNpcRuntimeStateStore()`** singleton alongside **`AddThreatStateStore()`** in **`Program.cs`**. + +5. **Dev fixture** — after HP + threat reset, call **`NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore)`**. + +6. **Docs** — **`server/README.md`** NPC runtime section: states, lazy advance (NEO-94 wires poll), fixture reset, no HTTP yet. + +### Catalog timings (test expectations) + +| Instance | Behavior def | `attackCooldownSeconds` | `telegraphWindupSeconds` | +|----------|--------------|---------------------------|--------------------------| +| `prototype_npc_melee` | `prototype_melee_pressure` | 3.0 | 1.5 | +| `prototype_npc_ranged` | `prototype_ranged_control` | 4.0 | 2.0 | +| `prototype_npc_elite` | `prototype_elite_mini_boss` | 5.0 | 2.5 | + +### Example cycle (melee, holder already set) + +``` +t=0 idle + holder → aggro +t=3 aggro → telegraph_windup +t=4.5 telegraph_windup → attack_execute → recover (instant attack stub) +t=7.5 recover → telegraph_windup (next cycle) +``` + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs` | Enum of prototype behavior states (stable wire names). | +| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs` | Immutable runtime row + optional active telegraph snapshot. | +| `server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs` | Store contract + `LastAdvancedUtc` for lazy tick. | +| `server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs` | Thread-safe in-memory prototype implementation. | +| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs` | `AdvanceAll`, `ResetAllPrototypeRows`, holder sync + phase transitions. | +| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs` | DI registration for `INpcRuntimeStateStore`. | +| `server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs` | AAA unit tests with fake `TimeProvider` / manual advance timestamps. | +| `server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs` | AAA store tests: lazy rows, reset, unknown id. | +| `docs/plans/NEO-93-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Program.cs` | Register `AddNpcRuntimeStateStore()`. | +| `server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs` | Reset runtime state rows alongside HP + threat on dev fixture. | +| `server/README.md` | Document NPC runtime state machine, lazy advance, fixture reset; note NEO-94 HTTP projection. | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | NEO-93 landed note when complete. | +| `docs/plans/E5M2-prototype-backlog.md` | E5M2-07 acceptance checkboxes + landed note when complete. | + +## Tests + +| File | Coverage | +|------|----------| +| `NpcRuntimeOperationsTests.cs` | Idle + no holder stays idle; idle + holder → aggro; no telegraph without holder; aggro → telegraph after melee `attackCooldownSeconds` (3.0); windup duration matches melee `telegraphWindupSeconds` (1.5); full chain through attack_execute → recover → second telegraph; holder clear mid-windup → idle; elite/ranged catalog timings spot check; delta cap does not overshoot single phase in one advance. | +| `InMemoryNpcRuntimeStateStoreTests.cs` | Unknown npc id fails; lazy init rows; reset all prototype ids; `LastAdvancedUtc` update. | +| `CombatTargetFixtureApiTests.cs` | Extend existing fixture test — after reset, runtime rows are idle (no active telegraph). | + +No Bruno changes (no HTTP wire). No `docs/manual-qa/NEO-93.md`. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|---------------------|--------| +| **Linear blockedBy NEO-92 / NEO-89** | Proceed — both **Done** on `main`. | **adopted** | +| **No HTTP in NEO-93** | Accept — NEO-94 calls `AdvanceAll` before snapshot read; unit tests drive advance directly. | **adopted** (kickoff) | +| **Proximity auto-aggro** | Deferred — holder-only per kickoff; E5M2 default proximity re-aggro is a follow-up if still desired. | **deferred** | +| **Multi-step advance in one poll** | Loop phase transitions until delta consumed or phase needs more time — prevents stuck states when client polls slowly. | **adopted** | +| **attack_execute duration** | Zero-duration stub; NEO-95 adds damage resolve at telegraph complete. | **adopted** | +| **Thread safety** | Mirror `InMemoryThreatStateStore` lock pattern for three fixed prototype ids. | **adopted** | diff --git a/docs/reviews/2026-05-27-NEO-93.md b/docs/reviews/2026-05-27-NEO-93.md new file mode 100644 index 0000000..ebef78e --- /dev/null +++ b/docs/reviews/2026-05-27-NEO-93.md @@ -0,0 +1,56 @@ +# Code review — NEO-93 (E5M2-07) + +**Date:** 2026-05-27 +**Scope:** Branch `NEO-93-npc-behavior-state-machine-lazy-tick` vs `origin/main` — NPC runtime state machine, lazy tick advance, tests, and docs +**Base:** `origin/main` + +**Follow-up:** Code review suggestions addressed (backlog in-scope/AC alignment, AAA test layout, single-call multi-phase test, plan test count, windup→recover write simplification). + +## Verdict + +**Approve with nits** + +## Summary + +This branch delivers the E5M2-07 server spine: `INpcRuntimeStateStore`, thread-safe in-memory rows, and `NpcRuntimeOperations.AdvanceAll` driving the prototype **`idle` → `aggro` → `telegraph_windup` → `attack_execute` → `recover`** loop from catalog timings and `IThreatStateStore` holders. Kickoff decisions are honored — holder-only aggro sync, no HTTP wire, instant `attack_execute` stub, comment-only telemetry hooks, and dev fixture reset alongside HP + threat. Tests cover the acceptance paths (cooldown, windup, full melee cycle, single-call multi-phase advance, holder clear, archetype spot checks, delta cap) plus store and fixture integration; **22** focused tests passed locally. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-93-implementation-plan.md` | **Matches** — store, ops, state machine, fixture, DI, tests, and reconciliation section align with the diff. | +| `docs/plans/E5M2-prototype-backlog.md` (E5M2-07) | **Matches** — AC checkboxes (including full-chain), landed note, and engine-only in-scope bullet align with kickoff and implementation. | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — E5M2-07 landed note and runtime state machine section present. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row includes **NEO-93 landed** note and plan/README links. | +| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server-owned behavior state; no client projection in this slice (NEO-94 deferred). | +| `server/README.md` | **Matches** — NPC runtime section documents states, lazy advance, holder sync, fixture reset, and NEO-94 deferral. | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Align E5M2-07 backlog “In scope” bullet** — Change “called from snapshot GET handler” to **engine + DI only in NEO-93; NEO-94 invokes `AdvanceAll` from `GET /game/world/npc-runtime-snapshot`** so the backlog matches the adopted kickoff decision and the implementation plan.~~ **Done.** E5M2-07 in-scope bullet updated. +2. ~~**Add full-chain AC to E5M2-07 backlog** — The implementation plan checks “full transition chain reachable”; add the same checkbox to the backlog AC list for traceability with Linear/E5M2-07.~~ **Done.** Fourth AC checkbox added to E5M2-07 backlog. +3. ~~**AAA layout in new tests** — Several `NpcRuntimeOperationsTests` methods call `TryGet` in **Act** (e.g. `AdvanceAll_ShouldCompleteMeleeWindupDuration_BeforeAttackExecute` performs a mid-Act read and a second `Advance`). Move verification reads to **Assert** per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert). Non-blocking but worth fixing before NEO-94 adds more tests on this file.~~ **Done.** `TryGet` moved to Assert; windup test split into two facts. +4. ~~**Optional: single-call multi-phase test** — Add one test that advances from aggro entry to second telegraph in **one** `AdvanceAll` call (e.g. `T0` → `T0+7.5`) to lock the inner `while (cursor < windowEnd)` multi-step loop independently of sequential polls.~~ **Done.** `AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall` added. + +## Nits + +- ~~Nit: `TelegraphWindup` case writes `AttackExecute` then immediately overwrites with `Recover`; the intermediate write is never observable — harmless but could be a single `Recover` write until NEO-95 needs a visible execute phase.~~ **Done.** Windup completion writes `Recover` directly with comment for logical `attack_execute`. +- ~~Nit: Plan reconciliation cites **17** NEO-93-focused tests; local filter run reports **18** (theory counts as two cases) — trivial count drift.~~ **Done.** Plan reconciliation updated to **18**. +- Nit: `LastAdvancedUtc` is an unlocked property on the store (same pattern as typical session singletons); acceptable for prototype poll-driven advance. + +## Verification + +```bash +# NEO-93-focused tests (passed 2026-05-27) +dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~NpcRuntime|FullyQualifiedName~InMemoryNpcRuntime|FullyQualifiedName~CombatTargetFixtureApiTests" + +# Full server suite before merge +dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +Manual: no Bruno changes (no HTTP wire). Confirm dev fixture still resets HP + aggro + runtime rows between Bruno combat runs. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index b7d7a56..b609f3a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -21,8 +21,17 @@ public sealed class CombatTargetFixtureApiTests var client = factory.CreateClient(); var store = factory.Services.GetRequiredService(); var threatStore = factory.Services.GetRequiredService(); + var runtimeStore = factory.Services.GetRequiredService(); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + var windupStarted = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); + _ = runtimeStore.TryWrite( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + new NpcRuntimeStateSnapshot( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + NpcBehaviorState.TelegraphWindup, + windupStarted, + new ActiveTelegraphSnapshot("prototype_npc_melee:test", windupStarted))); // Act var response = await client.PostAsJsonAsync( @@ -42,6 +51,9 @@ public sealed class CombatTargetFixtureApiTests Assert.False(melee.Defeated); threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat); Assert.Null(threat.AggroHolderPlayerId); + runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime); + Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState); + Assert.Null(runtime.ActiveTelegraph); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs new file mode 100644 index 0000000..c4984a4 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs @@ -0,0 +1,96 @@ +using NeonSprawl.Server.Game.Npc; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +public sealed class InMemoryNpcRuntimeStateStoreTests +{ + [Fact] + public void TryGet_ShouldLazyInitializeIdleRow_ForKnownNpcInstance() + { + // Arrange + var store = new InMemoryNpcRuntimeStateStore(); + // Act + var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.True(found); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.NpcInstanceId); + Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); + Assert.Null(snapshot.ActiveTelegraph); + } + + [Fact] + public void TryGet_ShouldReturnFalse_ForUnknownNpcInstance() + { + // Arrange + var store = new InMemoryNpcRuntimeStateStore(); + // Act + var found = store.TryGet("prototype_target_alpha", out var snapshot); + // Assert + Assert.False(found); + Assert.Equal(default, snapshot); + } + + [Fact] + public void TryWrite_ShouldPersistRuntimeRow_ForKnownNpcInstance() + { + // Arrange + var store = new InMemoryNpcRuntimeStateStore(); + var started = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); + var telegraph = new ActiveTelegraphSnapshot("prototype_npc_melee:1", started); + // Act + var written = store.TryWrite( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + new NpcRuntimeStateSnapshot( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + NpcBehaviorState.TelegraphWindup, + started, + telegraph)); + store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.True(written); + Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); + Assert.Equal(started, snapshot.PhaseStartedUtc); + Assert.NotNull(snapshot.ActiveTelegraph); + Assert.Equal("prototype_npc_melee:1", snapshot.ActiveTelegraph!.Value.TelegraphId); + } + + [Fact] + public void ResetAllPrototypeRows_ShouldReturnEachInstanceToIdle() + { + // Arrange + var store = new InMemoryNpcRuntimeStateStore(); + var started = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); + foreach (var npcId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + _ = store.TryWrite( + npcId, + new NpcRuntimeStateSnapshot(npcId, NpcBehaviorState.Recover, started, null)); + } + + store.LastAdvancedUtc = started.AddSeconds(10); + // Act + store.ResetAllPrototypeRows(); + // Assert + foreach (var npcId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + store.TryGet(npcId, out var snapshot); + Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); + Assert.Null(snapshot.ActiveTelegraph); + } + + Assert.Equal(DateTimeOffset.MinValue, store.LastAdvancedUtc); + } + + [Fact] + public void LastAdvancedUtc_ShouldBeSettable() + { + // Arrange + var store = new InMemoryNpcRuntimeStateStore(); + var marker = new DateTimeOffset(2026, 5, 27, 8, 0, 0, TimeSpan.Zero); + // Act + store.LastAdvancedUtc = marker; + // Assert + Assert.Equal(marker, store.LastAdvancedUtc); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs new file mode 100644 index 0000000..203b098 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -0,0 +1,319 @@ +using NeonSprawl.Server.Game.Npc; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +public sealed class NpcRuntimeOperationsTests +{ + private static readonly DateTimeOffset T0 = + new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); + + private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior) + CreateFixture() + { + return ( + new InMemoryNpcRuntimeStateStore(), + new InMemoryThreatStateStore(), + PrototypeNpcTestFixtures.CreateBehaviorRegistry()); + } + + private static void SetHolder(IThreatStateStore threatStore, string npcId, string playerId = "dev-local-1") => + _ = threatStore.TrySetHolder(npcId, playerId); + + private static void Advance( + DateTimeOffset nowUtc, + INpcRuntimeStateStore runtimeStore, + IThreatStateStore threatStore, + INpcBehaviorDefinitionRegistry behaviorRegistry, + double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) => + NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds); + + [Fact] + public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + // Act + Advance(T0, runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); + Assert.Null(snapshot.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + // Act + Advance(T0, runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); + Assert.Equal(T0, snapshot.PhaseStartedUtc); + Assert.Null(snapshot.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + runtime.LastAdvancedUtc = T0; + // Act + Advance(T0.AddSeconds(10), runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); + Assert.Null(snapshot.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(3), runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(3), snapshot.PhaseStartedUtc); + Assert.NotNull(snapshot.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(2.9), runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); + } + + [Fact] + public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + Advance(T0.AddSeconds(3), runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(4.4), runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); + } + + [Fact] + public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + Advance(T0.AddSeconds(3), runtime, threat, behavior); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(4.5), runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(4.5), snapshot.PhaseStartedUtc); + Assert.Null(snapshot.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + Advance(T0.AddSeconds(3), runtime, threat, behavior); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(7.5), runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(7.5), snapshot.PhaseStartedUtc); + Assert.NotNull(snapshot.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, maxDeltaSeconds: 10); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(7.5), snapshot.PhaseStartedUtc); + Assert.NotNull(snapshot.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + Advance(T0.AddSeconds(3), runtime, threat, behavior); + _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); + // Act + Advance(T0.AddSeconds(3.5), runtime, threat, behavior); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); + Assert.Null(snapshot.ActiveTelegraph); + } + + [Theory] + [InlineData("prototype_npc_ranged", 4.0)] + [InlineData("prototype_npc_elite", 5.0)] + public void AdvanceAll_ShouldEnterTelegraph_AtCatalogCooldownForArchetype( + string npcInstanceId, + double attackCooldownSeconds) + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, npcInstanceId); + Advance(T0, runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior); + // Assert + runtime.TryGet(npcInstanceId, out var snapshot); + Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(attackCooldownSeconds), snapshot.PhaseStartedUtc); + } + + [Theory] + [InlineData("prototype_npc_ranged", 4.0, 2.0)] + [InlineData("prototype_npc_elite", 5.0, 2.5)] + public void AdvanceAll_ShouldEnterRecover_AfterCatalogWindupForArchetype( + string npcInstanceId, + double attackCooldownSeconds, + double telegraphWindupSeconds) + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, npcInstanceId); + Advance(T0, runtime, threat, behavior); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior); + // Assert + runtime.TryGet(npcInstanceId, out var snapshot); + Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); + Assert.Equal( + T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), + snapshot.PhaseStartedUtc); + } + + [Fact] + public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(4.5), snapshot.PhaseStartedUtc); + Assert.Equal(T0.AddSeconds(5), runtime.LastAdvancedUtc); + } + + [Fact] + public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + Advance(T0.AddSeconds(100), runtime, threat, behavior, maxDeltaSeconds: 5); + // Act + Advance(T0.AddSeconds(100.5), runtime, threat, behavior, maxDeltaSeconds: 5); + // Assert + Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(9), snapshot.PhaseStartedUtc); + } + + [Fact] + public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + runtime.LastAdvancedUtc = T0.AddHours(1); + NpcRuntimeOperations.ResetAllPrototypeRows(runtime); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + // Act + Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5); + // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); + Assert.Equal(T0.AddHours(1).AddSeconds(10), snapshot.PhaseStartedUtc); + Assert.Equal(T0.AddHours(1).AddSeconds(10), runtime.LastAdvancedUtc); + } + + [Fact] + public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + Advance(T0, runtime, threat, behavior); + runtime.LastAdvancedUtc = T0.AddSeconds(10); + // Act + Advance(T0.AddSeconds(5), runtime, threat, behavior); + // Assert + Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); + } + + [Fact] + public void HasPositiveTimingDurations_ShouldRejectNonPositiveCatalogTimings() + { + // Arrange + var invalidCooldown = new NpcBehaviorDefRow( + "bad", + "Bad", + "melee_pressure", + 100, + 8.0, + 16.0, + 1.5, + 15, + 0.0); + var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 }; + var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 }; + // Act + var cooldownOk = NpcRuntimeOperations.HasPositiveTimingDurations(invalidCooldown); + var windupOk = NpcRuntimeOperations.HasPositiveTimingDurations(invalidWindup); + var validOk = NpcRuntimeOperations.HasPositiveTimingDurations(valid); + // Assert + Assert.False(cooldownOk); + Assert.False(windupOk); + Assert.True(validOk); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 0d3b4ac..2d27d96 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, IThreatStateStore threatStore) => + (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) => { if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) { @@ -25,6 +25,7 @@ public static class CombatTargetFixtureApi } AggroOperations.ClearAllPrototypeHolders(threatStore); + NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore); return Results.Json( new CombatTargetFixtureResponse diff --git a/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs b/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs new file mode 100644 index 0000000..69072a5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Session in-memory NPC runtime behavior state (NEO-93). +/// HTTP projection lands in NEO-94; lazy tick advance reads/writes rows here. +/// +public interface INpcRuntimeStateStore +{ + /// UTC instant of the last successful call. + DateTimeOffset LastAdvancedUtc { get; set; } + + /// Reads runtime state for a known prototype NPC instance. Lazy-initializes an idle row. + bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out NpcRuntimeStateSnapshot snapshot); + + /// Writes runtime state for a known prototype NPC instance. + bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot); + + /// Resets one prototype NPC runtime row to idle with no active telegraph. + bool TryResetPrototypeRow(string? npcInstanceId); + + /// Resets all prototype NPC runtime rows to idle. + void ResetAllPrototypeRows(); +} diff --git a/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs new file mode 100644 index 0000000..7ee4883 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs @@ -0,0 +1,110 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Npc; + +/// Thread-safe in-memory NPC runtime state for prototype instances (NEO-93). +public sealed class InMemoryNpcRuntimeStateStore : INpcRuntimeStateStore +{ + private readonly ConcurrentDictionary rowsByNpcId = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public DateTimeOffset LastAdvancedUtc { get; set; } = DateTimeOffset.MinValue; + + /// + public bool TryGet(string? npcInstanceId, out NpcRuntimeStateSnapshot snapshot) + { + var key = NormalizeNpcInstanceId(npcInstanceId); + if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _)) + { + snapshot = default; + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + var row = rowsByNpcId.GetOrAdd(key, static id => NpcRuntimeRow.CreateIdle(id)); + snapshot = row.ToSnapshot(); + return true; + } + } + + /// + public bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot) + { + var key = NormalizeNpcInstanceId(npcInstanceId); + if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _)) + { + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + rowsByNpcId[key] = NpcRuntimeRow.FromSnapshot(snapshot with { NpcInstanceId = key }); + return true; + } + } + + /// + public bool TryResetPrototypeRow(string? npcInstanceId) + { + var key = NormalizeNpcInstanceId(npcInstanceId); + if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _)) + { + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + rowsByNpcId[key] = NpcRuntimeRow.CreateIdle(key); + return true; + } + } + + /// + public void ResetAllPrototypeRows() + { + foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + _ = TryResetPrototypeRow(npcInstanceId); + } + + LastAdvancedUtc = DateTimeOffset.MinValue; + } + + private static string NormalizeNpcInstanceId(string? npcInstanceId) => + npcInstanceId?.Trim().ToLowerInvariant() ?? string.Empty; + + private sealed class NpcRuntimeRow + { + public required string NpcInstanceId { get; init; } + + public NpcBehaviorState BehaviorState { get; init; } + + public DateTimeOffset PhaseStartedUtc { get; init; } + + public ActiveTelegraphSnapshot? ActiveTelegraph { get; init; } + + public static NpcRuntimeRow CreateIdle(string npcInstanceId) => + new() + { + NpcInstanceId = npcInstanceId, + BehaviorState = NpcBehaviorState.Idle, + PhaseStartedUtc = DateTimeOffset.MinValue, + ActiveTelegraph = null, + }; + + public NpcRuntimeStateSnapshot ToSnapshot() => + new(NpcInstanceId, BehaviorState, PhaseStartedUtc, ActiveTelegraph); + + public static NpcRuntimeRow FromSnapshot(NpcRuntimeStateSnapshot snapshot) => + new() + { + NpcInstanceId = snapshot.NpcInstanceId, + BehaviorState = snapshot.BehaviorState, + PhaseStartedUtc = snapshot.PhaseStartedUtc, + ActiveTelegraph = snapshot.ActiveTelegraph, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs new file mode 100644 index 0000000..b3c76bc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs @@ -0,0 +1,27 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Prototype NPC behavior states (NEO-93). Wire names are stable for NEO-94 JSON. +public enum NpcBehaviorState +{ + Idle, + Aggro, + TelegraphWindup, + AttackExecute, + Recover, +} + +/// Stable snake_case wire names for . +public static class NpcBehaviorStateWire +{ + /// Returns the JSON wire name for . + public static string ToWireName(NpcBehaviorState state) => + state switch + { + NpcBehaviorState.Idle => "idle", + NpcBehaviorState.Aggro => "aggro", + NpcBehaviorState.TelegraphWindup => "telegraph_windup", + NpcBehaviorState.AttackExecute => "attack_execute", + NpcBehaviorState.Recover => "recover", + _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), + }; +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs new file mode 100644 index 0000000..ceaad3f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -0,0 +1,270 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93). +public static class NpcRuntimeOperations +{ + /// Default per-advance simulation cap (seconds) for lazy poll ticks. + public const double DefaultMaxDeltaSeconds = 5.0; + + /// + /// Advances all prototype NPC runtime rows by up to since the last call. + /// + public static void AdvanceAll( + DateTimeOffset nowUtc, + INpcRuntimeStateStore runtimeStore, + IThreatStateStore threatStore, + INpcBehaviorDefinitionRegistry behaviorRegistry, + double maxDeltaSeconds = DefaultMaxDeltaSeconds) + { + if (maxDeltaSeconds <= 0) + { + return; + } + + var lastAdvanced = runtimeStore.LastAdvancedUtc; + if (lastAdvanced == DateTimeOffset.MinValue) + { + foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + AdvanceOne( + npcInstanceId, + nowUtc, + nowUtc, + runtimeStore, + threatStore, + behaviorRegistry); + } + + runtimeStore.LastAdvancedUtc = nowUtc; + return; + } + + var rawDeltaSeconds = (nowUtc - lastAdvanced).TotalSeconds; + if (rawDeltaSeconds <= 0) + { + foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + AdvanceOne( + npcInstanceId, + nowUtc, + nowUtc, + runtimeStore, + threatStore, + behaviorRegistry); + } + + return; + } + + var deltaSeconds = Math.Min(rawDeltaSeconds, maxDeltaSeconds); + var windowStart = lastAdvanced; + var windowEnd = windowStart.AddSeconds(deltaSeconds); + + foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + AdvanceOne( + npcInstanceId, + windowStart, + windowEnd, + runtimeStore, + threatStore, + behaviorRegistry); + } + + runtimeStore.LastAdvancedUtc = windowEnd; + } + + /// Resets all prototype NPC runtime rows to idle (dev fixture). + public static void ResetAllPrototypeRows(INpcRuntimeStateStore runtimeStore) => + runtimeStore.ResetAllPrototypeRows(); + + private static void AdvanceOne( + string npcInstanceId, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + INpcRuntimeStateStore runtimeStore, + IThreatStateStore threatStore, + INpcBehaviorDefinitionRegistry behaviorRegistry) + { + if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || + !behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior)) + { + return; + } + + if (!HasPositiveTimingDurations(behavior)) + { + return; + } + + if (!runtimeStore.TryGet(npcInstanceId, out var snapshot)) + { + return; + } + + if (!TryHasAggroHolder(npcInstanceId, threatStore, out _)) + { + if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null) + { + WriteIdle(runtimeStore, npcInstanceId); + } + + return; + } + + if (snapshot.BehaviorState == NpcBehaviorState.Idle) + { + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.Aggro, + windowStart, + activeTelegraph: null); + // telemetry: npc_state_transition (NEO-96) + if (!runtimeStore.TryGet(npcInstanceId, out snapshot)) + { + return; + } + } + + var cursor = windowStart; + while (cursor < windowEnd) + { + if (!TryHasAggroHolder(npcInstanceId, threatStore, out _)) + { + WriteIdle(runtimeStore, npcInstanceId); + return; + } + + if (!runtimeStore.TryGet(npcInstanceId, out snapshot)) + { + return; + } + + var loopCursorBefore = cursor; + switch (snapshot.BehaviorState) + { + case NpcBehaviorState.Idle: + return; + + case NpcBehaviorState.Aggro: + { + var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds); + if (windowEnd < phaseEnd) + { + return; + } + + cursor = phaseEnd; + var telegraph = CreateTelegraph(npcInstanceId, cursor); + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.TelegraphWindup, + cursor, + telegraph); + // telemetry: npc_state_transition (NEO-96) + break; + } + + case NpcBehaviorState.TelegraphWindup: + { + var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.TelegraphWindupSeconds); + if (windowEnd < phaseEnd) + { + return; + } + + cursor = phaseEnd; + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.Recover, + cursor, + activeTelegraph: null); + // telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage) + break; + } + + case NpcBehaviorState.AttackExecute: + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.Recover, + cursor, + activeTelegraph: null); + // telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here + break; + + case NpcBehaviorState.Recover: + { + var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds); + if (windowEnd < phaseEnd) + { + return; + } + + cursor = phaseEnd; + var telegraph = CreateTelegraph(npcInstanceId, cursor); + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.TelegraphWindup, + cursor, + telegraph); + // telemetry: npc_state_transition (NEO-96) + break; + } + + default: + return; + } + + if (cursor <= loopCursorBefore) + { + return; + } + } + } + + /// Catalog timings must be positive; CI/content gate enforces for prototype defs. + internal static bool HasPositiveTimingDurations(NpcBehaviorDefRow behavior) => + behavior.AttackCooldownSeconds > 0 && behavior.TelegraphWindupSeconds > 0; + + private static bool TryHasAggroHolder( + string npcInstanceId, + IThreatStateStore threatStore, + out string holderPlayerId) + { + holderPlayerId = string.Empty; + if (!threatStore.TryGet(npcInstanceId, out var threat) || + string.IsNullOrEmpty(threat.AggroHolderPlayerId)) + { + return false; + } + + holderPlayerId = threat.AggroHolderPlayerId; + return true; + } + + private static ActiveTelegraphSnapshot CreateTelegraph(string npcInstanceId, DateTimeOffset windupStartedUtc) + { + var telegraphId = $"{npcInstanceId}:{windupStartedUtc.UtcTicks}"; + return new ActiveTelegraphSnapshot(telegraphId, windupStartedUtc); + } + + private static void WriteIdle(INpcRuntimeStateStore runtimeStore, string npcInstanceId) => + WriteState(runtimeStore, npcInstanceId, NpcBehaviorState.Idle, DateTimeOffset.MinValue, activeTelegraph: null); + + private static void WriteState( + INpcRuntimeStateStore runtimeStore, + string npcInstanceId, + NpcBehaviorState behaviorState, + DateTimeOffset phaseStartedUtc, + ActiveTelegraphSnapshot? activeTelegraph) + { + _ = runtimeStore.TryWrite( + npcInstanceId, + new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph)); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs new file mode 100644 index 0000000..9740e56 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Registers prototype NPC runtime behavior state store (NEO-93). +public static class NpcRuntimeServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddNpcRuntimeStateStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs new file mode 100644 index 0000000..5d89db3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs @@ -0,0 +1,17 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Active telegraph row while an NPC is in (NEO-93). +/// Stable id for NEO-94 wire projection. +/// UTC instant when windup phase began. +public readonly record struct ActiveTelegraphSnapshot(string TelegraphId, DateTimeOffset WindupStartedUtc); + +/// Per-NPC runtime behavior state snapshot (NEO-93). +/// Lowercase prototype NPC instance id. +/// Current behavior state. +/// UTC instant when the current phase began. +/// Non-null only during . +public readonly record struct NpcRuntimeStateSnapshot( + string NpcInstanceId, + NpcBehaviorState BehaviorState, + DateTimeOffset PhaseStartedUtc, + ActiveTelegraphSnapshot? ActiveTelegraph); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index e0a4e1d..543d87e 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -26,6 +26,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddThreatStateStore(); +builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); diff --git a/server/README.md b/server/README.md index 80d588c..a76fb6b 100644 --- a/server/README.md +++ b/server/README.md @@ -152,7 +152,7 @@ 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`** and clears all prototype aggro holders (**NEO-92**). **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`**, clears all prototype aggro holders (**NEO-92**), and resets NPC runtime behavior rows to **`idle`** (**NEO-93**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. ## Threat / aggro state (NEO-92) @@ -167,6 +167,27 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md). +## NPC runtime behavior state (NEO-93) + +Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** + **`InMemoryNpcRuntimeStateStore`**, keyed by **`PrototypeNpcRegistry`** instance id. **`NpcRuntimeOperations.AdvanceAll`** drives lazy tick advance (monotonic clock, per-call delta cap **5.0 s**) using catalog **`attackCooldownSeconds`** and **`telegraphWindupSeconds`**. + +| State | Meaning | +|-------|---------| +| **`idle`** | No aggro holder; no telegraph. | +| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. | +| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. | +| **`attack_execute`** | Instant stub in NEO-93 (player damage lands in [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). | +| **`recover`** | Post-attack cooldown wait before next windup. | + +| Rule | Behavior | +|------|----------| +| **Holder sync** | Empty holder → **`idle`**; holder while **`idle`** → **`aggro`**. No proximity auto-aggro (first-hit holder from NEO-92 only). | +| **Lazy advance** | **`AdvanceAll`** simulates up to **5.0 s** per call; [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) wires this to **`GET /game/world/npc-runtime-snapshot`**. | +| **Fixture reset** | Dev combat-target fixture clears runtime rows alongside HP + threat. | +| **HTTP read** | Not exposed yet — snapshot GET lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). | + +Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.md). + ## Combat engine (NEO-81) **`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept.