NEO-80: add implementation plan for combat entity health store.

pull/114/head
VinPropane 2026-05-24 22:35:37 -04:00
parent 61bed05298
commit e729996f47
1 changed files with 127 additions and 0 deletions

View File

@ -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<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).
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).