NEO-98: stop defeated NPC aggro and runtime attacks

Clear threat holder and reset runtime to idle when combat-target HP
is defeated — on cast accept and as an AdvanceOne guard so telegraph
cycles cannot continue after the killing blow.
pull/137/head
VinPropane 2026-05-30 17:38:27 -04:00
parent b03128a3b0
commit 332cd4445f
7 changed files with 213 additions and 67 deletions

View File

@ -54,7 +54,7 @@
- **`docs/manual-qa/NEO-98.md`** — single-session capstone (Melee full telegraph spine → Ranged/Elite defeat + telegraph visible); zero Bruno/curl in main checklist.
- **`client/README.md`** — **End-to-end NPC telegraph combat loop (NEO-98)** section with flow table and cross-links.
- **`docs/plans/E5M2-prototype-backlog.md`**, **`E5_M2_NpcAiAndBehaviorProfiles.md`**, **`documentation_and_implementation_alignment.md`** — E5M2-12 landed; E5.M2 **Ready**; Epic 5 Slice 2 client capstone complete.
- **Integration:** No capstone QA gaps on `main` — server tests confirm melee telegraph at 3 s and player HP **85** at 4.5 s (`NpcRuntimeOperationsTests`, `NpcRuntimeSnapshotWorldApiTests`); NEO-97 HUD wiring sufficient.
- **Integration fix (capstone QA):** defeated NPCs continued attacking — **`NpcRuntimeOperations.TryStopOnTargetDefeat`** on cast accept + **`AdvanceOne`** defeated guard; server tests added.
## Technical approach
@ -138,8 +138,12 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — NEO-98 landed + Slice 2 client capstone complete (on story completion). |
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-12 acceptance checkboxes + landed note (on story completion). |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | E5M2-12 capstone landed note + manual QA link (on story completion). |
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs` | **`TryStopOnTargetDefeat`** + defeated guard in **`AdvanceOne`**; **`AdvanceAll`** takes **`ICombatEntityHealthStore`**. |
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | Pass combat health store into **`AdvanceAll`**. |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Stop NPC combat on **`TargetDefeated`**. |
| `server/README.md` | Threat table — **Defeat clear** rule. |
**Conditional (only if capstone QA fails on `main`):**
**Conditional (only if capstone QA fails on client):**
| Path | Rationale |
|------|-----------|
@ -150,7 +154,8 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix
| Path | Change |
|------|--------|
| *(none expected)* | Capstone is docs + manual QA; NEO-97 GdUnit suites already cover poll clients, windup interpolation, and HUD helpers. |
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs` | **`TryStopOnTargetDefeat`** + defeated **`AdvanceAll`** guard tests. |
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | **`PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated`** integration test. |
**If integration fix adds/changes client behavior:** add or extend the smallest GdUnit test in the matching NEO-97 suite (`npc_combat_hud_refresh_test.gd`, etc.) — AAA layout per [testing-expectations.md](../../.cursor/rules/testing-expectations.md).
@ -160,7 +165,7 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| **Capstone dry-run on `main`** | Server integration tests confirm melee telegraph + player damage timeline; NEO-97 client HUD sufficient — no client code changes. | **adopted** |
| **Capstone dry-run on `main`** | Capstone QA found defeated melee still attacking; server fix clears aggro + runtime on defeat. | **adopted** |
| **Session length (elite ×8 casts)** | Document cast counts + cooldown reminders; elite section is defeat + telegraph visibility only (kickoff depth decision). | **adopted** |
| **Player HP drift across three fights** | Script asserts melee **85/100** only; later archetype hits optional in Notes — do not require exact final HP in AC. | **adopted** |
| **Overlap with NEO-97 manual QA** | NEO-98 supersedes as capstone; NEO-97 stays component regression (elite incoming-threat row). | **adopted** |

View File

