NEO-95: add tests, Bruno smoke, and docs for player combat HP
Cover elite telegraph damage, fixture reset, and combat-health GET verification; update E5M2 backlog and server README.pull/133/head
parent
cb1692ce85
commit
b702d9fb6f
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
| **Module ID** | E5.M2 |
|
| **Module ID** | E5.M2 |
|
||||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||||
| **Stage target** | Prototype |
|
| **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) |
|
| **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
|
## 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/`.
|
**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/`.
|
**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
|
## Risks and telemetry
|
||||||
|
|
|
||||||
|
|
@ -297,9 +297,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
|
||||||
|
|
||||||
**Acceptance criteria**
|
**Acceptance criteria**
|
||||||
|
|
||||||
- [ ] Elite archetype telegraph → attack deals catalog damage to holder.
|
- [x] Elite archetype telegraph → attack deals catalog damage to holder.
|
||||||
- [ ] Player HP read model matches store after NPC attack.
|
- [x] Player HP read model matches store after NPC attack.
|
||||||
- [ ] No client-side damage math required for verification (Bruno or integration tests).
|
- [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.
|
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — player HP + incoming telegraph/attack feedback labels.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,9 @@
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] Elite archetype telegraph → attack deals catalog damage (**25**) to holder.
|
- [x] Elite archetype telegraph → attack deals catalog damage (**25**) to holder.
|
||||||
- [ ] Player HP read model matches store after NPC attack.
|
- [x] Player HP read model matches store after NPC attack.
|
||||||
- [ ] No client-side damage math required for verification (Bruno or integration tests).
|
- [x] No client-side damage math required for verification (Bruno or integration tests).
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ public sealed class CombatTargetFixtureApiTests
|
||||||
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
||||||
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
||||||
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
|
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
|
||||||
|
var playerHealthStore = factory.Services.GetRequiredService<IPlayerCombatHealthStore>();
|
||||||
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||||
|
_ = playerHealthStore.TryApplyDamage("dev-local-1", 40, out _);
|
||||||
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
||||||
var windupStarted = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
var windupStarted = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
||||||
_ = runtimeStore.TryWrite(
|
_ = runtimeStore.TryWrite(
|
||||||
|
|
@ -54,6 +56,9 @@ public sealed class CombatTargetFixtureApiTests
|
||||||
runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime);
|
runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime);
|
||||||
Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState);
|
||||||
Assert.Null(runtime.ActiveTelegraph);
|
Assert.Null(runtime.ActiveTelegraph);
|
||||||
|
playerHealthStore.TryGet("dev-local-1", out var playerHealth);
|
||||||
|
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, playerHealth.CurrentHp);
|
||||||
|
Assert.False(playerHealth.Defeated);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PlayerCombatHealthResponse>();
|
||||||
|
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<NpcRuntimeSnapshotResponse>();
|
||||||
|
var healthBody = await healthResponse.Content.ReadFromJsonAsync<PlayerCombatHealthResponse>();
|
||||||
|
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<IPlayerCombatHealthStore>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<IPlayerCombatHealthStore>();
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(store);
|
||||||
|
Assert.True(store.TryGet("dev-local-1", out var snapshot));
|
||||||
|
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -8,13 +9,14 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
private static readonly DateTimeOffset T0 =
|
private static readonly DateTimeOffset T0 =
|
||||||
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
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()
|
CreateFixture()
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
new InMemoryNpcRuntimeStateStore(),
|
new InMemoryNpcRuntimeStateStore(),
|
||||||
new InMemoryThreatStateStore(),
|
new InMemoryThreatStateStore(),
|
||||||
PrototypeNpcTestFixtures.CreateBehaviorRegistry());
|
PrototypeNpcTestFixtures.CreateBehaviorRegistry(),
|
||||||
|
new InMemoryPlayerCombatHealthStore());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetHolder(IThreatStateStore threatStore, string npcId, string playerId = "dev-local-1") =>
|
private static void SetHolder(IThreatStateStore threatStore, string npcId, string playerId = "dev-local-1") =>
|
||||||
|
|
@ -25,16 +27,23 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
INpcRuntimeStateStore runtimeStore,
|
INpcRuntimeStateStore runtimeStore,
|
||||||
IThreatStateStore threatStore,
|
IThreatStateStore threatStore,
|
||||||
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||||
|
IPlayerCombatHealthStore playerHealthStore,
|
||||||
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
|
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
|
||||||
NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds);
|
NpcRuntimeOperations.AdvanceAll(
|
||||||
|
nowUtc,
|
||||||
|
runtimeStore,
|
||||||
|
threatStore,
|
||||||
|
behaviorRegistry,
|
||||||
|
playerHealthStore,
|
||||||
|
maxDeltaSeconds);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
|
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
// Act
|
// Act
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
|
@ -45,10 +54,10 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
|
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
|
@ -60,10 +69,10 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
|
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
runtime.LastAdvancedUtc = T0;
|
runtime.LastAdvancedUtc = T0;
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(10), runtime, threat, behavior);
|
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
|
@ -74,11 +83,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
|
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -90,11 +99,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
|
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(2.9), runtime, threat, behavior);
|
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
|
@ -104,12 +113,12 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
|
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(4.4), runtime, threat, behavior);
|
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -119,13 +128,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
|
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(4.4), runtime, threat, behavior);
|
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
||||||
|
|
@ -137,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
|
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(7.5), runtime, threat, behavior);
|
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -155,11 +164,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
|
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, maxDeltaSeconds: 10);
|
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 10);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -171,13 +180,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
|
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
|
||||||
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(3.5), runtime, threat, behavior);
|
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
|
@ -192,11 +201,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
double attackCooldownSeconds)
|
double attackCooldownSeconds)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, npcInstanceId);
|
SetHolder(threat, npcInstanceId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior);
|
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(npcInstanceId, out var snapshot);
|
runtime.TryGet(npcInstanceId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -212,12 +221,12 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
double telegraphWindupSeconds)
|
double telegraphWindupSeconds)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, npcInstanceId);
|
SetHolder(threat, npcInstanceId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior);
|
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior);
|
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(npcInstanceId, out var snapshot);
|
runtime.TryGet(npcInstanceId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
||||||
|
|
@ -230,11 +239,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
|
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5);
|
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
||||||
|
|
@ -246,12 +255,12 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
|
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
Advance(T0.AddSeconds(100), runtime, threat, behavior, maxDeltaSeconds: 5);
|
Advance(T0.AddSeconds(100), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, maxDeltaSeconds: 5);
|
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
|
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
|
@ -263,14 +272,14 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
|
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
runtime.LastAdvancedUtc = T0.AddHours(1);
|
runtime.LastAdvancedUtc = T0.AddHours(1);
|
||||||
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
|
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// 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
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
|
@ -282,15 +291,49 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
|
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior) = CreateFixture();
|
var (runtime, threat, behavior, playerHealth) = CreateFixture();
|
||||||
Advance(T0, runtime, threat, behavior);
|
Advance(T0, runtime, threat, behavior, playerHealth);
|
||||||
runtime.LastAdvancedUtc = T0.AddSeconds(10);
|
runtime.LastAdvancedUtc = T0.AddSeconds(10);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(5), runtime, threat, behavior);
|
Advance(T0.AddSeconds(5), runtime, threat, behavior, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
|
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]
|
[Fact]
|
||||||
public void HasPositiveTimingDurations_ShouldRejectNonPositiveCatalogTimings()
|
public void HasPositiveTimingDurations_ShouldRejectNonPositiveCatalogTimings()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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)
|
## 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. |
|
| **`idle`** | No aggro holder; no telegraph. |
|
||||||
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
|
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
|
||||||
| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. |
|
| **`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. |
|
| **`recover`** | Post-attack cooldown wait before next windup. |
|
||||||
|
|
||||||
| Rule | Behavior |
|
| 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"
|
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)
|
## 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).
|
- **`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).
|
- **`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).
|
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).
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue