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 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);

View File

@ -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 {

View File

@ -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).
}

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: 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.

View File

@ -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<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]
public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling()
{

View File

@ -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,