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
parent
b03128a3b0
commit
332cd4445f
|
|
@ -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** |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)). |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue