12 KiB
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 · Epic 5 Slice 1 · backlog E5M1-07 |
| Branch | NEO-82-wire-ability-cast-combat-resolution |
| Precursor | NEO-81 — CombatOperations.TryResolve + CombatResult (Done on main); E1.M4 cast funnel (NEO-28/31/32) |
| Pattern | NEO-70 — promote engine envelope to wire JSON; extend existing POST rather than new route |
| Blocks | NEO-44 gig XP on defeat, NEO-85 client HUD, E5M1-09 telemetry |
| Client counterpart | 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):
- Wire
AbilityCastApitoCombatOperations.TryResolve+ICombatEntityHealthStoreafter existing NEO-28/32 gates. - Extend
AbilityCastResponsev1 with optional nestedcombatResolutionon accept. - Per-ability cooldown from
AbilityDefRow.CooldownSecondson successful combat resolve only. - JSON deny
target_defeatedwhen 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.mdcast + cooldown sections updated; cross-link E1.M4 / E5.M1 deny vocabulary.
Out of scope (from Linear + backlog):
- Godot presentation — NEO-85.
- Gig XP on defeat — 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
combatResolutionpayload with deterministic catalog damage. - Cooldown duration matches ability catalog (
cooldownSeconds), notAbilityPrototypeCooldown.GlobalDuration. - Bruno documents lock → cast → damage → defeat deny spine.
Technical approach
-
Handler flow (
AbilityCastApi) — after existing loadout / target / range gates andon_cooldownpre-check:- Inject
ICombatEntityHealthStorealongsideIAbilityDefinitionRegistry. - Call
CombatOperations.TryResolve(normalizedRequest, lookupKey, abilities, healthStore). - Combat deny (
!result.Success): returnaccepted: false,reasonCode=result.ReasonCode(primarilytarget_defeated; defensiveunknown_target/unknown_abilityshould not occur post-gates). Do not callStartCooldown(kickoff decision). - Combat success: load definition via
TryGetDefinition,StartCooldown(id, slotIndex, now, TimeSpan.FromSeconds(definition.CooldownSeconds)), returnaccepted: truewithcombatResolutionmapped fromCombatResult.
- Inject
-
Wire DTOs (
AbilityCastDtos.cs) — addCombatResolutionJson:abilityId— normalized ability id (same as bound/request).targetId— normalized target id (lowercase registry key).damageDealt,targetRemainingHp,targetDefeated— promoted fromCombatResult(non-null HP on success).- On
AbilityCastResponse: optionalcombatResolution—JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull); present only whenaccepted: true.
-
Mapping helper — internal
MapCombatResolution/MapAcceptResponse(mirrorPlayerCraftApi.MapResponse) to keep route handler thin. -
Cooldown migration — remove
AbilityPrototypeCooldown.GlobalDurationusage 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. -
Deny vocabulary alignment — add
target_defeatedto README cast deny table; cross-linkCombatReasonCodes. E1 gate denies (invalid_target, etc.) unchanged and evaluated before combat. -
Bruno (
bruno/neon-sprawl-server/ability-cast/):- Update
Post cast happy.bru— assertcombatResolution.damageDealt === 25,targetRemainingHp === 75,targetDefeated === falsefor 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,targetDefeatedtrue on 4th); 5th cast →accepted: false,reasonCode: target_defeated. - Update
folder.brudocs with run order / NEO-82 spine note.
- Update
-
server/README.md— rewrite Ability cast section: combat resolution block field table,target_defeateddeny, per-ability cooldown from catalog; update Cooldown snapshot section to reference catalog durations. -
Module / backlog docs — during implementation reconcile E5M1-prototype-backlog.md E5M1-07 checkboxes and 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. |
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)
combatResolutionnested object on accept only; omitted on all denies.- Per-ability
cooldownSecondscommit only on successfulCombatOperationsresolve. target_defeatedJSON 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+ extendedAbilityCastResponseinAbilityCastDtos.cs.AbilityCastApiinvokesCombatOperations.TryResolve; per-ability catalog cooldown on success;target_defeateddeny 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.