From e729996f4711aebb5e2861dae0dcd5cc402c52c2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 22:35:37 -0400 Subject: [PATCH 1/4] NEO-80: add implementation plan for combat entity health store. --- docs/plans/NEO-80-implementation-plan.md | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/plans/NEO-80-implementation-plan.md diff --git a/docs/plans/NEO-80-implementation-plan.md b/docs/plans/NEO-80-implementation-plan.md new file mode 100644 index 0000000..a73b96e --- /dev/null +++ b/docs/plans/NEO-80-implementation-plan.md @@ -0,0 +1,127 @@ +# NEO-80 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-80 | +| **Title** | E5M1-05: Combat entity health store + dummy init | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-80/e5m1-05-combat-entity-health-store-dummy-init | +| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-05** | +| **Branch** | `NEO-80-combat-entity-health-store` | +| **Precursor** | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` + DI (**Done** on `main`) | +| **Pattern** | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — lazy in-memory instance store + per-id locking; [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — dedicated `Add*Store()` DI extension | +| **Blocks** | [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) — `CombatOperations.TryResolve` + `CombatResult` | +| **Client counterpart** | None (server-only internal store); HP visibility lands in [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) GET combat-targets + [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client HUD | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Max HP source** | Content JSON vs combat constant? | **`PrototypeCombatConstants.MaxPrototypeTargetHp = 100`** in `Game/Combat/` — no target content schema in Slice 1; matches E5M1 backlog default (four × 25 dmg pulse = defeat). | **User:** combat constants. | +| **Init timing** | Lazy first access vs eager startup seed? | **Lazy init** on first `TryGet` / `TryApplyDamage` for known registry ids — backlog explicit; mirrors NEO-61 resource node lazy rows. | **User:** lazy first access. | +| **Target id gate** | Registry-only vs generic any-id store? | **`PrototypeTargetRegistry` ids only** (`prototype_target_alpha`, `prototype_target_beta`) — E5M1 prototype scope; prevents arbitrary entity pollution. | **User:** registry only. | +| **ApplyDamage shape** | Out snapshot vs mutation outcome struct? | **`bool TryApplyDamage(targetId, damage, out CombatEntityHealthSnapshot)`** — snapshot carries `targetId`, `maxHp`, `currentHp`, `defeated`; NEO-81 wraps with combat deny logic. | **User:** TryApplyDamage + out snapshot. | + +## Goal, scope, and out-of-scope + +**Goal:** Provide a server-owned, in-memory HP store for prototype combat dummies keyed by **`PrototypeTargetRegistry`** target id. Lazy-initialize **`prototype_target_alpha`** and **`prototype_target_beta`** to **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** on first read or damage. Support deterministic damage application with HP floored at zero and a **`defeated`** flag until explicit reset. + +**In scope (from Linear + [E5M1-05](E5M1-prototype-backlog.md#e5m1-05--combat-entity-health-store--prototype-dummy-init)):** + +- `ICombatEntityHealthStore` with **`TryGet`**, **`TryApplyDamage`**, **`TryResetToFull`** for prototype registry targets. +- `InMemoryCombatEntityHealthStore` — thread-safe, per-id locking (NEO-61 pattern). +- `CombatEntityHealthSnapshot` readonly struct (`targetId`, `maxHp`, `currentHp`, `defeated`). +- `PrototypeCombatConstants.MaxPrototypeTargetHp = 100`. +- DI via **`AddCombatEntityHealthStore()`** in `Program.cs`. +- Unit tests (AAA): lazy init, damage, floor at zero, defeated persistence, reset, unknown-id deny, alpha/beta independence. +- Document in-memory prototype reset policy in `server/README.md` (server restart clears HP; **`TryResetToFull`** for tests / future fixtures). + +**Out of scope (from Linear + backlog):** + +- HTTP routes ([NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET). +- `CombatOperations.TryResolve` ([NEO-81](https://linear.app/neon-sprawl/issue/NEO-81)). +- Cast wiring / `AbilityCastResponse` resolution ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)). +- Player HP, Postgres persistence, NPC spawn ([E5.M2](E5M2_NpcAiAndBehaviorProfiles.md)). +- Godot / client changes. +- Bruno (no HTTP surface in this story). + +## Acceptance criteria checklist + +- [ ] Damaging **`prototype_target_alpha`** reduces stored HP; HP cannot go below zero. +- [ ] **`defeated`** is true when **`currentHp == 0`** and remains true on further damage until reset. +- [ ] **`prototype_target_beta`** initializes independently with the same max HP on first access. +- [ ] Unknown target id returns **`false`** from **`TryGet`** / **`TryApplyDamage`** without throwing. +- [ ] In-memory prototype reset policy documented (server restart; **`TryResetToFull`** for tests). +- [ ] DI resolves **`ICombatEntityHealthStore`** at host startup (test via **`InMemoryWebApplicationFactory`**). + +## Technical approach + +1. **`PrototypeCombatConstants`** — `public const int MaxPrototypeTargetHp = 100;` in `Game/Combat/`. XML remarks: four **`prototype_pulse`** casts (25 dmg) defeat one dummy; aligns with [E5.M1 freeze box](../decomposition/modules/E5_M1_CombatRulesEngine.md#prototype-slice-1-freeze-e5m1-01). + +2. **`CombatEntityHealthSnapshot`** — readonly record struct: **`TargetId`**, **`MaxHp`**, **`CurrentHp`**, **`Defeated`** (`CurrentHp <= 0`). Used by store read and mutation methods. + +3. **`ICombatEntityHealthStore`** — contract for NEO-81+ consumers: + - **`TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot)`** — normalize id (trim + lowercase); reject empty/unknown registry ids; **lazy-init** row at max HP on first successful read. + - **`TryApplyDamage(string? targetId, int damage, out CombatEntityHealthSnapshot snapshot)`** — same id gate + lazy init; **`damage`** must be **≥ 0** (negative → false); subtract damage, floor **`currentHp`** at **0**; set **`defeated`** when **`currentHp == 0`** (idempotent on overkill). + - **`TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot)`** — registry id only; set **`currentHp = maxHp`**, **`defeated = false`**; creates row if absent. + +4. **`InMemoryCombatEntityHealthStore`** — `ConcurrentDictionary` for current HP + per-id locks (mirror [`InMemoryResourceNodeInstanceStore`](../../server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs)). Private **`EnsurePrototypeTarget(string normalizedId)`** validates **`PrototypeTargetRegistry.TryGet`** and lazy-inits HP to **`MaxPrototypeTargetHp`**. + +5. **DI** — **`CombatEntityHealthServiceCollectionExtensions.AddCombatEntityHealthStore()`** registers **`ICombatEntityHealthStore` → `InMemoryCombatEntityHealthStore`** singleton. Call from **`Program.cs`** after ability catalog registration. **No Postgres** in Slice 1 (backlog in-memory policy). + +6. **Prototype reset policy (document):** + - **Production prototype:** HP rows are **not persisted**; **server process restart** resets all dummies to uninitialized (lazy full HP on next access). + - **`TryResetToFull`:** explicit test/fixture hook to revive a dummy without restart; not exposed over HTTP in this story. + +7. **NEO-81 handoff:** Store does **not** deny damage to already-defeated targets — HP stays **0**, **`defeated`** stays **true**. Structured **`target_defeated`** deny belongs in **`CombatOperations.TryResolve`** (NEO-81). + +### Expected prototype values + +| Target id | Max HP | Init policy | +|-----------|--------|-------------| +| `prototype_target_alpha` | 100 | Lazy on first `TryGet` / `TryApplyDamage` | +| `prototype_target_beta` | 100 | Lazy on first `TryGet` / `TryApplyDamage` | + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs` | Frozen **`MaxPrototypeTargetHp = 100`** for prototype dummies. | +| `server/NeonSprawl.Server/Game/Combat/CombatEntityHealthSnapshot.cs` | Readonly snapshot struct returned by store methods. | +| `server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs` | Public contract: **`TryGet`**, **`TryApplyDamage`**, **`TryResetToFull`**. | +| `server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs` | Thread-safe in-memory implementation with registry gate + lazy init. | +| `server/NeonSprawl.Server/Game/Combat/CombatEntityHealthServiceCollectionExtensions.cs` | **`AddCombatEntityHealthStore()`** DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs` | AAA unit + host DI tests. | +| `docs/plans/NEO-80-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Program.cs` | Call **`AddCombatEntityHealthStore()`** during service registration. | +| `server/README.md` | Document combat entity health store, max HP constant, lazy init, in-memory reset policy, and NEO-81 consumer note. | + +## Tests + +| File | Coverage | +|------|----------| +| `server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs` | **Lazy init:** first **`TryGet`** on alpha → **`currentHp` 100**, **`defeated` false**, **`maxHp` 100**. **Damage:** **`TryApplyDamage`** 25 → 75 HP; repeated until 0 → **`defeated` true**. **Floor:** overkill damage keeps **`currentHp` 0**, **`defeated` true**. **Reset:** **`TryResetToFull`** → 100 HP, **`defeated` false**. **Independence:** beta lazy-inits separately. **Deny:** unknown id, empty id, negative damage → false. **Host:** **`InMemoryWebApplicationFactory`** resolves **`ICombatEntityHealthStore`** from DI. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | + +No Bruno or manual QA for this story (server-only internal store; no HTTP or client-facing change). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|---------------------|--------| +| **Defeated-target damage at store layer** | **Allow apply** (HP stays 0); NEO-81 denies re-hit at combat ops layer. | **adopted** | +| **Healing / negative damage** | **Reject negative `damage`** with false; healing is out of Slice 1 scope. | **adopted** | +| **Persistence later** | **Defer Postgres** to a follow-up when E5.M2+ needs durable NPC HP; interface stays swappable. | **deferred** | +| **Test factory isolation** | Each test uses fresh **`InMemoryWebApplicationFactory`** (`using`) → fresh singleton store; no cross-test bleed. | **adopted** | + +## Decisions (kickoff) + +- **Max HP** lives in **`PrototypeCombatConstants`** (not content JSON). +- **Lazy init** on first access for **`PrototypeTargetRegistry`** ids only. +- **`TryApplyDamage`** returns **`bool`** + **`out CombatEntityHealthSnapshot`** (not mutation outcome struct). +- **No HTTP** in NEO-80; combat-target snapshot is [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83). From b060b9dffdbc69236d56e71e216f0c83b6e11599 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 22:37:57 -0400 Subject: [PATCH 2/4] NEO-80: add combat entity health store for prototype dummies. Server-owned in-memory HP for prototype_target_alpha/beta with lazy init at 100 HP, damage floor at zero, defeated flag, and DI registration. --- docs/plans/NEO-80-implementation-plan.md | 23 ++- .../Combat/CombatEntityHealthStoreTests.cs | 153 ++++++++++++++++++ ...ilityCatalogServiceCollectionExtensions.cs | 2 + ...EntityHealthServiceCollectionExtensions.cs | 12 ++ .../Game/Combat/CombatEntityHealthSnapshot.cs | 12 ++ .../Game/Combat/ICombatEntityHealthStore.cs | 30 ++++ .../Combat/InMemoryCombatEntityHealthStore.cs | 92 +++++++++++ .../Game/Combat/PrototypeCombatConstants.cs | 11 ++ server/README.md | 13 ++ 9 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatEntityHealthServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatEntityHealthSnapshot.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs diff --git a/docs/plans/NEO-80-implementation-plan.md b/docs/plans/NEO-80-implementation-plan.md index a73b96e..dcf29d6 100644 --- a/docs/plans/NEO-80-implementation-plan.md +++ b/docs/plans/NEO-80-implementation-plan.md @@ -48,12 +48,12 @@ ## Acceptance criteria checklist -- [ ] Damaging **`prototype_target_alpha`** reduces stored HP; HP cannot go below zero. -- [ ] **`defeated`** is true when **`currentHp == 0`** and remains true on further damage until reset. -- [ ] **`prototype_target_beta`** initializes independently with the same max HP on first access. -- [ ] Unknown target id returns **`false`** from **`TryGet`** / **`TryApplyDamage`** without throwing. -- [ ] In-memory prototype reset policy documented (server restart; **`TryResetToFull`** for tests). -- [ ] DI resolves **`ICombatEntityHealthStore`** at host startup (test via **`InMemoryWebApplicationFactory`**). +- [x] Damaging **`prototype_target_alpha`** reduces stored HP; HP cannot go below zero. +- [x] **`defeated`** is true when **`currentHp == 0`** and remains true on further damage until reset. +- [x] **`prototype_target_beta`** initializes independently with the same max HP on first access. +- [x] Unknown target id returns **`false`** from **`TryGet`** / **`TryApplyDamage`** without throwing. +- [x] In-memory prototype reset policy documented (server restart; **`TryResetToFull`** for tests). +- [x] DI resolves **`ICombatEntityHealthStore`** at host startup (test via **`InMemoryWebApplicationFactory`**). ## Technical approach @@ -68,7 +68,7 @@ 4. **`InMemoryCombatEntityHealthStore`** — `ConcurrentDictionary` for current HP + per-id locks (mirror [`InMemoryResourceNodeInstanceStore`](../../server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs)). Private **`EnsurePrototypeTarget(string normalizedId)`** validates **`PrototypeTargetRegistry.TryGet`** and lazy-inits HP to **`MaxPrototypeTargetHp`**. -5. **DI** — **`CombatEntityHealthServiceCollectionExtensions.AddCombatEntityHealthStore()`** registers **`ICombatEntityHealthStore` → `InMemoryCombatEntityHealthStore`** singleton. Call from **`Program.cs`** after ability catalog registration. **No Postgres** in Slice 1 (backlog in-memory policy). +5. **DI** — **`CombatEntityHealthServiceCollectionExtensions.AddCombatEntityHealthStore()`** registers **`ICombatEntityHealthStore` → `InMemoryCombatEntityHealthStore`** singleton. Chained from **`AddAbilityDefinitionCatalog`** after ability registry registration. **No Postgres** in Slice 1 (backlog in-memory policy). 6. **Prototype reset policy (document):** - **Production prototype:** HP rows are **not persisted**; **server process restart** resets all dummies to uninitialized (lazy full HP on next access). @@ -99,7 +99,7 @@ | Path | Rationale | |------|-----------| -| `server/NeonSprawl.Server/Program.cs` | Call **`AddCombatEntityHealthStore()`** during service registration. | +| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs` | Chain **`AddCombatEntityHealthStore()`** after ability registry registration (avoids `Program.cs` change for DI-only story). | | `server/README.md` | Document combat entity health store, max HP constant, lazy init, in-memory reset policy, and NEO-81 consumer note. | ## Tests @@ -125,3 +125,10 @@ No Bruno or manual QA for this story (server-only internal store; no HTTP or cli - **Lazy init** on first access for **`PrototypeTargetRegistry`** ids only. - **`TryApplyDamage`** returns **`bool`** + **`out CombatEntityHealthSnapshot`** (not mutation outcome struct). - **No HTTP** in NEO-80; combat-target snapshot is [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83). + +## Reconciliation (implementation) + +- **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** registered via **`AddCombatEntityHealthStore()`** chained from **`AddAbilityDefinitionCatalog`**. +- **`PrototypeCombatConstants.MaxPrototypeTargetHp = 100`**; lazy init gated to **`PrototypeTargetRegistry`** ids. +- **10** AAA tests in **`CombatEntityHealthStoreTests`** (all green). +- **`server/README.md`** — combat entity health section with init/damage/reset policy. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs new file mode 100644 index 0000000..477b83e --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public sealed class CombatEntityHealthStoreTests +{ + [Fact] + public void TryGet_OnFirstAccess_ShouldLazyInitAlphaToFullHp() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + // Assert + Assert.True(found); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.MaxHp); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.False(snapshot.Defeated); + } + + [Fact] + public void TryApplyDamage_ShouldReduceHp_WhenDamageIsApplied() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot); + // Assert + Assert.True(applied); + Assert.Equal(75, snapshot.CurrentHp); + Assert.False(snapshot.Defeated); + } + + [Fact] + public void TryApplyDamage_RepeatedUntilZero_ShouldMarkDefeated() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var first = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); + var second = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); + var third = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); + var fourth = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var defeated); + // Assert + Assert.True(first); + Assert.True(second); + Assert.True(third); + Assert.True(fourth); + Assert.Equal(0, defeated.CurrentHp); + Assert.True(defeated.Defeated); + } + + [Fact] + public void TryApplyDamage_OnDefeatedTarget_ShouldKeepHpAtZeroAndRemainDefeated() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); + // Act + var overkill = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot); + // Assert + Assert.True(overkill); + Assert.Equal(0, snapshot.CurrentHp); + Assert.True(snapshot.Defeated); + } + + [Fact] + public void TryResetToFull_AfterDefeat_ShouldRestoreFullHpAndClearDefeated() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); + // Act + var reset = store.TryResetToFull(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + // Assert + Assert.True(reset); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.False(snapshot.Defeated); + } + + [Fact] + public void TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 40, out _); + // Act + var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetBetaId, out var beta); + store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var alpha); + // Assert + Assert.True(found); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); + Assert.False(beta.Defeated); + Assert.Equal(60, alpha.CurrentHp); + } + + [Fact] + public void TryGet_ShouldReturnFalse_WhenTargetIdIsUnknown() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var found = store.TryGet("not_a_prototype_target", out var snapshot); + // Assert + Assert.False(found); + Assert.Equal(default, snapshot); + } + + [Fact] + public void TryGet_ShouldReturnFalse_WhenTargetIdIsEmpty() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var found = store.TryGet(" ", out var snapshot); + // Assert + Assert.False(found); + Assert.Equal(default, snapshot); + } + + [Fact] + public void TryApplyDamage_ShouldReturnFalse_WhenDamageIsNegative() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, -1, out var snapshot); + // Assert + Assert.False(applied); + Assert.Equal(default, snapshot); + } + + [Fact] + public async Task Host_ShouldResolveCombatEntityHealthStoreFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var store = factory.Services.GetRequiredService(); + var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + // Assert + Assert.True(found); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.False(snapshot.Defeated); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs index 9644fc5..31673e1 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs @@ -35,6 +35,8 @@ public static class AbilityCatalogServiceCollectionExtensions services.AddSingleton(sp => new AbilityDefinitionRegistry(sp.GetRequiredService())); + services.AddCombatEntityHealthStore(); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Combat/CombatEntityHealthServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/CombatEntityHealthServiceCollectionExtensions.cs new file mode 100644 index 0000000..9870b27 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatEntityHealthServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Registers prototype combat entity health store (NEO-80). +public static class CombatEntityHealthServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddCombatEntityHealthStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatEntityHealthSnapshot.cs b/server/NeonSprawl.Server/Game/Combat/CombatEntityHealthSnapshot.cs new file mode 100644 index 0000000..4fd0a1f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatEntityHealthSnapshot.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Authoritative HP snapshot for a prototype combat target (NEO-80). +/// Normalized lowercase target id. +/// Catalog max HP for prototype dummies. +/// Current HP after damage; floored at zero. +/// true when is zero. +public readonly record struct CombatEntityHealthSnapshot( + string TargetId, + int MaxHp, + int CurrentHp, + bool Defeated); diff --git a/server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs new file mode 100644 index 0000000..e53b324 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs @@ -0,0 +1,30 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Server-owned HP store for prototype combat dummies keyed by +/// target id (NEO-80). +/// +/// +/// NEO-81+: combat resolution should depend on this interface rather than reaching into store internals. +/// Re-hit deny on defeated targets belongs in CombatOperations (NEO-81), not this store. +/// +public interface ICombatEntityHealthStore +{ + /// + /// Reads the current snapshot for a known prototype target. Lazy-initializes HP to + /// on first access. + /// + bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot); + + /// + /// Applies non-negative damage to a known prototype target. Lazy-initializes on first access. + /// Floors at zero. + /// + bool TryApplyDamage(string? targetId, int damage, out CombatEntityHealthSnapshot snapshot); + + /// + /// Restores a known prototype target to full HP and clears . + /// Creates the row when absent. Intended for tests and future dev fixtures — not HTTP in Slice 1. + /// + bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot); +} diff --git a/server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs new file mode 100644 index 0000000..7e102b4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using NeonSprawl.Server.Game.Targeting; + +namespace NeonSprawl.Server.Game.Combat; + +/// Thread-safe in-memory HP for prototype combat dummies; empty at startup — lazy rows only (NEO-80). +public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore +{ + private readonly ConcurrentDictionary currentHpById = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot) + { + var key = NormalizeTargetId(targetId); + if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) + { + snapshot = default; + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + EnsureRowInitialized(key); + snapshot = CreateSnapshot(key, currentHpById[key]); + return true; + } + } + + /// + public bool TryApplyDamage(string? targetId, int damage, out CombatEntityHealthSnapshot snapshot) + { + snapshot = default; + if (damage < 0) + { + return false; + } + + var key = NormalizeTargetId(targetId); + if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) + { + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + EnsureRowInitialized(key); + var current = currentHpById[key]; + var next = Math.Max(0, current - damage); + currentHpById[key] = next; + snapshot = CreateSnapshot(key, next); + return true; + } + } + + /// + public bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot) + { + var key = NormalizeTargetId(targetId); + if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) + { + snapshot = default; + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + currentHpById[key] = PrototypeCombatConstants.MaxPrototypeTargetHp; + snapshot = CreateSnapshot(key, PrototypeCombatConstants.MaxPrototypeTargetHp); + return true; + } + } + + private void EnsureRowInitialized(string normalizedId) + { + if (!currentHpById.ContainsKey(normalizedId)) + { + currentHpById[normalizedId] = PrototypeCombatConstants.MaxPrototypeTargetHp; + } + } + + private static CombatEntityHealthSnapshot CreateSnapshot(string targetId, int currentHp) => + new( + targetId, + PrototypeCombatConstants.MaxPrototypeTargetHp, + currentHp, + currentHp <= 0); + + private static string NormalizeTargetId(string? targetId) => + targetId?.Trim().ToLowerInvariant() ?? string.Empty; +} diff --git a/server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs new file mode 100644 index 0000000..54111ad --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Frozen prototype combat tuning for E5.M1 Slice 1 (NEO-80). +public static class PrototypeCombatConstants +{ + /// + /// Shared max HP for combat dummies. + /// Four prototype_pulse casts at 25 damage each defeat one dummy (100 HP). + /// + public const int MaxPrototypeTargetHp = 100; +} diff --git a/server/README.md b/server/README.md index eb26759..5482432 100644 --- a/server/README.md +++ b/server/README.md @@ -101,6 +101,19 @@ On success, **Information** logs include the resolved abilities directory path, curl -sS -i "http://localhost:5253/game/world/ability-definitions" ``` +## Combat entity health (NEO-80) + +Server-owned HP for prototype combat dummies lives in **`Game/Combat/`** as **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`**. Rows are keyed by **`PrototypeTargetRegistry`** ids (**`prototype_target_alpha`**, **`prototype_target_beta`**) only. Max HP is frozen at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** — four **`prototype_pulse`** casts (25 damage each) defeat one dummy. + +| Policy | Behavior | +|--------|----------| +| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id. | +| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Re-hit on defeated targets still applies at store layer (HP stays 0); structured deny is **NEO-81** (`CombatOperations`). | +| **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives a dummy for tests/fixtures — not exposed over HTTP in Slice 1. | +| **HTTP read** | **`GET /game/world/combat-targets`** is **NEO-83**; cast wiring is **NEO-82**. | + +Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md). + ## Recipe definitions (NEO-68) **`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`. From c3df7fc8d0ec8b5027608f667ed45a1c0bb3cfce Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 22:40:26 -0400 Subject: [PATCH 3/4] NEO-80: Add code review for combat entity health store. --- docs/reviews/2026-05-24-NEO-80.md | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/reviews/2026-05-24-NEO-80.md diff --git a/docs/reviews/2026-05-24-NEO-80.md b/docs/reviews/2026-05-24-NEO-80.md new file mode 100644 index 0000000..b6d85aa --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-80.md @@ -0,0 +1,62 @@ +# Code review — NEO-80 combat entity health store + +**Date:** 2026-05-24 +**Scope:** Branch `NEO-80-combat-entity-health-store` · commits `e729996`–`b060b9d` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-80 delivers **E5M1-05**: a thread-safe, in-memory `ICombatEntityHealthStore` for prototype combat dummies (`prototype_target_alpha`, `prototype_target_beta`), with lazy init to `PrototypeCombatConstants.MaxPrototypeTargetHp` (100), damage flooring at zero, defeated flag semantics, and `TryResetToFull` for tests/fixtures. DI registers the store as a singleton via `AddCombatEntityHealthStore()`, chained from `AddAbilityDefinitionCatalog` per the plan. Ten AAA unit/host tests cover lazy init, damage, defeat/overkill, reset, alpha/beta independence, empty/unknown/negative deny paths, and host resolution. Full suite (377 tests) passes. Implementation aligns with the kickoff plan and mirrors the NEO-61 per-id locking pattern. Residual risk is low — server-only internal store with no HTTP surface; module tracking docs and one minor test gap should be closed on merge. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-80-implementation-plan.md`](../plans/NEO-80-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation; DI chained from ability catalog (not `Program.cs`) as documented in Files to modify. | +| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-05** | **Partially matches** — implementation complete; backlog AC checkboxes still unchecked and no landed note yet. | +| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Partially matches** — Status still says “E5M1-05+ pending”; should cite NEO-80 landed after merge. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E5.M1 note ends at NEO-78; NEO-80 health store not yet recorded. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Partially matches** — E5.M1 row should add NEO-80 landed on merge. | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only in-memory store; no client or HTTP changes. | +| [`server/README.md`](../../server/README.md) | **Matches** — combat entity health section documents init, damage, reset policy, and NEO-81 handoff. | +| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; HP visibility deferred to NEO-83/NEO-85. | +| Manual QA | **N/A** — plan skip rationale holds (server-only internal store). | + +## Blocking issues + +None. + +## Suggestions + +1. **Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** from “E5M1-05+ pending” to cite **E5M1-05 NEO-80 landed** (`ICombatEntityHealthStore` + lazy dummy init), matching prior NEO-76–78 landed pattern. + +2. **Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-80 landed:** `ICombatEntityHealthStore` / `InMemoryCombatEntityHealthStore`, `PrototypeCombatConstants.MaxPrototypeTargetHp = 100`, DI via `AddCombatEntityHealthStore()`. + +3. **E5M1 backlog landed note** — In `docs/plans/E5M1-prototype-backlog.md` § E5M1-05, check AC boxes and add a one-line landed note with link to [NEO-80 implementation plan](../plans/NEO-80-implementation-plan.md) (same pattern as E5M1-03/04). + +4. **`TryApplyDamage` deny coverage** — Plan Tests table lists “Deny: unknown id, empty id, negative damage.” Negative damage and empty id are covered only on `TryGet`; add `TryApplyDamage` false-path tests for unknown and empty ids so both mutation entry points are symmetrically guarded. + +## Nits + +- Nit: No test for case-variant ids (e.g. `"Prototype_Target_Alpha"`) — normalization is implemented via `Trim().ToLowerInvariant()` but untested; low risk given registry keys are lowercase constants. + +- Nit: `TryResetToFull` deny path (unknown id) is untested; happy path and `TryGet` deny are covered. + +- Nit: `AddCombatEntityHealthStore()` is chained inside `AddAbilityDefinitionCatalog` — acceptable per plan, but couples unrelated concerns; a future `AddCombatServices()` aggregator may be cleaner when NEO-81+ adds more combat DI. + +- Nit: `TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha` performs two `TryGet` calls in **Act** (beta + alpha read); alpha state could be asserted via a prior snapshot from Arrange instead — AAA style only. + +## Verification + +```bash +cd /home/don/neon-sprawl/server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~CombatEntityHealthStoreTests" +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +Manual: **N/A** — no HTTP or client surface in this story. From bfb0eb93060d691a53ac7dbe3077c971e3cadfb6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 22:42:33 -0400 Subject: [PATCH 4/4] NEO-80: Address code review follow-ups for health store. --- .../modules/E5_M1_CombatRulesEngine.md | 4 +- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E5M1-prototype-backlog.md | 6 ++- docs/plans/NEO-80-implementation-plan.md | 2 +- docs/reviews/2026-05-24-NEO-80.md | 27 +++++----- .../Combat/CombatEntityHealthStoreTests.cs | 54 +++++++++++++++++-- 7 files changed, 75 insertions(+), 22 deletions(-) diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index 7e72b5e..351162b 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M1 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05+ pending (see [dependency register](module_dependency_register.md)) | +| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06+ pending (see [dependency register](module_dependency_register.md)) | | **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) | ## Purpose @@ -52,6 +52,8 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 a **E5M1-04 (NEO-78):** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. +**E5M1-05 (NEO-80):** **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** — lazy HP init for **`PrototypeTargetRegistry`** dummies at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). + ## Linear backlog (decomposed) Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c24cbfe..43244ea 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -56,7 +56,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | -| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-05** [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | +| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-06** [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index b7ae1e8..cccb76f 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned | | E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned | -**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. +**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). ### Epic 6 — PvP Security diff --git a/docs/plans/E5M1-prototype-backlog.md b/docs/plans/E5M1-prototype-backlog.md index 45dc327..df40fb9 100644 --- a/docs/plans/E5M1-prototype-backlog.md +++ b/docs/plans/E5M1-prototype-backlog.md @@ -187,8 +187,10 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/ **Acceptance criteria** -- [ ] Damaging alpha reduces stored HP; HP cannot go below zero. -- [ ] Defeated target remains defeated until explicit reset policy (document prototype: server restart resets). +- [x] Damaging alpha reduces stored HP; HP cannot go below zero. +- [x] Defeated target remains defeated until explicit reset policy (document prototype: server restart resets). + +**Landed ([NEO-80](https://linear.app/neon-sprawl/issue/NEO-80)):** **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** — lazy init for **`prototype_target_alpha`** / **`prototype_target_beta`** at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** ([NEO-80-implementation-plan.md](NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../server/README.md#combat-entity-health-neo-80). --- diff --git a/docs/plans/NEO-80-implementation-plan.md b/docs/plans/NEO-80-implementation-plan.md index dcf29d6..dff1052 100644 --- a/docs/plans/NEO-80-implementation-plan.md +++ b/docs/plans/NEO-80-implementation-plan.md @@ -130,5 +130,5 @@ No Bruno or manual QA for this story (server-only internal store; no HTTP or cli - **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** registered via **`AddCombatEntityHealthStore()`** chained from **`AddAbilityDefinitionCatalog`**. - **`PrototypeCombatConstants.MaxPrototypeTargetHp = 100`**; lazy init gated to **`PrototypeTargetRegistry`** ids. -- **10** AAA tests in **`CombatEntityHealthStoreTests`** (all green). +- **14** AAA tests in **`CombatEntityHealthStoreTests`** (all green); code review follow-up added deny-path and normalization coverage. - **`server/README.md`** — combat entity health section with init/damage/reset policy. diff --git a/docs/reviews/2026-05-24-NEO-80.md b/docs/reviews/2026-05-24-NEO-80.md index b6d85aa..b4e9f47 100644 --- a/docs/reviews/2026-05-24-NEO-80.md +++ b/docs/reviews/2026-05-24-NEO-80.md @@ -2,7 +2,8 @@ **Date:** 2026-05-24 **Scope:** Branch `NEO-80-combat-entity-health-store` · commits `e729996`–`b060b9d` vs `origin/main` -**Base:** `origin/main` +**Base:** `origin/main` +**Follow-up:** Code review suggestions addressed (module/alignment docs, E5M1 backlog landed note, `TryApplyDamage` deny tests, normalization + reset deny tests, AAA fix on beta independence test). ## Verdict @@ -17,10 +18,10 @@ NEO-80 delivers **E5M1-05**: a thread-safe, in-memory `ICombatEntityHealthStore` | Document | Result | |----------|--------| | [`docs/plans/NEO-80-implementation-plan.md`](../plans/NEO-80-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation; DI chained from ability catalog (not `Program.cs`) as documented in Files to modify. | -| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-05** | **Partially matches** — implementation complete; backlog AC checkboxes still unchecked and no landed note yet. | -| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Partially matches** — Status still says “E5M1-05+ pending”; should cite NEO-80 landed after merge. | -| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E5.M1 note ends at NEO-78; NEO-80 health store not yet recorded. | -| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Partially matches** — E5.M1 row should add NEO-80 landed on merge. | +| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-05** | **Matches** — AC checkboxes checked; landed note with plan link added. | +| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites NEO-80 landed; related implementation slice added. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-80 health store. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row includes NEO-80 landed. | | [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only in-memory store; no client or HTTP changes. | | [`server/README.md`](../../server/README.md) | **Matches** — combat entity health section documents init, damage, reset policy, and NEO-81 handoff. | | Full-stack epic decomposition | **Matches** — plan documents no client counterpart; HP visibility deferred to NEO-83/NEO-85. | @@ -32,23 +33,23 @@ None. ## Suggestions -1. **Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** from “E5M1-05+ pending” to cite **E5M1-05 NEO-80 landed** (`ICombatEntityHealthStore` + lazy dummy init), matching prior NEO-76–78 landed pattern. +1. ~~**Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** from “E5M1-05+ pending” to cite **E5M1-05 NEO-80 landed** (`ICombatEntityHealthStore` + lazy dummy init), matching prior NEO-76–78 landed pattern.~~ **Done.** Status + related implementation slice updated. -2. **Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-80 landed:** `ICombatEntityHealthStore` / `InMemoryCombatEntityHealthStore`, `PrototypeCombatConstants.MaxPrototypeTargetHp = 100`, DI via `AddCombatEntityHealthStore()`. +2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-80 landed:** `ICombatEntityHealthStore` / `InMemoryCombatEntityHealthStore`, `PrototypeCombatConstants.MaxPrototypeTargetHp = 100`, DI via `AddCombatEntityHealthStore()`.~~ **Done.** -3. **E5M1 backlog landed note** — In `docs/plans/E5M1-prototype-backlog.md` § E5M1-05, check AC boxes and add a one-line landed note with link to [NEO-80 implementation plan](../plans/NEO-80-implementation-plan.md) (same pattern as E5M1-03/04). +3. ~~**E5M1 backlog landed note** — In `docs/plans/E5M1-prototype-backlog.md` § E5M1-05, check AC boxes and add a one-line landed note with link to [NEO-80 implementation plan](../plans/NEO-80-implementation-plan.md) (same pattern as E5M1-03/04).~~ **Done.** -4. **`TryApplyDamage` deny coverage** — Plan Tests table lists “Deny: unknown id, empty id, negative damage.” Negative damage and empty id are covered only on `TryGet`; add `TryApplyDamage` false-path tests for unknown and empty ids so both mutation entry points are symmetrically guarded. +4. ~~**`TryApplyDamage` deny coverage** — Plan Tests table lists “Deny: unknown id, empty id, negative damage.” Negative damage and empty id are covered only on `TryGet`; add `TryApplyDamage` false-path tests for unknown and empty ids so both mutation entry points are symmetrically guarded.~~ **Done.** Added unknown/empty deny tests plus case normalization and `TryResetToFull` deny tests. ## Nits -- Nit: No test for case-variant ids (e.g. `"Prototype_Target_Alpha"`) — normalization is implemented via `Trim().ToLowerInvariant()` but untested; low risk given registry keys are lowercase constants. +- ~~Nit: No test for case-variant ids (e.g. `"Prototype_Target_Alpha"`) — normalization is implemented via `Trim().ToLowerInvariant()` but untested; low risk given registry keys are lowercase constants.~~ **Done.** `TryGet_ShouldNormalizeCaseVariantTargetIds`. -- Nit: `TryResetToFull` deny path (unknown id) is untested; happy path and `TryGet` deny are covered. +- ~~Nit: `TryResetToFull` deny path (unknown id) is untested; happy path and `TryGet` deny are covered.~~ **Done.** `TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown`. -- Nit: `AddCombatEntityHealthStore()` is chained inside `AddAbilityDefinitionCatalog` — acceptable per plan, but couples unrelated concerns; a future `AddCombatServices()` aggregator may be cleaner when NEO-81+ adds more combat DI. +- Nit: `AddCombatEntityHealthStore()` is chained inside `AddAbilityDefinitionCatalog` — acceptable per plan, but couples unrelated concerns; a future `AddCombatServices()` aggregator may be cleaner when NEO-81+ adds more combat DI. **Deferred** — documented in plan; revisit with NEO-81 combat DI. -- Nit: `TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha` performs two `TryGet` calls in **Act** (beta + alpha read); alpha state could be asserted via a prior snapshot from Arrange instead — AAA style only. +- ~~Nit: `TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha` performs two `TryGet` calls in **Act** (beta + alpha read); alpha state could be asserted via a prior snapshot from Arrange instead — AAA style only.~~ **Done.** Alpha HP asserted from damage snapshot in Arrange. ## Verification diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs index 477b83e..25fd6d2 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs @@ -88,15 +88,14 @@ public sealed class CombatEntityHealthStoreTests { // Arrange var store = new InMemoryCombatEntityHealthStore(); - _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 40, out _); + _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 40, out var alphaAfterDamage); // Act var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetBetaId, out var beta); - store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var alpha); // Assert Assert.True(found); Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); Assert.False(beta.Defeated); - Assert.Equal(60, alpha.CurrentHp); + Assert.Equal(60, alphaAfterDamage.CurrentHp); } [Fact] @@ -135,6 +134,55 @@ public sealed class CombatEntityHealthStoreTests Assert.Equal(default, snapshot); } + [Fact] + public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsUnknown() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var applied = store.TryApplyDamage("not_a_prototype_target", 25, out var snapshot); + // Assert + Assert.False(applied); + Assert.Equal(default, snapshot); + } + + [Fact] + public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsEmpty() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var applied = store.TryApplyDamage(" ", 25, out var snapshot); + // Assert + Assert.False(applied); + Assert.Equal(default, snapshot); + } + + [Fact] + public void TryGet_ShouldNormalizeCaseVariantTargetIds() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var found = store.TryGet(" Prototype_Target_Alpha ", out var snapshot); + // Assert + Assert.True(found); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + } + + [Fact] + public void TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown() + { + // Arrange + var store = new InMemoryCombatEntityHealthStore(); + // Act + var reset = store.TryResetToFull("not_a_prototype_target", out var snapshot); + // Assert + Assert.False(reset); + Assert.Equal(default, snapshot); + } + [Fact] public async Task Host_ShouldResolveCombatEntityHealthStoreFromDi_WhenStartupSucceeds() {