import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena"; import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry"; import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types"; import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared"; import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types"; export const BOSS_MECHANIC_POOL = [ { id: "basic-melee", name: "Basic Melee", instruction: "Maintain tank pressure." }, { id: "bull-charge", name: "Bull Charge", instruction: "Clear the marked charge lane." }, { id: "crushing-pounce", name: "Crushing Pounce", instruction: "Stack to split the impact." }, { id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." }, { id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." }, { id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." }, { id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." }, { id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." }, { id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." }, { id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." }, { id: "guardian-cross", name: "Guardian Cross", instruction: "Find a safe quadrant." }, { id: "destruction-rush", name: "Destruction Rush", instruction: "Clear the marked rush lane." }, { id: "ruin-quake", name: "Ruin Quake", instruction: "Leave the destruction circle." }, { id: "destruction-pulse", name: "Destruction Pulse", instruction: "Step between the radial beams." }, { id: "ricochet-rush", name: "Ricochet Rush", instruction: "Dodge both rebound lanes." }, { id: "meteor-slam", name: "Meteor Slam", instruction: "Leave the impact and spreading flame." }, { id: "burrow-rush", name: "Burrow Rush", instruction: "Cross the marked trail." }, { id: "hourglass-eruption", name: "Hourglass Eruption", instruction: "Leave the eruptions and moving zone." }, { id: "sidewinder-rush", name: "Sidewinder Rush", instruction: "Clear the surf lane." }, { id: "crushing-tide", name: "Crushing Tide", instruction: "Spread before the claws close." }, { id: "vine-scissors", name: "Vine Scissors", instruction: "Dodge the first and rotated crosses." }, { id: "haunting-rifts", name: "Haunting Rifts", instruction: "Carry persistent rifts away from formation." }, { id: "tri-burst", name: "Tri-Burst", instruction: "Move through the three expanding rings." }, { id: "ultimate-skyfall", name: "Ultimate Skyfall", instruction: "Spread before the marked impacts." }, { 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; const MECHANIC_COPY_BY_ID = Object.fromEntries( BOSS_MECHANIC_POOL.map((mechanic) => [mechanic.id, mechanic]), ) as Record; export function bossMechanicName(id: BossMechanicId) { return MECHANIC_COPY_BY_ID[id].name; } export const POOLED_MECHANIC_TIMING = { activeDuration: 0.42, warningDuration: 1.4, } as const; export const BULL_CHARGE = { warning: 1.8, speed: 10.5, distance: 13.5, hitRadius: 1.35, damage: 18, knockdown: 0.75, cooldown: 4, aiClearance: 1.9, aiEvadeSpeed: 3.4, } as const; export const BULL_POUNCE = { stackDuration: 5, stackRadius: 2.2, sharedDamage: 200, leapDuration: 0.55, cooldown: 4, } as const; export const SKY_SWEEPER_BREATH = { telegraphDuration: 2, sweepDuration: 3.2, range: 10.5, halfAngle: Math.PI / 7, sweepArc: Math.PI * 0.95, tickDamage: 9, tickInterval: 0.45, cooldown: 4, } as const; export const VENOM_PURGE = { duration: 10, tickDamage: 5, castDuration: 2.5, poolRadius: 2, poolDuration: 7, poolDamage: 14, } as const; export const MEMORY_SEQUENCE = { sequenceLength: 4, flashDuration: 0.9, 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 = { 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 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, requestedId: BossMechanicId, ) { const count = motion.poolMechanicCount + 1; const entry = MECHANIC_COPY_BY_ID[requestedId]; 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, 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; } 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, }; } interface MechanicRuntime { readonly context: BossMechanicContext; boss: BossMechanicResult["boss"]; motion: BossMotionState; party: PartyMember[]; events: BossMechanicResult["events"]; } export interface BossMechanicDefinition { readonly id: BossMechanicId; readonly name: string; readonly instruction: string; readonly cooldown: number; readonly passive?: boolean; readonly start: (runtime: MechanicRuntime) => void; readonly advance: (runtime: MechanicRuntime) => void; upcoming?: (motion: BossMotionState, time: number) => UpcomingMechanic; readonly animationCue: (motion: BossMotionState) => BossAnimationCue; } function finishMechanic(runtime: MechanicRuntime, cooldown: number) { runtime.motion.activeMechanicId = null; runtime.motion.mode = "holding"; runtime.motion.phaseEndsAt = 0; runtime.motion.nextMechanicAt = runtime.context.time + cooldown; runtime.motion.chargeHitIds = []; runtime.motion.mechanicHitIds = []; runtime.motion.tetherIds = []; runtime.motion.slashLanes = []; } function timedAdvance(runtime: MechanicRuntime, cooldown: number) { if (runtime.context.time >= runtime.motion.phaseEndsAt) finishMechanic(runtime, cooldown); } function defaultUpcoming(definition: Pick, motion: BossMotionState, time: number): UpcomingMechanic { const remaining = Math.max(0, (motion.activeMechanicId ? motion.phaseEndsAt : motion.nextMechanicAt) - time); return { name: definition.name, remaining, cycle: definition.cooldown, urgent: motion.activeMechanicId !== null || remaining < 2.5 }; } function mechanicCopy(id: BossMechanicId) { return MECHANIC_COPY_BY_ID[id]; } export function bossMechanicIsPassive(id: BossMechanicId) { return BOSS_MECHANIC_REGISTRY[id].passive === true; } interface LaneChargeConfig { id: BossMechanicId; warning: number; speed: number; distance: number; width: number; damage: number; knockdown: number; cooldown: number; targetOrder: readonly MemberId[]; } function chargeEndpoint(start: WorldPosition, target: WorldPosition, travelDistance: number): WorldPosition { const angle = angleTo(start, target); return clampToArena([ start[0] + Math.sin(angle) * travelDistance, start[1] + Math.cos(angle) * travelDistance, ]); } function laneChargeDefinition(config: LaneChargeConfig): BossMechanicDefinition { const copy = mechanicCopy(config.id); const definition: BossMechanicDefinition = { id: config.id, name: copy.name, instruction: copy.instruction, cooldown: config.cooldown, start(runtime) { const targetId = chooseLivingTarget(runtime.party, config.targetOrder, runtime.motion.mechanicCount); const end = chargeEndpoint(runtime.motion.position, runtime.context.partyPositions[targetId], config.distance); runtime.motion.mode = "telegraph"; runtime.motion.chargeTargetId = targetId; runtime.motion.chargeStart = [...runtime.motion.position]; runtime.motion.chargeEnd = end; runtime.motion.chargeHitIds = []; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = runtime.context.time + config.warning; runtime.motion.slashLanes = [{ id: `${config.id}-${runtime.motion.mechanicCount}`, start: [...runtime.motion.position], end, width: config.width, damage: config.damage, }]; runtime.events.push({ at: runtime.context.time, message: `${copy.name} targets ${memberName(runtime.party, targetId)}. ${copy.instruction}`, tone: "danger", pulseKind: "charge", targetId, }); }, advance(runtime) { const { context, motion } = runtime; if (motion.mode === "telegraph" && context.time >= motion.phaseEndsAt) { motion.mode = "charging"; motion.phaseStartedAt = context.time; motion.phaseEndsAt = context.time + distance(motion.position, motion.chargeEnd) / config.speed; return; } if (motion.mode !== "charging") return; const previous = [...motion.position] as WorldPosition; motion.position = moveToward(motion.position, motion.chargeEnd, config.speed * context.delta); runtime.party = runtime.party.map((member) => { if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member; if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > config.width * 0.5) return member; motion.chargeHitIds.push(member.id); return { ...context.damageMember(member, config.damage, context.partyPositions[member.id], context.time), knockedUntil: context.time + config.knockdown, }; }); if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) { motion.position = [...motion.chargeEnd]; finishMechanic(runtime, config.cooldown); } }, animationCue: (motion) => motion.mode === "charging" ? "move" : "attack", }; definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time); return definition; } interface CircleAttackConfig { id: BossMechanicId; warning: number; radius: number; damage: number; cooldown: number; kind: CircleHazard["kind"]; targets: number; duration?: number; tickInterval?: number; centeredOnBoss?: boolean; stagger?: number; } function circleAttackDefinition(config: CircleAttackConfig): BossMechanicDefinition { const copy = mechanicCopy(config.id); const definition: BossMechanicDefinition = { id: config.id, name: copy.name, instruction: copy.instruction, cooldown: config.cooldown, start(runtime) { const activatesAt = runtime.context.time + config.warning; const targetIds = Array.from({ length: config.targets }, (_, offset) => chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount + offset)); const centers = config.centeredOnBoss ? [[...runtime.motion.position] as WorldPosition] : targetIds.map((targetId) => [...runtime.context.partyPositions[targetId]] as WorldPosition); runtime.motion.mode = config.id === "stormfall" ? "skyfall" : "golem_crownfall"; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = activatesAt + (centers.length - 1) * (config.stagger ?? 0) + Math.max(0.32, config.duration ?? 0.32); runtime.motion.hazards.push(...centers.map((center, index) => createCircleHazard({ id: `${config.id}-${runtime.motion.mechanicCount}-${index}`, kind: config.kind, center, radius: config.radius, activatesAt: activatesAt + index * (config.stagger ?? 0), duration: config.duration ?? 0.32, damage: config.damage, tickInterval: config.tickInterval, }))); runtime.events.push({ at: runtime.context.time, message: `${copy.name}: ${copy.instruction}`, tone: "danger", pulseKind: "skyfall", targetId: targetIds[0], }); }, advance(runtime) { timedAdvance(runtime, config.cooldown); }, animationCue: () => "special", }; definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time); return definition; } interface LaneAttackConfig { id: BossMechanicId; warning: number; width: number; damage: number; cooldown: number; angles: readonly number[]; rotateFollowup?: number; } function laneFromAngle(id: string, center: WorldPosition, angle: number, width: number, damage: number): SlashLane { const dx = Math.sin(angle) * 9; const dz = Math.cos(angle) * 9; return { id, start: [center[0] - dx, center[1] - dz], end: [center[0] + dx, center[1] + dz], width, damage }; } function resolveLanes(runtime: MechanicRuntime, name: string) { const hitIds: MemberId[] = []; runtime.party = runtime.party.map((member) => { if (member.hp <= 0) return member; const lane = runtime.motion.slashLanes.find((entry) => pointToSegmentDistance(runtime.context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5); if (!lane) return member; hitIds.push(member.id); runtime.events.push({ at: runtime.context.time, message: `${member.name} is struck by ${name}.`, tone: "danger", pulseKind: "slash", targetId: member.id }); return runtime.context.damageMember(member, lane.damage, runtime.context.partyPositions[member.id], runtime.context.time); }); runtime.motion.mechanicHitIds.push(...hitIds); } function laneAttackDefinition(config: LaneAttackConfig): BossMechanicDefinition { const copy = mechanicCopy(config.id); const startLanes = (runtime: MechanicRuntime, rotation = 0) => { const targetId = chooseLivingTarget(runtime.party, TARGET_ORDER, runtime.motion.mechanicCount); const center = runtime.context.partyPositions[targetId]; const aimed = angleTo(runtime.motion.position, center) + rotation; runtime.motion.slashLanes = config.angles.map((offset, index) => laneFromAngle(`${config.id}-${runtime.motion.mechanicCount}-${index}-${rotation}`, center, aimed + offset, config.width, config.damage)); runtime.motion.chargeTargetId = targetId; }; const definition: BossMechanicDefinition = { id: config.id, name: copy.name, instruction: copy.instruction, cooldown: config.cooldown, start(runtime) { runtime.motion.mode = "mantis_line_telegraph"; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = runtime.context.time + config.warning; runtime.motion.chargeCount = 0; runtime.motion.mechanicHitIds = []; startLanes(runtime); runtime.events.push({ at: runtime.context.time, message: `${copy.name}: ${copy.instruction}`, tone: "danger", pulseKind: "slash", targetId: runtime.motion.chargeTargetId }); }, advance(runtime) { if (runtime.context.time < runtime.motion.phaseEndsAt) return; resolveLanes(runtime, copy.name); if (config.rotateFollowup && runtime.motion.chargeCount === 0) { runtime.motion.chargeCount = 1; runtime.motion.mode = "mantis_cross_telegraph"; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = runtime.context.time + config.warning; startLanes(runtime, config.rotateFollowup); runtime.events.push({ at: runtime.context.time, message: `${copy.name} rotates. Find new safe ground.`, tone: "danger", pulseKind: "slash" }); return; } finishMechanic(runtime, config.cooldown); }, animationCue: () => "attack", }; definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time); return definition; } const BULL_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"]; const bullCharge = laneChargeDefinition({ id: "bull-charge", warning: BULL_CHARGE.warning, speed: BULL_CHARGE.speed, distance: BULL_CHARGE.distance, width: BULL_CHARGE.hitRadius * 2, damage: BULL_CHARGE.damage, knockdown: BULL_CHARGE.knockdown, cooldown: BULL_CHARGE.cooldown, targetOrder: BULL_TARGETS }); const destructionRush = laneChargeDefinition({ id: "destruction-rush", warning: 1.45, speed: 11.5, distance: 14, width: 2.5, damage: 27, knockdown: 0.55, cooldown: 3.8, targetOrder: BULL_TARGETS }); const burrowRush = laneChargeDefinition({ id: "burrow-rush", warning: 1.3, speed: 10.8, distance: 13, width: 2, damage: 25, knockdown: 0.35, cooldown: 3.8, targetOrder: ["aelia", "nia", "orin", "vale", "brann"] }); const sidewinderRush = laneChargeDefinition({ id: "sidewinder-rush", warning: 1.25, speed: 11, distance: 13.2, width: 2.3, damage: 24, knockdown: 0.42, cooldown: 3.7, targetOrder: BULL_TARGETS }); const crushingPounce: BossMechanicDefinition = { id: "crushing-pounce", name: bossMechanicName("crushing-pounce"), instruction: mechanicCopy("crushing-pounce").instruction, cooldown: 4, start(runtime) { const targetId = chooseLivingTarget(runtime.party, ["aelia", "nia", "orin", "vale", "brann"], runtime.motion.mechanicCount); runtime.motion.mode = "stacking"; runtime.motion.pounceTargetId = targetId; runtime.motion.pounceCenter = [...runtime.context.partyPositions[targetId]]; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = runtime.context.time + BULL_POUNCE.stackDuration; runtime.events.push({ at: runtime.context.time, message: `Crushing Pounce marks ${memberName(runtime.party, targetId)}. Stack to split the impact.`, tone: "danger", pulseKind: "pounce", targetId }); }, advance(runtime) { const { motion, context } = runtime; if (motion.mode === "stacking") { motion.pounceCenter = [...context.partyPositions[motion.pounceTargetId]]; if (context.time < motion.phaseEndsAt) return; motion.mode = "pouncing"; motion.chargeStart = [...motion.position]; motion.chargeEnd = [...motion.pounceCenter]; motion.phaseStartedAt = context.time; motion.phaseEndsAt = context.time + BULL_POUNCE.leapDuration; return; } if (motion.mode !== "pouncing") return; motion.position = moveToward(motion.position, motion.chargeEnd, 16 * context.delta); if (context.time < motion.phaseEndsAt && distance(motion.position, motion.chargeEnd) >= 0.08) return; const stackedIds = runtime.party.filter((member) => member.hp > 0 && distance(context.partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius).map((member) => member.id); const damage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length); runtime.party = runtime.party.map((member) => stackedIds.includes(member.id) ? context.damageMember(member, damage, context.partyPositions[member.id], context.time) : member); runtime.events.push({ at: context.time, message: `Crushing Pounce deals ${Math.round(damage)} damage across ${stackedIds.length} stacked allies.`, tone: "danger", pulseKind: "pounce", targetId: motion.pounceTargetId }); finishMechanic(runtime, BULL_POUNCE.cooldown); }, animationCue: (motion) => motion.mode === "pouncing" ? "special" : "attack", }; crushingPounce.upcoming = (motion, time) => defaultUpcoming(crushingPounce, motion, time); function instantTimedDefinition(id: BossMechanicId, cooldown: number, start: (runtime: MechanicRuntime) => void, cue: BossAnimationCue = "attack"): BossMechanicDefinition { const copy = mechanicCopy(id); const definition: BossMechanicDefinition = { id, name: copy.name, instruction: copy.instruction, cooldown, start(runtime) { runtime.motion.mode = "golem_shockwave"; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = runtime.context.time + 0.55; start(runtime); }, advance(runtime) { timedAdvance(runtime, cooldown); }, animationCue: () => cue, }; definition.upcoming = (motion, time) => defaultUpcoming(definition, motion, time); return definition; } const cinderNova = instantTimedDefinition("cinder-nova", 5, (runtime) => { runtime.party = runtime.party.map((member) => member.hp > 0 ? runtime.context.damageMember(member, 13, runtime.context.partyPositions[member.id], runtime.context.time) : member); runtime.events.push({ at: runtime.context.time, message: "Cinder Nova strikes the party.", tone: "danger", pulseKind: "boss" }); }, "special"); const emberBrand = instantTimedDefinition("ember-brand", 5, (runtime) => { const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount); runtime.party = runtime.party.map((member) => member.id === targetId ? { ...member, debuffs: [...member.debuffs, { id: `ember-brand-${runtime.motion.mechanicCount}`, name: "Ember Brand", expiresAt: runtime.context.time + 7, nextTickAt: runtime.context.time + 1, tickDamage: 6 }], } : member); runtime.events.push({ at: runtime.context.time, message: `Ember Brand afflicts ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "debuff", targetId }); }); const bindingWeb: BossMechanicDefinition = { id: "binding-web", name: bossMechanicName("binding-web"), instruction: mechanicCopy("binding-web").instruction, cooldown: 4, start(runtime) { const pairs: readonly (readonly [MemberId, MemberId])[] = [["brann", "vale"], ["nia", "orin"], ["aelia", "nia"]]; const pair = pairs[(runtime.motion.mechanicCount - 1) % pairs.length]; const first = chooseLivingTarget(runtime.party, pair, 0); const second = chooseLivingTarget(runtime.party, pair.filter((id) => id !== first), 0); runtime.motion.mode = "tethering"; runtime.motion.tetherIds = [first, second]; runtime.motion.tetherBreakDistance = 6.8; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = runtime.context.time + 4.5; runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first }); }, advance(runtime) { const [first, second] = runtime.motion.tetherIds; if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) { runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" }); finishMechanic(runtime, 4); return; } if (runtime.context.time < runtime.motion.phaseEndsAt) return; runtime.party = runtime.party.map((member) => runtime.motion.tetherIds.includes(member.id) ? { ...runtime.context.damageMember(member, 24, runtime.context.partyPositions[member.id], runtime.context.time), knockedUntil: runtime.context.time + 1.4 } : member); runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" }); finishMechanic(runtime, 4); }, animationCue: () => "attack", }; bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time); const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => { const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset)); runtime.motion.mode = "venom_cast"; runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration; runtime.party = runtime.party.map((member) => targets.includes(member.id) ? { ...member, debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage }], } : member); runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move away before cleansing.", tone: "danger", pulseKind: "venom", targetId: targets[0] }); }); const stormBreath: BossMechanicDefinition = { id: "storm-breath", name: bossMechanicName("storm-breath"), instruction: mechanicCopy("storm-breath").instruction, cooldown: 4, start(runtime) { const aimed = angleTo(runtime.motion.position, runtime.context.partyPositions.brann); const direction = runtime.motion.mechanicCount % 2 === 0 ? 1 : -1; runtime.motion.mode = "breath_telegraph"; runtime.motion.breathStartAngle = aimed - direction * Math.PI * 0.475; runtime.motion.breathEndAngle = aimed + direction * Math.PI * 0.475; runtime.motion.breathAngle = runtime.motion.breathStartAngle; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = runtime.context.time + SKY_SWEEPER_BREATH.telegraphDuration; runtime.motion.mechanicNextDamageAt = {}; runtime.events.push({ at: runtime.context.time, message: "Storm Breath gathers. Rotate behind the sweep.", tone: "danger", pulseKind: "breath" }); }, advance(runtime) { const { motion, context } = runtime; if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) { motion.mode = "breath_sweeping"; motion.phaseStartedAt = context.time; motion.phaseEndsAt = context.time + SKY_SWEEPER_BREATH.sweepDuration; return; } if (motion.mode !== "breath_sweeping") return; const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration)); motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress; runtime.party = runtime.party.map((member) => { if (member.hp <= 0) return member; const dx = context.partyPositions[member.id][0] - motion.position[0]; const dz = context.partyPositions[member.id][1] - motion.position[1]; const memberAngle = Math.atan2(dx, dz); const exposed = Math.hypot(dx, dz) <= SKY_SWEEPER_BREATH.range && Math.abs(Math.atan2(Math.sin(memberAngle - motion.breathAngle), Math.cos(memberAngle - motion.breathAngle))) <= SKY_SWEEPER_BREATH.halfAngle; if (!exposed) return member; const nextAt = motion.mechanicNextDamageAt[member.id] ?? context.time; if (nextAt > context.time) return member; motion.mechanicNextDamageAt[member.id] = context.time + SKY_SWEEPER_BREATH.tickInterval; return context.damageMember(member, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], context.time); }); if (context.time >= motion.phaseEndsAt) finishMechanic(runtime, SKY_SWEEPER_BREATH.cooldown); }, animationCue: () => "attack", }; stormBreath.upcoming = (motion, time) => defaultUpcoming(stormBreath, motion, time); const stormfall = circleAttackDefinition({ id: "stormfall", warning: 2, radius: 1.8, damage: 30, cooldown: 4, kind: "skyfall", targets: 3, duration: 5, tickInterval: 1, stagger: 0.9 }); const crushingTide = circleAttackDefinition({ id: "crushing-tide", warning: 1.35, radius: 1.7, damage: 26, cooldown: 3.7, kind: "tidal_burst", targets: 3 }); const hauntingRifts = circleAttackDefinition({ id: "haunting-rifts", warning: 1.45, radius: 1.75, damage: 5, cooldown: 3.9, kind: "soul_rift", targets: 2, duration: 4.2, tickInterval: 0.8 }); const ultimateSkyfall = circleAttackDefinition({ id: "ultimate-skyfall", warning: 1.5, radius: 1.85, damage: 28, cooldown: 4, kind: "crownfall", targets: 3 }); const ruinQuake = circleAttackDefinition({ id: "ruin-quake", warning: 1.35, radius: 3.6, damage: 31, cooldown: 3.8, kind: "quake", targets: 1, centeredOnBoss: true }); const elementalBeam = laneAttackDefinition({ id: "elemental-beam", warning: 0.9, width: 1.65, damage: 32, cooldown: 3.4, angles: [0] }); const guardianCross = laneAttackDefinition({ id: "guardian-cross", warning: 0.9, width: 1.45, damage: 25, cooldown: 3.4, angles: [-Math.PI * 0.18, Math.PI * 0.18] }); const destructionPulse = laneAttackDefinition({ id: "destruction-pulse", warning: 1.2, width: 1.25, damage: 24, cooldown: 3.8, angles: [0, Math.PI / 3, -Math.PI / 3] }); const vineScissors = laneAttackDefinition({ id: "vine-scissors", warning: 1.25, width: 1.55, damage: 22, cooldown: 3.9, angles: [0, Math.PI / 2], rotateFollowup: Math.PI / 4 }); const ricochetRush: BossMechanicDefinition = { ...laneChargeDefinition({ id: "ricochet-rush", warning: 1.25, speed: 12.5, distance: 13, width: 2.25, damage: 22, knockdown: 0.4, cooldown: 3.6, targetOrder: ["orin", "nia", "aelia", "vale", "brann"] }), advance(runtime) { const motion = runtime.motion; if (motion.mode === "telegraph" && runtime.context.time >= motion.phaseEndsAt) { motion.mode = "charging"; motion.chargeCount = 0; motion.phaseStartedAt = runtime.context.time; motion.phaseEndsAt = runtime.context.time + distance(motion.position, motion.chargeEnd) / 12.5; return; } if (motion.mode !== "charging") return; const previous = [...motion.position] as WorldPosition; motion.position = moveToward(motion.position, motion.chargeEnd, 12.5 * runtime.context.delta); runtime.party = runtime.party.map((member) => { if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(runtime.context.partyPositions[member.id], previous, motion.position) > 1.125) return member; motion.chargeHitIds.push(member.id); return runtime.context.damageMember(member, 22, runtime.context.partyPositions[member.id], runtime.context.time); }); if (distance(motion.position, motion.chargeEnd) >= 0.08 && runtime.context.time < motion.phaseEndsAt) return; motion.hazards.push(createCircleHazard({ id: `ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, kind: "lava_pool", center: motion.chargeEnd, radius: 1.5, activatesAt: runtime.context.time, duration: 4.5, damage: 5, tickInterval: 0.8 })); if (motion.chargeCount === 0) { const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, motion.mechanicCount + 2); const start = [...motion.chargeEnd] as WorldPosition; const end = chargeEndpoint(start, runtime.context.partyPositions[targetId], 13); motion.position = start; motion.chargeStart = start; motion.chargeEnd = end; motion.chargeTargetId = targetId; motion.chargeHitIds = []; motion.chargeCount = 1; motion.phaseEndsAt = runtime.context.time + distance(start, end) / 12.5; motion.slashLanes = [{ id: `ricochet-${motion.mechanicCount}-1`, start, end, width: 2.25, damage: 22 }]; runtime.events.push({ at: runtime.context.time, message: `Ricochet Rush rebounds toward ${memberName(runtime.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId }); return; } finishMechanic(runtime, 3.6); }, }; const meteorSlam: BossMechanicDefinition = { id: "meteor-slam", name: bossMechanicName("meteor-slam"), instruction: mechanicCopy("meteor-slam").instruction, cooldown: 3.6, start(runtime) { const activatesAt = runtime.context.time + 1.3; runtime.motion.mode = "cinderback_slam"; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = activatesAt + 0.3; runtime.motion.hazards.push(createCircleHazard({ id: `meteor-slam-${runtime.motion.mechanicCount}`, kind: "quake", center: runtime.motion.position, radius: 3.1, activatesAt, duration: 0.3, damage: 29 })); for (let index = 0; index < 3; index += 1) { const angle = index / 3 * Math.PI * 2; runtime.motion.hazards.push(createCircleHazard({ id: `meteor-flame-${runtime.motion.mechanicCount}-${index}`, kind: "lava_pool", center: clampToArena([runtime.motion.position[0] + Math.sin(angle) * 3.7, runtime.motion.position[1] + Math.cos(angle) * 3.7]), radius: 1.5, activatesAt, duration: 4.5, damage: 5, tickInterval: 0.8 })); } runtime.events.push({ at: runtime.context.time, message: "Meteor Slam: leave the impact and spreading flame.", tone: "danger", pulseKind: "boss" }); }, advance(runtime) { timedAdvance(runtime, 3.6); }, animationCue: () => "special", }; meteorSlam.upcoming = (motion, time) => defaultUpcoming(meteorSlam, motion, time); const hourglassEruption: BossMechanicDefinition = { id: "hourglass-eruption", name: bossMechanicName("hourglass-eruption"), instruction: mechanicCopy("hourglass-eruption").instruction, cooldown: 3.8, start(runtime) { const activatesAt = runtime.context.time + 1.55; for (let index = 0; index < 3; index += 1) { const targetId = chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + index); runtime.motion.hazards.push(createCircleHazard({ id: `stinger-${runtime.motion.mechanicCount}-${index}`, kind: "stinger_eruption", center: runtime.context.partyPositions[targetId], radius: 1.75, activatesAt, duration: 0.32, damage: 27 })); } runtime.motion.hazards.push(createCircleHazard({ id: `hourglass-${runtime.motion.mechanicCount}`, kind: "hourglass", center: clampToArena([runtime.motion.position[0], runtime.motion.position[1] + 3]), radius: 2.55, activatesAt: activatesAt + 0.9, duration: 3.6, damage: 6, tickInterval: 0.75 })); runtime.motion.mode = "sandglass_hourglass"; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = activatesAt + 4.5; runtime.events.push({ at: runtime.context.time, message: "Hourglass Eruption: leave the eruptions and moving zone.", tone: "danger", pulseKind: "skyfall" }); }, advance(runtime) { timedAdvance(runtime, 3.8); }, animationCue: () => "special", }; hourglassEruption.upcoming = (motion, time) => defaultUpcoming(hourglassEruption, motion, time); const triBurst: BossMechanicDefinition = { id: "tri-burst", name: bossMechanicName("tri-burst"), instruction: mechanicCopy("tri-burst").instruction, cooldown: 4, start(runtime) { const firstActivation = runtime.context.time + 1.2; const bands = [{ innerRadius: 0, radius: 2.35 }, { innerRadius: 2.35, radius: 4.7 }, { innerRadius: 4.7, radius: 7.05 }]; runtime.motion.mode = "golem_shockwave"; runtime.motion.phaseStartedAt = runtime.context.time; runtime.motion.phaseEndsAt = firstActivation + 1.62; runtime.motion.hazards.push(...bands.map((band, index) => createCircleHazard({ id: `tri-burst-${runtime.motion.mechanicCount}-${index}`, kind: "royal_shockwave", center: runtime.motion.position, innerRadius: band.innerRadius, radius: band.radius, activatesAt: firstActivation + index * 0.65, duration: 0.3, damage: 19 }))); runtime.events.push({ at: runtime.context.time, message: "Tri-Burst expands in three rings. Follow the safe bands.", tone: "danger", pulseKind: "boss" }); }, advance(runtime) { timedAdvance(runtime, 4); }, animationCue: () => "special", }; triBurst.upcoming = (motion, time) => defaultUpcoming(triBurst, motion, time); function telegraphDefinition(id: BossMechanicId): BossMechanicDefinition { const copy = mechanicCopy(id); const definition: BossMechanicDefinition = { id, name: copy.name, instruction: copy.instruction, cooldown: 5, start(runtime) { const started = beginPoolMechanic(runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, id); runtime.motion = started.motion; runtime.motion.mode = "golem_crownfall"; runtime.events.push(started.event); }, advance(runtime) { for (const telegraph of runtime.motion.poolTelegraphs) { if (telegraph.kind === "memory") { if (!telegraph.resolved) runtime.party = resolveMemorySequence(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events); continue; } if (telegraph.kind === "soul-siphon") { if (!telegraph.resolved) runtime.party = resolveSoulSiphon(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events); continue; } if (telegraph.resolved || runtime.context.time < telegraph.activatesAt) continue; runtime.party = resolveTelegraph(telegraph, runtime.party, runtime.context.partyPositions, runtime.context, runtime.events); telegraph.resolved = true; } runtime.motion.poolTelegraphs = runtime.motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > runtime.context.time); if (!runtime.motion.poolTelegraphs.length) finishMechanic(runtime, 5); }, upcoming(motion, time) { return upcomingPooledMechanic(motion, time) ?? defaultUpcoming(definition, motion, time); }, animationCue: () => id === "soul-siphon" || id === "memory-sequence" ? "special" : "attack", }; return definition; } const basicMelee: BossMechanicDefinition = { id: "basic-melee", name: bossMechanicName("basic-melee"), instruction: mechanicCopy("basic-melee").instruction, cooldown: 0, passive: true, start() {}, advance(runtime) { applyMelee(runtime.boss, runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, 2.5, 15, runtime.context.damageMember); }, animationCue: () => "idle", }; export const BOSS_MECHANIC_REGISTRY: Record = { "basic-melee": basicMelee, "bull-charge": bullCharge, "crushing-pounce": crushingPounce, "cinder-nova": cinderNova, "ember-brand": emberBrand, "binding-web": bindingWeb, "venom-purge": venomPurge, "storm-breath": stormBreath, stormfall, "elemental-beam": elementalBeam, "guardian-cross": guardianCross, "destruction-rush": destructionRush, "ruin-quake": ruinQuake, "destruction-pulse": destructionPulse, "ricochet-rush": ricochetRush, "meteor-slam": meteorSlam, "burrow-rush": burrowRush, "hourglass-eruption": hourglassEruption, "sidewinder-rush": sidewinderRush, "crushing-tide": crushingTide, "vine-scissors": vineScissors, "haunting-rifts": hauntingRifts, "tri-burst": triBurst, "ultimate-skyfall": ultimateSkyfall, "meteor-spread": telegraphDefinition("meteor-spread"), "hollow-collapse": telegraphDefinition("hollow-collapse"), "aetheric-soak": telegraphDefinition("aetheric-soak"), "prism-beam": telegraphDefinition("prism-beam"), "memory-sequence": telegraphDefinition("memory-sequence"), "soul-siphon": telegraphDefinition("soul-siphon"), }; function scheduledMechanicId( loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]], mechanicCount: number, ) { let activeCount = 0; for (const id of loadout) if (!BOSS_MECHANIC_REGISTRY[id].passive) activeCount += 1; if (!activeCount) throw new Error("Boss loadout requires at least one active mechanic."); let targetIndex = mechanicCount % activeCount; for (const id of loadout) { if (BOSS_MECHANIC_REGISTRY[id].passive) continue; if (targetIndex === 0) return id; targetIndex -= 1; } return loadout[0]; } export function advanceMechanicLoadout( context: BossMechanicContext, loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]], ): BossMechanicResult { const runtime: MechanicRuntime = { context, boss: { ...context.boss }, motion: cloneMotion(context.motion), party: context.party, events: [], }; if (runtime.motion.mode === "holding") returnBossToArenaCenter(runtime.motion, context.delta, 2); for (const mechanicId of loadout) { const definition = BOSS_MECHANIC_REGISTRY[mechanicId]; if (definition.passive) definition.advance(runtime); } if (runtime.motion.activeMechanicId) { BOSS_MECHANIC_REGISTRY[runtime.motion.activeMechanicId].advance(runtime); } if (!runtime.motion.activeMechanicId && context.time >= runtime.motion.nextMechanicAt) { const mechanicId = scheduledMechanicId(loadout, runtime.motion.mechanicCount); runtime.motion.mechanicCount += 1; runtime.motion.activeMechanicId = mechanicId; runtime.motion.nextMechanicAt = Number.POSITIVE_INFINITY; BOSS_MECHANIC_REGISTRY[mechanicId].start(runtime); } runtime.party = resolveCircleHazards(runtime.motion, runtime.party, context.partyPositions, context.time, context.damageMember, runtime.events); return { boss: runtime.boss, motion: runtime.motion, party: runtime.party, events: runtime.events }; } export function upcomingLoadoutMechanic( loadout: readonly [BossMechanicId, BossMechanicId, ...BossMechanicId[]], motion: BossMotionState, time: number, ): UpcomingMechanic { const id = motion.activeMechanicId ?? scheduledMechanicId(loadout, motion.mechanicCount); const definition = BOSS_MECHANIC_REGISTRY[id]; return definition.upcoming?.(motion, time) ?? defaultUpcoming(definition, motion, time); } export function bossAnimationCue(motion: BossMotionState): BossAnimationCue { return motion.activeMechanicId ? BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion) : "idle"; } export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) { const next = cloneMotion(motion); next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + 0.25, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 })); return next; } export function handleMechanicDispel( motion: BossMotionState, memberId: MemberId, position: WorldPosition, time: number, debuffNames: readonly string[], ) { if (debuffNames.includes("Widow Venom")) { return { motion: dropVenomPool(motion, memberId, position, time), message: "Widow Venom purged. A venom pool forms where the target stood.", }; } return { motion, message: "Harmful magic removed." }; }