Merge pull request #130 from ViPro-Technologies/NEO-92

NEO-92: AggroRule engine + IThreatStateStore
pull/132/head
VinPropane 2026-05-27 21:54:07 -04:00 committed by GitHub
commit cda1a457f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 853 additions and 10 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M2 | | **Module ID** | E5.M2 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype | | **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) | | **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 ## 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). **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/`. **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 ## Risks and telemetry

File diff suppressed because one or more lines are too long

View File

@ -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. - `IThreatStateStore` + in-memory implementation keyed by NPC instance id.
- `AggroOperations.TryAcquire` / `TryClearOnLeash` using player **`PositionState`** + registry anchors. - `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). - Unit + integration tests (AAA).
**Out of scope** **Out of scope**
@ -217,9 +217,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria** **Acceptance criteria**
- [ ] First damaging cast on NPC sets aggro holder to casting player id. - [x] First damaging cast on NPC sets aggro holder to casting player id.
- [ ] Holder clears when player leaves `leashRadius` (deterministic distance check). - [x] Holder clears when player leaves `leashRadius` (deterministic distance check).
- [ ] Re-aggro inside radius after clear follows same first-hit rule. - [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).
--- ---

View File

@ -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.

View File

@ -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-87NEO-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.

View File

@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using System.Text;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
@ -568,6 +569,28 @@ public sealed class AbilityCastApiTests
Assert.Equal(0, body.CombatResolution!.DamageDealt); Assert.Equal(0, body.CombatResolution!.DamageDealt);
Assert.Equal(100, body.CombatResolution.TargetRemainingHp); Assert.Equal(100, body.CombatResolution.TargetRemainingHp);
Assert.False(body.CombatResolution.TargetDefeated); Assert.False(body.CombatResolution.TargetDefeated);
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
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<IThreatStateStore>();
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] [Fact]

View File

@ -20,7 +20,9 @@ public sealed class CombatTargetFixtureApiTests
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>(); var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act // Act
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
@ -38,6 +40,8 @@ public sealed class CombatTargetFixtureApiTests
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee); store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee);
Assert.Equal(100, melee.CurrentHp); Assert.Equal(100, melee.CurrentHp);
Assert.False(melee.Defeated); Assert.False(melee.Defeated);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
Assert.Null(threat.AggroHolderPlayerId);
} }
[Fact] [Fact]

View File

@ -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);
}
}

View File

@ -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;
/// <summary>HTTP integration tests for NEO-92 aggro acquire + leash clear wiring.</summary>
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<IThreatStateStore>();
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<IThreatStateStore>();
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);
}
}

View File

@ -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);
}
}

View File

@ -3,7 +3,11 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.PositionState; namespace NeonSprawl.Server.Tests.Game.PositionState;
@ -186,4 +190,52 @@ public class MoveCommandApiTests
var text = await response.Content.ReadAsStringAsync(); var text = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("reasonCode", text, StringComparison.OrdinalIgnoreCase); 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<IThreatStateStore>();
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);
}
} }

View File

@ -64,6 +64,8 @@ public static class AbilityCastApi
IPlayerAbilityCooldownStore cooldowns, IPlayerAbilityCooldownStore cooldowns,
IAbilityDefinitionRegistry abilities, IAbilityDefinitionRegistry abilities,
ICombatEntityHealthStore healthStore, ICombatEntityHealthStore healthStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerGigProgressionStore gigStore, IPlayerGigProgressionStore gigStore,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
TimeProvider clock) => TimeProvider clock) =>
@ -223,6 +225,16 @@ public static class AbilityCastApi
normalizedRequest); normalizedRequest);
} }
if (combatResult.DamageDealt > 0)
{
AggroOperations.TryClearOnLeashForPlayer(
id,
positions,
behaviorRegistry,
threatStore);
AggroOperations.TryAcquire(lookupKey, id, threatStore);
}
cooldowns.StartCooldown( cooldowns.StartCooldown(
id, id,
body.SlotIndex, body.SlotIndex,

View File

@ -9,7 +9,7 @@ public static class CombatTargetFixtureApi
{ {
app.MapPost( app.MapPost(
"/game/__dev/combat-targets-fixture", "/game/__dev/combat-targets-fixture",
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore) => (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore) =>
{ {
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
{ {
@ -24,6 +24,8 @@ public static class CombatTargetFixtureApi
} }
} }
AggroOperations.ClearAllPrototypeHolders(threatStore);
return Results.Json( return Results.Json(
new CombatTargetFixtureResponse new CombatTargetFixtureResponse
{ {

View File

@ -0,0 +1,108 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Deterministic prototype aggro acquire and leash-clear rules (NEO-92).</summary>
public static class AggroOperations
{
/// <summary>
/// Sets aggro holder to <paramref name="playerId"/> when the NPC row is empty.
/// Returns <c>true</c> only when holder was empty and is now set.
/// </summary>
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);
}
/// <summary>
/// Clears aggro on every prototype NPC instance where <paramref name="playerId"/> is holder
/// and horizontal distance from the NPC anchor exceeds catalog <c>leashRadius</c>.
/// </summary>
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);
}
}
/// <summary>Clears aggro for one NPC when the holder is outside that NPC's leash radius.</summary>
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);
}
}
/// <summary>Clears aggro holders on all prototype NPC instances (dev fixture).</summary>
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;
}

View File

@ -0,0 +1,19 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
/// 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.
/// </summary>
public interface IThreatStateStore
{
/// <summary>Reads threat for a known prototype NPC instance. Lazy-initializes an empty holder row.</summary>
bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out ThreatStateSnapshot snapshot);
/// <summary>Sets or clears the aggro holder for a known prototype NPC instance.</summary>
bool TrySetHolder(string? npcInstanceId, string? playerIdLowercaseOrNull);
/// <summary>Clears the aggro holder for a known prototype NPC instance.</summary>
bool TryClearHolder(string? npcInstanceId);
}

View File

@ -0,0 +1,68 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Thread-safe in-memory aggro holders for prototype NPC instances (NEO-92).</summary>
public sealed class InMemoryThreatStateStore : IThreatStateStore
{
private readonly ConcurrentDictionary<string, string?> holderByNpcId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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();
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Registers prototype threat/aggro state store (NEO-92).</summary>
public static class ThreatStateServiceCollectionExtensions
{
/// <summary>Registers <see cref="IThreatStateStore"/> as an in-memory singleton.</summary>
public static IServiceCollection AddThreatStateStore(this IServiceCollection services)
{
services.AddSingleton<IThreatStateStore, InMemoryThreatStateStore>();
return services;
}
}

View File

@ -0,0 +1,6 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Per-NPC aggro holder snapshot (NEO-92).</summary>
/// <param name="NpcInstanceId">Lowercase prototype NPC instance id.</param>
/// <param name="AggroHolderPlayerId">Lowercase route player id holding aggro, or <see langword="null"/> when empty.</param>
public readonly record struct ThreatStateSnapshot(string NpcInstanceId, string? AggroHolderPlayerId);

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.PositionState;
@ -28,7 +29,7 @@ public static class PositionStateApi
app.MapPost( app.MapPost(
"/game/players/{id}/move", "/game/players/{id}/move",
(string id, MoveCommandRequest? body, IPositionStateStore store, IOptions<GamePositionOptions> gameOptions) => (string id, MoveCommandRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions<GamePositionOptions> gameOptions) =>
{ {
if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion)
{ {
@ -62,6 +63,8 @@ public static class PositionStateApi
return Results.NotFound(); return Results.NotFound();
} }
AggroOperations.TryClearOnLeashForPlayer(id, store, behaviorRegistry, threatStore);
var response = new PositionStateResponse var response = new PositionStateResponse
{ {
SchemaVersion = PositionStateResponse.CurrentSchemaVersion, SchemaVersion = PositionStateResponse.CurrentSchemaVersion,
@ -74,7 +77,7 @@ public static class PositionStateApi
app.MapPost( app.MapPost(
"/game/players/{id}/move-stream", "/game/players/{id}/move-stream",
(string id, MoveStreamRequest? body, IPositionStateStore store, IOptions<GamePositionOptions> gameOptions) => (string id, MoveStreamRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions<GamePositionOptions> gameOptions) =>
{ {
if (body is null || body.SchemaVersion != MoveStreamRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != MoveStreamRequest.CurrentSchemaVersion)
{ {
@ -121,6 +124,8 @@ public static class PositionStateApi
lastSnap = snap; lastSnap = snap;
} }
AggroOperations.TryClearOnLeashForPlayer(id, store, behaviorRegistry, threatStore);
var response = new PositionStateResponse var response = new PositionStateResponse
{ {
SchemaVersion = PositionStateResponse.CurrentSchemaVersion, SchemaVersion = PositionStateResponse.CurrentSchemaVersion,

View File

@ -25,6 +25,7 @@ builder.Services.AddResourceNodeCatalog(builder.Configuration);
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddThreatStateStore();
builder.Services.AddMasteryCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build(); var app = builder.Build();

View File

@ -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" 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) ## Combat engine (NEO-81)