262 lines
9.1 KiB
TypeScript
262 lines
9.1 KiB
TypeScript
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);
|
|
});
|
|
});
|