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