Files
healer-man/src/game/wow335InterruptUtilityBlocking.integration.test.ts
T
2026-10-07 13:05:04 -04:00

112 lines
6.9 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore, stableCombatUnit } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const TARGET = "synthetic-interrupt-block-creature";
const FRONT = [0, 0, -2] as const;
const ORIGIN = [0, 0, 0] as const;
const CASES = [
{ classId: "rogue" as const, name: "Kick", spellId: 1766, lockoutMs: 5_000 },
{ classId: "warrior" as const, name: "Pummel", spellId: 6552, lockoutMs: 4_000 },
];
function prepare(classId: "rogue" | "warrior", name: string) {
useCombatStore.getState().initializeCharacter({ classId, level: 40 });
usePartyStore.getState().initializeParty("interrupt-block-fixture", 40,
useCombatStore.getState().health, "damage", 1, "wow");
useCombatStore.getState().setPlayerPosition(FRONT);
useCombatStore.getState().registerMob(TARGET, { name: "Ordinary source NPC fixture",
serverEntry: 1666, level: 25, maxHealth: 100_000, xpReward: 0 });
registerMobRuntime(TARGET, ORIGIN, Math.PI);
registerCombatSpatialProvider({ actor: (id) => id === TARGET
? { id, position: ORIGIN, yaw: Math.PI } : { id, position: FRONT, yaw: 0 },
hasLineOfSight: () => true, projectGroundPoint: (position) => position });
useCombatStore.getState().selectMob(TARGET);
useCombatStore.setState((state) => ({ resource: state.maxResource,
resourcePools: { ...state.resourcePools, [classId === "warrior" ? "rage" : "energy"]: state.maxResource } }));
return abilityAtLevel(abilitiesForClass(classId)
.find((ability) => ability.id === (classId === "rogue" ? "rogue-kick" : "wow335-warrior-pummel"))!, 40);
}
function blockedTime(actorId: string, abilityId: string) {
for (let now = 1_000; now < 100_000; now++) {
if (stableCombatUnit(`${actorId}:${abilityId}:${TARGET}:${now}:utility-hit:outcome`) < 0.01) return now;
}
throw new Error("No deterministic source utility block roll");
}
function blockedEvents(actorId: string, abilityId: string) {
return useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === actorId
&& event.targetActorId === TARGET && event.abilityId === abilityId && event.blocked);
}
describe("exact selected Kick/Pummel ordinary NPC full blocking", () => {
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "rogue"]); });
afterEach(() => {
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); resetPartyRuntimeClock();
});
it.each(CASES)("records a blocked actual player $name while committing cost/cooldown", ({ classId, name, spellId }) => {
const ability = prepare(classId, name);
expect(ability.dbcSpellId).toBe(spellId);
const now = blockedTime(PLAYER_AGGRO_ID, ability.id);
const before = useCombatStore.getState();
const cast = before.castAbility(ability.id, { now, targetActorId: TARGET });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ outcome: "block", blocked: true, finalDamage: 0 });
expect(blockedEvents(PLAYER_AGGRO_ID, ability.id)).toHaveLength(1);
expect(useCombatStore.getState().resource).toBeLessThan(before.resource);
expect(useCombatStore.getState().cooldowns[ability.id]).toBeGreaterThan(now);
expect(useCombatStore.getState().mobs[TARGET]!.statuses).toHaveLength(0);
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === ability.id && event.kind === "damage")).toBe(false);
});
it.each(CASES)("respects the casting-defender block gate and real $name school lockout", ({ classId, name, lockoutMs }) => {
const ability = prepare(classId, name);
const now = blockedTime(PLAYER_AGGRO_ID, ability.id);
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [TARGET]: { ...state.mobs[TARGET]!,
activeCast: { attackId: "synthetic-fire-cast", name: "Synthetic interruptible Fire cast",
school: "fire", interruptible: true, targetActorId: PLAYER_AGGRO_ID,
sourcePosition: ORIGIN, targetPosition: FRONT, sourceYaw: Math.PI,
startedAt: now - 1, completesAt: now + 5_000 },
} } }));
const cast = useCombatStore.getState().castAbility(ability.id, { now, targetActorId: TARGET });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ outcome: "hit", blocked: false });
expect(useCombatStore.getState().mobs[TARGET]!.activeCast).toBeNull();
expect(useCombatStore.getState().mobs[TARGET]!.schoolLockedUntil.fire).toBe(now + lockoutMs);
});
it.each(CASES)("blocks accepted companion $name completion without inventing damage", ({ classId, name }) => {
const ability = prepare(classId, name);
const member = createConfiguredPartyMember({ name: "Configured interrupt companion", classId,
specialization: classId === "warrior" ? "Protection" : "Assassination",
specializationId: classId === "warrior" ? "warrior:protection" : "rogue:assassination",
talentTreeId: classId === "warrior" ? "warrior-protection" : "rogue-assassination",
talentRanks: {}, role: "damage" }, 40, 0, "interrupt-block-fixture");
const now = blockedTime(member.id, ability.id);
// Synthetic already-paid pending cast; actual target validation/completion
// consumes the selected source ability through the companion runtime.
usePartyStore.setState({ members: [{ ...member, nextActionAt: 100_000,
cooldowns: { [ability.id]: now + 10_000 }, activeCastTargetId: TARGET,
activeCast: { abilityId: ability.id, school: "physical", mode: "cast",
startedAt: now - 1, completesAt: now, originalDurationMs: 1,
pushbackHits: 0, origin: FRONT, targetId: TARGET },
}], command: "attack", activeMobId: TARGET });
registerPartyRuntimePosition(member.id, FRONT);
updatePartyRuntimeTargetVisibility(member.id, TARGET, true);
useCombatStore.getState().damageMob(TARGET, 1, now - 1);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(now);
expect(blockedEvents(member.id, ability.id)).toHaveLength(1);
expect(usePartyStore.getState().members[0]!.activeCast).toBeNull();
expect(usePartyStore.getState().members[0]!.cooldowns[ability.id]).toBe(now + 10_000);
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === ability.id && event.kind === "damage")).toBe(false);
});
});