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.
pull/114/head
VinPropane 2026-05-24 22:37:57 -04:00
parent e729996f47
commit b060b9dffd
9 changed files with 340 additions and 8 deletions

View File

@ -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<string, int>` 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.

View File

@ -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<ICombatEntityHealthStore>();
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
// Assert
Assert.True(found);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
Assert.False(snapshot.Defeated);
}
}

View File

@ -35,6 +35,8 @@ public static class AbilityCatalogServiceCollectionExtensions
services.AddSingleton<IAbilityDefinitionRegistry>(sp =>
new AbilityDefinitionRegistry(sp.GetRequiredService<AbilityDefinitionCatalog>()));
services.AddCombatEntityHealthStore();
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Registers prototype combat entity health store (NEO-80).</summary>
public static class CombatEntityHealthServiceCollectionExtensions
{
/// <summary>Registers <see cref="ICombatEntityHealthStore"/> as an in-memory singleton.</summary>
public static IServiceCollection AddCombatEntityHealthStore(this IServiceCollection services)
{
services.AddSingleton<ICombatEntityHealthStore, InMemoryCombatEntityHealthStore>();
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Authoritative HP snapshot for a prototype combat target (NEO-80).</summary>
/// <param name="TargetId">Normalized lowercase target id.</param>
/// <param name="MaxHp">Catalog max HP for prototype dummies.</param>
/// <param name="CurrentHp">Current HP after damage; floored at zero.</param>
/// <param name="Defeated"><c>true</c> when <paramref name="CurrentHp"/> is zero.</param>
public readonly record struct CombatEntityHealthSnapshot(
string TargetId,
int MaxHp,
int CurrentHp,
bool Defeated);

View File

@ -0,0 +1,30 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Server-owned HP store for prototype combat dummies keyed by
/// <see cref="Targeting.PrototypeTargetRegistry"/> target id (NEO-80).
/// </summary>
/// <remarks>
/// <para><b>NEO-81+:</b> combat resolution should depend on this interface rather than reaching into store internals.</para>
/// <para>Re-hit deny on defeated targets belongs in <c>CombatOperations</c> (NEO-81), not this store.</para>
/// </remarks>
public interface ICombatEntityHealthStore
{
/// <summary>
/// Reads the current snapshot for a known prototype target. Lazy-initializes HP to
/// <see cref="PrototypeCombatConstants.MaxPrototypeTargetHp"/> on first access.
/// </summary>
bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot);
/// <summary>
/// Applies non-negative damage to a known prototype target. Lazy-initializes on first access.
/// Floors <see cref="CombatEntityHealthSnapshot.CurrentHp"/> at zero.
/// </summary>
bool TryApplyDamage(string? targetId, int damage, out CombatEntityHealthSnapshot snapshot);
/// <summary>
/// Restores a known prototype target to full HP and clears <see cref="CombatEntityHealthSnapshot.Defeated"/>.
/// Creates the row when absent. Intended for tests and future dev fixtures — not HTTP in Slice 1.
/// </summary>
bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot);
}

View File

@ -0,0 +1,92 @@
using System.Collections.Concurrent;
using NeonSprawl.Server.Game.Targeting;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Thread-safe in-memory HP for prototype combat dummies; empty at startup — lazy rows only (NEO-80).</summary>
public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
{
private readonly ConcurrentDictionary<string, int> currentHpById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Frozen prototype combat tuning for E5.M1 Slice 1 (NEO-80).</summary>
public static class PrototypeCombatConstants
{
/// <summary>
/// Shared max HP for <see cref="Targeting.PrototypeTargetRegistry"/> combat dummies.
/// Four <c>prototype_pulse</c> casts at 25 damage each defeat one dummy (100 HP).
/// </summary>
public const int MaxPrototypeTargetHp = 100;
}

View File

@ -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/`.