# 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](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-06** | | **Branch** | `NEO-81-combat-operations-try-resolve` | | **Precursor** | [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) — `ICombatEntityHealthStore` (**Done** on `main`); [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` (**Done** on `main`) | | **Pattern** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) / [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) — static `*Operations.Try*` + `*Result` envelope + `*ReasonCodes`; NEO-80 store handoff for defeated re-hit deny | | **Blocks** | [NEO-82](https://linear.app/neon-sprawl/issue/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](https://linear.app/neon-sprawl/issue/NEO-82); HP HUD is [NEO-85](https://linear.app/neon-sprawl/issue/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](E5M1-prototype-backlog.md#e5m1-06--combatoperationstryresolve--combatresult)):** - `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](https://linear.app/neon-sprawl/issue/NEO-82)). - Per-ability cooldown commit ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)). - Gig XP on defeat ([NEO-44](https://linear.app/neon-sprawl/issue/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 - [x] **`prototype_pulse`** against full-HP dummy deals catalog damage (25) deterministically. - [x] Second resolve on defeated target returns structured deny (no silent no-op). - [x] Zero-damage utility abilities succeed with **`damageDealt = 0`** on live targets. - [x] Unknown ability / unknown target return stable deny codes from **`CombatOperations`**. - [x] 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`](../../server/NeonSprawl.Server/Game/Crafting/CraftResult.cs)): - **`Success`** — `true` when damage resolution committed (including zero-damage success). - **`ReasonCode`** — `null` 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). - **`TargetDefeated`** — `true` 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`](../../server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs)): ```csharp 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](../../.cursor/rules/csharp-style.md). | 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](https://linear.app/neon-sprawl/issue/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.