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