Update class mechanics, stat rulesets, and CoA reference catalogs
This commit is contained in:
1 parent
b16821e92e
commit
39458d5de6
221 files changed
+2584962
-568
No files matched your search
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/�*39;|'/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 };
|
||||
}
|
||||
@@ -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 & 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 & 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 } };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user