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).
pull/116/head
VinPropane 2026-05-25 10:32:33 -04:00
parent b8d87bbb54
commit 4126ac9bcc
1 changed files with 137 additions and 0 deletions

View File

@ -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) |
| 23 | 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).