11 KiB
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 · Epic 5 Slice 1 · backlog E5M1-05 |
| Branch | NEO-80-combat-entity-health-store |
| Precursor | NEO-79 — IAbilityDefinitionRegistry + DI (Done on main) |
| Pattern | NEO-61 — lazy in-memory instance store + per-id locking; NEO-32 — dedicated Add*Store() DI extension |
| Blocks | NEO-81 — CombatOperations.TryResolve + CombatResult |
| Client counterpart | None (server-only internal store); HP visibility lands in NEO-83 GET combat-targets + 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):
ICombatEntityHealthStorewithTryGet,TryApplyDamage,TryResetToFullfor prototype registry targets.InMemoryCombatEntityHealthStore— thread-safe, per-id locking (NEO-61 pattern).CombatEntityHealthSnapshotreadonly struct (targetId,maxHp,currentHp,defeated).PrototypeCombatConstants.MaxPrototypeTargetHp = 100.- DI via
AddCombatEntityHealthStore()inProgram.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;TryResetToFullfor tests / future fixtures).
Out of scope (from Linear + backlog):
- HTTP routes (NEO-83 combat-targets GET).
CombatOperations.TryResolve(NEO-81).- Cast wiring /
AbilityCastResponseresolution (NEO-82). - Player HP, Postgres persistence, NPC spawn (E5.M2).
- Godot / client changes.
- Bruno (no HTTP surface in this story).
Acceptance criteria checklist
- Damaging
prototype_target_alphareduces stored HP; HP cannot go below zero. defeatedis true whencurrentHp == 0and remains true on further damage until reset.prototype_target_betainitializes independently with the same max HP on first access.- Unknown target id returns
falsefromTryGet/TryApplyDamagewithout throwing. - In-memory prototype reset policy documented (server restart;
TryResetToFullfor tests). - DI resolves
ICombatEntityHealthStoreat host startup (test viaInMemoryWebApplicationFactory).
Technical approach
-
PrototypeCombatConstants—public const int MaxPrototypeTargetHp = 100;inGame/Combat/. XML remarks: fourprototype_pulsecasts (25 dmg) defeat one dummy; aligns with E5.M1 freeze box. -
CombatEntityHealthSnapshot— readonly record struct:TargetId,MaxHp,CurrentHp,Defeated(CurrentHp <= 0). Used by store read and mutation methods. -
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;damagemust be ≥ 0 (negative → false); subtract damage, floorcurrentHpat 0; setdefeatedwhencurrentHp == 0(idempotent on overkill).TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot)— registry id only; setcurrentHp = maxHp,defeated = false; creates row if absent.
-
InMemoryCombatEntityHealthStore—ConcurrentDictionary<string, int>for current HP + per-id locks (mirrorInMemoryResourceNodeInstanceStore). PrivateEnsurePrototypeTarget(string normalizedId)validatesPrototypeTargetRegistry.TryGetand lazy-inits HP toMaxPrototypeTargetHp. -
DI —
CombatEntityHealthServiceCollectionExtensions.AddCombatEntityHealthStore()registersICombatEntityHealthStore→InMemoryCombatEntityHealthStoresingleton. Chained fromAddAbilityDefinitionCatalogafter ability registry registration. No Postgres in Slice 1 (backlog in-memory policy). -
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.
-
NEO-81 handoff: Store does not deny damage to already-defeated targets — HP stays 0,
defeatedstays true. Structuredtarget_defeateddeny belongs inCombatOperations.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. |
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
PrototypeTargetRegistryids only. TryApplyDamagereturnsbool+out CombatEntityHealthSnapshot(not mutation outcome struct).- No HTTP in NEO-80; combat-target snapshot is NEO-83.
Reconciliation (implementation)
ICombatEntityHealthStore+InMemoryCombatEntityHealthStoreregistered viaAddCombatEntityHealthStore()chained fromAddAbilityDefinitionCatalog.PrototypeCombatConstants.MaxPrototypeTargetHp = 100; lazy init gated toPrototypeTargetRegistryids.- 14 AAA tests in
CombatEntityHealthStoreTests(all green); code review follow-up added deny-path and normalization coverage. server/README.md— combat entity health section with init/damage/reset policy.