Files
healer-man/src/game/bossGuide.ts
T
2026-09-15 14:43:15 -04:00

417 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DungeonBossObjective, DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
import {
normalizeEnemyAttack,
type EnemyAbilityCondition,
type EnemyAbilityEffect,
type EnemyAttackDefinition,
type EnemyAttackTarget,
type EnemyAttackTelegraph,
} from "./enemyAttacks";
import type { PopulationEntityDefinition } from "./mobPopulation";
export type BossGuideFidelity = "source" | "fallback" | "authored";
export interface BossMechanicGuideOverride {
readonly trigger?: string;
readonly timing?: string;
readonly targetAndEffect?: string;
readonly telegraph?: string;
readonly counterplay?: string;
readonly testInstruction?: string;
}
export type BossMechanicGuideOverrides = Readonly<Record<string, BossMechanicGuideOverride>>;
export interface BossMechanicGuideEntry {
readonly id: string;
readonly name: string;
readonly trigger: string;
readonly timing: string;
readonly targetAndEffect: string;
readonly telegraph: string;
readonly counterplay: string;
readonly testInstruction: string;
readonly fidelity: BossGuideFidelity;
readonly fidelityLabel: string;
readonly sourceDetail: string;
}
export interface BossGuideEntry {
readonly id: string;
readonly name: string;
readonly title?: string;
readonly entityId?: string;
readonly mechanics: readonly BossMechanicGuideEntry[];
}
export interface DungeonBossGuide {
readonly id: string;
readonly title: string;
readonly expansion: DungeonExpansion;
readonly bosses: readonly BossGuideEntry[];
}
export function bossGuideOverrideKey(
dungeonId: string,
bossEntityId: string,
mechanicId: string,
): string {
return `${dungeonId}:${bossEntityId}:${mechanicId}`;
}
function seconds(milliseconds: number): string {
const value = milliseconds / 1_000;
return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
}
function rangeSeconds(range: readonly [number, number]): string {
return range[0] === range[1]
? seconds(range[0])
: `${seconds(range[0])}–${seconds(range[1])}`;
}
function titleCase(value: string): string {
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function describeCondition(condition: EnemyAbilityCondition | undefined): string {
if (!condition || condition.kind === "in-combat") return "While the boss is in combat";
if (condition.kind === "self-health-percent") {
if (condition.minimum > 0) {
return `While the boss is between ${Math.round(condition.minimum)}% and ${Math.round(condition.maximum)}% health`;
}
return `When the boss reaches ${Math.round(condition.maximum)}% health`;
}
if (condition.kind === "friendly-missing-health") {
return `When an ally within ${condition.range}m is missing ${Math.round(condition.amount)} health`;
}
return `While encounter ${condition.encounterId} is in: ${condition.states.join(", ")}`;
}
function describeTiming(attack: EnemyAttackDefinition): string {
const parts: string[] = [];
if (attack.initialCooldownMs) parts.push(`First: ${rangeSeconds(attack.initialCooldownMs)}`);
if (attack.repeatCooldownMs) parts.push(`Repeats: ${rangeSeconds(attack.repeatCooldownMs)}`);
else if (attack.cooldownMs > 0) parts.push(`Cooldown: ${seconds(attack.cooldownMs)}`);
if ((attack.castTimeMs ?? 0) > 0) parts.push(`Cast: ${seconds(attack.castTimeMs!)}`);
if (attack.maximumCasts) parts.push(`Maximum uses: ${attack.maximumCasts}`);
return parts.join(" · ") || "Immediate encounter action";
}
const TARGET_LABELS: Readonly<Record<EnemyAttackTarget, string>> = {
primary: "the boss's current target",
"random-party": "a random party member",
"nearby-party": "party members in the affected area",
self: "the boss",
"lowest-health-friendly": "the lowest-health enemy ally",
};
function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAttackDefinition): string {
const target = TARGET_LABELS[attack.target];
if (!effect || effect.kind === "damage") {
const multiplier = effect?.multiplier ?? attack.damageMultiplier;
return `${titleCase(attack.school)} damage to ${target} (${Number(multiplier.toFixed(2))}× boss power)`;
}
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
if (effect.kind === "leech") {
return `Drains ${target} for ${effect.damageMultiplier}× boss power and heals the caster for ${effect.healMultiplier}× boss power`;
}
if (effect.kind === "composite") {
return effect.effects.map((entry) => describeEffect(entry, attack)).join("; ");
}
if (effect.kind === "aura") {
if (effect.meleeAutoCriticalProc) {
const proc = effect.meleeAutoCriticalProc;
return `Applies ${titleCase(effect.aura)} to ${target}; successful main-hand autos have a ${Math.round(proc.chance * 100)}% chance to grant ${proc.charges} charge${proc.charges === 1 ? "" : "s"} of +${proc.criticalChancePercent}% critical chance to the next main-hand auto for ${seconds(proc.durationMs)}`;
}
const magnitude = effect.magnitude === undefined ? "" : ` at ${effect.magnitude} strength`;
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}${magnitude}`;
}
if (effect.kind === "target-aura") {
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "control") {
const damage = effect.damageMultiplier === undefined
? ""
: `${titleCase(attack.school)} damage (${Number(effect.damageMultiplier.toFixed(2))}× boss power) and `;
return `${damage}${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
if (effect.kind === "pull") return `Pulls ${target} to the boss`;
if (effect.kind === "summon") return `Summons ${effect.count} add${effect.count === 1 ? "" : "s"} (creature ${effect.creatureEntry})`;
if (effect.kind === "call-for-help") return `Calls hostile allies within ${effect.radius}m into combat`;
return `Advances encounter event “${effect.eventId}”`;
}
function describeTelegraph(telegraph: EnemyAttackTelegraph | undefined, attack: EnemyAttackDefinition): string {
if (!telegraph) {
const warning = (attack.castTimeMs ?? 0) > 0 ? ` with a ${seconds(attack.castTimeMs!)} cast warning` : "";
const effects = attack.effect?.kind === "composite"
? attack.effect.effects
: attack.effect ? [attack.effect] : [];
return effects.length > 0 && effects.every((effect) => (
effect.kind === "heal"
|| effect.kind === "aura"
|| effect.kind === "transform"
|| effect.kind === "summon"
|| effect.kind === "call-for-help"
|| effect.kind === "encounter-event"
))
? `No harmful ground area${warning}`
: `Targeted mechanic; no ground area${warning}`;
}
const anchor = telegraph.anchor === "source" ? "centered on the boss" : "centered on the target";
if (telegraph.shape === "circle") {
return `${telegraph.radius}m radius circle ${anchor} · ${seconds(telegraph.warningMs)} warning`;
}
if (telegraph.shape === "cone") {
return `${telegraph.length}m, ${telegraph.angleDegrees}° frontal cone · ${seconds(telegraph.warningMs)} warning`;
}
return `${telegraph.length}m × ${telegraph.width}m line from the boss · ${seconds(telegraph.warningMs)} warning`;
}
function effectFollowup(effect: EnemyAbilityEffect | undefined): string {
if (!effect) return "Heal the affected party members after it resolves.";
if (effect.kind === "composite") {
return [...new Set(effect.effects.map((entry) => effectFollowup(entry)))].join(" ");
}
if (effect.kind === "leech") return "Limit the drain with mitigation and recover the health it steals.";
if (effect.kind === "control") return "Stabilize the controlled player and remove the control if an available ability allows it.";
if (effect.kind === "target-aura") return "Watch the affected player's debuff and respond with healing or removal if the effect permits it.";
if (effect.kind === "summon") return "Swap damage to the summoned adds and keep them away from vulnerable party members.";
if (effect.kind === "heal") return "Interrupt the heal when possible; otherwise plan for the boss to recover health.";
if (effect.kind === "call-for-help") return "Clear or control nearby enemies before this ability resolves.";
if (effect.kind === "transform") return "Expect the boss's behavior to change after the new form appears.";
if (effect.kind === "pull") return "Regain safe spacing after the forced movement resolves.";
if (effect.kind === "encounter-event") return "Watch for the encounter-state change and handle the newly activated objective.";
if (effect.kind === "aura") return "Adjust damage and healing for the aura while it remains active.";
return "Heal the affected party members after it resolves.";
}
function describeCounterplay(attack: EnemyAttackDefinition): string {
const telegraph = attack.telegraph;
let movement = "";
if (telegraph?.shape === "circle") {
movement = telegraph.anchor === "target"
? "Move out of the marked circle; if it follows a player, carry it away from the group. "
: "Leave the circle around the boss before the warning expires. ";
} else if (telegraph?.shape === "cone") {
movement = "Move beside or behind the boss; the tank should aim the frontal away from the party. ";
} else if (telegraph?.shape === "line") {
movement = "Sidestep the marked line before the warning expires. ";
} else if (attack.effect?.kind !== "heal" && attack.interruptible !== false && (attack.castTimeMs ?? 0) > 0) {
movement = "Interrupt the cast when possible. ";
}
return `${movement}${effectFollowup(attack.effect)}`.trim();
}
function describeTestInstruction(attack: EnemyAttackDefinition): string {
const checks: string[] = [];
if (attack.condition?.kind === "self-health-percent") {
checks.push(`stop just above ${Math.round(attack.condition.maximum)}% health, then cross the threshold`);
} else if (attack.initialCooldownMs) {
checks.push(attack.initialCooldownMs[1] === 0
? "engage the boss and watch the opening cast"
: `stay in combat through the ${rangeSeconds(attack.initialCooldownMs)} first-use window`);
} else {
checks.push("stay in combat until the mechanic becomes ready");
}
if (attack.telegraph) {
checks.push(`confirm the ${attack.telegraph.shape} appears for ${seconds(attack.telegraph.warningMs)}`);
} else if ((attack.castTimeMs ?? 0) > 0) {
checks.push(`confirm the ${seconds(attack.castTimeMs!)} cast warning appears`);
}
checks.push("verify the stated target/effect when it resolves");
return `${checks[0]}; ${checks.slice(1).join(", then ")}.`;
}
function mechanicFidelity(
dungeon: DungeonDefinition,
attack: EnemyAttackDefinition,
): Pick<BossMechanicGuideEntry, "fidelity" | "fidelityLabel" | "sourceDetail"> {
if (/source-gap|fallback/i.test(attack.id)) {
return {
fidelity: "fallback",
fidelityLabel: "HealerMan fallback",
sourceDetail: "Playable behavior is supplied because the original encounter evidence is incomplete.",
};
}
if (attack.source) {
return {
fidelity: "source",
fidelityLabel: "Source backed",
sourceDetail: attack.source.comment || `Server script row ${attack.source.rowId}`,
};
}
if (dungeon.expansion === "runewaker") {
return {
fidelity: "source",
fidelityLabel: "Source backed",
sourceDetail: attack.spellId
? `RuneWaker spell ${attack.spellId}`
: "RuneWaker spell or encounter-script import",
};
}
if (/azeroth|trinity/i.test(dungeon.provenance.databaseAdapter)) {
return {
fidelity: "source",
fidelityLabel: "Source backed",
sourceDetail: attack.spellId
? `AzerothCore encounter import · spell ${attack.spellId}`
: "AzerothCore encounter-script import",
};
}
return {
fidelity: "authored",
fidelityLabel: "Gameplay authored",
sourceDetail: attack.spellId ? `Spell ${attack.spellId}` : "HealerMan encounter definition",
};
}
function mechanicGuide(
dungeon: DungeonDefinition,
entityId: string,
sourceAttack: EnemyAttackDefinition,
overrides: BossMechanicGuideOverrides,
): BossMechanicGuideEntry {
const attack = normalizeEnemyAttack(sourceAttack);
const override = overrides[bossGuideOverrideKey(dungeon.id, entityId, attack.id)] ?? {};
return {
id: attack.id,
name: attack.name,
trigger: override.trigger ?? describeCondition(attack.condition),
timing: override.timing ?? describeTiming(attack),
targetAndEffect: override.targetAndEffect ?? describeEffect(attack.effect, attack),
telegraph: override.telegraph ?? describeTelegraph(attack.telegraph, attack),
counterplay: override.counterplay ?? describeCounterplay(attack),
testInstruction: override.testInstruction ?? describeTestInstruction(attack),
...mechanicFidelity(dungeon, attack),
};
}
function normalizedName(value: string): string {
return value.toLowerCase().replace(/\s+\d+$/, "").replace(/[^a-z0-9]+/g, "");
}
function bossEntityForObjective(
dungeon: DungeonDefinition,
objective: DungeonBossObjective,
): readonly [string, PopulationEntityDefinition] | undefined {
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objective.id);
if (staticSpawn) {
const entity = dungeon.entities[staticSpawn.entityId];
if (entity) return [staticSpawn.entityId, entity];
}
for (const group of dungeon.encounterSpawnGroups ?? []) {
const spawn = group.spawns.find((candidate) => candidate.id === objective.id);
if (spawn) {
const entity = dungeon.entities[spawn.entityId];
if (entity) return [spawn.entityId, entity];
}
}
for (const pack of dungeon.roamingPacks) {
const member = pack.members.find((candidate) => (
`${pack.id}:${candidate.id}` === objective.id || candidate.id === objective.id
));
if (member) {
const entity = dungeon.entities[member.entityId];
if (entity) return [member.entityId, entity];
}
}
const wanted = normalizedName(objective.name);
const named = Object.entries(dungeon.entities).find(([, entity]) => (
entity.kind === "boss"
&& (normalizedName(entity.name) === wanted || normalizedName(entity.title) === wanted)
));
if (named) return named;
if (objective.eventOnly) {
const related = Object.entries(dungeon.entities)
.filter(([, entity]) => {
const entityName = normalizedName(entity.name);
return entityName.length >= 5 && (wanted.includes(entityName) || entityName.includes(wanted));
})
.sort((left, right) => {
const leftBoss = left[1].kind === "boss" ? 1 : 0;
const rightBoss = right[1].kind === "boss" ? 1 : 0;
if (leftBoss !== rightBoss) return rightBoss - leftBoss;
const leftMechanics = left[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
const rightMechanics = right[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
return rightMechanics - leftMechanics || left[0].localeCompare(right[0]);
});
if (related[0]) return related[0];
}
// Aggregate objectives such as Blackrock Depths' "The Seven" use a
// synthetic objective id but retain the final source actor's exact position.
// That representative may be typed as a mob because the objective covers a
// multi-actor event rather than one loot-bearing boss.
for (const group of dungeon.encounterSpawnGroups ?? []) {
const exact = group.spawns.filter((candidate) => {
const dx = candidate.position[0] - objective.position[0];
const dy = candidate.position[1] - objective.position[1];
const dz = candidate.position[2] - objective.position[2];
return dx * dx + dy * dy + dz * dz <= 0.25 * 0.25;
});
const spawn = exact.find((candidate) => dungeon.entities[candidate.entityId]?.kind === "boss")
?? exact.find((candidate) => dungeon.entities[candidate.entityId] !== undefined);
if (spawn) return [spawn.entityId, dungeon.entities[spawn.entityId]];
}
return undefined;
}
function bossGuide(
dungeon: DungeonDefinition,
bossId: string,
bossName: string,
resolved: readonly [string, PopulationEntityDefinition] | undefined,
overrides: BossMechanicGuideOverrides,
): BossGuideEntry {
const [entityId, entity] = resolved ?? [];
const mechanics = entityId && entity?.combat
? entity.combat.attacks
.filter((attack) => attack.id !== "basic-attack")
.map((attack) => mechanicGuide(dungeon, entityId, attack, overrides))
: [];
return {
id: bossId,
name: bossName,
...(entity?.kind === "boss" ? { title: entity.title } : {}),
...(entityId ? { entityId } : {}),
mechanics,
};
}
export function buildDungeonBossGuide(
dungeon: DungeonDefinition,
overrides: BossMechanicGuideOverrides = {},
): DungeonBossGuide {
const representedEntityIds = new Set<string>();
const objectiveBosses = dungeon.bosses.map((objective) => {
const resolved = bossEntityForObjective(dungeon, objective);
if (resolved) representedEntityIds.add(resolved[0]);
return bossGuide(dungeon, objective.id, objective.name, resolved, overrides);
});
const unplacedBosses = Object.entries(dungeon.entities)
.filter(([entityId, entity]) => entity.kind === "boss" && !representedEntityIds.has(entityId))
.map(([entityId, entity]) => bossGuide(dungeon, `entity:${entityId}`, entity.name, [entityId, entity], overrides));
return {
id: dungeon.id,
title: dungeon.title,
expansion: dungeon.expansion,
bosses: [...objectiveBosses, ...unplacedBosses],
};
}
export function buildBossGuideCatalog(
dungeons: readonly DungeonDefinition[],
overrides: BossMechanicGuideOverrides = {},
): readonly DungeonBossGuide[] {
return dungeons
.map((dungeon) => buildDungeonBossGuide(dungeon, overrides))
.sort((left, right) => left.title.localeCompare(right.title));
}