import type { MobCombatState } from "./combatStore"; import type { EnemyDamageSchool, EnemyTelegraphShape } from "./enemyAttacks"; export type EncounterTimelineKind = "casting" | "timer" | "health"; export interface EncounterTimelineRow { readonly id: string; readonly name: string; readonly kind: EncounterTimelineKind; readonly value: string; readonly progress: number; readonly sortAt: number; readonly school: EnemyDamageSchool; readonly telegraphShape?: EnemyTelegraphShape; } export interface EncounterTimelineModel { readonly bossId: string; readonly bossName: string; readonly rows: readonly EncounterTimelineRow[]; } function clampedProgress(value: number): number { return Math.min(1, Math.max(0, value)); } export function encounterTimeline( mobs: Readonly>, now: number, limit = 4, ): EncounterTimelineModel | null { const boss = Object.values(mobs) .filter((mob) => mob.boss && mob.engaged && !mob.dead && !mob.despawned) .sort((left, right) => ( Number(Boolean(right.activeCast)) - Number(Boolean(left.activeCast)) || left.id.localeCompare(right.id) ))[0]; if (!boss) return null; const healthPercent = (boss.health / Math.max(1, boss.maxHealth)) * 100; const rows = boss.attacks.flatMap((attack) => { if (attack.id === "basic-attack") return []; const castCount = boss.abilityCastCounts[attack.id] ?? 0; if (attack.maximumCasts && castCount >= attack.maximumCasts) return []; const common = { id: attack.id, name: attack.name, school: attack.school, ...(attack.telegraph ? { telegraphShape: attack.telegraph.shape } : {}), }; if (boss.activeCast?.attackId === attack.id) { const duration = Math.max(1, boss.activeCast.completesAt - boss.activeCast.startedAt); const remaining = Math.max(0, boss.activeCast.completesAt - now); return [{ ...common, kind: "casting", value: `${(remaining / 1_000).toFixed(1)}s`, progress: clampedProgress(remaining / duration), sortAt: -1, }]; } if (attack.condition?.kind === "self-health-percent") { const threshold = attack.condition.maximum; return [{ ...common, kind: "health", value: `${Math.round(threshold)}% HP`, progress: clampedProgress(healthPercent / Math.max(1, threshold)), sortAt: 1_000_000 + (100 - threshold) * 1_000, }]; } const readyAt = boss.abilityReadyAt[attack.id]; if (!Number.isFinite(readyAt)) return []; const remaining = Math.max(0, (readyAt ?? now) - now); const cooldown = Math.max( 1, attack.repeatCooldownMs?.[1] ?? attack.initialCooldownMs?.[1] ?? attack.cooldownMs, ); return [{ ...common, kind: "timer", value: `${(remaining / 1_000).toFixed(1)}s`, progress: clampedProgress(remaining / cooldown), sortAt: remaining, }]; }).sort((left, right) => left.sortAt - right.sortAt || left.id.localeCompare(right.id)); const alwaysVisibleHealthRows = rows.filter((row) => row.kind === "health"); const countdownRows = rows .filter((row) => row.kind !== "health") .slice(0, Math.max(1, limit)); const visibleRows = [...countdownRows, ...alwaysVisibleHealthRows]; return visibleRows.length ? { bossId: boss.id, bossName: boss.name, rows: visibleRows, } : null; }