From 4126ac9bcc1097f104264b8782d5aa3e2a34ca9a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 10:32:33 -0400 Subject: [PATCH] NEO-82: Add implementation plan for cast combat wiring. Kickoff clarifications: nested combatResolution on accept, per-ability cooldown on successful resolve only, no manual QA doc (server-only). --- docs/plans/NEO-82-implementation-plan.md | 137 +++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/plans/NEO-82-implementation-plan.md diff --git a/docs/plans/NEO-82-implementation-plan.md b/docs/plans/NEO-82-implementation-plan.md new file mode 100644 index 0000000..1f7f040 --- /dev/null +++ b/docs/plans/NEO-82-implementation-plan.md @@ -0,0 +1,137 @@ +# NEO-82 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-82 | +| **Title** | E5M1-07: Wire ability-cast into combat engine + cast response | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-82/e5m1-07-wire-ability-cast-into-combat-engine-cast-response | +| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-07** | +| **Branch** | `NEO-82-wire-ability-cast-combat-resolution` | +| **Precursor** | [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) — `CombatOperations.TryResolve` + `CombatResult` (**Done** on `main`); E1.M4 cast funnel (NEO-28/31/32) | +| **Pattern** | [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — promote engine envelope to wire JSON; extend existing POST rather than new route | +| **Blocks** | [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat, [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client HUD, E5M1-09 telemetry | +| **Client counterpart** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) — Godot parses extended cast response + HP HUD (**out of scope** here; no client-facing change in this story) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Resolution wire shape** | Nested block vs flat fields on `AbilityCastResponse`? | **Nested `combatResolution` object** on accept only — E5M1 backlog “optional resolution block”; keeps E1.M4 `accepted`/`reasonCode` envelope stable. | **User:** nested `combatResolution`. | +| **Cooldown on `target_defeated`** | Commit catalog cooldown when combat denies re-hit on defeated dummy? | **No** — backlog: per-ability cooldown on successful **resolve** only. | **User:** no cooldown on defeated deny. | +| **Manual QA doc** | Add `docs/manual-qa/NEO-82.md`? | **Skip** — server-only; no Godot change; Bruno + README satisfy AC (NEO-70 pattern). | **User:** skip (no client-facing change). | + +## Goal, scope, and out-of-scope + +**Goal:** After E1.M4 cast gates pass, invoke **`CombatOperations.TryResolve`**, return wire **`combatResolution`** on accept, map combat denies (especially **`target_defeated`**) to JSON cast denies, and replace the global prototype cooldown with per-ability **`cooldownSeconds`** from the ability catalog on successful resolve. + +**In scope (from Linear + [E5M1-07](E5M1-prototype-backlog.md#e5m1-07--wire-ability-cast-into-combat-engine--extend-cast-response)):** + +- Wire **`AbilityCastApi`** to **`CombatOperations.TryResolve`** + **`ICombatEntityHealthStore`** after existing NEO-28/32 gates. +- Extend **`AbilityCastResponse` v1** with optional nested **`combatResolution`** on accept. +- Per-ability cooldown from **`AbilityDefRow.CooldownSeconds`** on successful combat resolve only. +- JSON deny **`target_defeated`** when firing at a defeated dummy (combat engine handoff from NEO-81). +- Integration tests (AAA) + Bruno: lock target → cast → damage payload; repeated casts until defeat → deny. +- **`server/README.md`** cast + cooldown sections updated; cross-link E1.M4 / E5.M1 deny vocabulary. + +**Out of scope (from Linear + backlog):** + +- Godot presentation — **[NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)**. +- Gig XP on defeat — **[NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)**. +- Telemetry hook emit — **E5M1-09** (comment-only hooks may get placement notes; no E9.M1 ingest). +- **`GET /game/world/combat-targets`** — **E5M1-08**. +- Rich **`ThreatState`**, player damage, PvP eligibility. +- **`docs/manual-qa/NEO-82.md`** (kickoff decision). + +## Acceptance criteria checklist + +- [ ] Accept path returns non-empty **`combatResolution`** payload with deterministic catalog damage. +- [ ] Cooldown duration matches ability catalog (**`cooldownSeconds`**), not **`AbilityPrototypeCooldown.GlobalDuration`**. +- [ ] Bruno documents lock → cast → damage → defeat deny spine. + +## Technical approach + +1. **Handler flow (`AbilityCastApi`)** — after existing loadout / target / range gates and **`on_cooldown`** pre-check: + - Inject **`ICombatEntityHealthStore`** alongside **`IAbilityDefinitionRegistry`**. + - Call **`CombatOperations.TryResolve(normalizedRequest, lookupKey, abilities, healthStore)`**. + - **Combat deny** (`!result.Success`): return **`accepted: false`**, **`reasonCode`** = **`result.ReasonCode`** (primarily **`target_defeated`**; defensive **`unknown_target`** / **`unknown_ability`** should not occur post-gates). **Do not** call **`StartCooldown`** (kickoff decision). + - **Combat success**: load definition via **`TryGetDefinition`**, **`StartCooldown(id, slotIndex, now, TimeSpan.FromSeconds(definition.CooldownSeconds))`**, return **`accepted: true`** with **`combatResolution`** mapped from **`CombatResult`**. + +2. **Wire DTOs (`AbilityCastDtos.cs`)** — add **`CombatResolutionJson`**: + - **`abilityId`** — normalized ability id (same as bound/request). + - **`targetId`** — normalized target id (lowercase registry key). + - **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`** — promoted from **`CombatResult`** (non-null HP on success). + - On **`AbilityCastResponse`**: optional **`combatResolution`** — **`JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)`**; present only when **`accepted: true`**. + +3. **Mapping helper** — internal **`MapCombatResolution`** / **`MapAcceptResponse`** (mirror **`PlayerCraftApi.MapResponse`**) to keep route handler thin. + +4. **Cooldown migration** — remove **`AbilityPrototypeCooldown.GlobalDuration`** usage from accept path. Keep class with updated remark (“superseded by catalog per E5M1-07”) or delete constant if unused after test updates. Snapshot GET unchanged (still reads store ends); duration source is now catalog on commit. + +5. **Deny vocabulary alignment** — add **`target_defeated`** to README cast deny table; cross-link **`CombatReasonCodes`**. E1 gate denies (**`invalid_target`**, etc.) unchanged and evaluated **before** combat. + +6. **Bruno (`bruno/neon-sprawl-server/ability-cast/`)**: + - Update **`Post cast happy.bru`** — assert **`combatResolution.damageDealt === 25`**, **`targetRemainingHp === 75`**, **`targetDefeated === false`** for first pulse on alpha. + - Add **`Post cast lock pulse defeat spine.bru`** — pre-request: move + target select alpha; seq of **4** pulse casts asserting HP stepping (75 → 50 → 25 → 0, **`targetDefeated`** true on 4th); **5th** cast → **`accepted: false`**, **`reasonCode: target_defeated`**. + - Update **`folder.bru`** docs with run order / NEO-82 spine note. + +7. **`server/README.md`** — rewrite Ability cast section: combat resolution block field table, **`target_defeated`** deny, per-ability cooldown from catalog; update Cooldown snapshot section to reference catalog durations. + +8. **Module / backlog docs** — during implementation reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-07 checkboxes and [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md) prototype handoff paragraph (cast now returns real resolution). + +### Expected cast outcomes (prototype_pulse → alpha, fresh HP) + +| Cast # | `accepted` | `reasonCode` | `combatResolution.damageDealt` | `targetRemainingHp` | `targetDefeated` | Cooldown committed | +|--------|------------|--------------|-------------------------------|---------------------|------------------|-------------------| +| 1 | true | null | 25 | 75 | false | 3.0s (pulse catalog) | +| 2–3 | true | null | 25 | 50 / 25 | false | yes | +| 4 | true | null | 25 | 0 | true | yes | +| 5 | false | `target_defeated` | (absent) | — | — | **no** | + +## Files to add + +| Path | Purpose | +|------|---------| +| `bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru` | Bruno lock → cast → damage → defeat → deny spine. | +| `docs/plans/NEO-82-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Invoke **`CombatOperations.TryResolve`**, map combat deny/success, per-ability cooldown commit. | +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs` | Add **`CombatResolutionJson`**; extend **`AbilityCastResponse`**. | +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs` | Mark global duration superseded; stop using on accept path. | +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Assert resolution payload, defeat deny, per-ability cooldown (e.g. guard 6s vs pulse 3s). | +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs` | Advance fake clock by catalog duration instead of global constant where applicable. | +| `bruno/neon-sprawl-server/ability-cast/Post cast happy.bru` | Assert **`combatResolution`** fields on accept. | +| `bruno/neon-sprawl-server/ability-cast/folder.bru` | Document NEO-82 spine run order. | +| `server/README.md` | Cast response **`combatResolution`** table, **`target_defeated`**, catalog cooldown policy. | +| `docs/plans/E5M1-prototype-backlog.md` | Mark E5M1-07 landed when complete. | +| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Update cast handoff — resolution now on wire. | + +## Tests + +| File | Coverage | +|------|----------| +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | **Happy path:** accept + **`combatResolution`** (25 dmg, 75 HP). **Defeat chain:** four pulses → 4th **`targetDefeated` true**; 5th → **`target_defeated` deny**, no extra HP mutation. **Zero-damage:** bind **`prototype_guard`**, accept with **`damageDealt` 0**, HP unchanged. **Per-ability cooldown:** guard 6s — still **`on_cooldown`** at 3.5s after first accept; pulse 3s — accept again after 3.5s. **Existing denies** unchanged (invalid_target, on_cooldown, etc.). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs` | Snapshot end time reflects catalog cooldown after successful combat resolve (update clock advance constant). | + +Bruno spine covers manual verification; no **`docs/manual-qa/NEO-82.md`** (server-only, no Godot). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|---------------------|--------| +| **Nested vs flat resolution fields** | **Nested `combatResolution`** (user kickoff). | **adopted** | +| **Cooldown on defeated deny** | **No commit** (user kickoff). | **adopted** | +| **Combat `unknown_target` after E1 gates** | **Map to deny as-is** if ever returned — defensive; normal path uses **`invalid_target`** at gate layer. | **adopted** | +| **Telemetry hook placement** | **Defer** catalog emit to E5M1-09; optional **`TODO(E9.M1)`** near combat success path only. | **deferred** | +| **Gig XP on 4th pulse defeat** | **Out of scope** — NEO-44 hooks defeat transition separately. | **adopted** | + +## Decisions (kickoff) + +- **`combatResolution`** nested object on accept only; omitted on all denies. +- Per-ability **`cooldownSeconds`** commit only on successful **`CombatOperations`** resolve. +- **`target_defeated`** JSON deny on defeated re-hit; no cooldown. +- No manual QA doc; Bruno + README sufficient (no client-facing change — NEO-85 owns HUD).