Update class mechanics, stat rulesets, and CoA reference catalogs

This commit is contained in:
phenom committed 2026-10-04 12:56:30 -04:00
1 parent b16821e92e
commit 39458d5de6
221 files changed
+2584962 -568

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
/** Reconstruct the exact scalar/string semantics of positional archive rows. */
export function archiveRowsToDbc(records, fieldTypes) {
if (!Array.isArray(fieldTypes) || fieldTypes.length !== 234) throw new Error("Archived Spell requires all 234 column types");
const strings = [Buffer.from([0])];
let stringSize = 1;
const stringOffsets = new Map([["", 0]]);
const rows = records.map((record) => {
const row = Buffer.alloc(234 * 4);
for (let index = 0; index < 234; index += 1) {
const key = `f${index}`;
if (!Object.hasOwn(record, key)) throw new Error(`Archived spell ${record.f0} is missing ${key}`);
const value = record[key];
const localized = (index >= 136 && index <= 151) || (index >= 153 && index <= 168)
|| (index >= 170 && index <= 185) || (index >= 187 && index <= 202);
const text = typeof value === "string" ? value : localized && typeof record[`${key}s`] === "string" ? record[`${key}s`] : null;
if (text !== null) {
if (!stringOffsets.has(text)) {
const bytes = Buffer.from(`${text}\0`, "utf8");
stringOffsets.set(text, stringSize);
strings.push(bytes);
stringSize += bytes.length;
}
row.writeUInt32LE(stringOffsets.get(text), index * 4);
} else if (fieldTypes[index] === "float") {
if (!Number.isFinite(value)) throw new Error(`Archived spell ${record.f0} has invalid ${key}`);
row.writeFloatLE(value, index * 4);
} else {
if (!Number.isInteger(value) || value < -2147483648 || value > 4294967295) throw new Error(`Archived spell ${record.f0} has invalid integer ${key}`);
row.writeUInt32LE(value >>> 0, index * 4);
}
}
return row;
});
const header = Buffer.alloc(20);
header.write("WDBC", 0);
header.writeUInt32LE(rows.length, 4);
header.writeUInt32LE(234, 8);
header.writeUInt32LE(234 * 4, 12);
header.writeUInt32LE(stringSize, 16);
return Buffer.concat([header, ...rows, ...strings]);
}
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { archiveRowsToDbc } from "./archive-dbc.mjs";
test("archive positional reconstruction preserves signed bits, inferred floats and localized text", () => {
const types = Array(234).fill("int");
types[229] = "float";
const row = Object.fromEntries(types.map((_, index) => [`f${index}`, 0]));
Object.assign(row, { f0: 100, f4: -2147483648, f41: -2, f47: 1101004800, f136: "New spell", f136i: 54321, f170: 43210, f170s: "Alternate decoded description", f229: 0.375 });
const buffer = archiveRowsToDbc([row], types);
assert.equal(buffer.readUInt32LE(4), 1);
assert.equal(buffer.readUInt32LE(8), 234);
assert.equal(buffer.readUInt32LE(20 + 4 * 4), 2147483648);
assert.equal(buffer.readInt32LE(20 + 41 * 4), -2);
assert.equal(buffer.readFloatLE(20 + 47 * 4), 20);
assert.equal(buffer.readFloatLE(20 + 229 * 4), 0.375);
const stringStart = 20 + 234 * 4;
for (const [field, text] of [[136, "New spell"], [170, "Alternate decoded description"]]) {
const start = stringStart + buffer.readUInt32LE(20 + field * 4);
assert.equal(buffer.toString("utf8", start, buffer.indexOf(0, start)), text);
}
const malformed = { ...row };
delete malformed.f71;
assert.throws(() => archiveRowsToDbc([malformed], types), /missing f71/);
});
+566
View File
@@ -0,0 +1,566 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { gunzipSync } from "node:zlib";
export const ARCHIVE_SPELL_SOURCE = Object.freeze({
id: "august-global-spell-base",
repository: "srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
shortRevision: "5078f9ac2254",
capturedAt: "2026-08-12T21:02:51+00:00",
variantPath: "raw/tables/Spell/variants/data-patch-t-mpq",
tableIndexPath: "raw/tables/Spell/variants/data-patch-t-mpq/index.json",
tableIndexSha256: "3f3bcf86285607f73bc1cab248c57e12abb1d858c015cbd06cfc15d8ec0aea84",
tableIndexGitBlobSha: "fadd592b46f7d6d413713d79dff78018c14595bb",
columnInfoPath: "raw/tables/Spell/variants/data-patch-t-mpq/Spell.colinfo.json",
columnInfoSha256: "ba7ecede4d45c93ac66dd5917e5f9c34c81ac7da818e4a3c33b5cc80d5e1cf20",
columnInfoGitBlobSha: "ad3acfe6b3894a7990b81050972d707a6c0e7b28",
sourceDbcSha256: "a1e4285cdbc8a269a1b8a3f4b304ee43eb4d1bfa5a98511639868c4ff6097623",
rows: 209206,
columns: 234,
});
const cacheSchemaVersion = 1;
const manifestName = "manifest.json";
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from("blob " + bytes.byteLength + "\0", "utf8"))
.update(bytes)
.digest("hex");
}
function parseJson(bytes, label) {
try {
return JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new Error("Archive Spell JSON is invalid at " + label + ": " + error.message, { cause: error });
}
}
function assertSafeRelativePath(value, label) {
if (typeof value !== "string" || value.length === 0) throw new TypeError(label + " must be a non-empty path");
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)
|| normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
throw new Error(label + " is not a safe relative path: " + value);
}
return normalized;
}
function validateSource(source) {
const required = [
"id", "repository", "revision", "shortRevision", "capturedAt", "variantPath",
"tableIndexPath", "tableIndexSha256", "tableIndexGitBlobSha", "columnInfoPath",
"columnInfoSha256", "columnInfoGitBlobSha", "sourceDbcSha256",
];
for (const key of required) {
if (typeof source[key] !== "string" || source[key].length === 0) {
throw new TypeError("source." + key + " must be a non-empty string");
}
}
if (!/^[0-9a-f]{40}$/i.test(source.revision) || !/^[0-9a-f]{7,40}$/i.test(source.shortRevision)) {
throw new TypeError("source revision values must be hexadecimal Git revisions");
}
for (const key of ["tableIndexSha256", "columnInfoSha256", "sourceDbcSha256"]) {
if (!/^[0-9a-f]{64}$/i.test(source[key])) throw new TypeError("source." + key + " must be a SHA-256 digest");
}
for (const key of ["tableIndexGitBlobSha", "columnInfoGitBlobSha"]) {
if (!/^[0-9a-f]{40}$/i.test(source[key])) throw new TypeError("source." + key + " must be a Git blob SHA");
}
if (!Number.isSafeInteger(source.rows) || source.rows < 1) throw new TypeError("source.rows must be a positive integer");
if (!Number.isSafeInteger(source.columns) || source.columns < 1) throw new TypeError("source.columns must be a positive integer");
source.variantPath = assertSafeRelativePath(source.variantPath, "source.variantPath");
source.tableIndexPath = assertSafeRelativePath(source.tableIndexPath, "source.tableIndexPath");
source.columnInfoPath = assertSafeRelativePath(source.columnInfoPath, "source.columnInfoPath");
if (source.tableIndexPath !== source.variantPath + "/index.json"
|| source.columnInfoPath !== source.variantPath + "/Spell.colinfo.json") {
throw new Error("Archive Spell metadata paths must belong to the pinned BASE variant");
}
return Object.freeze({ ...source });
}
function sourceRecord(source) {
return {
id: source.id,
repository: "https://github.com/" + source.repository,
revision: source.revision,
capturedAt: source.capturedAt,
variantPath: source.variantPath,
tableIndexPath: source.tableIndexPath,
tableIndexSha256: source.tableIndexSha256,
tableIndexGitBlobSha: source.tableIndexGitBlobSha,
columnInfoPath: source.columnInfoPath,
columnInfoSha256: source.columnInfoSha256,
columnInfoGitBlobSha: source.columnInfoGitBlobSha,
sourceDbcSha256: source.sourceDbcSha256,
rows: source.rows,
columns: source.columns,
};
}
function resultSource(source, files) {
const storedBytes = files.reduce((total, file) => total + file.size, 0);
return {
id: source.id,
path: source.variantPath,
sha256: source.tableIndexSha256,
bytes: storedBytes,
storedBytes,
sourceDbcSha256: source.sourceDbcSha256,
originalRecordCount: source.rows,
pin: source.revision,
capturedAt: source.capturedAt,
tableIndexPath: source.tableIndexPath,
tableIndexGitBlobSha: source.tableIndexGitBlobSha,
columnInfoPath: source.columnInfoPath,
columnInfoSha256: source.columnInfoSha256,
columnInfoGitBlobSha: source.columnInfoGitBlobSha,
};
}
function assertPinnedBytes(bytes, sha256Pin, gitBlobPin, label) {
const actualSha = sha256(bytes);
const actualBlob = gitBlobSha(bytes);
if (actualSha !== sha256Pin.toLowerCase()) {
throw new Error(label + " SHA-256 mismatch: expected " + sha256Pin + ", found " + actualSha);
}
if (actualBlob !== gitBlobPin.toLowerCase()) {
throw new Error(label + " Git blob mismatch: expected " + gitBlobPin + ", found " + actualBlob);
}
}
function validateIndex(index, source) {
if (!index || typeof index !== "object" || Array.isArray(index)) throw new Error("Archive Spell index is not an object");
if (index.table !== "Spell" || index.file !== "Spell.dbc") throw new Error("Archive Spell index is not the Spell table");
if (index.sourceSha256 !== source.sourceDbcSha256) {
throw new Error("Archive Spell index is not the pinned BASE patch-T Spell.dbc source");
}
if (index.rows !== source.rows || index.columns !== source.columns) {
throw new Error("Archive Spell index row or column count does not match the pinned source");
}
if (index.format !== "gzip" || index.shardKey?.column !== "f0"
|| index.shardKey?.mode !== "column" || index.shardKey?.unsigned !== true) {
throw new Error("Archive Spell index does not use the pinned unsigned f0 shard layout");
}
if (!Array.isArray(index.shards) || index.shards.length === 0) throw new Error("Archive Spell index has no shard list");
const names = new Set();
const ranges = [];
for (const shard of index.shards) {
const name = assertSafeRelativePath(shard?.file, "Archive Spell shard path");
const match = /^([0-9]+)-([0-9]+)\.jsonl\.gz$/.exec(name);
if (!match || name.includes("/")) throw new Error("Archive Spell index has an invalid shard filename: " + name);
if (names.has(name)) throw new Error("Archive Spell index repeats shard " + name);
names.add(name);
const lo = Number(match[1]);
const hi = Number(match[2]);
if (lo !== shard.lo || hi !== shard.hi || !Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || hi < lo) {
throw new Error("Archive Spell shard range disagrees with filename: " + name);
}
if (!Number.isSafeInteger(shard.rows) || shard.rows < 0
|| !/^[0-9a-f]{64}$/i.test(shard.sha256 ?? "") || shard.format !== "gzip") {
throw new Error("Archive Spell index has invalid shard metadata for " + name);
}
ranges.push({ lo, hi, name });
}
ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi);
for (let index = 1; index < ranges.length; index += 1) {
if (ranges[index].lo <= ranges[index - 1].hi) throw new Error("Archive Spell index has overlapping shard ranges");
}
return index;
}
function fieldTypesFromColumnInfo(columnInfo, source) {
const columns = columnInfo?.columns;
if (!columnInfo || typeof columnInfo !== "object" || !Array.isArray(columns)) {
throw new Error("Pinned BASE Spell.colinfo.json has no columns array");
}
if (columnInfo.table !== "Spell" || columnInfo.columnCount !== source.columns || columns.length !== source.columns
|| columnInfo.source?.winner !== "Data/patch-T.MPQ"
|| columnInfo.source?.sha256 !== source.sourceDbcSha256
|| columnInfo.header?.records !== source.rows) {
throw new Error("Pinned BASE Spell.colinfo.json does not describe all Spell fields");
}
const fieldTypes = Array(source.columns);
for (const column of columns) {
const index = Number(column?.index);
const type = column?.inferred;
if (!Number.isSafeInteger(index) || index < 0 || index >= source.columns || fieldTypes[index] !== undefined) {
throw new Error("Pinned BASE Spell.colinfo.json has an invalid or duplicate field index");
}
if (!["int", "float", "string", "unknown"].includes(type)) {
throw new Error("Pinned BASE Spell.colinfo.json has an unsupported inferred type at f" + index);
}
fieldTypes[index] = type;
}
if (fieldTypes.some((type) => type === undefined)) throw new Error("Pinned BASE Spell.colinfo.json is missing a field type");
return fieldTypes;
}
function parseShard(bytes, shard, source) {
if (sha256(bytes) !== shard.sha256.toLowerCase()) {
throw new Error("Cached Archive Spell shard SHA-256 mismatch: " + shard.file);
}
let text;
try {
text = gunzipSync(bytes).toString("utf8");
} catch (error) {
throw new Error("Archive Spell shard is not valid gzip data: " + shard.file, { cause: error });
}
const rows = [];
const ids = new Set();
for (const [lineIndex, line] of text.split("\n").entries()) {
if (line.length === 0) continue;
const row = parseJson(Buffer.from(line, "utf8"), shard.file + ":" + (lineIndex + 1));
if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("Archive Spell row is not an object in " + shard.file);
const id = Number(row.f0);
if (!Number.isSafeInteger(id) || id < shard.lo || id > shard.hi || ids.has(id)) {
throw new Error("Archive Spell row has an invalid or duplicate f0 in " + shard.file);
}
for (let field = 0; field < source.columns; field += 1) {
if (!Object.hasOwn(row, "f" + field)) throw new Error("Archive Spell row is missing f" + field + " in " + shard.file);
}
ids.add(id);
rows.push(row);
}
if (rows.length !== shard.rows) throw new Error("Archive Spell shard row count mismatch: " + shard.file);
rows.sort((a, b) => Number(a.f0) - Number(b.f0));
return rows;
}
function positiveSpellIds(input) {
if (!Array.isArray(input)) throw new TypeError("spellIds must be an array");
const ids = input.map((value) => Number(value));
if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
throw new TypeError("spellIds must contain positive safe integers");
}
return [...new Set(ids)].sort((a, b) => a - b);
}
function childrenFromRow(row) {
const children = [];
for (let effect = 0; effect < 3; effect += 1) {
const triggerId = Number(row["f" + (116 + effect)]);
if (Number.isSafeInteger(triggerId) && triggerId > 0) children.push(triggerId);
if (Number(row["f" + (71 + effect)]) === 168) {
const itemSpellId = Number(row["f" + (110 + effect)]);
if (Number.isSafeInteger(itemSpellId) && itemSpellId > 0) children.push(itemSpellId);
}
}
return children;
}
function descriptorForId(index, id) {
return index.shards.find((shard) => id >= shard.lo && id <= shard.hi) ?? null;
}
function validateManifest(manifest, source, index) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("Archive Spell cache manifest is not an object");
if (manifest.schemaVersion !== cacheSchemaVersion) throw new Error("Unsupported Archive Spell cache schema");
if (JSON.stringify(manifest.source) !== JSON.stringify(sourceRecord(source))) {
throw new Error("Archive Spell cache manifest points at an unexpected source or patch variant");
}
if (!Array.isArray(manifest.requestedIds) || !Array.isArray(manifest.files)) {
throw new Error("Archive Spell cache manifest is missing requestedIds or files");
}
const requestedIds = positiveSpellIds(manifest.requestedIds);
if (requestedIds.length !== manifest.requestedIds.length
|| requestedIds.some((id, position) => id !== manifest.requestedIds[position])) {
throw new Error("Archive Spell cache manifest requested ids must be sorted and unique");
}
const byFile = new Map(index.shards.map((shard) => [shard.file, shard]));
const names = new Set();
for (const file of manifest.files) {
const name = assertSafeRelativePath(file?.path, "Archive Spell manifest shard path");
const shard = byFile.get(name);
if (!shard || names.has(name) || file.sha256 !== shard.sha256
|| file.archivePath !== source.variantPath + "/" + name
|| file.lo !== shard.lo || file.hi !== shard.hi || file.rows !== shard.rows
|| !/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")
|| !Number.isSafeInteger(file.size) || file.size < 0) {
throw new Error("Archive Spell cache manifest contains an unknown or inconsistent shard: " + name);
}
names.add(name);
}
return { requestedIds, files: manifest.files };
}
export function createArchiveSpellCacheApi(sourceInput = ARCHIVE_SPELL_SOURCE) {
const source = validateSource({ ...sourceInput });
function archiveDirectory(cacheRoot) {
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) throw new TypeError("cacheRoot must be a non-empty directory path");
return path.join(path.resolve(cacheRoot), "archive-" + source.shortRevision, "spell-base");
}
function rawUrl(filePath) {
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
return "https://raw.githubusercontent.com/" + source.repository + "/" + source.revision + "/" + encodedPath;
}
async function responseBytes(fetchImpl, url) {
let response;
try {
response = await fetchImpl(url, { headers: { Accept: "application/json, application/gzip" } });
} catch (error) {
throw new Error("Archive Spell source unavailable at " + url + ": " + error.message, { cause: error });
}
if (!response || response.ok !== true) {
const status = response?.status === undefined ? "no response" : "HTTP " + response.status;
throw new Error("Archive Spell source unavailable at " + url + " (" + status + ")");
}
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
throw new Error("Archive Spell source response has no readable body at " + url);
}
async function writeAtomic(filePath, bytes) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = filePath + ".tmp-" + process.pid;
await fs.writeFile(tempPath, bytes);
await fs.rename(tempPath, filePath);
}
async function cachedPinnedBytes(filePath, shaPin, blobPin) {
try {
const bytes = await fs.readFile(filePath);
assertPinnedBytes(bytes, shaPin, blobPin, path.basename(filePath));
return bytes;
} catch (error) {
if (error?.code === "ENOENT") return null;
if (error?.code && error.code !== "ENOENT") throw error;
return null;
}
}
async function ensureMetadata(cacheRoot, fetchImpl) {
const root = archiveDirectory(cacheRoot);
const indexCachePath = path.join(root, "Spell.index.json");
const colinfoCachePath = path.join(root, "Spell.colinfo.json");
let [indexBytes, colinfoBytes] = await Promise.all([
cachedPinnedBytes(indexCachePath, source.tableIndexSha256, source.tableIndexGitBlobSha),
cachedPinnedBytes(colinfoCachePath, source.columnInfoSha256, source.columnInfoGitBlobSha),
]);
if (!indexBytes) {
indexBytes = await cachedPinnedBytes(
path.join(path.dirname(root), "remote-audit", "raw_tables_Spell_variants_data-patch-t-mpq_index.json"),
source.tableIndexSha256,
source.tableIndexGitBlobSha,
);
if (indexBytes) await writeAtomic(indexCachePath, indexBytes);
}
if (!colinfoBytes) {
colinfoBytes = await cachedPinnedBytes(
path.join(path.dirname(root), "remote-audit", "base_Spell.colinfo.json"),
source.columnInfoSha256,
source.columnInfoGitBlobSha,
);
if (colinfoBytes) await writeAtomic(colinfoCachePath, colinfoBytes);
}
if (!indexBytes) {
indexBytes = await responseBytes(fetchImpl, rawUrl(source.tableIndexPath));
assertPinnedBytes(indexBytes, source.tableIndexSha256, source.tableIndexGitBlobSha, "BASE Spell index");
await writeAtomic(indexCachePath, indexBytes);
}
if (!colinfoBytes) {
colinfoBytes = await responseBytes(fetchImpl, rawUrl(source.columnInfoPath));
assertPinnedBytes(colinfoBytes, source.columnInfoSha256, source.columnInfoGitBlobSha, "BASE Spell.colinfo.json");
await writeAtomic(colinfoCachePath, colinfoBytes);
}
const index = validateIndex(parseJson(indexBytes, source.tableIndexPath), source);
const columnInfo = parseJson(colinfoBytes, source.columnInfoPath);
const fieldTypes = fieldTypesFromColumnInfo(columnInfo, source);
return { root, index, fieldTypes };
}
async function readShard(root, shard, sourceInputForRows) {
const shardPath = path.join(root, "shards", shard.file);
const bytes = await fs.readFile(shardPath);
return { bytes, rows: parseShard(bytes, shard, sourceInputForRows) };
}
async function loadManifest(root, index) {
const manifestPath = path.join(root, manifestName);
let bytes;
try {
bytes = await fs.readFile(manifestPath);
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
return validateManifest(parseJson(bytes, manifestPath), source, index);
}
async function closeSpellIds({ index, requestedIds, root, fetchImpl, allowFetch }) {
const records = new Map();
const missing = new Set();
const selectedShards = new Map();
const loadedRows = new Map();
const pending = [...requestedIds];
const visited = new Set();
for (let cursor = 0; cursor < pending.length; cursor += 1) {
const id = pending[cursor];
if (visited.has(id)) continue;
visited.add(id);
const descriptor = descriptorForId(index, id);
if (!descriptor) {
missing.add(id);
continue;
}
selectedShards.set(descriptor.file, descriptor);
let rows = loadedRows.get(descriptor.file);
if (!rows) {
const localPath = path.join(root, "shards", descriptor.file);
let bytes = null;
let localBytesFound = false;
const candidatePaths = allowFetch
? [localPath, path.join(path.dirname(root), "remote-audit", "base-spell-shards", descriptor.file)]
: [localPath];
for (const candidatePath of candidatePaths) {
try {
const candidate = await fs.readFile(candidatePath);
if (candidatePath === localPath) localBytesFound = true;
if (sha256(candidate) !== descriptor.sha256.toLowerCase()) {
if (!allowFetch && candidatePath === localPath) {
throw new Error("Cached Archive Spell shard SHA-256 mismatch: " + descriptor.file);
}
continue;
}
bytes = candidate;
if (candidatePath !== localPath) await writeAtomic(localPath, bytes);
break;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
if (!bytes && allowFetch) {
bytes = await responseBytes(fetchImpl, rawUrl(source.variantPath + "/" + descriptor.file));
if (sha256(bytes) !== descriptor.sha256.toLowerCase()) {
throw new Error("BASE Archive Spell shard SHA-256 mismatch: " + descriptor.file);
}
await writeAtomic(localPath, bytes);
}
if (!bytes) {
const detail = localBytesFound ? " has invalid cached bytes" : " is missing";
throw new Error("Archive Spell cache shard " + descriptor.file + detail + "; run downloadArchiveSpells first");
}
rows = parseShard(bytes, descriptor, source);
loadedRows.set(descriptor.file, rows);
}
const row = rows.find((candidate) => Number(candidate.f0) === id);
if (!row) {
missing.add(id);
continue;
}
records.set(id, row);
for (const child of childrenFromRow(row)) if (!visited.has(child)) pending.push(child);
}
return {
records: [...records.values()].sort((a, b) => Number(a.f0) - Number(b.f0)),
missingIds: [...missing].sort((a, b) => a - b),
selectedShards,
};
}
function manifestFile(shard, bytes, source) {
return {
path: shard.file,
archivePath: source.variantPath + "/" + shard.file,
lo: shard.lo,
hi: shard.hi,
rows: shard.rows,
sha256: shard.sha256,
gitBlobSha: gitBlobSha(bytes),
size: bytes.byteLength,
};
}
async function readArchiveSpells({ cacheRoot }) {
const root = archiveDirectory(cacheRoot);
let indexBytes;
let colinfoBytes;
try {
[indexBytes, colinfoBytes] = await Promise.all([
fs.readFile(path.join(root, "Spell.index.json")),
fs.readFile(path.join(root, "Spell.colinfo.json")),
]);
} catch (error) {
if (error?.code === "ENOENT") throw new Error("Archive Spell cache metadata is missing; run downloadArchiveSpells first");
throw error;
}
assertPinnedBytes(indexBytes, source.tableIndexSha256, source.tableIndexGitBlobSha, "Cached BASE Spell index");
assertPinnedBytes(colinfoBytes, source.columnInfoSha256, source.columnInfoGitBlobSha, "Cached BASE Spell.colinfo.json");
const index = validateIndex(parseJson(indexBytes, source.tableIndexPath), source);
const fieldTypes = fieldTypesFromColumnInfo(parseJson(colinfoBytes, source.columnInfoPath), source);
const manifest = await loadManifest(root, index);
if (!manifest) throw new Error("Archive Spell cache manifest is missing; run downloadArchiveSpells first");
const closure = await closeSpellIds({ index, requestedIds: manifest.requestedIds, root, allowFetch: false });
const declared = new Map(manifest.files.map((file) => [file.path, file]));
if (declared.size !== closure.selectedShards.size
|| [...closure.selectedShards.keys()].some((name) => !declared.has(name))) {
throw new Error("Archive Spell cache manifest does not cover the complete selected spell closure");
}
const files = [];
for (const shard of [...closure.selectedShards.values()].sort((a, b) => a.lo - b.lo)) {
const { bytes } = await readShard(root, shard, source);
const file = manifestFile(shard, bytes, source);
const expected = declared.get(shard.file);
if (file.gitBlobSha !== expected.gitBlobSha || file.size !== expected.size) {
throw new Error("Archive Spell cached shard does not match its manifest: " + shard.file);
}
files.push(file);
}
return {
source: resultSource(source, files),
fieldTypes,
records: closure.records,
files,
missingIds: closure.missingIds,
};
}
async function downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl = globalThis.fetch }) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function");
const newIds = positiveSpellIds(spellIds);
const { root, index, fieldTypes } = await ensureMetadata(cacheRoot, fetchImpl);
const previous = await loadManifest(root, index);
const requestedIds = positiveSpellIds([...(previous?.requestedIds ?? []), ...newIds]);
const closure = await closeSpellIds({ index, requestedIds, root, fetchImpl, allowFetch: true });
const files = [];
for (const shard of [...closure.selectedShards.values()].sort((a, b) => a.lo - b.lo)) {
const { bytes } = await readShard(root, shard, source);
files.push(manifestFile(shard, bytes, source));
}
const manifest = {
schemaVersion: cacheSchemaVersion,
source: sourceRecord(source),
requestedIds,
files,
};
await writeAtomic(path.join(root, manifestName), Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"));
return {
source: resultSource(source, files),
fieldTypes,
records: closure.records,
files,
missingIds: closure.missingIds,
};
}
return Object.freeze({ downloadArchiveSpells, readArchiveSpells });
}
const defaultArchiveSpellApi = createArchiveSpellCacheApi();
export function downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl = globalThis.fetch }) {
return defaultArchiveSpellApi.downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl });
}
export function readArchiveSpells({ cacheRoot }) {
return defaultArchiveSpellApi.readArchiveSpells({ cacheRoot });
}
@@ -0,0 +1,252 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gzipSync } from "node:zlib";
import { ARCHIVE_SPELL_SOURCE, createArchiveSpellCacheApi } from "./archive-spells.mjs";
const fixtureSourceSha = "b".repeat(64);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from("blob " + bytes.byteLength + "\0", "utf8"))
.update(bytes)
.digest("hex");
}
function spellRow(id, options = {}) {
const row = {};
for (let field = 0; field < 234; field += 1) row["f" + field] = 0;
row.f0 = id;
row.f41 = 1;
row.f47 = 1.5;
row.f71 = options.effectType ?? 0;
row.f110 = options.miscValue ?? 0;
row.f116 = options.triggerSpellId ?? 0;
row.f136 = options.name ?? "Test spell " + id;
row.f136i = id * 10;
row.f153 = "Passive";
row.f153i = id * 10 + 1;
row.f170 = "Description for " + id;
row.f170i = id * 10 + 2;
row.f187 = "Alternate " + id;
row.f187i = id * 10 + 3;
row.f229 = 0.25;
return row;
}
function makeFixture({ sourceDbcSha256 = fixtureSourceSha } = {}) {
const shardRows = [
{ lo: 1, hi: 99, rows: [spellRow(1, { triggerSpellId: 101 })] },
{ lo: 100, hi: 199, rows: [
spellRow(101, { effectType: 168, miscValue: 202 }),
spellRow(155),
] },
{ lo: 200, hi: 299, rows: [spellRow(202)] },
{ lo: 300, hi: 399, rows: [spellRow(350)] },
];
const shards = [];
const archiveFiles = new Map();
for (const group of shardRows) {
const name = String(group.lo).padStart(8, "0") + "-" + String(group.hi).padStart(8, "0") + ".jsonl.gz";
const bytes = gzipSync(Buffer.from(group.rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"), { level: 9, mtime: 0 });
archiveFiles.set(name, bytes);
shards.push({
file: name,
format: "gzip",
lo: group.lo,
hi: group.hi,
plainBytes: 1,
rows: group.rows.length,
sha256: sha256(bytes),
storedBytes: bytes.byteLength,
});
}
const index = {
file: "Spell.dbc",
format: "gzip",
rows: 5,
columns: 234,
table: "Spell",
sourceSha256: sourceDbcSha256,
shardKey: { column: "f0", maxRows: 5000, mode: "column", unsigned: true },
shards,
};
const columnInfo = {
table: "Spell",
columnCount: 234,
header: { records: 5 },
source: { winner: "Data/patch-T.MPQ", sha256: sourceDbcSha256 },
columns: Array.from({ length: 234 }, (_, index) => ({
index,
inferred: index === 47 || index === 229 ? "float" : [136, 153, 170, 187].includes(index) ? "string" : "int",
})),
};
const indexBytes = Buffer.from(JSON.stringify(index), "utf8");
const colinfoBytes = Buffer.from(JSON.stringify(columnInfo), "utf8");
const variantPath = "raw/tables/Spell/variants/data-patch-t-mpq";
const source = {
id: "fixture-global-spell-base",
repository: "example/coa-datamine",
revision: "a".repeat(40),
shortRevision: "aaaaaaaaaaaa",
capturedAt: "2026-08-12T21:02:51+00:00",
variantPath,
tableIndexPath: variantPath + "/index.json",
tableIndexSha256: sha256(indexBytes),
tableIndexGitBlobSha: gitBlobSha(indexBytes),
columnInfoPath: variantPath + "/Spell.colinfo.json",
columnInfoSha256: sha256(colinfoBytes),
columnInfoGitBlobSha: gitBlobSha(colinfoBytes),
sourceDbcSha256,
rows: 5,
columns: 234,
};
const prefix = "https://raw.githubusercontent.com/" + source.repository + "/" + source.revision + "/";
const metadataFiles = new Map([
[prefix + source.tableIndexPath, indexBytes],
[prefix + source.columnInfoPath, colinfoBytes],
]);
for (const [name, bytes] of archiveFiles) metadataFiles.set(prefix + variantPath + "/" + name, bytes);
return { source, archiveFiles, files: metadataFiles, prefix, variantPath, indexBytes, colinfoBytes };
}
function fakeFetch(fileMap, calls) {
return async (url) => {
calls.push(url);
const bytes = fileMap.get(url);
if (!bytes) return { ok: false, status: 404 };
return {
ok: true,
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
};
};
}
async function withTempCache(run) {
const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), "coa-archive-spells-"));
try {
await run(cacheRoot);
} finally {
await fs.rm(cacheRoot, { recursive: true, force: true });
}
}
test("pins and reads the global patch-T source and closes trigger and effect-168 references", async () => {
assert.equal(ARCHIVE_SPELL_SOURCE.variantPath, "raw/tables/Spell/variants/data-patch-t-mpq");
assert.equal(ARCHIVE_SPELL_SOURCE.sourceDbcSha256, "a1e4285cdbc8a269a1b8a3f4b304ee43eb4d1bfa5a98511639868c4ff6097623");
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const api = createArchiveSpellCacheApi(fixture.source);
const calls = [];
const fetchImpl = fakeFetch(fixture.files, calls);
const downloaded = await api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl });
assert.deepEqual(downloaded.records.map((row) => row.f0), [1, 101, 202]);
assert.deepEqual(downloaded.missingIds, []);
assert.deepEqual(downloaded.fieldTypes.slice(0, 3), ["int", "int", "int"]);
assert.equal(downloaded.fieldTypes[47], "float");
assert.equal(downloaded.fieldTypes[136], "string");
assert.equal(downloaded.source.path, fixture.variantPath);
assert.equal(downloaded.source.sourceDbcSha256, fixture.source.sourceDbcSha256);
assert.equal(downloaded.source.originalRecordCount, 5);
assert.equal(downloaded.source.bytes, downloaded.files.reduce((sum, file) => sum + file.size, 0));
assert.equal(downloaded.files.length, 3);
assert.ok(downloaded.files.every((file) => file.archivePath.startsWith(fixture.variantPath + "/")));
assert.equal(calls.filter((url) => url.includes("/variants/") && url.endsWith(".jsonl.gz")).length, 3);
assert.ok(!calls.some((url) => url.includes("/variants/") && url.includes("00000300-")));
const offline = await api.readArchiveSpells({ cacheRoot });
assert.deepEqual(offline.records, downloaded.records);
assert.deepEqual(offline.fieldTypes, downloaded.fieldTypes);
assert.deepEqual(offline.missingIds, []);
});
});
test("resumes selected shards, repairs tampered cached bytes, and reports absent ids", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const api = createArchiveSpellCacheApi(fixture.source);
const calls = [];
const fetchImpl = fakeFetch(fixture.files, calls);
await api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl });
const firstCallCount = calls.length;
const resumed = await api.downloadArchiveSpells({ cacheRoot, spellIds: [350, 999], fetchImpl });
assert.deepEqual(resumed.records.map((row) => row.f0), [1, 101, 202, 350]);
assert.deepEqual(resumed.missingIds, [999]);
assert.equal(calls.length, firstCallCount + 1);
const cachedShard = path.join(cacheRoot, "archive-" + fixture.source.shortRevision, "spell-base", "shards", "00000001-00000099.jsonl.gz");
await fs.writeFile(cachedShard, Buffer.from("tampered"));
await assert.rejects(api.readArchiveSpells({ cacheRoot }), /SHA-256 mismatch/);
const beforeRepair = calls.length;
const repaired = await api.downloadArchiveSpells({ cacheRoot, spellIds: [350], fetchImpl });
assert.deepEqual(repaired.records.map((row) => row.f0), [1, 101, 202, 350]);
assert.equal(calls.length, beforeRepair + 1);
const raw = await fs.readFile(cachedShard);
assert.equal(sha256(raw), fixture.archiveFiles.get("00000001-00000099.jsonl.gz") && sha256(fixture.archiveFiles.get("00000001-00000099.jsonl.gz")));
});
});
test("reuses pin-matching remote-audit BASE metadata and shards without a network fetch", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const archiveRoot = path.join(cacheRoot, "archive-" + fixture.source.shortRevision);
const remoteAudit = path.join(archiveRoot, "remote-audit");
const shardDirectory = path.join(remoteAudit, "base-spell-shards");
await fs.mkdir(shardDirectory, { recursive: true });
await fs.writeFile(path.join(remoteAudit, "raw_tables_Spell_variants_data-patch-t-mpq_index.json"), fixture.indexBytes);
await fs.writeFile(path.join(remoteAudit, "base_Spell.colinfo.json"), fixture.colinfoBytes);
for (const name of [
"00000001-00000099.jsonl.gz",
"00000100-00000199.jsonl.gz",
"00000200-00000299.jsonl.gz",
]) {
await fs.writeFile(path.join(shardDirectory, name), fixture.archiveFiles.get(name));
}
const calls = [];
const result = await createArchiveSpellCacheApi(fixture.source).downloadArchiveSpells({
cacheRoot,
spellIds: [1],
fetchImpl: async (url) => {
calls.push(url);
throw new Error("unexpected network request");
},
});
assert.equal(calls.length, 0);
assert.deepEqual(result.records.map((row) => row.f0), [1, 101, 202]);
assert.deepEqual(result.files.map((file) => file.path), [
"00000001-00000099.jsonl.gz",
"00000100-00000199.jsonl.gz",
"00000200-00000299.jsonl.gz",
]);
});
});
test("rejects an overlay index even when its metadata hashes match the supplied bytes", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const overlayFixture = makeFixture({ sourceDbcSha256: "c".repeat(64) });
const wrongIndexUrl = fixture.prefix + fixture.source.tableIndexPath;
fixture.files.set(wrongIndexUrl, overlayFixture.files.get(wrongIndexUrl));
const overlayBytes = fixture.files.get(wrongIndexUrl);
fixture.source.tableIndexSha256 = sha256(overlayBytes);
fixture.source.tableIndexGitBlobSha = gitBlobSha(overlayBytes);
const api = createArchiveSpellCacheApi(fixture.source);
await assert.rejects(
api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl: fakeFetch(fixture.files, []) }),
/pinned BASE patch-T Spell\.dbc source/,
);
});
});
File diff suppressed because it is too large. Load diff
+207
View File
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs";
const gameIds = [
"barbarian", "witch-doctor", "felsworn", "witch-hunter", "stormbringer", "knight-of-xoroth",
"guardian", "templar", "bloodmage", "ranger", "chronomancer", "necromancer", "pyromancer",
"cultist", "starcaller", "sun-cleric", "tinker", "venomancer", "reaper", "primalist", "runemaster",
];
function sourceRow(id = "base-spell") {
return { id, path: "src/spells.dbc", sha256: "a".repeat(64), bytes: 100, kind: "dbc" };
}
function spell(id, triggerSpellId = 0, name = "Spell " + id) {
const dbc = {
id, name, rankText: "Rank 1", description: "A test spell.", castTimeMs: 0, procChance: 100,
effects: [{ index: 0, effectType: triggerSpellId ? 64 : 2, auraType: 0, basePoints: 1, triggerSpellId }],
};
return { id, name, rank: "Rank 1", description: dbc.description, dbc, source: "base", classIds: [12] };
}
function node(id, overrides = {}) {
return {
id, classId: 12, tabId: 1, x: 0, y: 0, name: "Node " + id,
nodeType: "SpendSquare", entryType: "Talent", isPassive: false, maxPoints: 1,
aeCost: 0, teCost: 1, requiredClassPoints: 0, requiredSpecPoints: 0,
requiredIds: [], connectedNodeIds: [], requiredLevel: 0, isStartingNode: false,
iconBasename: "test", flags: 0, group: 7, sortOrder: id, description: "A test mechanic.",
rankDescriptions: [{ rank: 1, spellId: id === 100 ? 200 : 201, description: "A test mechanic." }],
...overrides,
};
}
function referenceFixture() {
return {
schemaVersion: 1,
baseline: { kind: "local-july", capturedAt: "2026-07-01T00:00:00Z" },
classes: gameIds.map((gameId, index) => ({
classId: index + 12, gameId, className: gameId, tabs: index === 0
? [{ tabId: 1, tabName: "First", sortOrder: 1 }]
: [],
})),
nodes: [node(100), node(101, { group: 7 })],
spells: [spell(200, 201), spell(201)],
progressionChains: [{
id: "chain-a", classId: 12, name: "Test chain", iconBasename: "test",
talented: false, advancementType: "Ability", trainer: false,
ranks: [{ rank: 1, spellId: 200, level: 1 }],
}],
sources: [sourceRow()],
diagnostics: [],
abilities: [{
key: "alias-map", classId: "DemonHunter", name: "Alias fixture",
spellIds: [200], nodeIds: [], rankSpellIds: [201], live: null, evidence: "DBC class alias maps to CoA ID 14.",
}],
};
}
function writeJson(root, relative, value) {
const full = path.join(root, relative);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, JSON.stringify(value) + "\n");
}
function makeProjectRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-audit-"));
writeJson(root, "src/game/coaLiveData.generated.json", {
spells: [
{ id: 200, dbc: { ...spell(200).dbc, castTimeMs: 1500 } },
{ id: 201, dbc: spell(201).dbc },
{ id: 203, dbc: spell(203).dbc },
],
});
writeJson(root, "src/game/generated/coaAbilityRuntimeCatalog.json", {
schemaVersion: 1,
abilitiesByClass: {
barbarian: [{ id: "coa-tree-100", classId: "barbarian", talentEntryId: 100, dbcSpellId: 200 }],
},
resourcesByClass: { barbarian: { primary: "energy" } },
triggeredAbilitiesBySpellId: { "201": { id: "coa-trigger-201", classId: "barbarian", dbcSpellId: 201 } },
});
writeJson(root, "src/game/generated/talentRuntimeCatalog.json", {
schemaVersion: 1,
trees: [{ id: "coa-barbarian-1", classId: "barbarian" }],
nodes: [{ id: "coa-entry-100", index: 100, treeId: "coa-barbarian-1", system: "coa" }],
});
writeJson(root, "src/game/generated/coaTriggeredSpellCatalog.json", {
schemaVersion: 1,
spells: [spell(201).dbc],
});
return root;
}
test("validation resolves DBC class aliases and flags unresolved references as incomplete warnings", () => {
const reference = referenceFixture();
reference.nodes[0].requiredIds = [999];
reference.nodes[0].rankDescriptions[0].spellId = 999;
reference.spells[1].dbc.effects[0].triggerSpellId = 998;
const result = validateReference(reference);
assert.equal(result.valid, true);
assert.equal(result.complete, false);
assert.equal(result.counts.missingRankSpells, 1);
assert.equal(result.counts.missingPrerequisites, 1);
assert.equal(result.counts.missingTriggeredSpells, 1);
assert(result.warnings.some((item) => item.code === "missing-rank-spell" && item.spellId === 999));
assert(result.warnings.some((item) => item.code === "missing-prerequisite" && item.targetId === 999));
assert(result.warnings.some((item) => item.code === "unknown-triggered-spell" && item.spellId === 998));
assert(!result.errors.some((item) => item.code === "invalid-ability-class-id"));
});
test("validation reports malformed and duplicate keys, cross-class edges, and invalid choice groups as errors", () => {
const reference = referenceFixture();
reference.classes[1].tabs = [{ tabId: 2, tabName: "Second", sortOrder: 2 }];
reference.nodes[0].requiredIds = [300];
reference.nodes.push(node(300, { classId: 13, tabId: 2, group: 7 }));
reference.nodes.push(node(100));
reference.nodes[1].tabId = 2;
reference.nodes[1].requiredIds = [0];
reference.sources.push(sourceRow());
const result = validateReference(reference);
assert.equal(result.valid, false);
assert(result.errors.some((item) => item.code === "duplicate-node-key"));
assert(result.errors.some((item) => item.code === "cross-class-node-reference" && item.targetId === 300));
assert(result.errors.some((item) => item.code === "choice-group-cross-tab"));
assert(result.errors.some((item) => item.code === "invalid-node-reference-id"));
assert(result.errors.some((item) => item.code === "duplicate-source-key"));
});
test("audit compares July and August without merging, checks DBC fields, hashes inputs, and avoids implementation claims", () => {
const projectRoot = makeProjectRoot();
try {
const reference = referenceFixture();
reference.spells.push({ id: 202, name: "Placeholder", rank: "", description: "", source: "skill-line", classIds: [12] });
reference.sources[0].path = "src/game/coaLiveData.generated.json";
const supplements = {
source: { revision: "5078f9ac22541c45c17a866c23aac6c12073de23", capturedAt: "2026-08-12T21:02:51+00:00" },
classes: [{
classId: 12,
tabs: [{
tabId: 1, tabName: "First", sortOrder: 1, maxReqTabAE: 4, maxReqTabTE: 5,
nodes: [
{ ...node(100), name: "Changed name", reqTabAE: 0, reqTabTE: 1, isPassive: 0, isStartingNode: 0, spellId: 200, spellIds: [200], iconPath: "Interface/Icons/TEST.blp", rankDescriptions: [{ rank: 1, spellId: 200, description: "<span>A test mechanic.</span>" }] },
{ ...node(102), spellId: 202, spellIds: [202] },
],
}],
}],
manifest: { files: [{ path: "src/game/coaLiveData.generated.json", sha256: "b".repeat(64) }] },
metadata: { provenance: { source: "archive" }, clientSnapshot: { capturedAt: "2026-08-01T00:00:00Z" }, spells: { revision: "spell-set-1" } },
};
const report = auditReference({ reference, supplements, projectRoot });
const julyAugust = report.comparisons.julyToAugust;
assert.deepEqual(julyAugust.counts, {
treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0,
nodesAdded: 1, nodesRemoved: 1, nodesChanged: 1,
});
assert.deepEqual(julyAugust.changes.nodes[0].changedFields, ["name"]);
assert.equal(report.counts.nodes, 2);
assert.equal(report.classes.find((item) => item.classId === 12).counts.nodes, 2);
assert.equal(report.classes.find((item) => item.classId === 12).counts.spells, 3);
assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).runtimeAbilityMappings, 1);
assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).talentRuntimeNodes, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.baseRecords, 2);
assert.equal(report.comparisons.baseDbcToArea52.counts.baseMissingDbcRecords, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.area52Records, 3);
assert.equal(report.comparisons.baseDbcToArea52.counts.area52Only, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.changedRecords, 1);
assert.deepEqual(report.comparisons.baseDbcToArea52.changedFields, { castTimeMs: 1, effects: 1 });
assert.deepEqual(report.rankIdentityCoverage.nodeRanks, { missingSpellRecords: 0, missingBaseDbcRows: 0 });
assert.equal(report.supplementalFreshness.sourceHashes.changed, 1);
assert.equal(report.runtimeEvidence.behavioralMechanics.status, "not-assessed");
assert.deepEqual(report.runtimeEvidence.behavioralMechanics.provenImplementedClaims, []);
assert(report.classes.every((item) => item.backlog.some((backlog) => backlog.priority === "player-companion-mechanics")));
const markdown = renderAuditMarkdown(report);
assert(markdown.includes("1 reference spell records do not include a base DBC row"));
assert(markdown.includes("player or companion"));
assert(markdown.includes("No August supplemental cache") === false);
assert(markdown.indexOf("Changed spell samples:\n\n-") >= 0);
assert(markdown.indexOf("July/August tree and node samples:\n\n-") >= 0);
assert(!markdown.includes("implemented: yes"));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
test("missing supplements are explicitly reported and preserve baseline data", () => {
const projectRoot = makeProjectRoot();
try {
const reference = referenceFixture();
const report = auditReference({ reference, supplements: null, projectRoot });
assert.equal(report.comparisons.julyToAugust.status, "absent");
assert.equal(report.supplementalCache.status, "absent");
assert.deepEqual(report.comparisons.julyToAugust.counts, {
treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0, nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0,
});
assert.equal(renderAuditMarkdown(report).includes("No supplemental cache was supplied"), true);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { gzipSync, gunzipSync } from "node:zlib";
export const julyBaselinePath = "data/coa-reference/baselines/coa-july-2026-07-25.json.gz";
export const activeCatalogPath = "src/game/coaLiveData.generated.json";
export function resolveBaselinePath(projectRoot) {
return fs.existsSync(path.join(projectRoot, julyBaselinePath)) ? julyBaselinePath : activeCatalogPath;
}
export function decodeJsonBytes(bytes, filePath) {
return filePath.endsWith(".gz") ? gunzipSync(bytes) : bytes;
}
/** Preserve the original capture once; never replace it with a promoted catalog. */
export function freezeJulyBaseline(projectRoot) {
const destination = path.join(projectRoot, julyBaselinePath);
if (fs.existsSync(destination)) return destination;
const bytes = fs.readFileSync(path.join(projectRoot, activeCatalogPath));
const capture = JSON.parse(bytes.toString("utf8"));
if (capture.source?.synchronizedAt !== "2026-07-25T05:24:40.596Z") {
throw new Error("Cannot freeze July baseline: active catalog is not the original July capture");
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, gzipSync(bytes, { level: 9 }), { flag: "wx" });
return destination;
}
+41
View File
@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { activeCatalogPath, decodeJsonBytes, freezeJulyBaseline, julyBaselinePath, resolveBaselinePath } from "./baseline.mjs";
test("frozen July capture remains unchanged after active promotion", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-baseline-"));
try {
const active = path.join(root, activeCatalogPath);
fs.mkdirSync(path.dirname(active), { recursive: true });
const original = Buffer.from(JSON.stringify({ source: { synchronizedAt: "2026-07-25T05:24:40.596Z" }, entries: [{ id: 1 }] }));
fs.writeFileSync(active, original);
assert.equal(resolveBaselinePath(root), activeCatalogPath);
const frozen = freezeJulyBaseline(root);
const bytes = fs.readFileSync(frozen);
assert.deepEqual(decodeJsonBytes(bytes, frozen), original);
fs.writeFileSync(active, '{"source":{"synchronizedAt":"2026-08-12"}}');
assert.equal(resolveBaselinePath(root), julyBaselinePath);
assert.equal(freezeJulyBaseline(root), frozen);
assert.deepEqual(fs.readFileSync(frozen), bytes);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("freezing rejects a later capture rather than mislabelling it July", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-baseline-"));
try {
const active = path.join(root, activeCatalogPath);
fs.mkdirSync(path.dirname(active), { recursive: true });
fs.writeFileSync(active, '{"source":{"synchronizedAt":"2026-08-12"}}');
assert.throws(() => freezeJulyBaseline(root), /not the original July/);
assert.equal(fs.existsSync(path.join(root, julyBaselinePath)), false);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
+150
View File
@@ -0,0 +1,150 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs";
import { downloadSupplements, readSupplements } from "./supplements.mjs";
import { lookupReference, publishSnapshot, readSnapshot } from "./snapshot.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const usage = `CoA offline reference
build Build and atomically publish a local-first reference and audit
fetch Explicitly cache the pinned August archive talent supplement
fetch-spells Cache missing global spell records and their linked payloads
audit Verify the current snapshot and repeat offline coverage checks
lookup Search the current snapshot by --class, --tree, --spell, --ability
inventory Inspect the resolved local source pair without changing files
promote Promote the verified August Vol'Jin cache into the active CoA catalog
Options:
--client-root PATH Existing Ascension client directory
--extracted-root PATH Existing LadiksMPQEditor directory (supply both roots)
--output-root PATH Default: data/coa-reference
--cache-root PATH Default: data/coa-reference/cache
--limit NUMBER Lookup results per collection, default 20
--refresh Fetch command: revalidate the pinned supplement
--help Show this help
Build, audit, lookup, inventory and promote never make network requests.
Only promote writes the active gameplay catalog. Original client files stay intact.
`;
export function parseArguments(args) {
const hasCommand = Boolean(args[0] && !args[0].startsWith("--"));
const result = { command: hasCommand ? args[0] : "help" };
const valueFlags = new Set(["client-root", "extracted-root", "output-root", "cache-root", "class", "tree", "spell", "ability", "limit"]);
for (let index = hasCommand ? 1 : 0; index < args.length; index += 1) {
const flag = args[index];
if (flag === "--help") result.command = "help";
else if (flag === "--refresh") result.refresh = true;
else if (flag.startsWith("--") && valueFlags.has(flag.slice(2))) {
if (!args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`Missing value for ${flag}`);
result[flag.slice(2)] = args[++index];
} else throw new Error(`Unknown argument: ${flag}`);
}
if (!["help", "build", "fetch", "fetch-spells", "audit", "lookup", "inventory", "promote"].includes(result.command)) {
throw new Error(`Unknown command: ${result.command}`);
}
if (Boolean(result["client-root"]) !== Boolean(result["extracted-root"])) {
throw new Error("Supply both --client-root and --extracted-root to avoid mixing source installations");
}
if (result.refresh && result.command !== "fetch") throw new Error("--refresh is only supported by fetch");
return result;
}
/** Return unresolved spell roots owned by CoA plus explicit August rank references. */
export function unresolvedCoaArchiveSpellIds(reference, extraNodes = []) {
const spellById = new Map((reference?.spells ?? []).map((spell) => [Number(spell.id), spell]));
const required = new Set();
const add = (value) => {
const id = Number(value);
if (Number.isSafeInteger(id) && id > 0) required.add(id);
};
for (const node of extraNodes) {
for (const rank of node?.rankDescriptions ?? []) add(rank?.spellId);
}
for (const chain of reference?.progressionChains ?? []) {
for (const rank of chain?.ranks ?? []) add(rank?.spellId);
}
for (const spell of reference?.spells ?? []) {
if ((spell?.classIds ?? []).some((classId) => Number(classId) > 11)) add(spell.id);
}
return [...required]
.filter((id) => !spellById.get(id)?.dbc)
.sort((a, b) => a - b);
}
export async function main(args = process.argv.slice(2)) {
const options = parseArguments(args);
if (options.command === "help") {
process.stdout.write(usage);
return;
}
const outputRoot = path.resolve(options["output-root"] ?? path.join(projectRoot, "data/coa-reference"));
const cacheRoot = path.resolve(options["cache-root"] ?? path.join(outputRoot, "cache"));
const sourceOptions = {
projectRoot, clientRoot: options["client-root"], extractedRoot: options["extracted-root"],
};
if (options.command === "inventory") {
console.log(JSON.stringify(resolveSourceRoots(sourceOptions), null, 2));
return;
}
if (options.command === "fetch") {
console.log(JSON.stringify(await downloadSupplements({ cacheRoot, refresh: options.refresh ?? false }), null, 2));
return;
}
if (options.command === "promote") {
const { promoteCachedCoa } = await import("./promotion-command.mjs");
console.log(JSON.stringify(await promoteCachedCoa({ ...sourceOptions, cacheRoot }), null, 2));
return;
}
if (options.command === "fetch-spells") {
const { downloadArchiveSpells } = await import("./archive-spells.mjs");
const { normalizeArchiveNode } = await import("./promote.mjs");
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified talent cache is required; run coa:reference:fetch first");
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const reference = buildLocalReference({ ...sourceOptions, extraNodes });
const result = await downloadArchiveSpells({ cacheRoot, spellIds: unresolvedCoaArchiveSpellIds(reference, extraNodes) });
console.log(JSON.stringify({ source: result.source, records: result.records.length, files: result.files.length, missingIds: result.missingIds }, null, 2));
return;
}
if (options.command === "build") {
const supplements = await readSupplements({ cacheRoot });
const reference = await buildLocalReference(sourceOptions);
reference.supplementSource = supplements?.source ?? null;
const report = auditReference({ reference, supplements, projectRoot });
const validation = report.validation ?? validateReference(reference);
if (validation.errors?.length) {
throw new Error(`Reference validation failed; previous snapshot retained:\n${validation.errors.map((item) => item.message).join("\n")}`);
}
const result = publishSnapshot({ outputRoot, reference, report, markdown: renderAuditMarkdown(report) });
console.log(JSON.stringify({
snapshotId: result.snapshotId, report: path.join(result.directory, "audit.md"),
classes: reference.classes.length, nodes: reference.nodes.length, spells: reference.spells.length,
sourceFiles: reference.sources.length, supplement: reference.supplementSource,
validationErrors: validation.errors?.length ?? 0, validationWarnings: validation.warnings?.length ?? 0,
}, null, 2));
return;
}
const snapshot = readSnapshot(outputRoot);
if (options.command === "audit") {
const validation = validateReference(snapshot.reference);
console.log(JSON.stringify({
snapshotId: snapshot.manifest.snapshotId, report: path.join(snapshot.directory, "audit.md"),
sourceFiles: snapshot.reference.sources.length,
errors: validation.errors ?? [], warningCount: validation.warnings?.length ?? 0,
warningSample: (validation.warnings ?? []).slice(0, 10),
}, null, 2));
if (validation.errors?.length) throw new Error("Reference coverage validation failed");
return;
}
console.log(JSON.stringify(lookupReference(snapshot.reference, options), null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(`CoA reference: ${error.message}`);
process.exitCode = 1;
});
}
+58
View File
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import { parseArguments, unresolvedCoaArchiveSpellIds } from "./cli.mjs";
import { publishSnapshot } from "./snapshot.mjs";
test("CLI rejects incomplete source pairs, implicit refresh and unknown options", () => {
assert.throws(() => parseArguments(["build", "--client-root", "client"]), /Supply both/);
assert.throws(() => parseArguments(["build", "--refresh"]), /only supported by fetch/);
assert.throws(() => parseArguments(["build", "--typo"]), /Unknown argument/);
assert.throws(() => parseArguments(["lookup", "--class"]), /Missing value/);
assert.equal(parseArguments(["fetch", "--refresh"]).refresh, true);
assert.equal(parseArguments(["help"]).command, "help");
assert.equal(parseArguments(["--help"]).command, "help");
});
test("fetch-spells selects unresolved CoA-owned API roots and August dependencies", () => {
const reference = {
progressionChains: [{ classId: 27, ranks: [{ spellId: 300, rank: 1 }, { spellId: 301, rank: 2 }] }],
spells: [
{ id: 630868, classIds: [27], name: "July API root without global DBC" },
{ id: 630869, classIds: [27], name: "Owned trigger payload without global DBC" },
{ id: 100, classIds: [1], name: "Classic-only unresolved spell" },
{ id: 200, classIds: [27], dbc: { id: 200 }, name: "Already resolved CoA spell" },
{ id: 300, classIds: [27], dbc: { id: 300 }, name: "Resolved August rank" },
],
};
const extraNodes = [{ rankDescriptions: [{ spellId: 400, rank: 1 }, { spellId: 401, rank: 2 }] }];
assert.deepEqual(unresolvedCoaArchiveSpellIds(reference, extraNodes), [301, 400, 401, 630868, 630869]);
});
test("CLI lookup uses only the verified local snapshot when fetch is disabled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-cli-"));
try {
const reference = {
baseline: { kind: "local-july" }, sources: [],
classes: [{ classId: 12, gameId: "barbarian", className: "Barbarian", tabs: [{ tabId: 31, tabName: "Brutality" }] }],
nodes: [{ id: 1, classId: 12, tabId: 31, name: "Strike", rankDescriptions: [{ spellId: 100, rank: 1 }] }],
spells: [{ id: 100, name: "Strike" }], abilities: [],
};
publishSnapshot({ outputRoot: root, reference, report: {}, markdown: "# Audit" });
const blocker = path.join(root, "disable-network.mjs");
fs.writeFileSync(blocker, 'globalThis.fetch = () => { throw new Error("Network request during offline command"); };\n');
const result = spawnSync(process.execPath, ["--import", pathToFileURL(blocker).href, path.join(import.meta.dirname, "cli.mjs"),
"lookup", "--output-root", root, "--class", "barbarian", "--spell", "100"], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).spells[0].id, 100);
assert.equal(JSON.parse(result.stdout).baseline.kind, "local-july");
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
+142
View File
@@ -0,0 +1,142 @@
const FLIGHT_PUSH_MARKER = "self.__next_f.push";
function fail(message, offset) {
const suffix = Number.isInteger(offset) ? ` at offset ${offset}` : "";
throw new Error(`Could not decode pinned builder Flight text${suffix}: ${message}`);
}
function parsePushArgument(html, openParen) {
let start = openParen + 1;
while (/\s/.test(html[start] ?? "")) start += 1;
if (html[start] !== "[") fail("push argument is not a JSON array", openParen);
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = start; index < html.length; index += 1) {
const character = html[index];
if (quoted) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') quoted = false;
continue;
}
if (character === '"') quoted = true;
else if (character === "[") depth += 1;
else if (character === "]") {
depth -= 1;
if (depth === 0) {
const json = html.slice(start, index + 1);
let value;
try {
value = JSON.parse(json);
} catch (error) {
fail(`push argument is invalid JSON (${error.message})`, openParen);
}
return { value, end: index + 1 };
}
}
}
fail("push argument array is unterminated", openParen);
}
function collectTextChunks(builderHtml) {
if (typeof builderHtml !== "string") throw new TypeError("builderHtml must be a string");
const chunks = [];
let cursor = 0;
while (true) {
const markerAt = builderHtml.indexOf(FLIGHT_PUSH_MARKER, cursor);
if (markerAt < 0) break;
const openParen = builderHtml.indexOf("(", markerAt + FLIGHT_PUSH_MARKER.length);
if (openParen < 0) fail("push call has no opening parenthesis", markerAt);
const parsed = parsePushArgument(builderHtml, openParen);
if (!Array.isArray(parsed.value)) fail("push argument did not decode to an array", markerAt);
if (Number(parsed.value[0]) === 1 && typeof parsed.value[1] === "string") chunks.push(parsed.value[1]);
cursor = parsed.end;
}
if (chunks.length === 0) fail("no type-1 text chunks were found");
return chunks;
}
function collectTextRecords(builderHtml) {
const text = collectTextChunks(builderHtml).join("");
const bytes = Buffer.from(text, "utf8");
const records = new Map();
let offset = 0;
while (offset < bytes.length) {
while (bytes[offset] === 0x0a || bytes[offset] === 0x0d) offset += 1;
if (offset >= bytes.length) break;
const comma = bytes.indexOf(0x2c, offset);
const newline = bytes.indexOf(0x0a, offset);
if (comma >= 0 && (newline < 0 || comma < newline)) {
const header = bytes.toString("ascii", offset, comma);
const match = header.match(/^([0-9a-f]+):T([0-9a-f]+)$/i);
if (match) {
const id = match[1].toLowerCase();
const byteLength = Number.parseInt(match[2], 16);
const contentStart = comma + 1;
const contentEnd = contentStart + byteLength;
if (!Number.isSafeInteger(byteLength) || contentEnd > bytes.length) {
fail(`text row ${id} declares ${match[2]} bytes but is truncated`, offset);
}
const value = bytes.toString("utf8", contentStart, contentEnd);
if (records.has(id) && records.get(id) !== value) fail(`text row ${id} is defined more than once with different contents`, offset);
records.set(id, value);
offset = contentEnd;
if (bytes[offset] === 0x0d) offset += 1;
if (bytes[offset] === 0x0a) offset += 1;
continue;
}
}
const lineEnd = bytes.indexOf(0x0a, offset);
if (lineEnd < 0) break;
offset = lineEnd + 1;
}
if (records.size === 0) fail("no length-prefixed text rows were found");
return records;
}
function resolveDescription(value, records, location) {
if (typeof value !== "string" || value.startsWith("$$")) return value;
const match = value.match(/^\$([0-9a-f]+)$/i);
if (!match) return value;
const record = records.get(match[1].toLowerCase());
if (record === undefined) throw new Error(`Pinned builder description reference ${value} at ${location} has no Flight text row`);
return record;
}
function resolveNode(node, records, location) {
if (!node || typeof node !== "object" || Array.isArray(node)) return node;
if (Object.hasOwn(node, "description")) node.description = resolveDescription(node.description, records, `${location}.description`);
if (Array.isArray(node.rankDescriptions)) {
node.rankDescriptions.forEach((rank, index) => {
if (rank && typeof rank === "object" && !Array.isArray(rank) && Object.hasOwn(rank, "description")) {
rank.description = resolveDescription(rank.description, records, `${location}.rankDescriptions[${index}].description`);
}
});
}
return node;
}
function resolveNodeCollections(owner, records, location) {
if (!owner || typeof owner !== "object" || Array.isArray(owner)) return;
for (const key of ["nodes", "entries", "talents"]) {
if (Array.isArray(owner[key])) {
owner[key].forEach((node, index) => resolveNode(node, records, `${location}.${key}[${index}]`));
}
}
if (Array.isArray(owner.tabs)) {
owner.tabs.forEach((tab, index) => resolveNodeCollections(tab, records, `${location}.tabs[${index}]`));
}
}
/** Resolve exact React Flight string references in archived CoA node descriptions. */
export function resolveArchiveDescriptions(classes, builderHtml) {
if (!Array.isArray(classes)) throw new TypeError("classes must be an array");
const records = collectTextRecords(builderHtml);
const resolved = structuredClone(classes);
resolved.forEach((classRecord, index) => resolveNodeCollections(classRecord, records, `classes[${index}]`));
return resolved;
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveArchiveDescriptions } from "./flight-text.mjs";
function textRow(id, text) {
return `${id}:T${Buffer.byteLength(text, "utf8").toString(16)},${text}`;
}
function flightPush(value) {
return `<script>self.__next_f.push(${JSON.stringify(value)});</script>`;
}
function builderHtml(...chunks) {
return [flightPush([0]), ...chunks.map((chunk) => flightPush([1, chunk]))].join("");
}
test("resolves exact Flight text references across split chunks using UTF-8 byte lengths", () => {
const firstText = "Mycelial ring: café 🌱\nsecond line";
const secondText = "Spirit stable — restored";
const stream = `${textRow("17f", firstText)}${textRow("180", secondText)}`;
const splitA = stream.slice(0, 8);
const splitB = stream.slice(8, 36);
const splitC = stream.slice(36);
const classes = [{
classId: 12,
untouched: { note: "unchanged" },
nodes: [
{
id: 7221,
description: "$17F",
rankDescriptions: [{ rank: 1, spellId: 1, description: "$180" }],
spellId: 1,
},
{
id: 7222,
description: "$$180",
rankDescriptions: [{ rank: 1, spellId: 2, description: "Literal $d placeholder" }],
},
],
}];
const resolved = resolveArchiveDescriptions(classes, builderHtml(splitA, splitB, splitC));
assert.notEqual(resolved, classes);
assert.notEqual(resolved[0], classes[0]);
assert.equal(resolved[0].nodes[0].description, firstText);
assert.equal(resolved[0].nodes[0].rankDescriptions[0].description, secondText);
assert.equal(resolved[0].nodes[0].spellId, 1);
assert.deepEqual(resolved[0].untouched, classes[0].untouched);
assert.equal(resolved[0].nodes[1].description, "$$180");
assert.equal(resolved[0].nodes[1].rankDescriptions[0].description, "Literal $d placeholder");
assert.equal(classes[0].nodes[0].description, "$17F");
assert.equal(classes[0].nodes[0].rankDescriptions[0].description, "$180");
});
test("resolves node descriptions inside nested tab collections and preserves escaped dollar text", () => {
const stream = textRow("abc", "\u003cstrong\u003eArchive text\u003c/strong\u003e");
const classes = [{
classId: 13,
tabs: [{ tabId: 1, nodes: [{ id: 1, description: "$abc", rankDescriptions: [] }] }],
}];
const resolved = resolveArchiveDescriptions(classes, builderHtml(stream));
assert.equal(resolved[0].tabs[0].nodes[0].description, "<strong>Archive text</strong>");
});
test("rejects missing exact references with their source field", () => {
const classes = [{ classId: 12, nodes: [{ id: 7221, description: "$dead", rankDescriptions: [] }] }];
assert.throws(
() => resolveArchiveDescriptions(classes, builderHtml(textRow("1", "one"))),
/description reference \$dead at classes\[0\]\.nodes\[0\]\.description has no Flight text row/,
);
});
test("rejects truncated UTF-8 text records and malformed Flight push JSON", () => {
const classes = [{ classId: 12, nodes: [] }];
assert.throws(() => resolveArchiveDescriptions(classes, builderHtml("1:T20,short")), /text row 1 declares 20 bytes but is truncated/);
assert.throws(() => resolveArchiveDescriptions(classes, '<script>self.__next_f.push([1,"unterminated]);</script>'), /push argument array is unterminated/);
});
File diff suppressed because it is too large. Load diff
+353
View File
@@ -0,0 +1,353 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
import { freezeJulyBaseline } from "./baseline.mjs";
function makeDbc(records, fieldCount) {
const stringParts = [Buffer.from([0])];
let stringSize = 1;
const rows = records.map((record) => {
const fields = Array.from({ length: fieldCount }, (_, index) => Number(record.fields?.[index] ?? 0));
for (const [fieldIndex, value] of Object.entries(record.strings ?? {})) {
const encoded = Buffer.from(`${value}\0`, "utf8");
fields[Number(fieldIndex)] = stringSize;
stringParts.push(encoded);
stringSize += encoded.length;
}
const row = Buffer.alloc(fieldCount * 4);
for (let index = 0; index < fieldCount; index += 1) {
const value = fields[index];
if (record.floatFields?.includes(index)) row.writeFloatLE(value, index * 4);
else row.writeUInt32LE(value >>> 0, index * 4);
}
return row;
});
const recordsBuffer = Buffer.concat(rows);
const stringsBuffer = Buffer.concat(stringParts);
const header = Buffer.alloc(20);
header.write("WDBC", 0, "ascii");
header.writeUInt32LE(records.length, 4);
header.writeUInt32LE(fieldCount, 8);
header.writeUInt32LE(fieldCount * 4, 12);
header.writeUInt32LE(stringsBuffer.length, 16);
return Buffer.concat([header, recordsBuffer, stringsBuffer]);
}
function spell(id, name, { rank = "Rank 1", description = `${name} description`, trigger = 0, castTimeIndex = 0, durationIndex = 0 } = {}) {
const fields = Array(234).fill(0);
fields[0] = id;
fields[28] = castTimeIndex;
fields[40] = durationIndex;
fields[71] = trigger ? 64 : 0;
fields[116] = trigger;
fields[205] = 133;
fields[206] = 1500;
fields[136] = 0;
fields[153] = 0;
fields[170] = 0;
return { fields, strings: { 136: name, 153: rank, 170: description } };
}
function archiveSpellRow(id, name, { triggerSpellId = 0, effectType = 0, basePoints = 0 } = {}) {
const row = {};
for (let index = 0; index < 234; index += 1) row[`f${index}`] = 0;
row.f0 = id;
row.f71 = triggerSpellId ? 64 : effectType;
row.f80 = basePoints;
row.f86 = effectType ? 6 : 0;
row.f116 = triggerSpellId;
row.f136 = name;
row.f136i = 1;
row.f153 = "Rank 1";
row.f153i = 2;
row.f170 = `August global description for ${id}`;
row.f170i = 3;
row.f187 = "";
row.f187i = 0;
return row;
}
function writeFile(root, relativePath, value) {
const target = path.join(root, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, value);
return target;
}
function createFixture({ includeCastTimes = true } = {}) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-local-source-"));
const projectRoot = path.join(tempRoot, "HealerMan");
const clientRoot = path.join(tempRoot, "resources", "client");
const extractedRoot = path.join(tempRoot, "LadiksMPQEditor");
const dbcRoot = "client-current";
const classDefinitions = [
{ classId: 12, className: "Barbarian", tabs: [] },
{ classId: 14, className: "Felsworn", tabs: [] },
{ classId: 19, className: "Templar", tabs: [] },
{ classId: 20, className: "Bloodmage", tabs: [] },
];
const snapshot = {
source: { synchronizedAt: "2026-07-25T05:24:40.596Z" },
classes: classDefinitions,
entries: [{
id: 500,
classId: 14,
tabId: 1,
x: 0,
y: 0,
name: "Alias Ability",
rankDescriptions: [{ rank: 1, spellId: 1002, description: "Baseline rank description" }],
}],
progressionChains: [{
id: "coa-14-alias-1002",
classId: 14,
name: "Alias Ability",
ranks: [{ spellId: 1002, rank: 1, level: 10 }, { spellId: 1003, rank: 2, level: 20 }],
}],
};
writeFile(projectRoot, "src/game/coaLiveData.generated.json", `${JSON.stringify(snapshot)}\n`);
const advancementRows = [
{ ID: 500, Class: "DemonHunter", Name: "Alias Ability", Spells: [1002], Realms: "123" },
{ ID: 501, Class: "Monk", Name: "Templar Client Ability", Spells: [3001], Realms: "456" },
{ ID: 502, Class: "SonOfArugal", Name: "Bloodmage Client Ability", Spells: [3002], Realms: "789" },
{ ID: 503, Class: "Felsworn", Name: "Overlay Only", Spells: [4000], Realms: "101" },
];
writeFile(clientRoot, "Data/Content/CharacterAdvancementData.json", `${JSON.stringify(advancementRows)}\n`);
const spellRankRows = [
{ firstSpellId: 1002, spellId: 1002, rank: 1, level: 10 },
{ firstSpellId: 1002, spellId: 1003, rank: 2, level: 20 },
{ firstSpellId: 1002, spellId: 1004, rank: 2, level: 20 },
{ firstSpellId: 1002, spellId: 1005, rank: 2, level: 20 },
{ firstSpellId: 3001, spellId: 3001, rank: 1, level: 10 },
{ firstSpellId: 3002, spellId: 3002, rank: 1, level: 10 },
];
writeFile(clientRoot, "Data/Content/SpellRankData.json", `${JSON.stringify(spellRankRows)}\n`);
const clientClasses = [
[12, "Barbarian"], [14, "Felsworn"], [19, "Templar"], [20, "Bloodmage"],
].map(([id, name]) => ({ fields: [id], strings: { 4: name } }));
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrClasses.dbc`, makeDbc(clientClasses, 60));
const specs = clientClasses.map((row, index) => ({
fields: [index + 1],
strings: { 26: row.strings[4] },
}));
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrSpecs.dbc`, makeDbc(specs, 65));
const skillRows = [
[1002, 14], [1003, 14], [1004, 13], [1005, 14], [3001, 19], [3002, 20],
].map(([spellId, classId], index) => {
const fields = Array(14).fill(0);
fields[0] = index + 1;
fields[1] = 100 + classId;
fields[2] = spellId;
fields[5] = 1 << (classId - 1);
return { fields };
});
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/SkillLineAbility.dbc`, makeDbc(skillRows, 14));
const baseSpells = [
spell(1002, "BASE Alias", { trigger: 2002, castTimeIndex: 1, durationIndex: 1 }),
spell(1003, "BASE Alias", { rank: "Rank 2", castTimeIndex: 2 }),
spell(1004, "Foreign Class Rank", { rank: "Rank 2" }),
spell(1005, "Second Same-Class Rank", { rank: "Rank 2" }),
spell(2002, "BASE Trigger", { rank: "" }),
spell(3001, "Templar Spell"),
spell(3002, "Bloodmage Spell"),
];
const baseSpellPath = writeFile(extractedRoot, `${dbcRoot}/patch-T/DBFilesClient/Spell.dbc`, makeDbc(baseSpells, 234));
const overlaySpells = [spell(1002, "OVERLAY Alias"), spell(4000, "OVERLAY Only")];
const overlaySpellPath = writeFile(extractedRoot, `${dbcRoot}/area-52/patch-D/DBFilesClient/Spell.dbc`, makeDbc(overlaySpells, 234));
if (includeCastTimes) {
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellCastTimes.dbc`, makeDbc([{ fields: [1, 125] }, { fields: [2, -1] }], 2));
}
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellDuration.dbc`, makeDbc([{ fields: [1, 0, 0, -1] }], 4));
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRadius.dbc`, makeDbc([{ fields: [1, 0, 0, 3.5], floatFields: [3] }], 4));
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRange.dbc`, makeDbc([{ fields: [1, 0, 0, 0, 40], floatFields: [1, 2, 3, 4] }], 5));
writeFile(clientRoot, "Data/area-52/listarchive", "patch-D.MPQ\n");
writeFile(extractedRoot, `${dbcRoot}/README.md`, "area-52/patch-D/DBFilesClient provides the realm override. Global patch-T Spell.dbc is retained as a fallback only.\n");
return {
tempRoot,
projectRoot,
clientRoot,
extractedRoot,
baseSpellPath,
overlaySpellPath,
options: { projectRoot, clientRoot, extractedRoot },
};
}
function cleanup(fixture) {
fs.rmSync(fixture.tempRoot, { recursive: true, force: true });
}
test("resolveSourceRoots keeps the client and extraction roots paired", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-roots-"));
try {
const projectRoot = path.join(tempRoot, "HealerMan");
const siblingClient = path.join(tempRoot, "resources", "client");
const siblingExtracts = path.join(tempRoot, "LadiksMPQEditor");
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(siblingClient, { recursive: true });
fs.mkdirSync(siblingExtracts, { recursive: true });
const resolved = resolveSourceRoots({ projectRoot });
assert.equal(resolved.rootSet, "F");
assert.equal(resolved.clientRoot, siblingClient);
assert.equal(resolved.extractedRoot, siblingExtracts);
const supplied = resolveSourceRoots({ projectRoot, clientRoot: siblingClient, extractedRoot: siblingExtracts });
assert.equal(supplied.rootSet, "supplied");
assert.equal(supplied.clientRoot, siblingClient);
assert.equal(supplied.extractedRoot, siblingExtracts);
assert.throws(() => resolveSourceRoots({ projectRoot, clientRoot: siblingClient }), /together/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("buildLocalReference keeps patch-T as BASE and closes node, CAD, and trigger spell references", () => {
const fixture = createFixture();
try {
const result = buildLocalReference(fixture.options);
const bySpellId = new Map(result.spells.map((entry) => [entry.id, entry]));
assert.equal(result.schemaVersion, 1);
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
assert.equal(result.classes.find((definition) => definition.classId === 14).gameId, "felsworn");
assert.deepEqual(result.classes.find((definition) => definition.classId === 14).aliases, ["DemonHunter"]);
assert.equal(result.classes.find((definition) => definition.classId === 14).clientClassId, 14);
assert.equal(bySpellId.get(1002).name, "BASE Alias");
assert.equal(bySpellId.get(1002).dbc.castTimeMs, 125);
assert.equal(bySpellId.get(1002).dbc.durationMs, -1);
assert.equal(bySpellId.get(1003).dbc.castTimeMs, -1);
assert.equal(bySpellId.get(1002).dbc.startRecoveryTimeMs, 1500);
assert.equal(bySpellId.get(2002).name, "BASE Trigger");
assert.ok(bySpellId.get(2002).sourceEvidence.some((evidence) => evidence.kind === "dbc-effect-trigger" && evidence.sourceSpellId === 1002));
assert.equal(bySpellId.get(4000).dbc, undefined);
assert.ok(result.diagnostics.some((item) => item.code === "missing-spell-reference" && item.spellId === 4000 && item.overlayOnly));
assert.deepEqual(result.inventory.spellTables.base.recordCount, 7);
assert.equal(result.inventory.spellTables.base.sha256, crypto.createHash("sha256").update(fs.readFileSync(fixture.baseSpellPath)).digest("hex"));
assert.equal(result.inventory.spellTables.overlay.recordCount, 2);
assert.ok(result.diagnostics.some((item) => item.code === "source-order-conflict"));
assert.ok(result.sources.every((source) => !path.isAbsolute(source.path)));
const ability = result.abilities.find((entry) => entry.name === "Alias Ability");
assert.equal(ability.classId, 14);
assert.equal(ability.live, null);
assert.deepEqual(ability.nodeIds, [500]);
assert.deepEqual(ability.rankSpellIds, [1002, 1003, 1005]);
assert.ok(result.diagnostics.some((item) => item.code === "ambiguous-rank-join" && item.abilityKey === ability.key && item.rank === 2));
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 19).advancementRows.some((row) => row.Class === "Monk"));
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 20).advancementRows.some((row) => row.Class === "SonOfArugal"));
assert.notEqual(fixture.overlaySpellPath, fixture.baseSpellPath);
} finally {
cleanup(fixture);
}
});
test("malformed BASE Spell.dbc is rejected instead of falling back to the overlay", () => {
const fixture = createFixture();
try {
fs.writeFileSync(fixture.baseSpellPath, Buffer.from("not a dbc"));
assert.throws(() => buildLocalReference(fixture.options), /WDBC|truncated/);
} finally {
cleanup(fixture);
}
});
test("missing optional cast-time support leaves a nonzero cast index unresolved", () => {
const fixture = createFixture({ includeCastTimes: false });
try {
const result = buildLocalReference(fixture.options);
const spell = result.spells.find((entry) => entry.id === 1002);
assert.equal(spell.dbc.castTimeIndex, 1);
assert.equal(spell.dbc.castTimeMs, null);
assert.ok(result.diagnostics.some((item) => item.code === "missing-optional-support-dbc" && item.sourceId === "spell-cast-times"));
} finally {
cleanup(fixture);
}
});
test("August seeds resolve new ranks and effect-168 dependencies without replacing July nodes", () => {
const fixture = createFixture();
try {
freezeJulyBaseline(fixture.projectRoot);
writeFile(fixture.projectRoot, "src/game/coaLiveData.generated.json", '{"source":{"synchronizedAt":"2026-08-12"}}');
const newSpell = spell(9002, "August Spell");
newSpell.fields[71] = 168;
newSpell.fields[110] = 9003;
fs.writeFileSync(fixture.baseSpellPath, makeDbc([
...[1002, 1003, 1004, 1005, 2002, 3001, 3002].map((id) => spell(id, `Spell ${id}`)),
newSpell, spell(9003, "August Payload"),
], 234));
const result = buildLocalReference({ ...fixture.options, extraNodes: [
{ id: 901, classId: 14, rankDescriptions: [{ rank: 1, spellId: 9002 }] },
] });
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
assert.deepEqual(result.nodes.map((node) => node.id), [500]);
assert.equal(result.spells.find((row) => row.id === 9002).dbc.name, "August Spell");
assert.equal(result.spells.find((row) => row.id === 9003).dbc.name, "August Payload");
assert.ok(result.spells.find((row) => row.id === 9002).sourceEvidence.some((row) => row.kind === "august-node-rank"));
} finally {
cleanup(fixture);
}
});
test("July CoA spell identity seeds archived BASE rows and transfers ownership to missing trigger payloads", () => {
const fixture = createFixture();
try {
const activePath = path.join(fixture.projectRoot, "src/game/coaLiveData.generated.json");
const july = JSON.parse(fs.readFileSync(activePath, "utf8"));
july.spells = [{
id: 630868,
classIds: [27],
name: "Old Rejuvenating Venom",
rank: "Rank 1",
description: "Old Area52 description",
dbc: { id: 630868, name: "Area52 Rejuvenating Venom", effects: [] },
}];
fs.writeFileSync(activePath, `${JSON.stringify(july)}\n`);
freezeJulyBaseline(fixture.projectRoot);
fs.writeFileSync(fixture.overlaySpellPath, makeDbc([spell(630868, "Area52 Rejuvenating Venom")], 234));
const withoutArchive = buildLocalReference(fixture.options);
const unresolved = withoutArchive.spells.find((row) => row.id === 630868);
assert.deepEqual(unresolved.classIds, [27]);
assert.equal(unresolved.dbc, undefined);
assert.notEqual(unresolved.name, "Old Rejuvenating Venom");
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "july-spell-identity" && item.classId === 27));
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "comparative-overlay-presence-only"));
const archiveSource = {
id: "august-global-spell-base",
path: "raw/tables/Spell/variants/data-patch-t-mpq",
sha256: "archive-index-fixture",
bytes: 123,
};
const archivedSpells = {
source: archiveSource,
fieldTypes: Array.from({ length: 234 }, (_, index) => (
[47, 77, 78, 79, 102, 103, 119, 120, 121, 216, 217, 218, 229, 230, 231].includes(index)
? "float"
: [136, 153, 170, 187].includes(index) ? "string" : "int"
)),
records: [
archiveSpellRow(630868, "August Rejuvenating Venom", { triggerSpellId: 630869 }),
archiveSpellRow(630869, "August Venom Payload", { effectType: 2, basePoints: 49 }),
],
files: [{ path: "00600000-00699999.jsonl.gz" }],
};
const withArchive = buildLocalReference({ ...fixture.options, archivedSpells });
const root = withArchive.spells.find((row) => row.id === 630868);
const payload = withArchive.spells.find((row) => row.id === 630869);
assert.equal(root.dbc.name, "August Rejuvenating Venom");
assert.deepEqual(root.classIds, [27]);
assert.ok(root.sourceEvidence.some((item) => item.kind === "base-spell-dbc-row" && item.sourceId === archiveSource.id));
assert.equal(payload.dbc.name, "August Venom Payload");
assert.deepEqual(payload.classIds, [27]);
assert.ok(payload.sourceEvidence.some((item) => item.kind === "trigger-payload-owner" && item.classId === 27));
assert.ok(!JSON.stringify(withArchive.spells).includes("Area52 Rejuvenating Venom"));
} finally {
cleanup(fixture);
}
});
+770
View File
@@ -0,0 +1,770 @@
import { isDeepStrictEqual } from "node:util";
export const COA_PROMOTION_ARCHIVE = Object.freeze({
repository: "https://github.com/srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
capturedAt: "2026-08-12T21:02:51+00:00",
realmSlug: "voljin",
realmName: "Vol'Jin",
coaBaseSpellPath: "client-current/patch-T/DBFilesClient/Spell.dbc",
classicSpellPath: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
});
const CLASSIC_CLASS_MAX = 11;
const EXPECTED_COA_CLASS_IDS = Array.from({ length: 21 }, (_, index) => index + 12);
function assert(condition, message) {
if (!condition) throw new TypeError(message);
}
function numberOrZero(value) {
return Number(value) || 0;
}
function cleanHtml(value = "") {
return String(value)
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#0*39;|&apos;/gi, "'")
.replace(/\s+/g, " ")
.trim();
}
function cleanName(value = "") {
return String(value).replace(/^@/, "").replace(/^6(?=[A-Z])/, "").trim();
}
function iconBasename(value = "") {
return String(value)
.replace(/^.*[\\/]/, "")
.replace(/\.(?:blp|tga)$/i, "")
.toLowerCase();
}
function numberList(value) {
return (Array.isArray(value) ? value : []).map(Number).filter(Boolean);
}
function compareEntries(left, right) {
return left.classId - right.classId
|| left.tabId - right.tabId
|| left.y - right.y
|| left.x - right.x
|| left.id - right.id;
}
function compareNumbers(left, right) {
return left - right;
}
function stable(value) {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function changedFields(before, after) {
const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
return keys.filter((key) => stable(before?.[key]) !== stable(after?.[key]));
}
function changedValues(before, after, fields = changedFields(before, after)) {
return Object.fromEntries(fields.map((field) => {
const beforeValue = before?.[field];
const afterValue = after?.[field];
if (field === "dbc" && beforeValue && afterValue && typeof beforeValue === "object" && typeof afterValue === "object") {
const dbcFields = changedFields(beforeValue, afterValue);
return [field, Object.fromEntries(dbcFields.map((dbcField) => [dbcField, {
...(beforeValue[dbcField] === undefined ? {} : { before: clone(beforeValue[dbcField]) }),
...(afterValue[dbcField] === undefined ? {} : { after: clone(afterValue[dbcField]) }),
}]))];
}
return [field, {
...(beforeValue === undefined ? {} : { before: clone(beforeValue) }),
...(afterValue === undefined ? {} : { after: clone(afterValue) }),
}];
}));
}
function spellSummary(spell) {
return {
id: Number(spell.id),
classIds: clone(spell.classIds ?? []),
name: spell.name ?? "",
rank: spell.rank ?? "",
description: spell.description ?? "",
hasBaseDbc: Boolean(spell.dbc),
};
}
function clone(value) {
return structuredClone(value);
}
function classNameById(classes, label) {
const result = new Map();
for (const definition of classes) {
const classId = Number(definition?.classId);
assert(Number.isInteger(classId), `${label} contains a class without an integer classId`);
assert(!result.has(classId), `${label} contains duplicate classId ${classId}`);
result.set(classId, String(definition.className ?? definition.builderClassName ?? definition.class ?? ""));
}
return result;
}
function assertUniqueById(records, label) {
const seen = new Set();
for (const record of records) {
const id = Number(record?.id);
assert(Number.isSafeInteger(id) && id > 0, `${label} contains invalid id ${String(record?.id)}`);
assert(!seen.has(id), `${label} contains duplicate id ${id}`);
seen.add(id);
}
return seen;
}
function previousSpellWrapper(spell) {
const {
id, classIds = [], name = "", iconBasename: icon = "", level = 0, rank = "", description = "",
liveTooltip, sourceIds = [], advancementType = "", advancementTabId = 0, talented = false,
excluded = false, dbc,
} = spell;
return {
id: Number(id),
classIds: clone(classIds),
name,
iconBasename: icon,
level,
rank,
description,
...(liveTooltip ? { liveTooltip } : {}),
sourceIds: clone(sourceIds),
advancementType,
advancementTabId,
talented,
excluded,
...(dbc ? { dbc: clone(dbc) } : {}),
};
}
function referenceSpellWrapper({ id, referenceSpell, previousSpell, owners, nodeOwner, descriptionOverride, dbcSourceOnly = true }) {
const dbc = referenceSpell?.dbc;
const node = nodeOwner?.node;
const description = descriptionOverride
?? nodeOwner?.rankDescription?.description
?? node?.description
?? referenceSpell?.description
?? (dbcSourceOnly ? "" : previousSpell?.description)
?? dbc?.description
?? "";
const ownerClassIds = [...new Set([...(owners ?? [])])].filter((classId) => classId > CLASSIC_CLASS_MAX).sort(compareNumbers);
const icon = node?.iconBasename || previousSpell?.iconBasename || "";
const rankDescriptionRank = nodeOwner?.rankDescription?.rank;
return {
id,
classIds: ownerClassIds.length
? ownerClassIds
: clone(referenceSpell?.classIds ?? previousSpell?.classIds ?? []),
name: referenceSpell?.name || node?.name || previousSpell?.name || dbc?.name || "",
iconBasename: icon,
level: referenceSpell?.level ?? previousSpell?.level ?? dbc?.spellLevel ?? 0,
rank: rankDescriptionRank !== undefined
? `Rank ${rankDescriptionRank}`
: referenceSpell?.rank || previousSpell?.rank || dbc?.rankText || "",
description,
sourceIds: clone(previousSpell?.sourceIds ?? []),
advancementType: previousSpell?.advancementType ?? "",
advancementTabId: previousSpell?.advancementTabId ?? 0,
talented: previousSpell?.talented ?? false,
excluded: previousSpell?.excluded ?? false,
...(dbc ? { dbc: clone(dbc) } : {}),
};
}
function rankOwnerMap(entries) {
const owners = new Map();
for (const node of entries) {
for (const rankDescription of node.rankDescriptions) {
const spellId = rankDescription.spellId;
const existing = owners.get(spellId) ?? [];
existing.push({ classId: node.classId, node, rankDescription });
owners.set(spellId, existing);
}
}
for (const ownerList of owners.values()) {
ownerList.sort((left, right) => compareEntries(left.node, right.node)
|| left.rankDescription.rank - right.rankDescription.rank);
}
return owners;
}
function firstNodeOwner(ownerList) {
return ownerList?.[0] ?? null;
}
function mapOfSpells(spells, label) {
assert(Array.isArray(spells), `${label} must be an array`);
assertUniqueById(spells, label);
return new Map(spells.map((spell) => [Number(spell.id), spell]));
}
function chainSpellIds(chain) {
return (Array.isArray(chain?.ranks) ? chain.ranks : []).map((rank) => Number(rank.spellId));
}
function spellIdsForClassicChains(chains) {
const ids = new Set();
for (const chain of chains) {
if (Number(chain.classId) > CLASSIC_CLASS_MAX) continue;
for (const spellId of chainSpellIds(chain)) if (Number.isSafeInteger(spellId) && spellId > 0) ids.add(spellId);
}
return ids;
}
function classicOwnedSpellIds(spells, classicChainSpellIds) {
const ids = new Set(classicChainSpellIds);
for (const spell of spells) {
if ((spell.classIds ?? []).some((classId) => Number(classId) > 0 && Number(classId) <= CLASSIC_CLASS_MAX)) {
ids.add(Number(spell.id));
}
}
return ids;
}
function classicProtectionClosure({ previousSpells, classicChains, classicTriggeredSpells = [] }) {
const mainById = new Map(previousSpells.map((spell) => [Number(spell.id), spell]));
const triggeredById = new Map();
for (const spell of classicTriggeredSpells) {
const id = Number(spell?.id);
if (Number.isSafeInteger(id) && id > 0 && !triggeredById.has(id)) triggeredById.set(id, spell);
}
const seeds = classicOwnedSpellIds(previousSpells, spellIdsForClassicChains(classicChains));
const referencedIds = new Set(seeds);
const protectedIds = new Set([...seeds].filter((id) => mainById.has(id)));
const queue = [...seeds].sort(compareNumbers);
const visited = new Set();
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const spellId = queue[cursor];
if (visited.has(spellId)) continue;
visited.add(spellId);
const rows = [mainById.get(spellId), triggeredById.get(spellId)].filter(Boolean);
const childIds = new Set();
for (const row of rows) {
const effects = row.dbc?.effects ?? row.effects ?? [];
for (const effect of effects) {
const triggerSpellId = Number(effect.triggerSpellId);
if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.add(triggerSpellId);
const miscValue = Number(effect.miscValue);
if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValue) && miscValue > 0) childIds.add(miscValue);
}
}
for (const childId of [...childIds].sort(compareNumbers)) {
referencedIds.add(childId);
if (mainById.has(childId)) protectedIds.add(childId);
if (!visited.has(childId)) queue.push(childId);
}
}
return { protectedIds, referencedIds };
}
function closureForCoaSpells({ entries, chains, referenceSpells }) {
const ownersBySpellId = new Map();
const addOwner = (spellId, classIds) => {
const id = Number(spellId);
if (!Number.isSafeInteger(id) || id <= 0) return false;
const owners = ownersBySpellId.get(id) ?? new Set();
const before = owners.size;
for (const classId of classIds) {
const numericClassId = Number(classId);
if (numericClassId > CLASSIC_CLASS_MAX) owners.add(numericClassId);
}
ownersBySpellId.set(id, owners);
return owners.size !== before;
};
for (const node of entries) {
for (const rank of node.rankDescriptions) addOwner(rank.spellId, [node.classId]);
}
for (const chain of chains) {
if (Number(chain.classId) <= CLASSIC_CLASS_MAX) continue;
for (const rank of chain.ranks ?? []) addOwner(rank.spellId, [chain.classId]);
}
for (const spell of referenceSpells) {
const classes = (spell.classIds ?? []).filter((classId) => Number(classId) > CLASSIC_CLASS_MAX);
if (classes.length) addOwner(spell.id, classes);
}
const byId = new Map(referenceSpells.map((spell) => [Number(spell.id), spell]));
const processedOwners = new Map();
const queue = [...ownersBySpellId.keys()].sort(compareNumbers);
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const spellId = queue[cursor];
const owners = ownersBySpellId.get(spellId) ?? new Set();
const processed = processedOwners.get(spellId) ?? new Set();
const newOwners = [...owners].filter((classId) => !processed.has(classId));
if (newOwners.length === 0) continue;
for (const classId of newOwners) processed.add(classId);
processedOwners.set(spellId, processed);
const dbc = byId.get(spellId)?.dbc;
for (const effect of dbc?.effects ?? []) {
const childIds = [];
const triggerSpellId = Number(effect.triggerSpellId);
if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.push(triggerSpellId);
const miscValueId = Number(effect.miscValue);
if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValueId) && miscValueId > 0) childIds.push(miscValueId);
for (const childId of childIds) {
if (addOwner(childId, newOwners)) queue.push(childId);
}
}
}
return ownersBySpellId;
}
function preserveChecks({ previous, candidate, oldEntriesById, oldChainById, oldSpellById, protectedSpellIds, classicReachableSpellIds, coaOwnersBySpellId }) {
const classicChains = previous.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX);
const candidateClassicChains = candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX);
const classicProtected = [...protectedSpellIds].sort(compareNumbers);
const classicSpellRecords = classicProtected.flatMap((id) => {
const before = oldSpellById.get(id);
if (!before) return [];
const after = candidate.spells.find((spell) => Number(spell.id) === id);
return [{ id, before, after: after ?? null }];
});
const missingOldNodeIds = [];
const crossClassRewires = [];
for (const [id, oldNode] of oldEntriesById) {
const nextNode = candidate.entries.find((node) => Number(node.id) === id);
if (!nextNode) missingOldNodeIds.push(id);
else if (Number(nextNode.classId) !== Number(oldNode.classId)) {
crossClassRewires.push({ id, beforeClassId: oldNode.classId, afterClassId: nextNode.classId });
}
}
const missingOldChainIds = [];
const changedCoaChainRanks = [];
const candidateChainMap = new Map(candidate.progressionChains.map((chain) => [chain.id, chain]));
for (const [id, oldChain] of oldChainById) {
const nextChain = candidateChainMap.get(id);
if (!nextChain) missingOldChainIds.push(id);
else if (!isDeepStrictEqual(chainSpellIds(oldChain), chainSpellIds(nextChain))) {
changedCoaChainRanks.push({ id, before: chainSpellIds(oldChain), after: chainSpellIds(nextChain) });
}
}
const classicRecordsUnchanged = classicSpellRecords.every(({ before, after }) => after && isDeepStrictEqual(before, after));
return {
classicProgressionChains: {
passed: isDeepStrictEqual(classicChains, candidateClassicChains),
count: classicChains.length,
},
classicProtectedSpellRecords: {
passed: classicRecordsUnchanged,
recordCount: classicSpellRecords.length,
protectedMainIdCount: classicProtected.length,
classicReachableIdCount: classicReachableSpellIds.size,
},
previousCoaNodeIds: {
passed: missingOldNodeIds.length === 0 && crossClassRewires.length === 0,
previousCount: oldEntriesById.size,
missingIds: missingOldNodeIds.sort(compareNumbers),
crossClassRewires: crossClassRewires.sort((left, right) => left.id - right.id),
},
previousProgressionChainIdentities: {
passed: missingOldChainIds.length === 0 && changedCoaChainRanks.length === 0,
previousCount: oldChainById.size,
missingIds: missingOldChainIds.sort(),
changedCoaRanks: changedCoaChainRanks.sort((left, right) => String(left.id).localeCompare(String(right.id))),
},
coaSpellOverrideCoverage: {
passed: candidate.coaSpellOverrides.every((spell) => (coaOwnersBySpellId.get(spell.id)?.size ?? 0) > 0),
overrideCount: candidate.coaSpellOverrides.length,
},
};
}
function validationError(message, report) {
const error = new Error(message);
error.name = "CoaPromotionValidationError";
error.report = report;
return error;
}
function makeSource(previous, reference, supplements) {
const prior = previous.source ?? {};
const baseSpellSource = reference.sources?.find((source) => source.id === "global-spell-base") ?? null;
return {
...clone(prior),
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
realmName: COA_PROMOTION_ARCHIVE.realmName,
synchronizedAt: supplements.source.capturedAt,
classSpellRowsCapturedAt: prior.synchronizedAt,
spellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
supportDbcDirectory: "client-current/patch-S/DBFilesClient",
coaSpellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
classicSpellDbcPath: COA_PROMOTION_ARCHIVE.classicSpellPath,
coaSkillLineAbilityDbcPath: "client-current/patch-M/DBFilesClient/SkillLineAbility.dbc",
...(reference.archivedSpellSupplement ? { archivedSpellSupplement: clone(reference.archivedSpellSupplement) } : {}),
spellDbcSources: {
coaBase: {
patch: "T",
path: baseSpellSource?.path ?? `extracts/${COA_PROMOTION_ARCHIVE.coaBaseSpellPath}`,
sourceId: baseSpellSource?.id ?? "global-spell-base",
},
classic: {
patch: "area-52",
path: prior.spellDbcPath?.includes("area-52")
? prior.spellDbcPath
: COA_PROMOTION_ARCHIVE.classicSpellPath,
sourceId: "classic-area52-preserved",
},
},
promotion: {
archiveRepository: supplements.source.repository,
archiveRevision: supplements.source.revision,
archiveCapturedAt: supplements.source.capturedAt,
builderSnapshotPath: supplements.source.builderSnapshotPath,
builderSnapshotSha256: supplements.source.builderSnapshotSha256,
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
coaSpellSource: "client-current/patch-T/DBFilesClient/Spell.dbc",
classicSpellSource: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
},
};
}
function makeCounts(candidate, previous) {
const classSpells = candidate.spells.flatMap((spell) => spell.classIds ?? []);
return {
classes: candidate.classes.length,
tabs: candidate.classes.reduce((total, definition) => total + definition.tabs.length, 0),
entries: candidate.entries.length,
abilityEntries: candidate.entries.filter((entry) => entry.entryType === "Ability").length,
talentEntries: candidate.entries.filter((entry) => entry.entryType === "Talent").length,
rankedNodeSpells: candidate.entries.reduce((total, entry) => total + entry.rankDescriptions.length, 0),
classSpellRows: previous.counts?.classSpellRows ?? classSpells.length,
classicClassSpellRows: previous.counts?.classicClassSpellRows ?? classSpells.filter((classId) => classId <= CLASSIC_CLASS_MAX).length,
coaClassSpellRows: previous.counts?.coaClassSpellRows ?? classSpells.filter((classId) => classId > CLASSIC_CLASS_MAX).length,
spellClassMemberships: classSpells.length,
uniqueSpells: candidate.spells.length,
dbcMatchedSpells: candidate.spells.filter((spell) => spell.dbc).length,
progressionChains: candidate.progressionChains.length,
classicProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX).length,
classicBaseProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX && !chain.talented).length,
coaProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX).length,
progressionRanks: candidate.progressionChains.reduce((total, chain) => total + (chain.ranks?.length ?? 0), 0),
liveTooltips: candidate.spells.filter((spell) => spell.liveTooltip).length,
coaSpellOverrides: candidate.coaSpellOverrides.length,
};
}
/** Convert one pinned archive tree node to the active CoA entry shape used by the old sync. */
export function normalizeArchiveNode(raw) {
assert(raw && typeof raw === "object" && !Array.isArray(raw), "Archive node must be an object");
const id = Number(raw.id);
const classId = Number(raw.classId);
const tabId = Number(raw.tabId);
assert(Number.isSafeInteger(id) && id > 0, `Archive node has invalid id ${String(raw.id)}`);
assert(Number.isInteger(classId) && EXPECTED_COA_CLASS_IDS.includes(classId), `Archive node ${id} has noncanonical classId ${String(raw.classId)}`);
assert(Number.isInteger(tabId) && tabId > 0, `Archive node ${id} has invalid tabId ${String(raw.tabId)}`);
const x = Number(raw.x ?? raw.column);
const y = Number(raw.y ?? raw.row);
assert(Number.isFinite(x) && Number.isFinite(y), `Archive node ${id} has invalid coordinates`);
const rankDescriptions = (Array.isArray(raw.rankDescriptions) ? raw.rankDescriptions : []).map((rank, index) => {
const rankNumber = Number(rank?.rank);
const spellId = Number(rank?.spellId);
assert(Number.isInteger(rankNumber) && rankNumber > 0, `Archive node ${id} has invalid rank number at index ${index}`);
assert(Number.isSafeInteger(spellId) && spellId > 0, `Archive node ${id} has invalid rank spellId at index ${index}`);
return {
rank: rankNumber,
spellId,
description: cleanHtml(rank?.description),
};
});
const spellIdCount = Array.isArray(raw.spellIds) ? raw.spellIds.length : rankDescriptions.length;
return {
id,
classId,
tabId,
x,
y,
name: cleanName(raw.name),
iconBasename: iconBasename(raw.iconPath ?? raw.icon),
nodeType: String(raw.nodeType ?? ""),
entryType: String(raw.entryType ?? ""),
isPassive: Boolean(raw.isPassive),
maxPoints: numberOrZero(raw.maxPoints ?? raw.maxRank) || Math.max(1, spellIdCount),
aeCost: numberOrZero(raw.aeCost ?? raw.abilityEssenceCost),
teCost: numberOrZero(raw.teCost ?? raw.talentEssenceCost),
requiredClassPoints: numberOrZero(raw.reqTabAE ?? raw.requiredClassPoints),
requiredSpecPoints: numberOrZero(raw.reqTabTE ?? raw.requiredSpecPoints),
requiredIds: numberList(raw.requiredIds ?? raw.prerequisites),
connectedNodeIds: numberList(raw.connectedNodeIds ?? raw.connections),
requiredLevel: numberOrZero(raw.requiredLevel),
isStartingNode: Boolean(raw.isStartingNode),
flags: numberOrZero(raw.flags),
group: numberOrZero(raw.group),
sortOrder: numberOrZero(raw.sortOrder),
description: cleanHtml(raw.description),
rankDescriptions,
};
}
/** Build an in-memory, validated CoA promotion candidate and an exact machine-readable diff. */
export function buildCoaPromotion({ previous, reference, supplements }) {
assert(previous && typeof previous === "object", "previous CoA data is required");
assert(reference && typeof reference === "object", "local CoA reference is required");
assert(supplements && typeof supplements === "object", "pinned CoA supplements are required");
assert(Array.isArray(previous.classes), "previous CoA data has no classes array");
assert(Array.isArray(previous.entries), "previous CoA data has no entries array");
assert(Array.isArray(previous.progressionChains), "previous CoA data has no progressionChains array");
assert(Array.isArray(previous.spells), "previous CoA data has no spells array");
assert(Array.isArray(reference.classes), "local reference has no classes array");
assert(Array.isArray(reference.nodes), "local reference has no nodes array");
assert(Array.isArray(reference.progressionChains), "local reference has no progressionChains array");
assert(Array.isArray(reference.spells), "local reference has no spells array");
assert(Array.isArray(reference.sources), "local reference has no sources array");
assert(Array.isArray(supplements.classes), "pinned supplements have no classes array");
assert(supplements.source?.revision === COA_PROMOTION_ARCHIVE.revision, "pinned supplement revision does not match the approved August archive");
assert(supplements.source?.capturedAt === COA_PROMOTION_ARCHIVE.capturedAt, "pinned supplement capture time does not match the approved August archive");
const canonicalNames = classNameById(previous.classes, "previous classes");
const previousClassIds = [...canonicalNames.keys()].sort(compareNumbers);
assert(isDeepStrictEqual(previousClassIds, EXPECTED_COA_CLASS_IDS), "previous CoA classes must contain canonical IDs 12 through 32");
const referenceNames = classNameById(reference.classes, "reference classes");
const archiveNames = classNameById(supplements.classes, "supplement classes");
for (const classId of EXPECTED_COA_CLASS_IDS) {
const canonicalName = canonicalNames.get(classId);
assert(canonicalName, `previous CoA class ${classId} has no canonical className`);
assert(referenceNames.get(classId) === canonicalName, `reference class ${classId} does not match canonical name ${canonicalName}`);
const archiveClass = supplements.classes.find((definition) => Number(definition.classId) === classId);
assert(archiveClass, `pinned archive is missing CoA class ${classId}`);
assert(archiveNames.get(classId) === canonicalName, `pinned archive class ${classId} does not match canonical name ${canonicalName}`);
assert((archiveClass.builderClassName ?? archiveClass.class) === canonicalName, `pinned builder class name for ${classId} does not match ${canonicalName}`);
}
assert(supplements.classes.length === EXPECTED_COA_CLASS_IDS.length, "pinned archive must contain exactly 21 CoA classes");
const entries = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode)).sort(compareEntries);
assert(entries.length > 0, "pinned archive has no CoA nodes");
assertUniqueById(entries, "pinned archive nodes");
const entryIds = new Set(entries.map((entry) => entry.id));
const priorEntryIds = assertUniqueById(previous.entries, "previous CoA entries");
const oldEntriesById = new Map(previous.entries.map((entry) => [Number(entry.id), entry]));
const oldIdsMissing = [...priorEntryIds].filter((id) => !entryIds.has(id)).sort(compareNumbers);
const crossClassRewires = previous.entries.flatMap((oldNode) => {
const newNode = entries.find((node) => node.id === Number(oldNode.id));
return newNode && newNode.classId !== Number(oldNode.classId)
? [{ id: Number(oldNode.id), beforeClassId: Number(oldNode.classId), afterClassId: newNode.classId }]
: [];
});
assert(oldIdsMissing.length === 0, `August archive removes July node IDs: ${oldIdsMissing.join(", ")}`);
assert(crossClassRewires.length === 0, `August archive rewires node IDs across classes: ${crossClassRewires.map((item) => item.id).join(", ")}`);
const classes = previous.classes.map((definition) => {
const classId = Number(definition.classId);
const archiveClass = supplements.classes.find((item) => Number(item.classId) === classId);
const classNodes = entries.filter((node) => node.classId === classId);
const nodeTabs = new Set(classNodes.map((node) => node.tabId));
const tabs = (archiveClass.tabs ?? [])
.filter((tab) => tab?.isEmpty !== true && Number(tab?.entryCount ?? 1) > 0 && nodeTabs.has(Number(tab.tabId)))
.map((tab) => ({
tabId: Number(tab.tabId),
tabName: String(tab.tabName ?? tab.name ?? ""),
sortOrder: numberOrZero(tab.sortOrder),
}))
.sort((left, right) => left.sortOrder - right.sortOrder || left.tabId - right.tabId);
assert(tabs.length > 0, `pinned archive class ${classId} has no nonempty tabs`);
const tabIds = new Set(tabs.map((tab) => tab.tabId));
assert(classNodes.every((node) => tabIds.has(node.tabId)), `pinned archive class ${classId} contains a node outside a nonempty tab`);
return { ...clone(definition), tabs };
});
const priorChainIds = new Set(previous.progressionChains.map((chain) => String(chain.id)));
assert(priorChainIds.size === previous.progressionChains.length, "previous progression chains contain duplicate IDs");
const oldChainById = new Map(previous.progressionChains.map((chain) => [String(chain.id), chain]));
const progressionChains = clone(previous.progressionChains);
const referenceSpellById = mapOfSpells(reference.spells, "reference spells");
const oldSpellById = mapOfSpells(previous.spells, "previous spells");
const classicProtection = classicProtectionClosure({
previousSpells: previous.spells,
classicChains: previous.progressionChains,
classicTriggeredSpells: previous.classicTriggeredSpells ?? [],
});
const protectedSpellIds = classicProtection.protectedIds;
const rankOwners = rankOwnerMap(entries);
const coaOwnersBySpellId = closureForCoaSpells({
entries,
chains: progressionChains,
referenceSpells: reference.spells,
});
const allSpellIds = new Set([
...oldSpellById.keys(),
...referenceSpellById.keys(),
...rankOwners.keys(),
...[...coaOwnersBySpellId.keys()],
...progressionChains.flatMap((chain) => chainSpellIds(chain)),
]);
const sortedSpellIds = [...allSpellIds].sort(compareNumbers);
const spells = [];
const coaSpellOverrides = [];
for (const id of sortedSpellIds) {
const previousSpell = oldSpellById.get(id);
const referenceSpell = referenceSpellById.get(id);
const ownerList = rankOwners.get(id) ?? [];
const nodeOwner = firstNodeOwner(ownerList);
const coaOwners = [...(coaOwnersBySpellId.get(id) ?? [])].sort(compareNumbers);
if (protectedSpellIds.has(id) && previousSpell) {
spells.push(clone(previousSpell));
} else if (referenceSpell || previousSpell || nodeOwner) {
spells.push(referenceSpellWrapper({ id, referenceSpell, previousSpell, owners: coaOwners, nodeOwner }));
}
if (protectedSpellIds.has(id) && coaOwners.length > 0) {
coaSpellOverrides.push(referenceSpellWrapper({
id,
referenceSpell,
previousSpell,
owners: coaOwners,
nodeOwner,
}));
}
}
coaSpellOverrides.sort((left, right) => left.id - right.id);
const candidate = {
source: makeSource(previous, reference, supplements),
counts: {},
classes,
entries,
progressionChains,
spells,
...(coaSpellOverrides.length ? { coaSpellOverrides } : {}),
};
candidate.coaSpellOverrides ??= [];
candidate.counts = makeCounts(candidate, previous);
const coaChainSpellIds = new Set(progressionChains
.filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX)
.flatMap((chain) => chainSpellIds(chain)));
const coaRequiredIds = new Set([...coaOwnersBySpellId.keys(), ...coaChainSpellIds]);
const missingBaseSpellIds = [...coaRequiredIds]
.filter((id) => !referenceSpellById.get(id)?.dbc)
.sort(compareNumbers);
const missingBaseProgressionRankIds = [...coaChainSpellIds]
.filter((id) => !referenceSpellById.get(id)?.dbc)
.sort(compareNumbers);
const previousSpellIds = new Set(oldSpellById.keys());
const candidateSpellIds = new Set(spells.map((spell) => Number(spell.id)));
const addedSpellIds = [...candidateSpellIds].filter((id) => !previousSpellIds.has(id)).sort(compareNumbers);
const removedSpellIds = [...previousSpellIds].filter((id) => !candidateSpellIds.has(id)).sort(compareNumbers);
const changedSpells = [];
for (const spell of spells) {
const before = oldSpellById.get(Number(spell.id));
if (before && !isDeepStrictEqual(before, spell)) {
const fields = changedFields(before, spell);
changedSpells.push({ id: Number(spell.id), changedFields: fields, changes: changedValues(before, spell, fields) });
}
}
const oldEntryById = oldEntriesById;
const newEntryById = new Map(entries.map((entry) => [entry.id, entry]));
const addedNodeIds = [...newEntryById.keys()].filter((id) => !oldEntryById.has(id)).sort(compareNumbers);
const removedNodeIds = [...oldEntryById.keys()].filter((id) => !newEntryById.has(id)).sort(compareNumbers);
const changedNodes = [];
for (const node of entries) {
const before = oldEntryById.get(node.id);
if (before && !isDeepStrictEqual(before, node)) {
changedNodes.push({ id: node.id, classId: node.classId, changedFields: changedFields(before, node), before: clone(before), after: clone(node) });
}
}
const overrideChanges = coaSpellOverrides.map((after) => {
const before = oldSpellById.get(after.id) ?? null;
const fields = before ? changedFields(before, after) : Object.keys(after).sort();
return {
id: after.id,
protectedClassicRecord: Boolean(before),
classIds: clone(after.classIds),
description: after.description,
hasBaseDbc: Boolean(after.dbc),
changedFields: fields,
changes: before ? changedValues(before, after, fields) : undefined,
};
});
const report = {
schemaVersion: 1,
status: "validated",
sources: {
previous: clone(previous.source ?? null),
reference: {
baseSpellDbc: clone(reference.sources.find((source) => source.id === "global-spell-base") ?? null),
classSources: clone(reference.sources.filter((source) => /class|skill-line/i.test(`${source.id} ${source.role}`))),
},
archive: clone(supplements.source),
},
preserveChecks: {},
nodeChanges: {
previousCount: previous.entries.length,
promotedCount: entries.length,
pinnedArchiveExpectedCount: 3618,
pinnedArchiveCountMatches: entries.length === 3618,
added: addedNodeIds.map((id) => clone(newEntryById.get(id))),
removed: removedNodeIds.map((id) => clone(oldEntryById.get(id))),
changed: changedNodes,
unexpectedRemovals: removedNodeIds,
crossClassRewires,
},
spellChanges: {
previousCount: previous.spells.length,
promotedCount: spells.length,
added: addedSpellIds.map((id) => spellSummary(spells.find((spell) => spell.id === id))),
removed: removedSpellIds.map((id) => spellSummary(oldSpellById.get(id))),
changed: changedSpells,
coaOverrides: overrideChanges,
protectedClassicIdCount: protectedSpellIds.size,
coaContextSpellCount: coaOwnersBySpellId.size,
sharedProtectedIds: overrideChanges.map((item) => item.id),
},
gaps: {
missingBaseSpellIds,
missingBaseSpellCount: missingBaseSpellIds.length,
missingBaseProgressionRankIds,
missingBaseProgressionRankCount: missingBaseProgressionRankIds.length,
},
provenance: {
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
archiveRevision: supplements.source.revision,
archiveCapturedAt: supplements.source.capturedAt,
coaBaseSpellPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
classicSpellPath: candidate.source.spellDbcSources.classic.path,
coaSkillLineAbilityDbcPath: candidate.source.coaSkillLineAbilityDbcPath,
fixedCaptureTime: true,
generationTimestampAdded: false,
},
};
report.preserveChecks = preserveChecks({
previous,
candidate,
oldEntriesById,
oldChainById,
oldSpellById,
protectedSpellIds,
classicReachableSpellIds: classicProtection.referencedIds,
coaOwnersBySpellId,
});
const failedChecks = Object.entries(report.preserveChecks).filter(([, result]) => !result.passed).map(([name]) => name);
if (failedChecks.length) {
report.status = "blocked";
throw validationError(`CoA promotion failed preservation checks: ${failedChecks.join(", ")}`, report);
}
const invalidDbc = candidate.spells.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id));
const invalidOverrideDbc = candidate.coaSpellOverrides.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id));
if (invalidDbc.length || invalidOverrideDbc.length) {
report.status = "blocked";
throw validationError(`CoA promotion contains mismatched BASE DBC rows for IDs: ${[...invalidDbc, ...invalidOverrideDbc].map((spell) => spell.id).join(", ")}`, report);
}
return { candidate, report };
}
+292
View File
@@ -0,0 +1,292 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCoaPromotion, COA_PROMOTION_ARCHIVE, normalizeArchiveNode } from "./promote.mjs";
const classNames = [
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer", "Knight of Xoroth", "Guardian",
"Templar", "Bloodmage", "Ranger", "Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
"Sun Cleric", "Tinker", "Venomancer", "Reaper", "Primalist", "Runemaster",
];
function archiveNode({ classId, id, spellId, description = "Archive description", tabId = 30 + classId }) {
return {
id,
classId,
tabId,
x: 1,
y: 2,
name: `Class ${classId} node`,
iconPath: "Interface\\Icons\\TEST_ICON.blp",
nodeType: "SpendSquare",
entryType: "Talent",
isPassive: 0,
maxPoints: 1,
aeCost: 0,
teCost: 1,
reqTabAE: 0,
reqTabTE: 0,
requiredIds: [],
connectedNodeIds: [],
requiredLevel: 0,
isStartingNode: 0,
flags: 0,
group: 0,
sortOrder: 0,
description,
rankDescriptions: [{ rank: 1, spellId, description }],
};
}
function dbc(id, { description = `Base description ${id}`, effects = [] } = {}) {
return { id, name: `Base spell ${id}`, rankText: "Rank 1", description, effects };
}
function spell(id, classIds, { description = `Classic description ${id}`, effects = [], liveTooltip } = {}) {
return {
id,
classIds,
name: `Spell ${id}`,
iconBasename: "classic_icon",
level: 1,
rank: "Rank 1",
description,
...(liveTooltip ? { liveTooltip } : {}),
sourceIds: [],
advancementType: "",
advancementTabId: 0,
talented: false,
excluded: false,
dbc: { ...dbc(id), marker: "Area52 overlay row" },
};
}
function fixtures({ missingCoaRanks = [] } = {}) {
const previousClasses = classNames.map((className, index) => ({
classId: index + 12,
className,
maxTalentEssence: 25,
maxAbilityEssence: 26,
tabs: [{ tabId: 30 + index + 12, tabName: "July tab", sortOrder: 1 }],
}));
const archiveClasses = previousClasses.map((definition, index) => {
const classId = definition.classId;
const spellId = classId === 12 ? 100 : 200 + classId;
const node = archiveNode({
classId,
id: 12000 + classId,
spellId,
description: classId === 12 ? "<b>August &amp; improved</b><br>rank" : `August ${classId}`,
});
return {
classId,
class: definition.className,
builderClassName: definition.className,
essence: { maxTalentEssence: 99, maxAbilityEssence: 98 },
tabs: [
{ tabId: node.tabId, tabName: "August tab", sortOrder: 1, entryCount: 1, isEmpty: false },
{ tabId: 900 + classId, tabName: "Empty tab", sortOrder: 2, entryCount: 0, isEmpty: true },
],
nodes: [node],
};
});
const archiveEntries = archiveClasses.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const julyEntries = structuredClone(archiveEntries);
julyEntries.find((node) => node.classId === 12).description = "July-only description";
julyEntries.find((node) => node.classId === 12).rankDescriptions[0].description = "July rank description";
const classicChain = {
id: "classic-preserved-chain",
classId: 1,
name: "Classic chain",
iconBasename: "classic",
talented: false,
advancementType: "",
trainer: true,
ranks: [{ spellId: 100, rank: 1, level: 1 }],
};
const coaChain = {
id: "coa-stable-chain",
classId: 12,
name: "Stable CoA chain",
iconBasename: "coa",
talented: false,
advancementType: "",
trainer: false,
ranks: [
{ spellId: 91300, rank: 1, level: 1 },
...missingCoaRanks.map((spellId, index) => ({ spellId, rank: index + 2, level: index + 2 })),
],
};
const sharedSpell = spell(100, [1, 12], { liveTooltip: "stale Area52 tooltip" });
const classicTriggered = spell(101, [5], { liveTooltip: "classic tooltip" });
const classicPayload = spell(102, [5]);
const classicClosureSeed = spell(500, [2]);
const classicEmptyOwnerChild = spell(501, []);
const classicEmptyOwnerPayload = spell(502, []);
const staleCoa = spell(999, [12], { liveTooltip: "stale CoA tooltip" });
const previous = {
source: { realmSlug: "voljin", spellDbcPath: "C:/client/area-52/patch-D/DBFilesClient/Spell.dbc" },
counts: {},
classes: previousClasses,
entries: julyEntries,
progressionChains: [classicChain, coaChain],
spells: [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload, staleCoa],
classicTriggeredSpells: [
{ id: 500, effects: [{ effectType: 64, triggerSpellId: 501, miscValue: 0 }] },
{ id: 501, effects: [{ effectType: 168, triggerSpellId: 0, miscValue: 502 }] },
],
};
const allArchiveRanks = archiveClasses.flatMap((definition) => definition.nodes.flatMap((node) => node.rankDescriptions));
const referenceSpellMap = new Map();
for (const rank of allArchiveRanks) {
if (missingCoaRanks.includes(rank.spellId)) continue;
let effects = [];
if (rank.spellId === 100) {
effects = [
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
];
}
referenceSpellMap.set(rank.spellId, {
id: rank.spellId,
classIds: rank.spellId === 100 ? [1, 12] : [12],
name: `Base spell ${rank.spellId}`,
rank: "Rank 1",
level: 1,
description: `July reference description ${rank.spellId}`,
dbc: dbc(rank.spellId, { effects }),
});
}
for (const classicSpell of [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload]) {
referenceSpellMap.set(classicSpell.id, {
id: classicSpell.id,
classIds: classicSpell.classIds,
name: `BASE ${classicSpell.id}`,
rank: "Rank 1",
level: 1,
description: `BASE ${classicSpell.id} description`,
dbc: dbc(classicSpell.id, {
effects: classicSpell.id === 100
? [
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
]
: [],
}),
});
}
const supplements = {
source: {
...COA_PROMOTION_ARCHIVE,
capturedAt: COA_PROMOTION_ARCHIVE.capturedAt,
},
classes: archiveClasses,
};
const reference = {
classes: previousClasses,
nodes: [],
progressionChains: [classicChain, coaChain],
spells: [...referenceSpellMap.values()].sort((left, right) => left.id - right.id),
sources: [{ id: "global-spell-base", path: "extracts/client-current/patch-T/DBFilesClient/Spell.dbc", role: "base" }],
};
return { previous, reference, supplements, archiveEntries, classicChain, coaChain };
}
test("normalizeArchiveNode matches the old sync entry shape and cleans archive text", () => {
const normalized = normalizeArchiveNode({
...archiveNode({ classId: 12, id: 1, spellId: 2, description: "<span>One</span><br><br>Two &amp; three" }),
name: "@6New Name",
iconPath: "Interface\\Icons\\TEST_ICON.TGA",
isPassive: 127,
isStartingNode: 0,
reqTabAE: undefined,
reqTabTE: undefined,
requiredClassPoints: 5,
requiredSpecPoints: 6,
});
assert.deepEqual(normalized, {
id: 1,
classId: 12,
tabId: 42,
x: 1,
y: 2,
name: "New Name",
iconBasename: "test_icon",
nodeType: "SpendSquare",
entryType: "Talent",
isPassive: true,
maxPoints: 1,
aeCost: 0,
teCost: 1,
requiredClassPoints: 5,
requiredSpecPoints: 6,
requiredIds: [],
connectedNodeIds: [],
requiredLevel: 0,
isStartingNode: false,
flags: 0,
group: 0,
sortOrder: 0,
description: "One Two & three",
rankDescriptions: [{ rank: 1, spellId: 2, description: "One Two & three" }],
});
});
test("promotion retains classic data and old node and chain identities while using August nodes and BASE spells", () => {
const { previous, reference, supplements, archiveEntries, classicChain, coaChain } = fixtures();
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
const oldClassicSpells = previous.spells.filter((item) => item.classIds.some((classId) => classId <= 11));
assert.deepEqual(candidate.progressionChains[0], classicChain);
assert.deepEqual(candidate.spells.filter((item) => oldClassicSpells.some((old) => old.id === item.id)), oldClassicSpells);
for (const id of [500, 501, 502]) {
assert.deepEqual(candidate.spells.find((item) => item.id === id), previous.spells.find((item) => item.id === id));
}
assert.deepEqual(candidate.entries, archiveEntries);
assert.deepEqual(candidate.progressionChains[1], coaChain);
assert.equal(candidate.classes[0].tabs.length, 1);
assert.equal(candidate.classes[0].maxTalentEssence, 25);
assert.equal(candidate.entries[0].description, "August & improved rank");
assert.equal(candidate.spells.find((item) => item.id === 100).description, "Classic description 100");
assert.equal(candidate.spells.find((item) => item.id === 100).dbc.marker, "Area52 overlay row");
const overrideById = new Map(candidate.coaSpellOverrides.map((item) => [item.id, item]));
for (const id of [100, 101, 102]) {
assert.equal(overrideById.has(id), true, `expected CoA-context override for linked spell ${id}`);
assert.equal(overrideById.get(id).dbc.id, id);
assert.equal(overrideById.get(id).dbc.marker, undefined);
assert.deepEqual(overrideById.get(id).classIds, [12]);
assert.equal("liveTooltip" in overrideById.get(id), false);
}
assert.equal(overrideById.get(100).description, "August & improved rank");
assert.equal(candidate.source.realmSlug, "voljin");
assert.equal(candidate.source.spellDbcSources.coaBase.patch, "T");
assert.match(candidate.source.promotion.classicSpellSource, /area-52\/patch-D/);
assert.equal(candidate.source.promotion.archiveCapturedAt, "2026-08-12T21:02:51+00:00");
assert.equal(report.preserveChecks.classicProgressionChains.passed, true);
assert.equal(report.preserveChecks.classicProtectedSpellRecords.passed, true);
assert.equal(report.nodeChanges.unexpectedRemovals.length, 0);
assert.equal(report.gaps.missingBaseProgressionRankCount, 1);
assert.equal(report.gaps.missingBaseProgressionRankIds.includes(91300), true);
assert.equal(candidate.spells.find((item) => item.id === 999).dbc, undefined);
assert.equal("liveTooltip" in candidate.spells.find((item) => item.id === 999), false);
});
test("promotion reports all missing BASE progression ranks without replacing chain IDs", () => {
const missingCoaRanks = Array.from({ length: 13 }, (_, index) => 91400 + index);
const { previous, reference, supplements, coaChain } = fixtures({ missingCoaRanks });
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
assert.deepEqual(candidate.progressionChains[1], coaChain);
assert.equal(report.gaps.missingBaseProgressionRankCount, 14);
assert.deepEqual(report.gaps.missingBaseProgressionRankIds, [91300, ...missingCoaRanks]);
assert.equal(candidate.spells.some((item) => missingCoaRanks.includes(item.id)), false);
});
test("promotion blocks July node removal and cross-class rewiring", () => {
const missing = fixtures();
missing.supplements.classes.find((item) => item.classId === 12).nodes = [];
assert.throws(() => buildCoaPromotion(missing), /August archive removes July node IDs/);
const rewired = fixtures();
const moved = rewired.supplements.classes.find((item) => item.classId === 12).nodes[0];
rewired.supplements.classes.find((item) => item.classId === 12).nodes = [];
rewired.supplements.classes.find((item) => item.classId === 13).nodes.push({ ...moved, classId: 13, tabId: 43 });
assert.throws(() => buildCoaPromotion(rewired), /August archive rewires node IDs across classes/);
});
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { activeCatalogPath, decodeJsonBytes, freezeJulyBaseline } from "./baseline.mjs";
import { buildLocalReference } from "./local-source.mjs";
import { buildCoaPromotion, normalizeArchiveNode } from "./promote.mjs";
import { readSupplements } from "./supplements.mjs";
import { validateReference } from "./audit.mjs";
import { stableJson } from "./snapshot.mjs";
import { readArchiveSpells } from "./archive-spells.mjs";
/** Explicit offline promotion, distinct from the historical reference build. */
export async function promoteCachedCoa({ projectRoot, cacheRoot, ...sourceOptions }) {
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified August cache is required; run coa:reference:fetch once");
const baselinePath = freezeJulyBaseline(projectRoot);
const previous = JSON.parse(decodeJsonBytes(fs.readFileSync(baselinePath), baselinePath).toString("utf8"));
const triggered = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/generated/coaTriggeredSpellCatalog.json"), "utf8"));
previous.classicTriggeredSpells = triggered.classicSpells ?? triggered.spells;
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const archivedSpells = await readArchiveSpells({ cacheRoot });
const reference = buildLocalReference({ projectRoot, ...sourceOptions, extraNodes, archivedSpells });
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
const spellById = new Map([...candidate.spells, ...(candidate.coaSpellOverrides ?? [])].map((spell) => [spell.id, spell]));
const validation = validateReference({
...reference,
classes: reference.classes.map((definition) => ({ ...definition, tabs: candidate.classes.find((item) => item.classId === definition.classId).tabs })),
nodes: candidate.entries,
spells: [...spellById.values()],
});
if (validation.errors.length) throw new Error(`Promotion validation failed; active catalog retained: ${validation.errors.map((item) => item.message).join("; ")}`);
for (const entry of candidate.entries) {
for (const rank of entry.rankDescriptions) {
if (!spellById.get(rank.spellId)?.dbc) throw new Error(`August node ${entry.id} rank ${rank.rank} lacks global Spell.dbc evidence; active catalog retained`);
}
}
const candidateBytes = `${JSON.stringify(candidate, null, 2)}\n`;
report.validation = validation;
report.candidateSha256 = createHash("sha256").update(candidateBytes).digest("hex");
const reportPath = path.join(projectRoot, "data/coa-reference/promotion-report.json");
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, stableJson(report));
const activePath = path.join(projectRoot, activeCatalogPath);
const temporaryPath = `${activePath}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(temporaryPath, candidateBytes, { flag: "wx" });
if (createHash("sha256").update(fs.readFileSync(temporaryPath)).digest("hex") !== report.candidateSha256) throw new Error("Staged promotion checksum mismatch");
fs.renameSync(temporaryPath, activePath);
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
}
return { reportPath, candidateSha256: report.candidateSha256, counts: candidate.counts, validationWarnings: validation.warnings.length,
gaps: { missingBaseSpellCount: report.gaps.missingBaseSpellCount, missingBaseProgressionRankIds: report.gaps.missingBaseProgressionRankIds } };
}
+156
View File
@@ -0,0 +1,156 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
export function stableJson(value) {
const sort = (item) => {
if (Array.isArray(item)) return item.map(sort);
if (item && typeof item === "object") {
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])]));
}
return item;
};
return `${JSON.stringify(sort(value))}\n`;
}
function safeChild(root, relative) {
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(root, relative);
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`Unsafe reference path: ${relative}`);
return resolved;
}
function verifyFiles(directory, manifest) {
if (manifest.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(manifest.snapshotId)) {
throw new Error("Invalid CoA snapshot manifest");
}
for (const name of ["reference.json", "audit.json", "audit.md"]) {
const file = safeChild(directory, name);
const expected = manifest.files[name];
if (!expected || !fs.existsSync(file)) throw new Error(`Missing reference file: ${name}`);
const bytes = fs.readFileSync(file);
if (bytes.length !== expected.bytes || digest(bytes) !== expected.sha256) {
throw new Error(`Corrupt reference file: ${name}; regenerate from verified sources using a fresh --output-root`);
}
}
const expectedId = digest(stableJson(manifest.files));
if (expectedId !== manifest.snapshotId) throw new Error("Snapshot manifest identity mismatch");
}
export function readSnapshot(outputRoot) {
const pointerPath = path.join(outputRoot, "current.json");
if (!fs.existsSync(pointerPath)) throw new Error("No CoA reference snapshot; run coa:reference:build first");
const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
if (!/^[a-f0-9]{64}$/.test(pointer.snapshotId)) throw new Error("Invalid reference snapshot pointer");
const directory = safeChild(outputRoot, `snapshots/${pointer.snapshotId}`);
const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
if (pointer.snapshotId !== manifest.snapshotId) throw new Error("Reference pointer and manifest disagree");
verifyFiles(directory, manifest);
return {
directory,
manifest,
reference: JSON.parse(fs.readFileSync(path.join(directory, "reference.json"), "utf8")),
report: JSON.parse(fs.readFileSync(path.join(directory, "audit.json"), "utf8")),
};
}
/** Publish immutable files before changing the current pointer; failures keep the old snapshot usable. */
export function publishSnapshot({ outputRoot, reference, report, markdown }) {
const files = {
"reference.json": stableJson(reference),
"audit.json": stableJson(report),
"audit.md": `${markdown.trimEnd()}\n`,
};
const fileManifest = Object.fromEntries(Object.entries(files).map(([name, bytes]) => [name, {
sha256: digest(bytes), bytes: Buffer.byteLength(bytes),
}]));
const snapshotId = digest(stableJson(fileManifest));
const manifest = { schemaVersion: 1, snapshotId, files: fileManifest };
const snapshotsRoot = path.join(outputRoot, "snapshots");
fs.mkdirSync(snapshotsRoot, { recursive: true });
const directory = safeChild(snapshotsRoot, snapshotId);
if (fs.existsSync(directory)) {
const previous = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
verifyFiles(directory, previous);
if (stableJson(previous) !== stableJson(manifest)) throw new Error("Existing snapshot identity collision");
} else {
const staging = fs.mkdtempSync(path.join(snapshotsRoot, ".staging-"));
try {
for (const [name, bytes] of Object.entries(files)) fs.writeFileSync(path.join(staging, name), bytes);
fs.writeFileSync(path.join(staging, "manifest.json"), stableJson(manifest));
verifyFiles(staging, manifest);
fs.renameSync(staging, directory);
} finally {
if (fs.existsSync(staging)) {
safeChild(snapshotsRoot, path.relative(snapshotsRoot, staging));
fs.rmSync(staging, { recursive: true });
}
}
}
const pointer = path.join(outputRoot, "current.json");
const temporary = path.join(outputRoot, `.current-${crypto.randomUUID()}.tmp`);
try {
fs.writeFileSync(temporary, stableJson({ schemaVersion: 1, snapshotId }));
fs.renameSync(temporary, pointer);
} finally {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
}
return { snapshotId, directory, manifest };
}
export function lookupReference(reference, options = {}) {
const needle = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const requestedClass = options.class;
const classes = reference.classes.filter((item) => !requestedClass || [
item.classId, item.gameId, item.className, ...(item.aliases ?? []),
].some((value) => needle(value) === needle(requestedClass)));
if (requestedClass && classes.length === 0) throw new Error(`Unknown CoA class: ${requestedClass}`);
const classIds = new Set(classes.map((item) => item.classId));
let nodes = reference.nodes.filter((node) => classIds.has(node.classId));
if (options.tree) {
const tabs = new Set(classes.flatMap((item) => item.tabs).filter((tab) => [tab.tabId, tab.tabName]
.some((value) => needle(value) === needle(options.tree))).map((tab) => tab.tabId));
if (!tabs.size) throw new Error(`Unknown tree: ${options.tree}`);
nodes = nodes.filter((node) => tabs.has(node.tabId));
}
if (options.ability) nodes = nodes.filter((node) => needle(node.name).includes(needle(options.ability)));
const limit = Number(options.limit ?? 20);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Lookup limit must be 1..1000");
const rankIds = new Set(nodes.flatMap((node) => node.rankDescriptions.map((rank) => rank.spellId)));
let abilities = (reference.abilities ?? []).filter((item) => classIds.has(item.classId)
&& (!options.ability || needle(item.name).includes(needle(options.ability)))
&& (!options.tree || item.nodeIds.some((id) => nodes.some((node) => node.id === id))));
for (const ability of abilities) {
for (const id of [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])]) rankIds.add(id);
}
const bySpellId = new Map(reference.spells.map((spell) => [spell.id, spell]));
const pending = [...rankIds];
for (let index = 0; index < pending.length; index += 1) {
for (const effect of bySpellId.get(pending[index])?.dbc?.effects ?? []) {
const id = effect.triggerSpellId;
if (id > 0 && !rankIds.has(id)) {
rankIds.add(id);
pending.push(id);
}
}
}
let spells = reference.spells.filter((spell) => options.spell
? spell.id === Number(options.spell)
: rankIds.has(spell.id) || (options.ability && needle(spell.name).includes(needle(options.ability)) && !requestedClass));
if (options.spell) {
if (!spells.length) throw new Error(`Spell ${options.spell} is not in this reference snapshot`);
const spellId = Number(options.spell);
nodes = nodes.filter((node) => node.rankDescriptions.some((rank) => rank.spellId === spellId));
abilities = abilities.filter((ability) => [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])].includes(spellId)
|| ability.nodeIds.some((id) => nodes.some((node) => node.id === id)));
if (requestedClass && !rankIds.has(spellId)) throw new Error(`Spell ${spellId} has no evidence for class ${requestedClass}`);
}
return {
baseline: reference.baseline,
classes: classes.map(({ classId, gameId, className, aliases, tabs }) => ({ classId, gameId, className, aliases, tabs })),
totals: { nodes: nodes.length, abilities: abilities.length, spells: spells.length },
nodes: nodes.slice(0, limit), abilities: abilities.slice(0, limit), spells: spells.slice(0, limit),
};
}
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { lookupReference, publishSnapshot, readSnapshot, stableJson } from "./snapshot.mjs";
function fixture() {
return {
baseline: { kind: "local-july", capturedAt: "2026-07-25" },
classes: [{ classId: 12, gameId: "barbarian", className: "Barbarian", tabs: [{ tabId: 31, tabName: "Brutality" }] }],
nodes: [{ id: 1, classId: 12, tabId: 31, name: "Ancestral Strike", rankDescriptions: [{ rank: 1, spellId: 100 }] }],
spells: [{ id: 100, name: "Ancestral Strike" }, { id: 101, name: "Ancestral Strike", rank: "Rank 2" }],
abilities: [{ key: "c12:ancestralstrike", classId: 12, name: "Ancestral Strike", spellIds: [100], rankSpellIds: [100, 101], nodeIds: [1] }],
};
}
test("stable serialization ignores object insertion order", () => {
assert.equal(stableJson({ b: 2, a: { y: 1, x: 2 } }), stableJson({ a: { x: 2, y: 1 }, b: 2 }));
});
test("snapshot rebuild is deterministic and corruption does not advance the pointer", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-"));
try {
const first = publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" });
const second = publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" });
assert.equal(first.snapshotId, second.snapshotId);
assert.deepEqual(readSnapshot(root).reference, fixture());
const pointer = fs.readFileSync(path.join(root, "current.json"), "utf8");
fs.writeFileSync(path.join(first.directory, "reference.json"), "{}\n");
assert.throws(() => readSnapshot(root), /Corrupt reference/);
assert.throws(() => publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" }), /Corrupt reference/);
assert.equal(fs.readFileSync(path.join(root, "current.json"), "utf8"), pointer);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("invalid pointer cannot escape snapshot directory", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-"));
try {
fs.writeFileSync(path.join(root, "current.json"), '{"snapshotId":"../outside"}\n');
assert.throws(() => readSnapshot(root), /Invalid reference snapshot pointer/);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("lookup supports class names, trees, rank spell IDs and ability names", () => {
assert.equal(lookupReference(fixture(), { class: "Barbarian", tree: "Brutality", ability: "ancestral" }).spells.length, 2);
assert.equal(lookupReference(fixture(), { class: "12", spell: "101" }).spells[0].id, 101);
assert.throws(() => lookupReference(fixture(), { class: "unknown" }), /Unknown CoA class/);
assert.throws(() => lookupReference(fixture(), { tree: "unknown" }), /Unknown tree/);
assert.throws(() => lookupReference(fixture(), { spell: "999" }), /not in this reference/);
});
test("class lookup follows triggered spell dependencies without treating them as learned abilities", () => {
const reference = fixture();
reference.spells[0].dbc = { effects: [{ triggerSpellId: 102 }] };
reference.spells.push({ id: 102, name: "Hidden strike", dbc: { effects: [{ triggerSpellId: 100 }] } });
const result = lookupReference(reference, { class: "barbarian", spell: "102" });
assert.equal(result.spells[0].id, 102);
assert.equal(result.abilities.length, 0);
assert.equal(result.nodes.length, 0);
});
+557
View File
@@ -0,0 +1,557 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveArchiveDescriptions } from "./flight-text.mjs";
import { fileURLToPath } from "node:url";
export const COA_ARCHIVE = Object.freeze({
repository: "srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
shortRevision: "5078f9ac2254",
capturedAt: "2026-08-12T21:02:51+00:00",
fetchManifestPath: "raw/talents/_fetch.json",
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
builderSnapshotSha256: "41a6ccd393c0648ae2b1fdc34fe94955d4fd13c0cd59040ad3860e64a8ff6c7a",
});
const defaultProjectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const expectedClassCount = 21;
const cacheSchemaVersion = 1;
const manifestName = "manifest.json";
function assertSafeRelativePath(value, context = "archive path") {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${context} must be a non-empty string`);
}
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
throw new Error(`${context} must be relative: ${value}`);
}
if (normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`${context} contains an unsafe path segment: ${value}`);
}
return normalized;
}
function classFileFromDescriptor(descriptor) {
let candidate = null;
if (typeof descriptor === "string") {
candidate = descriptor;
} else if (descriptor && typeof descriptor === "object") {
candidate = descriptor.path ?? descriptor.file ?? descriptor.filename ?? descriptor.href ?? null;
if (candidate === null) candidate = descriptor.slug ?? descriptor.key ?? descriptor.id ?? null;
}
if (candidate === null || candidate === undefined) {
throw new Error("CoA talent class index contains an entry without a file path or class key");
}
const relative = assertSafeRelativePath(String(candidate), "CoA talent class file reference");
let filePath;
if (relative.startsWith("data/talents/coa/classes/")) {
filePath = relative;
} else if (relative.startsWith("classes/")) {
filePath = `data/talents/coa/${relative}`;
} else if (relative.startsWith("data/talents/coa/") && !relative.slice("data/talents/coa/".length).includes("/")) {
filePath = relative;
} else if (relative.includes("/")) {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
} else {
filePath = `data/talents/coa/${relative.endsWith(".json") ? relative : `${relative}.json`}`;
}
if (!filePath.startsWith("data/talents/coa/") || !filePath.endsWith(".json")
|| filePath === "data/talents/coa/index.json"
|| filePath === "data/talents/coa/_meta.json") {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
}
return filePath;
}
function isClassArchivePath(filePath) {
return filePath.startsWith("data/talents/coa/")
&& filePath.endsWith(".json")
&& filePath !== "data/talents/coa/index.json"
&& filePath !== "data/talents/coa/_meta.json";
}
function descriptorsFromIndex(index) {
const entries = index?.classes;
if (Array.isArray(entries)) return entries;
if (entries && typeof entries === "object") {
return Object.entries(entries).map(([key, value]) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") return { key, ...value };
return { key };
});
}
throw new Error("CoA talent index has no classes array or object");
}
function classPathsFromIndex(index) {
const descriptors = descriptorsFromIndex(index);
if (descriptors.length !== expectedClassCount) {
throw new Error(`CoA talent index expected ${expectedClassCount} class files, found ${descriptors.length}`);
}
const paths = descriptors.map(classFileFromDescriptor);
if (new Set(paths).size !== expectedClassCount) {
throw new Error("CoA talent index contains duplicate class file references");
}
return paths;
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
.update(bytes)
.digest("hex");
}
function parseJson(bytes, filePath) {
try {
return JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new Error(`CoA archive JSON is invalid at ${filePath}: ${error.message}`, { cause: error });
}
}
function isRegularBlob(entry) {
return entry?.type === "blob" && entry?.mode === "100644" && /^[0-9a-f]{40}$/i.test(entry.sha ?? "");
}
export function createSupplementCacheApi(archiveInput = COA_ARCHIVE) {
const archive = Object.freeze({ ...archiveInput });
for (const key of ["repository", "revision", "shortRevision", "capturedAt", "fetchManifestPath", "builderSnapshotPath", "builderSnapshotSha256"]) {
if (typeof archive[key] !== "string" || archive[key].length === 0) {
throw new TypeError(`archive.${key} must be a non-empty string`);
}
}
if (!/^[0-9a-f]{40}$/i.test(archive.revision) || !/^[0-9a-f]{7,40}$/i.test(archive.shortRevision)) {
throw new TypeError("archive revision values must be hexadecimal Git revisions");
}
if (!/^[0-9a-f]{64}$/i.test(archive.builderSnapshotSha256)) {
throw new TypeError("archive.builderSnapshotSha256 must be a SHA-256 hex digest");
}
const requiredPaths = [
archive.fetchManifestPath,
archive.builderSnapshotPath,
"data/talents/coa/index.json",
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
];
function archiveDirectory(cacheRoot) {
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) {
throw new TypeError("cacheRoot must be a non-empty directory path");
}
return path.join(path.resolve(cacheRoot), `archive-${archive.shortRevision}`);
}
function archiveUrls(filePath) {
const apiUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
const rawPath = filePath.split("/").map(encodeURIComponent).join("/");
const rawUrl = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/${rawPath}`;
return { apiUrl, rawUrl };
}
function parseCaptureManifest(bytes) {
const capture = parseJson(bytes, archive.fetchManifestPath);
if (capture?.capturedUtc !== archive.capturedAt) {
throw new Error(`CoA capture manifest has an unexpected capture time: ${String(capture?.capturedUtc)}`);
}
if (capture?.savedAs !== path.posix.basename(archive.builderSnapshotPath)) {
throw new Error(`CoA capture manifest has an unexpected savedAs value: ${String(capture?.savedAs)}`);
}
if (capture?.sha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA capture manifest does not pin builder SHA-256 ${archive.builderSnapshotSha256}`);
}
return capture;
}
function sourceRecord() {
return {
repository: `https://github.com/${archive.repository}`,
revision: archive.revision,
capturedAt: archive.capturedAt,
captureManifestPath: archive.fetchManifestPath,
builderSnapshotPath: archive.builderSnapshotPath,
builderSnapshotSha256: archive.builderSnapshotSha256,
};
}
function validateManifestShape(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new Error("CoA supplement manifest is not an object");
}
if (manifest.schemaVersion !== cacheSchemaVersion) {
throw new Error(`Unsupported CoA supplement cache schema: ${String(manifest.schemaVersion)}`);
}
if (manifest.source?.revision !== archive.revision) {
throw new Error("CoA supplement manifest points at an unexpected archive revision");
}
if (typeof manifest.generation !== "string" || !/^generation-[0-9a-f-]+$/.test(manifest.generation)) {
throw new Error("CoA supplement manifest has an invalid generation name");
}
if (!Array.isArray(manifest.files)) throw new Error("CoA supplement manifest has no file list");
const source = manifest.source;
if (source.captureManifestPath !== archive.fetchManifestPath
|| source.builderSnapshotPath !== archive.builderSnapshotPath
|| source.builderSnapshotSha256 !== archive.builderSnapshotSha256
|| source.capturedAt !== archive.capturedAt) {
throw new Error("CoA supplement manifest has unexpected capture metadata");
}
}
function expectedFilesFromManifest(manifest) {
validateManifestShape(manifest);
const expected = new Map();
for (const file of manifest.files) {
const filePath = assertSafeRelativePath(file?.path, "CoA supplement manifest file path");
if (expected.has(filePath)) throw new Error(`CoA supplement manifest repeats ${filePath}`);
if (!/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")) {
throw new Error(`CoA supplement manifest has an invalid Git blob SHA for ${filePath}`);
}
if (!/^[0-9a-f]{64}$/i.test(file.sha256 ?? "")) {
throw new Error(`CoA supplement manifest has an invalid SHA-256 for ${filePath}`);
}
if (!Number.isSafeInteger(file.size) || file.size < 0) {
throw new Error(`CoA supplement manifest has an invalid size for ${filePath}`);
}
expected.set(filePath, file);
}
for (const filePath of requiredPaths) {
if (!expected.has(filePath)) throw new Error(`CoA supplement manifest is missing ${filePath}`);
}
const classPaths = [...expected.keys()].filter(isClassArchivePath);
if (classPaths.length !== expectedClassCount || expected.size !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplement manifest must contain ${expectedClassCount} class files and ${requiredPaths.length} required files`);
}
return expected;
}
async function responseBytes(fetchImpl, url, accept) {
let response;
try {
response = await fetchImpl(url, { headers: { Accept: accept } });
} catch (error) {
throw new Error(`CoA supplemental source unavailable at ${url}: ${error.message}`, { cause: error });
}
if (!response || response.ok !== true) {
const status = response?.status === undefined ? "no response" : `HTTP ${response.status}`;
throw new Error(`CoA supplemental source unavailable at ${url} (${status})`);
}
try {
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
} catch (error) {
throw new Error(`CoA supplemental source read failed at ${url}: ${error.message}`, { cause: error });
}
throw new Error(`CoA supplemental source response has no readable body at ${url}`);
}
async function fetchTree(fetchImpl) {
const { apiUrl } = archiveUrls(archive.fetchManifestPath);
const bytes = await responseBytes(fetchImpl, apiUrl, "application/vnd.github+json");
const treeDocument = parseJson(bytes, "GitHub archive tree");
if (treeDocument.truncated === true) {
throw new Error("CoA archive tree was truncated; refusing an incomplete supplemental cache");
}
if (!Array.isArray(treeDocument.tree)) throw new Error("CoA archive tree response does not contain a tree array");
return new Map(treeDocument.tree.map((entry) => [entry.path, entry]));
}
function expectedFile(tree, filePath) {
const safePath = assertSafeRelativePath(filePath);
const entry = tree.get(safePath);
if (!isRegularBlob(entry)) throw new Error(`Pinned CoA archive tree is missing a regular file at ${safePath}`);
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
throw new Error(`Pinned CoA archive tree has no valid file size for ${safePath}`);
}
return { path: safePath, gitBlobSha: entry.sha.toLowerCase(), size: entry.size };
}
async function readManifest(archiveRoot) {
try {
return parseJson(await fs.readFile(path.join(archiveRoot, manifestName)), manifestName);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
async function readFileUnder(root, relativePath) {
const safePath = assertSafeRelativePath(relativePath);
const absoluteRoot = path.resolve(root);
const absolutePath = path.resolve(absoluteRoot, ...safePath.split("/"));
if (!absolutePath.startsWith(`${absoluteRoot}${path.sep}`)) {
throw new Error(`CoA archive path escapes its cache directory: ${relativePath}`);
}
return fs.readFile(absolutePath);
}
async function verifyFile(root, fileRecord) {
const bytes = await readFileUnder(root, fileRecord.path);
if (bytes.byteLength !== fileRecord.size) throw new Error(`CoA supplement size mismatch for ${fileRecord.path}`);
if (gitBlobSha(bytes) !== fileRecord.gitBlobSha.toLowerCase()) {
throw new Error(`CoA supplement Git blob checksum mismatch for ${fileRecord.path}`);
}
const actualSha256 = sha256(bytes);
if (actualSha256 !== fileRecord.sha256.toLowerCase()) {
throw new Error(`CoA supplement SHA-256 mismatch for ${fileRecord.path}`);
}
if (fileRecord.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (fileRecord.path === archive.builderSnapshotPath && actualSha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA builder snapshot does not match pinned SHA-256 ${archive.builderSnapshotSha256}`);
}
return bytes;
}
async function loadPublishedCache(archiveRoot) {
const manifest = await readManifest(archiveRoot);
if (manifest === null) return null;
const expected = expectedFilesFromManifest(manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
parseCaptureManifest(contents.get(archive.fetchManifestPath));
const indexPath = "data/talents/coa/index.json";
const index = parseJson(contents.get(indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (classPaths.slice().sort().join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement manifest class files do not match data/talents/coa/index.json");
}
const metadata = {
talents: parseJson(contents.get("data/talents/coa/_meta.json"), "data/talents/coa/_meta.json"),
provenance: parseJson(contents.get("raw/provenance.json"), "raw/provenance.json"),
clientSnapshot: parseJson(contents.get("raw/_snapshot.json"), "raw/_snapshot.json"),
spells: parseJson(contents.get("data/spells/_meta.json"), "data/spells/_meta.json"),
};
const descriptors = descriptorsFromIndex(index);
const rawClasses = classPaths.map((filePath) => parseJson(contents.get(filePath), filePath));
const hasTextReferences = rawClasses.some((definition) => definition.nodes?.some((node) => (
/^\$[0-9a-f]+$/i.test(node.description ?? "")
|| node.rankDescriptions?.some((rank) => /^\$[0-9a-f]+$/i.test(rank.description ?? ""))
)));
const classes = hasTextReferences
? resolveArchiveDescriptions(rawClasses, contents.get(archive.builderSnapshotPath).toString("utf8"))
: rawClasses;
const classFiles = classPaths.map((filePath, position) => ({ path: filePath, indexEntry: descriptors[position], data: classes[position] }));
return {
source: structuredClone(manifest.source),
classes,
classFiles,
metadata,
index,
manifest: structuredClone(manifest),
};
}
async function atomicWrite(filePath, bytes) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.tmp-${randomUUID()}`;
try {
await fs.writeFile(temporaryPath, bytes, { flag: "wx" });
await fs.rename(temporaryPath, filePath);
} catch (error) {
await fs.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
}
async function discardInvalidFile(filePath) {
try {
await fs.rm(filePath, { force: true });
} catch (error) {
throw new Error(`Could not discard invalid cached CoA supplement ${filePath}: ${error.message}`, { cause: error });
}
}
async function readAndVerifyStaged(stagingRoot, expected) {
let bytes;
try {
bytes = await readFileUnder(stagingRoot, expected.path);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
if (bytes.byteLength !== expected.size || gitBlobSha(bytes) !== expected.gitBlobSha) {
await discardInvalidFile(stagedPath);
return null;
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) {
try {
parseCaptureManifest(bytes);
} catch {
await discardInvalidFile(stagedPath);
return null;
}
}
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
await discardInvalidFile(stagedPath);
return null;
}
return { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
}
async function fetchAndPromoteFile(fetchImpl, stagingRoot, expected) {
const { rawUrl } = archiveUrls(expected.path);
const bytes = await responseBytes(fetchImpl, rawUrl, "application/octet-stream");
if (gitBlobSha(bytes) !== expected.gitBlobSha) {
throw new Error(`Downloaded CoA supplement Git blob checksum mismatch for ${expected.path}`);
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
throw new Error(`Downloaded CoA builder snapshot SHA-256 mismatch for ${expected.path}`);
}
const record = { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
await atomicWrite(stagedPath, bytes);
await verifyFile(stagingRoot, record);
return record;
}
async function stageFile(fetchImpl, stagingRoot, expected) {
const cached = await readAndVerifyStaged(stagingRoot, expected);
if (cached) return { record: cached, downloaded: false };
return { record: await fetchAndPromoteFile(fetchImpl, stagingRoot, expected), downloaded: true };
}
async function validateGeneration(generationRoot, manifest) {
const expected = expectedFilesFromManifest(manifest);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
const index = parseJson(contents.get("data/talents/coa/index.json"), "data/talents/coa/index.json");
const indexedClassPaths = classPathsFromIndex(index).sort();
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (indexedClassPaths.join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement generation class files do not match the pinned index");
}
parseCaptureManifest(contents.get(archive.fetchManifestPath));
for (const filePath of [
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
...indexedClassPaths,
]) {
parseJson(contents.get(filePath), filePath);
}
}
async function writeManifest(archiveRoot, manifest) {
await atomicWrite(
path.join(archiveRoot, manifestName),
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
);
}
function cacheSummary(manifest, cacheDirectory, reused, downloadedFiles = []) {
return {
source: structuredClone(manifest.source),
cacheDirectory,
revision: archive.revision,
capturedAt: archive.capturedAt,
fileCount: manifest.files.length,
classCount: expectedClassCount,
reused,
downloadedFiles,
};
}
async function readSupplements({ cacheRoot } = {}) {
const archiveRoot = archiveDirectory(cacheRoot);
try {
await fs.access(archiveRoot);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
try {
const contents = await loadPublishedCache(archiveRoot);
if (contents === null) throw new Error("published manifest is missing");
return contents;
} catch (error) {
throw new Error(`CoA supplement cache at ${archiveRoot} is corrupt or incomplete: ${error.message}`, { cause: error });
}
}
async function downloadSupplements({ cacheRoot, fetchImpl = globalThis.fetch, refresh = false } = {}) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a fetch-compatible function");
const archiveRoot = archiveDirectory(cacheRoot);
if (!refresh) {
try {
const cached = await loadPublishedCache(archiveRoot);
if (cached) return cacheSummary(cached.manifest, archiveRoot, true);
} catch {
// Keep the previous manifest and generation until a complete replacement is ready.
}
}
await fs.mkdir(archiveRoot, { recursive: true });
const tree = await fetchTree(fetchImpl);
const expected = new Map(requiredPaths.map((filePath) => [filePath, expectedFile(tree, filePath)]));
const stagingRoot = path.join(archiveRoot, "staging");
const stagedRecords = new Map();
const downloadedFiles = [];
for (const filePath of requiredPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const indexPath = "data/talents/coa/index.json";
const index = parseJson(await readFileUnder(stagingRoot, indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
for (const filePath of classPaths) expected.set(filePath, expectedFile(tree, filePath));
for (const filePath of classPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const files = [...stagedRecords.values()].sort((left, right) => left.path.localeCompare(right.path));
if (files.length !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplemental cache expected ${requiredPaths.length + expectedClassCount} files but staged ${files.length}`);
}
parseCaptureManifest(await readFileUnder(stagingRoot, archive.fetchManifestPath));
const manifest = {
schemaVersion: cacheSchemaVersion,
source: sourceRecord(),
generation: `generation-${randomUUID()}`,
files,
};
// Validate every staged byte before moving the resumable cache into an immutable generation.
await validateGeneration(stagingRoot, manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
await fs.rename(stagingRoot, generationRoot);
await writeManifest(archiveRoot, manifest);
return cacheSummary(manifest, archiveRoot, false, downloadedFiles);
}
return Object.freeze({ readSupplements, downloadSupplements });
}
const defaultApi = createSupplementCacheApi();
export const readSupplements = defaultApi.readSupplements;
export const downloadSupplements = defaultApi.downloadSupplements;
export function getDefaultSupplementCacheRoot(projectRoot = defaultProjectRoot) {
return path.join(path.resolve(projectRoot), "data", "coa-reference", "cache");
}
+238
View File
@@ -0,0 +1,238 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { afterEach, beforeEach } from "node:test";
import { createSupplementCacheApi } from "./supplements.mjs";
const testArchiveBase = {
repository: "example/coa-archive-fixture",
revision: "a".repeat(40),
shortRevision: "a".repeat(12),
capturedAt: "2026-08-12T21:02:51+00:00",
fetchManifestPath: "raw/talents/_fetch.json",
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
};
let tempRoot;
let archive;
let fileBytes;
let treeUrl;
let api;
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
.update(bytes)
.digest("hex");
}
function pathIsWithin(parent, candidate) {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}
function makeFixture({ traversal = false } = {}) {
const html = Buffer.from("fixture builder snapshot payload\n", "utf8");
archive = {
...testArchiveBase,
builderSnapshotSha256: sha256(html),
};
const descriptors = Array.from({ length: 21 }, (_, index) => ({
name: `Class ${index + 1}`,
file: traversal && index === 0
? "../outside.json"
: index === 0
? "Barbarian.json"
: `Class-${String(index + 1).padStart(2, "0")}.json`,
}));
const captureManifest = Buffer.from(`${JSON.stringify({
capturedUtc: archive.capturedAt,
sha256: archive.builderSnapshotSha256,
savedAs: path.posix.basename(archive.builderSnapshotPath),
})}\n`, "utf8");
const entries = new Map([
[archive.fetchManifestPath, captureManifest],
[archive.builderSnapshotPath, html],
["data/talents/coa/index.json", Buffer.from(`${JSON.stringify({ classes: descriptors })}\n`, "utf8")],
["data/talents/coa/_meta.json", Buffer.from('{"capture":"talents"}\n', "utf8")],
["raw/provenance.json", Buffer.from('{"sources":[]}\n', "utf8")],
["raw/_snapshot.json", Buffer.from('{"commit":"fixture"}\n', "utf8")],
["data/spells/_meta.json", Buffer.from('{"capture":"spells"}\n', "utf8")],
]);
for (let index = 0; index < 21; index += 1) {
const fileName = index === 0 ? "Barbarian.json" : `Class-${String(index + 1).padStart(2, "0")}.json`;
const filePath = `data/talents/coa/${fileName}`;
entries.set(filePath, Buffer.from(`${JSON.stringify({ classNumber: index + 1, trees: [{ id: index + 1 }] })}\n`, "utf8"));
}
// These must remain untouched: only metadata and the pinned capture are selected.
entries.set("data/spells/123456.json", Buffer.from("large spell payload"));
entries.set("raw/Spell.dbc", Buffer.from("client dbc"));
fileBytes = entries;
const tree = [...entries].map(([filePath, bytes]) => ({
path: filePath,
mode: "100644",
type: "blob",
sha: gitBlobSha(bytes),
size: bytes.byteLength,
}));
treeUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
api = createSupplementCacheApi(archive);
return { tree };
}
function createFetch({ tree, failPath = null, failOnce = false, calls = [] } = {}) {
let didFail = false;
return {
calls,
fetch: async (url) => {
calls.push(url);
if (url === treeUrl) {
return response(Buffer.from(JSON.stringify({ truncated: false, tree }), "utf8"));
}
const rawPrefix = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/`;
assert.ok(url.startsWith(rawPrefix), `unexpected URL ${url}`);
const filePath = url.slice(rawPrefix.length).split("/").map(decodeURIComponent).join("/");
if (filePath === failPath && (!failOnce || !didFail)) {
didFail = true;
return { ok: false, status: 503 };
}
const bytes = fileBytes.get(filePath);
if (!bytes) return { ok: false, status: 404 };
return response(bytes);
},
};
}
function response(bytes) {
return { ok: true, arrayBuffer: async () => Uint8Array.from(bytes).buffer };
}
async function freshRoot() {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "coa-supplements-test-"));
return path.join(tempRoot, "cache");
}
beforeEach(() => {
tempRoot = null;
});
afterEach(async () => {
if (tempRoot) {
assert.ok(pathIsWithin(os.tmpdir(), tempRoot), "cleanup path must stay inside the OS temporary directory");
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
test("downloads the selected archive files and reuses a valid cache offline", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
const firstFetch = createFetch({ tree });
const first = await api.downloadSupplements({ cacheRoot, fetchImpl: firstFetch.fetch });
assert.equal(first.reused, false);
assert.equal(first.fileCount, 28);
assert.equal(first.classCount, 21);
assert.equal(firstFetch.calls.length, 29);
assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/data/spells/123456.json`), false);
assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/raw/Spell.dbc`), false);
const second = await api.downloadSupplements({
cacheRoot,
fetchImpl: async () => {
throw new Error("valid cache should avoid the network");
},
});
const supplements = await api.readSupplements({ cacheRoot });
assert.equal(second.reused, true);
assert.equal(supplements.classes.length, 21);
assert.deepEqual(supplements.metadata, {
talents: { capture: "talents" },
provenance: { sources: [] },
clientSnapshot: { commit: "fixture" },
spells: { capture: "spells" },
});
assert.equal(supplements.classFiles[0].path, "data/talents/coa/Barbarian.json");
assert.deepEqual(supplements.classFiles[0].data, { classNumber: 1, trees: [{ id: 1 }] });
});
test("resumes verified files after an interrupted download", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
const interrupted = createFetch({ tree, failPath: "data/talents/coa/Class-03.json" });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: interrupted.fetch }),
/HTTP 503/,
);
await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/);
const resumedFetch = createFetch({ tree });
const summary = await api.downloadSupplements({ cacheRoot, fetchImpl: resumedFetch.fetch });
assert.equal(summary.reused, false);
assert.equal(summary.downloadedFiles.length, 19);
const supplements = await api.readSupplements({ cacheRoot });
assert.equal(supplements.classes.length, 21);
});
test("offline reads refuse corrupted files, and an online run repairs them", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`);
const manifest = JSON.parse(await fs.readFile(path.join(archiveRoot, "manifest.json"), "utf8"));
const generationFile = path.join(archiveRoot, manifest.generation, "data", "talents", "coa", "Barbarian.json");
await fs.writeFile(generationFile, "corrupted\n", "utf8");
await assert.rejects(api.readSupplements({ cacheRoot }), /size mismatch|checksum mismatch/);
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const repaired = await api.readSupplements({ cacheRoot });
assert.deepEqual(repaired.classes[0], { classNumber: 1, trees: [{ id: 1 }] });
});
test("reports an unavailable source without publishing a partial manifest", async () => {
makeFixture();
const cacheRoot = await freshRoot();
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: async () => { throw new Error("offline"); } }),
/CoA supplemental source unavailable/,
);
await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/);
});
test("keeps the last valid manifest and generation when a refresh fails", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`);
const manifestPath = path.join(archiveRoot, "manifest.json");
const previousManifest = await fs.readFile(manifestPath);
const previous = JSON.parse(previousManifest.toString("utf8"));
const previousClassPath = path.join(archiveRoot, previous.generation, "data", "talents", "coa", "Barbarian.json");
const previousClass = await fs.readFile(previousClassPath);
const interruptedRefresh = createFetch({ tree, failPath: "data/talents/coa/Class-04.json" });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: interruptedRefresh.fetch, refresh: true }),
/HTTP 503/,
);
assert.deepEqual(await fs.readFile(manifestPath), previousManifest);
assert.deepEqual(await fs.readFile(previousClassPath), previousClass);
assert.equal((await api.readSupplements({ cacheRoot })).classes.length, 21);
});
test("rejects index path traversal before requesting a class file", async () => {
const { tree } = makeFixture({ traversal: true });
const cacheRoot = await freshRoot();
const source = createFetch({ tree });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: source.fetch }),
/unsafe path segment/,
);
assert.equal(source.calls.some((url) => url.includes("outside.json")), false);
assert.equal(await fs.stat(path.join(tempRoot, "outside.json")).then(() => true, () => false), false);
});