diff --git a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru new file mode 100644 index 0000000..dedc19b --- /dev/null +++ b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru @@ -0,0 +1,90 @@ +meta { + name: Get combat health after elite attack + type: http + seq: 2 +} + +docs { + E5M2-09 AC smoke: lock prototype_npc_elite, damaging cast, wait attackCooldownSeconds (5.0s) + telegraphWindupSeconds (2.5s), GET combat-health. + Integration tests own precise timing via FakeClock; Bruno uses sleep(8000) against dev server real clock. +} + +script:pre-request { + const axios = require("axios"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await resetPrototypeCombatTargets(bru); + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x: -2, y: 0.9, z: -2 } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId: "prototype_npc_elite" }, + jsonHeaders, + ); + + const castResponse = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + { + schemaVersion: 1, + slotIndex: 0, + abilityId: "prototype_pulse", + targetId: "prototype_npc_elite", + }, + jsonHeaders, + ); + + const castBody = castResponse.data; + if (!castBody?.accepted || !castBody?.combatResolution) { + throw new Error(`neo95 cast failed: ${JSON.stringify(castBody)}`); + } + + await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`); + + await sleep(8000); + + bru.setVar("neo95CastBody", JSON.stringify(castBody)); +} + +get { + url: {{baseUrl}}/game/players/dev-local-1/combat-health + body: none + auth: none +} + +tests { + test("cast body shows accepted damaging pulse on elite", function () { + const castBody = JSON.parse(bru.getVar("neo95CastBody")); + expect(castBody.accepted).to.equal(true); + expect(castBody.combatResolution.targetRemainingHp).to.equal(175); + }); + + test("player HP decreased by elite attackDamage (25)", function () { + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal("dev-local-1"); + expect(body.maxHp).to.equal(100); + expect(body.currentHp).to.equal(75); + expect(body.defeated).to.equal(false); + }); +} diff --git a/bruno/neon-sprawl-server/combat-health/Get player combat health.bru b/bruno/neon-sprawl-server/combat-health/Get player combat health.bru new file mode 100644 index 0000000..9ca6dcd --- /dev/null +++ b/bruno/neon-sprawl-server/combat-health/Get player combat health.bru @@ -0,0 +1,24 @@ +meta { + name: Get player combat health + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/game/players/dev-local-1/combat-health + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1 and full HP baseline", function () { + expect(res.getStatus()).to.equal(200); + expect(res.getHeader("content-type")).to.contain("application/json"); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal("dev-local-1"); + expect(body.maxHp).to.equal(100); + expect(body.currentHp).to.equal(100); + expect(body.defeated).to.equal(false); + }); +} diff --git a/bruno/neon-sprawl-server/combat-health/folder.bru b/bruno/neon-sprawl-server/combat-health/folder.bru new file mode 100644 index 0000000..b52efdf --- /dev/null +++ b/bruno/neon-sprawl-server/combat-health/folder.bru @@ -0,0 +1,9 @@ +meta { + name: combat-health +} + +docs { + NEO-95: GET /game/players/{id}/combat-health — session player combat HP after NPC telegraph resolve. + Pre-request scripts call POST /game/__dev/combat-targets-fixture so CI survives ability-cast folder pollution. + Elite attack smoke (seq 2) uses sleep(8000) against real dev server clock; integration tests use FakeClock. +} diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 03510cb..306e3e3 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M2 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | +| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | | **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | ## Purpose @@ -89,6 +89,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j **NPC runtime snapshot HTTP (NEO-94):** **`GET /game/world/npc-runtime-snapshot`** — versioned read-only projection (`schemaVersion` **1**, **`npcInstances`**, optional nested **`activeTelegraph`**) with lazy **`AdvanceAll`** on poll. Plan: [NEO-94 implementation plan](../../plans/NEO-94-implementation-plan.md); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. +**Session player combat HP (NEO-95):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** in `server/NeonSprawl.Server/Game/Combat/` — telegraph complete applies catalog **`attackDamage`** to aggro holder; **`GET /game/players/{id}/combat-health`** read model for client HUD. Plan: [NEO-95 implementation plan](../../plans/NEO-95-implementation-plan.md); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. + **NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. ## Risks and telemetry diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 4428249..289c6ec 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -297,9 +297,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d **Acceptance criteria** -- [ ] Elite archetype telegraph → attack deals catalog damage to holder. -- [ ] Player HP read model matches store after NPC attack. -- [ ] No client-side damage math required for verification (Bruno or integration tests). +- [x] Elite archetype telegraph → attack deals catalog damage to holder. +- [x] Player HP read model matches store after NPC attack. +- [x] No client-side damage math required for verification (Bruno or integration tests). + +**Landed ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph complete applies catalog damage; **`GET /game/players/{id}/combat-health`**; plan [NEO-95-implementation-plan.md](NEO-95-implementation-plan.md). **Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — player HP + incoming telegraph/attack feedback labels. diff --git a/docs/plans/NEO-95-implementation-plan.md b/docs/plans/NEO-95-implementation-plan.md index b71d076..622ba43 100644 --- a/docs/plans/NEO-95-implementation-plan.md +++ b/docs/plans/NEO-95-implementation-plan.md @@ -46,9 +46,9 @@ ## Acceptance criteria checklist -- [ ] Elite archetype telegraph → attack deals catalog damage (**25**) to holder. -- [ ] Player HP read model matches store after NPC attack. -- [ ] No client-side damage math required for verification (Bruno or integration tests). +- [x] Elite archetype telegraph → attack deals catalog damage (**25**) to holder. +- [x] Player HP read model matches store after NPC attack. +- [x] No client-side damage math required for verification (Bruno or integration tests). ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index b609f3a..584fbe9 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -22,7 +22,9 @@ public sealed class CombatTargetFixtureApiTests var store = factory.Services.GetRequiredService(); var threatStore = factory.Services.GetRequiredService(); var runtimeStore = factory.Services.GetRequiredService(); + var playerHealthStore = factory.Services.GetRequiredService(); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); + _ = playerHealthStore.TryApplyDamage("dev-local-1", 40, out _); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); var windupStarted = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); _ = runtimeStore.TryWrite( @@ -54,6 +56,9 @@ public sealed class CombatTargetFixtureApiTests runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime); Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState); Assert.Null(runtime.ActiveTelegraph); + playerHealthStore.TryGet("dev-local-1", out var playerHealth); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, playerHealth.CurrentHp); + Assert.False(playerHealth.Defeated); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs new file mode 100644 index 0000000..f8c2084 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs @@ -0,0 +1,89 @@ +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Npc; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public sealed class NpcAttackOperationsTests +{ + [Fact] + public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches() + { + // Arrange + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + attackDamage: 15, + playerHealthStore, + threatStore); + // Assert + Assert.True(ok); + playerHealthStore.TryGet("dev-local-1", out var snapshot); + Assert.Equal(85, snapshot.CurrentHp); + } + + [Fact] + public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared() + { + // Arrange + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + _ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + attackDamage: 15, + playerHealthStore, + threatStore); + // Assert + Assert.False(ok); + playerHealthStore.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + + [Fact] + public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers() + { + // Arrange + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player"); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + attackDamage: 15, + playerHealthStore, + threatStore); + // Assert + Assert.False(ok); + playerHealthStore.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + + [Fact] + public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero() + { + // Arrange + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + attackDamage: 0, + playerHealthStore, + threatStore); + // Assert + Assert.True(ok); + playerHealthStore.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthApiTests.cs new file mode 100644 index 0000000..481cca9 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthApiTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public sealed class PlayerCombatHealthApiTests +{ + private static async Task MoveDevPlayerNearEliteAsync(HttpClient client) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/move", + new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = -2, Y = 0.9, Z = -2 }, + }); + response.EnsureSuccessStatusCode(); + } + + private static async Task BindSlot0PulseAsync(HttpClient client) + { + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }], + }); + post.EnsureSuccessStatusCode(); + } + + private static async Task LockPrototypeNpcEliteAsync(HttpClient client) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeNpcRegistry.PrototypeNpcEliteId, + }); + response.EnsureSuccessStatusCode(); + } + + private static AbilityCastRequest PulseCastRequest() => + new() + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeNpcRegistry.PrototypeNpcEliteId, + }; + + [Fact] + public async Task GetPlayerCombatHealth_ShouldReturnSchemaV1_WithFullHpBaseline() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/game/players/dev-local-1/combat-health"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(PlayerCombatHealthResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal("dev-local-1", body.PlayerId); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, body.MaxHp); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, body.CurrentHp); + Assert.False(body.Defeated); + } + + [Fact] + public async Task GetPlayerCombatHealth_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/game/players/missing-player/combat-health"); + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetPlayerCombatHealth_ShouldDecreaseAfterEliteTelegraphComplete() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0PulseAsync(client); + await MoveDevPlayerNearEliteAsync(client); + await LockPrototypeNpcEliteAsync(client); + var castResponse = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + PulseCastRequest()); + castResponse.EnsureSuccessStatusCode(); + var initSnapshot = await client.GetAsync("/game/world/npc-runtime-snapshot"); + initSnapshot.EnsureSuccessStatusCode(); + var baseline = await client.GetAsync("/game/players/dev-local-1/combat-health"); + baseline.EnsureSuccessStatusCode(); + factory.FakeClock!.Advance(TimeSpan.FromSeconds(5)); + var midSnapshot = await client.GetAsync("/game/world/npc-runtime-snapshot"); + midSnapshot.EnsureSuccessStatusCode(); + factory.FakeClock.Advance(TimeSpan.FromSeconds(2.5)); + // Act + var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot"); + var healthResponse = await client.GetAsync("/game/players/dev-local-1/combat-health"); + // Assert + snapshotResponse.EnsureSuccessStatusCode(); + healthResponse.EnsureSuccessStatusCode(); + var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync(); + var healthBody = await healthResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(snapshotBody); + Assert.NotNull(healthBody); + var elite = snapshotBody!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcEliteId); + Assert.Equal("recover", elite.State); + Assert.Equal(75, healthBody!.CurrentHp); + Assert.False(healthBody.Defeated); + + var store = factory.Services.GetRequiredService(); + store.TryGet("dev-local-1", out var storeSnapshot); + Assert.Equal(healthBody.CurrentHp, storeSnapshot.CurrentHp); + Assert.Equal(healthBody.MaxHp, storeSnapshot.MaxHp); + Assert.Equal(healthBody.Defeated, storeSnapshot.Defeated); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthStoreTests.cs new file mode 100644 index 0000000..023785c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthStoreTests.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public sealed class PlayerCombatHealthStoreTests +{ + [Fact] + public void TryGet_ShouldLazyInitToMaxHp_OnFirstAccess() + { + // Arrange + var store = new InMemoryPlayerCombatHealthStore(); + // Act + var ok = store.TryGet("dev-local-1", out var snapshot); + // Assert + Assert.True(ok); + Assert.Equal("dev-local-1", snapshot.PlayerId); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.MaxHp); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + Assert.False(snapshot.Defeated); + } + + [Fact] + public void TryApplyDamage_ShouldDecreaseHp_AndFloorAtZero() + { + // Arrange + var store = new InMemoryPlayerCombatHealthStore(); + // Act + var ok = store.TryApplyDamage("dev-local-1", 15, out var snapshot); + // Assert + Assert.True(ok); + Assert.Equal(85, snapshot.CurrentHp); + Assert.False(snapshot.Defeated); + } + + [Fact] + public void TryApplyDamage_ShouldMarkDefeated_WhenHpReachesZero() + { + // Arrange + var store = new InMemoryPlayerCombatHealthStore(); + // Act + var ok = store.TryApplyDamage("dev-local-1", 100, out var snapshot); + // Assert + Assert.True(ok); + Assert.Equal(0, snapshot.CurrentHp); + Assert.True(snapshot.Defeated); + } + + [Fact] + public void TryApplyDamage_ShouldKeepHpAtZero_WhenAlreadyDefeated() + { + // Arrange + var store = new InMemoryPlayerCombatHealthStore(); + _ = store.TryApplyDamage("dev-local-1", 100, out _); + // Act + var ok = store.TryApplyDamage("dev-local-1", 25, out var snapshot); + // Assert + Assert.True(ok); + Assert.Equal(0, snapshot.CurrentHp); + Assert.True(snapshot.Defeated); + } + + [Fact] + public void TryResetToFull_ShouldRestoreMaxHp() + { + // Arrange + var store = new InMemoryPlayerCombatHealthStore(); + _ = store.TryApplyDamage("dev-local-1", 40, out _); + // Act + var ok = store.TryResetToFull("dev-local-1", out var snapshot); + // Assert + Assert.True(ok); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + Assert.False(snapshot.Defeated); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryGet_ShouldReturnFalse_WhenPlayerIdInvalid(string? playerId) + { + // Arrange + var store = new InMemoryPlayerCombatHealthStore(); + // Act + var ok = store.TryGet(playerId, out var snapshot); + // Assert + Assert.False(ok); + Assert.Equal(default, snapshot); + } + + [Fact] + public void TryApplyDamage_ShouldReturnFalse_WhenDamageNegative() + { + // Arrange + var store = new InMemoryPlayerCombatHealthStore(); + // Act + var ok = store.TryApplyDamage("dev-local-1", -1, out var snapshot); + // Assert + Assert.False(ok); + Assert.Equal(default, snapshot); + } + + [Fact] + public async Task Host_ShouldResolvePlayerCombatHealthStoreFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + // Act + var store = factory.Services.GetRequiredService(); + // Assert + Assert.NotNull(store); + Assert.True(store.TryGet("dev-local-1", out var snapshot)); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 203b098..572eabb 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; using Xunit; @@ -8,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) + private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IPlayerCombatHealthStore PlayerHealth) CreateFixture() { return ( new InMemoryNpcRuntimeStateStore(), new InMemoryThreatStateStore(), - PrototypeNpcTestFixtures.CreateBehaviorRegistry()); + PrototypeNpcTestFixtures.CreateBehaviorRegistry(), + new InMemoryPlayerCombatHealthStore()); } private static void SetHolder(IThreatStateStore threatStore, string npcId, string playerId = "dev-local-1") => @@ -25,16 +27,23 @@ public sealed class NpcRuntimeOperationsTests INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) => - NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds); + NpcRuntimeOperations.AdvanceAll( + nowUtc, + runtimeStore, + threatStore, + behaviorRegistry, + playerHealthStore, + maxDeltaSeconds); [Fact] public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); // Act - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -45,10 +54,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -60,10 +69,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); runtime.LastAdvancedUtc = T0; // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior); + Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -74,11 +83,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(3), runtime, threat, behavior); + Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -90,11 +99,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(2.9), runtime, threat, behavior); + Advance(T0.AddSeconds(2.9), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -104,12 +113,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); - Advance(T0.AddSeconds(3), runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(4.4), runtime, threat, behavior); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -119,13 +128,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); - Advance(T0.AddSeconds(3), runtime, threat, behavior); - Advance(T0.AddSeconds(4.4), runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -137,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); - Advance(T0.AddSeconds(3), runtime, threat, behavior); - Advance(T0.AddSeconds(4.5), runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -155,11 +164,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, maxDeltaSeconds: 10); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 10); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -171,13 +180,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); - Advance(T0.AddSeconds(3), runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddSeconds(3.5), runtime, threat, behavior); + Advance(T0.AddSeconds(3.5), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -192,11 +201,11 @@ public sealed class NpcRuntimeOperationsTests double attackCooldownSeconds) { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -212,12 +221,12 @@ public sealed class NpcRuntimeOperationsTests double telegraphWindupSeconds) { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior); - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior); + Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -230,11 +239,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -246,12 +255,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); - Advance(T0.AddSeconds(100), runtime, threat, behavior, maxDeltaSeconds: 5); + Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(100), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); // Act - Advance(T0.AddSeconds(100.5), runtime, threat, behavior, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(100.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); @@ -263,14 +272,14 @@ public sealed class NpcRuntimeOperationsTests public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior); + Advance(T0, runtime, threat, behavior, playerHealth); runtime.LastAdvancedUtc = T0.AddHours(1); NpcRuntimeOperations.ResetAllPrototypeRows(runtime); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5); + Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -282,15 +291,49 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses() { // Arrange - var (runtime, threat, behavior) = CreateFixture(); - Advance(T0, runtime, threat, behavior); + var (runtime, threat, behavior, playerHealth) = CreateFixture(); + Advance(T0, runtime, threat, behavior, playerHealth); runtime.LastAdvancedUtc = T0.AddSeconds(10); // Act - Advance(T0.AddSeconds(5), runtime, threat, behavior); + Advance(T0.AddSeconds(5), runtime, threat, behavior, playerHealth); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); } + [Fact] + public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes() + { + // Arrange + var (runtime, threat, behavior, 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); + // Act + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); + // Assert + playerHealth.TryGet("dev-local-1", out var snapshot); + Assert.Equal(85, snapshot.CurrentHp); + } + + [Fact] + public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes() + { + // Arrange + var (runtime, threat, behavior, playerHealth) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); + _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); + // Act + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); + // Assert + playerHealth.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtimeSnapshot); + Assert.Equal(NpcBehaviorState.Idle, runtimeSnapshot.BehaviorState); + } + [Fact] public void HasPositiveTimingDurations_ShouldRejectNonPositiveCatalogTimings() { diff --git a/server/README.md b/server/README.md index 24bc59d..fc410fd 100644 --- a/server/README.md +++ b/server/README.md @@ -152,7 +152,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m curl -sS -i "http://localhost:5253/game/world/combat-targets" ``` -**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), and resets NPC runtime behavior rows to **`idle`** (**NEO-93**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. +**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), and restores dev player combat HP to full (**NEO-95**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. ## Threat / aggro state (NEO-92) @@ -176,7 +176,7 @@ Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** + | **`idle`** | No aggro holder; no telegraph. | | **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. | | **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. | -| **`attack_execute`** | Instant stub in NEO-93 (player damage lands in [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). | +| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies catalog **`attackDamage`** to aggro holder ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). | | **`recover`** | Post-attack cooldown wait before next windup. | | Rule | Behavior | @@ -204,7 +204,26 @@ Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.m curl -sS -i "http://localhost:5253/game/world/npc-runtime-snapshot" ``` -**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). +**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, catalog **`attackDamage`** applies to the aggro holder via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). + +## Session player combat HP (NEO-95) + +Session player HP for incoming NPC damage lives in **`Game/Combat/`** as **`IPlayerCombatHealthStore`** + **`InMemoryPlayerCombatHealthStore`**. Rows are keyed by lowercase player id; lazy-init **`currentHp`** / **`maxHp`** **100** on first access. No Postgres in Slice 2. + +| Rule | Behavior | +|------|----------| +| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. | +| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes and aggro holder still matches. | +| **Holder re-check** | No damage when holder cleared during windup (leash clear). | +| **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. | + +**`GET /game/players/{id}/combat-health`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`maxHp`**, **`currentHp`**, **`defeated`**) backed by **`IPlayerCombatHealthStore`**. **404** when the player has no position row (same gate as cooldown snapshot). Plan: [NEO-95 implementation plan](../../docs/plans/NEO-95-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-health/`. + +```bash +curl -sS -i "http://localhost:5253/game/players/dev-local-1/combat-health" +``` + +**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) polls this GET alongside **`npc-runtime-snapshot`** for player HP + telegraph HUD. ## Combat engine (NEO-81) @@ -226,7 +245,8 @@ Comment-only hook sites in **`CombatOperations.TryResolve`** for future E9.M1 ca - **`ability_used`** — on every successful resolve (zero-damage abilities included). - **`enemy_defeat`** — only when lethal damage drives **`after.Defeated`** to **`true`** (re-hit on defeated targets denies at **`target_defeated`** before damage). -- **Reserved (comments only):** **`encounter_start`** (E5.M3 encounters), **`player_death`** (player HP deferred). +- **Reserved (comments only):** **`encounter_start`** (E5.M3 encounters). +- **`player_death`** — only when lethal NPC damage drives player **`currentHp`** to **0** (comment hook in NEO-95; full event deferred). E1.M4 cast funnel events **`ability_cast_requested`** / **`ability_cast_denied`** remain in **`AbilityCastApi`** ([NEO-30](#ability-cast-neo-31-neo-82)). E9.M1 ingest should correlate route **`playerId`** with engine payload fields at accept. Plan: [NEO-84 implementation plan](../../docs/plans/NEO-84-implementation-plan.md).