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