717 lines
31 KiB
TypeScript
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);
|
|
});
|
|
});
|