neon-sprawl/docs/plans/NEO-80-implementation-plan.md

11 KiB
Raw Blame History

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-79IAbilityDefinitionRegistry + DI (Done on main)
Pattern NEO-61 — lazy in-memory instance store + per-id locking; NEO-32 — dedicated Add*Store() DI extension
Blocks NEO-81CombatOperations.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):

  • 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 combat-targets GET).
  • CombatOperations.TryResolve (NEO-81).
  • Cast wiring / AbilityCastResponse resolution (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_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. PrototypeCombatConstantspublic const int MaxPrototypeTargetHp = 100; in Game/Combat/. XML remarks: four prototype_pulse casts (25 dmg) defeat one dummy; aligns with E5.M1 freeze box.

  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. InMemoryCombatEntityHealthStoreConcurrentDictionary<string, int> for current HP + per-id locks (mirror InMemoryResourceNodeInstanceStore). Private EnsurePrototypeTarget(string normalizedId) validates PrototypeTargetRegistry.TryGet and lazy-inits HP to MaxPrototypeTargetHp.

  5. DICombatEntityHealthServiceCollectionExtensions.AddCombatEntityHealthStore() registers ICombatEntityHealthStoreInMemoryCombatEntityHealthStore 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.

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.

Reconciliation (implementation)

  • ICombatEntityHealthStore + InMemoryCombatEntityHealthStore registered via AddCombatEntityHealthStore() chained from AddAbilityDefinitionCatalog.
  • PrototypeCombatConstants.MaxPrototypeTargetHp = 100; lazy init gated to PrototypeTargetRegistry ids.
  • 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.