From 52bf9fc4bf468f6e509731070a9f19bd207444b0 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:04:55 -0400 Subject: [PATCH] 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,