Merge pull request #130 from ViPro-Technologies/NEO-92
NEO-92: AggroRule engine + IThreatStateStorepull/132/head
commit
cda1a457f8
|
|
@ -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
|
|
@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Code review — NEO-92 (E5M2-06)
|
||||||
|
|
||||||
|
**Date:** 2026-05-27
|
||||||
|
**Scope:** Branch `NEO-92` vs `main` — NEO-92 aggro store implementation, plan/docs, and code review
|
||||||
|
**Base:** `main`
|
||||||
|
|
||||||
|
**Follow-up:** Code review suggestions addressed (docs alignment, backlog hook bullet, exact-leash-boundary test).
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Approve with nits**
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This branch adds the prototype aggro spine for Epic 5 Slice 2: an in-memory `IThreatStateStore`, deterministic `AggroOperations` (first-hit acquire, leash-radius clear), and wiring in `AbilityCastApi`, `PositionStateApi`, and the dev combat-target fixture. The design keeps `CombatOperations` engine-pure and matches kickoff decisions (cast-layer acquire, move + pre-acquire leash clear, no proximity re-aggro). Test coverage is solid — unit, HTTP integration, cast guard skip, move clear, and fixture reset — and the filtered NEO-92-related test run passed locally. Risk is low for merge; remaining gaps are documentation tracking and a few optional test/doc polish items.
|
||||||
|
|
||||||
|
## Documentation checked
|
||||||
|
|
||||||
|
| Path | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `docs/plans/NEO-92-implementation-plan.md` | **Matches** — store, ops, hooks, fixture, tests, and reconciliation section align with the diff. |
|
||||||
|
| `docs/plans/E5M2-prototype-backlog.md` (E5M2-06) | **Matches** — AC checkboxes, landed note, and in-scope hook bullet updated to `AbilityCastApi`. |
|
||||||
|
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — E5M2-06 landed note and threat-store section present. |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row includes **NEO-92 landed** note and plan link. |
|
||||||
|
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server-owned aggro holder; no client projection in this slice (NEO-94 deferred). |
|
||||||
|
| `server/README.md` | **Matches** — threat/aggro section documents acquire, leash clear, fixture reset, and NEO-94 deferral. |
|
||||||
|
|
||||||
|
## Blocking issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Suggestions
|
||||||
|
|
||||||
|
1. ~~**Update `documentation_and_implementation_alignment.md` E5.M2 row** — Add **NEO-92 landed** (`IThreatStateStore`, `AggroOperations`, cast/move/fixture hooks) and link `NEO-92-implementation-plan.md` in the References column, consistent with NEO-87–NEO-91 entries. The implementation plan and E5_M2 module doc already claim this update.~~ **Done.** E5.M2 row updated with NEO-92 landed note and References link.
|
||||||
|
2. ~~**Align E5M2-06 backlog “In scope” hook bullet** — Change “Hook from `CombatOperations.TryResolve`” to **`AbilityCastApi` after successful resolve when `DamageDealt > 0`** so the backlog matches the adopted kickoff decision and avoids misleading NEO-93 readers.~~ **Done.** E5M2-06 in-scope bullet updated.
|
||||||
|
3. ~~**Add an exact-leash-boundary unit test** — Plan calls out inclusive boundary (`distance > leashRadius` keeps holder). Existing tests use clearly inside/outside positions; a case at `distance == leashRadius` would lock that invariant alongside `HorizontalReach` conventions.~~ **Done.** `TryClearOnLeashForPlayer_ShouldKeepHolder_WhenPlayerAtExactLeashRadius` in `AggroOperationsTests.cs`.
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
- ~~Nit: Branch mixes five unrelated `chore:` commits (`docs/dev/local-mlx-cursor.md`, README pointer) with NEO-92 work. Consider splitting or noting in the PR description so reviewers can focus on the aggro diff.~~ **Done.** MLX guide removed from NEO-92 branch (lives on `docs/local-mlx-cursor`); history rewritten to NEO-92-only commits.
|
||||||
|
- Nit: `AbilityCastApiTests` still names helpers `LockPrototypeTargetAlphaAsync` while locking `prototype_npc_melee` — harmless legacy naming from NEO-91 migration.
|
||||||
|
- Nit: Plan mentioned an optional thread-safety spot check for `InMemoryThreatStateStore`; acceptable to defer for three fixed prototype ids.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NEO-92-focused tests (passed 2026-05-27)
|
||||||
|
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||||
|
--filter "FullyQualifiedName~Npc|FullyQualifiedName~MoveCommandApiTests.PostMove_ShouldClearAggro|FullyQualifiedName~AbilityCastApiTests.PostAbilityCast_ShouldSetAggro|FullyQualifiedName~AbilityCastApiTests.PostAbilityCast_ShouldReturnCombatResolution_WhenZeroDamage|FullyQualifiedName~CombatTargetFixtureApiTests"
|
||||||
|
|
||||||
|
# Full server suite before merge
|
||||||
|
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual: no Bruno changes required (store not on wire). Confirm dev fixture still resets HP + aggro between Bruno runs if exercising combat spines locally.
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue