# 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.
