Files
healer-man/src/game/dungeonRaceAffixRuntime.test.ts
T
2026-09-27 18:46:30 -04:00

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