@ -652,6 +652,49 @@ public sealed class AbilityCastApiTests
Assert.Null(fifth.CombatResolution);
}
[Fact]
public async Task PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = PulseCastRequest();
for (var i = 0; i < 3; i++)
{
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
// Act
var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
fourthResponse.EnsureSuccessStatusCode();
var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
factory.FakeClock.Advance(TimeSpan.FromSeconds(30));
_ = await client.GetAsync("/game/world/npc-runtime-snapshot");
var healthAfterWaitResponse = await client.GetAsync("/game/players/dev-local-1/combat-health");
// Assert
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.NotNull(fourth);
Assert.True(fourth!.CombatResolution!.TargetDefeated);
snapshotResponse.EnsureSuccessStatusCode();
var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
Assert.NotNull(snapshotBody);
var melee = snapshotBody!.NpcInstances.Single(
row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal("idle", melee.State);
Assert.Null(melee.AggroHolderPlayerId);
Assert.Null(melee.ActiveTelegraph);
healthAfterWaitResponse.EnsureSuccessStatusCode();
var healthBody = await healthAfterWaitResponse.Content.ReadFromJsonAsync<PlayerCombatHealthResponse>();
Assert.NotNull(healthBody);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, healthBody!.CurrentHp);
}
[Fact]
public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression()
{

View File

@ -9,13 +9,14 @@ public sealed class NpcRuntimeOperationsTests
private static readonly DateTimeOffset T0 =
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IPlayerCombatHealthStore PlayerHealth)
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, InMemoryCombatEntityHealthStore CombatHealth, IPlayerCombatHealthStore PlayerHealth)
CreateFixture()
{
return (
new InMemoryNpcRuntimeStateStore(),
new InMemoryThreatStateStore(),
PrototypeNpcTestFixtures.CreateBehaviorRegistry(),
PrototypeNpcTestFixtures.CreateHealthStore(),
new InMemoryPlayerCombatHealthStore());
}
@ -27,6 +28,7 @@ public sealed class NpcRuntimeOperationsTests
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
InMemoryCombatEntityHealthStore combatHealthStore,
IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
NpcRuntimeOperations.AdvanceAll(
@ -34,6 +36,7 @@ public sealed class NpcRuntimeOperationsTests
runtimeStore,
threatStore,
behaviorRegistry,
combatHealthStore,
playerHealthStore,
maxDeltaSeconds);
@ -41,9 +44,9 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
// Act
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -54,10 +57,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -69,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
runtime.LastAdvancedUtc = T0;
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -83,11 +86,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -99,11 +102,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -113,12 +116,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -128,13 +131,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -146,13 +149,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -164,11 +167,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 10);
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 10);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -180,13 +183,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -201,11 +204,11 @@ public sealed class NpcRuntimeOperationsTests
double attackCooldownSeconds)
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -221,12 +224,12 @@ public sealed class NpcRuntimeOperationsTests
double telegraphWindupSeconds)
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -239,11 +242,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -255,12 +258,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(100), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(100), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5);
// Act
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
@ -272,14 +275,14 @@ public sealed class NpcRuntimeOperationsTests
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
runtime.LastAdvancedUtc = T0.AddHours(1);
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -291,11 +294,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
Advance(T0, runtime, threat, behavior, playerHealth);
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
runtime.LastAdvancedUtc = T0.AddSeconds(10);
// Act
Advance(T0.AddSeconds(5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(5), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
}
@ -304,13 +307,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(85, snapshot.CurrentHp);
@ -320,13 +323,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
@ -359,4 +362,48 @@ public sealed class NpcRuntimeOperationsTests
Assert.False(windupOk);
Assert.True(validOk);
}
[Fact]
public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime()
{
// Arrange
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
// Act
NpcRuntimeOperations.TryStopOnTargetDefeat(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
combatHealth,
threat,
runtime);
// Assert
threat.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threatRow);
Assert.Null(threatRow.AggroHolderPlayerId);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtimeRow);
Assert.Equal(NpcBehaviorState.Idle, runtimeRow.BehaviorState);
Assert.Null(runtimeRow.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated()
{
// Arrange
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
NpcRuntimeOperations.TryStopOnTargetDefeat(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
combatHealth,
threat,
runtime);
// Act
Advance(T0.AddSeconds(30), runtime, threat, behavior, combatHealth, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
}

View File

@ -66,6 +66,7 @@ public static class AbilityCastApi
ICombatEntityHealthStore healthStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
INpcRuntimeStateStore npcRuntimeStore,
IPlayerGigProgressionStore gigStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
@ -243,6 +244,11 @@ public static class AbilityCastApi
if (combatResult.TargetDefeated)
{
NpcRuntimeOperations.TryStopOnTargetDefeat(
lookupKey,
healthStore,
threatStore,
npcRuntimeStore);
CombatDefeatGigXpGrant.GrantOnCombatDefeat(
id.Trim(),
gigStore,

View File

@ -20,6 +20,7 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
ICombatEntityHealthStore combatHealthStore,
IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
{
@ -40,6 +41,7 @@ public static class NpcRuntimeOperations
runtimeStore,
threatStore,
behaviorRegistry,
combatHealthStore,
playerHealthStore);
}
@ -59,6 +61,7 @@ public static class NpcRuntimeOperations
runtimeStore,
threatStore,
behaviorRegistry,
combatHealthStore,
playerHealthStore);
}
@ -78,6 +81,7 @@ public static class NpcRuntimeOperations
runtimeStore,
threatStore,
behaviorRegistry,
combatHealthStore,
playerHealthStore);
}
@ -92,6 +96,27 @@ public static class NpcRuntimeOperations
runtimeStore.ResetAllPrototypeRows();
}
/// <summary>
/// Clears aggro and runtime combat state when the NPC combat-target row is <see cref="CombatEntityHealthSnapshot.Defeated"/>.
/// </summary>
public static void TryStopOnTargetDefeat(
string npcInstanceId,
ICombatEntityHealthStore combatHealthStore,
IThreatStateStore threatStore,
INpcRuntimeStateStore runtimeStore)
{
var npcKey = NormalizeNpcId(npcInstanceId);
if (npcKey.Length == 0 ||
!combatHealthStore.TryGet(npcKey, out var combatHp) ||
!combatHp.Defeated)
{
return;
}
threatStore.TryClearHolder(npcKey);
WriteIdle(runtimeStore, npcKey);
}
private static void AdvanceOne(
string npcInstanceId,
DateTimeOffset windowStart,
@ -99,6 +124,7 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
ICombatEntityHealthStore combatHealthStore,
IPlayerCombatHealthStore playerHealthStore)
{
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
@ -117,6 +143,19 @@ public static class NpcRuntimeOperations
return;
}
if (combatHealthStore.TryGet(npcInstanceId, out var combatHp) && combatHp.Defeated)
{
if (TryHasAggroHolder(npcInstanceId, threatStore, out _) ||
snapshot.BehaviorState != NpcBehaviorState.Idle ||
snapshot.ActiveTelegraph is not null)
{
threatStore.TryClearHolder(npcInstanceId);
WriteIdle(runtimeStore, npcInstanceId);
}
return;
}
if (!TryHasAggroHolder(npcInstanceId, threatStore, out var aggroHolderPlayerId))
{
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
@ -326,4 +365,7 @@ public static class NpcRuntimeOperations
npcInstanceId,
new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph));
}
private static string NormalizeNpcId(string? raw) =>
raw?.Trim().ToLowerInvariant() ?? string.Empty;
}

View File

@ -14,6 +14,7 @@ public static class NpcRuntimeSnapshotWorldApi
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
ICombatEntityHealthStore combatHealthStore,
IPlayerCombatHealthStore playerHealthStore) =>
{
var now = clock.GetUtcNow();
@ -22,6 +23,7 @@ public static class NpcRuntimeSnapshotWorldApi
runtimeStore,
threatStore,
behaviorRegistry,
combatHealthStore,
playerHealthStore);
return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
});

View File

@ -162,6 +162,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In
|------|----------|
| **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. |
| **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). |
| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
| **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |