From 849c34f4602d04ebac8d7167b694d4f6d0258de8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 10:14:53 -0400 Subject: [PATCH] NEO-81: Add implementation plan for CombatOperations.TryResolve. --- docs/plans/NEO-81-implementation-plan.md | 140 +++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/plans/NEO-81-implementation-plan.md diff --git a/docs/plans/NEO-81-implementation-plan.md b/docs/plans/NEO-81-implementation-plan.md new file mode 100644 index 0000000..619a754 --- /dev/null +++ b/docs/plans/NEO-81-implementation-plan.md @@ -0,0 +1,140 @@ +# 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 + +- [ ] **`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`](../../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).