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

12 KiB
Raw Blame History

NEO-81 — Implementation plan

Story reference

Field Value
Key NEO-81
Title E5M1-06: CombatOperations.TryResolve + CombatResult
Linear https://linear.app/neon-sprawl/issue/NEO-81/e5m1-06-combatoperationstryresolve-combatresult
Module E5.M1 — CombatRulesEngine · Epic 5 Slice 1 · backlog E5M1-06
Branch NEO-81-combat-operations-try-resolve
Precursor NEO-80ICombatEntityHealthStore (Done on main); NEO-79IAbilityDefinitionRegistry (Done on main)
Pattern NEO-62 / NEO-69 — static *Operations.Try* + *Result envelope + *ReasonCodes; NEO-80 store handoff for defeated re-hit deny
Blocks NEO-82 — wire ability-cast into combat engine + extend cast response
Client counterpart None (server-only internal ops); wire CombatResolution on cast POST is NEO-82; HP HUD is NEO-85

Kickoff clarifications

Topic Question Agent recommendation Answer
Defeated-target deny scope Deny all abilities on defeated target, including zero-damage utility? Deny all — E5M1-07 uses target_defeated on cast at defeated dummy regardless of ability kind. User: deny all.
Unknown id validation Validate ability + target inside TryResolve vs assume E1.M4 pre-validated? Validate inside — backlog requires stable deny for unknown ids; mirrors GatherOperations / CraftOperations self-contained validation. User: validate inside.
Zero-damage success path TryGet only vs TryApplyDamage(target, 0)? Success with damageDealt = 0; TryGet snapshot only — no HP mutation when catalog damage is 0; still lazy-inits via TryGet. User: success, TryGet only.

Goal, scope, and out-of-scope

Goal: Provide pure server combat resolution: known ability + known prototype target → deterministic catalog damage, remaining HP, and defeated flag. Return structured denies (no silent no-op) when the target is already defeated or ids are unknown.

In scope (from Linear + E5M1-06):

  • CombatOperations.TryResolve using IAbilityDefinitionRegistry + ICombatEntityHealthStore.
  • CombatResult readonly record struct with DamageDealt, TargetRemainingHp, TargetDefeated, Success, ReasonCode.
  • CombatReasonCodes stable deny strings aligned with E1.M4 / E5M1-07 vocabulary.
  • Unit tests (AAA): happy path (prototype_pulse → 25 dmg), zero-damage utility success, defeated-target deny (including guard/dash), unknown ability/target denies.
  • server/README.md combat engine section.

Out of scope (from Linear + backlog):

  • HTTP wiring / AbilityCastResponse extension (NEO-82).
  • Per-ability cooldown commit (NEO-82).
  • Gig XP on defeat (NEO-44).
  • Telemetry hooks (reserved for follow-up like NEO-64 / NEO-71 pattern).
  • Godot / client changes.
  • Bruno (no HTTP surface in this story).

Acceptance criteria checklist

  • prototype_pulse against full-HP dummy deals catalog damage (25) deterministically.
  • Second resolve on defeated target returns structured deny (no silent no-op).
  • Zero-damage utility abilities succeed with damageDealt = 0 on live targets.
  • Unknown ability / unknown target return stable deny codes from CombatOperations.
  • Defeated-target deny applies to zero-damage utility abilities.

