Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user