112 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|