neon-sprawl/docs/plans/NEO-82-implementation-plan.md

145 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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
- [x] Accept path returns non-empty **`combatResolution`** payload with deterministic catalog damage.
- [x] Cooldown duration matches ability catalog (**`cooldownSeconds`**), not **`AbilityPrototypeCooldown.GlobalDuration`**.
- [x] 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).
## Reconciliation (implementation)
- **`CombatResolutionJson`** + extended **`AbilityCastResponse`** in **`AbilityCastDtos.cs`**.
- **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`**; per-ability catalog cooldown on success; **`target_defeated`** deny without cooldown.
- **4** new AAA tests in **`AbilityCastApiTests`** (resolution, zero-damage, defeat chain, catalog cooldown); existing tests updated for resolution payload.
- Bruno happy + defeat spine; **`server/README.md`**, E5M1 backlog, E5_M1 module doc updated.