135 lines
11 KiB
Markdown
135 lines
11 KiB
Markdown
# 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
|
||
|
||
- [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
|
||
|
||
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. 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).
|
||
- **`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/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
|
||
|
||
| 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).
|
||
|
||
## 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.
|