Files
i-want-to-heal-mmo/src/game/store.test.ts
T
2026-07-12 22:10:12 -04:00

717 lines
31 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
import { barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
import type { RunBuffRanks } from "./types";
function startBuffedEncounter(runBuffRanks: RunBuffRanks) {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
useGameStore.setState({ runBuffRanks, runModifiers: compileRunModifiers(runBuffRanks) });
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
}
function testDebuff(id: string) {
return { id, name: id, expiresAt: 10, nextTickAt: 9, tickDamage: 1 };
}
describe("Disc Priest combat simulation", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
useGameStore.getState().startEncounter();
});
it("resolves Mend after a 0.5 second cast with no cooldown", () => {
const party = useGameStore.getState().party.map((member) =>
member.id === "nia" ? { ...member, hp: 40 } : member,
);
useGameStore.setState({ party });
useGameStore.getState().selectMember("nia");
expect(useGameStore.getState().castAbility("mend")).toBe(true);
useGameStore.getState().tick(0.4);
expect(useGameStore.getState().party.find((member) => member.id === "nia")?.hp).toBe(40);
useGameStore.getState().tick(0.11);
const state = useGameStore.getState();
expect(state.party.find((member) => member.id === "nia")?.hp).toBe(78);
expect(state.activeCast).toBeNull();
expect(state.cooldowns.mend).toBe(0);
expect(state.castAbility("mend")).toBe(true);
});
it("uses the rebalanced Priest mana costs", () => {
expect(Object.fromEntries(Object.entries(HEALER_CLASSES.priest.abilities).map(([id, ability]) => [id, ability.mana]))).toEqual({
mend: 5,
renew: 7,
shield: 8,
purify: 5,
radiance: 12,
barrier: 10,
});
});
it("uses a three-second Purify cooldown for every healer class", () => {
expect(HEALER_CLASSES.priest.abilities.purify.cooldown).toBe(3);
expect(HEALER_CLASSES.druid.abilities.purify.cooldown).toBe(3);
expect(HEALER_CLASSES.shaman.abilities.purify.cooldown).toBe(3);
});
it("configures placeholder healer kits and class-owned inventory", () => {
const inventory = createClassInventory("druid");
useGameStore.getState().configureHealer("druid", "Aelia", inventory);
const state = useGameStore.getState();
expect(state.healerClassId).toBe("druid");
expect(state.party[0].className).toBe("Restoration Druid");
expect(state.party[0].name).toBe("Aelia");
expect(state.inventory).toEqual(inventory);
expect(HEALER_CLASSES.druid.abilities.mend.name).toBe("Healing Touch");
expect(HEALER_CLASSES.shaman.abilities.radiance.name).toBe("Chain Heal");
});
it("ticks Renew once per second for eight seconds", () => {
const party = useGameStore.getState().party.map((member) =>
member.id === "brann" ? { ...member, hp: 70 } : member,
);
useGameStore.setState({ party });
useGameStore.getState().castAbility("renew");
for (let index = 0; index < 8; index += 1) useGameStore.getState().tick(1);
const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
expect(brann.renewExpiresAt).toBe(0);
expect(brann.hp).toBeGreaterThan(70);
});
it("gives Renew no individual cooldown", () => {
expect(useGameStore.getState().castAbility("renew")).toBe(true);
expect(useGameStore.getState().cooldowns.renew).toBe(0);
useGameStore.getState().tick(0.5);
expect(useGameStore.getState().castAbility("renew")).toBe(true);
expect(useGameStore.getState().cooldowns.renew).toBe(0);
});
it("blocks all abilities during the shared 0.5 second global cooldown", () => {
expect(useGameStore.getState().castAbility("renew")).toBe(true);
expect(useGameStore.getState().castAbility("shield")).toBe(false);
useGameStore.getState().tick(0.49);
expect(useGameStore.getState().castAbility("shield")).toBe(false);
useGameStore.getState().tick(0.02);
expect(useGameStore.getState().castAbility("shield")).toBe(true);
});
it("uses blue absorption before health", () => {
useGameStore.getState().castAbility("shield");
useGameStore.getState().tick(2);
const brann = useGameStore.getState().party.find((member) => member.id === "brann")!;
expect(brann.hp).toBe(150);
expect(brann.absorb).toBe(21);
});
it("Purify removes Ember Brand from selected ally", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, mechanicCount: 3, nextMechanicAt: state.time },
}));
useGameStore.getState().tick(0.1);
const branded = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Ember Brand"))!;
expect(branded.debuffs).toHaveLength(1);
useGameStore.getState().selectMember(branded.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().party.find((member) => member.id === branded.id)?.debuffs).toHaveLength(0);
});
it("Radiance heals every living party member", () => {
useGameStore.setState({
party: useGameStore.getState().party.map((member) => ({ ...member, hp: member.hp - 30 })),
});
useGameStore.getState().castAbility("radiance");
for (const member of useGameStore.getState().party) {
expect(member.hp).toBe(member.maxHp - 8);
}
});
it("reduces damage by 30% for party members inside Barrier", () => {
useGameStore.getState().setPlayerPosition([0, -3.7]);
useGameStore.setState((state) => ({
boss: { ...state.boss },
}));
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
useGameStore.getState().tick(2);
const state = useGameStore.getState();
expect(state.party.find((member) => member.id === "brann")?.hp).toBeCloseTo(139.5, 4);
expect(barrierProtects(state.partyPositions.brann, state.barrier, state.time)).toBe(true);
expect(state.cooldowns.barrier).toBe(60);
});
it("expires Barrier after eight seconds", () => {
useGameStore.getState().castAbility("barrier");
const barrier = useGameStore.getState().barrier;
expect(barrierProtects(barrier.center, barrier, 7.99)).toBe(true);
expect(barrierProtects(barrier.center, barrier, 8)).toBe(false);
});
it("keeps ranged allies stable while Vale holds behind the boss", () => {
const start = structuredClone(useGameStore.getState().partyPositions);
const bossPosition = useGameStore.getState().bossMotion.position;
useGameStore.getState().tick(1);
const moved = useGameStore.getState().partyPositions;
expect(moved.aelia).toEqual(start.aelia);
expect(moved.brann).toEqual(start.brann);
expect(moved.nia).toEqual(start.nia);
expect(moved.orin).toEqual(start.orin);
expect(moved.brann[1]).toBeGreaterThan(bossPosition[1]);
expect(moved.vale[1]).toBeLessThan(bossPosition[1]);
});
it("freezes authoritative simulation while paused", () => {
useGameStore.getState().tick(1);
const before = useGameStore.getState();
useGameStore.getState().setPaused(true);
useGameStore.getState().tick(5);
const paused = useGameStore.getState();
expect(paused.time).toBe(before.time);
expect(paused.party).toEqual(before.party);
expect(paused.boss.hp).toBe(before.boss.hp);
expect(paused.castAbility("renew")).toBe(false);
});
it("does not publish unchanged player positions while idle", () => {
let updates = 0;
const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
const start = useGameStore.getState().playerPosition;
useGameStore.getState().setPlayerPosition([start[0], start[1]]);
expect(updates).toBe(0);
useGameStore.getState().setPlayerPosition([start[0] + 0.25, start[1]]);
expect(updates).toBe(1);
unsubscribe();
});
it("telegraphs, executes, and recovers from a Bull charge", () => {
while (useGameStore.getState().bossMotion.mode !== "telegraph") useGameStore.getState().tick(0.1);
const telegraph = useGameStore.getState().bossMotion;
expect(telegraph.mode).toBe("telegraph");
const targetId = telegraph.chargeTargetId;
const midpoint: [number, number] = [
(telegraph.chargeStart[0] + telegraph.chargeEnd[0]) / 2,
(telegraph.chargeStart[1] + telegraph.chargeEnd[1]) / 2,
];
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, [targetId]: midpoint },
party: state.party.map((member) => member.id === targetId ? { ...member, knockedUntil: state.time + 10 } : member),
}));
while (useGameStore.getState().bossMotion.mode === "telegraph") useGameStore.getState().tick(0.1);
for (let step = 0; step < 30 && !useGameStore.getState().bossMotion.chargeHitIds.includes(targetId); step += 1) {
useGameStore.getState().tick(0.05);
}
const hitState = useGameStore.getState();
expect(hitState.bossMotion.chargeHitIds).toContain(targetId);
expect(hitState.party.find((member) => member.id === targetId)!.knockedUntil - hitState.time).toBeCloseTo(0.75, 1);
for (let step = 0; step < 100 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
useGameStore.getState().tick(0.1);
}
const recovered = useGameStore.getState();
expect(recovered.bossMotion.mode).toBe("holding");
expect(recovered.bossMotion.nextMechanicAt).toBeGreaterThan(recovered.time);
});
it("moves every mobile AI party member out of the charge lane before impact", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
}));
while (useGameStore.getState().bossMotion.mode !== "telegraph") {
useGameStore.getState().tick(0.1);
}
const warning = useGameStore.getState().bossMotion;
const exposedAtWarning = (["brann", "nia", "orin", "vale"] as const).filter((memberId) =>
pointToSegmentDistance(
useGameStore.getState().partyPositions[memberId],
warning.chargeStart,
warning.chargeEnd,
) <= BULL_CHARGE.hitRadius,
);
expect(exposedAtWarning).toContain(warning.chargeTargetId);
while (useGameStore.getState().bossMotion.mode === "telegraph") {
useGameStore.getState().tick(0.1);
}
const charge = useGameStore.getState();
for (const memberId of exposedAtWarning) {
expect(pointToSegmentDistance(
charge.partyPositions[memberId],
charge.bossMotion.chargeStart,
charge.bossMotion.chargeEnd,
)).toBeGreaterThan(BULL_CHARGE.hitRadius);
}
while (useGameStore.getState().bossMotion.mode === "charging") {
useGameStore.getState().tick(0.05);
}
const hitIds = useGameStore.getState().bossMotion.chargeHitIds;
for (const memberId of exposedAtWarning) expect(hitIds).not.toContain(memberId);
});
it("marks a stack target after three charges and splits 200 pounce damage", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
playerPosition: [0, 0],
partyPositions: {
aelia: [0, 0],
brann: [0, 0],
nia: [0, 0],
orin: [0, 0],
vale: [0, 0],
},
bossMotion: { ...state.bossMotion, activeMechanicId: null, mode: "holding", position: [0, -1], mechanicCount: 1, nextMechanicAt: state.time },
}));
const startingHp = Object.fromEntries(useGameStore.getState().party.map((member) => [member.id, member.hp]));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().bossMotion.mode).toBe("stacking");
expect(useGameStore.getState().bossMotion.pounceTargetId).toBeTruthy();
expect(useGameStore.getState().bossMotion.phaseEndsAt - useGameStore.getState().time).toBeCloseTo(5, 2);
for (let step = 0; step < 70 && useGameStore.getState().bossMotion.mode === "stacking"; step += 1) {
useGameStore.getState().tick(0.1);
}
expect(useGameStore.getState().bossMotion.mode).toBe("pouncing");
for (let step = 0; step < 20 && useGameStore.getState().bossMotion.mode === "pouncing"; step += 1) {
useGameStore.getState().tick(0.05);
}
const impacted = useGameStore.getState();
expect(impacted.bossMotion.mode).toBe("holding");
for (const member of impacted.party) {
expect(member.hp).toBeCloseTo(startingHp[member.id] - 28, 3);
}
expect(impacted.partyCombat.tankAura.expiresAt).toBeGreaterThan(impacted.time);
});
});
describe("Broodfang encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
});
it("lets modular party behavior spread tethered allies until Binding Web snaps", () => {
while (useGameStore.getState().bossMotion.mode !== "tethering") useGameStore.getState().tick(0.1);
const tether = useGameStore.getState().bossMotion;
expect(tether.tetherIds).toEqual(["brann", "vale"]);
const [first, second] = tether.tetherIds;
expect(Math.hypot(
useGameStore.getState().partyPositions[first][0] - useGameStore.getState().partyPositions[second][0],
useGameStore.getState().partyPositions[first][1] - useGameStore.getState().partyPositions[second][1],
)).toBeLessThan(tether.tetherBreakDistance);
for (let step = 0; step < 50 && useGameStore.getState().bossMotion.mode === "tethering"; step += 1) {
useGameStore.getState().tick(0.1);
}
expect(useGameStore.getState().bossMotion.mode).toBe("holding");
expect(useGameStore.getState().bossMotion.tetherIds).toEqual([]);
});
it("drops a persistent venom pool when Widow Venom is purified", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
}));
useGameStore.getState().tick(0.05);
const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
expect(poisoned).toBeDefined();
useGameStore.getState().selectMember(poisoned.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
const state = useGameStore.getState();
expect(state.party.find((member) => member.id === poisoned.id)?.debuffs).toEqual([]);
expect(state.bossMotion.hazards).toHaveLength(1);
expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] });
});
it("repeatedly damages the player while they remain in a venom pool", () => {
useGameStore.getState().setPlayerPosition([0, 0]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: {
...dropVenomPool(state.bossMotion, "aelia", [0, 0], state.time),
nextMechanicAt: 999,
},
}));
const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.3);
const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp;
expect(firstTickHp).toBe(startingHp - VENOM_PURGE.poolDamage);
expect(secondTickHp).toBe(firstTickHp - VENOM_PURGE.poolDamage);
useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(1);
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
});
it("returns to the room center when the tank is displaced", () => {
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, brann: [3, 1] },
bossMotion: { ...state.bossMotion, position: [5, 5], nextMechanicAt: 999 },
}));
const start = useGameStore.getState().bossMotion.position;
useGameStore.getState().tick(0.5);
const result = useGameStore.getState().bossMotion.position;
expect(Math.hypot(result[0] - ARENA_CENTER[0], result[1] - ARENA_CENTER[1])).toBeLessThan(
Math.hypot(start[0] - ARENA_CENTER[0], start[1] - ARENA_CENTER[1]),
);
});
});
describe("PVE dual-boss encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["broodfang-spider", "tempestscale-dragon"]);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, nextMeleeAt: 999 } })),
}));
});
it("runs two distinct bosses concurrently and requires both to fall", () => {
const initial = useGameStore.getState();
expect(initial.boss.id).toBe("broodfang-spider");
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["tempestscale-dragon"]);
expect(initial.bossMotion.position[0]).toBeLessThan(initial.additionalBosses[0].motion.position[0]);
useGameStore.getState().tick(1);
const damaged = useGameStore.getState();
const primaryDamage = damaged.partyDamageEvents
.filter((event) => event.targetInstanceId === `boss-0-${damaged.boss.id}`)
.reduce((sum, event) => sum + event.amount, 0);
const secondaryDamage = damaged.partyDamageEvents
.filter((event) => event.targetInstanceId === damaged.additionalBosses[0].instanceId)
.reduce((sum, event) => sum + event.amount, 0);
expect(primaryDamage + secondaryDamage).toBeGreaterThan(0);
expect(damaged.boss.hp).toBeCloseTo(damaged.boss.maxHp - primaryDamage, 4);
expect(damaged.additionalBosses[0].boss.hp).toBeCloseTo(damaged.additionalBosses[0].boss.maxHp - secondaryDamage, 4);
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 1 } })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
useGameStore.getState().tick(1);
expect(useGameStore.getState().phase).toBe("victory");
});
});
describe("Roguelike ability buffs", () => {
it("reduces Mend cost and cast time while echoing to lowest-health allies", () => {
startBuffedEncounter({ "mend-echo": 2, "mend-efficiency": 3, "mend-cast-speed": 3 });
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "aelia" ? 90 : member.id === "brann" ? 60 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : 30,
})),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("mend")).toBe(true);
expect(useGameStore.getState().mana).toBe(97);
expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3);
useGameStore.getState().tick(0.22);
const party = useGameStore.getState().party;
expect(party.find((member) => member.id === "brann")?.hp).toBe(98);
expect(party.find((member) => member.id === "nia")?.hp).toBe(29);
expect(party.find((member) => member.id === "orin")?.hp).toBe(39);
expect(party.find((member) => member.id === "vale")?.hp).toBe(30);
});
it("spreads longer, stronger Renew effects without recursive targeting", () => {
startBuffedEncounter({ "renew-spread": 2, "renew-duration": 2, "renew-potency": 2 });
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "brann" ? 50 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
})),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("renew")).toBe(true);
let party = useGameStore.getState().party;
expect(party.filter((member) => member.renewExpiresAt === 12).map((member) => member.id)).toEqual(["brann", "nia", "orin"]);
useGameStore.getState().tick(1.01);
party = useGameStore.getState().party;
expect(party.find((member) => member.id === "brann")?.hp).toBeCloseTo(59.8);
expect(party.find((member) => member.id === "nia")?.hp).toBeCloseTo(19.8);
expect(party.find((member) => member.id === "orin")?.hp).toBeCloseTo(29.8);
});
it("strengthens and echoes Shield to deterministic secondary targets", () => {
startBuffedEncounter({ "shield-echo": 2, "shield-potency": 2 });
useGameStore.setState((state) => ({
party: state.party.map((member) => ({
...member,
hp: member.id === "brann" ? 70 : member.id === "nia" ? 10 : member.id === "orin" ? 20 : member.hp,
})),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("shield")).toBe(true);
const party = useGameStore.getState().party;
expect(party.find((member) => member.id === "brann")?.absorb).toBe(54);
expect(party.find((member) => member.id === "nia")?.absorb).toBe(27);
expect(party.find((member) => member.id === "orin")?.absorb).toBe(27);
});
it("chains Purify and applies triggered Renew and half-strength Shield", () => {
startBuffedEncounter({ "purify-renew": 1, "purify-shield": 1, "purify-chain": 1 });
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "brann"
? { ...member, hp: 70, debuffs: [testDebuff("tank-mark")] }
: member.id === "nia"
? { ...member, hp: 10, debuffs: [testDebuff("ranger-mark")] }
: member.id === "orin"
? { ...member, hp: 20, debuffs: [testDebuff("mage-mark")] }
: member),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("purify")).toBe(true);
const party = useGameStore.getState().party;
for (const id of ["brann", "nia"] as const) {
const member = party.find((candidate) => candidate.id === id)!;
expect(member.debuffs).toEqual([]);
expect(member.renewExpiresAt).toBe(8);
expect(member.absorb).toBe(18);
}
expect(party.find((member) => member.id === "orin")?.debuffs).toHaveLength(1);
});
it("applies Radiance cooldown, party Renew, and party absorption", () => {
startBuffedEncounter({ "radiance-cooldown": 3, "radiance-renew": 1, "radiance-shield": 2 });
useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 30) })) }));
expect(useGameStore.getState().castAbility("radiance")).toBe(true);
const state = useGameStore.getState();
expect(state.cooldowns.radiance).toBeCloseTo(14 * 0.8 ** 3);
expect(state.party.every((member) => member.renewExpiresAt === 8)).toBe(true);
expect(state.party.every((member) => member.absorb === 18)).toBe(true);
});
it("extends, quickens, and pulses healing from Barrier", () => {
startBuffedEncounter({ "barrier-cooldown": 3, "barrier-duration": 3, "barrier-regen": 3 });
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 50 } : member),
}));
expect(useGameStore.getState().castAbility("barrier")).toBe(true);
expect(useGameStore.getState().cooldowns.barrier).toBeCloseTo(60 * 0.8 ** 3);
expect(useGameStore.getState().barrier.expiresAt).toBe(14);
useGameStore.getState().tick(1.01);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBe(59);
expect(useGameStore.getState().barrier.nextHealAt).toBe(2);
});
it("reduces incoming damage while absorption is present", () => {
startBuffedEncounter({ "shield-guard": 3 });
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "aelia" ? {
...member,
hp: 50,
absorb: 100,
debuffs: [{ id: "pulse", name: "Pulse", expiresAt: 2, nextTickAt: 0.5, tickDamage: 10 }],
} : member),
}));
useGameStore.getState().tick(1);
expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.absorb).toBeCloseTo(92.4);
});
});
describe("Roguelike rounds", () => {
beforeEach(() => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
"roguelike",
);
useGameStore.getState().startEncounter();
});
it("blocks progression for a buff, then starts round two with new bosses at 110% base HP", () => {
const roundOne = useGameStore.getState();
const previousBossIds = [roundOne.boss.id, roundOne.additionalBosses[0].boss.id];
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("intermission");
const intermissionTime = useGameStore.getState().time;
useGameStore.getState().tick(10);
expect(useGameStore.getState().phase).toBe("intermission");
expect(useGameStore.getState().round).toBe(1);
expect(useGameStore.getState().time).toBe(intermissionTime);
const chosenBuffId = useGameStore.getState().draftBuffIds[0]!;
expect(chosenBuffId).toBeDefined();
expect(useGameStore.getState().chooseRunBuff(chosenBuffId)).toBe(true);
const roundTwo = useGameStore.getState();
const nextBossIds = [roundTwo.boss.id, roundTwo.additionalBosses[0].boss.id];
expect(roundTwo.phase).toBe("combat");
expect(roundTwo.round).toBe(2);
expect(roundTwo.runBuffRanks[chosenBuffId]).toBe(1);
expect(nextBossIds.every((bossId) => !previousBossIds.includes(bossId))).toBe(true);
expect(roundTwo.boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.boss.id].maxHp * 1.1));
expect(roundTwo.additionalBosses[0].boss.maxHp).toBe(Math.round(BOSS_DEFINITIONS[roundTwo.additionalBosses[0].boss.id].maxHp * 1.1));
expect(roundTwo.party[0].maxHp).toBe(100);
});
it("rejects buff claims outside intermission", () => {
expect(useGameStore.getState().chooseRunBuff("mend-efficiency")).toBe(false);
expect(useGameStore.getState().round).toBe(1);
});
it("offers explicit continuation after every buff reaches maximum rank", () => {
const maxedRanks = Object.fromEntries(RUN_BUFF_ORDER.map((id) => [id, RUN_BUFFS[id].maxRank])) as RunBuffRanks;
useGameStore.setState({
phase: "intermission",
runBuffRanks: maxedRanks,
runModifiers: compileRunModifiers(maxedRanks),
draftBuffIds: [],
selectedRunBuffId: null,
});
expect(useGameStore.getState().continueRoguelikeRound()).toBe(true);
expect(useGameStore.getState().phase).toBe("combat");
expect(useGameStore.getState().round).toBe(2);
expect(useGameStore.getState().runBuffRanks).toEqual(maxedRanks);
expect(useGameStore.getState().draftBuffIds).toEqual([]);
expect(useGameStore.getState().continueRoguelikeRound()).toBe(false);
});
});
describe("shared arena boundary", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"));
useGameStore.getState().startEncounter();
});
it("constrains player, party, and boss positions to the same room", () => {
useGameStore.getState().setPlayerPosition([100, 100]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, position: [100, -100] },
partyPositions: {
...state.partyPositions,
brann: [50, 50],
nia: [-50, 50],
orin: [50, -50],
vale: [-50, -50],
},
}));
useGameStore.getState().tick(0.1);
const state = useGameStore.getState();
expect(isInsideArena(state.playerPosition)).toBe(true);
for (const position of Object.values(state.partyPositions)) expect(isInsideArena(position)).toBe(true);
expect(isInsideArena(state.bossMotion.position)).toBe(true);
});
});
describe("Tempestscale encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "tempestscale-dragon");
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
});
it("rotates Searing Sweep while AI party behavior moves to safe flanks", () => {
while (useGameStore.getState().bossMotion.mode !== "breath_telegraph") useGameStore.getState().tick(0.1);
expect(useGameStore.getState().bossMotion.breathEndAngle).not.toBe(useGameStore.getState().bossMotion.breathStartAngle);
for (let step = 0; step < 80 && useGameStore.getState().bossMotion.mode !== "holding"; step += 1) {
useGameStore.getState().tick(0.1);
}
const hitIds = useGameStore.getState().bossMotion.mechanicHitIds;
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hitIds).not.toContain(memberId);
});
it("creates three staggered Skyfall impacts and moves AI clear", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
}));
useGameStore.getState().tick(0.05);
const warning = useGameStore.getState();
expect(warning.bossMotion.mode).toBe("skyfall");
expect(warning.bossMotion.hazards).toHaveLength(3);
expect(warning.bossMotion.hazards[1].activatesAt).toBeGreaterThan(warning.bossMotion.hazards[0].activatesAt);
for (let step = 0; step < 45; step += 1) useGameStore.getState().tick(0.1);
expect(useGameStore.getState().bossMotion.hazards).toHaveLength(3);
for (const hazard of useGameStore.getState().bossMotion.hazards) {
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(hazard.hitIds).not.toContain(memberId);
}
});
it("deals repeated Searing Sweep damage until the exposed player moves out", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
partyPositions: { ...state.partyPositions, aelia: [0, 1] },
bossMotion: {
...state.bossMotion,
activeMechanicId: "storm-breath",
mode: "breath_sweeping",
position: [0, -2.8],
phaseStartedAt: state.time,
phaseEndsAt: state.time + 3,
breathAngle: 0,
breathStartAngle: 0,
breathEndAngle: 0,
mechanicHitIds: [],
mechanicNextDamageAt: {},
},
}));
const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.1);
const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.5);
const secondTickHp = useGameStore.getState().party[0].hp;
expect(firstTickHp).toBeLessThan(startingHp);
expect(secondTickHp).toBeLessThan(firstTickHp);
useGameStore.getState().setPlayerPosition([6, 6]);
useGameStore.getState().tick(0.5);
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
});
});