neon-sprawl/docs/plans/NEO-92-implementation-plan.md

11 KiB

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 · Epic 5 Slice 2 · backlog E5M2-06
Branch NEO-92
Precursor NEO-91PrototypeNpcRegistry + combat-target migration (Done on main)
Pattern NEO-80 — in-memory session store + DI; NEO-82 — cast-layer player correlation
Blocks NEO-93 — NPC behavior state machine (needs aggro holder)
Client counterpart None for this slice — threat projection lands in 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):

  • 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).
  • HTTP DTO projection of threat state (NEO-94).
  • Proximity auto-aggro inside aggroRadius without a damaging cast.
  • Godot / client HUD (NEO-97).
  • docs/manual-qa/NEO-92.md — server-only; Bruno + automated tests (NEO-91 pattern).

Acceptance criteria checklist

  • First damaging cast on NPC sets aggro holder to casting player id.
  • Holder clears when player leaves leashRadius (deterministic horizontal distance check).
  • 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-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.