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>; 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> = { 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 { 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(); 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)); }