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.
pull/116/head
VinPropane 2026-05-25 11:04:55 -04:00
parent 322798b2ef
commit 52bf9fc4bf
6 changed files with 63 additions and 22 deletions

View File

@ -8,13 +8,27 @@ script:pre-request {
const axios = require("axios"); const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/move`, `${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, 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, "schemaVersion": 1,
"slotIndex": 0, "slotIndex": 0,
"abilityId": "prototype_pulse", "abilityId": "prototype_pulse",
"targetId": "prototype_target_alpha" "targetId": "prototype_target_beta"
} }
} }
@ -46,7 +60,7 @@ tests {
expect(body.accepted).to.equal(true); expect(body.accepted).to.equal(true);
expect(body.combatResolution).to.be.an("object"); expect(body.combatResolution).to.be.an("object");
expect(body.combatResolution.abilityId).to.equal("prototype_pulse"); 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.damageDealt).to.equal(25);
expect(body.combatResolution.targetRemainingHp).to.equal(75); expect(body.combatResolution.targetRemainingHp).to.equal(75);
expect(body.combatResolution.targetDefeated).to.equal(false); expect(body.combatResolution.targetDefeated).to.equal(false);

View File

@ -1,7 +1,7 @@
meta { meta {
name: POST cast lock pulse defeat spine (NEO-82) name: POST cast lock pulse defeat spine (NEO-82)
type: http type: http
seq: 23 seq: 30
} }
docs { docs {
@ -55,12 +55,12 @@ script:pre-request {
); );
castResults.push(response.data); castResults.push(response.data);
if (i < 3) { if (i < 3) {
await sleep(3100); await sleep(3200);
} }
} }
bru.setVar("neo82CastResults", JSON.stringify(castResults)); bru.setVar("neo82CastResults", JSON.stringify(castResults));
await sleep(3100); await sleep(3200);
} }
post { post {

View File

@ -3,5 +3,5 @@ meta {
} }
docs { 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).
} }

View File

@ -45,7 +45,7 @@ None.
- ~~Nit: `PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated` runs pulses 13 inside **Act**; move them to **Arrange** for stricter AAA (same note as NEO-81 four-pulse pattern).~~ **Done.** — Pulses 13 in Arrange; Act is 4th + 5th cast; Assert includes HP stepping. - ~~Nit: `PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated` runs pulses 13 inside **Act**; move them to **Arrange** for stricter AAA (same note as NEO-81 four-pulse pattern).~~ **Done.** — Pulses 13 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. - Nit: `AbilityPrototypeCooldown.GlobalDuration` is `[Obsolete]` but retained — matches plan; no remaining runtime references in tests or accept path. **No action** — intentional per plan.

View File

@ -444,6 +444,32 @@ public sealed class AbilityCastApiTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); 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<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, castResponse.StatusCode);
Assert.NotNull(body);
Assert.True(body!.Accepted);
Assert.NotNull(body.CombatResolution);
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<CooldownSnapshotResponse>();
Assert.NotNull(snapshot);
var slot0 = snapshot!.Slots.Single(s => s.SlotIndex == 0);
Assert.NotNull(slot0.CooldownEndsAtUtc);
}
[Fact] [Fact]
public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling() public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling()
{ {

View File

@ -184,6 +184,20 @@ public static class AbilityCastApi
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown }); 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( var combatResult = CombatOperations.TryResolve(
normalizedRequest, normalizedRequest,
lookupKey, lookupKey,
@ -204,19 +218,6 @@ public static class AbilityCastApi
normalizedRequest); normalizedRequest);
} }
if (!abilities.TryGetDefinition(normalizedRequest, out var definition))
{
return JsonAbilityCast(
id,
body,
new AbilityCastResponse
{
Accepted = false,
ReasonCode = ReasonUnknownAbility,
},
normalizedRequest);
}
cooldowns.StartCooldown( cooldowns.StartCooldown(
id, id,
body.SlotIndex, body.SlotIndex,