Release v0.1.4 2026-07-12
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user