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
parent
e729996f47
commit
b060b9dffd
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ public static class AbilityCatalogServiceCollectionExtensions
|
|||
services.AddSingleton<IAbilityDefinitionRegistry>(sp =>
|
||||
new AbilityDefinitionRegistry(sp.GetRequiredService<AbilityDefinitionCatalog>()));
|
||||
|
||||
services.AddCombatEntityHealthStore();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue