From 4126ac9bcc1097f104264b8782d5aa3e2a34ca9a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 10:32:33 -0400 Subject: [PATCH 1/7] 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). From e8be100f8020b8900939b00eb95493fca0c18ec4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 10:36:42 -0400 Subject: [PATCH 2/7] NEO-82: Wire cast POST into combat engine and cast response. Invoke CombatOperations.TryResolve after E1 gates; nested combatResolution on accept; per-ability catalog cooldown; target_defeated deny without cooldown. Tests, Bruno defeat spine, README and module docs updated. --- .../ability-cast/Post cast happy.bru | 8 +- .../Post cast lock pulse defeat spine.bru | 96 +++++++++++++ .../ability-cast/folder.bru | 4 + .../modules/E5_M1_CombatRulesEngine.md | 2 +- docs/plans/E5M1-prototype-backlog.md | 8 +- docs/plans/NEO-82-implementation-plan.md | 13 +- .../Game/AbilityInput/AbilityCastApiTests.cs | 136 +++++++++++++++++- .../AbilityInput/CooldownSnapshotApiTests.cs | 2 +- .../Game/AbilityInput/AbilityCastApi.cs | 64 ++++++++- .../Game/AbilityInput/AbilityCastDtos.cs | 24 ++++ .../AbilityInput/AbilityPrototypeCooldown.cs | 5 +- server/README.md | 23 ++- 12 files changed, 364 insertions(+), 21 deletions(-) create mode 100644 bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index 0d3194c..af6f307 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -40,9 +40,15 @@ tests { expect(res.getStatus()).to.equal(200); }); - test("accepted true", function () { + test("accepted true with combat resolution", function () { const body = res.getBody(); expect(body.schemaVersion).to.equal(1); expect(body.accepted).to.equal(true); + expect(body.combatResolution).to.be.an("object"); + expect(body.combatResolution.abilityId).to.equal("prototype_pulse"); + expect(body.combatResolution.targetId).to.equal("prototype_target_alpha"); + expect(body.combatResolution.damageDealt).to.equal(25); + expect(body.combatResolution.targetRemainingHp).to.equal(75); + expect(body.combatResolution.targetDefeated).to.equal(false); }); } diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru new file mode 100644 index 0000000..c92ca1f --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -0,0 +1,96 @@ +meta { + name: POST cast lock pulse defeat spine (NEO-82) + type: http + seq: 23 +} + +docs { + NEO-82 spine: pre-request moves, locks alpha, casts prototype_pulse four times (3s catalog cooldown between), then this request is the fifth cast expecting target_defeated deny. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId: "prototype_target_alpha" }, + jsonHeaders, + ); + + const castBody = { + schemaVersion: 1, + slotIndex: 0, + abilityId: "prototype_pulse", + targetId: "prototype_target_alpha", + }; + + for (let i = 0; i < 4; i++) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + if (i < 3) { + await sleep(3100); + } + if (i === 3) { + bru.setVar("neo82FourthCast", JSON.stringify(response.data)); + } + } + + await sleep(3100); +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/ability-cast + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "slotIndex": 0, + "abilityId": "prototype_pulse", + "targetId": "prototype_target_alpha" + } +} + +tests { + test("fourth cast defeated target", function () { + const fourth = JSON.parse(bru.getVar("neo82FourthCast")); + expect(fourth.accepted).to.equal(true); + expect(fourth.combatResolution.targetRemainingHp).to.equal(0); + expect(fourth.combatResolution.targetDefeated).to.equal(true); + }); + + test("fifth cast denies target_defeated", function () { + const body = res.getBody(); + expect(res.getStatus()).to.equal(200); + expect(body.accepted).to.equal(false); + expect(body.reasonCode).to.equal("target_defeated"); + expect(body.combatResolution).to.equal(undefined); + }); +} diff --git a/bruno/neon-sprawl-server/ability-cast/folder.bru b/bruno/neon-sprawl-server/ability-cast/folder.bru index 59b3b47..f596329 100644 --- a/bruno/neon-sprawl-server/ability-cast/folder.bru +++ b/bruno/neon-sprawl-server/ability-cast/folder.bru @@ -1,3 +1,7 @@ meta { name: ability-cast } + +docs { + NEO-82: happy cast asserts combatResolution; defeat spine (seq 23) requires ~10s pre-request for pulse cooldowns between four lethal casts. +} diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index b8b7637..cb957e4 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -90,7 +90,7 @@ The **first shipped four-ability combat spine** under `content/abilities/*.json` ## Prototype notes (NEO-28) -Epic 1 **Slice 3** exercises a minimal cast accept/deny surface on **`POST /game/players/{id}/ability-cast`** before the full engine exists: after hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), the server requires **`targetId`** to match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`** for vocabulary alignment. **`accepted: true`** still does not imply damage, cooldown commit, or a full **`CombatResolution`** payload — those remain E5.M1 scope. +Epic 1 **Slice 3** cast accept/deny on **`POST /game/players/{id}/ability-cast`** remains the E1.M4 gate funnel: hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), **`targetId`** must match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`**. **NEO-82 (E5M1-07):** after gates pass, **`CombatOperations.TryResolve`** runs; accept returns nested wire **`combatResolution`** (`damageDealt`, `targetRemainingHp`, `targetDefeated`, …) and commits per-ability catalog cooldown; **`target_defeated`** JSON deny on re-hit at zero HP. ## Risks and telemetry diff --git a/docs/plans/E5M1-prototype-backlog.md b/docs/plans/E5M1-prototype-backlog.md index 352514a..0b305b1 100644 --- a/docs/plans/E5M1-prototype-backlog.md +++ b/docs/plans/E5M1-prototype-backlog.md @@ -236,9 +236,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/ **Acceptance criteria** -- [ ] Accept path returns non-empty resolution payload with deterministic damage. -- [ ] Cooldown duration matches ability catalog, not global constant. -- [ ] Bruno documents lock → cast → damage → defeat deny spine. +- [x] Accept path returns non-empty resolution payload with deterministic damage. +- [x] Cooldown duration matches ability catalog, not global constant. +- [x] Bruno documents lock → cast → damage → defeat deny spine. + +**Landed ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)):** cast POST invokes **`CombatOperations.TryResolve`**; nested **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** deny ([NEO-82-implementation-plan.md](NEO-82-implementation-plan.md)); Bruno defeat spine. **Client counterpart:** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) diff --git a/docs/plans/NEO-82-implementation-plan.md b/docs/plans/NEO-82-implementation-plan.md index 1f7f040..1667124 100644 --- a/docs/plans/NEO-82-implementation-plan.md +++ b/docs/plans/NEO-82-implementation-plan.md @@ -46,9 +46,9 @@ ## 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. +- [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 @@ -135,3 +135,10 @@ Bruno spine covers manual verification; no **`docs/manual-qa/NEO-82.md`** (serve - 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. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 9787621..771ba00 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using System.Text; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using Xunit; @@ -44,6 +45,27 @@ public sealed class AbilityCastApiTests Assert.True(body!.SelectionApplied); } + private static async Task BindSlot0GuardAsync(HttpClient client) + { + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeGuard }], + }); + post.EnsureSuccessStatusCode(); + } + + private static AbilityCastRequest PulseCastRequest() => + new() + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z) { var response = await client.PostAsJsonAsync( @@ -82,6 +104,12 @@ public sealed class AbilityCastApiTests Assert.NotNull(body); Assert.True(body!.Accepted); Assert.Null(body.ReasonCode); + Assert.NotNull(body.CombatResolution); + Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, body.CombatResolution!.AbilityId); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.CombatResolution.TargetId); + Assert.Equal(25, body.CombatResolution.DamageDealt); + Assert.Equal(75, body.CombatResolution.TargetRemainingHp); + Assert.False(body.CombatResolution.TargetDefeated); } [Fact] @@ -467,7 +495,7 @@ public sealed class AbilityCastApiTests // Act var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); - factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50)); + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert @@ -479,5 +507,111 @@ public sealed class AbilityCastApiTests Assert.NotNull(body2); Assert.True(body1!.Accepted); Assert.True(body2!.Accepted); + Assert.NotNull(body2.CombatResolution); + Assert.Equal(50, body2.CombatResolution!.TargetRemainingHp); + } + + [Fact] + public async Task PostAbilityCast_ShouldReturnCombatResolution_WhenZeroDamageUtilityAccepted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0GuardAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypeGuard, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + + // Act + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + + // Assert + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.True(body!.Accepted); + Assert.NotNull(body.CombatResolution); + Assert.Equal(0, body.CombatResolution!.DamageDealt); + Assert.Equal(100, body.CombatResolution.TargetRemainingHp); + Assert.False(body.CombatResolution.TargetDefeated); + } + + [Fact] + public async Task PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = PulseCastRequest(); + + // Act + AbilityCastResponse? fourth = null; + for (var i = 0; i < 4; i++) + { + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + fourth = await response.Content.ReadFromJsonAsync(); + response.EnsureSuccessStatusCode(); + if (i < 3) + { + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + } + } + + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + var fifthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + + // Assert + Assert.NotNull(fourth); + Assert.True(fourth!.Accepted); + Assert.NotNull(fourth.CombatResolution); + Assert.Equal(0, fourth.CombatResolution!.TargetRemainingHp); + Assert.True(fourth.CombatResolution.TargetDefeated); + var fifth = await fifthResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(fifth); + Assert.False(fifth!.Accepted); + Assert.Equal(CombatReasonCodes.TargetDefeated, fifth.ReasonCode); + Assert.Null(fifth.CombatResolution); + } + + [Fact] + public async Task PostAbilityCast_ShouldUseCatalogCooldown_WhenGuardCooldownLongerThanPulse() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0GuardAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypeGuard, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + + // Act + var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3.5)); + var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3)); + var third = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + + // Assert + var body1 = await first.Content.ReadFromJsonAsync(); + var body2 = await second.Content.ReadFromJsonAsync(); + var body3 = await third.Content.ReadFromJsonAsync(); + Assert.True(body1!.Accepted); + Assert.False(body2!.Accepted); + Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode); + Assert.True(body3!.Accepted); } } diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs index 2950f84..c190225 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs @@ -87,7 +87,7 @@ public sealed class CooldownSnapshotApiTests // Act var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); var snapDuring = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot"); - factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50)); + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var snapAfter = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot"); // Assert diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 763c996..075ec64 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -13,7 +13,8 @@ namespace NeonSprawl.Server.Game.AbilityInput; /// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode). /// Optional dev stdout: set NEON_SPRAWL_API_LOG=1 (see ) for one line per JSON cast response. /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON AbilityCastResponse). -/// NEO-32: JSON deny on_cooldown when the slot is inside the prototype global cooldown window. +/// NEO-32: JSON deny on_cooldown when the slot is inside the server cooldown window. +/// NEO-82: after E1 gates pass, invokes ; per-ability catalog cooldown on successful resolve. /// public static class AbilityCastApi { @@ -59,6 +60,7 @@ public static class AbilityCastApi IPlayerTargetLockStore locks, IPlayerAbilityCooldownStore cooldowns, IAbilityDefinitionRegistry abilities, + ICombatEntityHealthStore healthStore, TimeProvider clock) => { // NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body. @@ -182,17 +184,73 @@ public static class AbilityCastApi new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown }); } - cooldowns.StartCooldown(id, body.SlotIndex, now, AbilityPrototypeCooldown.GlobalDuration); + var combatResult = CombatOperations.TryResolve( + normalizedRequest, + lookupKey, + abilities, + healthStore); + + if (!combatResult.Success) + { + // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). + return JsonAbilityCast( + id, + body, + new AbilityCastResponse + { + Accepted = false, + ReasonCode = combatResult.ReasonCode, + }, + normalizedRequest); + } + + if (!abilities.TryGetDefinition(normalizedRequest, out var definition)) + { + return JsonAbilityCast( + id, + body, + new AbilityCastResponse + { + Accepted = false, + ReasonCode = ReasonUnknownAbility, + }, + normalizedRequest); + } + + cooldowns.StartCooldown( + id, + body.SlotIndex, + now, + TimeSpan.FromSeconds(definition.CooldownSeconds)); // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). return JsonAbilityCast( id, body, - new AbilityCastResponse { Accepted = true }, + MapAcceptResponse(normalizedRequest, lookupKey, combatResult), normalizedRequest); }); return app; } + + internal static AbilityCastResponse MapAcceptResponse( + string normalizedAbilityId, + string normalizedTargetId, + CombatResult result) + { + return new AbilityCastResponse + { + Accepted = true, + CombatResolution = new CombatResolutionJson + { + AbilityId = normalizedAbilityId, + TargetId = normalizedTargetId, + DamageDealt = result.DamageDealt, + TargetRemainingHp = result.TargetRemainingHp!.Value, + TargetDefeated = result.TargetDefeated, + }, + }; + } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs index e34d56c..0932295 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs @@ -24,6 +24,25 @@ public sealed class AbilityCastRequest public string? TargetId { get; init; } } +/// Combat outcome block on successful cast accept (NEO-82). +public sealed class CombatResolutionJson +{ + [JsonPropertyName("abilityId")] + public required string AbilityId { get; init; } + + [JsonPropertyName("targetId")] + public required string TargetId { get; init; } + + [JsonPropertyName("damageDealt")] + public int DamageDealt { get; init; } + + [JsonPropertyName("targetRemainingHp")] + public int TargetRemainingHp { get; init; } + + [JsonPropertyName("targetDefeated")] + public bool TargetDefeated { get; init; } +} + /// POST response for cast submit (prototype accept/deny; NEO-31 + NEO-28 target rules). public sealed class AbilityCastResponse { @@ -38,4 +57,9 @@ public sealed class AbilityCastResponse [JsonPropertyName("reasonCode")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ReasonCode { get; init; } + + /// Present only when is true after combat resolve (NEO-82). + [JsonPropertyName("combatResolution")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CombatResolutionJson? CombatResolution { get; init; } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs index aa91066..1e18869 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs @@ -1,8 +1,9 @@ namespace NeonSprawl.Server.Game.AbilityInput; -/// Prototype global cooldown applied on successful cast (NEO-32) until E5.M1 ability catalog. +/// Legacy prototype global cooldown constant (NEO-32); superseded by per-ability catalog durations (NEO-82). public static class AbilityPrototypeCooldown { - /// Single duration for every known prototype ability on accept. + /// Former global duration; kept for historical test/doc references — cast uses catalog cooldownSeconds. + [Obsolete("Use ability catalog cooldownSeconds on successful combat resolve (NEO-82).")] public static readonly TimeSpan GlobalDuration = TimeSpan.FromSeconds(3); } diff --git a/server/README.md b/server/README.md index 63d5102..5efec5a 100644 --- a/server/README.md +++ b/server/README.md @@ -116,7 +116,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m ## Combat engine (NEO-81) -**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. Cast HTTP wiring and wire **`CombatResolution`** fields are **NEO-82**. +**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept. | Reason code | When | |-------------|------| @@ -545,11 +545,21 @@ Prototype server-owned hotbar bindings are available at: Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`. -## Ability cast (NEO-31) +## Ability cast (NEO-31, NEO-82) -Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**. +Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**. -- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling; on accept, starts the prototype global cooldown for that slot. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`). +- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**. + +**Accept payload (`combatResolution`, present only when `accepted: true`):** + +| Field | Meaning | +|-------|---------| +| `abilityId` | Normalized ability id (matches request/binding). | +| `targetId` | Normalized prototype target id (lowercase registry key). | +| `damageDealt` | Catalog **`baseDamage`** applied (0 for utility abilities). | +| `targetRemainingHp` | Authoritative HP after resolve. | +| `targetDefeated` | `true` when post-resolve HP is zero. | **HTTP status:** @@ -569,13 +579,14 @@ Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** | `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). | | `invalid_target` | `targetId` missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). | | `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). | -| `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful accept (NEO-32). | +| `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful combat resolve (NEO-32). | +| `target_defeated` | Target HP is already zero; combat engine deny on re-hit (NEO-82 / NEO-81). No cooldown committed. | Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above). ## Cooldown snapshot (NEO-32) -Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **Global duration** `AbilityPrototypeCooldown.GlobalDuration` applies on each successful cast after all NEO-28 gates pass. +Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **NEO-82:** duration comes from ability catalog **`cooldownSeconds`** on each successful combat resolve (e.g. **`prototype_pulse`** 3.0s, **`prototype_guard`** 6.0s). - **`GET /game/players/{id}/cooldown-snapshot`** → `CooldownSnapshotResponse` v1 (`schemaVersion`, `playerId`, `serverTimeUtc`, `slots[]` with `slotIndex` and optional `cooldownEndsAtUtc` per slot). **404** when the player id is unknown to position state (same rule as loadout GET). From 0b40ae35984ee0cbab34fb128fe973d8bfe46349 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 10:44:02 -0400 Subject: [PATCH 3/7] NEO-82: Add code review for cast combat wiring. --- docs/reviews/2026-05-25-NEO-82.md | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/reviews/2026-05-25-NEO-82.md diff --git a/docs/reviews/2026-05-25-NEO-82.md b/docs/reviews/2026-05-25-NEO-82.md new file mode 100644 index 0000000..0e51b7a --- /dev/null +++ b/docs/reviews/2026-05-25-NEO-82.md @@ -0,0 +1,63 @@ +# Code review — NEO-82 Wire ability-cast into combat engine + cast response + +**Date:** 2026-05-25 +**Scope:** Branch `NEO-82-wire-ability-cast-combat-resolution` · commits `4126ac9`–`e8be100` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-82 delivers **E5M1-07**: after E1.M4 cast gates pass, `AbilityCastApi` invokes `CombatOperations.TryResolve`, returns nested wire `combatResolution` on accept, maps combat denies (notably `target_defeated`) to JSON cast denies without cooldown commit, and replaces the global prototype cooldown with per-ability catalog `cooldownSeconds` on successful resolve. Handler flow, DTO shape, kickoff decisions (nested block, no cooldown on defeated deny), Bruno happy + defeat spine, and README updates align with the implementation plan. Four new integration tests cover zero-damage accept, four-pulse defeat chain, and guard-vs-pulse catalog cooldown; existing cast tests assert resolution fields. Residual risk is low — server-only HTTP extension with clear deny vocabulary; module tracking docs should sync on merge (same follow-up pattern as NEO-81). + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-82-implementation-plan.md`](../plans/NEO-82-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, expected cast table, and reconciliation align with code. | +| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-07** | **Matches** — AC checked, landed note added. | +| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Partially matches** — prototype notes paragraph updated for NEO-82; **Status** still reads “E5M1-07+ pending”; no **Related implementation slices** entry for E5M1-07 (NEO-78–NEO-81 pattern). | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E5.M1 note cites NEO-81; **NEO-82 landed** (cast → `combatResolution`, catalog cooldown) not yet added. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Partially matches** — E5.M1 row still ends with “accepts without **`CombatResolution`** yet”; should cite NEO-82 landed after merge. | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server validates and computes all combat outcomes; client unchanged (NEO-85). | +| [`docs/decomposition/modules/pvp_combat_integration.md`](../decomposition/modules/pvp_combat_integration.md) | **N/A** — prototype NPC targets only. | +| [`server/README.md`](../../server/README.md) | **Matches** — cast `combatResolution` table, `target_defeated` deny, catalog cooldown policy, combat engine cross-link. | +| Full-stack epic decomposition | **Matches** — plan documents NEO-85 client counterpart; no Godot change in scope. | +| Manual QA | **N/A** — kickoff skip; Bruno + README sufficient. | + +## Blocking issues + +None. + +## Suggestions + +1. **Module doc status + slice entry** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** to cite **E5M1-07 NEO-82 landed** (cast POST returns `combatResolution`), and add **E5M1-07 (NEO-82)** under **Related implementation slices** linking the plan and README ability-cast section (mirror NEO-81 landed pattern). + +2. **Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-82 landed:** `AbilityCastApi` → `CombatOperations.TryResolve`, nested `combatResolution`, per-ability catalog cooldown, `target_defeated` cast deny. + +3. **Bruno defeat spine HP stepping** — Plan §6 calls for asserting HP 75 → 50 → 25 → 0 on casts 1–4; `Post cast lock pulse defeat spine.bru` only captures/asserts the fourth cast. Consider storing intermediate cast bodies in pre-request and asserting `targetRemainingHp` on each pulse for stronger manual lock-in (non-blocking — fifth deny + integration test cover the critical path). + +## Nits + +- Nit: `PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated` runs pulses 1–3 inside **Act**; move them to **Arrange** for stricter AAA (same note as NEO-81 four-pulse pattern). + +- Nit: After successful `TryResolve`, `TryGetDefinition` runs again solely for `CooldownSeconds` — `CombatOperations` already loaded the definition internally. Acceptable for thin handler clarity; optional future refactor if catalog lookups become hot-path. + +- Nit: `AbilityPrototypeCooldown.GlobalDuration` is `[Obsolete]` but retained — matches plan; no remaining runtime references in tests or accept path. + +## Verification + +```bash +cd /home/don/neon-sprawl/server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~AbilityCastApiTests|FullyQualifiedName~CooldownSnapshotApiTests" +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +**AbilityCast + CooldownSnapshot tests:** 22/22 passed (local run). + +**Full suite:** 394/394 passed (local run). + +**Bruno (manual):** Run `bruno/neon-sprawl-server/ability-cast/` folder — happy cast + defeat spine (seq 23, ~10s pre-request cooldown waits). From 322798b2ef36f317ee0548ce2961b4988e42e57f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 10:45:31 -0400 Subject: [PATCH 4/7] NEO-82: Address code review doc sync and test AAA follow-ups. Update E5.M1 module doc, dependency register, and alignment table for NEO-82 landed; Bruno defeat spine HP stepping; refactor four-pulse cast test Arrange/Act/Assert; strike through resolved review items. --- .../Post cast lock pulse defeat spine.bru | 18 +++++----- .../modules/E5_M1_CombatRulesEngine.md | 4 ++- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/reviews/2026-05-25-NEO-82.md | 20 ++++++----- .../Game/AbilityInput/AbilityCastApiTests.cs | 33 +++++++++++++++---- 6 files changed, 52 insertions(+), 27 deletions(-) diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru index c92ca1f..9d30dd3 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -46,20 +46,20 @@ script:pre-request { targetId: "prototype_target_alpha", }; + const castResults = []; for (let i = 0; i < 4; i++) { const response = await axios.post( `${baseUrl}/game/players/${playerId}/ability-cast`, castBody, jsonHeaders, ); + castResults.push(response.data); if (i < 3) { await sleep(3100); } - if (i === 3) { - bru.setVar("neo82FourthCast", JSON.stringify(response.data)); - } } + bru.setVar("neo82CastResults", JSON.stringify(castResults)); await sleep(3100); } @@ -79,11 +79,13 @@ body:json { } tests { - test("fourth cast defeated target", function () { - const fourth = JSON.parse(bru.getVar("neo82FourthCast")); - expect(fourth.accepted).to.equal(true); - expect(fourth.combatResolution.targetRemainingHp).to.equal(0); - expect(fourth.combatResolution.targetDefeated).to.equal(true); + test("pulse HP stepping on casts 1-4", function () { + const casts = JSON.parse(bru.getVar("neo82CastResults")); + expect(casts[0].combatResolution.targetRemainingHp).to.equal(75); + expect(casts[1].combatResolution.targetRemainingHp).to.equal(50); + expect(casts[2].combatResolution.targetRemainingHp).to.equal(25); + expect(casts[3].combatResolution.targetRemainingHp).to.equal(0); + expect(casts[3].combatResolution.targetDefeated).to.equal(true); }); test("fifth cast denies target_defeated", function () { diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index cb957e4..bbcb22d 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M1 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07+ pending (see [dependency register](module_dependency_register.md)) | +| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08+ pending (see [dependency register](module_dependency_register.md)) | | **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) | ## Purpose @@ -56,6 +56,8 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 a **E5M1-06 (NEO-81):** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — deterministic catalog damage, defeated pre-check deny (`target_defeated`), unknown ability/target validation ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). +**E5M1-07 (NEO-82):** **`AbilityCastApi`** → **`CombatOperations.TryResolve`** — nested wire **`combatResolution`** on accept, per-ability catalog **`cooldownSeconds`**, **`target_defeated`** cast deny without cooldown commit ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/`. + ## Linear backlog (decomposed) Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index fffd157..3d836b9 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -56,7 +56,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | -| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-07** [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | +| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-08** [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 1ceced2..64b283c 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned | | E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned | -**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). +**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. ### Epic 6 — PvP Security diff --git a/docs/reviews/2026-05-25-NEO-82.md b/docs/reviews/2026-05-25-NEO-82.md index 0e51b7a..99398cd 100644 --- a/docs/reviews/2026-05-25-NEO-82.md +++ b/docs/reviews/2026-05-25-NEO-82.md @@ -4,6 +4,8 @@ **Scope:** Branch `NEO-82-wire-ability-cast-combat-resolution` · commits `4126ac9`–`e8be100` vs `origin/main` **Base:** `origin/main` +**Follow-up:** Code review suggestions and nits addressed in follow-up commit on `NEO-82-wire-ability-cast-combat-resolution`. + ## Verdict **Approve with nits** @@ -18,9 +20,9 @@ NEO-82 delivers **E5M1-07**: after E1.M4 cast gates pass, `AbilityCastApi` invok |----------|--------| | [`docs/plans/NEO-82-implementation-plan.md`](../plans/NEO-82-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, expected cast table, and reconciliation align with code. | | [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-07** | **Matches** — AC checked, landed note added. | -| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Partially matches** — prototype notes paragraph updated for NEO-82; **Status** still reads “E5M1-07+ pending”; no **Related implementation slices** entry for E5M1-07 (NEO-78–NEO-81 pattern). | -| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E5.M1 note cites NEO-81; **NEO-82 landed** (cast → `combatResolution`, catalog cooldown) not yet added. | -| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Partially matches** — E5.M1 row still ends with “accepts without **`CombatResolution`** yet”; should cite NEO-82 landed after merge. | +| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites E5M1-07 NEO-82 landed; **Related implementation slices** entry added. ~~Partially matches~~ **Done.** | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-82 landed. ~~Partially matches~~ **Done.** | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row cites NEO-82 cast → `combatResolution`. ~~Partially matches~~ **Done.** | | [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server validates and computes all combat outcomes; client unchanged (NEO-85). | | [`docs/decomposition/modules/pvp_combat_integration.md`](../decomposition/modules/pvp_combat_integration.md) | **N/A** — prototype NPC targets only. | | [`server/README.md`](../../server/README.md) | **Matches** — cast `combatResolution` table, `target_defeated` deny, catalog cooldown policy, combat engine cross-link. | @@ -33,19 +35,19 @@ None. ## Suggestions -1. **Module doc status + slice entry** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** to cite **E5M1-07 NEO-82 landed** (cast POST returns `combatResolution`), and add **E5M1-07 (NEO-82)** under **Related implementation slices** linking the plan and README ability-cast section (mirror NEO-81 landed pattern). +1. ~~**Module doc status + slice entry** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** to cite **E5M1-07 NEO-82 landed** (cast POST returns `combatResolution`), and add **E5M1-07 (NEO-82)** under **Related implementation slices** linking the plan and README ability-cast section (mirror NEO-81 landed pattern).~~ **Done.** -2. **Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-82 landed:** `AbilityCastApi` → `CombatOperations.TryResolve`, nested `combatResolution`, per-ability catalog cooldown, `target_defeated` cast deny. +2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-82 landed:** `AbilityCastApi` → `CombatOperations.TryResolve`, nested `combatResolution`, per-ability catalog cooldown, `target_defeated` cast deny.~~ **Done.** -3. **Bruno defeat spine HP stepping** — Plan §6 calls for asserting HP 75 → 50 → 25 → 0 on casts 1–4; `Post cast lock pulse defeat spine.bru` only captures/asserts the fourth cast. Consider storing intermediate cast bodies in pre-request and asserting `targetRemainingHp` on each pulse for stronger manual lock-in (non-blocking — fifth deny + integration test cover the critical path). +3. ~~**Bruno defeat spine HP stepping** — Plan §6 calls for asserting HP 75 → 50 → 25 → 0 on casts 1–4; `Post cast lock pulse defeat spine.bru` only captures/asserts the fourth cast. Consider storing intermediate cast bodies in pre-request and asserting `targetRemainingHp` on each pulse for stronger manual lock-in (non-blocking — fifth deny + integration test cover the critical path).~~ **Done.** — `neo82CastResults` stores all four casts; tests assert HP stepping. ## Nits -- Nit: `PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated` runs pulses 1–3 inside **Act**; move them to **Arrange** for stricter AAA (same note as NEO-81 four-pulse pattern). +- ~~Nit: `PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated` runs pulses 1–3 inside **Act**; move them to **Arrange** for stricter AAA (same note as NEO-81 four-pulse pattern).~~ **Done.** — Pulses 1–3 in Arrange; Act is 4th + 5th cast; Assert includes HP stepping. -- Nit: After successful `TryResolve`, `TryGetDefinition` runs again solely for `CooldownSeconds` — `CombatOperations` already loaded the definition internally. Acceptable for thin handler clarity; optional future refactor if catalog lookups become hot-path. +- Nit: After successful `TryResolve`, `TryGetDefinition` runs again solely for `CooldownSeconds` — `CombatOperations` already loaded the definition internally. Acceptable for thin handler clarity; optional future refactor if catalog lookups become hot-path. **Deferred** — no hot-path concern in prototype. -- Nit: `AbilityPrototypeCooldown.GlobalDuration` is `[Obsolete]` but retained — matches plan; no remaining runtime references in tests or accept path. +- Nit: `AbilityPrototypeCooldown.GlobalDuration` is `[Obsolete]` but retained — matches plan; no remaining runtime references in tests or accept path. **No action** — intentional per plan. ## Verification diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 771ba00..9230e1f 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -551,24 +551,43 @@ public sealed class AbilityCastApiTests await BindSlot0PulseAsync(client); await LockPrototypeTargetAlphaAsync(client); var cast = PulseCastRequest(); - - // Act - AbilityCastResponse? fourth = null; - for (var i = 0; i < 4; i++) + AbilityCastResponse? first = null; + AbilityCastResponse? second = null; + AbilityCastResponse? third = null; + for (var i = 0; i < 3; i++) { var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); - fourth = await response.Content.ReadFromJsonAsync(); response.EnsureSuccessStatusCode(); - if (i < 3) + var body = await response.Content.ReadFromJsonAsync(); + if (i == 0) { - factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + first = body; } + else if (i == 1) + { + second = body; + } + else + { + third = body; + } + + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); } + // Act + var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); var fifthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); // Assert + Assert.NotNull(first); + Assert.Equal(75, first!.CombatResolution!.TargetRemainingHp); + Assert.NotNull(second); + Assert.Equal(50, second!.CombatResolution!.TargetRemainingHp); + Assert.NotNull(third); + Assert.Equal(25, third!.CombatResolution!.TargetRemainingHp); + var fourth = await fourthResponse.Content.ReadFromJsonAsync(); Assert.NotNull(fourth); Assert.True(fourth!.Accepted); Assert.NotNull(fourth.CombatResolution); From 52bf9fc4bf468f6e509731070a9f19bd207444b0 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:04:55 -0400 Subject: [PATCH 5/7] NEO-82: Fix cast definition lookup order and Bruno target isolation. Load ability definition before TryResolve so cooldown lookup cannot deny after HP mutation (Bugbot). Happy Bruno cast uses beta; defeat spine keeps alpha; bump spine seq and cooldown sleep margin for CI. --- .../ability-cast/Post cast happy.bru | 22 ++++++++++++--- .../Post cast lock pulse defeat spine.bru | 6 ++--- .../ability-cast/folder.bru | 2 +- docs/reviews/2026-05-25-NEO-82.md | 2 +- .../Game/AbilityInput/AbilityCastApiTests.cs | 26 ++++++++++++++++++ .../Game/AbilityInput/AbilityCastApi.cs | 27 ++++++++++--------- 6 files changed, 63 insertions(+), 22 deletions(-) diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index af6f307..d238b66 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -8,13 +8,27 @@ script:pre-request { const axios = require("axios"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + await axios.post( `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x: 5, y: 0.9, z: 5 } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, { schemaVersion: 1, - target: { x: -5, y: 0.9, z: -5 }, + slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], }, - { headers: { "Content-Type": "application/json" } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId: "prototype_target_beta" }, + jsonHeaders, ); } @@ -29,7 +43,7 @@ body:json { "schemaVersion": 1, "slotIndex": 0, "abilityId": "prototype_pulse", - "targetId": "prototype_target_alpha" + "targetId": "prototype_target_beta" } } @@ -46,7 +60,7 @@ tests { expect(body.accepted).to.equal(true); expect(body.combatResolution).to.be.an("object"); expect(body.combatResolution.abilityId).to.equal("prototype_pulse"); - expect(body.combatResolution.targetId).to.equal("prototype_target_alpha"); + expect(body.combatResolution.targetId).to.equal("prototype_target_beta"); expect(body.combatResolution.damageDealt).to.equal(25); expect(body.combatResolution.targetRemainingHp).to.equal(75); expect(body.combatResolution.targetDefeated).to.equal(false); diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru index 9d30dd3..a5bd209 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -1,7 +1,7 @@ meta { name: POST cast lock pulse defeat spine (NEO-82) type: http - seq: 23 + seq: 30 } docs { @@ -55,12 +55,12 @@ script:pre-request { ); castResults.push(response.data); if (i < 3) { - await sleep(3100); + await sleep(3200); } } bru.setVar("neo82CastResults", JSON.stringify(castResults)); - await sleep(3100); + await sleep(3200); } post { diff --git a/bruno/neon-sprawl-server/ability-cast/folder.bru b/bruno/neon-sprawl-server/ability-cast/folder.bru index f596329..8d70f37 100644 --- a/bruno/neon-sprawl-server/ability-cast/folder.bru +++ b/bruno/neon-sprawl-server/ability-cast/folder.bru @@ -3,5 +3,5 @@ meta { } docs { - NEO-82: happy cast asserts combatResolution; defeat spine (seq 23) requires ~10s pre-request for pulse cooldowns between four lethal casts. + NEO-82: happy cast uses beta at spawn (5,5) so alpha stays fresh for defeat spine (seq 30). } diff --git a/docs/reviews/2026-05-25-NEO-82.md b/docs/reviews/2026-05-25-NEO-82.md index 99398cd..27f4132 100644 --- a/docs/reviews/2026-05-25-NEO-82.md +++ b/docs/reviews/2026-05-25-NEO-82.md @@ -45,7 +45,7 @@ None. - ~~Nit: `PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated` runs pulses 1–3 inside **Act**; move them to **Arrange** for stricter AAA (same note as NEO-81 four-pulse pattern).~~ **Done.** — Pulses 1–3 in Arrange; Act is 4th + 5th cast; Assert includes HP stepping. -- Nit: After successful `TryResolve`, `TryGetDefinition` runs again solely for `CooldownSeconds` — `CombatOperations` already loaded the definition internally. Acceptable for thin handler clarity; optional future refactor if catalog lookups become hot-path. **Deferred** — no hot-path concern in prototype. +- ~~Nit: After successful `TryResolve`, `TryGetDefinition` runs again solely for `CooldownSeconds` — `CombatOperations` already loaded the definition internally. Acceptable for thin handler clarity; optional future refactor if catalog lookups become hot-path. **Deferred** — no hot-path concern in prototype.~~ **Done.** — `TryGetDefinition` moved before `TryResolve` so cooldown lookup cannot deny after HP mutation (Bugbot fix). - Nit: `AbilityPrototypeCooldown.GlobalDuration` is `[Obsolete]` but retained — matches plan; no remaining runtime references in tests or accept path. **No action** — intentional per plan. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 9230e1f..99b0ae7 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -444,6 +444,32 @@ public sealed class AbilityCastApiTests Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task PostAbilityCast_ShouldStartCatalogCooldown_WhenResolveSucceedsAfterDefinitionGate() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = PulseCastRequest(); + + // Act + var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + var snapshotResponse = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot"); + + // Assert + var body = await castResponse.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.OK, castResponse.StatusCode); + Assert.NotNull(body); + Assert.True(body!.Accepted); + Assert.NotNull(body.CombatResolution); + var snapshot = await snapshotResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(snapshot); + var slot0 = snapshot!.Slots.Single(s => s.SlotIndex == 0); + Assert.NotNull(slot0.CooldownEndsAtUtc); + } + [Fact] public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling() { diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 075ec64..0b924b5 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -184,6 +184,20 @@ public static class AbilityCastApi new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown }); } + if (!abilities.TryGetDefinition(normalizedRequest, out var definition)) + { + // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). + return JsonAbilityCast( + id, + body, + new AbilityCastResponse + { + Accepted = false, + ReasonCode = ReasonUnknownAbility, + }, + normalizedRequest); + } + var combatResult = CombatOperations.TryResolve( normalizedRequest, lookupKey, @@ -204,19 +218,6 @@ public static class AbilityCastApi normalizedRequest); } - if (!abilities.TryGetDefinition(normalizedRequest, out var definition)) - { - return JsonAbilityCast( - id, - body, - new AbilityCastResponse - { - Accepted = false, - ReasonCode = ReasonUnknownAbility, - }, - normalizedRequest); - } - cooldowns.StartCooldown( id, body.SlotIndex, From 71d93ee1fd879b8f63696b47fbc37d11dd37b0d4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:05:02 -0400 Subject: [PATCH 6/7] NEO-82: Note Bugbot fix in code review follow-up. --- docs/reviews/2026-05-25-NEO-82.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reviews/2026-05-25-NEO-82.md b/docs/reviews/2026-05-25-NEO-82.md index 27f4132..5b42481 100644 --- a/docs/reviews/2026-05-25-NEO-82.md +++ b/docs/reviews/2026-05-25-NEO-82.md @@ -4,7 +4,7 @@ **Scope:** Branch `NEO-82-wire-ability-cast-combat-resolution` · commits `4126ac9`–`e8be100` vs `origin/main` **Base:** `origin/main` -**Follow-up:** Code review suggestions and nits addressed in follow-up commit on `NEO-82-wire-ability-cast-combat-resolution`. +**Follow-up:** Code review suggestions and nits addressed in follow-up commit on `NEO-82-wire-ability-cast-combat-resolution`. Bugbot + CI fixes in `52bf9fc` (definition-before-resolve; Bruno beta/alpha isolation). ## Verdict From 3e0d370677360e0a4a5c85896b4edad25d0d5f4c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:07:59 -0400 Subject: [PATCH 7/7] NEO-82: Use slot 1 in Bruno defeat spine to avoid cooldown clash. Happy cast (seq 22) leaves slot 0 cooling; defeat spine pre-request casts were denied with on_cooldown. Slot 1 + pre-request fail-fast on bad cast. --- .../Post cast lock pulse defeat spine.bru | 16 +++++++++++----- bruno/neon-sprawl-server/ability-cast/folder.bru | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru index a5bd209..c4db35c 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -5,7 +5,7 @@ meta { } docs { - NEO-82 spine: pre-request moves, locks alpha, casts prototype_pulse four times (3s catalog cooldown between), then this request is the fifth cast expecting target_defeated deny. + NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks alpha, casts pulse four times (3.2s between), fifth request expects target_defeated deny. } script:pre-request { @@ -28,7 +28,7 @@ script:pre-request { `${baseUrl}/game/players/${playerId}/hotbar-loadout`, { schemaVersion: 1, - slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + slots: [{ slotIndex: 1, abilityId: "prototype_pulse" }], }, jsonHeaders, ); @@ -41,7 +41,7 @@ script:pre-request { const castBody = { schemaVersion: 1, - slotIndex: 0, + slotIndex: 1, abilityId: "prototype_pulse", targetId: "prototype_target_alpha", }; @@ -53,7 +53,13 @@ script:pre-request { castBody, jsonHeaders, ); - castResults.push(response.data); + const body = response.data; + if (!body?.accepted || !body?.combatResolution) { + throw new Error( + `neo82 cast ${i + 1} failed: ${JSON.stringify(body)}`, + ); + } + castResults.push(body); if (i < 3) { await sleep(3200); } @@ -72,7 +78,7 @@ post { body:json { { "schemaVersion": 1, - "slotIndex": 0, + "slotIndex": 1, "abilityId": "prototype_pulse", "targetId": "prototype_target_alpha" } diff --git a/bruno/neon-sprawl-server/ability-cast/folder.bru b/bruno/neon-sprawl-server/ability-cast/folder.bru index 8d70f37..82d5d03 100644 --- a/bruno/neon-sprawl-server/ability-cast/folder.bru +++ b/bruno/neon-sprawl-server/ability-cast/folder.bru @@ -3,5 +3,5 @@ meta { } docs { - NEO-82: happy cast uses beta at spawn (5,5) so alpha stays fresh for defeat spine (seq 30). + NEO-82: happy cast uses beta on slot 0; defeat spine uses slot 1 on alpha (~13s pre-request). }