Release v0.1.4 2026-07-12

This commit is contained in:
Warren H
2026-07-12 13:49:46 -04:00
parent b48b3a4f8f
commit bef71d391a
803 changed files with 3800 additions and 358322 deletions
+248
View File
@@ -0,0 +1,248 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import { createBaseMotion } from "./shared";
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool";
import type { BossMechanicContext, BossMechanicResult } 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,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
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 result(contextValue: BossMechanicContext): BossMechanicResult {
return { boss: contextValue.boss, motion: contextValue.motion, party: contextValue.party, events: [] };
}
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,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
}
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,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
}
describe("shared boss mechanic pool", () => {
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => {
const source = context(POOLED_MECHANIC_TIMING.firstAt);
const started = advancePooledBossMechanics(source, result(source));
expect(started.motion.mode).toBe("holding");
expect(started.motion.poolTelegraphs).not.toHaveLength(0);
expect(started.events[0].message).toContain(":");
expect(started.motion.nextPoolMechanicAt).toBe(Number.POSITIVE_INFINITY);
});
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, nextPoolMechanicAt: Number.POSITIVE_INFINITY, 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 = advancePooledBossMechanics(atImpact, result(atImpact));
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, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] },
partyPositions: positions,
time: activatesAt,
};
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
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");
});
});