Release Healer Man 0.1.21

This commit is contained in:
phenom committed 2026-09-08 10:40:26 -04:00
1 parent 699fc858c9
commit f6f2375145
210 files changed
+976039 -6247

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
import type { 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 === "aura") {
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") {
return `${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
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` : "";
return attack.effect?.kind === "heal"
|| attack.effect?.kind === "aura"
|| attack.effect?.kind === "transform"
|| attack.effect?.kind === "summon"
|| attack.effect?.kind === "call-for-help"
|| attack.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 === "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 === "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,
objectiveId: string,
objectiveName: string,
): readonly [string, PopulationEntityDefinition] | undefined {
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objectiveId);
if (staticSpawn) {
const entity = dungeon.entities[staticSpawn.entityId];
if (entity) return [staticSpawn.entityId, entity];
}
for (const pack of dungeon.roamingPacks) {
const member = pack.members.find((candidate) => (
`${pack.id}:${candidate.id}` === objectiveId || candidate.id === objectiveId
));
if (member) {
const entity = dungeon.entities[member.entityId];
if (entity) return [member.entityId, entity];
}
}
const wanted = normalizedName(objectiveName);
return Object.entries(dungeon.entities).find(([, entity]) => (
entity.kind === "boss"
&& (normalizedName(entity.name) === wanted || normalizedName(entity.title) === wanted)
));
}
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.id, objective.name);
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));
}