Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 13:13:35 -04:00
1 parent 39458d5de6
commit 6cc1b9217f
203 files changed
+133929 -31271

No files matched your search

@@ -1005,6 +1005,8 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
recoveryTimeMs: unsigned(29),
school: schoolFor(unsigned(225)),
schoolMask: unsigned(225),
damageClass: unsigned(213),
durationMs: durations.get(unsigned(40)) ?? 0,
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
effectType: unsigned(71 + effectIndex),
@@ -1246,6 +1248,12 @@ function spellAttack(spell, hash, boss) {
delivery: target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile",
target,
school: spell.school,
schoolMask: spell.schoolMask,
damageOrigin: "wow335",
damageClass: spell.damageClass,
...(spell.damageClass === 1 ? { attackKind: "spell" }
: spell.damageClass === 2 ? { attackKind: "melee" }
: spell.damageClass === 3 ? { attackKind: "ranged" } : {}),
animation: melee ? "attack" : "cast",
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)),
@@ -0,0 +1,285 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const campaignFile = path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.json");
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
const runewakerSpellFile = path.join(
projectRoot,
"scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
);
const outputFile = path.join(projectRoot, "src/game/generated/enemyDamageTypeSources.generated.json");
const defaultWowDbc = path.resolve(
projectRoot,
"../wow-pvt-server-backups-2/wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc",
);
const DAMAGE_EFFECTS = new Set([1, 2, 7, 9, 17, 31, 58, 62, 121]);
const WOW_SCHOOL_BITS = [
[1, "physical"],
[2, "holy"],
[4, "fire"],
[8, "nature"],
[16, "frost"],
[32, "shadow"],
[64, "arcane"],
];
const ROM_CHANNELS = ["earth", "water", "fire", "wind", "light", "darkness"];
const ROM_RUNTIME_SCHOOLS = ["nature", "frost", "fire", "nature", "holy", "shadow"];
const ROM_PHYSICAL_RUNTIME_SCHOOLS = { physical: "physical" };
const ROM_PHYSICAL_CALCULATION_TYPES = new Set([0, 2, 3, 9]);
const ROM_MAGICAL_CALCULATION_TYPES = new Set([1, 10]);
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function readWord(index, fieldCount, localizedShift) {
const sourceField = index >= 77 ? index + localizedShift : index;
assert(sourceField < fieldCount, `Spell.dbc field ${sourceField} is outside the record.`);
return sourceField;
}
function parseWowDamageRecords(buffer, wantedIds) {
assert(buffer.length >= 20 && buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not WDBC data.");
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
assert(fieldCount >= 226 && recordSize >= fieldCount * 4, "Spell.dbc lacks required 3.3.5 fields.");
assert(20 + recordCount * recordSize + stringSize <= buffer.length, "Spell.dbc has invalid record/string bounds.");
const localizedShift = fieldCount >= 239 ? 6 : 0;
const stringsOffset = 20 + recordCount * recordSize;
const results = new Map();
const readName = (base) => {
for (let locale = 0; locale < 16; locale += 1) {
const field = readWord(136 + locale, fieldCount, localizedShift);
const offset = buffer.readUInt32LE(base + field * 4);
if (!offset || offset >= stringSize) continue;
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
const value = buffer.toString("utf8", start, end < 0 ? buffer.length : end).replace(/\s+/g, " ").trim();
if (value) return value;
}
return "";
};
for (let row = 0; row < recordCount; row += 1) {
const base = 20 + row * recordSize;
const spellId = buffer.readUInt32LE(base);
if (!wantedIds.has(spellId)) continue;
const unsigned = (field) => buffer.readUInt32LE(base + readWord(field, fieldCount, localizedShift) * 4);
const effectIds = Array.from({ length: 3 }, (_, index) => unsigned(71 + index));
const damageEffects = effectIds.filter((effectId) => DAMAGE_EFFECTS.has(effectId));
// Utility/script spells such as Call Flames are not source damage merely
// because a DBC record has a school. Keep their authored runtime school.
if (!damageEffects.length) continue;
const schoolMask = unsigned(225);
const schools = WOW_SCHOOL_BITS.filter(([bit]) => (schoolMask & bit) !== 0).map(([, school]) => school);
const damageClass = unsigned(213);
results.set(spellId, {
spellId,
name: readName(base),
damageClass,
schoolMask,
schools,
...(schools.length === 1 ? { school: schools[0] } : {}),
...(damageClass === 1 ? { attackKind: "spell" }
: damageClass === 2 ? { attackKind: "melee" }
: damageClass === 3 ? { attackKind: "ranged" } : {}),
damageEffects: [...new Set(damageEffects)].sort((left, right) => left - right),
source: "WoW 3.3.5a Spell.dbc; AzerothCore DBCStructure.h fields 213/225",
});
}
return { records: results, recordCount, fieldCount, recordSize };
}
function spellIdsFromCampaign(campaign) {
const ids = new Set();
let inspectedAttackCount = 0;
for (const definition of campaign.definitions ?? []) {
for (const entity of Object.values(definition.entities ?? {})) {
for (const attack of entity.combat?.attacks ?? []) {
const spellId = Number(attack.spellId);
if (Number.isSafeInteger(spellId) && spellId > 0) {
ids.add(spellId);
inspectedAttackCount += 1;
}
}
}
}
return { ids, inspectedAttackCount };
}
function spellIdsFromBossMechanics(catalog) {
const ids = new Set();
for (const boss of Object.values(catalog.bosses ?? {})) {
// This catalog also contains Runewaker boss entries. Their numeric IDs
// belong to a different spell namespace and must never be matched in DBC.
if (boss.source?.kind === "runewaker") continue;
for (const mechanic of boss.mechanics ?? []) {
const spellId = Number(mechanic.spellId);
if (Number.isSafeInteger(spellId) && spellId > 0) ids.add(spellId);
}
}
return ids;
}
function runewakerSourceDamageClass(calculationType) {
if (ROM_PHYSICAL_CALCULATION_TYPES.has(Number(calculationType))) return "physical";
if (ROM_MAGICAL_CALCULATION_TYPES.has(Number(calculationType))) return "magical";
return undefined;
}
function runewakerDirectFormula(calculationType) {
switch (Number(calculationType)) {
case 2: return "throw";
case 4: return "fixed";
case 8: return "target-hp-percent";
default: return undefined;
}
}
function runewakerFormulaMitigation(calculationType, effectType, ignoreMagicAbsorb, isFixDotPower) {
if (ignoreMagicAbsorb || (Number(calculationType) === 8 && isFixDotPower)) return "bypassed";
switch (Number(calculationType)) {
case 2: return "weapon-absorb";
case 4: return "magic-absorb";
case 8: return Number(effectType) === 1 ? "weapon-absorb" : "magic-absorb";
default: return undefined;
}
}
function runewakerAttackKind(calculationType) {
const value = Number(calculationType);
if (value === 0 || value === 9) return "melee";
if (value === 2) return "ranged";
if (value === 3) return "ranged";
if (value === 1 || value === 10) return "spell";
return undefined;
}
function runewakerSourceChannel(magicType) {
const value = Number(magicType);
if (value === -1) return "physical";
return ROM_CHANNELS[value];
}
function runewakerRuntimeSchool(sourceChannel) {
if (sourceChannel === "physical") return ROM_PHYSICAL_RUNTIME_SCHOOLS.physical;
const index = ROM_CHANNELS.indexOf(sourceChannel);
return index >= 0 ? ROM_RUNTIME_SCHOOLS[index] : undefined;
}
function romRecord(spell) {
const components = (spell.components ?? [])
.filter((component) => component.directHpDamage)
.map((component) => {
const rawChannel = Number(component.magicType);
const sourceDamageClass = runewakerSourceDamageClass(component.calculationType);
const sourceChannel = runewakerSourceChannel(rawChannel);
const school = runewakerRuntimeSchool(sourceChannel);
return {
componentId: Number(component.id),
calculationType: Number(component.calculationType),
effectType: Number(component.effectType),
magicType: rawChannel,
...(runewakerDirectFormula(component.calculationType)
? { directFormula: runewakerDirectFormula(component.calculationType) } : {}),
...(runewakerFormulaMitigation(component.calculationType, component.effectType, component.ignoreMagicAbsorb, component.isFixDotPower)
? { formulaMitigation: runewakerFormulaMitigation(component.calculationType, component.effectType, component.ignoreMagicAbsorb, component.isFixDotPower) } : {}),
...(sourceDamageClass ? { sourceDamageClass } : {}),
...(runewakerAttackKind(component.calculationType)
? { attackKind: runewakerAttackKind(component.calculationType) } : {}),
...(sourceChannel ? { sourceChannel } : {}),
...(school ? { school } : {}),
};
});
if (!components.length) return null;
return {
spellId: Number(spell.spellId),
name: spell.name,
components,
source: "RuneWaker v0.20 MagicObjectDB; MagicResistENUM in ObjectStruct.h",
};
}
function parseArguments(argv) {
let wowDbc = defaultWowDbc;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--wow-dbc") {
wowDbc = path.resolve(argv[index + 1] ?? "");
index += 1;
}
}
return { wowDbc };
}
const { wowDbc } = parseArguments(process.argv.slice(2));
const [campaignBytes, bossMechanicsBytes, runewakerSpellBytes, wowDbcBytes] = await Promise.all([
readFile(campaignFile),
readFile(bossMechanicsFile),
readFile(runewakerSpellFile),
readFile(wowDbc),
]);
const campaign = JSON.parse(campaignBytes.toString("utf8"));
const bossMechanics = JSON.parse(bossMechanicsBytes.toString("utf8"));
const runewakerCatalog = JSON.parse(runewakerSpellBytes.toString("utf8"));
const campaignIds = spellIdsFromCampaign(campaign);
const wowSpellIds = new Set([...campaignIds.ids, ...spellIdsFromBossMechanics(bossMechanics)]);
const wow = parseWowDamageRecords(wowDbcBytes, wowSpellIds);
const runewaker = Object.fromEntries((runewakerCatalog.spells ?? [])
.map((spell) => [String(spell.spellId), romRecord(spell)])
.filter(([, record]) => record !== null));
const output = {
schemaVersion: 1,
generatedFrom: {
wowSpellDbc: path.relative(projectRoot, wowDbc).replaceAll(path.sep, "/"),
wowSpellDbcSha256: sha256(wowDbcBytes),
runewakerNpcSpellCatalog: path.relative(projectRoot, runewakerSpellFile).replaceAll(path.sep, "/"),
runewakerNpcSpellCatalogSha256: sha256(runewakerSpellBytes),
wowRecordLayout: `${wow.fieldCount} fields; ${wow.recordSize} byte rows`,
wowSpellDamageFields: { damageClass: 213, schoolMask: 225, effects: [71, 72, 73] },
runewakerCalculationTypePolicy: {
physical: [0, 3, 9],
magical: [1, 10],
directFormulaTypes: { "2": "throw", "4": "fixed", "8": "target-hp-percent" },
formulaMitigationIsIndependentOfMagicTypeAndCollectEffectType: true,
attackKind: { "0": "melee", "1": "spell", "3": "ranged", "9": "melee", "10": "spell" },
unsupportedTypesRemainUnclassified: true,
},
runewakerChannelEnum: "MagicType -1 is physical; values 0..5 are Earth, Water, Fire, Wind, Light, Darkness",
},
policy: {
wowLookupScope: "spell ids used by the current compiled campaign enemy attacks or boss mechanics",
wowDamageEffectsOnly: [...DAMAGE_EFFECTS].sort((left, right) => left - right),
wowCompositeMaskPolicy: "preserve recognized schools; export `school` only for a single-bit mask",
runewakerLookupScope: "NPC spell catalog entries with positively classified direct HP damage components",
runewakerCompatibilitySchoolPolicy: "source packet class comes from MagicObject.Attack.CalType; exact MagicType channel is preserved independently and mapped to a best-fit runtime school",
sourceIdentityKey: "origin plus spellId; RuneWaker numeric ids are never matched against WoW entries",
},
counts: {
wowCandidateSpellIds: wowSpellIds.size,
wowExportedDamageSpells: wow.records.size,
wowDirectDamageEffectSpells: [...wow.records.values()].filter((entry) => entry.damageEffects.includes(2)).length,
wowCompositeSchoolMasks: [...wow.records.values()].filter((entry) => entry.schools.length > 1).length,
runewakerCatalogSpells: (runewakerCatalog.spells ?? []).length,
runewakerExportedDamageSpells: Object.keys(runewaker).length,
runewakerDamageComponents: Object.values(runewaker).reduce((sum, entry) => sum + entry.components.length, 0),
campaignEnemyAttackReferences: campaignIds.inspectedAttackCount,
},
wow335: Object.fromEntries([...wow.records.entries()]
.sort(([left], [right]) => left - right)
.map(([spellId, record]) => [String(spellId), record])),
runewaker: Object.fromEntries(Object.entries(runewaker).sort(([left], [right]) => Number(left) - Number(right))),
};
await writeFile(outputFile, JSON.stringify(output, null, 2) + "\n", "utf8");
console.log(`[enemy-damage-types] exported ${output.counts.wowExportedDamageSpells} WoW and ${output.counts.runewakerExportedDamageSpells} RuneWaker source spells.`);
console.log(`[enemy-damage-types] ${path.relative(projectRoot, outputFile)}`);
@@ -41,6 +41,20 @@ const epochDungeonSourceFile = path.join(
*/
export const DEFAULT_AZEROTHCORE_COMMIT = "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3";
export const PINNED_CREATURE_IMMUNITIES_SOURCE = Object.freeze({
repository: "https://github.com/azerothcore/azerothcore-wotlk",
commit: DEFAULT_AZEROTHCORE_COMMIT,
path: "data/sql/base/db_world/creature_immunities.sql",
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${DEFAULT_AZEROTHCORE_COMMIT}/data/sql/base/db_world/creature_immunities.sql`,
sha256: "aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13",
fixtureRoot: path.join(
projectRoot,
"dungeon-pipeline",
"source-fixtures",
`azerothcore-${DEFAULT_AZEROTHCORE_COMMIT}`,
),
});
const SOURCE_TABLES = Object.freeze([
"creature",
"creature_template",
@@ -303,16 +317,37 @@ function bitMaskNames(maskValue, names, bitOffset = 0) {
));
}
function creatureTemplateReference(row) {
export function creatureTemplateReference(row, creatureImmunitiesById = new Map()) {
const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"]));
const mechanicImmuneMask = numeric(firstValue(
row,
["mechanic_immune_mask", "mechanicImmuneMask", "CreatureImmunitiesId"],
));
const spellSchoolImmuneMask = numeric(firstValue(
const legacyMechanicImmuneMask = firstValue(row, ["mechanic_immune_mask", "mechanicImmuneMask"]);
const creatureImmunitiesId = firstValue(row, ["CreatureImmunitiesId", "creatureImmunitiesId"]);
let mechanicImmuneMask = numeric(legacyMechanicImmuneMask);
let spellSchoolImmuneMask = numeric(firstValue(
row,
["spell_school_immune_mask", "spellSchoolImmuneMask"],
));
if (legacyMechanicImmuneMask === undefined && creatureImmunitiesId !== undefined) {
const immunityId = numeric(creatureImmunitiesId);
if (immunityId !== 0) {
const immunity = creatureImmunitiesById.get(immunityId);
if (!immunity) {
throw new Error(
`Creature ${entry} references missing creature_immunities.ID ${immunityId}.`,
);
}
const sourceMechanicsMask = numeric(firstValue(immunity, ["MechanicsMask", "mechanicsMask"]));
if (!Number.isSafeInteger(sourceMechanicsMask) || sourceMechanicsMask < 0) {
throw new Error(
`creature_immunities.ID ${immunityId} has an unsupported MechanicsMask ${sourceMechanicsMask}.`,
);
}
// CreatureImmunities.Mechanic stores bit N at the mechanic enum value N.
// The legacy creature_template mask stores bit N-1. Normalize back to
// that established runtime representation before decoding names below.
mechanicImmuneMask = Math.floor(sourceMechanicsMask / 2);
spellSchoolImmuneMask = numeric(firstValue(immunity, ["SchoolMask", "schoolMask"]));
}
}
return {
entry,
name: String(firstValue(row, ["name", "Name"]) ?? `Creature ${entry}`),
@@ -589,7 +624,13 @@ function parseColumns(sql, tableName) {
return columns;
}
async function parseTable(sourceRoot, tableName, visitor) {
export function creatureTemplateUsesImmunitiesReference(sql) {
return parseColumns(sql, "creature_template").some((column) => (
column === "CreatureImmunitiesId" || column === "creatureImmunitiesId"
));
}
export async function parseTable(sourceRoot, tableName, visitor) {
const file = path.join(sourceRoot, `${tableName}.sql`);
const buffer = await readFile(file);
const sql = buffer.toString("utf8");
@@ -627,6 +668,27 @@ async function fetchSources(sourceRoot, commit) {
sha256: sha256(buffer),
});
}
const creatureTemplateSql = (await readFile(path.join(sourceRoot, "creature_template.sql"))).toString("utf8");
if (creatureTemplateUsesImmunitiesReference(creatureTemplateSql)) {
const tableName = "creature_immunities";
const url = `${base}/${tableName}.sql`;
const response = await fetch(url, {
headers: { "User-Agent": "healer-man-dungeon-pipeline" },
});
if (!response.ok) {
throw new Error(`${tableName}: ${response.status} ${response.statusText} while fetching ${url}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const file = path.join(sourceRoot, `${tableName}.sql`);
await writeFile(file, buffer);
reports.push({
table: tableName,
url,
file: path.relative(projectRoot, file).replace(/\\/g, "/"),
byteLength: buffer.length,
sha256: sha256(buffer),
});
}
await writeJson(path.join(sourceRoot, "source.lock.json"), {
schemaVersion: 1,
repository: "https://github.com/azerothcore/azerothcore-wotlk",
@@ -857,6 +919,42 @@ export async function importAzerothCoreWorld({
}
}));
const creatureImmunitiesById = new Map();
const creatureTemplateSql = (await readFile(
path.join(resolvedSourceRoot, "creature_template.sql"),
)).toString("utf8");
if (creatureTemplateUsesImmunitiesReference(creatureTemplateSql)) {
let creatureImmunitiesSourceRoot = resolvedSourceRoot;
try {
await readFile(path.join(creatureImmunitiesSourceRoot, "creature_immunities.sql"));
} catch (error) {
if (error?.code !== "ENOENT" || commit !== PINNED_CREATURE_IMMUNITIES_SOURCE.commit) throw error;
creatureImmunitiesSourceRoot = PINNED_CREATURE_IMMUNITIES_SOURCE.fixtureRoot;
}
const creatureImmunitiesReport = await parseTable(
creatureImmunitiesSourceRoot,
"creature_immunities",
(row) => {
const id = numeric(firstValue(row, ["ID", "id"]));
if (creatureImmunitiesById.has(id)) {
throw new Error(`creature_immunities contains duplicate ID ${id}.`);
}
creatureImmunitiesById.set(id, row);
},
);
if (commit === PINNED_CREATURE_IMMUNITIES_SOURCE.commit
&& creatureImmunitiesReport.sha256 !== PINNED_CREATURE_IMMUNITIES_SOURCE.sha256) {
throw new Error(
`creature_immunities.sql does not match pinned ${commit} SHA-256 `
+ `${PINNED_CREATURE_IMMUNITIES_SOURCE.sha256}.`,
);
}
tableReports.push({
...creatureImmunitiesReport,
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${commit}/data/sql/base/db_world/creature_immunities.sql`,
});
}
const templateByEntry = new Map();
tableReports.push(await parseTable(resolvedSourceRoot, "creature_template", (row) => {
const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"]));
@@ -1137,7 +1235,7 @@ export async function importAzerothCoreWorld({
const normalizedTemplates = Object.fromEntries(
[...templateByEntry.entries()]
.sort(([left], [right]) => left - right)
.map(([entry, row]) => [entry, creatureTemplateReference(row)]),
.map(([entry, row]) => [entry, creatureTemplateReference(row, creatureImmunitiesById)]),
);
const normalizedCreatureClassStats = Object.fromEntries(
creatureClassLevelStats
@@ -1170,9 +1268,10 @@ export async function importAzerothCoreWorld({
}
}
const sourceFiles = tableReports.map(({ file, columns, rows, byteLength, sha256: hash }) => ({
const sourceFiles = tableReports.map(({ file, columns, rows, byteLength, sha256: hash, url }) => ({
table: path.basename(file, ".sql"),
file: path.basename(file),
...(url ? { url } : {}),
columns,
rows,
byteLength,
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { before, describe, it } from "node:test";
import {
creatureTemplateReference,
creatureTemplateUsesImmunitiesReference,
parseTable,
PINNED_CREATURE_IMMUNITIES_SOURCE,
} from "./import-azerothcore-world.mjs";
describe("AzerothCore creature immunity import", () => {
let creatureImmunitiesById;
let tableReport;
before(async () => {
creatureImmunitiesById = new Map();
tableReport = await parseTable(
PINNED_CREATURE_IMMUNITIES_SOURCE.fixtureRoot,
"creature_immunities",
(row) => creatureImmunitiesById.set(Number(row.ID), row),
);
});
it("uses the pinned schema, source commit, and exact table hash", () => {
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.commit, "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3");
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.path, "data/sql/base/db_world/creature_immunities.sql");
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.sha256,
"aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13");
assert.ok(tableReport.rows > 0);
assert.equal(tableReport.sha256, PINNED_CREATURE_IMMUNITIES_SOURCE.sha256);
assert.ok([
"ID",
"SchoolMask",
"MechanicsMask",
].every((column) => tableReport.columns.includes(column)));
assert.deepEqual({
ID: creatureImmunitiesById.get(-93).ID,
SchoolMask: creatureImmunitiesById.get(-93).SchoolMask,
MechanicsMask: creatureImmunitiesById.get(-93).MechanicsMask,
}, {
ID: -93,
SchoolMask: 0,
MechanicsMask: 16_777_248,
});
});
it("resolves Lady Anacondra's immunity FK to Fear and Horror only", () => {
const lady = creatureTemplateReference({
entry: 3671,
name: "Lady Anacondra",
CreatureImmunitiesId: -93,
}, creatureImmunitiesById);
assert.equal(lady.entry, 3671);
assert.equal(lady.mechanicImmuneMask, 0x0080_0010);
assert.deepEqual(lady.mechanicImmunities, ["fear", "horror"]);
assert.equal(lady.spellSchoolImmuneMask, 0);
assert.deepEqual(lady.schoolImmunities, []);
assert.ok(!lady.mechanicImmunities.includes("silence"));
assert.ok(!lady.mechanicImmunities.includes("interrupt"));
});
it("retains source-backed Silence and Interrupt immunity on rows that declare them", () => {
const immune = creatureTemplateReference({
entry: 99_377,
name: "Source immunity control",
CreatureImmunitiesId: -377,
}, creatureImmunitiesById);
assert.ok(immune.mechanicImmunities.includes("silence"));
assert.ok(immune.mechanicImmunities.includes("interrupt"));
});
it("preserves legacy literal masks and fails closed on a missing FK", () => {
const legacy = creatureTemplateReference({
entry: 1,
mechanic_immune_mask: 0x0080_0010,
spell_school_immune_mask: 0x04,
});
assert.equal(legacy.mechanicImmuneMask, 0x0080_0010);
assert.deepEqual(legacy.mechanicImmunities, ["fear", "horror"]);
assert.equal(legacy.spellSchoolImmuneMask, 0x04);
assert.deepEqual(legacy.schoolImmunities, ["fire"]);
assert.throws(() => creatureTemplateReference({
entry: 3671,
CreatureImmunitiesId: -999_999,
}, creatureImmunitiesById), /references missing creature_immunities\.ID -999999/);
});
it("loads the FK table only for schemas that declare CreatureImmunitiesId", () => {
const legacySchema = "CREATE TABLE `creature_template` (\n `mechanic_immune_mask` int NOT NULL DEFAULT '0'\n) ENGINE=InnoDB;";
const fkSchema = "CREATE TABLE `creature_template` (\n `CreatureImmunitiesId` int NOT NULL DEFAULT '0'\n) ENGINE=InnoDB;";
assert.equal(creatureTemplateUsesImmunitiesReference(legacySchema), false);
assert.equal(creatureTemplateUsesImmunitiesReference(fkSchema), true);
});
});