From b060b9dffdbc69236d56e71e216f0c83b6e11599 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 22:37:57 -0400 Subject: [PATCH] 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/`.