import { describe, expect, it } from "vitest"; import { freshParty } from "../data"; import { createBaseMotion } from "./shared"; import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool"; import type { BossMechanicContext } from "./types"; import type { BossState, PoolTelegraph, WorldPosition } from "../types"; const POSITIONS: BossMechanicContext["partyPositions"] = { aelia: [0, 4.5], brann: [0, 0], nia: [-3, 2], orin: [3, 2], vale: [0, -2], }; function state(): BossState { return { id: "bulldrome", name: "Bulldrome", maxHp: 500, hp: 500, nextMeleeAt: Number.POSITIVE_INFINITY, }; } function context(time: number, positions = POSITIONS): BossMechanicContext { return { boss: state(), motion: createBaseMotion("bulldrome"), party: freshParty(), partyPositions: structuredClone(positions), time, delta: 0.1, damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }), }; } function memoryTelegraph(): PoolTelegraph { return { id: "test-memory", kind: "memory", name: "Memory Sequence", center: [0, -8], radius: 0, activatesAt: 0, inputStartsAt: 0, expiresAt: 10, damage: 15, targetId: "aelia", sequence: ["triangle", "cross", "circle", "square"], tiles: [ { symbol: "triangle", center: [-3, 2] }, { symbol: "cross", center: [3, 2] }, { symbol: "circle", center: [-3, -2] }, { symbol: "square", center: [3, -2] }, ], inputIndex: 0, resolved: false, hitIds: [], }; } function advanceMemory( time: number, telegraph: PoolTelegraph, party = freshParty(), aeliaPosition: WorldPosition = [0, 4.5], ) { const positions = structuredClone(POSITIONS); positions.aelia = aeliaPosition; const source = context(time, positions); source.party = party; source.motion = { ...source.motion, activeMechanicId: "memory-sequence", poolTelegraphs: [telegraph], }; return advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]); } function soulSiphonTelegraph(): PoolTelegraph { return { id: "test-soul-siphon", kind: "soul-siphon", name: "Soul Siphon", center: [0, -7], radius: 0, activatesAt: 0, expiresAt: Number.POSITIVE_INFINITY, damage: 0, targetId: "aelia", soulSiphon: { targetId: "aelia", ghostPosition: [0, 3.6], wardPosition: [0, -7], wardRadius: SOUL_SIPHON.wardRadius, nextDamageAt: SOUL_SIPHON.tickInterval, tickCount: 0, }, resolved: false, hitIds: [], }; } function advanceSoulSiphon( time: number, telegraph: PoolTelegraph, party = freshParty(), aeliaPosition: WorldPosition = [0, 4.5], ) { const positions = structuredClone(POSITIONS); positions.aelia = aeliaPosition; const source = context(time, positions); source.party = party; source.motion = { ...source.motion, activeMechanicId: "soul-siphon", poolTelegraphs: [telegraph], }; return advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]); } describe("shared boss mechanic pool", () => { it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => { const source = context(0); source.motion.nextMechanicAt = 0; const started = advanceMechanicLoadout(source, [id, id]); expect(started.motion.activeMechanicId).toBe(id); expect(["idle", "move", "attack", "special"]).toContain(bossAnimationCue(started.motion)); }); it("schedules the exact mechanic ID selected by a boss loadout", () => { const source = context(0); source.motion.nextMechanicAt = 0; const started = advanceMechanicLoadout(source, ["meteor-spread", "bull-charge"]); expect(started.motion.activeMechanicId).toBe("meteor-spread"); expect(started.motion.poolTelegraphs).not.toHaveLength(0); expect(started.events[0].message).toContain(":"); expect(bossAnimationCue(started.motion)).toBe("attack"); }); it("splits soak damage across allies inside its indicator", () => { const source = context(0); const activatesAt = 1.5; const soak = { id: "test-soak", kind: "soak" as const, name: "Aetheric Soak", center: [0, 0] as WorldPosition, radius: 2.5, activatesAt, expiresAt: activatesAt + 0.5, damage: 0, totalDamage: 90, minimumParticipants: 3, targetId: "brann" as const, resolved: false, hitIds: [], }; const motion = { ...source.motion, activeMechanicId: "aetheric-soak" as const, poolTelegraphs: [soak] }; const positions = structuredClone(POSITIONS); positions.aelia = [0.2, 0]; positions.brann = [0, 0]; positions.nia = [-0.2, 0]; positions.vale = [0, -4]; const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt }; const resolved = advanceMechanicLoadout(atImpact, ["aetheric-soak", "bull-charge"]); expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30); expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30); expect(resolved.party.find((member) => member.id === "nia")!.hp).toBe(source.party[2].hp - 30); expect(resolved.events[0].message).toContain("splits 90 damage across 3 allies"); }); it("resolves a donut only in its outer ring", () => { const source = context(0); const activatesAt = 1.5; const donut = { id: "test-donut", kind: "donut" as const, name: "Hollow Collapse", center: [0, 0] as WorldPosition, radius: 5, innerRadius: 2, activatesAt, expiresAt: activatesAt + 0.5, damage: 25, resolved: false, hitIds: [], }; const positions = structuredClone(POSITIONS); positions.aelia = [0, 0]; positions.brann = [3, 0]; const atImpact = { ...source, motion: { ...source.motion, activeMechanicId: "hollow-collapse" as const, poolTelegraphs: [donut] }, partyPositions: positions, time: activatesAt, }; const resolved = advanceMechanicLoadout(atImpact, ["hollow-collapse", "bull-charge"]); expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp); expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25); }); it("requires only the healer to cross four tiles in shown order", () => { let current = advanceMemory(1, memoryTelegraph(), freshParty(), [-3, 2]); expect(current.motion.poolTelegraphs[0].inputIndex).toBe(1); current = advanceMemory(1.1, current.motion.poolTelegraphs[0], current.party, [0, 4.5]); current = advanceMemory(1.2, current.motion.poolTelegraphs[0], current.party, [3, 2]); expect(current.motion.poolTelegraphs[0].inputIndex).toBe(2); current = advanceMemory(1.3, current.motion.poolTelegraphs[0], current.party, [0, 4.5]); current = advanceMemory(1.4, current.motion.poolTelegraphs[0], current.party, [-3, -2]); current = advanceMemory(1.5, current.motion.poolTelegraphs[0], current.party, [0, 4.5]); current = advanceMemory(1.6, current.motion.poolTelegraphs[0], current.party, [3, -2]); expect(current.motion.poolTelegraphs).toHaveLength(0); expect(current.events[0].message).toBe("Memory Sequence cleared by healer."); expect(current.party).toEqual(freshParty()); }); it("ignores NPC positions but deals 15 raidwide damage when healer selects a wrong tile", () => { const waiting = advanceMemory(1, memoryTelegraph(), freshParty(), [0, 4.5]); expect(waiting.events).toEqual([]); expect(waiting.motion.poolTelegraphs[0].inputIndex).toBe(0); const failed = advanceMemory(1, memoryTelegraph(), freshParty(), [3, 2]); for (const member of failed.party) { expect(member.hp).toBe(member.maxHp - 15); } expect(failed.events[0].message).toContain("15 raidwide damage"); }); it("drains the healer until they enter the opposite cleansing ward", () => { const started = soulSiphonTelegraph(); const firstTick = advanceSoulSiphon(SOUL_SIPHON.tickInterval + 0.01, started); expect(firstTick.party.find((member) => member.id === "aelia")!.hp).toBe( freshParty()[0].hp - SOUL_SIPHON.tickDamage, ); expect(firstTick.motion.poolTelegraphs[0].soulSiphon?.ghostPosition).not.toEqual(started.soulSiphon?.ghostPosition); const wardPosition = firstTick.motion.poolTelegraphs[0].soulSiphon!.wardPosition; const cleansed = advanceSoulSiphon(1, firstTick.motion.poolTelegraphs[0], firstTick.party, wardPosition); expect(cleansed.motion.poolTelegraphs).toHaveLength(0); expect(cleansed.events[0].message).toContain("cleansing ward"); }); it("deals half-strength ramping Soul Siphon damage and caps the ramp", () => { const result = advanceSoulSiphon(SOUL_SIPHON.tickInterval * 6 + 0.01, soulSiphonTelegraph()); const aelia = result.party.find((member) => member.id === "aelia")!; expect(SOUL_SIPHON.tickDamage).toBe(3); expect(SOUL_SIPHON.tickRamp).toBe(1); expect(aelia.hp).toBe(freshParty()[0].hp - (3 + 4 + 5 + 6 + 7 + 7)); expect(result.motion.poolTelegraphs[0].soulSiphon?.tickCount).toBe(6); }); });