162 lines
11 KiB
Markdown
162 lines
11 KiB
Markdown
# 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.
|