diff --git a/docs/plans/NEO-98-implementation-plan.md b/docs/plans/NEO-98-implementation-plan.md index af4bbac..a5dc32e 100644 --- a/docs/plans/NEO-98-implementation-plan.md +++ b/docs/plans/NEO-98-implementation-plan.md @@ -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** | diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 1c5af70..e2d6f2d 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -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(); + Assert.NotNull(fourth); + Assert.True(fourth!.CombatResolution!.TargetDefeated); + snapshotResponse.EnsureSuccessStatusCode(); + var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync(); + 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(); + Assert.NotNull(healthBody); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, healthBody!.CurrentHp); + } + [Fact] public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression() { diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 572eabb..1d8101b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -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); + } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 323d5b5..cb94318 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index e138945..104cc5f 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -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(); } + /// + /// Clears aggro and runtime combat state when the NPC combat-target row is . + /// + 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; } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs index c565a94..5601a4a 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs @@ -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)); }); diff --git a/server/README.md b/server/README.md index 9fb9939..b0b89ba 100644 --- a/server/README.md +++ b/server/README.md @@ -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)). |