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
+584
View File
@@ -0,0 +1,584 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const BOSS_MECHANIC_POOL = [
{
id: "meteor-spread",
name: "Meteor Spread",
instruction: "Break formation before the marked circles land.",
},
{
id: "hollow-collapse",
name: "Hollow Collapse",
instruction: "Move inside the inner safe circle.",
},
{
id: "aetheric-soak",
name: "Aetheric Soak",
instruction: "Stack in the marked circle to split the hit.",
},
{
id: "prism-beam",
name: "Prism Beam",
instruction: "Clear the marked beam lane.",
},
{
id: "memory-sequence",
name: "Memory Sequence",
instruction: "Healer only: watch four symbols, then cross matching tiles in order.",
},
{
id: "soul-siphon",
name: "Soul Siphon",
instruction: "Healer only: run through the gold cleansing ward before the shade drains you.",
},
] as const;
export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"];
export const POOLED_MECHANIC_TIMING = {
firstAt: 15,
repeatDelay: 26,
activeDuration: 0.42,
warningDuration: 1.4,
} as const;
export const MEMORY_SEQUENCE = {
sequenceLength: 4,
flashDuration: 0.68,
inputDuration: 7,
tileSize: 2.15,
raidwideDamage: 15,
} as const;
export const SOUL_SIPHON = {
wardRadius: 1.3,
wardDistance: ARENA_RADIUS - 1.05,
ghostSpeed: 2.6,
tickInterval: 0.7,
tickDamage: 6,
tickRamp: 2,
} as const;
export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
triangle: { label: "Triangle", color: "#ff4d55" },
cross: { label: "Cross", color: "#4fa8ff" },
circle: { label: "Circle", color: "#65d67a" },
square: { label: "Square", color: "#ffd34d" },
};
const MEMORY_TILE_LAYOUT: readonly { symbol: MemorySymbolId; center: WorldPosition }[] = [
{ symbol: "triangle", center: [-2.35, 1.35] },
{ symbol: "cross", center: [2.35, 1.35] },
{ symbol: "circle", center: [-2.35, -3.15] },
{ symbol: "square", center: [2.35, -3.15] },
];
const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
["triangle", "cross", "circle", "square"],
["circle", "triangle", "square", "cross"],
["square", "circle", "cross", "triangle"],
["cross", "square", "triangle", "circle"],
];
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
function bossPoolOffset(bossId: string) {
let value = 0;
for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length;
return value;
}
function liveTarget(party: PartyMember[], targetId: MemberId) {
return party.some((member) => member.id === targetId && member.hp > 0)
? targetId
: party.find((member) => member.hp > 0)?.id ?? targetId;
}
function circleTelegraph({
id,
kind,
name,
center,
radius,
innerRadius,
activatesAt,
damage,
targetId,
}: {
id: string;
kind: "spread" | "donut" | "soak";
name: string;
center: WorldPosition;
radius: number;
innerRadius?: number;
activatesAt: number;
damage: number;
targetId?: MemberId;
}): PoolTelegraph {
return {
id,
kind,
name,
center: [center[0], center[1]],
radius,
innerRadius,
activatesAt,
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
damage,
targetId,
resolved: false,
hitIds: [],
};
}
function beamTelegraph(id: string, center: WorldPosition, target: WorldPosition, activatesAt: number): PoolTelegraph {
const angle = angleTo(center, target);
const halfLength = 9.2;
const dx = Math.sin(angle) * halfLength;
const dz = Math.cos(angle) * halfLength;
return {
id,
kind: "beam",
name: "Prism Beam",
center: [center[0], center[1]],
radius: 0,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: 1.45,
activatesAt,
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
damage: 29,
resolved: false,
hitIds: [],
};
}
function memorySequenceTelegraph(id: string, bossPosition: WorldPosition, count: number, inputStartsAt: number): PoolTelegraph {
const sequence = MEMORY_SEQUENCES[count % MEMORY_SEQUENCES.length];
return {
id,
kind: "memory",
name: "Memory Sequence",
center: [bossPosition[0], bossPosition[1]],
radius: 0,
activatesAt: inputStartsAt,
inputStartsAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration,
expiresAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration + MEMORY_SEQUENCE.inputDuration,
damage: MEMORY_SEQUENCE.raidwideDamage,
targetId: "aelia",
sequence: [...sequence],
tiles: MEMORY_TILE_LAYOUT.map((tile) => ({ symbol: tile.symbol, center: [tile.center[0], tile.center[1]] })),
inputIndex: 0,
resolved: false,
hitIds: [],
};
}
function oppositeWardPosition(healerPosition: WorldPosition, count: number): WorldPosition {
const offsetX = healerPosition[0] - ARENA_CENTER[0];
const offsetZ = healerPosition[1] - ARENA_CENTER[1];
const offsetLength = Math.hypot(offsetX, offsetZ);
const fallbackAngle = count % 2 === 0 ? 0 : Math.PI / 2;
const directionX = offsetLength > 0.05 ? -offsetX / offsetLength : Math.sin(fallbackAngle);
const directionZ = offsetLength > 0.05 ? -offsetZ / offsetLength : Math.cos(fallbackAngle);
return clampToArena([
ARENA_CENTER[0] + directionX * SOUL_SIPHON.wardDistance,
ARENA_CENTER[1] + directionZ * SOUL_SIPHON.wardDistance,
], 0.25);
}
function soulSiphonTelegraph(id: string, healerPosition: WorldPosition, count: number, time: number): PoolTelegraph {
const wardPosition = oppositeWardPosition(healerPosition, count);
return {
id,
kind: "soul-siphon",
name: "Soul Siphon",
center: [wardPosition[0], wardPosition[1]],
radius: 0,
activatesAt: time,
expiresAt: Number.POSITIVE_INFINITY,
damage: 0,
targetId: "aelia",
soulSiphon: {
targetId: "aelia",
ghostPosition: clampToArena([healerPosition[0] - 0.85, healerPosition[1] + 0.85], 0.2),
wardPosition,
wardRadius: SOUL_SIPHON.wardRadius,
nextDamageAt: time + SOUL_SIPHON.tickInterval,
tickCount: 0,
},
resolved: false,
hitIds: [],
};
}
function beginPoolMechanic(
motion: BossMotionState,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
time: number,
) {
const count = motion.poolMechanicCount + 1;
const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length];
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
let telegraphs: PoolTelegraph[];
let targetId: MemberId | undefined;
if (entry.id === "meteor-spread") {
const targets = [0, 1, 2].map((offset) => liveTarget(party, TARGET_ORDER[(count + offset) % TARGET_ORDER.length]));
telegraphs = targets.map((id, index) => circleTelegraph({
id: `pool-spread-${count}-${index}`,
kind: "spread",
name: "Meteor Spread",
center: positions[id],
radius: 1.75,
activatesAt: activatesAt + index * 0.42,
damage: 24,
targetId: id,
}));
targetId = targets[0];
} else if (entry.id === "hollow-collapse") {
telegraphs = [circleTelegraph({
id: `pool-donut-${count}`,
kind: "donut",
name: "Hollow Collapse",
center: motion.position,
radius: 7.25,
innerRadius: 2.15,
activatesAt,
damage: 27,
})];
} else if (entry.id === "aetheric-soak") {
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
const soak = circleTelegraph({
id: `pool-soak-${count}`,
kind: "soak",
name: "Aetheric Soak",
center: positions[targetId],
radius: 2.25,
activatesAt: time + 1.75,
damage: 0,
targetId,
});
soak.totalDamage = 78;
soak.minimumParticipants = 3;
telegraphs = [soak];
} else if (entry.id === "memory-sequence") {
targetId = "aelia";
telegraphs = [memorySequenceTelegraph(`pool-memory-${count}`, motion.position, count, time)];
} else if (entry.id === "soul-siphon") {
targetId = "aelia";
telegraphs = [soulSiphonTelegraph(`pool-soul-siphon-${count}`, positions.aelia, count, time)];
} else {
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
telegraphs = [beamTelegraph(`pool-beam-${count}`, motion.position, positions[targetId], activatesAt)];
}
return {
motion: {
...motion,
poolMechanicCount: count,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: telegraphs,
},
event: {
at: time,
message: `${entry.name}: ${entry.instruction}`,
tone: "danger" as const,
pulseKind: "boss" as const,
targetId,
},
};
}
function memoryTileAt(telegraph: PoolTelegraph, position: WorldPosition) {
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
return telegraph.tiles?.find((tile) =>
Math.abs(position[0] - tile.center[0]) <= halfSize && Math.abs(position[1] - tile.center[1]) <= halfSize,
);
}
function failMemorySequence(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: `${telegraph.name} fails — every party member takes ${telegraph.damage} raidwide damage.`,
tone: "danger",
pulseKind: "boss",
targetId: "aelia",
});
return party.map((member) => member.hp > 0
? context.damageMember(member, telegraph.damage, positions[member.id], context.time)
: member);
}
function resolveMemorySequence(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined || telegraph.inputIndex === undefined) {
return failMemorySequence(telegraph, party, positions, context, events);
}
if (context.time < telegraph.inputStartsAt) return party;
if (context.time >= telegraph.expiresAt) return failMemorySequence(telegraph, party, positions, context, events);
const selectedTile = memoryTileAt(telegraph, positions.aelia);
if (!selectedTile) {
telegraph.lastHealerTileId = undefined;
} else if (selectedTile.symbol !== telegraph.lastHealerTileId) {
telegraph.lastHealerTileId = selectedTile.symbol;
if (selectedTile.symbol !== telegraph.sequence[telegraph.inputIndex]) {
return failMemorySequence(telegraph, party, positions, context, events);
}
telegraph.inputIndex += 1;
if (telegraph.inputIndex === telegraph.sequence.length) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: "Memory Sequence cleared by healer.",
tone: "neutral",
pulseKind: "boss",
targetId: "aelia",
});
return party;
}
}
return party;
}
function resolveSoulSiphon(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const siphon = telegraph.soulSiphon;
if (!siphon) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
return party;
}
const healerPosition = positions[siphon.targetId];
if (distance(healerPosition, siphon.wardPosition) <= siphon.wardRadius) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: "Aelia reaches the cleansing ward. Soul Siphon collapses.",
tone: "neutral",
pulseKind: "purify",
targetId: "aelia",
});
return party;
}
const nextSiphon = {
...siphon,
ghostPosition: moveToward(siphon.ghostPosition, healerPosition, SOUL_SIPHON.ghostSpeed * context.delta),
};
let nextParty = party;
const healerIndex = nextParty.findIndex((member) => member.id === siphon.targetId);
if (healerIndex >= 0 && nextParty[healerIndex].hp > 0) {
let nextDamageAt = nextSiphon.nextDamageAt;
let tickCount = nextSiphon.tickCount;
let healer = nextParty[healerIndex];
while (nextDamageAt <= context.time + 0.001) {
healer = context.damageMember(
healer,
SOUL_SIPHON.tickDamage + Math.min(tickCount, 4) * SOUL_SIPHON.tickRamp,
healerPosition,
nextDamageAt,
);
nextDamageAt += SOUL_SIPHON.tickInterval;
tickCount += 1;
}
nextSiphon.nextDamageAt = nextDamageAt;
nextSiphon.tickCount = tickCount;
if (healer !== nextParty[healerIndex]) {
nextParty = [...nextParty];
nextParty[healerIndex] = healer;
}
}
telegraph.soulSiphon = nextSiphon;
return nextParty;
}
function isHit(telegraph: PoolTelegraph, position: WorldPosition) {
if (telegraph.kind === "beam") {
return !!telegraph.start && !!telegraph.end
&& pointToSegmentDistance(position, telegraph.start, telegraph.end) <= (telegraph.width ?? 0) * 0.5;
}
const distance = Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
return distance <= telegraph.radius && distance >= (telegraph.innerRadius ?? 0);
}
function resolveSoak(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const participants = party.filter((member) => member.hp > 0 && isHit(telegraph, positions[member.id]));
const minimum = telegraph.minimumParticipants ?? 1;
const failed = participants.length < minimum;
const totalDamage = (telegraph.totalDamage ?? 0) * (failed ? 1.5 : 1);
if (!participants.length) {
const marked = party.find((member) => member.id === telegraph.targetId && member.hp > 0);
if (!marked) return party;
events.push({
at: context.time,
message: `${telegraph.name} fails — ${marked.name} takes ${Math.round(totalDamage)} damage alone.`,
tone: "danger",
pulseKind: "boss",
targetId: marked.id,
});
return party.map((member) => member.id === marked.id
? context.damageMember(member, totalDamage, positions[member.id], context.time)
: member);
}
const splitDamage = totalDamage / participants.length;
events.push({
at: context.time,
message: failed
? `${telegraph.name} is under-soaked by ${participants.length}. ${Math.round(splitDamage)} damage each.`
: `${telegraph.name} splits ${Math.round(totalDamage)} damage across ${participants.length} allies.`,
tone: "danger",
pulseKind: "boss",
targetId: telegraph.targetId,
});
const participantIds = new Set(participants.map((member) => member.id));
return party.map((member) => participantIds.has(member.id)
? context.damageMember(member, splitDamage, positions[member.id], context.time)
: member);
}
function resolveTelegraph(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
if (telegraph.kind === "soak") return resolveSoak(telegraph, party, positions, context, events);
const hitIds: MemberId[] = [];
const next = party.map((member) => {
if (member.hp <= 0 || !isHit(telegraph, positions[member.id])) return member;
hitIds.push(member.id);
return context.damageMember(member, telegraph.damage, positions[member.id], context.time, "hazard");
});
telegraph.hitIds = hitIds;
events.push({
at: context.time,
message: hitIds.length
? `${telegraph.name} catches ${hitIds.length} ${hitIds.length === 1 ? "ally" : "allies"}.`
: `${telegraph.name} misses the party.`,
tone: "danger",
pulseKind: "boss",
targetId: telegraph.targetId,
});
return next;
}
/** Composes the shared mechanic pool after a boss's signature mechanic update. */
export function advancePooledBossMechanics(
context: BossMechanicContext,
result: BossMechanicResult,
): BossMechanicResult {
if (context.allowPooledMechanics === false) return result;
const source = result.motion;
if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result;
let motion: BossMotionState = {
...source,
poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({
...telegraph,
center: [telegraph.center[0], telegraph.center[1]],
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
soulSiphon: telegraph.soulSiphon && {
...telegraph.soulSiphon,
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
},
hitIds: [...telegraph.hitIds],
})),
};
let party = result.party;
const events = [...result.events];
for (const telegraph of motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.resolved || context.time < telegraph.activatesAt) continue;
party = resolveTelegraph(telegraph, party, context.partyPositions, context, events);
telegraph.resolved = true;
}
motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time);
if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) {
const started = beginPoolMechanic(motion, party, context.partyPositions, context.time);
motion = started.motion;
events.push(started.event);
}
if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) {
motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay;
}
return { ...result, motion, party, events };
}
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
if (!telegraphs.length) return null;
const next = telegraphs.reduce((earliest, telegraph) => telegraph.activatesAt < earliest.activatesAt ? telegraph : earliest);
if (next.kind === "memory") {
const inputStartsAt = next.inputStartsAt ?? next.activatesAt;
const showingSequence = time < inputStartsAt;
return {
name: showingSequence ? "Memory Sequence — watch boss" : "Memory Sequence — match tiles",
remaining: Math.max(0, (showingSequence ? inputStartsAt : next.expiresAt) - time),
cycle: showingSequence ? Math.max(0.01, inputStartsAt - next.activatesAt) : MEMORY_SEQUENCE.inputDuration,
urgent: true,
};
}
if (next.kind === "soul-siphon" && next.soulSiphon) {
return {
name: "Soul Siphon — reach cleansing ward",
remaining: Math.max(0, next.soulSiphon.nextDamageAt - time),
cycle: SOUL_SIPHON.tickInterval,
urgent: true,
};
}
return {
name: next.kind === "soak" ? `${next.name} — stack` : next.kind === "donut" ? `${next.name} — move in` : next.kind === "spread" ? `${next.name} — spread` : `${next.name} — clear lane`,
remaining: Math.max(0, next.activatesAt - time),
cycle: Math.max(0.01, next.activatesAt - (next.activatesAt - POOLED_MECHANIC_TIMING.warningDuration)),
urgent: true,
};
}