248 lines
12 KiB
TypeScript
248 lines
12 KiB
TypeScript
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<string>();
|
|
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);
|
|
});
|
|
});
|