433 lines
19 KiB
TypeScript
433 lines
19 KiB
TypeScript
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";
|
||
import { enemyTriggeredShoutContract } from "./enemyTriggeredShouts";
|
||
|
||
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`;
|
||
}
|
||
if (condition.kind === "friendly-missing-aura") {
|
||
return `When an ally within ${condition.range}m is missing this buff`;
|
||
}
|
||
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",
|
||
"nearby-friendly": "the boss and nearby enemy allies",
|
||
self: "the boss",
|
||
"lowest-health-friendly": "the lowest-health enemy ally",
|
||
"selected-friendly": "the boss's selected 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 === "dot") return `${titleCase(attack.school)} ${effect.wowPeriodicSource ? "bleed " : ""}damage to ${target} every ${seconds(effect.intervalMs)} for ${effect.ticks} ticks${effect.wowPeriodicSource ? "; ignores armor" : "; dispellable"}`;
|
||
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 === "triggered-shout") {
|
||
const contract = enemyTriggeredShoutContract(effect.sourceSpellId, effect.sourceProfile);
|
||
if (!contract) return "Controls the cast victim and nearby party members";
|
||
const child = contract.childEffects.find((entry) => entry.kind === "control");
|
||
return `${child?.kind === "control" ? titleCase(child.mechanic) : "Control"} on the cast victim for ${seconds(contract.childSignature.durationMs)}; fear within ${contract.targeting.parentArea.radiusYards}m of the boss for ${seconds(contract.parentSignature.durationMs)}${contract.targeting.parentArea.maximumTargets ? ` (up to ${contract.targeting.parentArea.maximumTargets} party members)` : ""}${contract.childEffects.some((entry) => entry.kind === "damage-taken") ? "; cast victim takes 15% more physical damage" : ""}`;
|
||
}
|
||
if (effect.kind === "weapon-capability") return `Allows ${target} to dual wield`;
|
||
if (effect.kind === "extra-attacks") return `Grants ${effect.count} extra attacks to ${target}`;
|
||
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 === "triggered-shout") return "Keep vulnerable party members beyond 8m of the boss and stabilize the cast victim through the control.";
|
||
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));
|
||
}
|