import { beforeEach, describe, expect, it } from "vitest"; import { createRaceBot } from "../app/dungeonRaceBot"; import { useDungeonRaceStore } from "../app/dungeonRaceStore"; import { useCombatStore } from "./combatStore"; import { lastStandStacks, raceAffixBoonUntil, raceAffixHasteMultiplier, raceAffixMobDamageMultiplier } from "./dungeonRaceAffixBuffs"; import { raceAffixAvoidanceDestination } from "./dungeonRaceAffixAvoidance"; import { advanceDungeonRaceAffixes, activeRaceOrbId, clearDungeonRaceAffixes, resumeDungeonRaceAffixes, suspendDungeonRaceAffixes, useDungeonRaceAffixStore } from "./dungeonRaceAffixRuntime"; import { dungeonRaceAffixPace, rollDungeonRaceAffixes, validDungeonRaceAffixes } from "./dungeonRaceAffixes"; import { useDungeonRunStore } from "./dungeonRunStore"; import { dungeonDefinitionById } from "./dungeonRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; const dungeonId = "bite-sized-stormwind-stockade"; const definition = dungeonDefinitionById(dungeonId)!; const trashIds = definition.staticSpawns.filter((spawn) => !definition.bosses.some((boss) => boss.id === spawn.id)) .map((spawn) => spawn.id); function start(killId: string, movementId = "falling-stars") { useDungeonRaceStore.setState({ mode: "practice", inGame: true, seed: "affix-test", dungeonId, startAt: 10_000, affixIds: [killId, movementId] }); advanceDungeonRaceAffixes(10_000, []); } function deadTrash(id: string, position: readonly [number, number, number] = [0, 0, 0]) { setMobPosition(id, position); useCombatStore.getState().registerMob(id, { name: id, level: 20, maxHealth: 100 }); const mob = useCombatStore.getState().mobs[id]; useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [id]: { ...mob, dead: true, health: 0, diedAt: 10_000 } } })); } describe("Bite-Sized race mechanics", () => { beforeEach(() => { useDungeonRaceStore.getState().reset(); clearDungeonRaceAffixes(); clearMobRuntimeRegistry(); useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 20 }); useCombatStore.setState((state) => ({ mobs: {}, playerPosition: [0, 0, 0], health: state.maxHealth })); usePartyStore.setState({ members: [] }); useDungeonRunStore.setState({ progress: null }); useGameStore.setState({ activeDungeonId: dungeonId, gameMode: "dungeon" }); }); it("rolls a valid shared pair and uses both affixes in the NPC pace", () => { const pairs = new Set(); for (let index = 0; index < 1_000; index += 1) { const pair = rollDungeonRaceAffixes(`race-${index}`); expect(validDungeonRaceAffixes(pair)).toBe(true); expect(pair).toEqual(rollDungeonRaceAffixes(`race-${index}`)); pairs.add(pair.join("+")); } expect(pairs.size).toBe(20); const pair = rollDungeonRaceAffixes("bot-pair"); const base = createRaceBot("bot-pair", "regular", "wow"); const tuned = createRaceBot("bot-pair", "regular", "wow", pair); expect(Math.abs(tuned.finishMs - base.finishMs * dungeonRaceAffixPace(pair))).toBeLessThan(2); expect(tuned.milestones.at(-1)?.atMs).toBe(tuned.finishMs); }); it("deduplicates simultaneous volatile blasts and does not hit another floor", () => { start("volatile-remains"); deadTrash(trashIds[0]); deadTrash(trashIds[1]); advanceDungeonRaceAffixes(10_100, trashIds.slice(0, 2)); const full = useCombatStore.getState().health; advanceDungeonRaceAffixes(15_099, []); expect(useCombatStore.getState().health).toBe(full); advanceDungeonRaceAffixes(15_100, []); expect(useCombatStore.getState().health).toBeLessThan(full); expect(useCombatStore.getState().health).toBeGreaterThan(full * 0.6); const hit = useCombatStore.getState().health; advanceDungeonRaceAffixes(15_200, []); expect(useCombatStore.getState().health).toBe(hit); useCombatStore.setState({ playerPosition: [0, 8, 0] }); deadTrash(trashIds[2]); advanceDungeonRaceAffixes(16_000, [trashIds[2]]); advanceDungeonRaceAffixes(21_000, []); expect(useCombatStore.getState().health).toBe(hit); }); it("stacks nearby Last Stand to three and expires after six seconds", () => { start("last-stand"); const survivor = trashIds[0]; const far = trashIds[1]; setMobPosition(survivor, [2, 0, 0]); useCombatStore.getState().registerMob(survivor, { name: "survivor", maxHealth: 100 }); setMobPosition(far, [12, 0, 0]); useCombatStore.getState().registerMob(far, { name: "far", maxHealth: 100 }); for (let index = 0; index < 4; index += 1) { const id = trashIds[index + 2]; deadTrash(id); advanceDungeonRaceAffixes(10_100 + index * 100, [id]); } expect(lastStandStacks(survivor, 10_400)).toBe(3); expect(raceAffixMobDamageMultiplier(survivor, 10_400)).toBe(1.75); expect(raceAffixMobDamageMultiplier(far, 10_400)).toBe(1); expect(raceAffixMobDamageMultiplier(survivor, 16_401)).toBe(1); }); it("limits burning patches to three and ticks only on their floor", () => { start("lingering-ash"); for (let index = 0; index < 4; index += 1) { const id = trashIds[index]; deadTrash(id); advanceDungeonRaceAffixes(10_100 + index * 100, [id]); } expect(useDungeonRaceAffixStore.getState().effects.filter((effect) => effect.kind === "ash")).toHaveLength(3); const full = useCombatStore.getState().health; advanceDungeonRaceAffixes(11_500, []); expect(useCombatStore.getState().health).toBeLessThan(full); useCombatStore.setState({ playerPosition: [0, 8, 0] }); const after = useCombatStore.getState().health; advanceDungeonRaceAffixes(12_500, []); expect(useCombatStore.getState().health).toBe(after); }); it("ticks Ash for eight seconds and Predator for six seconds", () => { start("lingering-ash"); deadTrash(trashIds[0]); advanceDungeonRaceAffixes(10_100, [trashIds[0]]); let previous = useCombatStore.getState().health; for (let second = 1; second <= 8; second += 1) { advanceDungeonRaceAffixes(10_100 + second * 1_000, []); const current = useCombatStore.getState().health; expect(current).toBeLessThan(previous); previous = current; } expect(useDungeonRaceAffixStore.getState().effects.some((effect) => effect.kind === "ash")).toBe(false); clearDungeonRaceAffixes(); useDungeonRaceStore.setState({ seed: "predator-test", affixIds: ["last-stand", "predator-mark"] }); advanceDungeonRaceAffixes(10_000, []); advanceDungeonRaceAffixes(20_000, []); previous = useCombatStore.getState().health; for (let second = 3; second <= 8; second += 1) { advanceDungeonRaceAffixes(20_000 + second * 1_000, []); const current = useCombatStore.getState().health; expect(current).toBeLessThan(previous); previous = current; } expect(useDungeonRaceAffixStore.getState().effects.some((effect) => effect.kind === "predator")).toBe(false); }); it("lets damage defeat an orb and pulses if a later orb expires", () => { start("unstable-orb"); deadTrash(trashIds[0]); advanceDungeonRaceAffixes(10_100, [trashIds[0]]); const orb = activeRaceOrbId(); expect(orb).toBeTruthy(); useCombatStore.getState().damageMob(orb!, 1_000, 10_200); const full = useCombatStore.getState().health; advanceDungeonRaceAffixes(18_100, []); expect(activeRaceOrbId()).toBeNull(); expect(useCombatStore.getState().health).toBe(full); deadTrash(trashIds[1]); advanceDungeonRaceAffixes(25_100, [trashIds[1]]); expect(activeRaceOrbId()).toBeTruthy(); advanceDungeonRaceAffixes(33_100, []); expect(useCombatStore.getState().health).toBeLessThan(full); expect(activeRaceOrbId()).toBeNull(); }); it("requires a pickup before granting eight seconds of party haste", () => { start("risky-boon"); deadTrash(trashIds[0]); advanceDungeonRaceAffixes(10_100, [trashIds[0]]); expect(raceAffixBoonUntil()).toBe(0); advanceDungeonRaceAffixes(10_600, []); expect(raceAffixHasteMultiplier(10_600)).toBe(1.15); expect(raceAffixHasteMultiplier(18_601)).toBe(1); }); it.each([ ["falling-stars", "star"], ["stormcall", "storm"], ["arcane-pulse", "ring"], ["predator-mark", "predator"], ])("creates the %s warning on the ten-second cadence", (movementId, kind) => { start("last-stand", movementId); advanceDungeonRaceAffixes(19_999, []); expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0); advanceDungeonRaceAffixes(20_000, []); const effect = useDungeonRaceAffixStore.getState().effects[0]; expect(effect.kind).toBe(kind); expect(effect.resolvesAt).toBe(22_000); const destination = raceAffixAvoidanceDestination("npc", kind === "ring" ? [5, 0, 0] : [0, 0, 0], 20_100); expect(destination).toBeTruthy(); const distance = Math.hypot(destination![0] - effect.position[0], destination![2] - effect.position[2]); expect(kind === "ring" ? distance < 4 || distance > 7 : distance > effect.radius).toBe(true); }); it.each(["falling-stars", "stormcall"])("retains the %s strike visual without repeating damage", (movementId) => { start("last-stand", movementId); advanceDungeonRaceAffixes(20_000, []); advanceDungeonRaceAffixes(22_000, []); const healthAfterStrike = useCombatStore.getState().health; const effect = useDungeonRaceAffixStore.getState().effects[0]; expect(effect.tickAt).toBe(22_000); expect(effect.expiresAt).toBe(24_800); advanceDungeonRaceAffixes(23_000, []); expect(useCombatStore.getState().health).toBe(healthAfterStrike); expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(1); advanceDungeonRaceAffixes(24_800, []); expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0); }); it("freezes a warning and the next hazard across an offline pause", () => { start("volatile-remains"); deadTrash(trashIds[0]); advanceDungeonRaceAffixes(10_100, [trashIds[0]]); suspendDungeonRaceAffixes(11_000); resumeDungeonRaceAffixes(21_000); expect(useDungeonRaceAffixStore.getState().effects[0].resolvesAt).toBe(25_100); expect(useDungeonRaceAffixStore.getState().nextMovementAt).toBe(30_000); const full = useCombatStore.getState().health; advanceDungeonRaceAffixes(25_099, []); expect(useCombatStore.getState().health).toBe(full); advanceDungeonRaceAffixes(25_100, []); expect(useCombatStore.getState().health).toBeLessThan(full); }); it("damages only the warned ring band on its floor", () => { start("last-stand", "arcane-pulse"); advanceDungeonRaceAffixes(20_000, []); useCombatStore.setState({ playerPosition: [3, 0, 0] }); const full = useCombatStore.getState().health; advanceDungeonRaceAffixes(22_000, []); expect(useCombatStore.getState().health).toBe(full); advanceDungeonRaceAffixes(30_000, []); useCombatStore.setState({ playerPosition: [5, 8, 0] }); advanceDungeonRaceAffixes(32_000, []); expect(useCombatStore.getState().health).toBe(full); useCombatStore.setState({ playerPosition: [0, 0, 0] }); advanceDungeonRaceAffixes(40_000, []); useCombatStore.setState({ playerPosition: [5, 0, 0] }); advanceDungeonRaceAffixes(42_000, []); expect(useCombatStore.getState().health).toBeLessThan(full); }); it("clears live hazards and buffs when the race run ends", () => { start("risky-boon"); deadTrash(trashIds[0]); advanceDungeonRaceAffixes(10_100, [trashIds[0]]); advanceDungeonRaceAffixes(10_600, []); expect(raceAffixBoonUntil()).toBeGreaterThan(0); useDungeonRaceStore.getState().reset(); expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0); expect(raceAffixBoonUntil()).toBe(0); }); });