Update Healer Man server
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user