Technical approach

  1. CombatReasonCodes — static constants in Game/Combat/:

    • TargetDefeated = "target_defeated" — target HP is already 0 (checked before damage application; no store mutation).
    • UnknownAbility = "unknown_ability" — ability id fails IAbilityDefinitionRegistry.TryNormalizeKnown (matches cast/hotbar vocabulary).
    • UnknownTarget = "unknown_target" — target id fails ICombatEntityHealthStore.TryGet (store gates PrototypeTargetRegistry ids; matches E1.M3 targeting vocabulary).
  2. CombatResult — readonly record struct (mirror CraftResult):

    • Successtrue when damage resolution committed (including zero-damage success).
    • ReasonCodenull on success; stable string on deny.
    • DamageDealt — catalog AbilityDefRow.BaseDamage on success; 0 on deny.
    • TargetRemainingHp — authoritative HP after resolve on success; null on deny (or current HP on target_defeated deny — see step 4).
    • TargetDefeatedtrue when post-resolve HP is 0 on success; false on deny (even when target was already defeated — caller uses ReasonCode).
  3. CombatOperations — static class (mirror GatherOperations):

    public static CombatResult TryResolve(
        string abilityId,
        string targetId,
        IAbilityDefinitionRegistry abilityRegistry,
        ICombatEntityHealthStore healthStore)
    
    • Ability gate: abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility) → deny unknown_ability when false.
    • Definition load: abilityRegistry.TryGetDefinition(normalizedAbility, out var definition) — should succeed after normalize; treat miss as unknown_ability (defensive).
    • Target read: healthStore.TryGet(targetId, out var snapshot) → deny unknown_target when false (lazy-inits live targets).
    • Defeated pre-check: when snapshot.Defeated → deny target_defeated without calling TryApplyDamage (NEO-80 handoff; applies to all abilities including zero-damage).
    • Zero-damage path: when definition.BaseDamage == 0 → success with DamageDealt = 0, TargetRemainingHp = snapshot.CurrentHp, TargetDefeated = snapshot.Defeated (always false for live target after pre-check).
    • Damage path: healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after) → success with DamageDealt = definition.BaseDamage, TargetRemainingHp = after.CurrentHp, TargetDefeated = after.Defeated.
    • Private Deny(string reasonCode) helper returns Success = false, empty damage fields.
  4. target_defeated deny payload: On defeated pre-check, set TargetRemainingHp = 0 (from snapshot) so callers/tests can observe current HP without re-querying store; DamageDealt = 0, TargetDefeated = false on envelope (deny is ReasonCode-driven — same pattern as gather depletion deny carrying RemainingGathers).

  5. No DI registration — static ops class; tests construct InMemoryCombatEntityHealthStore + in-memory registry helper (same as AbilityDefinitionRegistryTests). Host already registers both dependencies via AddAbilityDefinitionCatalog.

  6. server/README.md — new Combat engine (NEO-81) section after combat entity health: document CombatOperations.TryResolve, CombatResult fields, reason-code table, and cross-link NEO-82 cast wiring.

Expected prototype resolve values

Ability Target Initial HP Result
prototype_pulse prototype_target_alpha 100 (lazy) Success: damageDealt 25, targetRemainingHp 75, targetDefeated false
prototype_pulse × 4 same 4th success: targetRemainingHp 0, targetDefeated true
prototype_pulse same (defeated) 0 Deny: target_defeated, damageDealt 0
prototype_guard live alpha 100 Success: damageDealt 0, HP unchanged
prototype_guard defeated alpha 0 Deny: target_defeated
unknown_ability alpha Deny: unknown_ability
prototype_pulse not_a_target Deny: unknown_target

Files to add

Path Purpose
server/NeonSprawl.Server/Game/Combat/CombatReasonCodes.cs Stable deny reason strings for combat ops.
server/NeonSprawl.Server/Game/Combat/CombatResult.cs Server-internal combat resolution envelope.
server/NeonSprawl.Server/Game/Combat/CombatOperations.cs Static TryResolve orchestrating registry + health store.
server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs AAA unit tests for resolve happy/deny paths.
docs/plans/NEO-81-implementation-plan.md This plan.

Files to modify

Path Rationale
server/README.md Document CombatOperations.TryResolve, CombatResult, reason codes, and NEO-82 handoff.

Tests

File Coverage
server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs Happy path: prototype_pulse on fresh alpha → 25 dmg, 75 HP. Deterministic chain: four pulses → defeated on 4th. Defeated deny: 5th pulse → target_defeated, no HP change. Zero-damage: prototype_guard on live target → success, 0 dmg, HP unchanged. Defeated + utility: prototype_guard on defeated → target_defeated. Unknown ability: deny unknown_ability. Unknown target: deny unknown_target. Burst: prototype_burst → 40 dmg once. Use in-memory registry helper + InMemoryCombatEntityHealthStore; optional host test resolving registries from InMemoryWebApplicationFactory. AAA per csharp-style.

No Bruno or manual QA for this story (server-only internal ops; no HTTP or client-facing change).

Open questions / risks

Question / risk Agent recommendation Status
Wire naming: CombatResult vs CombatResolution CombatResult server-internal; NEO-82 promotes to wire CombatResolution / cast response block (CraftResult → CraftResponse precedent). adopted
Defeated deny on zero-damage Deny all abilities on defeated target (user kickoff). adopted
Telemetry hook site Defer comment-only hooks to NEO-82 or dedicated telemetry story when cast path wires in. deferred
Player-as-target / PvP Out of scope — prototype registry has NPC stubs only; PvP gates are E6.M1. adopted

Decisions (kickoff)

  • Defeated pre-check before damage; no TryApplyDamage on defeated targets.
  • Zero-damage abilities succeed via TryGet only (no store mutation).
  • Unknown ability/target validated inside TryResolve with stable reason codes.
  • No HTTP in NEO-81; cast wiring is NEO-82.

Reconciliation (implementation)

  • CombatReasonCodes, CombatResult, CombatOperations.TryResolve added under Game/Combat/.
  • Defeated pre-check denies before damage; zero-damage path uses TryGet only.
  • 9 AAA tests in CombatOperationsTests (all green), including host DI resolve.
  • server/README.md — combat engine section with reason-code table and NEO-82 handoff.