Files
healer-man/src/game/encounterTimeline.ts
T
2026-09-05 11:40:50 -04:00

100 lines
3.4 KiB
TypeScript

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<Record<string, MobCombatState>>,
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<EncounterTimelineRow>((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;
}