Release Healer Man 0.1.45

This commit is contained in:
phenom committed 2026-09-15 14:43:15 -04:00
1 parent 756ee4f64a
commit f82daf1c13
242 files changed
+1440 -101

No files matched your search

@@ -0,0 +1,94 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const catalog = JSON.parse(fs.readFileSync(
path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.json"),
"utf8",
));
const manifest = JSON.parse(fs.readFileSync(
path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"),
"utf8",
));
const highConfidenceEffectName = /\b(?:dominat\w*|polymorph|hex|silence|slow|hamstring|frost nova|chill nova|summon|raise dead|ward|frost armor|drain life|death coil|vampiric touch|leech poison|pierce armor|sunder armor|dark shell|shock barrier|damage immunity)\b/i;
const auxiliarySpellIds = new Set([21_708, 47_669, 54_888]);
const manifestBySpellId = new Map();
for (const entry of manifest.entries ?? []) manifestBySpellId.set(Number(entry.spellId), entry);
const bossCasts = [];
for (const dungeon of catalog.definitions ?? []) {
for (const entity of Object.values(dungeon.entities ?? {})) {
if (entity.kind !== "boss") continue;
for (const attack of entity.combat?.attacks ?? []) {
if (!attack.spellId) continue;
bossCasts.push({ dungeonId: dungeon.id, boss: entity.name, ...attack });
}
}
}
const effectCasts = bossCasts.filter((attack) => attack.effect);
const suspicious = bossCasts.filter((attack) => (
!attack.effect && highConfidenceEffectName.test(attack.name)
));
const auxiliaryLeaks = bossCasts.filter((attack) => auxiliarySpellIds.has(attack.spellId));
const invalidComposites = effectCasts.filter((attack) => (
attack.effect.kind === "composite" && !attack.effect.effects?.length
));
const atomicEffects = (attack) => attack.effect?.kind === "composite"
? attack.effect.effects
: attack.effect ? [attack.effect] : [];
const harmful = (attack) => atomicEffects(attack).some((effect) => (
["damage", "leech", "target-aura", "control", "pull"].includes(effect.kind)
));
const inferredGroundTelegraph = (attack) => harmful(attack) && (
attack.delivery === "area"
|| attack.radius !== undefined
|| /\b(?:breath|cleave|frontal|sweep|cone|beam|charge|line|lance)\b/i.test(attack.name)
);
const presentation = effectCasts.map((attack) => {
const entry = manifestBySpellId.get(attack.spellId);
const originalVisual = Boolean(entry && Object.values(entry.phases ?? {}).some(
(assets) => Array.isArray(assets) && assets.length > 0,
));
const originalAudio = Boolean(entry?.sounds?.some((sound) => sound.url));
return { ...attack, originalVisual, originalAudio };
});
const issues = [...suspicious, ...auxiliaryLeaks, ...invalidComposites].map((attack) => (
`${attack.dungeonId}:${attack.boss}:${attack.spellId}:${attack.name}`
));
const report = {
status: issues.length ? "failed" : "complete",
bosses: new Set(bossCasts.map((attack) => `${attack.dungeonId}:${attack.boss}`)).size,
spellCasts: bossCasts.length,
explicitEffectCasts: effectCasts.length,
presentation: {
originalVisual: presentation.filter((attack) => attack.originalVisual).length,
generatedVisualFallback: presentation.filter((attack) => !attack.originalVisual).length,
originalAudio: presentation.filter((attack) => attack.originalAudio).length,
generatedAudioFallback: presentation.filter((attack) => !attack.originalAudio).length,
},
indicators: {
castBars: effectCasts.filter((attack) => (
(attack.castTimeMs ?? 0) > 0
|| attack.animation === "cast"
|| inferredGroundTelegraph(attack)
)).length,
groundTelegraphs: effectCasts.filter((attack) => (
attack.telegraph || inferredGroundTelegraph(attack)
)).length,
releaseOrImpactCues: effectCasts.length,
auraOrControlHud: effectCasts.filter((attack) => {
return atomicEffects(attack).some((effect) => (
["aura", "target-aura", "control"].includes(effect.kind)
));
}).length,
},
issues,
};
console.log(JSON.stringify(report, null, 2));
if (issues.length) process.exitCode = 1;
@@ -23,7 +23,7 @@ const validTargets = new Set([
]);
const validSchools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const validEffects = new Set([
"damage", "heal", "aura", "target-aura", "control", "transform", "summon", "call-for-help", "encounter-event",
"damage", "heal", "leech", "aura", "target-aura", "control", "transform", "pull", "summon", "call-for-help", "encounter-event", "composite",
]);
const basicOnlyBossExceptions = new Map(
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
@@ -102,6 +102,14 @@ function validateAttack(dungeonId, entityId, attack, issues) {
if (attack?.effect && !validEffects.has(attack.effect.kind)) {
issues.push(key + ": unsupported effect " + attack.effect.kind + ".");
}
if (attack?.effect?.kind === "composite") {
if (!attack.effect.effects?.length) issues.push(key + ": composite effect is empty.");
for (const effect of attack.effect.effects ?? []) {
if (!validEffects.has(effect.kind) || effect.kind === "composite") {
issues.push(key + ": unsupported composite effect " + effect.kind + ".");
}
}
}
}
function reportMarkdown(report) {
@@ -87,6 +87,14 @@ const epochSpellDbc = path.join(
"DBFilesClient",
"Spell.dbc",
);
const defaultSpellSupportRoot = path.join(
projectRoot,
"..",
"LadiksMPQEditor",
"client-current",
"patch-S",
"DBFilesClient",
);
const standardGameObjectDisplayDbc = path.join(
workRoot,
"deadmines",
@@ -935,7 +943,37 @@ function schoolFor(mask) {
return "physical";
}
function parseSpells(buffer, wantedIds) {
function numberTable(buffer, field, floating = false) {
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
if (fieldCount <= field || recordSize < fieldCount * 4) return new Map();
const result = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
result.set(buffer.readUInt32LE(base), floating
? buffer.readFloatLE(base + field * 4)
: buffer.readInt32LE(base + field * 4));
}
return result;
}
async function spellSupportTables(spellFile, fallbackRoot) {
const siblingRoot = path.dirname(spellFile);
const table = async (name, field, floating = false) => {
const sibling = path.join(siblingRoot, `${name}.dbc`);
const fallback = path.join(fallbackRoot, `${name}.dbc`);
const file = await exists(sibling) ? sibling : fallback;
return await exists(file) ? numberTable(await readFile(file), field, floating) : new Map();
};
return {
durations: await table("SpellDuration", 3),
radii: await table("SpellRadius", 3, true),
};
}
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()) {
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
@@ -944,6 +982,7 @@ function parseSpells(buffer, wantedIds) {
if (fieldCount < 226 || recordSize < fieldCount * 4) return new Map();
const stringsOffset = 20 + recordCount * recordSize;
const localizedShift = fieldCount >= 239 ? 6 : 0;
const shifted = (field) => field >= 77 ? field + localizedShift : field;
const readString = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = stringsOffset + offset;
@@ -956,6 +995,7 @@ function parseSpells(buffer, wantedIds) {
const id = buffer.readUInt32LE(base);
if (!wantedIds.has(id)) continue;
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
const signed = (field) => buffer.readInt32LE(base + field * 4);
let name = "";
for (let locale = 0; locale < 16 && !name; locale += 1) {
name = readString(unsigned(136 + localizedShift + locale));
@@ -965,11 +1005,163 @@ function parseSpells(buffer, wantedIds) {
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
recoveryTimeMs: unsigned(29),
school: schoolFor(unsigned(225)),
durationMs: durations.get(unsigned(40)) ?? 0,
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
effectType: unsigned(71 + effectIndex),
auraType: unsigned(shifted(95 + effectIndex)),
basePoints: signed(shifted(80 + effectIndex)) + 1,
mechanic: unsigned(shifted(83 + effectIndex)),
implicitTargetA: unsigned(shifted(86 + effectIndex)),
implicitTargetB: unsigned(shifted(89 + effectIndex)),
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
miscValue: signed(shifted(110 + effectIndex)),
})).filter((effect) => effect.effectType || effect.auraType),
});
}
return spells;
}
const CONTROL_AURA_MECHANICS = Object.freeze(new Map([
[5, "confuse"],
[6, "confuse"],
[7, "fear"],
[12, "stun"],
[26, "root"],
[27, "silence"],
[33, "slow"],
[56, "confuse"],
[60, "silence"],
[92, "root"],
[193, "slow"],
]));
const AUXILIARY_NON_BOSS_CASTS = Object.freeze(new Set([
"26687:47669", // The stasis orb awakens Gortok; Gortok does not cast this spell.
"29307:54888", // The Colossus does not own the elemental's spawn/reset visual.
"29573:54888", // The elemental uses this during reset, not as a combat attack.
"13282:21708", // Noxxion's helper dummy is triggered by the actual summon spell.
]));
function spellSemantic(spell, boss, area) {
const durationMs = spell.durationMs < 0
? 0
: spell.durationMs || (boss ? 8_000 : 5_000);
const damageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
const hasDamage = spell.effects.some((effect) => [2, 31, 58, 121].includes(effect.effectType));
const controlEffect = spell.effects.find((effect) => CONTROL_AURA_MECHANICS.has(effect.auraType));
const controlMechanic = /\b(?:sleep|slumber)\b/i.test(spell.name)
? "sleep"
: controlEffect ? CONTROL_AURA_MECHANICS.get(controlEffect.auraType) : undefined;
const leechEffect = spell.effects.find((effect) => effect.effectType === 9 || effect.auraType === 53);
const summons = spell.effects.filter((effect) => (
[28, 41].includes(effect.effectType) && effect.miscValue > 0
));
if (spell.id === 52723) {
return {
kind: "aura",
aura: `spell-${spell.id}`,
durationMs: 30_000,
magnitude: 0.5,
lifeStealPercent: 0.5,
};
}
if (spell.effects.some((effect) => effect.effectType === 43)) {
return { kind: "pull", destination: "source" };
}
if (summons.length) {
const summonEffects = summons.map((effect) => ({
kind: "summon",
creatureEntry: effect.miscValue,
count: Math.max(1, Math.min(8, effect.basePoints)),
}));
const effects = hasDamage
? [{ kind: "damage", multiplier: damageMultiplier }, ...summonEffects]
: summonEffects;
return effects.length === 1 ? effects[0] : { kind: "composite", effects };
}
if (leechEffect) {
const leech = {
kind: "leech",
damageMultiplier,
healMultiplier: damageMultiplier,
};
if (!controlEffect) return leech;
return {
kind: "composite",
effects: [
leech,
{
kind: "control",
mechanic: controlMechanic,
durationMs,
},
],
};
}
if (controlEffect) {
return {
kind: "control",
mechanic: controlMechanic,
durationMs,
...(hasDamage ? { damageMultiplier } : {}),
};
}
const absorb = spell.effects.find((effect) => effect.auraType === 69);
const reflect = spell.effects.find((effect) => effect.auraType === 28);
const schoolDefense = spell.effects.find((effect) => effect.auraType === 87);
const armorBuff = spell.effects.find((effect) => effect.auraType === 22);
if (schoolDefense && schoolDefense.implicitTargetA !== 1) {
const targetAura = {
kind: "target-aura",
aura: `spell-${spell.id}`,
durationMs,
damageTakenMultiplier: Math.max(0, 1 + schoolDefense.basePoints / 100),
damageSchool: schoolFor(Math.max(0, schoolDefense.miscValue)),
};
return hasDamage
? {
kind: "composite",
effects: [{ kind: "damage", multiplier: damageMultiplier }, targetAura],
}
: targetAura;
}
if (absorb || reflect || schoolDefense || armorBuff) {
const defensive = absorb ?? reflect ?? schoolDefense ?? armorBuff;
const school = schoolFor(Math.max(0, defensive.miscValue));
const damageTakenMultiplier = schoolDefense
? Math.max(0, Math.min(1, 1 + schoolDefense.basePoints / 100))
: armorBuff ? 0.85 : undefined;
return {
kind: "aura",
aura: `spell-${spell.id}`,
durationMs,
...(damageTakenMultiplier === undefined ? {} : { damageTakenMultiplier }),
...((schoolDefense || armorBuff) ? { damageSchool: schoolDefense ? school : "physical" } : {}),
...(absorb ? { absorbMultiplier: 2.5 } : {}),
...(reflect ? { reflectSpellChance: Math.max(0, Math.min(1, reflect.basePoints / 100)) } : {}),
};
}
const armorReduction = spell.effects.find((effect) => effect.auraType === 101);
if (armorReduction) {
return {
kind: "target-aura",
aura: `spell-${spell.id}`,
durationMs,
damageTakenMultiplier: 1.2,
damageSchool: "physical",
};
}
return undefined;
}
function basicAttack(cooldownMs = 1_900) {
return {
id: "basic-attack",
@@ -986,13 +1178,19 @@ function basicAttack(cooldownMs = 1_900) {
function spellAttack(spell, hash, boss) {
const area = /nova|roar|stomp|whirl|rain|volley|blizzard|explosion|quake|storm|breath|cleave|fear|horror|terror/i
.test(spell.name);
.test(spell.name) || spell.id === 53030;
const melee = spell.school === "physical"
&& /strike|slash|cleave|bash|smash|rend|bite|claw|pummel/i.test(spell.name);
const healing = /\b(?:heal|healing|mend|renew|rejuvenat\w*|restor\w*|holy light|lay on hands)\b/i
.test(spell.name);
const control = /\b(?:fear|horror|terror|terrify)\b/i.test(spell.name)
? "fear"
: /\b(?:hex|polymorph|dominat\w*)\b/i.test(spell.name)
? "confuse"
: /\b(?:silence)\b/i.test(spell.name)
? "silence"
: /\b(?:slow|hamstring)\b/i.test(spell.name)
? "slow"
: /\b(?:sleep|slumber)\b/i.test(spell.name)
? "sleep"
: /\b(?:root|entangl\w*|web wrap|web spray|freeze|frozen prison)\b/i.test(spell.name)
@@ -1002,10 +1200,19 @@ function spellAttack(spell, hash, boss) {
: null;
const aura = /\b(?:enrage|frenzy|shield|reflection|reflect|defensive aura|battle aura|berserker aura|haste|hasten)\b/i
.test(spell.name);
const semanticEffect = spellSemantic(spell, boss, area);
const semanticEffects = semanticEffect?.kind === "composite"
? semanticEffect.effects
: semanticEffect ? [semanticEffect] : [];
const semanticHas = (kind) => semanticEffects.some((entry) => entry.kind === kind);
const target = healing
? "lowest-health-friendly"
: aura
: semanticHas("aura") || aura
? "self"
: semanticEffects.length > 0 && semanticEffects.every((entry) => entry.kind === "summon")
? "self"
: semanticHas("control")
? area ? "nearby-party" : "random-party"
: control === "fear" && area
? "nearby-party"
: area
@@ -1013,7 +1220,7 @@ function spellAttack(spell, hash, boss) {
: control
? "random-party"
: "primary";
const effect = healing
const effect = semanticEffect ?? (healing
? { kind: "heal", multiplier: boss ? 1.2 : 0.9 }
: control
? {
@@ -1030,7 +1237,8 @@ function spellAttack(spell, hash, boss) {
durationMs: boss ? 12_000 : 8_000,
magnitude: boss ? 0.25 : 0.15,
}
: undefined;
: undefined);
const dbcRadius = spell.effects.reduce((maximum, entry) => Math.max(maximum, entry.radius), 0);
return {
id: `spell-${spell.id}`,
name: spell.name,
@@ -1042,7 +1250,7 @@ function spellAttack(spell, hash, boss) {
range: target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28,
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
damageMultiplier: effect ? 0 : area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12,
...(area ? { radius: boss ? 11 : 8 } : {}),
...(area ? { radius: dbcRadius || (boss ? 11 : 8) } : {}),
...(effect ? { effect } : {}),
...(healing
? {
@@ -1189,13 +1397,15 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!spell) continue;
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
const attack = spellAttack(spell, hash, true);
const target = [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target;
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target: [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target,
target,
range: target === "self" ? attack.range : Math.max(attack.range, 28),
source,
});
} else if (actionType === 12) {
@@ -1290,7 +1500,11 @@ function combatFor(
const seenSpellIds = new Set();
const spellAttacks = orderedSpellDetails
.filter((detail) => {
if (!detail.spellId || seenSpellIds.has(detail.spellId)) return false;
if (
!detail.spellId
|| seenSpellIds.has(detail.spellId)
|| AUXILIARY_NON_BOSS_CASTS.has(`${entry}:${detail.spellId}`)
) return false;
seenSpellIds.add(detail.spellId);
return true;
})
@@ -1310,6 +1524,9 @@ function combatFor(
let mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
mechanics = mechanics.filter((mechanic) => (
!mechanic.spellId || !AUXILIARY_NON_BOSS_CASTS.has(`${entry}:${mechanic.spellId}`)
));
mechanics = uniqueMechanics(mechanics);
if (!mechanics.length && boss && !authored) mechanics.push(fallbackSpecial(entity.id, true));
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
@@ -1582,11 +1799,23 @@ async function compile() {
}
}
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
const ascensionSpellSupport = await spellSupportTables(spellDbc, defaultSpellSupportRoot);
const ascensionSpells = await exists(spellDbc)
? parseSpells(await readFile(spellDbc), wantedSpellIds)
? parseSpells(
await readFile(spellDbc),
wantedSpellIds,
ascensionSpellSupport.durations,
ascensionSpellSupport.radii,
)
: new Map();
const epochSpellSupport = await spellSupportTables(epochSpellDbc, defaultSpellSupportRoot);
const epochSpells = await exists(epochSpellDbc)
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
? parseSpells(
await readFile(epochSpellDbc),
wantedSpellIds,
epochSpellSupport.durations,
epochSpellSupport.radii,
)
: ascensionSpells;
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
? parseGameObjectDisplays(await readFile(standardGameObjectDisplayDbc))