Update class mechanics, stat rulesets, and CoA reference catalogs

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

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
/** Reconstruct the exact scalar/string semantics of positional archive rows. */
export function archiveRowsToDbc(records, fieldTypes) {
if (!Array.isArray(fieldTypes) || fieldTypes.length !== 234) throw new Error("Archived Spell requires all 234 column types");
const strings = [Buffer.from([0])];
let stringSize = 1;
const stringOffsets = new Map([["", 0]]);
const rows = records.map((record) => {
const row = Buffer.alloc(234 * 4);
for (let index = 0; index < 234; index += 1) {
const key = `f${index}`;
if (!Object.hasOwn(record, key)) throw new Error(`Archived spell ${record.f0} is missing ${key}`);
const value = record[key];
const localized = (index >= 136 && index <= 151) || (index >= 153 && index <= 168)
|| (index >= 170 && index <= 185) || (index >= 187 && index <= 202);
const text = typeof value === "string" ? value : localized && typeof record[`${key}s`] === "string" ? record[`${key}s`] : null;
if (text !== null) {
if (!stringOffsets.has(text)) {
const bytes = Buffer.from(`${text}\0`, "utf8");
stringOffsets.set(text, stringSize);
strings.push(bytes);
stringSize += bytes.length;
}
row.writeUInt32LE(stringOffsets.get(text), index * 4);
} else if (fieldTypes[index] === "float") {
if (!Number.isFinite(value)) throw new Error(`Archived spell ${record.f0} has invalid ${key}`);
row.writeFloatLE(value, index * 4);
} else {
if (!Number.isInteger(value) || value < -2147483648 || value > 4294967295) throw new Error(`Archived spell ${record.f0} has invalid integer ${key}`);
row.writeUInt32LE(value >>> 0, index * 4);
}
}
return row;
});
const header = Buffer.alloc(20);
header.write("WDBC", 0);
header.writeUInt32LE(rows.length, 4);
header.writeUInt32LE(234, 8);
header.writeUInt32LE(234 * 4, 12);
header.writeUInt32LE(stringSize, 16);
return Buffer.concat([header, ...rows, ...strings]);
}
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { archiveRowsToDbc } from "./archive-dbc.mjs";
test("archive positional reconstruction preserves signed bits, inferred floats and localized text", () => {
const types = Array(234).fill("int");
types[229] = "float";
const row = Object.fromEntries(types.map((_, index) => [`f${index}`, 0]));
Object.assign(row, { f0: 100, f4: -2147483648, f41: -2, f47: 1101004800, f136: "New spell", f136i: 54321, f170: 43210, f170s: "Alternate decoded description", f229: 0.375 });
const buffer = archiveRowsToDbc([row], types);
assert.equal(buffer.readUInt32LE(4), 1);
assert.equal(buffer.readUInt32LE(8), 234);
assert.equal(buffer.readUInt32LE(20 + 4 * 4), 2147483648);
assert.equal(buffer.readInt32LE(20 + 41 * 4), -2);
assert.equal(buffer.readFloatLE(20 + 47 * 4), 20);
assert.equal(buffer.readFloatLE(20 + 229 * 4), 0.375);
const stringStart = 20 + 234 * 4;
for (const [field, text] of [[136, "New spell"], [170, "Alternate decoded description"]]) {
const start = stringStart + buffer.readUInt32LE(20 + field * 4);
assert.equal(buffer.toString("utf8", start, buffer.indexOf(0, start)), text);
}
const malformed = { ...row };
delete malformed.f71;
assert.throws(() => archiveRowsToDbc([malformed], types), /missing f71/);
});
+566
View File
@@ -0,0 +1,566 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { gunzipSync } from "node:zlib";
export const ARCHIVE_SPELL_SOURCE = Object.freeze({
id: "august-global-spell-base",
repository: "srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
shortRevision: "5078f9ac2254",
capturedAt: "2026-08-12T21:02:51+00:00",
variantPath: "raw/tables/Spell/variants/data-patch-t-mpq",
tableIndexPath: "raw/tables/Spell/variants/data-patch-t-mpq/index.json",
tableIndexSha256: "3f3bcf86285607f73bc1cab248c57e12abb1d858c015cbd06cfc15d8ec0aea84",
tableIndexGitBlobSha: "fadd592b46f7d6d413713d79dff78018c14595bb",
columnInfoPath: "raw/tables/Spell/variants/data-patch-t-mpq/Spell.colinfo.json",
columnInfoSha256: "ba7ecede4d45c93ac66dd5917e5f9c34c81ac7da818e4a3c33b5cc80d5e1cf20",
columnInfoGitBlobSha: "ad3acfe6b3894a7990b81050972d707a6c0e7b28",
sourceDbcSha256: "a1e4285cdbc8a269a1b8a3f4b304ee43eb4d1bfa5a98511639868c4ff6097623",
rows: 209206,
columns: 234,
});
const cacheSchemaVersion = 1;
const manifestName = "manifest.json";
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from("blob " + bytes.byteLength + "\0", "utf8"))
.update(bytes)
.digest("hex");
}
function parseJson(bytes, label) {
try {
return JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new Error("Archive Spell JSON is invalid at " + label + ": " + error.message, { cause: error });
}
}
function assertSafeRelativePath(value, label) {
if (typeof value !== "string" || value.length === 0) throw new TypeError(label + " must be a non-empty path");
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)
|| normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
throw new Error(label + " is not a safe relative path: " + value);
}
return normalized;
}
function validateSource(source) {
const required = [
"id", "repository", "revision", "shortRevision", "capturedAt", "variantPath",
"tableIndexPath", "tableIndexSha256", "tableIndexGitBlobSha", "columnInfoPath",
"columnInfoSha256", "columnInfoGitBlobSha", "sourceDbcSha256",
];
for (const key of required) {
if (typeof source[key] !== "string" || source[key].length === 0) {
throw new TypeError("source." + key + " must be a non-empty string");
}
}
if (!/^[0-9a-f]{40}$/i.test(source.revision) || !/^[0-9a-f]{7,40}$/i.test(source.shortRevision)) {
throw new TypeError("source revision values must be hexadecimal Git revisions");
}
for (const key of ["tableIndexSha256", "columnInfoSha256", "sourceDbcSha256"]) {
if (!/^[0-9a-f]{64}$/i.test(source[key])) throw new TypeError("source." + key + " must be a SHA-256 digest");
}
for (const key of ["tableIndexGitBlobSha", "columnInfoGitBlobSha"]) {
if (!/^[0-9a-f]{40}$/i.test(source[key])) throw new TypeError("source." + key + " must be a Git blob SHA");
}
if (!Number.isSafeInteger(source.rows) || source.rows < 1) throw new TypeError("source.rows must be a positive integer");
if (!Number.isSafeInteger(source.columns) || source.columns < 1) throw new TypeError("source.columns must be a positive integer");
source.variantPath = assertSafeRelativePath(source.variantPath, "source.variantPath");
source.tableIndexPath = assertSafeRelativePath(source.tableIndexPath, "source.tableIndexPath");
source.columnInfoPath = assertSafeRelativePath(source.columnInfoPath, "source.columnInfoPath");
if (source.tableIndexPath !== source.variantPath + "/index.json"
|| source.columnInfoPath !== source.variantPath + "/Spell.colinfo.json") {
throw new Error("Archive Spell metadata paths must belong to the pinned BASE variant");
}
return Object.freeze({ ...source });
}
function sourceRecord(source) {
return {
id: source.id,
repository: "https://github.com/" + source.repository,
revision: source.revision,
capturedAt: source.capturedAt,
variantPath: source.variantPath,
tableIndexPath: source.tableIndexPath,
tableIndexSha256: source.tableIndexSha256,
tableIndexGitBlobSha: source.tableIndexGitBlobSha,
columnInfoPath: source.columnInfoPath,
columnInfoSha256: source.columnInfoSha256,
columnInfoGitBlobSha: source.columnInfoGitBlobSha,
sourceDbcSha256: source.sourceDbcSha256,
rows: source.rows,
columns: source.columns,
};
}
function resultSource(source, files) {
const storedBytes = files.reduce((total, file) => total + file.size, 0);
return {
id: source.id,
path: source.variantPath,
sha256: source.tableIndexSha256,
bytes: storedBytes,
storedBytes,
sourceDbcSha256: source.sourceDbcSha256,
originalRecordCount: source.rows,
pin: source.revision,
capturedAt: source.capturedAt,
tableIndexPath: source.tableIndexPath,
tableIndexGitBlobSha: source.tableIndexGitBlobSha,
columnInfoPath: source.columnInfoPath,
columnInfoSha256: source.columnInfoSha256,
columnInfoGitBlobSha: source.columnInfoGitBlobSha,
};
}
function assertPinnedBytes(bytes, sha256Pin, gitBlobPin, label) {
const actualSha = sha256(bytes);
const actualBlob = gitBlobSha(bytes);
if (actualSha !== sha256Pin.toLowerCase()) {
throw new Error(label + " SHA-256 mismatch: expected " + sha256Pin + ", found " + actualSha);
}
if (actualBlob !== gitBlobPin.toLowerCase()) {
throw new Error(label + " Git blob mismatch: expected " + gitBlobPin + ", found " + actualBlob);
}
}
function validateIndex(index, source) {
if (!index || typeof index !== "object" || Array.isArray(index)) throw new Error("Archive Spell index is not an object");
if (index.table !== "Spell" || index.file !== "Spell.dbc") throw new Error("Archive Spell index is not the Spell table");
if (index.sourceSha256 !== source.sourceDbcSha256) {
throw new Error("Archive Spell index is not the pinned BASE patch-T Spell.dbc source");
}
if (index.rows !== source.rows || index.columns !== source.columns) {
throw new Error("Archive Spell index row or column count does not match the pinned source");
}
if (index.format !== "gzip" || index.shardKey?.column !== "f0"
|| index.shardKey?.mode !== "column" || index.shardKey?.unsigned !== true) {
throw new Error("Archive Spell index does not use the pinned unsigned f0 shard layout");
}
if (!Array.isArray(index.shards) || index.shards.length === 0) throw new Error("Archive Spell index has no shard list");
const names = new Set();
const ranges = [];
for (const shard of index.shards) {
const name = assertSafeRelativePath(shard?.file, "Archive Spell shard path");
const match = /^([0-9]+)-([0-9]+)\.jsonl\.gz$/.exec(name);
if (!match || name.includes("/")) throw new Error("Archive Spell index has an invalid shard filename: " + name);
if (names.has(name)) throw new Error("Archive Spell index repeats shard " + name);
names.add(name);
const lo = Number(match[1]);
const hi = Number(match[2]);
if (lo !== shard.lo || hi !== shard.hi || !Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || hi < lo) {
throw new Error("Archive Spell shard range disagrees with filename: " + name);
}
if (!Number.isSafeInteger(shard.rows) || shard.rows < 0
|| !/^[0-9a-f]{64}$/i.test(shard.sha256 ?? "") || shard.format !== "gzip") {
throw new Error("Archive Spell index has invalid shard metadata for " + name);
}
ranges.push({ lo, hi, name });
}
ranges.sort((a, b) => a.lo - b.lo || a.hi - b.hi);
for (let index = 1; index < ranges.length; index += 1) {
if (ranges[index].lo <= ranges[index - 1].hi) throw new Error("Archive Spell index has overlapping shard ranges");
}
return index;
}
function fieldTypesFromColumnInfo(columnInfo, source) {
const columns = columnInfo?.columns;
if (!columnInfo || typeof columnInfo !== "object" || !Array.isArray(columns)) {
throw new Error("Pinned BASE Spell.colinfo.json has no columns array");
}
if (columnInfo.table !== "Spell" || columnInfo.columnCount !== source.columns || columns.length !== source.columns
|| columnInfo.source?.winner !== "Data/patch-T.MPQ"
|| columnInfo.source?.sha256 !== source.sourceDbcSha256
|| columnInfo.header?.records !== source.rows) {
throw new Error("Pinned BASE Spell.colinfo.json does not describe all Spell fields");
}
const fieldTypes = Array(source.columns);
for (const column of columns) {
const index = Number(column?.index);
const type = column?.inferred;
if (!Number.isSafeInteger(index) || index < 0 || index >= source.columns || fieldTypes[index] !== undefined) {
throw new Error("Pinned BASE Spell.colinfo.json has an invalid or duplicate field index");
}
if (!["int", "float", "string", "unknown"].includes(type)) {
throw new Error("Pinned BASE Spell.colinfo.json has an unsupported inferred type at f" + index);
}
fieldTypes[index] = type;
}
if (fieldTypes.some((type) => type === undefined)) throw new Error("Pinned BASE Spell.colinfo.json is missing a field type");
return fieldTypes;
}
function parseShard(bytes, shard, source) {
if (sha256(bytes) !== shard.sha256.toLowerCase()) {
throw new Error("Cached Archive Spell shard SHA-256 mismatch: " + shard.file);
}
let text;
try {
text = gunzipSync(bytes).toString("utf8");
} catch (error) {
throw new Error("Archive Spell shard is not valid gzip data: " + shard.file, { cause: error });
}
const rows = [];
const ids = new Set();
for (const [lineIndex, line] of text.split("\n").entries()) {
if (line.length === 0) continue;
const row = parseJson(Buffer.from(line, "utf8"), shard.file + ":" + (lineIndex + 1));
if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("Archive Spell row is not an object in " + shard.file);
const id = Number(row.f0);
if (!Number.isSafeInteger(id) || id < shard.lo || id > shard.hi || ids.has(id)) {
throw new Error("Archive Spell row has an invalid or duplicate f0 in " + shard.file);
}
for (let field = 0; field < source.columns; field += 1) {
if (!Object.hasOwn(row, "f" + field)) throw new Error("Archive Spell row is missing f" + field + " in " + shard.file);
}
ids.add(id);
rows.push(row);
}
if (rows.length !== shard.rows) throw new Error("Archive Spell shard row count mismatch: " + shard.file);
rows.sort((a, b) => Number(a.f0) - Number(b.f0));
return rows;
}
function positiveSpellIds(input) {
if (!Array.isArray(input)) throw new TypeError("spellIds must be an array");
const ids = input.map((value) => Number(value));
if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
throw new TypeError("spellIds must contain positive safe integers");
}
return [...new Set(ids)].sort((a, b) => a - b);
}
function childrenFromRow(row) {
const children = [];
for (let effect = 0; effect < 3; effect += 1) {
const triggerId = Number(row["f" + (116 + effect)]);
if (Number.isSafeInteger(triggerId) && triggerId > 0) children.push(triggerId);
if (Number(row["f" + (71 + effect)]) === 168) {
const itemSpellId = Number(row["f" + (110 + effect)]);
if (Number.isSafeInteger(itemSpellId) && itemSpellId > 0) children.push(itemSpellId);
}
}
return children;
}
function descriptorForId(index, id) {
return index.shards.find((shard) => id >= shard.lo && id <= shard.hi) ?? null;
}
function validateManifest(manifest, source, index) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("Archive Spell cache manifest is not an object");
if (manifest.schemaVersion !== cacheSchemaVersion) throw new Error("Unsupported Archive Spell cache schema");
if (JSON.stringify(manifest.source) !== JSON.stringify(sourceRecord(source))) {
throw new Error("Archive Spell cache manifest points at an unexpected source or patch variant");
}
if (!Array.isArray(manifest.requestedIds) || !Array.isArray(manifest.files)) {
throw new Error("Archive Spell cache manifest is missing requestedIds or files");
}
const requestedIds = positiveSpellIds(manifest.requestedIds);
if (requestedIds.length !== manifest.requestedIds.length
|| requestedIds.some((id, position) => id !== manifest.requestedIds[position])) {
throw new Error("Archive Spell cache manifest requested ids must be sorted and unique");
}
const byFile = new Map(index.shards.map((shard) => [shard.file, shard]));
const names = new Set();
for (const file of manifest.files) {
const name = assertSafeRelativePath(file?.path, "Archive Spell manifest shard path");
const shard = byFile.get(name);
if (!shard || names.has(name) || file.sha256 !== shard.sha256
|| file.archivePath !== source.variantPath + "/" + name
|| file.lo !== shard.lo || file.hi !== shard.hi || file.rows !== shard.rows
|| !/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")
|| !Number.isSafeInteger(file.size) || file.size < 0) {
throw new Error("Archive Spell cache manifest contains an unknown or inconsistent shard: " + name);
}
names.add(name);
}
return { requestedIds, files: manifest.files };
}
export function createArchiveSpellCacheApi(sourceInput = ARCHIVE_SPELL_SOURCE) {
const source = validateSource({ ...sourceInput });
function archiveDirectory(cacheRoot) {
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) throw new TypeError("cacheRoot must be a non-empty directory path");
return path.join(path.resolve(cacheRoot), "archive-" + source.shortRevision, "spell-base");
}
function rawUrl(filePath) {
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
return "https://raw.githubusercontent.com/" + source.repository + "/" + source.revision + "/" + encodedPath;
}
async function responseBytes(fetchImpl, url) {
let response;
try {
response = await fetchImpl(url, { headers: { Accept: "application/json, application/gzip" } });
} catch (error) {
throw new Error("Archive Spell source unavailable at " + url + ": " + error.message, { cause: error });
}
if (!response || response.ok !== true) {
const status = response?.status === undefined ? "no response" : "HTTP " + response.status;
throw new Error("Archive Spell source unavailable at " + url + " (" + status + ")");
}
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
throw new Error("Archive Spell source response has no readable body at " + url);
}
async function writeAtomic(filePath, bytes) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = filePath + ".tmp-" + process.pid;
await fs.writeFile(tempPath, bytes);
await fs.rename(tempPath, filePath);
}
async function cachedPinnedBytes(filePath, shaPin, blobPin) {
try {
const bytes = await fs.readFile(filePath);
assertPinnedBytes(bytes, shaPin, blobPin, path.basename(filePath));
return bytes;
} catch (error) {
if (error?.code === "ENOENT") return null;
if (error?.code && error.code !== "ENOENT") throw error;
return null;
}
}
async function ensureMetadata(cacheRoot, fetchImpl) {
const root = archiveDirectory(cacheRoot);
const indexCachePath = path.join(root, "Spell.index.json");
const colinfoCachePath = path.join(root, "Spell.colinfo.json");
let [indexBytes, colinfoBytes] = await Promise.all([
cachedPinnedBytes(indexCachePath, source.tableIndexSha256, source.tableIndexGitBlobSha),
cachedPinnedBytes(colinfoCachePath, source.columnInfoSha256, source.columnInfoGitBlobSha),
]);
if (!indexBytes) {
indexBytes = await cachedPinnedBytes(
path.join(path.dirname(root), "remote-audit", "raw_tables_Spell_variants_data-patch-t-mpq_index.json"),
source.tableIndexSha256,
source.tableIndexGitBlobSha,
);
if (indexBytes) await writeAtomic(indexCachePath, indexBytes);
}
if (!colinfoBytes) {
colinfoBytes = await cachedPinnedBytes(
path.join(path.dirname(root), "remote-audit", "base_Spell.colinfo.json"),
source.columnInfoSha256,
source.columnInfoGitBlobSha,
);
if (colinfoBytes) await writeAtomic(colinfoCachePath, colinfoBytes);
}
if (!indexBytes) {
indexBytes = await responseBytes(fetchImpl, rawUrl(source.tableIndexPath));
assertPinnedBytes(indexBytes, source.tableIndexSha256, source.tableIndexGitBlobSha, "BASE Spell index");
await writeAtomic(indexCachePath, indexBytes);
}
if (!colinfoBytes) {
colinfoBytes = await responseBytes(fetchImpl, rawUrl(source.columnInfoPath));
assertPinnedBytes(colinfoBytes, source.columnInfoSha256, source.columnInfoGitBlobSha, "BASE Spell.colinfo.json");
await writeAtomic(colinfoCachePath, colinfoBytes);
}
const index = validateIndex(parseJson(indexBytes, source.tableIndexPath), source);
const columnInfo = parseJson(colinfoBytes, source.columnInfoPath);
const fieldTypes = fieldTypesFromColumnInfo(columnInfo, source);
return { root, index, fieldTypes };
}
async function readShard(root, shard, sourceInputForRows) {
const shardPath = path.join(root, "shards", shard.file);
const bytes = await fs.readFile(shardPath);
return { bytes, rows: parseShard(bytes, shard, sourceInputForRows) };
}
async function loadManifest(root, index) {
const manifestPath = path.join(root, manifestName);
let bytes;
try {
bytes = await fs.readFile(manifestPath);
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
return validateManifest(parseJson(bytes, manifestPath), source, index);
}
async function closeSpellIds({ index, requestedIds, root, fetchImpl, allowFetch }) {
const records = new Map();
const missing = new Set();
const selectedShards = new Map();
const loadedRows = new Map();
const pending = [...requestedIds];
const visited = new Set();
for (let cursor = 0; cursor < pending.length; cursor += 1) {
const id = pending[cursor];
if (visited.has(id)) continue;
visited.add(id);
const descriptor = descriptorForId(index, id);
if (!descriptor) {
missing.add(id);
continue;
}
selectedShards.set(descriptor.file, descriptor);
let rows = loadedRows.get(descriptor.file);
if (!rows) {
const localPath = path.join(root, "shards", descriptor.file);
let bytes = null;
let localBytesFound = false;
const candidatePaths = allowFetch
? [localPath, path.join(path.dirname(root), "remote-audit", "base-spell-shards", descriptor.file)]
: [localPath];
for (const candidatePath of candidatePaths) {
try {
const candidate = await fs.readFile(candidatePath);
if (candidatePath === localPath) localBytesFound = true;
if (sha256(candidate) !== descriptor.sha256.toLowerCase()) {
if (!allowFetch && candidatePath === localPath) {
throw new Error("Cached Archive Spell shard SHA-256 mismatch: " + descriptor.file);
}
continue;
}
bytes = candidate;
if (candidatePath !== localPath) await writeAtomic(localPath, bytes);
break;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
if (!bytes && allowFetch) {
bytes = await responseBytes(fetchImpl, rawUrl(source.variantPath + "/" + descriptor.file));
if (sha256(bytes) !== descriptor.sha256.toLowerCase()) {
throw new Error("BASE Archive Spell shard SHA-256 mismatch: " + descriptor.file);
}
await writeAtomic(localPath, bytes);
}
if (!bytes) {
const detail = localBytesFound ? " has invalid cached bytes" : " is missing";
throw new Error("Archive Spell cache shard " + descriptor.file + detail + "; run downloadArchiveSpells first");
}
rows = parseShard(bytes, descriptor, source);
loadedRows.set(descriptor.file, rows);
}
const row = rows.find((candidate) => Number(candidate.f0) === id);
if (!row) {
missing.add(id);
continue;
}
records.set(id, row);
for (const child of childrenFromRow(row)) if (!visited.has(child)) pending.push(child);
}
return {
records: [...records.values()].sort((a, b) => Number(a.f0) - Number(b.f0)),
missingIds: [...missing].sort((a, b) => a - b),
selectedShards,
};
}
function manifestFile(shard, bytes, source) {
return {
path: shard.file,
archivePath: source.variantPath + "/" + shard.file,
lo: shard.lo,
hi: shard.hi,
rows: shard.rows,
sha256: shard.sha256,
gitBlobSha: gitBlobSha(bytes),
size: bytes.byteLength,
};
}
async function readArchiveSpells({ cacheRoot }) {
const root = archiveDirectory(cacheRoot);
let indexBytes;
let colinfoBytes;
try {
[indexBytes, colinfoBytes] = await Promise.all([
fs.readFile(path.join(root, "Spell.index.json")),
fs.readFile(path.join(root, "Spell.colinfo.json")),
]);
} catch (error) {
if (error?.code === "ENOENT") throw new Error("Archive Spell cache metadata is missing; run downloadArchiveSpells first");
throw error;
}
assertPinnedBytes(indexBytes, source.tableIndexSha256, source.tableIndexGitBlobSha, "Cached BASE Spell index");
assertPinnedBytes(colinfoBytes, source.columnInfoSha256, source.columnInfoGitBlobSha, "Cached BASE Spell.colinfo.json");
const index = validateIndex(parseJson(indexBytes, source.tableIndexPath), source);
const fieldTypes = fieldTypesFromColumnInfo(parseJson(colinfoBytes, source.columnInfoPath), source);
const manifest = await loadManifest(root, index);
if (!manifest) throw new Error("Archive Spell cache manifest is missing; run downloadArchiveSpells first");
const closure = await closeSpellIds({ index, requestedIds: manifest.requestedIds, root, allowFetch: false });
const declared = new Map(manifest.files.map((file) => [file.path, file]));
if (declared.size !== closure.selectedShards.size
|| [...closure.selectedShards.keys()].some((name) => !declared.has(name))) {
throw new Error("Archive Spell cache manifest does not cover the complete selected spell closure");
}
const files = [];
for (const shard of [...closure.selectedShards.values()].sort((a, b) => a.lo - b.lo)) {
const { bytes } = await readShard(root, shard, source);
const file = manifestFile(shard, bytes, source);
const expected = declared.get(shard.file);
if (file.gitBlobSha !== expected.gitBlobSha || file.size !== expected.size) {
throw new Error("Archive Spell cached shard does not match its manifest: " + shard.file);
}
files.push(file);
}
return {
source: resultSource(source, files),
fieldTypes,
records: closure.records,
files,
missingIds: closure.missingIds,
};
}
async function downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl = globalThis.fetch }) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function");
const newIds = positiveSpellIds(spellIds);
const { root, index, fieldTypes } = await ensureMetadata(cacheRoot, fetchImpl);
const previous = await loadManifest(root, index);
const requestedIds = positiveSpellIds([...(previous?.requestedIds ?? []), ...newIds]);
const closure = await closeSpellIds({ index, requestedIds, root, fetchImpl, allowFetch: true });
const files = [];
for (const shard of [...closure.selectedShards.values()].sort((a, b) => a.lo - b.lo)) {
const { bytes } = await readShard(root, shard, source);
files.push(manifestFile(shard, bytes, source));
}
const manifest = {
schemaVersion: cacheSchemaVersion,
source: sourceRecord(source),
requestedIds,
files,
};
await writeAtomic(path.join(root, manifestName), Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"));
return {
source: resultSource(source, files),
fieldTypes,
records: closure.records,
files,
missingIds: closure.missingIds,
};
}
return Object.freeze({ downloadArchiveSpells, readArchiveSpells });
}
const defaultArchiveSpellApi = createArchiveSpellCacheApi();
export function downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl = globalThis.fetch }) {
return defaultArchiveSpellApi.downloadArchiveSpells({ cacheRoot, spellIds, fetchImpl });
}
export function readArchiveSpells({ cacheRoot }) {
return defaultArchiveSpellApi.readArchiveSpells({ cacheRoot });
}
@@ -0,0 +1,252 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gzipSync } from "node:zlib";
import { ARCHIVE_SPELL_SOURCE, createArchiveSpellCacheApi } from "./archive-spells.mjs";
const fixtureSourceSha = "b".repeat(64);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from("blob " + bytes.byteLength + "\0", "utf8"))
.update(bytes)
.digest("hex");
}
function spellRow(id, options = {}) {
const row = {};
for (let field = 0; field < 234; field += 1) row["f" + field] = 0;
row.f0 = id;
row.f41 = 1;
row.f47 = 1.5;
row.f71 = options.effectType ?? 0;
row.f110 = options.miscValue ?? 0;
row.f116 = options.triggerSpellId ?? 0;
row.f136 = options.name ?? "Test spell " + id;
row.f136i = id * 10;
row.f153 = "Passive";
row.f153i = id * 10 + 1;
row.f170 = "Description for " + id;
row.f170i = id * 10 + 2;
row.f187 = "Alternate " + id;
row.f187i = id * 10 + 3;
row.f229 = 0.25;
return row;
}
function makeFixture({ sourceDbcSha256 = fixtureSourceSha } = {}) {
const shardRows = [
{ lo: 1, hi: 99, rows: [spellRow(1, { triggerSpellId: 101 })] },
{ lo: 100, hi: 199, rows: [
spellRow(101, { effectType: 168, miscValue: 202 }),
spellRow(155),
] },
{ lo: 200, hi: 299, rows: [spellRow(202)] },
{ lo: 300, hi: 399, rows: [spellRow(350)] },
];
const shards = [];
const archiveFiles = new Map();
for (const group of shardRows) {
const name = String(group.lo).padStart(8, "0") + "-" + String(group.hi).padStart(8, "0") + ".jsonl.gz";
const bytes = gzipSync(Buffer.from(group.rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8"), { level: 9, mtime: 0 });
archiveFiles.set(name, bytes);
shards.push({
file: name,
format: "gzip",
lo: group.lo,
hi: group.hi,
plainBytes: 1,
rows: group.rows.length,
sha256: sha256(bytes),
storedBytes: bytes.byteLength,
});
}
const index = {
file: "Spell.dbc",
format: "gzip",
rows: 5,
columns: 234,
table: "Spell",
sourceSha256: sourceDbcSha256,
shardKey: { column: "f0", maxRows: 5000, mode: "column", unsigned: true },
shards,
};
const columnInfo = {
table: "Spell",
columnCount: 234,
header: { records: 5 },
source: { winner: "Data/patch-T.MPQ", sha256: sourceDbcSha256 },
columns: Array.from({ length: 234 }, (_, index) => ({
index,
inferred: index === 47 || index === 229 ? "float" : [136, 153, 170, 187].includes(index) ? "string" : "int",
})),
};
const indexBytes = Buffer.from(JSON.stringify(index), "utf8");
const colinfoBytes = Buffer.from(JSON.stringify(columnInfo), "utf8");
const variantPath = "raw/tables/Spell/variants/data-patch-t-mpq";
const source = {
id: "fixture-global-spell-base",
repository: "example/coa-datamine",
revision: "a".repeat(40),
shortRevision: "aaaaaaaaaaaa",
capturedAt: "2026-08-12T21:02:51+00:00",
variantPath,
tableIndexPath: variantPath + "/index.json",
tableIndexSha256: sha256(indexBytes),
tableIndexGitBlobSha: gitBlobSha(indexBytes),
columnInfoPath: variantPath + "/Spell.colinfo.json",
columnInfoSha256: sha256(colinfoBytes),
columnInfoGitBlobSha: gitBlobSha(colinfoBytes),
sourceDbcSha256,
rows: 5,
columns: 234,
};
const prefix = "https://raw.githubusercontent.com/" + source.repository + "/" + source.revision + "/";
const metadataFiles = new Map([
[prefix + source.tableIndexPath, indexBytes],
[prefix + source.columnInfoPath, colinfoBytes],
]);
for (const [name, bytes] of archiveFiles) metadataFiles.set(prefix + variantPath + "/" + name, bytes);
return { source, archiveFiles, files: metadataFiles, prefix, variantPath, indexBytes, colinfoBytes };
}
function fakeFetch(fileMap, calls) {
return async (url) => {
calls.push(url);
const bytes = fileMap.get(url);
if (!bytes) return { ok: false, status: 404 };
return {
ok: true,
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
};
};
}
async function withTempCache(run) {
const cacheRoot = await fs.mkdtemp(path.join(os.tmpdir(), "coa-archive-spells-"));
try {
await run(cacheRoot);
} finally {
await fs.rm(cacheRoot, { recursive: true, force: true });
}
}
test("pins and reads the global patch-T source and closes trigger and effect-168 references", async () => {
assert.equal(ARCHIVE_SPELL_SOURCE.variantPath, "raw/tables/Spell/variants/data-patch-t-mpq");
assert.equal(ARCHIVE_SPELL_SOURCE.sourceDbcSha256, "a1e4285cdbc8a269a1b8a3f4b304ee43eb4d1bfa5a98511639868c4ff6097623");
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const api = createArchiveSpellCacheApi(fixture.source);
const calls = [];
const fetchImpl = fakeFetch(fixture.files, calls);
const downloaded = await api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl });
assert.deepEqual(downloaded.records.map((row) => row.f0), [1, 101, 202]);
assert.deepEqual(downloaded.missingIds, []);
assert.deepEqual(downloaded.fieldTypes.slice(0, 3), ["int", "int", "int"]);
assert.equal(downloaded.fieldTypes[47], "float");
assert.equal(downloaded.fieldTypes[136], "string");
assert.equal(downloaded.source.path, fixture.variantPath);
assert.equal(downloaded.source.sourceDbcSha256, fixture.source.sourceDbcSha256);
assert.equal(downloaded.source.originalRecordCount, 5);
assert.equal(downloaded.source.bytes, downloaded.files.reduce((sum, file) => sum + file.size, 0));
assert.equal(downloaded.files.length, 3);
assert.ok(downloaded.files.every((file) => file.archivePath.startsWith(fixture.variantPath + "/")));
assert.equal(calls.filter((url) => url.includes("/variants/") && url.endsWith(".jsonl.gz")).length, 3);
assert.ok(!calls.some((url) => url.includes("/variants/") && url.includes("00000300-")));
const offline = await api.readArchiveSpells({ cacheRoot });
assert.deepEqual(offline.records, downloaded.records);
assert.deepEqual(offline.fieldTypes, downloaded.fieldTypes);
assert.deepEqual(offline.missingIds, []);
});
});
test("resumes selected shards, repairs tampered cached bytes, and reports absent ids", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const api = createArchiveSpellCacheApi(fixture.source);
const calls = [];
const fetchImpl = fakeFetch(fixture.files, calls);
await api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl });
const firstCallCount = calls.length;
const resumed = await api.downloadArchiveSpells({ cacheRoot, spellIds: [350, 999], fetchImpl });
assert.deepEqual(resumed.records.map((row) => row.f0), [1, 101, 202, 350]);
assert.deepEqual(resumed.missingIds, [999]);
assert.equal(calls.length, firstCallCount + 1);
const cachedShard = path.join(cacheRoot, "archive-" + fixture.source.shortRevision, "spell-base", "shards", "00000001-00000099.jsonl.gz");
await fs.writeFile(cachedShard, Buffer.from("tampered"));
await assert.rejects(api.readArchiveSpells({ cacheRoot }), /SHA-256 mismatch/);
const beforeRepair = calls.length;
const repaired = await api.downloadArchiveSpells({ cacheRoot, spellIds: [350], fetchImpl });
assert.deepEqual(repaired.records.map((row) => row.f0), [1, 101, 202, 350]);
assert.equal(calls.length, beforeRepair + 1);
const raw = await fs.readFile(cachedShard);
assert.equal(sha256(raw), fixture.archiveFiles.get("00000001-00000099.jsonl.gz") && sha256(fixture.archiveFiles.get("00000001-00000099.jsonl.gz")));
});
});
test("reuses pin-matching remote-audit BASE metadata and shards without a network fetch", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const archiveRoot = path.join(cacheRoot, "archive-" + fixture.source.shortRevision);
const remoteAudit = path.join(archiveRoot, "remote-audit");
const shardDirectory = path.join(remoteAudit, "base-spell-shards");
await fs.mkdir(shardDirectory, { recursive: true });
await fs.writeFile(path.join(remoteAudit, "raw_tables_Spell_variants_data-patch-t-mpq_index.json"), fixture.indexBytes);
await fs.writeFile(path.join(remoteAudit, "base_Spell.colinfo.json"), fixture.colinfoBytes);
for (const name of [
"00000001-00000099.jsonl.gz",
"00000100-00000199.jsonl.gz",
"00000200-00000299.jsonl.gz",
]) {
await fs.writeFile(path.join(shardDirectory, name), fixture.archiveFiles.get(name));
}
const calls = [];
const result = await createArchiveSpellCacheApi(fixture.source).downloadArchiveSpells({
cacheRoot,
spellIds: [1],
fetchImpl: async (url) => {
calls.push(url);
throw new Error("unexpected network request");
},
});
assert.equal(calls.length, 0);
assert.deepEqual(result.records.map((row) => row.f0), [1, 101, 202]);
assert.deepEqual(result.files.map((file) => file.path), [
"00000001-00000099.jsonl.gz",
"00000100-00000199.jsonl.gz",
"00000200-00000299.jsonl.gz",
]);
});
});
test("rejects an overlay index even when its metadata hashes match the supplied bytes", async () => {
await withTempCache(async (cacheRoot) => {
const fixture = makeFixture();
const overlayFixture = makeFixture({ sourceDbcSha256: "c".repeat(64) });
const wrongIndexUrl = fixture.prefix + fixture.source.tableIndexPath;
fixture.files.set(wrongIndexUrl, overlayFixture.files.get(wrongIndexUrl));
const overlayBytes = fixture.files.get(wrongIndexUrl);
fixture.source.tableIndexSha256 = sha256(overlayBytes);
fixture.source.tableIndexGitBlobSha = gitBlobSha(overlayBytes);
const api = createArchiveSpellCacheApi(fixture.source);
await assert.rejects(
api.downloadArchiveSpells({ cacheRoot, spellIds: [1], fetchImpl: fakeFetch(fixture.files, []) }),
/pinned BASE patch-T Spell\.dbc source/,
);
});
});
File diff suppressed because it is too large. Load diff
+207
View File
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs";
const gameIds = [
"barbarian", "witch-doctor", "felsworn", "witch-hunter", "stormbringer", "knight-of-xoroth",
"guardian", "templar", "bloodmage", "ranger", "chronomancer", "necromancer", "pyromancer",
"cultist", "starcaller", "sun-cleric", "tinker", "venomancer", "reaper", "primalist", "runemaster",
];
function sourceRow(id = "base-spell") {
return { id, path: "src/spells.dbc", sha256: "a".repeat(64), bytes: 100, kind: "dbc" };
}
function spell(id, triggerSpellId = 0, name = "Spell " + id) {
const dbc = {
id, name, rankText: "Rank 1", description: "A test spell.", castTimeMs: 0, procChance: 100,
effects: [{ index: 0, effectType: triggerSpellId ? 64 : 2, auraType: 0, basePoints: 1, triggerSpellId }],
};
return { id, name, rank: "Rank 1", description: dbc.description, dbc, source: "base", classIds: [12] };
}
function node(id, overrides = {}) {
return {
id, classId: 12, tabId: 1, x: 0, y: 0, name: "Node " + id,
nodeType: "SpendSquare", entryType: "Talent", isPassive: false, maxPoints: 1,
aeCost: 0, teCost: 1, requiredClassPoints: 0, requiredSpecPoints: 0,
requiredIds: [], connectedNodeIds: [], requiredLevel: 0, isStartingNode: false,
iconBasename: "test", flags: 0, group: 7, sortOrder: id, description: "A test mechanic.",
rankDescriptions: [{ rank: 1, spellId: id === 100 ? 200 : 201, description: "A test mechanic." }],
...overrides,
};
}
function referenceFixture() {
return {
schemaVersion: 1,
baseline: { kind: "local-july", capturedAt: "2026-07-01T00:00:00Z" },
classes: gameIds.map((gameId, index) => ({
classId: index + 12, gameId, className: gameId, tabs: index === 0
? [{ tabId: 1, tabName: "First", sortOrder: 1 }]
: [],
})),
nodes: [node(100), node(101, { group: 7 })],
spells: [spell(200, 201), spell(201)],
progressionChains: [{
id: "chain-a", classId: 12, name: "Test chain", iconBasename: "test",
talented: false, advancementType: "Ability", trainer: false,
ranks: [{ rank: 1, spellId: 200, level: 1 }],
}],
sources: [sourceRow()],
diagnostics: [],
abilities: [{
key: "alias-map", classId: "DemonHunter", name: "Alias fixture",
spellIds: [200], nodeIds: [], rankSpellIds: [201], live: null, evidence: "DBC class alias maps to CoA ID 14.",
}],
};
}
function writeJson(root, relative, value) {
const full = path.join(root, relative);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, JSON.stringify(value) + "\n");
}
function makeProjectRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-audit-"));
writeJson(root, "src/game/coaLiveData.generated.json", {
spells: [
{ id: 200, dbc: { ...spell(200).dbc, castTimeMs: 1500 } },
{ id: 201, dbc: spell(201).dbc },
{ id: 203, dbc: spell(203).dbc },
],
});
writeJson(root, "src/game/generated/coaAbilityRuntimeCatalog.json", {
schemaVersion: 1,
abilitiesByClass: {
barbarian: [{ id: "coa-tree-100", classId: "barbarian", talentEntryId: 100, dbcSpellId: 200 }],
},
resourcesByClass: { barbarian: { primary: "energy" } },
triggeredAbilitiesBySpellId: { "201": { id: "coa-trigger-201", classId: "barbarian", dbcSpellId: 201 } },
});
writeJson(root, "src/game/generated/talentRuntimeCatalog.json", {
schemaVersion: 1,
trees: [{ id: "coa-barbarian-1", classId: "barbarian" }],
nodes: [{ id: "coa-entry-100", index: 100, treeId: "coa-barbarian-1", system: "coa" }],
});
writeJson(root, "src/game/generated/coaTriggeredSpellCatalog.json", {
schemaVersion: 1,
spells: [spell(201).dbc],
});
return root;
}
test("validation resolves DBC class aliases and flags unresolved references as incomplete warnings", () => {
const reference = referenceFixture();
reference.nodes[0].requiredIds = [999];
reference.nodes[0].rankDescriptions[0].spellId = 999;
reference.spells[1].dbc.effects[0].triggerSpellId = 998;
const result = validateReference(reference);
assert.equal(result.valid, true);
assert.equal(result.complete, false);
assert.equal(result.counts.missingRankSpells, 1);
assert.equal(result.counts.missingPrerequisites, 1);
assert.equal(result.counts.missingTriggeredSpells, 1);
assert(result.warnings.some((item) => item.code === "missing-rank-spell" && item.spellId === 999));
assert(result.warnings.some((item) => item.code === "missing-prerequisite" && item.targetId === 999));
assert(result.warnings.some((item) => item.code === "unknown-triggered-spell" && item.spellId === 998));
assert(!result.errors.some((item) => item.code === "invalid-ability-class-id"));
});
test("validation reports malformed and duplicate keys, cross-class edges, and invalid choice groups as errors", () => {
const reference = referenceFixture();
reference.classes[1].tabs = [{ tabId: 2, tabName: "Second", sortOrder: 2 }];
reference.nodes[0].requiredIds = [300];
reference.nodes.push(node(300, { classId: 13, tabId: 2, group: 7 }));
reference.nodes.push(node(100));
reference.nodes[1].tabId = 2;
reference.nodes[1].requiredIds = [0];
reference.sources.push(sourceRow());
const result = validateReference(reference);
assert.equal(result.valid, false);
assert(result.errors.some((item) => item.code === "duplicate-node-key"));
assert(result.errors.some((item) => item.code === "cross-class-node-reference" && item.targetId === 300));
assert(result.errors.some((item) => item.code === "choice-group-cross-tab"));
assert(result.errors.some((item) => item.code === "invalid-node-reference-id"));
assert(result.errors.some((item) => item.code === "duplicate-source-key"));
});
test("audit compares July and August without merging, checks DBC fields, hashes inputs, and avoids implementation claims", () => {
const projectRoot = makeProjectRoot();
try {
const reference = referenceFixture();
reference.spells.push({ id: 202, name: "Placeholder", rank: "", description: "", source: "skill-line", classIds: [12] });
reference.sources[0].path = "src/game/coaLiveData.generated.json";
const supplements = {
source: { revision: "5078f9ac22541c45c17a866c23aac6c12073de23", capturedAt: "2026-08-12T21:02:51+00:00" },
classes: [{
classId: 12,
tabs: [{
tabId: 1, tabName: "First", sortOrder: 1, maxReqTabAE: 4, maxReqTabTE: 5,
nodes: [
{ ...node(100), name: "Changed name", reqTabAE: 0, reqTabTE: 1, isPassive: 0, isStartingNode: 0, spellId: 200, spellIds: [200], iconPath: "Interface/Icons/TEST.blp", rankDescriptions: [{ rank: 1, spellId: 200, description: "<span>A test mechanic.</span>" }] },
{ ...node(102), spellId: 202, spellIds: [202] },
],
}],
}],
manifest: { files: [{ path: "src/game/coaLiveData.generated.json", sha256: "b".repeat(64) }] },
metadata: { provenance: { source: "archive" }, clientSnapshot: { capturedAt: "2026-08-01T00:00:00Z" }, spells: { revision: "spell-set-1" } },
};
const report = auditReference({ reference, supplements, projectRoot });
const julyAugust = report.comparisons.julyToAugust;
assert.deepEqual(julyAugust.counts, {
treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0,
nodesAdded: 1, nodesRemoved: 1, nodesChanged: 1,
});
assert.deepEqual(julyAugust.changes.nodes[0].changedFields, ["name"]);
assert.equal(report.counts.nodes, 2);
assert.equal(report.classes.find((item) => item.classId === 12).counts.nodes, 2);
assert.equal(report.classes.find((item) => item.classId === 12).counts.spells, 3);
assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).runtimeAbilityMappings, 1);
assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).talentRuntimeNodes, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.baseRecords, 2);
assert.equal(report.comparisons.baseDbcToArea52.counts.baseMissingDbcRecords, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.area52Records, 3);
assert.equal(report.comparisons.baseDbcToArea52.counts.area52Only, 1);
assert.equal(report.comparisons.baseDbcToArea52.counts.changedRecords, 1);
assert.deepEqual(report.comparisons.baseDbcToArea52.changedFields, { castTimeMs: 1, effects: 1 });
assert.deepEqual(report.rankIdentityCoverage.nodeRanks, { missingSpellRecords: 0, missingBaseDbcRows: 0 });
assert.equal(report.supplementalFreshness.sourceHashes.changed, 1);
assert.equal(report.runtimeEvidence.behavioralMechanics.status, "not-assessed");
assert.deepEqual(report.runtimeEvidence.behavioralMechanics.provenImplementedClaims, []);
assert(report.classes.every((item) => item.backlog.some((backlog) => backlog.priority === "player-companion-mechanics")));
const markdown = renderAuditMarkdown(report);
assert(markdown.includes("1 reference spell records do not include a base DBC row"));
assert(markdown.includes("player or companion"));
assert(markdown.includes("No August supplemental cache") === false);
assert(markdown.indexOf("Changed spell samples:\n\n-") >= 0);
assert(markdown.indexOf("July/August tree and node samples:\n\n-") >= 0);
assert(!markdown.includes("implemented: yes"));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
test("missing supplements are explicitly reported and preserve baseline data", () => {
const projectRoot = makeProjectRoot();
try {
const reference = referenceFixture();
const report = auditReference({ reference, supplements: null, projectRoot });
assert.equal(report.comparisons.julyToAugust.status, "absent");
assert.equal(report.supplementalCache.status, "absent");
assert.deepEqual(report.comparisons.julyToAugust.counts, {
treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0, nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0,
});
assert.equal(renderAuditMarkdown(report).includes("No supplemental cache was supplied"), true);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { gzipSync, gunzipSync } from "node:zlib";
export const julyBaselinePath = "data/coa-reference/baselines/coa-july-2026-07-25.json.gz";
export const activeCatalogPath = "src/game/coaLiveData.generated.json";
export function resolveBaselinePath(projectRoot) {
return fs.existsSync(path.join(projectRoot, julyBaselinePath)) ? julyBaselinePath : activeCatalogPath;
}
export function decodeJsonBytes(bytes, filePath) {
return filePath.endsWith(".gz") ? gunzipSync(bytes) : bytes;
}
/** Preserve the original capture once; never replace it with a promoted catalog. */
export function freezeJulyBaseline(projectRoot) {
const destination = path.join(projectRoot, julyBaselinePath);
if (fs.existsSync(destination)) return destination;
const bytes = fs.readFileSync(path.join(projectRoot, activeCatalogPath));
const capture = JSON.parse(bytes.toString("utf8"));
if (capture.source?.synchronizedAt !== "2026-07-25T05:24:40.596Z") {
throw new Error("Cannot freeze July baseline: active catalog is not the original July capture");
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, gzipSync(bytes, { level: 9 }), { flag: "wx" });
return destination;
}
+41
View File
@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { activeCatalogPath, decodeJsonBytes, freezeJulyBaseline, julyBaselinePath, resolveBaselinePath } from "./baseline.mjs";
test("frozen July capture remains unchanged after active promotion", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-baseline-"));
try {
const active = path.join(root, activeCatalogPath);
fs.mkdirSync(path.dirname(active), { recursive: true });
const original = Buffer.from(JSON.stringify({ source: { synchronizedAt: "2026-07-25T05:24:40.596Z" }, entries: [{ id: 1 }] }));
fs.writeFileSync(active, original);
assert.equal(resolveBaselinePath(root), activeCatalogPath);
const frozen = freezeJulyBaseline(root);
const bytes = fs.readFileSync(frozen);
assert.deepEqual(decodeJsonBytes(bytes, frozen), original);
fs.writeFileSync(active, '{"source":{"synchronizedAt":"2026-08-12"}}');
assert.equal(resolveBaselinePath(root), julyBaselinePath);
assert.equal(freezeJulyBaseline(root), frozen);
assert.deepEqual(fs.readFileSync(frozen), bytes);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("freezing rejects a later capture rather than mislabelling it July", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-baseline-"));
try {
const active = path.join(root, activeCatalogPath);
fs.mkdirSync(path.dirname(active), { recursive: true });
fs.writeFileSync(active, '{"source":{"synchronizedAt":"2026-08-12"}}');
assert.throws(() => freezeJulyBaseline(root), /not the original July/);
assert.equal(fs.existsSync(path.join(root, julyBaselinePath)), false);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
+150
View File
@@ -0,0 +1,150 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs";
import { downloadSupplements, readSupplements } from "./supplements.mjs";
import { lookupReference, publishSnapshot, readSnapshot } from "./snapshot.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const usage = `CoA offline reference
build Build and atomically publish a local-first reference and audit
fetch Explicitly cache the pinned August archive talent supplement
fetch-spells Cache missing global spell records and their linked payloads
audit Verify the current snapshot and repeat offline coverage checks
lookup Search the current snapshot by --class, --tree, --spell, --ability
inventory Inspect the resolved local source pair without changing files
promote Promote the verified August Vol'Jin cache into the active CoA catalog
Options:
--client-root PATH Existing Ascension client directory
--extracted-root PATH Existing LadiksMPQEditor directory (supply both roots)
--output-root PATH Default: data/coa-reference
--cache-root PATH Default: data/coa-reference/cache
--limit NUMBER Lookup results per collection, default 20
--refresh Fetch command: revalidate the pinned supplement
--help Show this help
Build, audit, lookup, inventory and promote never make network requests.
Only promote writes the active gameplay catalog. Original client files stay intact.
`;
export function parseArguments(args) {
const hasCommand = Boolean(args[0] && !args[0].startsWith("--"));
const result = { command: hasCommand ? args[0] : "help" };
const valueFlags = new Set(["client-root", "extracted-root", "output-root", "cache-root", "class", "tree", "spell", "ability", "limit"]);
for (let index = hasCommand ? 1 : 0; index < args.length; index += 1) {
const flag = args[index];
if (flag === "--help") result.command = "help";
else if (flag === "--refresh") result.refresh = true;
else if (flag.startsWith("--") && valueFlags.has(flag.slice(2))) {
if (!args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`Missing value for ${flag}`);
result[flag.slice(2)] = args[++index];
} else throw new Error(`Unknown argument: ${flag}`);
}
if (!["help", "build", "fetch", "fetch-spells", "audit", "lookup", "inventory", "promote"].includes(result.command)) {
throw new Error(`Unknown command: ${result.command}`);
}
if (Boolean(result["client-root"]) !== Boolean(result["extracted-root"])) {
throw new Error("Supply both --client-root and --extracted-root to avoid mixing source installations");
}
if (result.refresh && result.command !== "fetch") throw new Error("--refresh is only supported by fetch");
return result;
}
/** Return unresolved spell roots owned by CoA plus explicit August rank references. */
export function unresolvedCoaArchiveSpellIds(reference, extraNodes = []) {
const spellById = new Map((reference?.spells ?? []).map((spell) => [Number(spell.id), spell]));
const required = new Set();
const add = (value) => {
const id = Number(value);
if (Number.isSafeInteger(id) && id > 0) required.add(id);
};
for (const node of extraNodes) {
for (const rank of node?.rankDescriptions ?? []) add(rank?.spellId);
}
for (const chain of reference?.progressionChains ?? []) {
for (const rank of chain?.ranks ?? []) add(rank?.spellId);
}
for (const spell of reference?.spells ?? []) {
if ((spell?.classIds ?? []).some((classId) => Number(classId) > 11)) add(spell.id);
}
return [...required]
.filter((id) => !spellById.get(id)?.dbc)
.sort((a, b) => a - b);
}
export async function main(args = process.argv.slice(2)) {
const options = parseArguments(args);
if (options.command === "help") {
process.stdout.write(usage);
return;
}
const outputRoot = path.resolve(options["output-root"] ?? path.join(projectRoot, "data/coa-reference"));
const cacheRoot = path.resolve(options["cache-root"] ?? path.join(outputRoot, "cache"));
const sourceOptions = {
projectRoot, clientRoot: options["client-root"], extractedRoot: options["extracted-root"],
};
if (options.command === "inventory") {
console.log(JSON.stringify(resolveSourceRoots(sourceOptions), null, 2));
return;
}
if (options.command === "fetch") {
console.log(JSON.stringify(await downloadSupplements({ cacheRoot, refresh: options.refresh ?? false }), null, 2));
return;
}
if (options.command === "promote") {
const { promoteCachedCoa } = await import("./promotion-command.mjs");
console.log(JSON.stringify(await promoteCachedCoa({ ...sourceOptions, cacheRoot }), null, 2));
return;
}
if (options.command === "fetch-spells") {
const { downloadArchiveSpells } = await import("./archive-spells.mjs");
const { normalizeArchiveNode } = await import("./promote.mjs");
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified talent cache is required; run coa:reference:fetch first");
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const reference = buildLocalReference({ ...sourceOptions, extraNodes });
const result = await downloadArchiveSpells({ cacheRoot, spellIds: unresolvedCoaArchiveSpellIds(reference, extraNodes) });
console.log(JSON.stringify({ source: result.source, records: result.records.length, files: result.files.length, missingIds: result.missingIds }, null, 2));
return;
}
if (options.command === "build") {
const supplements = await readSupplements({ cacheRoot });
const reference = await buildLocalReference(sourceOptions);
reference.supplementSource = supplements?.source ?? null;
const report = auditReference({ reference, supplements, projectRoot });
const validation = report.validation ?? validateReference(reference);
if (validation.errors?.length) {
throw new Error(`Reference validation failed; previous snapshot retained:\n${validation.errors.map((item) => item.message).join("\n")}`);
}
const result = publishSnapshot({ outputRoot, reference, report, markdown: renderAuditMarkdown(report) });
console.log(JSON.stringify({
snapshotId: result.snapshotId, report: path.join(result.directory, "audit.md"),
classes: reference.classes.length, nodes: reference.nodes.length, spells: reference.spells.length,
sourceFiles: reference.sources.length, supplement: reference.supplementSource,
validationErrors: validation.errors?.length ?? 0, validationWarnings: validation.warnings?.length ?? 0,
}, null, 2));
return;
}
const snapshot = readSnapshot(outputRoot);
if (options.command === "audit") {
const validation = validateReference(snapshot.reference);
console.log(JSON.stringify({
snapshotId: snapshot.manifest.snapshotId, report: path.join(snapshot.directory, "audit.md"),
sourceFiles: snapshot.reference.sources.length,
errors: validation.errors ?? [], warningCount: validation.warnings?.length ?? 0,
warningSample: (validation.warnings ?? []).slice(0, 10),
}, null, 2));
if (validation.errors?.length) throw new Error("Reference coverage validation failed");
return;
}
console.log(JSON.stringify(lookupReference(snapshot.reference, options), null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(`CoA reference: ${error.message}`);
process.exitCode = 1;
});
}
+58
View File
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import { parseArguments, unresolvedCoaArchiveSpellIds } from "./cli.mjs";
import { publishSnapshot } from "./snapshot.mjs";
test("CLI rejects incomplete source pairs, implicit refresh and unknown options", () => {
assert.throws(() => parseArguments(["build", "--client-root", "client"]), /Supply both/);
assert.throws(() => parseArguments(["build", "--refresh"]), /only supported by fetch/);
assert.throws(() => parseArguments(["build", "--typo"]), /Unknown argument/);
assert.throws(() => parseArguments(["lookup", "--class"]), /Missing value/);
assert.equal(parseArguments(["fetch", "--refresh"]).refresh, true);
assert.equal(parseArguments(["help"]).command, "help");
assert.equal(parseArguments(["--help"]).command, "help");
});
test("fetch-spells selects unresolved CoA-owned API roots and August dependencies", () => {
const reference = {
progressionChains: [{ classId: 27, ranks: [{ spellId: 300, rank: 1 }, { spellId: 301, rank: 2 }] }],
spells: [
{ id: 630868, classIds: [27], name: "July API root without global DBC" },
{ id: 630869, classIds: [27], name: "Owned trigger payload without global DBC" },
{ id: 100, classIds: [1], name: "Classic-only unresolved spell" },
{ id: 200, classIds: [27], dbc: { id: 200 }, name: "Already resolved CoA spell" },
{ id: 300, classIds: [27], dbc: { id: 300 }, name: "Resolved August rank" },
],
};
const extraNodes = [{ rankDescriptions: [{ spellId: 400, rank: 1 }, { spellId: 401, rank: 2 }] }];
assert.deepEqual(unresolvedCoaArchiveSpellIds(reference, extraNodes), [301, 400, 401, 630868, 630869]);
});
test("CLI lookup uses only the verified local snapshot when fetch is disabled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-cli-"));
try {
const reference = {
baseline: { kind: "local-july" }, sources: [],
classes: [{ classId: 12, gameId: "barbarian", className: "Barbarian", tabs: [{ tabId: 31, tabName: "Brutality" }] }],
nodes: [{ id: 1, classId: 12, tabId: 31, name: "Strike", rankDescriptions: [{ spellId: 100, rank: 1 }] }],
spells: [{ id: 100, name: "Strike" }], abilities: [],
};
publishSnapshot({ outputRoot: root, reference, report: {}, markdown: "# Audit" });
const blocker = path.join(root, "disable-network.mjs");
fs.writeFileSync(blocker, 'globalThis.fetch = () => { throw new Error("Network request during offline command"); };\n');
const result = spawnSync(process.execPath, ["--import", pathToFileURL(blocker).href, path.join(import.meta.dirname, "cli.mjs"),
"lookup", "--output-root", root, "--class", "barbarian", "--spell", "100"], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).spells[0].id, 100);
assert.equal(JSON.parse(result.stdout).baseline.kind, "local-july");
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
+142
View File
@@ -0,0 +1,142 @@
const FLIGHT_PUSH_MARKER = "self.__next_f.push";
function fail(message, offset) {
const suffix = Number.isInteger(offset) ? ` at offset ${offset}` : "";
throw new Error(`Could not decode pinned builder Flight text${suffix}: ${message}`);
}
function parsePushArgument(html, openParen) {
let start = openParen + 1;
while (/\s/.test(html[start] ?? "")) start += 1;
if (html[start] !== "[") fail("push argument is not a JSON array", openParen);
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = start; index < html.length; index += 1) {
const character = html[index];
if (quoted) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') quoted = false;
continue;
}
if (character === '"') quoted = true;
else if (character === "[") depth += 1;
else if (character === "]") {
depth -= 1;
if (depth === 0) {
const json = html.slice(start, index + 1);
let value;
try {
value = JSON.parse(json);
} catch (error) {
fail(`push argument is invalid JSON (${error.message})`, openParen);
}
return { value, end: index + 1 };
}
}
}
fail("push argument array is unterminated", openParen);
}
function collectTextChunks(builderHtml) {
if (typeof builderHtml !== "string") throw new TypeError("builderHtml must be a string");
const chunks = [];
let cursor = 0;
while (true) {
const markerAt = builderHtml.indexOf(FLIGHT_PUSH_MARKER, cursor);
if (markerAt < 0) break;
const openParen = builderHtml.indexOf("(", markerAt + FLIGHT_PUSH_MARKER.length);
if (openParen < 0) fail("push call has no opening parenthesis", markerAt);
const parsed = parsePushArgument(builderHtml, openParen);
if (!Array.isArray(parsed.value)) fail("push argument did not decode to an array", markerAt);
if (Number(parsed.value[0]) === 1 && typeof parsed.value[1] === "string") chunks.push(parsed.value[1]);
cursor = parsed.end;
}
if (chunks.length === 0) fail("no type-1 text chunks were found");
return chunks;
}
function collectTextRecords(builderHtml) {
const text = collectTextChunks(builderHtml).join("");
const bytes = Buffer.from(text, "utf8");
const records = new Map();
let offset = 0;
while (offset < bytes.length) {
while (bytes[offset] === 0x0a || bytes[offset] === 0x0d) offset += 1;
if (offset >= bytes.length) break;
const comma = bytes.indexOf(0x2c, offset);
const newline = bytes.indexOf(0x0a, offset);
if (comma >= 0 && (newline < 0 || comma < newline)) {
const header = bytes.toString("ascii", offset, comma);
const match = header.match(/^([0-9a-f]+):T([0-9a-f]+)$/i);
if (match) {
const id = match[1].toLowerCase();
const byteLength = Number.parseInt(match[2], 16);
const contentStart = comma + 1;
const contentEnd = contentStart + byteLength;
if (!Number.isSafeInteger(byteLength) || contentEnd > bytes.length) {
fail(`text row ${id} declares ${match[2]} bytes but is truncated`, offset);
}
const value = bytes.toString("utf8", contentStart, contentEnd);
if (records.has(id) && records.get(id) !== value) fail(`text row ${id} is defined more than once with different contents`, offset);
records.set(id, value);
offset = contentEnd;
if (bytes[offset] === 0x0d) offset += 1;
if (bytes[offset] === 0x0a) offset += 1;
continue;
}
}
const lineEnd = bytes.indexOf(0x0a, offset);
if (lineEnd < 0) break;
offset = lineEnd + 1;
}
if (records.size === 0) fail("no length-prefixed text rows were found");
return records;
}
function resolveDescription(value, records, location) {
if (typeof value !== "string" || value.startsWith("$$")) return value;
const match = value.match(/^\$([0-9a-f]+)$/i);
if (!match) return value;
const record = records.get(match[1].toLowerCase());
if (record === undefined) throw new Error(`Pinned builder description reference ${value} at ${location} has no Flight text row`);
return record;
}
function resolveNode(node, records, location) {
if (!node || typeof node !== "object" || Array.isArray(node)) return node;
if (Object.hasOwn(node, "description")) node.description = resolveDescription(node.description, records, `${location}.description`);
if (Array.isArray(node.rankDescriptions)) {
node.rankDescriptions.forEach((rank, index) => {
if (rank && typeof rank === "object" && !Array.isArray(rank) && Object.hasOwn(rank, "description")) {
rank.description = resolveDescription(rank.description, records, `${location}.rankDescriptions[${index}].description`);
}
});
}
return node;
}
function resolveNodeCollections(owner, records, location) {
if (!owner || typeof owner !== "object" || Array.isArray(owner)) return;
for (const key of ["nodes", "entries", "talents"]) {
if (Array.isArray(owner[key])) {
owner[key].forEach((node, index) => resolveNode(node, records, `${location}.${key}[${index}]`));
}
}
if (Array.isArray(owner.tabs)) {
owner.tabs.forEach((tab, index) => resolveNodeCollections(tab, records, `${location}.tabs[${index}]`));
}
}
/** Resolve exact React Flight string references in archived CoA node descriptions. */
export function resolveArchiveDescriptions(classes, builderHtml) {
if (!Array.isArray(classes)) throw new TypeError("classes must be an array");
const records = collectTextRecords(builderHtml);
const resolved = structuredClone(classes);
resolved.forEach((classRecord, index) => resolveNodeCollections(classRecord, records, `classes[${index}]`));
return resolved;
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveArchiveDescriptions } from "./flight-text.mjs";
function textRow(id, text) {
return `${id}:T${Buffer.byteLength(text, "utf8").toString(16)},${text}`;
}
function flightPush(value) {
return `<script>self.__next_f.push(${JSON.stringify(value)});</script>`;
}
function builderHtml(...chunks) {
return [flightPush([0]), ...chunks.map((chunk) => flightPush([1, chunk]))].join("");
}
test("resolves exact Flight text references across split chunks using UTF-8 byte lengths", () => {
const firstText = "Mycelial ring: café 🌱\nsecond line";
const secondText = "Spirit stable — restored";
const stream = `${textRow("17f", firstText)}${textRow("180", secondText)}`;
const splitA = stream.slice(0, 8);
const splitB = stream.slice(8, 36);
const splitC = stream.slice(36);
const classes = [{
classId: 12,
untouched: { note: "unchanged" },
nodes: [
{
id: 7221,
description: "$17F",
rankDescriptions: [{ rank: 1, spellId: 1, description: "$180" }],
spellId: 1,
},
{
id: 7222,
description: "$$180",
rankDescriptions: [{ rank: 1, spellId: 2, description: "Literal $d placeholder" }],
},
],
}];
const resolved = resolveArchiveDescriptions(classes, builderHtml(splitA, splitB, splitC));
assert.notEqual(resolved, classes);
assert.notEqual(resolved[0], classes[0]);
assert.equal(resolved[0].nodes[0].description, firstText);
assert.equal(resolved[0].nodes[0].rankDescriptions[0].description, secondText);
assert.equal(resolved[0].nodes[0].spellId, 1);
assert.deepEqual(resolved[0].untouched, classes[0].untouched);
assert.equal(resolved[0].nodes[1].description, "$$180");
assert.equal(resolved[0].nodes[1].rankDescriptions[0].description, "Literal $d placeholder");
assert.equal(classes[0].nodes[0].description, "$17F");
assert.equal(classes[0].nodes[0].rankDescriptions[0].description, "$180");
});
test("resolves node descriptions inside nested tab collections and preserves escaped dollar text", () => {
const stream = textRow("abc", "\u003cstrong\u003eArchive text\u003c/strong\u003e");
const classes = [{
classId: 13,
tabs: [{ tabId: 1, nodes: [{ id: 1, description: "$abc", rankDescriptions: [] }] }],
}];
const resolved = resolveArchiveDescriptions(classes, builderHtml(stream));
assert.equal(resolved[0].tabs[0].nodes[0].description, "<strong>Archive text</strong>");
});
test("rejects missing exact references with their source field", () => {
const classes = [{ classId: 12, nodes: [{ id: 7221, description: "$dead", rankDescriptions: [] }] }];
assert.throws(
() => resolveArchiveDescriptions(classes, builderHtml(textRow("1", "one"))),
/description reference \$dead at classes\[0\]\.nodes\[0\]\.description has no Flight text row/,
);
});
test("rejects truncated UTF-8 text records and malformed Flight push JSON", () => {
const classes = [{ classId: 12, nodes: [] }];
assert.throws(() => resolveArchiveDescriptions(classes, builderHtml("1:T20,short")), /text row 1 declares 20 bytes but is truncated/);
assert.throws(() => resolveArchiveDescriptions(classes, '<script>self.__next_f.push([1,"unterminated]);</script>'), /push argument array is unterminated/);
});
File diff suppressed because it is too large. Load diff
+353
View File
@@ -0,0 +1,353 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
import { freezeJulyBaseline } from "./baseline.mjs";
function makeDbc(records, fieldCount) {
const stringParts = [Buffer.from([0])];
let stringSize = 1;
const rows = records.map((record) => {
const fields = Array.from({ length: fieldCount }, (_, index) => Number(record.fields?.[index] ?? 0));
for (const [fieldIndex, value] of Object.entries(record.strings ?? {})) {
const encoded = Buffer.from(`${value}\0`, "utf8");
fields[Number(fieldIndex)] = stringSize;
stringParts.push(encoded);
stringSize += encoded.length;
}
const row = Buffer.alloc(fieldCount * 4);
for (let index = 0; index < fieldCount; index += 1) {
const value = fields[index];
if (record.floatFields?.includes(index)) row.writeFloatLE(value, index * 4);
else row.writeUInt32LE(value >>> 0, index * 4);
}
return row;
});
const recordsBuffer = Buffer.concat(rows);
const stringsBuffer = Buffer.concat(stringParts);
const header = Buffer.alloc(20);
header.write("WDBC", 0, "ascii");
header.writeUInt32LE(records.length, 4);
header.writeUInt32LE(fieldCount, 8);
header.writeUInt32LE(fieldCount * 4, 12);
header.writeUInt32LE(stringsBuffer.length, 16);
return Buffer.concat([header, recordsBuffer, stringsBuffer]);
}
function spell(id, name, { rank = "Rank 1", description = `${name} description`, trigger = 0, castTimeIndex = 0, durationIndex = 0 } = {}) {
const fields = Array(234).fill(0);
fields[0] = id;
fields[28] = castTimeIndex;
fields[40] = durationIndex;
fields[71] = trigger ? 64 : 0;
fields[116] = trigger;
fields[205] = 133;
fields[206] = 1500;
fields[136] = 0;
fields[153] = 0;
fields[170] = 0;
return { fields, strings: { 136: name, 153: rank, 170: description } };
}
function archiveSpellRow(id, name, { triggerSpellId = 0, effectType = 0, basePoints = 0 } = {}) {
const row = {};
for (let index = 0; index < 234; index += 1) row[`f${index}`] = 0;
row.f0 = id;
row.f71 = triggerSpellId ? 64 : effectType;
row.f80 = basePoints;
row.f86 = effectType ? 6 : 0;
row.f116 = triggerSpellId;
row.f136 = name;
row.f136i = 1;
row.f153 = "Rank 1";
row.f153i = 2;
row.f170 = `August global description for ${id}`;
row.f170i = 3;
row.f187 = "";
row.f187i = 0;
return row;
}
function writeFile(root, relativePath, value) {
const target = path.join(root, ...relativePath.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, value);
return target;
}
function createFixture({ includeCastTimes = true } = {}) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-local-source-"));
const projectRoot = path.join(tempRoot, "HealerMan");
const clientRoot = path.join(tempRoot, "resources", "client");
const extractedRoot = path.join(tempRoot, "LadiksMPQEditor");
const dbcRoot = "client-current";
const classDefinitions = [
{ classId: 12, className: "Barbarian", tabs: [] },
{ classId: 14, className: "Felsworn", tabs: [] },
{ classId: 19, className: "Templar", tabs: [] },
{ classId: 20, className: "Bloodmage", tabs: [] },
];
const snapshot = {
source: { synchronizedAt: "2026-07-25T05:24:40.596Z" },
classes: classDefinitions,
entries: [{
id: 500,
classId: 14,
tabId: 1,
x: 0,
y: 0,
name: "Alias Ability",
rankDescriptions: [{ rank: 1, spellId: 1002, description: "Baseline rank description" }],
}],
progressionChains: [{
id: "coa-14-alias-1002",
classId: 14,
name: "Alias Ability",
ranks: [{ spellId: 1002, rank: 1, level: 10 }, { spellId: 1003, rank: 2, level: 20 }],
}],
};
writeFile(projectRoot, "src/game/coaLiveData.generated.json", `${JSON.stringify(snapshot)}\n`);
const advancementRows = [
{ ID: 500, Class: "DemonHunter", Name: "Alias Ability", Spells: [1002], Realms: "123" },
{ ID: 501, Class: "Monk", Name: "Templar Client Ability", Spells: [3001], Realms: "456" },
{ ID: 502, Class: "SonOfArugal", Name: "Bloodmage Client Ability", Spells: [3002], Realms: "789" },
{ ID: 503, Class: "Felsworn", Name: "Overlay Only", Spells: [4000], Realms: "101" },
];
writeFile(clientRoot, "Data/Content/CharacterAdvancementData.json", `${JSON.stringify(advancementRows)}\n`);
const spellRankRows = [
{ firstSpellId: 1002, spellId: 1002, rank: 1, level: 10 },
{ firstSpellId: 1002, spellId: 1003, rank: 2, level: 20 },
{ firstSpellId: 1002, spellId: 1004, rank: 2, level: 20 },
{ firstSpellId: 1002, spellId: 1005, rank: 2, level: 20 },
{ firstSpellId: 3001, spellId: 3001, rank: 1, level: 10 },
{ firstSpellId: 3002, spellId: 3002, rank: 1, level: 10 },
];
writeFile(clientRoot, "Data/Content/SpellRankData.json", `${JSON.stringify(spellRankRows)}\n`);
const clientClasses = [
[12, "Barbarian"], [14, "Felsworn"], [19, "Templar"], [20, "Bloodmage"],
].map(([id, name]) => ({ fields: [id], strings: { 4: name } }));
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrClasses.dbc`, makeDbc(clientClasses, 60));
const specs = clientClasses.map((row, index) => ({
fields: [index + 1],
strings: { 26: row.strings[4] },
}));
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrSpecs.dbc`, makeDbc(specs, 65));
const skillRows = [
[1002, 14], [1003, 14], [1004, 13], [1005, 14], [3001, 19], [3002, 20],
].map(([spellId, classId], index) => {
const fields = Array(14).fill(0);
fields[0] = index + 1;
fields[1] = 100 + classId;
fields[2] = spellId;
fields[5] = 1 << (classId - 1);
return { fields };
});
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/SkillLineAbility.dbc`, makeDbc(skillRows, 14));
const baseSpells = [
spell(1002, "BASE Alias", { trigger: 2002, castTimeIndex: 1, durationIndex: 1 }),
spell(1003, "BASE Alias", { rank: "Rank 2", castTimeIndex: 2 }),
spell(1004, "Foreign Class Rank", { rank: "Rank 2" }),
spell(1005, "Second Same-Class Rank", { rank: "Rank 2" }),
spell(2002, "BASE Trigger", { rank: "" }),
spell(3001, "Templar Spell"),
spell(3002, "Bloodmage Spell"),
];
const baseSpellPath = writeFile(extractedRoot, `${dbcRoot}/patch-T/DBFilesClient/Spell.dbc`, makeDbc(baseSpells, 234));
const overlaySpells = [spell(1002, "OVERLAY Alias"), spell(4000, "OVERLAY Only")];
const overlaySpellPath = writeFile(extractedRoot, `${dbcRoot}/area-52/patch-D/DBFilesClient/Spell.dbc`, makeDbc(overlaySpells, 234));
if (includeCastTimes) {
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellCastTimes.dbc`, makeDbc([{ fields: [1, 125] }, { fields: [2, -1] }], 2));
}
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellDuration.dbc`, makeDbc([{ fields: [1, 0, 0, -1] }], 4));
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRadius.dbc`, makeDbc([{ fields: [1, 0, 0, 3.5], floatFields: [3] }], 4));
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRange.dbc`, makeDbc([{ fields: [1, 0, 0, 0, 40], floatFields: [1, 2, 3, 4] }], 5));
writeFile(clientRoot, "Data/area-52/listarchive", "patch-D.MPQ\n");
writeFile(extractedRoot, `${dbcRoot}/README.md`, "area-52/patch-D/DBFilesClient provides the realm override. Global patch-T Spell.dbc is retained as a fallback only.\n");
return {
tempRoot,
projectRoot,
clientRoot,
extractedRoot,
baseSpellPath,
overlaySpellPath,
options: { projectRoot, clientRoot, extractedRoot },
};
}
function cleanup(fixture) {
fs.rmSync(fixture.tempRoot, { recursive: true, force: true });
}
test("resolveSourceRoots keeps the client and extraction roots paired", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-roots-"));
try {
const projectRoot = path.join(tempRoot, "HealerMan");
const siblingClient = path.join(tempRoot, "resources", "client");
const siblingExtracts = path.join(tempRoot, "LadiksMPQEditor");
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(siblingClient, { recursive: true });
fs.mkdirSync(siblingExtracts, { recursive: true });
const resolved = resolveSourceRoots({ projectRoot });
assert.equal(resolved.rootSet, "F");
assert.equal(resolved.clientRoot, siblingClient);
assert.equal(resolved.extractedRoot, siblingExtracts);
const supplied = resolveSourceRoots({ projectRoot, clientRoot: siblingClient, extractedRoot: siblingExtracts });
assert.equal(supplied.rootSet, "supplied");
assert.equal(supplied.clientRoot, siblingClient);
assert.equal(supplied.extractedRoot, siblingExtracts);
assert.throws(() => resolveSourceRoots({ projectRoot, clientRoot: siblingClient }), /together/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("buildLocalReference keeps patch-T as BASE and closes node, CAD, and trigger spell references", () => {
const fixture = createFixture();
try {
const result = buildLocalReference(fixture.options);
const bySpellId = new Map(result.spells.map((entry) => [entry.id, entry]));
assert.equal(result.schemaVersion, 1);
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
assert.equal(result.classes.find((definition) => definition.classId === 14).gameId, "felsworn");
assert.deepEqual(result.classes.find((definition) => definition.classId === 14).aliases, ["DemonHunter"]);
assert.equal(result.classes.find((definition) => definition.classId === 14).clientClassId, 14);
assert.equal(bySpellId.get(1002).name, "BASE Alias");
assert.equal(bySpellId.get(1002).dbc.castTimeMs, 125);
assert.equal(bySpellId.get(1002).dbc.durationMs, -1);
assert.equal(bySpellId.get(1003).dbc.castTimeMs, -1);
assert.equal(bySpellId.get(1002).dbc.startRecoveryTimeMs, 1500);
assert.equal(bySpellId.get(2002).name, "BASE Trigger");
assert.ok(bySpellId.get(2002).sourceEvidence.some((evidence) => evidence.kind === "dbc-effect-trigger" && evidence.sourceSpellId === 1002));
assert.equal(bySpellId.get(4000).dbc, undefined);
assert.ok(result.diagnostics.some((item) => item.code === "missing-spell-reference" && item.spellId === 4000 && item.overlayOnly));
assert.deepEqual(result.inventory.spellTables.base.recordCount, 7);
assert.equal(result.inventory.spellTables.base.sha256, crypto.createHash("sha256").update(fs.readFileSync(fixture.baseSpellPath)).digest("hex"));
assert.equal(result.inventory.spellTables.overlay.recordCount, 2);
assert.ok(result.diagnostics.some((item) => item.code === "source-order-conflict"));
assert.ok(result.sources.every((source) => !path.isAbsolute(source.path)));
const ability = result.abilities.find((entry) => entry.name === "Alias Ability");
assert.equal(ability.classId, 14);
assert.equal(ability.live, null);
assert.deepEqual(ability.nodeIds, [500]);
assert.deepEqual(ability.rankSpellIds, [1002, 1003, 1005]);
assert.ok(result.diagnostics.some((item) => item.code === "ambiguous-rank-join" && item.abilityKey === ability.key && item.rank === 2));
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 19).advancementRows.some((row) => row.Class === "Monk"));
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 20).advancementRows.some((row) => row.Class === "SonOfArugal"));
assert.notEqual(fixture.overlaySpellPath, fixture.baseSpellPath);
} finally {
cleanup(fixture);
}
});
test("malformed BASE Spell.dbc is rejected instead of falling back to the overlay", () => {
const fixture = createFixture();
try {
fs.writeFileSync(fixture.baseSpellPath, Buffer.from("not a dbc"));
assert.throws(() => buildLocalReference(fixture.options), /WDBC|truncated/);
} finally {
cleanup(fixture);
}
});
test("missing optional cast-time support leaves a nonzero cast index unresolved", () => {
const fixture = createFixture({ includeCastTimes: false });
try {
const result = buildLocalReference(fixture.options);
const spell = result.spells.find((entry) => entry.id === 1002);
assert.equal(spell.dbc.castTimeIndex, 1);
assert.equal(spell.dbc.castTimeMs, null);
assert.ok(result.diagnostics.some((item) => item.code === "missing-optional-support-dbc" && item.sourceId === "spell-cast-times"));
} finally {
cleanup(fixture);
}
});
test("August seeds resolve new ranks and effect-168 dependencies without replacing July nodes", () => {
const fixture = createFixture();
try {
freezeJulyBaseline(fixture.projectRoot);
writeFile(fixture.projectRoot, "src/game/coaLiveData.generated.json", '{"source":{"synchronizedAt":"2026-08-12"}}');
const newSpell = spell(9002, "August Spell");
newSpell.fields[71] = 168;
newSpell.fields[110] = 9003;
fs.writeFileSync(fixture.baseSpellPath, makeDbc([
...[1002, 1003, 1004, 1005, 2002, 3001, 3002].map((id) => spell(id, `Spell ${id}`)),
newSpell, spell(9003, "August Payload"),
], 234));
const result = buildLocalReference({ ...fixture.options, extraNodes: [
{ id: 901, classId: 14, rankDescriptions: [{ rank: 1, spellId: 9002 }] },
] });
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
assert.deepEqual(result.nodes.map((node) => node.id), [500]);
assert.equal(result.spells.find((row) => row.id === 9002).dbc.name, "August Spell");
assert.equal(result.spells.find((row) => row.id === 9003).dbc.name, "August Payload");
assert.ok(result.spells.find((row) => row.id === 9002).sourceEvidence.some((row) => row.kind === "august-node-rank"));
} finally {
cleanup(fixture);
}
});
test("July CoA spell identity seeds archived BASE rows and transfers ownership to missing trigger payloads", () => {
const fixture = createFixture();
try {
const activePath = path.join(fixture.projectRoot, "src/game/coaLiveData.generated.json");
const july = JSON.parse(fs.readFileSync(activePath, "utf8"));
july.spells = [{
id: 630868,
classIds: [27],
name: "Old Rejuvenating Venom",
rank: "Rank 1",
description: "Old Area52 description",
dbc: { id: 630868, name: "Area52 Rejuvenating Venom", effects: [] },
}];
fs.writeFileSync(activePath, `${JSON.stringify(july)}\n`);
freezeJulyBaseline(fixture.projectRoot);
fs.writeFileSync(fixture.overlaySpellPath, makeDbc([spell(630868, "Area52 Rejuvenating Venom")], 234));
const withoutArchive = buildLocalReference(fixture.options);
const unresolved = withoutArchive.spells.find((row) => row.id === 630868);
assert.deepEqual(unresolved.classIds, [27]);
assert.equal(unresolved.dbc, undefined);
assert.notEqual(unresolved.name, "Old Rejuvenating Venom");
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "july-spell-identity" && item.classId === 27));
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "comparative-overlay-presence-only"));
const archiveSource = {
id: "august-global-spell-base",
path: "raw/tables/Spell/variants/data-patch-t-mpq",
sha256: "archive-index-fixture",
bytes: 123,
};
const archivedSpells = {
source: archiveSource,
fieldTypes: Array.from({ length: 234 }, (_, index) => (
[47, 77, 78, 79, 102, 103, 119, 120, 121, 216, 217, 218, 229, 230, 231].includes(index)
? "float"
: [136, 153, 170, 187].includes(index) ? "string" : "int"
)),
records: [
archiveSpellRow(630868, "August Rejuvenating Venom", { triggerSpellId: 630869 }),
archiveSpellRow(630869, "August Venom Payload", { effectType: 2, basePoints: 49 }),
],
files: [{ path: "00600000-00699999.jsonl.gz" }],
};
const withArchive = buildLocalReference({ ...fixture.options, archivedSpells });
const root = withArchive.spells.find((row) => row.id === 630868);
const payload = withArchive.spells.find((row) => row.id === 630869);
assert.equal(root.dbc.name, "August Rejuvenating Venom");
assert.deepEqual(root.classIds, [27]);
assert.ok(root.sourceEvidence.some((item) => item.kind === "base-spell-dbc-row" && item.sourceId === archiveSource.id));
assert.equal(payload.dbc.name, "August Venom Payload");
assert.deepEqual(payload.classIds, [27]);
assert.ok(payload.sourceEvidence.some((item) => item.kind === "trigger-payload-owner" && item.classId === 27));
assert.ok(!JSON.stringify(withArchive.spells).includes("Area52 Rejuvenating Venom"));
} finally {
cleanup(fixture);
}
});
+770
View File
@@ -0,0 +1,770 @@
import { isDeepStrictEqual } from "node:util";
export const COA_PROMOTION_ARCHIVE = Object.freeze({
repository: "https://github.com/srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
capturedAt: "2026-08-12T21:02:51+00:00",
realmSlug: "voljin",
realmName: "Vol'Jin",
coaBaseSpellPath: "client-current/patch-T/DBFilesClient/Spell.dbc",
classicSpellPath: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
});
const CLASSIC_CLASS_MAX = 11;
const EXPECTED_COA_CLASS_IDS = Array.from({ length: 21 }, (_, index) => index + 12);
function assert(condition, message) {
if (!condition) throw new TypeError(message);
}
function numberOrZero(value) {
return Number(value) || 0;
}
function cleanHtml(value = "") {
return String(value)
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#0*39;|&apos;/gi, "'")
.replace(/\s+/g, " ")
.trim();
}
function cleanName(value = "") {
return String(value).replace(/^@/, "").replace(/^6(?=[A-Z])/, "").trim();
}
function iconBasename(value = "") {
return String(value)
.replace(/^.*[\\/]/, "")
.replace(/\.(?:blp|tga)$/i, "")
.toLowerCase();
}
function numberList(value) {
return (Array.isArray(value) ? value : []).map(Number).filter(Boolean);
}
function compareEntries(left, right) {
return left.classId - right.classId
|| left.tabId - right.tabId
|| left.y - right.y
|| left.x - right.x
|| left.id - right.id;
}
function compareNumbers(left, right) {
return left - right;
}
function stable(value) {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function changedFields(before, after) {
const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
return keys.filter((key) => stable(before?.[key]) !== stable(after?.[key]));
}
function changedValues(before, after, fields = changedFields(before, after)) {
return Object.fromEntries(fields.map((field) => {
const beforeValue = before?.[field];
const afterValue = after?.[field];
if (field === "dbc" && beforeValue && afterValue && typeof beforeValue === "object" && typeof afterValue === "object") {
const dbcFields = changedFields(beforeValue, afterValue);
return [field, Object.fromEntries(dbcFields.map((dbcField) => [dbcField, {
...(beforeValue[dbcField] === undefined ? {} : { before: clone(beforeValue[dbcField]) }),
...(afterValue[dbcField] === undefined ? {} : { after: clone(afterValue[dbcField]) }),
}]))];
}
return [field, {
...(beforeValue === undefined ? {} : { before: clone(beforeValue) }),
...(afterValue === undefined ? {} : { after: clone(afterValue) }),
}];
}));
}
function spellSummary(spell) {
return {
id: Number(spell.id),
classIds: clone(spell.classIds ?? []),
name: spell.name ?? "",
rank: spell.rank ?? "",
description: spell.description ?? "",
hasBaseDbc: Boolean(spell.dbc),
};
}
function clone(value) {
return structuredClone(value);
}
function classNameById(classes, label) {
const result = new Map();
for (const definition of classes) {
const classId = Number(definition?.classId);
assert(Number.isInteger(classId), `${label} contains a class without an integer classId`);
assert(!result.has(classId), `${label} contains duplicate classId ${classId}`);
result.set(classId, String(definition.className ?? definition.builderClassName ?? definition.class ?? ""));
}
return result;
}
function assertUniqueById(records, label) {
const seen = new Set();
for (const record of records) {
const id = Number(record?.id);
assert(Number.isSafeInteger(id) && id > 0, `${label} contains invalid id ${String(record?.id)}`);
assert(!seen.has(id), `${label} contains duplicate id ${id}`);
seen.add(id);
}
return seen;
}
function previousSpellWrapper(spell) {
const {
id, classIds = [], name = "", iconBasename: icon = "", level = 0, rank = "", description = "",
liveTooltip, sourceIds = [], advancementType = "", advancementTabId = 0, talented = false,
excluded = false, dbc,
} = spell;
return {
id: Number(id),
classIds: clone(classIds),
name,
iconBasename: icon,
level,
rank,
description,
...(liveTooltip ? { liveTooltip } : {}),
sourceIds: clone(sourceIds),
advancementType,
advancementTabId,
talented,
excluded,
...(dbc ? { dbc: clone(dbc) } : {}),
};
}
function referenceSpellWrapper({ id, referenceSpell, previousSpell, owners, nodeOwner, descriptionOverride, dbcSourceOnly = true }) {
const dbc = referenceSpell?.dbc;
const node = nodeOwner?.node;
const description = descriptionOverride
?? nodeOwner?.rankDescription?.description
?? node?.description
?? referenceSpell?.description
?? (dbcSourceOnly ? "" : previousSpell?.description)
?? dbc?.description
?? "";
const ownerClassIds = [...new Set([...(owners ?? [])])].filter((classId) => classId > CLASSIC_CLASS_MAX).sort(compareNumbers);
const icon = node?.iconBasename || previousSpell?.iconBasename || "";
const rankDescriptionRank = nodeOwner?.rankDescription?.rank;
return {
id,
classIds: ownerClassIds.length
? ownerClassIds
: clone(referenceSpell?.classIds ?? previousSpell?.classIds ?? []),
name: referenceSpell?.name || node?.name || previousSpell?.name || dbc?.name || "",
iconBasename: icon,
level: referenceSpell?.level ?? previousSpell?.level ?? dbc?.spellLevel ?? 0,
rank: rankDescriptionRank !== undefined
? `Rank ${rankDescriptionRank}`
: referenceSpell?.rank || previousSpell?.rank || dbc?.rankText || "",
description,
sourceIds: clone(previousSpell?.sourceIds ?? []),
advancementType: previousSpell?.advancementType ?? "",
advancementTabId: previousSpell?.advancementTabId ?? 0,
talented: previousSpell?.talented ?? false,
excluded: previousSpell?.excluded ?? false,
...(dbc ? { dbc: clone(dbc) } : {}),
};
}
function rankOwnerMap(entries) {
const owners = new Map();
for (const node of entries) {
for (const rankDescription of node.rankDescriptions) {
const spellId = rankDescription.spellId;
const existing = owners.get(spellId) ?? [];
existing.push({ classId: node.classId, node, rankDescription });
owners.set(spellId, existing);
}
}
for (const ownerList of owners.values()) {
ownerList.sort((left, right) => compareEntries(left.node, right.node)
|| left.rankDescription.rank - right.rankDescription.rank);
}
return owners;
}
function firstNodeOwner(ownerList) {
return ownerList?.[0] ?? null;
}
function mapOfSpells(spells, label) {
assert(Array.isArray(spells), `${label} must be an array`);
assertUniqueById(spells, label);
return new Map(spells.map((spell) => [Number(spell.id), spell]));
}
function chainSpellIds(chain) {
return (Array.isArray(chain?.ranks) ? chain.ranks : []).map((rank) => Number(rank.spellId));
}
function spellIdsForClassicChains(chains) {
const ids = new Set();
for (const chain of chains) {
if (Number(chain.classId) > CLASSIC_CLASS_MAX) continue;
for (const spellId of chainSpellIds(chain)) if (Number.isSafeInteger(spellId) && spellId > 0) ids.add(spellId);
}
return ids;
}
function classicOwnedSpellIds(spells, classicChainSpellIds) {
const ids = new Set(classicChainSpellIds);
for (const spell of spells) {
if ((spell.classIds ?? []).some((classId) => Number(classId) > 0 && Number(classId) <= CLASSIC_CLASS_MAX)) {
ids.add(Number(spell.id));
}
}
return ids;
}
function classicProtectionClosure({ previousSpells, classicChains, classicTriggeredSpells = [] }) {
const mainById = new Map(previousSpells.map((spell) => [Number(spell.id), spell]));
const triggeredById = new Map();
for (const spell of classicTriggeredSpells) {
const id = Number(spell?.id);
if (Number.isSafeInteger(id) && id > 0 && !triggeredById.has(id)) triggeredById.set(id, spell);
}
const seeds = classicOwnedSpellIds(previousSpells, spellIdsForClassicChains(classicChains));
const referencedIds = new Set(seeds);
const protectedIds = new Set([...seeds].filter((id) => mainById.has(id)));
const queue = [...seeds].sort(compareNumbers);
const visited = new Set();
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const spellId = queue[cursor];
if (visited.has(spellId)) continue;
visited.add(spellId);
const rows = [mainById.get(spellId), triggeredById.get(spellId)].filter(Boolean);
const childIds = new Set();
for (const row of rows) {
const effects = row.dbc?.effects ?? row.effects ?? [];
for (const effect of effects) {
const triggerSpellId = Number(effect.triggerSpellId);
if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.add(triggerSpellId);
const miscValue = Number(effect.miscValue);
if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValue) && miscValue > 0) childIds.add(miscValue);
}
}
for (const childId of [...childIds].sort(compareNumbers)) {
referencedIds.add(childId);
if (mainById.has(childId)) protectedIds.add(childId);
if (!visited.has(childId)) queue.push(childId);
}
}
return { protectedIds, referencedIds };
}
function closureForCoaSpells({ entries, chains, referenceSpells }) {
const ownersBySpellId = new Map();
const addOwner = (spellId, classIds) => {
const id = Number(spellId);
if (!Number.isSafeInteger(id) || id <= 0) return false;
const owners = ownersBySpellId.get(id) ?? new Set();
const before = owners.size;
for (const classId of classIds) {
const numericClassId = Number(classId);
if (numericClassId > CLASSIC_CLASS_MAX) owners.add(numericClassId);
}
ownersBySpellId.set(id, owners);
return owners.size !== before;
};
for (const node of entries) {
for (const rank of node.rankDescriptions) addOwner(rank.spellId, [node.classId]);
}
for (const chain of chains) {
if (Number(chain.classId) <= CLASSIC_CLASS_MAX) continue;
for (const rank of chain.ranks ?? []) addOwner(rank.spellId, [chain.classId]);
}
for (const spell of referenceSpells) {
const classes = (spell.classIds ?? []).filter((classId) => Number(classId) > CLASSIC_CLASS_MAX);
if (classes.length) addOwner(spell.id, classes);
}
const byId = new Map(referenceSpells.map((spell) => [Number(spell.id), spell]));
const processedOwners = new Map();
const queue = [...ownersBySpellId.keys()].sort(compareNumbers);
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const spellId = queue[cursor];
const owners = ownersBySpellId.get(spellId) ?? new Set();
const processed = processedOwners.get(spellId) ?? new Set();
const newOwners = [...owners].filter((classId) => !processed.has(classId));
if (newOwners.length === 0) continue;
for (const classId of newOwners) processed.add(classId);
processedOwners.set(spellId, processed);
const dbc = byId.get(spellId)?.dbc;
for (const effect of dbc?.effects ?? []) {
const childIds = [];
const triggerSpellId = Number(effect.triggerSpellId);
if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.push(triggerSpellId);
const miscValueId = Number(effect.miscValue);
if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValueId) && miscValueId > 0) childIds.push(miscValueId);
for (const childId of childIds) {
if (addOwner(childId, newOwners)) queue.push(childId);
}
}
}
return ownersBySpellId;
}
function preserveChecks({ previous, candidate, oldEntriesById, oldChainById, oldSpellById, protectedSpellIds, classicReachableSpellIds, coaOwnersBySpellId }) {
const classicChains = previous.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX);
const candidateClassicChains = candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX);
const classicProtected = [...protectedSpellIds].sort(compareNumbers);
const classicSpellRecords = classicProtected.flatMap((id) => {
const before = oldSpellById.get(id);
if (!before) return [];
const after = candidate.spells.find((spell) => Number(spell.id) === id);
return [{ id, before, after: after ?? null }];
});
const missingOldNodeIds = [];
const crossClassRewires = [];
for (const [id, oldNode] of oldEntriesById) {
const nextNode = candidate.entries.find((node) => Number(node.id) === id);
if (!nextNode) missingOldNodeIds.push(id);
else if (Number(nextNode.classId) !== Number(oldNode.classId)) {
crossClassRewires.push({ id, beforeClassId: oldNode.classId, afterClassId: nextNode.classId });
}
}
const missingOldChainIds = [];
const changedCoaChainRanks = [];
const candidateChainMap = new Map(candidate.progressionChains.map((chain) => [chain.id, chain]));
for (const [id, oldChain] of oldChainById) {
const nextChain = candidateChainMap.get(id);
if (!nextChain) missingOldChainIds.push(id);
else if (!isDeepStrictEqual(chainSpellIds(oldChain), chainSpellIds(nextChain))) {
changedCoaChainRanks.push({ id, before: chainSpellIds(oldChain), after: chainSpellIds(nextChain) });
}
}
const classicRecordsUnchanged = classicSpellRecords.every(({ before, after }) => after && isDeepStrictEqual(before, after));
return {
classicProgressionChains: {
passed: isDeepStrictEqual(classicChains, candidateClassicChains),
count: classicChains.length,
},
classicProtectedSpellRecords: {
passed: classicRecordsUnchanged,
recordCount: classicSpellRecords.length,
protectedMainIdCount: classicProtected.length,
classicReachableIdCount: classicReachableSpellIds.size,
},
previousCoaNodeIds: {
passed: missingOldNodeIds.length === 0 && crossClassRewires.length === 0,
previousCount: oldEntriesById.size,
missingIds: missingOldNodeIds.sort(compareNumbers),
crossClassRewires: crossClassRewires.sort((left, right) => left.id - right.id),
},
previousProgressionChainIdentities: {
passed: missingOldChainIds.length === 0 && changedCoaChainRanks.length === 0,
previousCount: oldChainById.size,
missingIds: missingOldChainIds.sort(),
changedCoaRanks: changedCoaChainRanks.sort((left, right) => String(left.id).localeCompare(String(right.id))),
},
coaSpellOverrideCoverage: {
passed: candidate.coaSpellOverrides.every((spell) => (coaOwnersBySpellId.get(spell.id)?.size ?? 0) > 0),
overrideCount: candidate.coaSpellOverrides.length,
},
};
}
function validationError(message, report) {
const error = new Error(message);
error.name = "CoaPromotionValidationError";
error.report = report;
return error;
}
function makeSource(previous, reference, supplements) {
const prior = previous.source ?? {};
const baseSpellSource = reference.sources?.find((source) => source.id === "global-spell-base") ?? null;
return {
...clone(prior),
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
realmName: COA_PROMOTION_ARCHIVE.realmName,
synchronizedAt: supplements.source.capturedAt,
classSpellRowsCapturedAt: prior.synchronizedAt,
spellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
supportDbcDirectory: "client-current/patch-S/DBFilesClient",
coaSpellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
classicSpellDbcPath: COA_PROMOTION_ARCHIVE.classicSpellPath,
coaSkillLineAbilityDbcPath: "client-current/patch-M/DBFilesClient/SkillLineAbility.dbc",
...(reference.archivedSpellSupplement ? { archivedSpellSupplement: clone(reference.archivedSpellSupplement) } : {}),
spellDbcSources: {
coaBase: {
patch: "T",
path: baseSpellSource?.path ?? `extracts/${COA_PROMOTION_ARCHIVE.coaBaseSpellPath}`,
sourceId: baseSpellSource?.id ?? "global-spell-base",
},
classic: {
patch: "area-52",
path: prior.spellDbcPath?.includes("area-52")
? prior.spellDbcPath
: COA_PROMOTION_ARCHIVE.classicSpellPath,
sourceId: "classic-area52-preserved",
},
},
promotion: {
archiveRepository: supplements.source.repository,
archiveRevision: supplements.source.revision,
archiveCapturedAt: supplements.source.capturedAt,
builderSnapshotPath: supplements.source.builderSnapshotPath,
builderSnapshotSha256: supplements.source.builderSnapshotSha256,
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
coaSpellSource: "client-current/patch-T/DBFilesClient/Spell.dbc",
classicSpellSource: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
},
};
}
function makeCounts(candidate, previous) {
const classSpells = candidate.spells.flatMap((spell) => spell.classIds ?? []);
return {
classes: candidate.classes.length,
tabs: candidate.classes.reduce((total, definition) => total + definition.tabs.length, 0),
entries: candidate.entries.length,
abilityEntries: candidate.entries.filter((entry) => entry.entryType === "Ability").length,
talentEntries: candidate.entries.filter((entry) => entry.entryType === "Talent").length,
rankedNodeSpells: candidate.entries.reduce((total, entry) => total + entry.rankDescriptions.length, 0),
classSpellRows: previous.counts?.classSpellRows ?? classSpells.length,
classicClassSpellRows: previous.counts?.classicClassSpellRows ?? classSpells.filter((classId) => classId <= CLASSIC_CLASS_MAX).length,
coaClassSpellRows: previous.counts?.coaClassSpellRows ?? classSpells.filter((classId) => classId > CLASSIC_CLASS_MAX).length,
spellClassMemberships: classSpells.length,
uniqueSpells: candidate.spells.length,
dbcMatchedSpells: candidate.spells.filter((spell) => spell.dbc).length,
progressionChains: candidate.progressionChains.length,
classicProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX).length,
classicBaseProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX && !chain.talented).length,
coaProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX).length,
progressionRanks: candidate.progressionChains.reduce((total, chain) => total + (chain.ranks?.length ?? 0), 0),
liveTooltips: candidate.spells.filter((spell) => spell.liveTooltip).length,
coaSpellOverrides: candidate.coaSpellOverrides.length,
};
}
/** Convert one pinned archive tree node to the active CoA entry shape used by the old sync. */
export function normalizeArchiveNode(raw) {
assert(raw && typeof raw === "object" && !Array.isArray(raw), "Archive node must be an object");
const id = Number(raw.id);
const classId = Number(raw.classId);
const tabId = Number(raw.tabId);
assert(Number.isSafeInteger(id) && id > 0, `Archive node has invalid id ${String(raw.id)}`);
assert(Number.isInteger(classId) && EXPECTED_COA_CLASS_IDS.includes(classId), `Archive node ${id} has noncanonical classId ${String(raw.classId)}`);
assert(Number.isInteger(tabId) && tabId > 0, `Archive node ${id} has invalid tabId ${String(raw.tabId)}`);
const x = Number(raw.x ?? raw.column);
const y = Number(raw.y ?? raw.row);
assert(Number.isFinite(x) && Number.isFinite(y), `Archive node ${id} has invalid coordinates`);
const rankDescriptions = (Array.isArray(raw.rankDescriptions) ? raw.rankDescriptions : []).map((rank, index) => {
const rankNumber = Number(rank?.rank);
const spellId = Number(rank?.spellId);
assert(Number.isInteger(rankNumber) && rankNumber > 0, `Archive node ${id} has invalid rank number at index ${index}`);
assert(Number.isSafeInteger(spellId) && spellId > 0, `Archive node ${id} has invalid rank spellId at index ${index}`);
return {
rank: rankNumber,
spellId,
description: cleanHtml(rank?.description),
};
});
const spellIdCount = Array.isArray(raw.spellIds) ? raw.spellIds.length : rankDescriptions.length;
return {
id,
classId,
tabId,
x,
y,
name: cleanName(raw.name),
iconBasename: iconBasename(raw.iconPath ?? raw.icon),
nodeType: String(raw.nodeType ?? ""),
entryType: String(raw.entryType ?? ""),
isPassive: Boolean(raw.isPassive),
maxPoints: numberOrZero(raw.maxPoints ?? raw.maxRank) || Math.max(1, spellIdCount),
aeCost: numberOrZero(raw.aeCost ?? raw.abilityEssenceCost),
teCost: numberOrZero(raw.teCost ?? raw.talentEssenceCost),
requiredClassPoints: numberOrZero(raw.reqTabAE ?? raw.requiredClassPoints),
requiredSpecPoints: numberOrZero(raw.reqTabTE ?? raw.requiredSpecPoints),
requiredIds: numberList(raw.requiredIds ?? raw.prerequisites),
connectedNodeIds: numberList(raw.connectedNodeIds ?? raw.connections),
requiredLevel: numberOrZero(raw.requiredLevel),
isStartingNode: Boolean(raw.isStartingNode),
flags: numberOrZero(raw.flags),
group: numberOrZero(raw.group),
sortOrder: numberOrZero(raw.sortOrder),
description: cleanHtml(raw.description),
rankDescriptions,
};
}
/** Build an in-memory, validated CoA promotion candidate and an exact machine-readable diff. */
export function buildCoaPromotion({ previous, reference, supplements }) {
assert(previous && typeof previous === "object", "previous CoA data is required");
assert(reference && typeof reference === "object", "local CoA reference is required");
assert(supplements && typeof supplements === "object", "pinned CoA supplements are required");
assert(Array.isArray(previous.classes), "previous CoA data has no classes array");
assert(Array.isArray(previous.entries), "previous CoA data has no entries array");
assert(Array.isArray(previous.progressionChains), "previous CoA data has no progressionChains array");
assert(Array.isArray(previous.spells), "previous CoA data has no spells array");
assert(Array.isArray(reference.classes), "local reference has no classes array");
assert(Array.isArray(reference.nodes), "local reference has no nodes array");
assert(Array.isArray(reference.progressionChains), "local reference has no progressionChains array");
assert(Array.isArray(reference.spells), "local reference has no spells array");
assert(Array.isArray(reference.sources), "local reference has no sources array");
assert(Array.isArray(supplements.classes), "pinned supplements have no classes array");
assert(supplements.source?.revision === COA_PROMOTION_ARCHIVE.revision, "pinned supplement revision does not match the approved August archive");
assert(supplements.source?.capturedAt === COA_PROMOTION_ARCHIVE.capturedAt, "pinned supplement capture time does not match the approved August archive");
const canonicalNames = classNameById(previous.classes, "previous classes");
const previousClassIds = [...canonicalNames.keys()].sort(compareNumbers);
assert(isDeepStrictEqual(previousClassIds, EXPECTED_COA_CLASS_IDS), "previous CoA classes must contain canonical IDs 12 through 32");
const referenceNames = classNameById(reference.classes, "reference classes");
const archiveNames = classNameById(supplements.classes, "supplement classes");
for (const classId of EXPECTED_COA_CLASS_IDS) {
const canonicalName = canonicalNames.get(classId);
assert(canonicalName, `previous CoA class ${classId} has no canonical className`);
assert(referenceNames.get(classId) === canonicalName, `reference class ${classId} does not match canonical name ${canonicalName}`);
const archiveClass = supplements.classes.find((definition) => Number(definition.classId) === classId);
assert(archiveClass, `pinned archive is missing CoA class ${classId}`);
assert(archiveNames.get(classId) === canonicalName, `pinned archive class ${classId} does not match canonical name ${canonicalName}`);
assert((archiveClass.builderClassName ?? archiveClass.class) === canonicalName, `pinned builder class name for ${classId} does not match ${canonicalName}`);
}
assert(supplements.classes.length === EXPECTED_COA_CLASS_IDS.length, "pinned archive must contain exactly 21 CoA classes");
const entries = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode)).sort(compareEntries);
assert(entries.length > 0, "pinned archive has no CoA nodes");
assertUniqueById(entries, "pinned archive nodes");
const entryIds = new Set(entries.map((entry) => entry.id));
const priorEntryIds = assertUniqueById(previous.entries, "previous CoA entries");
const oldEntriesById = new Map(previous.entries.map((entry) => [Number(entry.id), entry]));
const oldIdsMissing = [...priorEntryIds].filter((id) => !entryIds.has(id)).sort(compareNumbers);
const crossClassRewires = previous.entries.flatMap((oldNode) => {
const newNode = entries.find((node) => node.id === Number(oldNode.id));
return newNode && newNode.classId !== Number(oldNode.classId)
? [{ id: Number(oldNode.id), beforeClassId: Number(oldNode.classId), afterClassId: newNode.classId }]
: [];
});
assert(oldIdsMissing.length === 0, `August archive removes July node IDs: ${oldIdsMissing.join(", ")}`);
assert(crossClassRewires.length === 0, `August archive rewires node IDs across classes: ${crossClassRewires.map((item) => item.id).join(", ")}`);
const classes = previous.classes.map((definition) => {
const classId = Number(definition.classId);
const archiveClass = supplements.classes.find((item) => Number(item.classId) === classId);
const classNodes = entries.filter((node) => node.classId === classId);
const nodeTabs = new Set(classNodes.map((node) => node.tabId));
const tabs = (archiveClass.tabs ?? [])
.filter((tab) => tab?.isEmpty !== true && Number(tab?.entryCount ?? 1) > 0 && nodeTabs.has(Number(tab.tabId)))
.map((tab) => ({
tabId: Number(tab.tabId),
tabName: String(tab.tabName ?? tab.name ?? ""),
sortOrder: numberOrZero(tab.sortOrder),
}))
.sort((left, right) => left.sortOrder - right.sortOrder || left.tabId - right.tabId);
assert(tabs.length > 0, `pinned archive class ${classId} has no nonempty tabs`);
const tabIds = new Set(tabs.map((tab) => tab.tabId));
assert(classNodes.every((node) => tabIds.has(node.tabId)), `pinned archive class ${classId} contains a node outside a nonempty tab`);
return { ...clone(definition), tabs };
});
const priorChainIds = new Set(previous.progressionChains.map((chain) => String(chain.id)));
assert(priorChainIds.size === previous.progressionChains.length, "previous progression chains contain duplicate IDs");
const oldChainById = new Map(previous.progressionChains.map((chain) => [String(chain.id), chain]));
const progressionChains = clone(previous.progressionChains);
const referenceSpellById = mapOfSpells(reference.spells, "reference spells");
const oldSpellById = mapOfSpells(previous.spells, "previous spells");
const classicProtection = classicProtectionClosure({
previousSpells: previous.spells,
classicChains: previous.progressionChains,
classicTriggeredSpells: previous.classicTriggeredSpells ?? [],
});
const protectedSpellIds = classicProtection.protectedIds;
const rankOwners = rankOwnerMap(entries);
const coaOwnersBySpellId = closureForCoaSpells({
entries,
chains: progressionChains,
referenceSpells: reference.spells,
});
const allSpellIds = new Set([
...oldSpellById.keys(),
...referenceSpellById.keys(),
...rankOwners.keys(),
...[...coaOwnersBySpellId.keys()],
...progressionChains.flatMap((chain) => chainSpellIds(chain)),
]);
const sortedSpellIds = [...allSpellIds].sort(compareNumbers);
const spells = [];
const coaSpellOverrides = [];
for (const id of sortedSpellIds) {
const previousSpell = oldSpellById.get(id);
const referenceSpell = referenceSpellById.get(id);
const ownerList = rankOwners.get(id) ?? [];
const nodeOwner = firstNodeOwner(ownerList);
const coaOwners = [...(coaOwnersBySpellId.get(id) ?? [])].sort(compareNumbers);
if (protectedSpellIds.has(id) && previousSpell) {
spells.push(clone(previousSpell));
} else if (referenceSpell || previousSpell || nodeOwner) {
spells.push(referenceSpellWrapper({ id, referenceSpell, previousSpell, owners: coaOwners, nodeOwner }));
}
if (protectedSpellIds.has(id) && coaOwners.length > 0) {
coaSpellOverrides.push(referenceSpellWrapper({
id,
referenceSpell,
previousSpell,
owners: coaOwners,
nodeOwner,
}));
}
}
coaSpellOverrides.sort((left, right) => left.id - right.id);
const candidate = {
source: makeSource(previous, reference, supplements),
counts: {},
classes,
entries,
progressionChains,
spells,
...(coaSpellOverrides.length ? { coaSpellOverrides } : {}),
};
candidate.coaSpellOverrides ??= [];
candidate.counts = makeCounts(candidate, previous);
const coaChainSpellIds = new Set(progressionChains
.filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX)
.flatMap((chain) => chainSpellIds(chain)));
const coaRequiredIds = new Set([...coaOwnersBySpellId.keys(), ...coaChainSpellIds]);
const missingBaseSpellIds = [...coaRequiredIds]
.filter((id) => !referenceSpellById.get(id)?.dbc)
.sort(compareNumbers);
const missingBaseProgressionRankIds = [...coaChainSpellIds]
.filter((id) => !referenceSpellById.get(id)?.dbc)
.sort(compareNumbers);
const previousSpellIds = new Set(oldSpellById.keys());
const candidateSpellIds = new Set(spells.map((spell) => Number(spell.id)));
const addedSpellIds = [...candidateSpellIds].filter((id) => !previousSpellIds.has(id)).sort(compareNumbers);
const removedSpellIds = [...previousSpellIds].filter((id) => !candidateSpellIds.has(id)).sort(compareNumbers);
const changedSpells = [];
for (const spell of spells) {
const before = oldSpellById.get(Number(spell.id));
if (before && !isDeepStrictEqual(before, spell)) {
const fields = changedFields(before, spell);
changedSpells.push({ id: Number(spell.id), changedFields: fields, changes: changedValues(before, spell, fields) });
}
}
const oldEntryById = oldEntriesById;
const newEntryById = new Map(entries.map((entry) => [entry.id, entry]));
const addedNodeIds = [...newEntryById.keys()].filter((id) => !oldEntryById.has(id)).sort(compareNumbers);
const removedNodeIds = [...oldEntryById.keys()].filter((id) => !newEntryById.has(id)).sort(compareNumbers);
const changedNodes = [];
for (const node of entries) {
const before = oldEntryById.get(node.id);
if (before && !isDeepStrictEqual(before, node)) {
changedNodes.push({ id: node.id, classId: node.classId, changedFields: changedFields(before, node), before: clone(before), after: clone(node) });
}
}
const overrideChanges = coaSpellOverrides.map((after) => {
const before = oldSpellById.get(after.id) ?? null;
const fields = before ? changedFields(before, after) : Object.keys(after).sort();
return {
id: after.id,
protectedClassicRecord: Boolean(before),
classIds: clone(after.classIds),
description: after.description,
hasBaseDbc: Boolean(after.dbc),
changedFields: fields,
changes: before ? changedValues(before, after, fields) : undefined,
};
});
const report = {
schemaVersion: 1,
status: "validated",
sources: {
previous: clone(previous.source ?? null),
reference: {
baseSpellDbc: clone(reference.sources.find((source) => source.id === "global-spell-base") ?? null),
classSources: clone(reference.sources.filter((source) => /class|skill-line/i.test(`${source.id} ${source.role}`))),
},
archive: clone(supplements.source),
},
preserveChecks: {},
nodeChanges: {
previousCount: previous.entries.length,
promotedCount: entries.length,
pinnedArchiveExpectedCount: 3618,
pinnedArchiveCountMatches: entries.length === 3618,
added: addedNodeIds.map((id) => clone(newEntryById.get(id))),
removed: removedNodeIds.map((id) => clone(oldEntryById.get(id))),
changed: changedNodes,
unexpectedRemovals: removedNodeIds,
crossClassRewires,
},
spellChanges: {
previousCount: previous.spells.length,
promotedCount: spells.length,
added: addedSpellIds.map((id) => spellSummary(spells.find((spell) => spell.id === id))),
removed: removedSpellIds.map((id) => spellSummary(oldSpellById.get(id))),
changed: changedSpells,
coaOverrides: overrideChanges,
protectedClassicIdCount: protectedSpellIds.size,
coaContextSpellCount: coaOwnersBySpellId.size,
sharedProtectedIds: overrideChanges.map((item) => item.id),
},
gaps: {
missingBaseSpellIds,
missingBaseSpellCount: missingBaseSpellIds.length,
missingBaseProgressionRankIds,
missingBaseProgressionRankCount: missingBaseProgressionRankIds.length,
},
provenance: {
realmSlug: COA_PROMOTION_ARCHIVE.realmSlug,
archiveRevision: supplements.source.revision,
archiveCapturedAt: supplements.source.capturedAt,
coaBaseSpellPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath,
classicSpellPath: candidate.source.spellDbcSources.classic.path,
coaSkillLineAbilityDbcPath: candidate.source.coaSkillLineAbilityDbcPath,
fixedCaptureTime: true,
generationTimestampAdded: false,
},
};
report.preserveChecks = preserveChecks({
previous,
candidate,
oldEntriesById,
oldChainById,
oldSpellById,
protectedSpellIds,
classicReachableSpellIds: classicProtection.referencedIds,
coaOwnersBySpellId,
});
const failedChecks = Object.entries(report.preserveChecks).filter(([, result]) => !result.passed).map(([name]) => name);
if (failedChecks.length) {
report.status = "blocked";
throw validationError(`CoA promotion failed preservation checks: ${failedChecks.join(", ")}`, report);
}
const invalidDbc = candidate.spells.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id));
const invalidOverrideDbc = candidate.coaSpellOverrides.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id));
if (invalidDbc.length || invalidOverrideDbc.length) {
report.status = "blocked";
throw validationError(`CoA promotion contains mismatched BASE DBC rows for IDs: ${[...invalidDbc, ...invalidOverrideDbc].map((spell) => spell.id).join(", ")}`, report);
}
return { candidate, report };
}
+292
View File
@@ -0,0 +1,292 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCoaPromotion, COA_PROMOTION_ARCHIVE, normalizeArchiveNode } from "./promote.mjs";
const classNames = [
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer", "Knight of Xoroth", "Guardian",
"Templar", "Bloodmage", "Ranger", "Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
"Sun Cleric", "Tinker", "Venomancer", "Reaper", "Primalist", "Runemaster",
];
function archiveNode({ classId, id, spellId, description = "Archive description", tabId = 30 + classId }) {
return {
id,
classId,
tabId,
x: 1,
y: 2,
name: `Class ${classId} node`,
iconPath: "Interface\\Icons\\TEST_ICON.blp",
nodeType: "SpendSquare",
entryType: "Talent",
isPassive: 0,
maxPoints: 1,
aeCost: 0,
teCost: 1,
reqTabAE: 0,
reqTabTE: 0,
requiredIds: [],
connectedNodeIds: [],
requiredLevel: 0,
isStartingNode: 0,
flags: 0,
group: 0,
sortOrder: 0,
description,
rankDescriptions: [{ rank: 1, spellId, description }],
};
}
function dbc(id, { description = `Base description ${id}`, effects = [] } = {}) {
return { id, name: `Base spell ${id}`, rankText: "Rank 1", description, effects };
}
function spell(id, classIds, { description = `Classic description ${id}`, effects = [], liveTooltip } = {}) {
return {
id,
classIds,
name: `Spell ${id}`,
iconBasename: "classic_icon",
level: 1,
rank: "Rank 1",
description,
...(liveTooltip ? { liveTooltip } : {}),
sourceIds: [],
advancementType: "",
advancementTabId: 0,
talented: false,
excluded: false,
dbc: { ...dbc(id), marker: "Area52 overlay row" },
};
}
function fixtures({ missingCoaRanks = [] } = {}) {
const previousClasses = classNames.map((className, index) => ({
classId: index + 12,
className,
maxTalentEssence: 25,
maxAbilityEssence: 26,
tabs: [{ tabId: 30 + index + 12, tabName: "July tab", sortOrder: 1 }],
}));
const archiveClasses = previousClasses.map((definition, index) => {
const classId = definition.classId;
const spellId = classId === 12 ? 100 : 200 + classId;
const node = archiveNode({
classId,
id: 12000 + classId,
spellId,
description: classId === 12 ? "<b>August &amp; improved</b><br>rank" : `August ${classId}`,
});
return {
classId,
class: definition.className,
builderClassName: definition.className,
essence: { maxTalentEssence: 99, maxAbilityEssence: 98 },
tabs: [
{ tabId: node.tabId, tabName: "August tab", sortOrder: 1, entryCount: 1, isEmpty: false },
{ tabId: 900 + classId, tabName: "Empty tab", sortOrder: 2, entryCount: 0, isEmpty: true },
],
nodes: [node],
};
});
const archiveEntries = archiveClasses.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const julyEntries = structuredClone(archiveEntries);
julyEntries.find((node) => node.classId === 12).description = "July-only description";
julyEntries.find((node) => node.classId === 12).rankDescriptions[0].description = "July rank description";
const classicChain = {
id: "classic-preserved-chain",
classId: 1,
name: "Classic chain",
iconBasename: "classic",
talented: false,
advancementType: "",
trainer: true,
ranks: [{ spellId: 100, rank: 1, level: 1 }],
};
const coaChain = {
id: "coa-stable-chain",
classId: 12,
name: "Stable CoA chain",
iconBasename: "coa",
talented: false,
advancementType: "",
trainer: false,
ranks: [
{ spellId: 91300, rank: 1, level: 1 },
...missingCoaRanks.map((spellId, index) => ({ spellId, rank: index + 2, level: index + 2 })),
],
};
const sharedSpell = spell(100, [1, 12], { liveTooltip: "stale Area52 tooltip" });
const classicTriggered = spell(101, [5], { liveTooltip: "classic tooltip" });
const classicPayload = spell(102, [5]);
const classicClosureSeed = spell(500, [2]);
const classicEmptyOwnerChild = spell(501, []);
const classicEmptyOwnerPayload = spell(502, []);
const staleCoa = spell(999, [12], { liveTooltip: "stale CoA tooltip" });
const previous = {
source: { realmSlug: "voljin", spellDbcPath: "C:/client/area-52/patch-D/DBFilesClient/Spell.dbc" },
counts: {},
classes: previousClasses,
entries: julyEntries,
progressionChains: [classicChain, coaChain],
spells: [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload, staleCoa],
classicTriggeredSpells: [
{ id: 500, effects: [{ effectType: 64, triggerSpellId: 501, miscValue: 0 }] },
{ id: 501, effects: [{ effectType: 168, triggerSpellId: 0, miscValue: 502 }] },
],
};
const allArchiveRanks = archiveClasses.flatMap((definition) => definition.nodes.flatMap((node) => node.rankDescriptions));
const referenceSpellMap = new Map();
for (const rank of allArchiveRanks) {
if (missingCoaRanks.includes(rank.spellId)) continue;
let effects = [];
if (rank.spellId === 100) {
effects = [
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
];
}
referenceSpellMap.set(rank.spellId, {
id: rank.spellId,
classIds: rank.spellId === 100 ? [1, 12] : [12],
name: `Base spell ${rank.spellId}`,
rank: "Rank 1",
level: 1,
description: `July reference description ${rank.spellId}`,
dbc: dbc(rank.spellId, { effects }),
});
}
for (const classicSpell of [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload]) {
referenceSpellMap.set(classicSpell.id, {
id: classicSpell.id,
classIds: classicSpell.classIds,
name: `BASE ${classicSpell.id}`,
rank: "Rank 1",
level: 1,
description: `BASE ${classicSpell.id} description`,
dbc: dbc(classicSpell.id, {
effects: classicSpell.id === 100
? [
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
]
: [],
}),
});
}
const supplements = {
source: {
...COA_PROMOTION_ARCHIVE,
capturedAt: COA_PROMOTION_ARCHIVE.capturedAt,
},
classes: archiveClasses,
};
const reference = {
classes: previousClasses,
nodes: [],
progressionChains: [classicChain, coaChain],
spells: [...referenceSpellMap.values()].sort((left, right) => left.id - right.id),
sources: [{ id: "global-spell-base", path: "extracts/client-current/patch-T/DBFilesClient/Spell.dbc", role: "base" }],
};
return { previous, reference, supplements, archiveEntries, classicChain, coaChain };
}
test("normalizeArchiveNode matches the old sync entry shape and cleans archive text", () => {
const normalized = normalizeArchiveNode({
...archiveNode({ classId: 12, id: 1, spellId: 2, description: "<span>One</span><br><br>Two &amp; three" }),
name: "@6New Name",
iconPath: "Interface\\Icons\\TEST_ICON.TGA",
isPassive: 127,
isStartingNode: 0,
reqTabAE: undefined,
reqTabTE: undefined,
requiredClassPoints: 5,
requiredSpecPoints: 6,
});
assert.deepEqual(normalized, {
id: 1,
classId: 12,
tabId: 42,
x: 1,
y: 2,
name: "New Name",
iconBasename: "test_icon",
nodeType: "SpendSquare",
entryType: "Talent",
isPassive: true,
maxPoints: 1,
aeCost: 0,
teCost: 1,
requiredClassPoints: 5,
requiredSpecPoints: 6,
requiredIds: [],
connectedNodeIds: [],
requiredLevel: 0,
isStartingNode: false,
flags: 0,
group: 0,
sortOrder: 0,
description: "One Two & three",
rankDescriptions: [{ rank: 1, spellId: 2, description: "One Two & three" }],
});
});
test("promotion retains classic data and old node and chain identities while using August nodes and BASE spells", () => {
const { previous, reference, supplements, archiveEntries, classicChain, coaChain } = fixtures();
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
const oldClassicSpells = previous.spells.filter((item) => item.classIds.some((classId) => classId <= 11));
assert.deepEqual(candidate.progressionChains[0], classicChain);
assert.deepEqual(candidate.spells.filter((item) => oldClassicSpells.some((old) => old.id === item.id)), oldClassicSpells);
for (const id of [500, 501, 502]) {
assert.deepEqual(candidate.spells.find((item) => item.id === id), previous.spells.find((item) => item.id === id));
}
assert.deepEqual(candidate.entries, archiveEntries);
assert.deepEqual(candidate.progressionChains[1], coaChain);
assert.equal(candidate.classes[0].tabs.length, 1);
assert.equal(candidate.classes[0].maxTalentEssence, 25);
assert.equal(candidate.entries[0].description, "August & improved rank");
assert.equal(candidate.spells.find((item) => item.id === 100).description, "Classic description 100");
assert.equal(candidate.spells.find((item) => item.id === 100).dbc.marker, "Area52 overlay row");
const overrideById = new Map(candidate.coaSpellOverrides.map((item) => [item.id, item]));
for (const id of [100, 101, 102]) {
assert.equal(overrideById.has(id), true, `expected CoA-context override for linked spell ${id}`);
assert.equal(overrideById.get(id).dbc.id, id);
assert.equal(overrideById.get(id).dbc.marker, undefined);
assert.deepEqual(overrideById.get(id).classIds, [12]);
assert.equal("liveTooltip" in overrideById.get(id), false);
}
assert.equal(overrideById.get(100).description, "August & improved rank");
assert.equal(candidate.source.realmSlug, "voljin");
assert.equal(candidate.source.spellDbcSources.coaBase.patch, "T");
assert.match(candidate.source.promotion.classicSpellSource, /area-52\/patch-D/);
assert.equal(candidate.source.promotion.archiveCapturedAt, "2026-08-12T21:02:51+00:00");
assert.equal(report.preserveChecks.classicProgressionChains.passed, true);
assert.equal(report.preserveChecks.classicProtectedSpellRecords.passed, true);
assert.equal(report.nodeChanges.unexpectedRemovals.length, 0);
assert.equal(report.gaps.missingBaseProgressionRankCount, 1);
assert.equal(report.gaps.missingBaseProgressionRankIds.includes(91300), true);
assert.equal(candidate.spells.find((item) => item.id === 999).dbc, undefined);
assert.equal("liveTooltip" in candidate.spells.find((item) => item.id === 999), false);
});
test("promotion reports all missing BASE progression ranks without replacing chain IDs", () => {
const missingCoaRanks = Array.from({ length: 13 }, (_, index) => 91400 + index);
const { previous, reference, supplements, coaChain } = fixtures({ missingCoaRanks });
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
assert.deepEqual(candidate.progressionChains[1], coaChain);
assert.equal(report.gaps.missingBaseProgressionRankCount, 14);
assert.deepEqual(report.gaps.missingBaseProgressionRankIds, [91300, ...missingCoaRanks]);
assert.equal(candidate.spells.some((item) => missingCoaRanks.includes(item.id)), false);
});
test("promotion blocks July node removal and cross-class rewiring", () => {
const missing = fixtures();
missing.supplements.classes.find((item) => item.classId === 12).nodes = [];
assert.throws(() => buildCoaPromotion(missing), /August archive removes July node IDs/);
const rewired = fixtures();
const moved = rewired.supplements.classes.find((item) => item.classId === 12).nodes[0];
rewired.supplements.classes.find((item) => item.classId === 12).nodes = [];
rewired.supplements.classes.find((item) => item.classId === 13).nodes.push({ ...moved, classId: 13, tabId: 43 });
assert.throws(() => buildCoaPromotion(rewired), /August archive rewires node IDs across classes/);
});
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { activeCatalogPath, decodeJsonBytes, freezeJulyBaseline } from "./baseline.mjs";
import { buildLocalReference } from "./local-source.mjs";
import { buildCoaPromotion, normalizeArchiveNode } from "./promote.mjs";
import { readSupplements } from "./supplements.mjs";
import { validateReference } from "./audit.mjs";
import { stableJson } from "./snapshot.mjs";
import { readArchiveSpells } from "./archive-spells.mjs";
/** Explicit offline promotion, distinct from the historical reference build. */
export async function promoteCachedCoa({ projectRoot, cacheRoot, ...sourceOptions }) {
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified August cache is required; run coa:reference:fetch once");
const baselinePath = freezeJulyBaseline(projectRoot);
const previous = JSON.parse(decodeJsonBytes(fs.readFileSync(baselinePath), baselinePath).toString("utf8"));
const triggered = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/generated/coaTriggeredSpellCatalog.json"), "utf8"));
previous.classicTriggeredSpells = triggered.classicSpells ?? triggered.spells;
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const archivedSpells = await readArchiveSpells({ cacheRoot });
const reference = buildLocalReference({ projectRoot, ...sourceOptions, extraNodes, archivedSpells });
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
const spellById = new Map([...candidate.spells, ...(candidate.coaSpellOverrides ?? [])].map((spell) => [spell.id, spell]));
const validation = validateReference({
...reference,
classes: reference.classes.map((definition) => ({ ...definition, tabs: candidate.classes.find((item) => item.classId === definition.classId).tabs })),
nodes: candidate.entries,
spells: [...spellById.values()],
});
if (validation.errors.length) throw new Error(`Promotion validation failed; active catalog retained: ${validation.errors.map((item) => item.message).join("; ")}`);
for (const entry of candidate.entries) {
for (const rank of entry.rankDescriptions) {
if (!spellById.get(rank.spellId)?.dbc) throw new Error(`August node ${entry.id} rank ${rank.rank} lacks global Spell.dbc evidence; active catalog retained`);
}
}
const candidateBytes = `${JSON.stringify(candidate, null, 2)}\n`;
report.validation = validation;
report.candidateSha256 = createHash("sha256").update(candidateBytes).digest("hex");
const reportPath = path.join(projectRoot, "data/coa-reference/promotion-report.json");
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, stableJson(report));
const activePath = path.join(projectRoot, activeCatalogPath);
const temporaryPath = `${activePath}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(temporaryPath, candidateBytes, { flag: "wx" });
if (createHash("sha256").update(fs.readFileSync(temporaryPath)).digest("hex") !== report.candidateSha256) throw new Error("Staged promotion checksum mismatch");
fs.renameSync(temporaryPath, activePath);
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
}
return { reportPath, candidateSha256: report.candidateSha256, counts: candidate.counts, validationWarnings: validation.warnings.length,
gaps: { missingBaseSpellCount: report.gaps.missingBaseSpellCount, missingBaseProgressionRankIds: report.gaps.missingBaseProgressionRankIds } };
}
+156
View File
@@ -0,0 +1,156 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
export function stableJson(value) {
const sort = (item) => {
if (Array.isArray(item)) return item.map(sort);
if (item && typeof item === "object") {
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])]));
}
return item;
};
return `${JSON.stringify(sort(value))}\n`;
}
function safeChild(root, relative) {
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(root, relative);
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`Unsafe reference path: ${relative}`);
return resolved;
}
function verifyFiles(directory, manifest) {
if (manifest.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(manifest.snapshotId)) {
throw new Error("Invalid CoA snapshot manifest");
}
for (const name of ["reference.json", "audit.json", "audit.md"]) {
const file = safeChild(directory, name);
const expected = manifest.files[name];
if (!expected || !fs.existsSync(file)) throw new Error(`Missing reference file: ${name}`);
const bytes = fs.readFileSync(file);
if (bytes.length !== expected.bytes || digest(bytes) !== expected.sha256) {
throw new Error(`Corrupt reference file: ${name}; regenerate from verified sources using a fresh --output-root`);
}
}
const expectedId = digest(stableJson(manifest.files));
if (expectedId !== manifest.snapshotId) throw new Error("Snapshot manifest identity mismatch");
}
export function readSnapshot(outputRoot) {
const pointerPath = path.join(outputRoot, "current.json");
if (!fs.existsSync(pointerPath)) throw new Error("No CoA reference snapshot; run coa:reference:build first");
const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
if (!/^[a-f0-9]{64}$/.test(pointer.snapshotId)) throw new Error("Invalid reference snapshot pointer");
const directory = safeChild(outputRoot, `snapshots/${pointer.snapshotId}`);
const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
if (pointer.snapshotId !== manifest.snapshotId) throw new Error("Reference pointer and manifest disagree");
verifyFiles(directory, manifest);
return {
directory,
manifest,
reference: JSON.parse(fs.readFileSync(path.join(directory, "reference.json"), "utf8")),
report: JSON.parse(fs.readFileSync(path.join(directory, "audit.json"), "utf8")),
};
}
/** Publish immutable files before changing the current pointer; failures keep the old snapshot usable. */
export function publishSnapshot({ outputRoot, reference, report, markdown }) {
const files = {
"reference.json": stableJson(reference),
"audit.json": stableJson(report),
"audit.md": `${markdown.trimEnd()}\n`,
};
const fileManifest = Object.fromEntries(Object.entries(files).map(([name, bytes]) => [name, {
sha256: digest(bytes), bytes: Buffer.byteLength(bytes),
}]));
const snapshotId = digest(stableJson(fileManifest));
const manifest = { schemaVersion: 1, snapshotId, files: fileManifest };
const snapshotsRoot = path.join(outputRoot, "snapshots");
fs.mkdirSync(snapshotsRoot, { recursive: true });
const directory = safeChild(snapshotsRoot, snapshotId);
if (fs.existsSync(directory)) {
const previous = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8"));
verifyFiles(directory, previous);
if (stableJson(previous) !== stableJson(manifest)) throw new Error("Existing snapshot identity collision");
} else {
const staging = fs.mkdtempSync(path.join(snapshotsRoot, ".staging-"));
try {
for (const [name, bytes] of Object.entries(files)) fs.writeFileSync(path.join(staging, name), bytes);
fs.writeFileSync(path.join(staging, "manifest.json"), stableJson(manifest));
verifyFiles(staging, manifest);
fs.renameSync(staging, directory);
} finally {
if (fs.existsSync(staging)) {
safeChild(snapshotsRoot, path.relative(snapshotsRoot, staging));
fs.rmSync(staging, { recursive: true });
}
}
}
const pointer = path.join(outputRoot, "current.json");
const temporary = path.join(outputRoot, `.current-${crypto.randomUUID()}.tmp`);
try {
fs.writeFileSync(temporary, stableJson({ schemaVersion: 1, snapshotId }));
fs.renameSync(temporary, pointer);
} finally {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
}
return { snapshotId, directory, manifest };
}
export function lookupReference(reference, options = {}) {
const needle = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const requestedClass = options.class;
const classes = reference.classes.filter((item) => !requestedClass || [
item.classId, item.gameId, item.className, ...(item.aliases ?? []),
].some((value) => needle(value) === needle(requestedClass)));
if (requestedClass && classes.length === 0) throw new Error(`Unknown CoA class: ${requestedClass}`);
const classIds = new Set(classes.map((item) => item.classId));
let nodes = reference.nodes.filter((node) => classIds.has(node.classId));
if (options.tree) {
const tabs = new Set(classes.flatMap((item) => item.tabs).filter((tab) => [tab.tabId, tab.tabName]
.some((value) => needle(value) === needle(options.tree))).map((tab) => tab.tabId));
if (!tabs.size) throw new Error(`Unknown tree: ${options.tree}`);
nodes = nodes.filter((node) => tabs.has(node.tabId));
}
if (options.ability) nodes = nodes.filter((node) => needle(node.name).includes(needle(options.ability)));
const limit = Number(options.limit ?? 20);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Lookup limit must be 1..1000");
const rankIds = new Set(nodes.flatMap((node) => node.rankDescriptions.map((rank) => rank.spellId)));
let abilities = (reference.abilities ?? []).filter((item) => classIds.has(item.classId)
&& (!options.ability || needle(item.name).includes(needle(options.ability)))
&& (!options.tree || item.nodeIds.some((id) => nodes.some((node) => node.id === id))));
for (const ability of abilities) {
for (const id of [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])]) rankIds.add(id);
}
const bySpellId = new Map(reference.spells.map((spell) => [spell.id, spell]));
const pending = [...rankIds];
for (let index = 0; index < pending.length; index += 1) {
for (const effect of bySpellId.get(pending[index])?.dbc?.effects ?? []) {
const id = effect.triggerSpellId;
if (id > 0 && !rankIds.has(id)) {
rankIds.add(id);
pending.push(id);
}
}
}
let spells = reference.spells.filter((spell) => options.spell
? spell.id === Number(options.spell)
: rankIds.has(spell.id) || (options.ability && needle(spell.name).includes(needle(options.ability)) && !requestedClass));
if (options.spell) {
if (!spells.length) throw new Error(`Spell ${options.spell} is not in this reference snapshot`);
const spellId = Number(options.spell);
nodes = nodes.filter((node) => node.rankDescriptions.some((rank) => rank.spellId === spellId));
abilities = abilities.filter((ability) => [...(ability.spellIds ?? []), ...(ability.rankSpellIds ?? [])].includes(spellId)
|| ability.nodeIds.some((id) => nodes.some((node) => node.id === id)));
if (requestedClass && !rankIds.has(spellId)) throw new Error(`Spell ${spellId} has no evidence for class ${requestedClass}`);
}
return {
baseline: reference.baseline,
classes: classes.map(({ classId, gameId, className, aliases, tabs }) => ({ classId, gameId, className, aliases, tabs })),
totals: { nodes: nodes.length, abilities: abilities.length, spells: spells.length },
nodes: nodes.slice(0, limit), abilities: abilities.slice(0, limit), spells: spells.slice(0, limit),
};
}
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { lookupReference, publishSnapshot, readSnapshot, stableJson } from "./snapshot.mjs";
function fixture() {
return {
baseline: { kind: "local-july", capturedAt: "2026-07-25" },
classes: [{ classId: 12, gameId: "barbarian", className: "Barbarian", tabs: [{ tabId: 31, tabName: "Brutality" }] }],
nodes: [{ id: 1, classId: 12, tabId: 31, name: "Ancestral Strike", rankDescriptions: [{ rank: 1, spellId: 100 }] }],
spells: [{ id: 100, name: "Ancestral Strike" }, { id: 101, name: "Ancestral Strike", rank: "Rank 2" }],
abilities: [{ key: "c12:ancestralstrike", classId: 12, name: "Ancestral Strike", spellIds: [100], rankSpellIds: [100, 101], nodeIds: [1] }],
};
}
test("stable serialization ignores object insertion order", () => {
assert.equal(stableJson({ b: 2, a: { y: 1, x: 2 } }), stableJson({ a: { x: 2, y: 1 }, b: 2 }));
});
test("snapshot rebuild is deterministic and corruption does not advance the pointer", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-"));
try {
const first = publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" });
const second = publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" });
assert.equal(first.snapshotId, second.snapshotId);
assert.deepEqual(readSnapshot(root).reference, fixture());
const pointer = fs.readFileSync(path.join(root, "current.json"), "utf8");
fs.writeFileSync(path.join(first.directory, "reference.json"), "{}\n");
assert.throws(() => readSnapshot(root), /Corrupt reference/);
assert.throws(() => publishSnapshot({ outputRoot: root, reference: fixture(), report: { warnings: [] }, markdown: "# Audit" }), /Corrupt reference/);
assert.equal(fs.readFileSync(path.join(root, "current.json"), "utf8"), pointer);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("invalid pointer cannot escape snapshot directory", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-"));
try {
fs.writeFileSync(path.join(root, "current.json"), '{"snapshotId":"../outside"}\n');
assert.throws(() => readSnapshot(root), /Invalid reference snapshot pointer/);
} finally {
assert.ok(root.startsWith(`${path.resolve(os.tmpdir())}${path.sep}`));
fs.rmSync(root, { recursive: true });
}
});
test("lookup supports class names, trees, rank spell IDs and ability names", () => {
assert.equal(lookupReference(fixture(), { class: "Barbarian", tree: "Brutality", ability: "ancestral" }).spells.length, 2);
assert.equal(lookupReference(fixture(), { class: "12", spell: "101" }).spells[0].id, 101);
assert.throws(() => lookupReference(fixture(), { class: "unknown" }), /Unknown CoA class/);
assert.throws(() => lookupReference(fixture(), { tree: "unknown" }), /Unknown tree/);
assert.throws(() => lookupReference(fixture(), { spell: "999" }), /not in this reference/);
});
test("class lookup follows triggered spell dependencies without treating them as learned abilities", () => {
const reference = fixture();
reference.spells[0].dbc = { effects: [{ triggerSpellId: 102 }] };
reference.spells.push({ id: 102, name: "Hidden strike", dbc: { effects: [{ triggerSpellId: 100 }] } });
const result = lookupReference(reference, { class: "barbarian", spell: "102" });
assert.equal(result.spells[0].id, 102);
assert.equal(result.abilities.length, 0);
assert.equal(result.nodes.length, 0);
});
+557
View File
@@ -0,0 +1,557 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveArchiveDescriptions } from "./flight-text.mjs";
import { fileURLToPath } from "node:url";
export const COA_ARCHIVE = Object.freeze({
repository: "srhinos/coa-datamine",
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
shortRevision: "5078f9ac2254",
capturedAt: "2026-08-12T21:02:51+00:00",
fetchManifestPath: "raw/talents/_fetch.json",
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
builderSnapshotSha256: "41a6ccd393c0648ae2b1fdc34fe94955d4fd13c0cd59040ad3860e64a8ff6c7a",
});
const defaultProjectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const expectedClassCount = 21;
const cacheSchemaVersion = 1;
const manifestName = "manifest.json";
function assertSafeRelativePath(value, context = "archive path") {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${context} must be a non-empty string`);
}
const normalized = value.replaceAll("\\", "/");
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
throw new Error(`${context} must be relative: ${value}`);
}
if (normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`${context} contains an unsafe path segment: ${value}`);
}
return normalized;
}
function classFileFromDescriptor(descriptor) {
let candidate = null;
if (typeof descriptor === "string") {
candidate = descriptor;
} else if (descriptor && typeof descriptor === "object") {
candidate = descriptor.path ?? descriptor.file ?? descriptor.filename ?? descriptor.href ?? null;
if (candidate === null) candidate = descriptor.slug ?? descriptor.key ?? descriptor.id ?? null;
}
if (candidate === null || candidate === undefined) {
throw new Error("CoA talent class index contains an entry without a file path or class key");
}
const relative = assertSafeRelativePath(String(candidate), "CoA talent class file reference");
let filePath;
if (relative.startsWith("data/talents/coa/classes/")) {
filePath = relative;
} else if (relative.startsWith("classes/")) {
filePath = `data/talents/coa/${relative}`;
} else if (relative.startsWith("data/talents/coa/") && !relative.slice("data/talents/coa/".length).includes("/")) {
filePath = relative;
} else if (relative.includes("/")) {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
} else {
filePath = `data/talents/coa/${relative.endsWith(".json") ? relative : `${relative}.json`}`;
}
if (!filePath.startsWith("data/talents/coa/") || !filePath.endsWith(".json")
|| filePath === "data/talents/coa/index.json"
|| filePath === "data/talents/coa/_meta.json") {
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
}
return filePath;
}
function isClassArchivePath(filePath) {
return filePath.startsWith("data/talents/coa/")
&& filePath.endsWith(".json")
&& filePath !== "data/talents/coa/index.json"
&& filePath !== "data/talents/coa/_meta.json";
}
function descriptorsFromIndex(index) {
const entries = index?.classes;
if (Array.isArray(entries)) return entries;
if (entries && typeof entries === "object") {
return Object.entries(entries).map(([key, value]) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") return { key, ...value };
return { key };
});
}
throw new Error("CoA talent index has no classes array or object");
}
function classPathsFromIndex(index) {
const descriptors = descriptorsFromIndex(index);
if (descriptors.length !== expectedClassCount) {
throw new Error(`CoA talent index expected ${expectedClassCount} class files, found ${descriptors.length}`);
}
const paths = descriptors.map(classFileFromDescriptor);
if (new Set(paths).size !== expectedClassCount) {
throw new Error("CoA talent index contains duplicate class file references");
}
return paths;
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
.update(bytes)
.digest("hex");
}
function parseJson(bytes, filePath) {
try {
return JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new Error(`CoA archive JSON is invalid at ${filePath}: ${error.message}`, { cause: error });
}
}
function isRegularBlob(entry) {
return entry?.type === "blob" && entry?.mode === "100644" && /^[0-9a-f]{40}$/i.test(entry.sha ?? "");
}
export function createSupplementCacheApi(archiveInput = COA_ARCHIVE) {
const archive = Object.freeze({ ...archiveInput });
for (const key of ["repository", "revision", "shortRevision", "capturedAt", "fetchManifestPath", "builderSnapshotPath", "builderSnapshotSha256"]) {
if (typeof archive[key] !== "string" || archive[key].length === 0) {
throw new TypeError(`archive.${key} must be a non-empty string`);
}
}
if (!/^[0-9a-f]{40}$/i.test(archive.revision) || !/^[0-9a-f]{7,40}$/i.test(archive.shortRevision)) {
throw new TypeError("archive revision values must be hexadecimal Git revisions");
}
if (!/^[0-9a-f]{64}$/i.test(archive.builderSnapshotSha256)) {
throw new TypeError("archive.builderSnapshotSha256 must be a SHA-256 hex digest");
}
const requiredPaths = [
archive.fetchManifestPath,
archive.builderSnapshotPath,
"data/talents/coa/index.json",
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
];
function archiveDirectory(cacheRoot) {
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) {
throw new TypeError("cacheRoot must be a non-empty directory path");
}
return path.join(path.resolve(cacheRoot), `archive-${archive.shortRevision}`);
}
function archiveUrls(filePath) {
const apiUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
const rawPath = filePath.split("/").map(encodeURIComponent).join("/");
const rawUrl = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/${rawPath}`;
return { apiUrl, rawUrl };
}
function parseCaptureManifest(bytes) {
const capture = parseJson(bytes, archive.fetchManifestPath);
if (capture?.capturedUtc !== archive.capturedAt) {
throw new Error(`CoA capture manifest has an unexpected capture time: ${String(capture?.capturedUtc)}`);
}
if (capture?.savedAs !== path.posix.basename(archive.builderSnapshotPath)) {
throw new Error(`CoA capture manifest has an unexpected savedAs value: ${String(capture?.savedAs)}`);
}
if (capture?.sha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA capture manifest does not pin builder SHA-256 ${archive.builderSnapshotSha256}`);
}
return capture;
}
function sourceRecord() {
return {
repository: `https://github.com/${archive.repository}`,
revision: archive.revision,
capturedAt: archive.capturedAt,
captureManifestPath: archive.fetchManifestPath,
builderSnapshotPath: archive.builderSnapshotPath,
builderSnapshotSha256: archive.builderSnapshotSha256,
};
}
function validateManifestShape(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new Error("CoA supplement manifest is not an object");
}
if (manifest.schemaVersion !== cacheSchemaVersion) {
throw new Error(`Unsupported CoA supplement cache schema: ${String(manifest.schemaVersion)}`);
}
if (manifest.source?.revision !== archive.revision) {
throw new Error("CoA supplement manifest points at an unexpected archive revision");
}
if (typeof manifest.generation !== "string" || !/^generation-[0-9a-f-]+$/.test(manifest.generation)) {
throw new Error("CoA supplement manifest has an invalid generation name");
}
if (!Array.isArray(manifest.files)) throw new Error("CoA supplement manifest has no file list");
const source = manifest.source;
if (source.captureManifestPath !== archive.fetchManifestPath
|| source.builderSnapshotPath !== archive.builderSnapshotPath
|| source.builderSnapshotSha256 !== archive.builderSnapshotSha256
|| source.capturedAt !== archive.capturedAt) {
throw new Error("CoA supplement manifest has unexpected capture metadata");
}
}
function expectedFilesFromManifest(manifest) {
validateManifestShape(manifest);
const expected = new Map();
for (const file of manifest.files) {
const filePath = assertSafeRelativePath(file?.path, "CoA supplement manifest file path");
if (expected.has(filePath)) throw new Error(`CoA supplement manifest repeats ${filePath}`);
if (!/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")) {
throw new Error(`CoA supplement manifest has an invalid Git blob SHA for ${filePath}`);
}
if (!/^[0-9a-f]{64}$/i.test(file.sha256 ?? "")) {
throw new Error(`CoA supplement manifest has an invalid SHA-256 for ${filePath}`);
}
if (!Number.isSafeInteger(file.size) || file.size < 0) {
throw new Error(`CoA supplement manifest has an invalid size for ${filePath}`);
}
expected.set(filePath, file);
}
for (const filePath of requiredPaths) {
if (!expected.has(filePath)) throw new Error(`CoA supplement manifest is missing ${filePath}`);
}
const classPaths = [...expected.keys()].filter(isClassArchivePath);
if (classPaths.length !== expectedClassCount || expected.size !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplement manifest must contain ${expectedClassCount} class files and ${requiredPaths.length} required files`);
}
return expected;
}
async function responseBytes(fetchImpl, url, accept) {
let response;
try {
response = await fetchImpl(url, { headers: { Accept: accept } });
} catch (error) {
throw new Error(`CoA supplemental source unavailable at ${url}: ${error.message}`, { cause: error });
}
if (!response || response.ok !== true) {
const status = response?.status === undefined ? "no response" : `HTTP ${response.status}`;
throw new Error(`CoA supplemental source unavailable at ${url} (${status})`);
}
try {
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
} catch (error) {
throw new Error(`CoA supplemental source read failed at ${url}: ${error.message}`, { cause: error });
}
throw new Error(`CoA supplemental source response has no readable body at ${url}`);
}
async function fetchTree(fetchImpl) {
const { apiUrl } = archiveUrls(archive.fetchManifestPath);
const bytes = await responseBytes(fetchImpl, apiUrl, "application/vnd.github+json");
const treeDocument = parseJson(bytes, "GitHub archive tree");
if (treeDocument.truncated === true) {
throw new Error("CoA archive tree was truncated; refusing an incomplete supplemental cache");
}
if (!Array.isArray(treeDocument.tree)) throw new Error("CoA archive tree response does not contain a tree array");
return new Map(treeDocument.tree.map((entry) => [entry.path, entry]));
}
function expectedFile(tree, filePath) {
const safePath = assertSafeRelativePath(filePath);
const entry = tree.get(safePath);
if (!isRegularBlob(entry)) throw new Error(`Pinned CoA archive tree is missing a regular file at ${safePath}`);
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
throw new Error(`Pinned CoA archive tree has no valid file size for ${safePath}`);
}
return { path: safePath, gitBlobSha: entry.sha.toLowerCase(), size: entry.size };
}
async function readManifest(archiveRoot) {
try {
return parseJson(await fs.readFile(path.join(archiveRoot, manifestName)), manifestName);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
async function readFileUnder(root, relativePath) {
const safePath = assertSafeRelativePath(relativePath);
const absoluteRoot = path.resolve(root);
const absolutePath = path.resolve(absoluteRoot, ...safePath.split("/"));
if (!absolutePath.startsWith(`${absoluteRoot}${path.sep}`)) {
throw new Error(`CoA archive path escapes its cache directory: ${relativePath}`);
}
return fs.readFile(absolutePath);
}
async function verifyFile(root, fileRecord) {
const bytes = await readFileUnder(root, fileRecord.path);
if (bytes.byteLength !== fileRecord.size) throw new Error(`CoA supplement size mismatch for ${fileRecord.path}`);
if (gitBlobSha(bytes) !== fileRecord.gitBlobSha.toLowerCase()) {
throw new Error(`CoA supplement Git blob checksum mismatch for ${fileRecord.path}`);
}
const actualSha256 = sha256(bytes);
if (actualSha256 !== fileRecord.sha256.toLowerCase()) {
throw new Error(`CoA supplement SHA-256 mismatch for ${fileRecord.path}`);
}
if (fileRecord.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (fileRecord.path === archive.builderSnapshotPath && actualSha256 !== archive.builderSnapshotSha256) {
throw new Error(`CoA builder snapshot does not match pinned SHA-256 ${archive.builderSnapshotSha256}`);
}
return bytes;
}
async function loadPublishedCache(archiveRoot) {
const manifest = await readManifest(archiveRoot);
if (manifest === null) return null;
const expected = expectedFilesFromManifest(manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
parseCaptureManifest(contents.get(archive.fetchManifestPath));
const indexPath = "data/talents/coa/index.json";
const index = parseJson(contents.get(indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (classPaths.slice().sort().join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement manifest class files do not match data/talents/coa/index.json");
}
const metadata = {
talents: parseJson(contents.get("data/talents/coa/_meta.json"), "data/talents/coa/_meta.json"),
provenance: parseJson(contents.get("raw/provenance.json"), "raw/provenance.json"),
clientSnapshot: parseJson(contents.get("raw/_snapshot.json"), "raw/_snapshot.json"),
spells: parseJson(contents.get("data/spells/_meta.json"), "data/spells/_meta.json"),
};
const descriptors = descriptorsFromIndex(index);
const rawClasses = classPaths.map((filePath) => parseJson(contents.get(filePath), filePath));
const hasTextReferences = rawClasses.some((definition) => definition.nodes?.some((node) => (
/^\$[0-9a-f]+$/i.test(node.description ?? "")
|| node.rankDescriptions?.some((rank) => /^\$[0-9a-f]+$/i.test(rank.description ?? ""))
)));
const classes = hasTextReferences
? resolveArchiveDescriptions(rawClasses, contents.get(archive.builderSnapshotPath).toString("utf8"))
: rawClasses;
const classFiles = classPaths.map((filePath, position) => ({ path: filePath, indexEntry: descriptors[position], data: classes[position] }));
return {
source: structuredClone(manifest.source),
classes,
classFiles,
metadata,
index,
manifest: structuredClone(manifest),
};
}
async function atomicWrite(filePath, bytes) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.tmp-${randomUUID()}`;
try {
await fs.writeFile(temporaryPath, bytes, { flag: "wx" });
await fs.rename(temporaryPath, filePath);
} catch (error) {
await fs.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
}
async function discardInvalidFile(filePath) {
try {
await fs.rm(filePath, { force: true });
} catch (error) {
throw new Error(`Could not discard invalid cached CoA supplement ${filePath}: ${error.message}`, { cause: error });
}
}
async function readAndVerifyStaged(stagingRoot, expected) {
let bytes;
try {
bytes = await readFileUnder(stagingRoot, expected.path);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
if (bytes.byteLength !== expected.size || gitBlobSha(bytes) !== expected.gitBlobSha) {
await discardInvalidFile(stagedPath);
return null;
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) {
try {
parseCaptureManifest(bytes);
} catch {
await discardInvalidFile(stagedPath);
return null;
}
}
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
await discardInvalidFile(stagedPath);
return null;
}
return { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
}
async function fetchAndPromoteFile(fetchImpl, stagingRoot, expected) {
const { rawUrl } = archiveUrls(expected.path);
const bytes = await responseBytes(fetchImpl, rawUrl, "application/octet-stream");
if (gitBlobSha(bytes) !== expected.gitBlobSha) {
throw new Error(`Downloaded CoA supplement Git blob checksum mismatch for ${expected.path}`);
}
const contentSha256 = sha256(bytes);
if (expected.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
throw new Error(`Downloaded CoA builder snapshot SHA-256 mismatch for ${expected.path}`);
}
const record = { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
await atomicWrite(stagedPath, bytes);
await verifyFile(stagingRoot, record);
return record;
}
async function stageFile(fetchImpl, stagingRoot, expected) {
const cached = await readAndVerifyStaged(stagingRoot, expected);
if (cached) return { record: cached, downloaded: false };
return { record: await fetchAndPromoteFile(fetchImpl, stagingRoot, expected), downloaded: true };
}
async function validateGeneration(generationRoot, manifest) {
const expected = expectedFilesFromManifest(manifest);
const contents = new Map();
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
const index = parseJson(contents.get("data/talents/coa/index.json"), "data/talents/coa/index.json");
const indexedClassPaths = classPathsFromIndex(index).sort();
const recordedClassPaths = [...expected.keys()]
.filter(isClassArchivePath)
.sort();
if (indexedClassPaths.join("\n") !== recordedClassPaths.join("\n")) {
throw new Error("CoA supplement generation class files do not match the pinned index");
}
parseCaptureManifest(contents.get(archive.fetchManifestPath));
for (const filePath of [
"data/talents/coa/_meta.json",
"raw/provenance.json",
"raw/_snapshot.json",
"data/spells/_meta.json",
...indexedClassPaths,
]) {
parseJson(contents.get(filePath), filePath);
}
}
async function writeManifest(archiveRoot, manifest) {
await atomicWrite(
path.join(archiveRoot, manifestName),
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
);
}
function cacheSummary(manifest, cacheDirectory, reused, downloadedFiles = []) {
return {
source: structuredClone(manifest.source),
cacheDirectory,
revision: archive.revision,
capturedAt: archive.capturedAt,
fileCount: manifest.files.length,
classCount: expectedClassCount,
reused,
downloadedFiles,
};
}
async function readSupplements({ cacheRoot } = {}) {
const archiveRoot = archiveDirectory(cacheRoot);
try {
await fs.access(archiveRoot);
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
try {
const contents = await loadPublishedCache(archiveRoot);
if (contents === null) throw new Error("published manifest is missing");
return contents;
} catch (error) {
throw new Error(`CoA supplement cache at ${archiveRoot} is corrupt or incomplete: ${error.message}`, { cause: error });
}
}
async function downloadSupplements({ cacheRoot, fetchImpl = globalThis.fetch, refresh = false } = {}) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a fetch-compatible function");
const archiveRoot = archiveDirectory(cacheRoot);
if (!refresh) {
try {
const cached = await loadPublishedCache(archiveRoot);
if (cached) return cacheSummary(cached.manifest, archiveRoot, true);
} catch {
// Keep the previous manifest and generation until a complete replacement is ready.
}
}
await fs.mkdir(archiveRoot, { recursive: true });
const tree = await fetchTree(fetchImpl);
const expected = new Map(requiredPaths.map((filePath) => [filePath, expectedFile(tree, filePath)]));
const stagingRoot = path.join(archiveRoot, "staging");
const stagedRecords = new Map();
const downloadedFiles = [];
for (const filePath of requiredPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const indexPath = "data/talents/coa/index.json";
const index = parseJson(await readFileUnder(stagingRoot, indexPath), indexPath);
const classPaths = classPathsFromIndex(index);
for (const filePath of classPaths) expected.set(filePath, expectedFile(tree, filePath));
for (const filePath of classPaths) {
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
stagedRecords.set(filePath, staged.record);
if (staged.downloaded) downloadedFiles.push(filePath);
}
const files = [...stagedRecords.values()].sort((left, right) => left.path.localeCompare(right.path));
if (files.length !== requiredPaths.length + expectedClassCount) {
throw new Error(`CoA supplemental cache expected ${requiredPaths.length + expectedClassCount} files but staged ${files.length}`);
}
parseCaptureManifest(await readFileUnder(stagingRoot, archive.fetchManifestPath));
const manifest = {
schemaVersion: cacheSchemaVersion,
source: sourceRecord(),
generation: `generation-${randomUUID()}`,
files,
};
// Validate every staged byte before moving the resumable cache into an immutable generation.
await validateGeneration(stagingRoot, manifest);
const generationRoot = path.join(archiveRoot, manifest.generation);
await fs.rename(stagingRoot, generationRoot);
await writeManifest(archiveRoot, manifest);
return cacheSummary(manifest, archiveRoot, false, downloadedFiles);
}
return Object.freeze({ readSupplements, downloadSupplements });
}
const defaultApi = createSupplementCacheApi();
export const readSupplements = defaultApi.readSupplements;
export const downloadSupplements = defaultApi.downloadSupplements;
export function getDefaultSupplementCacheRoot(projectRoot = defaultProjectRoot) {
return path.join(path.resolve(projectRoot), "data", "coa-reference", "cache");
}
+238
View File
@@ -0,0 +1,238 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { afterEach, beforeEach } from "node:test";
import { createSupplementCacheApi } from "./supplements.mjs";
const testArchiveBase = {
repository: "example/coa-archive-fixture",
revision: "a".repeat(40),
shortRevision: "a".repeat(12),
capturedAt: "2026-08-12T21:02:51+00:00",
fetchManifestPath: "raw/talents/_fetch.json",
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
};
let tempRoot;
let archive;
let fileBytes;
let treeUrl;
let api;
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function gitBlobSha(bytes) {
return createHash("sha1")
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
.update(bytes)
.digest("hex");
}
function pathIsWithin(parent, candidate) {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}
function makeFixture({ traversal = false } = {}) {
const html = Buffer.from("fixture builder snapshot payload\n", "utf8");
archive = {
...testArchiveBase,
builderSnapshotSha256: sha256(html),
};
const descriptors = Array.from({ length: 21 }, (_, index) => ({
name: `Class ${index + 1}`,
file: traversal && index === 0
? "../outside.json"
: index === 0
? "Barbarian.json"
: `Class-${String(index + 1).padStart(2, "0")}.json`,
}));
const captureManifest = Buffer.from(`${JSON.stringify({
capturedUtc: archive.capturedAt,
sha256: archive.builderSnapshotSha256,
savedAs: path.posix.basename(archive.builderSnapshotPath),
})}\n`, "utf8");
const entries = new Map([
[archive.fetchManifestPath, captureManifest],
[archive.builderSnapshotPath, html],
["data/talents/coa/index.json", Buffer.from(`${JSON.stringify({ classes: descriptors })}\n`, "utf8")],
["data/talents/coa/_meta.json", Buffer.from('{"capture":"talents"}\n', "utf8")],
["raw/provenance.json", Buffer.from('{"sources":[]}\n', "utf8")],
["raw/_snapshot.json", Buffer.from('{"commit":"fixture"}\n', "utf8")],
["data/spells/_meta.json", Buffer.from('{"capture":"spells"}\n', "utf8")],
]);
for (let index = 0; index < 21; index += 1) {
const fileName = index === 0 ? "Barbarian.json" : `Class-${String(index + 1).padStart(2, "0")}.json`;
const filePath = `data/talents/coa/${fileName}`;
entries.set(filePath, Buffer.from(`${JSON.stringify({ classNumber: index + 1, trees: [{ id: index + 1 }] })}\n`, "utf8"));
}
// These must remain untouched: only metadata and the pinned capture are selected.
entries.set("data/spells/123456.json", Buffer.from("large spell payload"));
entries.set("raw/Spell.dbc", Buffer.from("client dbc"));
fileBytes = entries;
const tree = [...entries].map(([filePath, bytes]) => ({
path: filePath,
mode: "100644",
type: "blob",
sha: gitBlobSha(bytes),
size: bytes.byteLength,
}));
treeUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
api = createSupplementCacheApi(archive);
return { tree };
}
function createFetch({ tree, failPath = null, failOnce = false, calls = [] } = {}) {
let didFail = false;
return {
calls,
fetch: async (url) => {
calls.push(url);
if (url === treeUrl) {
return response(Buffer.from(JSON.stringify({ truncated: false, tree }), "utf8"));
}
const rawPrefix = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/`;
assert.ok(url.startsWith(rawPrefix), `unexpected URL ${url}`);
const filePath = url.slice(rawPrefix.length).split("/").map(decodeURIComponent).join("/");
if (filePath === failPath && (!failOnce || !didFail)) {
didFail = true;
return { ok: false, status: 503 };
}
const bytes = fileBytes.get(filePath);
if (!bytes) return { ok: false, status: 404 };
return response(bytes);
},
};
}
function response(bytes) {
return { ok: true, arrayBuffer: async () => Uint8Array.from(bytes).buffer };
}
async function freshRoot() {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "coa-supplements-test-"));
return path.join(tempRoot, "cache");
}
beforeEach(() => {
tempRoot = null;
});
afterEach(async () => {
if (tempRoot) {
assert.ok(pathIsWithin(os.tmpdir(), tempRoot), "cleanup path must stay inside the OS temporary directory");
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
test("downloads the selected archive files and reuses a valid cache offline", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
const firstFetch = createFetch({ tree });
const first = await api.downloadSupplements({ cacheRoot, fetchImpl: firstFetch.fetch });
assert.equal(first.reused, false);
assert.equal(first.fileCount, 28);
assert.equal(first.classCount, 21);
assert.equal(firstFetch.calls.length, 29);
assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/data/spells/123456.json`), false);
assert.equal(firstFetch.calls.includes(`https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/raw/Spell.dbc`), false);
const second = await api.downloadSupplements({
cacheRoot,
fetchImpl: async () => {
throw new Error("valid cache should avoid the network");
},
});
const supplements = await api.readSupplements({ cacheRoot });
assert.equal(second.reused, true);
assert.equal(supplements.classes.length, 21);
assert.deepEqual(supplements.metadata, {
talents: { capture: "talents" },
provenance: { sources: [] },
clientSnapshot: { commit: "fixture" },
spells: { capture: "spells" },
});
assert.equal(supplements.classFiles[0].path, "data/talents/coa/Barbarian.json");
assert.deepEqual(supplements.classFiles[0].data, { classNumber: 1, trees: [{ id: 1 }] });
});
test("resumes verified files after an interrupted download", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
const interrupted = createFetch({ tree, failPath: "data/talents/coa/Class-03.json" });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: interrupted.fetch }),
/HTTP 503/,
);
await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/);
const resumedFetch = createFetch({ tree });
const summary = await api.downloadSupplements({ cacheRoot, fetchImpl: resumedFetch.fetch });
assert.equal(summary.reused, false);
assert.equal(summary.downloadedFiles.length, 19);
const supplements = await api.readSupplements({ cacheRoot });
assert.equal(supplements.classes.length, 21);
});
test("offline reads refuse corrupted files, and an online run repairs them", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`);
const manifest = JSON.parse(await fs.readFile(path.join(archiveRoot, "manifest.json"), "utf8"));
const generationFile = path.join(archiveRoot, manifest.generation, "data", "talents", "coa", "Barbarian.json");
await fs.writeFile(generationFile, "corrupted\n", "utf8");
await assert.rejects(api.readSupplements({ cacheRoot }), /size mismatch|checksum mismatch/);
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const repaired = await api.readSupplements({ cacheRoot });
assert.deepEqual(repaired.classes[0], { classNumber: 1, trees: [{ id: 1 }] });
});
test("reports an unavailable source without publishing a partial manifest", async () => {
makeFixture();
const cacheRoot = await freshRoot();
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: async () => { throw new Error("offline"); } }),
/CoA supplemental source unavailable/,
);
await assert.rejects(api.readSupplements({ cacheRoot }), /incomplete.*published manifest is missing/);
});
test("keeps the last valid manifest and generation when a refresh fails", async () => {
const { tree } = makeFixture();
const cacheRoot = await freshRoot();
await api.downloadSupplements({ cacheRoot, fetchImpl: createFetch({ tree }).fetch });
const archiveRoot = path.join(cacheRoot, `archive-${archive.shortRevision}`);
const manifestPath = path.join(archiveRoot, "manifest.json");
const previousManifest = await fs.readFile(manifestPath);
const previous = JSON.parse(previousManifest.toString("utf8"));
const previousClassPath = path.join(archiveRoot, previous.generation, "data", "talents", "coa", "Barbarian.json");
const previousClass = await fs.readFile(previousClassPath);
const interruptedRefresh = createFetch({ tree, failPath: "data/talents/coa/Class-04.json" });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: interruptedRefresh.fetch, refresh: true }),
/HTTP 503/,
);
assert.deepEqual(await fs.readFile(manifestPath), previousManifest);
assert.deepEqual(await fs.readFile(previousClassPath), previousClass);
assert.equal((await api.readSupplements({ cacheRoot })).classes.length, 21);
});
test("rejects index path traversal before requesting a class file", async () => {
const { tree } = makeFixture({ traversal: true });
const cacheRoot = await freshRoot();
const source = createFetch({ tree });
await assert.rejects(
api.downloadSupplements({ cacheRoot, fetchImpl: source.fetch }),
/unsafe path segment/,
);
assert.equal(source.calls.some((url) => url.includes("outside.json")), false);
assert.equal(await fs.stat(path.join(tempRoot, "outside.json")).then(() => true, () => false), false);
});
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
const dbcDirectory = (root, patch, fileName) => path.join(root, patch, "DBFilesClient", fileName);
function firstExisting(candidates, exists) {
return candidates.find((candidate) => exists(candidate)) ?? candidates.at(-1);
}
/** Resolve independent source paths for classic and CoA data in the combined importer. */
export function resolveCoaSourcePaths({
extractedClientRoot,
legacyExtractedRoot,
classicSpellOverride,
exists = fs.existsSync,
}) {
const area52Spell = dbcDirectory(path.join(extractedClientRoot, "area-52"), "patch-D", "Spell.dbc");
const globalSpell = dbcDirectory(extractedClientRoot, "patch-T", "Spell.dbc");
const legacySpell = dbcDirectory(legacyExtractedRoot, "patch-T", "Spell.dbc");
const area52SkillLineAbility = dbcDirectory(
path.join(extractedClientRoot, "area-52"),
"patch-D",
"SkillLineAbility.dbc",
);
const globalSkillLineAbility = dbcDirectory(extractedClientRoot, "patch-M", "SkillLineAbility.dbc");
const legacySkillLineAbility = dbcDirectory(legacyExtractedRoot, "patch-M", "SkillLineAbility.dbc");
const classicSpellDbcPath = classicSpellOverride
? path.resolve(classicSpellOverride)
: firstExisting([area52Spell, globalSpell, legacySpell], exists);
const classicSiblingSkillLineAbility = path.join(path.dirname(classicSpellDbcPath), "SkillLineAbility.dbc");
return {
classicSpellDbcPath,
classicSkillLineAbilityDbcPath: exists(classicSiblingSkillLineAbility)
? classicSiblingSkillLineAbility
: firstExisting([area52SkillLineAbility, globalSkillLineAbility, legacySkillLineAbility], exists),
coaSpellDbcPath: firstExisting([globalSpell, legacySpell], exists),
coaSkillLineAbilityDbcPath: firstExisting([globalSkillLineAbility, legacySkillLineAbility], exists),
};
}
/** Collect DBC IDs whose classic metadata must remain available beside CoA metadata. */
export function collectRealmSpellIds({
classicClassIds,
coaClassIds,
classSpellRows,
classicRankCandidates,
coaRankCandidates,
coaEntrySpellIds,
}) {
const classicIds = new Set();
const coaIds = new Set(coaEntrySpellIds ?? []);
const classicClasses = new Set(classicClassIds);
const coaClasses = new Set(coaClassIds);
for (const row of classSpellRows) {
if (classicClasses.has(row.classId)) classicIds.add(row.id);
if (coaClasses.has(row.classId)) coaIds.add(row.id);
}
for (const candidate of classicRankCandidates) classicIds.add(candidate.id);
for (const candidate of coaRankCandidates) coaIds.add(candidate.id);
return { classicIds, coaIds };
}
/** Return IDs that need a separate CoA wrapper to protect classic metadata. */
export function sharedRealmSpellIds(classicIds, coaIds) {
return [...coaIds].filter((spellId) => classicIds.has(spellId)).sort((left, right) => left - right);
}
/** Include linked base-client payloads, including effect 168's miscValue spell. */
export function expandCoaSpellIds(buffer, initialIds) {
if (buffer.length < 20 || buffer.toString("ascii", 0, 4) !== "WDBC" || buffer.readUInt32LE(8) < 234) throw new Error("CoA Spell.dbc does not expose the 234-field WDBC schema");
const recordCount = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
if (recordSize < 234 * 4 || 20 + recordCount * recordSize > buffer.length) throw new Error("CoA Spell.dbc has invalid record lengths");
const rows = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const id = buffer.readUInt32LE(base);
if (!rows.has(id)) rows.set(id, base);
}
const ids = new Set(initialIds);
const queue = [...ids];
for (let cursor = 0; cursor < queue.length; cursor += 1) {
const base = rows.get(queue[cursor]);
if (base === undefined) continue;
for (let index = 0; index < 3; index += 1) {
const childId = buffer.readUInt32LE(base + (116 + index) * 4)
|| (buffer.readUInt32LE(base + (71 + index) * 4) === 168 ? buffer.readUInt32LE(base + (110 + index) * 4) : 0);
if (childId > 0 && !ids.has(childId)) {
ids.add(childId);
queue.push(childId);
}
}
}
return ids;
}
+114
View File
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import {
collectRealmSpellIds,
expandCoaSpellIds,
resolveCoaSourcePaths,
sharedRealmSpellIds,
} from "./coa-source-policy.mjs";
test("CoA payload closure includes effect-168 links, cycles and absent IDs without overlay fallback", () => {
const buffer = Buffer.alloc(20 + 3 * 234 * 4);
buffer.write("WDBC");
buffer.writeUInt32LE(3, 4);
buffer.writeUInt32LE(234, 8);
buffer.writeUInt32LE(234 * 4, 12);
const write = (row, field, value) => buffer.writeUInt32LE(value, 20 + (row * 234 + field) * 4);
write(0, 0, 100);
write(0, 71, 168);
write(0, 110, 200);
write(1, 0, 200);
write(1, 116, 300);
write(2, 0, 300);
write(2, 116, 100);
write(2, 117, 400);
assert.deepEqual([...expandCoaSpellIds(buffer, [100])], [100, 200, 300, 400]);
assert.throws(() => expandCoaSpellIds(Buffer.from("invalid"), [100]), /234-field/);
});
test("CoA source resolution selects global patch-T and patch-M while classic keeps Area52 priority", () => {
const root = path.resolve("fixture/client-current");
const legacy = path.resolve("fixture/legacy");
const exists = new Set([
path.join(root, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"),
path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(root, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
]);
const sources = resolveCoaSourcePaths({
extractedClientRoot: root,
legacyExtractedRoot: legacy,
exists: (candidate) => exists.has(candidate),
});
assert.equal(sources.classicSpellDbcPath, path.join(root, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.classicSkillLineAbilityDbcPath, path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"));
assert.equal(sources.coaSpellDbcPath, path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.coaSkillLineAbilityDbcPath, path.join(root, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"));
});
test("CoA sources fall back only to global legacy files, never Area52", () => {
const root = path.resolve("fixture/client-current");
const legacy = path.resolve("fixture/legacy");
const exists = new Set([
path.join(root, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"),
path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
]);
const sources = resolveCoaSourcePaths({
extractedClientRoot: root,
legacyExtractedRoot: legacy,
exists: (candidate) => exists.has(candidate),
});
assert.equal(sources.coaSpellDbcPath, path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.coaSkillLineAbilityDbcPath, path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"));
});
test("classic SkillLineAbility follows the selected Spell.dbc when that sibling exists", () => {
const root = path.resolve("fixture/client-current");
const legacy = path.resolve("fixture/legacy");
const exists = new Set([
path.join(root, "area-52", "patch-D", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(root, "patch-T", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(root, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
path.join(legacy, "patch-T", "DBFilesClient", "Spell.dbc"),
path.join(legacy, "patch-M", "DBFilesClient", "SkillLineAbility.dbc"),
]);
const sources = resolveCoaSourcePaths({
extractedClientRoot: root,
legacyExtractedRoot: legacy,
exists: (candidate) => exists.has(candidate),
});
assert.equal(sources.classicSpellDbcPath, path.join(root, "patch-T", "DBFilesClient", "Spell.dbc"));
assert.equal(sources.classicSkillLineAbilityDbcPath, path.join(root, "patch-T", "DBFilesClient", "SkillLineAbility.dbc"));
});
test("realm spell ownership protects classic references and includes CoA tree ranks", () => {
const { classicIds, coaIds } = collectRealmSpellIds({
classicClassIds: [11],
coaClassIds: [27],
classSpellRows: [
{ id: 100, classId: 11 },
{ id: 200, classId: 27 },
{ id: 300, classId: 27 },
{ id: 400, classId: 11 },
{ id: 400, classId: 27 },
],
classicRankCandidates: [{ id: 101 }],
coaRankCandidates: [{ id: 201 }],
coaEntrySpellIds: [300, 301],
});
assert.deepEqual([...classicIds].sort(), [100, 101, 400]);
assert.deepEqual([...coaIds].sort(), [200, 201, 300, 301, 400]);
assert.ok(classicIds.has(100) && coaIds.has(300) && classicIds.has(400) && coaIds.has(400));
assert.deepEqual(sharedRealmSpellIds(classicIds, coaIds), [400]);
assert.deepEqual(sharedRealmSpellIds(new Set([401]), new Set([401])), [401]);
});
+19 -5
View File
@@ -1,9 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { resolveSourceRoots } from "./coa-reference/local-source.mjs";
const projectRoot = path.resolve(import.meta.dirname, "..");
const clientRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor", "client-current");
const spellPath = path.join(clientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
const { extractedRoot } = resolveSourceRoots({ projectRoot });
const clientRoot = path.join(extractedRoot, "client-current");
const spellPath = path.join(clientRoot, "patch-T", "DBFilesClient", "Spell.dbc");
const supportRoot = path.join(clientRoot, "patch-S", "DBFilesClient");
const runtimePath = path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json");
const outputPath = path.join(projectRoot, "src", "game", "generated", "coaTriggeredSpellCatalog.json");
@@ -176,19 +179,30 @@ const castTimes = dbcNumberTable(path.join(supportRoot, "SpellCastTimes.dbc"), 1
const durations = dbcNumberTable(path.join(supportRoot, "SpellDuration.dbc"), 3);
const radii = dbcNumberTable(path.join(supportRoot, "SpellRadius.dbc"), 3, true);
const ranges = dbcRangeTable(path.join(supportRoot, "SpellRange.dbc"));
const parseSpell = spellParser(fs.readFileSync(spellPath), castTimes, durations, radii, ranges);
const spellBytes = fs.readFileSync(spellPath);
const parseSpell = spellParser(spellBytes, castTimes, durations, radii, ranges);
const pending = [...referencedSpellIds(runtime)];
const spells = new Map();
const visited = new Set();
while (pending.length) {
const id = pending.shift();
if (spells.has(id)) continue;
if (visited.has(id)) continue;
visited.add(id);
const spell = parseSpell(id);
if (!spell) continue;
spells.set(id, spell);
for (const effect of spell.effects) {
if (effect.triggerSpellId > 0 && !spells.has(effect.triggerSpellId)) pending.push(effect.triggerSpellId);
if (effect.effectType === 168 && effect.miscValue > 0) pending.push(effect.miscValue);
}
}
fs.writeFileSync(outputPath, `${JSON.stringify({ schemaVersion: 1, spells: [...spells.values()] })}\n`, "utf8");
const previous = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const classicSpells = previous.classicSpells ?? previous.spells;
fs.writeFileSync(outputPath, `${JSON.stringify({
schemaVersion: 1,
source: { spellDbcPath: "client-current/patch-T/DBFilesClient/Spell.dbc", sha256: createHash("sha256").update(spellBytes).digest("hex") },
spells: [...spells.values()].sort((left, right) => left.id - right.id),
classicSpells,
})}\n`, "utf8");
console.log(`Wrote ${outputPath} with ${spells.size} triggered spell records.`);
+6 -2
View File
@@ -13,7 +13,8 @@ const wow335GeneratedPath = path.join(projectRoot, "src", "game", "wow335Ability
const manastormAffixesPath = path.join(projectRoot, "src", "game", "generated", "manastormAffixes.json");
const manastormRuntimePath = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
const defaultSource = path.resolve(projectRoot, "..", "LadiksMPQEditor", "patch-I", "Interface", "Icons");
const sourceDirectory = path.resolve(process.argv[2] ?? defaultSource);
const sourceDirectory = path.resolve(process.argv.slice(2).find((argument) => !argument.startsWith("--")) ?? defaultSource);
const missingOnly = process.argv.includes("--missing-only");
const outputDirectory = path.join(projectRoot, "public", "assets", "ui", "spells");
const catalogSources = catalogPaths.map((catalogPath) => fs.readFileSync(catalogPath, "utf8"));
@@ -278,8 +279,10 @@ function encodePng({ width, height, pixels }) {
fs.mkdirSync(outputDirectory, { recursive: true });
const fallbackSourcePath = path.join(sourceDirectory, "inv_misc_questionmark.blp");
let fallbacks = 0;
let exported = 0;
const fallbackDetails = [];
for (const basename of iconBasenames) {
if (missingOnly && fs.existsSync(path.join(outputDirectory, `${basename}.png`))) continue;
const sourcePath = path.join(sourceDirectory, `${basename}.blp`);
const missingSource = !fs.existsSync(sourcePath);
const resolvedSourcePath = missingSource ? fallbackSourcePath : sourcePath;
@@ -297,10 +300,11 @@ for (const basename of iconBasenames) {
png = encodePng(decodeBlp2(fs.readFileSync(fallbackSourcePath), fallbackSourcePath));
}
fs.writeFileSync(path.join(outputDirectory, `${basename}.png`), png);
exported += 1;
}
console.log(JSON.stringify({
exported: iconBasenames.length,
exported,
fallbacks,
fallbackDetails,
sourceDirectory,
+5 -2
View File
@@ -17,6 +17,7 @@ import { TALENT_NODES, TALENT_TREES } from "../src/game/talentCatalog";
const projectRoot = resolve(import.meta.dirname, "..");
const talentOutputDirectory = resolve(projectRoot, "src/game/generated/talentUi");
const coaAbilityOutputDirectory = resolve(projectRoot, "src/game/generated/coaAbilities");
const coaOnly = process.argv.includes("--coa-only");
function writeJson(file: string, value: unknown): void {
writeFileSync(file, `${JSON.stringify(value)}\n`, "utf8");
@@ -33,9 +34,10 @@ function removeStaleJson(directory: string, expectedNames: ReadonlySet<string>):
mkdirSync(talentOutputDirectory, { recursive: true });
const talentClassIds = new Set<ClassId>(TALENT_TREES.map((tree) => tree.classId));
const talentFileNames = new Set([...talentClassIds].map((classId) => `${classId}.json`));
removeStaleJson(talentOutputDirectory, talentFileNames);
if (!coaOnly) removeStaleJson(talentOutputDirectory, talentFileNames);
for (const classId of talentClassIds) {
if (coaOnly && !COA_CLASS_IDS.includes(classId as CoaClassId)) continue;
const trees = TALENT_TREES.filter((tree) => tree.classId === classId).map((tree) => ({
id: tree.id,
classId: tree.classId,
@@ -103,4 +105,5 @@ for (const classId of COA_CLASS_IDS) {
});
}
console.log(`Generated ${talentFileNames.size} talent UI shards and ${coaAbilityFileNames.size} Conquest ability shards from ${basename(coaAbilityInput)}.`);
const writtenTalentCount = coaOnly ? [...talentClassIds].filter((classId) => COA_CLASS_IDS.includes(classId as CoaClassId)).length : talentFileNames.size;
console.log(`Generated ${writtenTalentCount} talent UI shards and ${coaAbilityFileNames.size} Conquest ability shards from ${basename(coaAbilityInput)}.`);
+26 -3
View File
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { CLASSES, type CoaClassId } from "../src/app/characterCatalog";
import {
@@ -40,7 +40,7 @@ const coaBudgetsByClass = Object.fromEntries(CLASSES
}];
}));
writeJson("src/game/generated/talentRuntimeCatalog.json", {
const talentSnapshot = {
schemaVersion: 1,
maximumCoaLevel: COA_LIVE_SOURCE.maximumLevel,
coaBudgetsByClass,
@@ -62,4 +62,27 @@ writeJson("src/game/generated/talentRuntimeCatalog.json", {
requiredLevel: node.requiredLevel,
isPassive: node.isPassive,
})),
});
};
if (process.argv.includes("--coa-only")) {
const previous = JSON.parse(readFileSync("src/game/generated/talentRuntimeCatalog.json", "utf8"));
const coaClasses = new Set(CLASSES.filter((definition) => definition.mode === "conquest").map((definition) => definition.id));
const coaTrees = new Set(talentSnapshot.trees.filter((tree) => coaClasses.has(tree.classId)).map((tree) => tree.id));
function replaceCoa<T>(oldRecords: T[], newRecords: T[], isCoa: (record: T) => boolean): T[] {
const result: T[] = [];
let inserted = false;
for (const record of oldRecords) {
if (!isCoa(record)) result.push(record);
else if (!inserted) {
result.push(...newRecords.filter(isCoa));
inserted = true;
}
}
if (!inserted) result.push(...newRecords.filter(isCoa));
return result;
}
talentSnapshot.trees = replaceCoa(previous.trees, talentSnapshot.trees, (tree) => coaClasses.has(tree.classId));
talentSnapshot.nodes = replaceCoa(previous.nodes, talentSnapshot.nodes, (node) => coaTrees.has(node.treeId));
}
writeJson("src/game/generated/talentRuntimeCatalog.json", talentSnapshot);
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultArchive = path.resolve(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ");
const defaultOutput = path.join(projectRoot, "src", "game", "generated", "wow335PetFamilyData.json");
const archivePath = path.resolve(process.argv[2] ?? defaultArchive);
const outputPath = path.resolve(process.argv[3] ?? defaultOutput);
function assertDbc(buffer, name, minimumFields) {
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
if (fieldCount < minimumFields || recordSize < fieldCount * 4) {
throw new Error(`${name} has an unsupported schema.`);
}
}
function rowCount(buffer) {
return buffer.readUInt32LE(4);
}
function recordSize(buffer) {
return buffer.readUInt32LE(12);
}
function recordBase(index, size) {
return 20 + index * size;
}
function textAt(buffer, base, firstField, shift = 0) {
const count = rowCount(buffer);
const stringsOffset = 20 + count * recordSize(buffer);
const stringSize = buffer.readUInt32LE(16);
for (let locale = 0; locale < 16; locale += 1) {
const offset = buffer.readUInt32LE(base + (firstField + shift + locale) * 4);
if (!offset || offset >= stringSize) continue;
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
const value = buffer.toString("utf8", start, end < 0 ? buffer.length : end);
if (value) return value;
}
return "";
}
function spellRows(buffer, durations) {
assertDbc(buffer, "Spell.dbc", 234);
const count = rowCount(buffer);
const size = recordSize(buffer);
const fields = buffer.readUInt32LE(8);
const shift = fields >= 239 ? 6 : 0;
const shifted = (field) => field >= 77 ? field + shift : field;
const rows = new Map();
for (let index = 0; index < count; index += 1) {
const base = recordBase(index, size);
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
const signed = (field) => buffer.readInt32LE(base + field * 4);
const float = (field) => buffer.readFloatLE(base + field * 4);
const effects = Array.from({ length: 3 }, (_, effectIndex) => {
const effectType = unsigned(71 + effectIndex);
const auraType = unsigned(shifted(95 + effectIndex));
const basePoints = signed(shifted(80 + effectIndex)) + 1;
const triggerSpellId = unsigned(shifted(116 + effectIndex));
return {
index: effectIndex,
effectType,
auraType,
mechanic: unsigned(shifted(83 + effectIndex)),
basePoints,
dieSides: signed(74 + effectIndex),
pointsPerLevel: float(shifted(77 + effectIndex)),
periodMs: unsigned(shifted(98 + effectIndex)),
miscValue: signed(shifted(110 + effectIndex)),
triggerSpellId,
coefficient: float(shifted(229 + effectIndex)),
};
}).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId);
const attributes0 = unsigned(4);
rows.set(unsigned(0), {
spellId: unsigned(0),
name: textAt(buffer, base, 136, shift),
rankText: textAt(buffer, base, 153, shift),
mechanic: unsigned(3),
requiredLevel: unsigned(39),
passive: Boolean(attributes0 & 0x40),
attributesEx1: unsigned(5),
powerType: signed(41),
powerCost: unsigned(42),
cooldownMs: unsigned(29),
categoryCooldownMs: unsigned(30),
globalCooldownMs: unsigned(shifted(206)),
auraInterruptFlags: unsigned(32),
schoolMask: unsigned(shifted(225)),
procFlags: unsigned(34),
procChance: unsigned(35),
procCharges: unsigned(36),
durationMs: durations.get(unsigned(40)) ?? 0,
effects,
});
}
return rows;
}
function durationTable(buffer) {
assertDbc(buffer, "SpellDuration.dbc", 4);
const durations = new Map();
const size = recordSize(buffer);
for (let index = 0; index < rowCount(buffer); index += 1) {
const base = recordBase(index, size);
durations.set(buffer.readUInt32LE(base), buffer.readInt32LE(base + 3 * 4));
}
return durations;
}
function familyRows(buffer) {
assertDbc(buffer, "CreatureFamily.dbc", 28);
const families = [];
const size = recordSize(buffer);
for (let index = 0; index < rowCount(buffer); index += 1) {
const base = recordBase(index, size);
const minScale = buffer.readFloatLE(base + 1 * 4);
const maxScale = buffer.readFloatLE(base + 3 * 4);
const petTalentType = buffer.readInt32LE(base + 8 * 4);
const name = textAt(buffer, base, 10);
if (!name) continue;
families.push({
id: buffer.readUInt32LE(base),
name,
minScale,
minScaleLevel: buffer.readUInt32LE(base + 2 * 4),
maxScale,
maxScaleLevel: buffer.readUInt32LE(base + 4 * 4),
petFoodMask: buffer.readUInt32LE(base + 7 * 4),
petTalentType,
familySkillLineId: buffer.readUInt32LE(base + 5 * 4),
commonPetSkillLineId: buffer.readUInt32LE(base + 6 * 4),
categoryEnumId: buffer.readUInt32LE(base + 9 * 4),
});
}
return families;
}
function skillRows(buffer, lineIds, spells) {
assertDbc(buffer, "SkillLineAbility.dbc", 14);
const result = new Map([...lineIds].map((id) => [id, []]));
const size = recordSize(buffer);
for (let index = 0; index < rowCount(buffer); index += 1) {
const base = recordBase(index, size);
const lineId = buffer.readUInt32LE(base + 1 * 4);
if (!result.has(lineId)) continue;
const spellId = buffer.readUInt32LE(base + 2 * 4);
const spell = spells.get(spellId);
if (!spell) continue;
result.get(lineId).push({
...spell,
acquireMethod: buffer.readUInt32LE(base + 9 * 4),
raceMask: buffer.readUInt32LE(base + 3 * 4),
classMask: buffer.readUInt32LE(base + 4 * 4),
minSkill: buffer.readUInt32LE(base + 7 * 4),
supersededBySpellId: buffer.readUInt32LE(base + 8 * 4),
});
}
for (const rows of result.values()) {
rows.sort((left, right) => left.requiredLevel - right.requiredLevel || left.spellId - right.spellId);
}
return result;
}
const archive = MpqArchive.open(archivePath);
let output;
try {
const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`);
const familyBuffer = extract("CreatureFamily");
const skillLineBuffer = extract("SkillLineAbility");
const spellBuffer = extract("Spell");
const durations = durationTable(extract("SpellDuration"));
const spells = spellRows(spellBuffer, durations);
const families = familyRows(familyBuffer);
const lineIds = new Set(families.flatMap((family) => [family.familySkillLineId, family.commonPetSkillLineId]).filter(Boolean));
const skills = skillRows(skillLineBuffer, lineIds, spells);
output = {
source: {
product: "World of Warcraft",
build: "3.3.5a.12340",
archive: path.relative(projectRoot, archivePath).replaceAll("\\", "/"),
familyRows: rowCount(familyBuffer),
skillLineAbilityRows: rowCount(skillLineBuffer),
spellRows: rowCount(spellBuffer),
},
families: families.map((family) => ({
...family,
hunterPet: family.petTalentType >= 0,
familySkills: skills.get(family.familySkillLineId) ?? [],
commonPetSkills: skills.get(family.commonPetSkillLineId) ?? [],
})),
};
} finally {
archive.close();
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify(output)}\n`);
console.log(`Generated ${output.families.filter((family) => family.hunterPet).length} Hunter pet family records at ${outputPath}.`);
+621
View File
@@ -0,0 +1,621 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve(process.env.HUNTER_CAT_PLAYTEST_DIR || 'playtest-artifacts/hunter-cat');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-cat-'));
function cleanupDataDir() {
const resolvedDataDir = path.resolve(dataDir);
const resolvedTempDir = path.resolve(os.tmpdir());
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|| !path.basename(resolvedDataDir).startsWith('healer-man-hunter-cat-')) {
throw new Error(`Refusing recursive cleanup outside the dedicated Hunter QA temp directory: ${resolvedDataDir}`);
}
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
}
const assetDirectory = path.join(staticDir, 'assets');
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
cleanupDataDir();
throw new Error('A fresh production build is required before this playtest. Build dist, then rerun.');
}
const assets = fs.readdirSync(assetDirectory);
const mainModule = `/assets/${assets.find((file) => /^index-.*\.js$/.test(file))}`;
const combatModule = `/assets/${assets.find((file) => /^combatStore-.*\.js$/.test(file))}`;
if (mainModule.endsWith('undefined') || combatModule.endsWith('undefined')) {
cleanupDataDir();
throw new Error('Could not locate the production app and combat store modules in dist/assets.');
}
fs.mkdirSync(directory, { recursive: true });
async function storeSnapshot(page) {
return page.evaluate(async () => {
const { shell, combat, game } = await window.hunterFamilyStores();
const roster = shell.getState();
const runtime = combat.getState();
const activePet = runtime.summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
const activeProfile = roster.characters.find((character) => character.id === roster.selectedCharacterId);
return {
phase: roster.phase,
selectedCharacter: activeProfile ? {
name: activeProfile.name,
classId: activeProfile.classId,
level: activeProfile.level,
specializationId: activeProfile.specializationId,
hunterPetFamily: activeProfile.hunterPetFamily ?? null,
} : null,
combat: {
classId: runtime.classId,
level: runtime.level,
family: runtime.hunterPetFamily,
activePet: activePet ? {
id: activePet.id,
ownerActorId: activePet.ownerActorId,
name: activePet.name,
family: activePet.hunterPet.family,
alive: activePet.health > 0,
health: activePet.health,
primary: {
name: activePet.hunterPet.abilityName,
spellId: activePet.hunterPet.spellId,
rank: activePet.hunterPet.rank,
},
rake: activePet.hunterPet.rake ? {
spellId: activePet.hunterPet.rake.spellId,
rank: activePet.hunterPet.rake.rank,
requiredLevel: activePet.hunterPet.rake.requiredLevel,
} : null,
prowl: activePet.hunterPet.prowl ? {
spellId: activePet.hunterPet.prowl.spellId,
rank: activePet.hunterPet.prowl.rank,
requiredLevel: activePet.hunterPet.prowl.requiredLevel,
} : null,
shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null,
furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null,
command: activePet.hunterPetCommand ?? 'follow',
reactMode: activePet.hunterPetReactMode ?? 'defensive',
growlAutocast: Boolean(activePet.hunterPetGrowlAutocastEnabled),
} : null,
},
assetStatus: game.getState().assetStatus,
};
});
}
async function waitForGameReady(page) {
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
const isRenderedAndReady = async () => page.evaluate(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
});
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
}, null, { timeout: 180_000 });
await page.waitForTimeout(1_500);
if (!await isRenderedAndReady()) {
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
}, null, { timeout: 180_000 });
}
await page.locator('canvas').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 }).catch(() => undefined);
}
async function renderedReadiness(page) {
return page.evaluate(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return {
assetStatus: game.getState().assetStatus,
dungeonIntroActive: game.getState().dungeonIntroActive,
loadingScreenVisible: overlayVisible,
canvasEngine: canvas?.dataset.engine ?? null,
canvasSize: canvas ? { width: canvas.width, height: canvas.height } : null,
};
});
}
async function createHunter(page, name) {
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-list').getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-create-screen').waitFor();
await page.locator('.class-grid').getByRole('button', { name: /Hunter/ }).click();
await page.locator('.creator-specializations').getByRole('button', { name: /Marksmanship/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(name);
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).waitFor();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor();
await page.getByRole('button', { name: /Choose role/ }).click();
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.party-frame').nth(4).waitFor({ timeout: 180_000 });
}
async function capture(page, name, fullPage = false) {
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage });
console.log(`Captured ${name}`);
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
let browser;
let page;
const pageErrors = [];
const consoleErrors = [];
const httpErrors = [];
const recentRequests = [];
const keyEvents = [];
try {
browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
page = await context.newPage();
page.setDefaultTimeout(90_000);
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); });
page.on('request', (request) => {
recentRequests.push({ method: request.method(), url: request.url() });
if (recentRequests.length > 100) recentRequests.shift();
});
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() }); });
page.on('requestfailed', (request) => httpErrors.push({ status: 'failed', url: request.url(), failure: request.failure()?.errorText }));
await page.addInitScript(({ mainModule, combatModule }) => {
window.__hunterKeyEvents = [];
window.addEventListener('keydown', (event) => {
window.__hunterKeyEvents.push({ key: event.key, code: event.code, target: event.target?.nodeName ?? null });
}, true);
window.hunterFamilyStores = async () => {
const exports = [...Object.values(await import(mainModule)), ...Object.values(await import(combatModule))];
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
return {
shell: exports.find((value) => stateOf(value) && 'characters' in stateOf(value) && 'selectedCharacterId' in stateOf(value)),
combat: exports.find((value) => stateOf(value) && 'castAbility' in stateOf(value) && 'summons' in stateOf(value)),
game: exports.find((value) => stateOf(value) && 'assetStatus' in stateOf(value) && 'openOverlay' in stateOf(value)),
simulation: exports.find((value) => stateOf(value) && 'sessionMode' in stateOf(value) && 'runState' in stateOf(value)),
biteSizedLayout: exports.find((value) => stateOf(value) && 'panelFocused' in stateOf(value) && 'editorMode' in stateOf(value)),
};
};
}, { mainModule, combatModule });
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.locator('.character-select-screen').waitFor();
await createHunter(page, 'Catguard');
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await createHunter(page, 'Wolfguard');
// QA-only eligibility seed: after normal UI creation, advance both temporary
// local profiles to level 10 through the repository-backed progression API.
// Family selection and persistence are exercised only through the rendered UI.
const seededProfiles = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
const names = ['Catguard', 'Wolfguard'];
const seeded = [];
for (const name of names) {
const profile = shell.getState().characters.find((character) => character.name === name);
if (!profile) throw new Error(`Missing UI-created test character ${name}.`);
shell.setState({ activeCharacter: profile });
shell.getState().saveActiveProgression(10, 0, profile.talentRanks, profile.glyphIds);
shell.setState({ activeCharacter: null });
const saved = shell.getState().characters.find((character) => character.id === profile.id);
if (saved?.level !== 10) throw new Error(`${name} did not reach the level-10 QA fixture.`);
seeded.push({ name, level: saved.level, family: saved.hunterPetFamily ?? 'wolf' });
}
return seeded;
});
assert.deepEqual(seededProfiles, [
{ name: 'Catguard', level: 10, family: 'wolf' },
{ name: 'Wolfguard', level: 10, family: 'wolf' },
]);
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-character').filter({ hasText: 'Catguard' }).click();
await page.getByRole('button', { name: /Continue as Catguard/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await enterWailingCaverns(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
const bossIntro = page.locator('.dungeon-boss-intro');
const bossIntroObserved = await bossIntro.waitFor({ state: 'visible', timeout: 12_000 }).then(() => true, () => false);
if (bossIntroObserved) await bossIntro.waitFor({ state: 'detached', timeout: 30_000 });
await waitForGameReady(page);
await page.waitForTimeout(300);
const entranceLock = {
disabled: await selector.isDisabled(),
selected: await selector.inputValue(),
engagedMobs: await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
}),
};
assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.');
if (entranceLock.disabled) {
await capture(page, '01-wailing-entry-combat-locked-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
await capture(page, '02-wailing-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
// OOC family selector is used. This bounded wait never changes encounter state.
await page.waitForFunction(() => {
const select = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return Boolean(select && !select.disabled);
}, null, { timeout: 60_000 });
assert.equal(await selector.isEnabled(), true, 'Family selection unlocks naturally when the entrance encounter ends.');
const outOfCombatMobs = await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
});
assert.deepEqual(outOfCombatMobs, [], 'Cat is selected only after the actual encounter has ended.');
const familyPointerTarget = await selector.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
return { hitTag: hit?.tagName ?? null, hitClassName: hit?.className?.toString?.() ?? null,
hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element.closest('.hunter-pet-controls')) };
});
assert.equal(familyPointerTarget.hitControls, true, 'The visible family selector receives normal pointer hit testing.');
await selector.click();
await page.keyboard.press('End');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { shell, combat } = await window.hunterFamilyStores();
const profile = shell.getState().characters.find((character) => character.name === 'Catguard');
const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
return profile?.hunterPetFamily === 'cat'
&& combat.getState().hunterPetFamily === 'cat'
&& pet?.name === 'Cat'
&& pet.hunterPet.family === 'cat'
&& pet.hunterPet.abilityName === 'Claw'
&& pet.hunterPet.rake?.spellId === 59881
&& pet.hunterPet.prowl?.spellId === 24450
&& !pet.hunterPet.furiousHowl
&& !pet.hunterPet.shellShield;
}, null, { timeout: 10_000 });
let current = await storeSnapshot(page);
assert.equal(current.selectedCharacter?.hunterPetFamily, 'cat');
assert.equal(current.combat.activePet?.id, '__player__:summon-1');
assert.equal(current.combat.activePet?.ownerActorId, '__player__');
assert.deepEqual(current.combat.activePet?.primary, { name: 'Claw', spellId: 16_828, rank: 2 });
assert.deepEqual(current.combat.activePet?.rake, { spellId: 59_881, rank: 1, requiredLevel: 1 });
assert.deepEqual(current.combat.activePet?.prowl, { spellId: 24_450, rank: 1, requiredLevel: 1 });
assert.equal(current.combat.activePet?.furiousHowlSpellId, null);
assert.equal(current.combat.activePet?.shellShieldSpellId, null);
await waitForGameReady(page);
const renderReadyProof = await renderedReadiness(page);
assert.ok(['ready', 'proxy'].includes(renderReadyProof.assetStatus), 'Dungeon assets are ready before visual capture.');
assert.equal(renderReadyProof.loadingScreenVisible, false, 'The actual loading screen is hidden before visual capture.');
assert.ok(renderReadyProof.canvasEngine, 'The rendered 3D canvas is present before visual capture.');
const canvasSize = renderReadyProof.canvasSize;
assert.ok(canvasSize.width > 0 && canvasSize.height > 0, 'The rendered dungeon has an active WebGL canvas.');
await page.setViewportSize({ width: 1440, height: 1000 });
await capture(page, '03-cat-family-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
const narrowLayout = await page.evaluate(() => {
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const topRightHud = document.querySelector('.hud-top-right');
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const overlaps = (first, second) => Boolean(first && second
&& first.left < second.right && first.right > second.left
&& first.top < second.bottom && first.bottom > second.top);
const controlRect = rect(selector);
const petRect = rect(petFrame);
const partyRect = rect(partyFrames);
const topRightRect = rect(topRightHud);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorVisibleInViewport: Boolean(controlRect && controlRect.width > 0 && controlRect.height > 0
&& controlRect.left >= 0 && controlRect.right <= innerWidth
&& controlRect.top >= 0 && controlRect.bottom <= innerHeight),
partyFrameRect: partyRect,
petFrameRect: petRect,
selectorRect: controlRect,
partyClearance: partyRect && petRect ? petRect.top - partyRect.bottom : null,
overlapsPartyFrames: overlaps(petRect, partyRect),
overlapsTopRightHud: overlaps(petRect, topRightRect),
};
});
assert.equal(narrowLayout.horizontalOverflow, false, 'The narrow HUD has no horizontal overflow.');
assert.equal(narrowLayout.selectorVisibleInViewport, true, 'The narrow family selector remains visible.');
assert.ok(narrowLayout.partyClearance >= 8, 'The player pet card sits below the party stack with clearance.');
assert.equal(narrowLayout.overlapsTopRightHud, false, 'The player pet card does not overlap buffs/debuffs or minimap HUD.');
await capture(page, '04-cat-family-narrow');
const petControls = page.locator('.summon-frame--summon .hunter-pet-controls');
const petCommands = petControls.getByRole('group', { name: 'Pet command' });
const stayButton = petCommands.getByRole('button', { name: 'stay', exact: true });
const followButton = petCommands.getByRole('button', { name: 'follow', exact: true });
const attackButton = petCommands.getByRole('button', { name: 'attack', exact: true });
assert.equal(await petControls.count(), 1, 'The active player-owned Cat exposes its rendered controls.');
const commandPointerTarget = await petControls.evaluate((element) => {
const button = element.querySelector('button[aria-pressed="false"]');
if (!button) return null;
const bounds = button.getBoundingClientRect();
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
return { button: button.textContent?.trim(), hitTag: hit?.tagName ?? null,
hitClassName: hit?.className?.toString?.() ?? null,
hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element) };
});
assert.equal(commandPointerTarget.hitControls, true, 'Visible command buttons receive normal pointer hit testing.');
const attackDisabledWithoutTarget = await attackButton.isDisabled();
assert.equal(attackDisabledWithoutTarget, true, 'Attack is visibly unavailable without a selected hostile target.');
await stayButton.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'stay';
}, null, { timeout: 5_000 });
await followButton.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'follow';
}, null, { timeout: 5_000 });
const reactSelector = petControls.getByLabel('Pet reaction');
await reactSelector.click();
await page.keyboard.press('End');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'passive';
}, null, { timeout: 5_000 });
await reactSelector.click();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'aggressive';
}, null, { timeout: 5_000 });
await reactSelector.click();
await page.keyboard.press('Home');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'defensive';
}, null, { timeout: 5_000 });
const growlToggle = petControls.locator('.hunter-pet-controls__growl input[type="checkbox"]');
await growlToggle.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === true;
}, null, { timeout: 5_000 });
await growlToggle.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === false;
}, null, { timeout: 5_000 });
const commandReactProof = {
rendered: await petControls.count() === 1,
pointerActivation: 'Stay, follow, reaction changes, and Growl autocast were exercised on the rendered controls after hit-testing them against the controls card.',
pointerHitTest: commandPointerTarget,
staySelected: await stayButton.getAttribute('aria-pressed'),
followSelectedAfterRestore: await followButton.getAttribute('aria-pressed'),
attackDisabledWithoutTarget,
reactionValuesTested: ['passive', 'aggressive', 'defensive'],
growlAutocastToggledAndRestored: true,
restoredReaction: await reactSelector.inputValue(),
};
// Try the ordinary Escape -> pause menu -> Spellbook route once. If it does
// not open in this input context, stop instead of calling store actions.
const spellRoute = { escapePressed: false, pauseOpened: false, spellbookOpened: false, callPet: 'not attempted' };
await page.setViewportSize({ width: 1440, height: 1000 });
await page.locator('.scene-shell').focus().catch(() => undefined);
await page.keyboard.press('Escape').catch(() => undefined);
spellRoute.escapePressed = true;
spellRoute.pauseOpened = await page.locator('.pause-menu').isVisible().catch(() => false);
if (spellRoute.pauseOpened) {
await page.getByRole('button', { name: 'Spellbook', exact: true }).click();
const spellbook = page.locator('.spellbook-panel');
await spellbook.waitFor({ timeout: 5_000 });
spellRoute.spellbookOpened = true;
const callPetAbility = spellbook.locator('.spellbook-ability').filter({ hasText: /^Call Pet/ }).first();
if (await callPetAbility.count()) {
const label = await callPetAbility.getAttribute('aria-label');
if (label?.startsWith('Call Pet, unlocked')) {
await callPetAbility.click();
await page.waitForTimeout(600);
const afterCall = await storeSnapshot(page);
spellRoute.callPet = {
attemptedThroughRenderedSpellbook: true,
abilityLabel: label,
petId: afterCall.combat.activePet?.id ?? null,
family: afterCall.combat.activePet?.family ?? null,
overlayAfterClick: await page.locator('.spellbook-panel').isVisible().catch(() => false) ? 'spellbook' : 'closed',
};
} else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, abilityLabel: label ?? null };
} else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'No Call Pet entry in the rendered spellbook.' };
} else {
spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'Escape did not expose the pause menu in this gameplay route.' };
}
spellRoute.revivePet = 'Not attempted: the live pet remained alive; no ordinary UI route to a naturally dead pet occurred.';
await page.setViewportSize({ width: 1440, height: 1000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.character-select-screen').waitFor();
let afterReload = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
return shell.getState().characters.map((character) => ({
name: character.name,
family: character.hunterPetFamily ?? null,
level: character.level,
}));
});
assert.equal(afterReload.find((profile) => profile.name === 'Catguard')?.family, 'cat');
assert.equal(afterReload.find((profile) => profile.name === 'Wolfguard')?.family, 'wolf');
assert.deepEqual(pageErrors, [], 'No new browser page errors occurred.');
const unexpectedConsoleErrors = consoleErrors.filter((entry) => !entry.location.url.endsWith('/favicon.ico'));
assert.deepEqual(unexpectedConsoleErrors, [], 'No browser console errors beyond the local server’s missing favicon occurred.');
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({
passed: true,
server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context',
characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so the level-10 family controls and Cat skill ranks could be inspected. This is eligibility setup, not earned progression.',
scope: 'Manual Hunter Cat family UI diagnostic only in disposable Wailing Caverns. Not a Hunter balance certification or Ready-party certification.',
familySelectionProof: 'Cat was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.',
combatLockProof: entranceLock.disabled
? 'At entry, live Wailing Caverns mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
: 'The Cat run’s first observation came after the entrance pack had ended; this run did not capture an in-combat Cat-family lock state. Earlier Hunter family QA covered the lock with Wolf/Turtle.',
callAndRevive: spellRoute,
commandReactProof,
catAbilityProof: 'The active player-owned pet runtime profile reports Claw 16828 rank 2, Rake 59881 rank 1, and Prowl 24450 rank 1 at level 10. It has neither Bite, Furious Howl, nor Shell Shield. This profile/readiness evidence does not prove live Rake or Prowl casts.',
visual: { renderReadyProof, bossIntroObserved, screenshots: [
...(entranceLock.disabled ? [
'01-wailing-entry-combat-locked-desktop.png',
'02-wailing-entry-combat-locked-narrow.png',
] : []),
'03-cat-family-desktop.png',
'04-cat-family-narrow.png',
] },
entranceLock,
outOfCombatMobs,
narrowLayout,
afterReload: afterReload,
finalState: current,
secondaryCharacter: afterReload.find((profile) => profile.name === 'Wolfguard'),
pageErrors,
consoleErrors,
httpErrors,
}, null, 2)}\n`);
console.log(`Hunter Cat browser playtest passed. Evidence: ${directory}`);
} catch (error) {
if (page) {
await page.screenshot({ path: path.join(directory, 'failure.png'), fullPage: true }).catch(() => undefined);
console.log((await page.locator('body').innerText().catch(() => '')).slice(-2_000));
}
console.log('Browser page errors:', pageErrors);
console.log('Browser console errors:', consoleErrors);
console.log('HTTP errors:', httpErrors);
console.log('Recent requests:', recentRequests.slice(-30));
const diagnostic = await page?.evaluate(async () => {
const { shell, combat, game, simulation, biteSizedLayout } = await window.hunterFamilyStores();
const runtime = combat.getState();
const familySelector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return {
keyEvents: window.__hunterKeyEvents ?? [],
focusedElement: { tag: document.activeElement?.tagName, className: document.activeElement?.className },
game: { overlay: game.getState().overlay, dungeonIntroActive: game.getState().dungeonIntroActive, assetStatus: game.getState().assetStatus },
inputContext: {
sessionMode: simulation?.getState().sessionMode ?? null,
runState: simulation?.getState().runState ?? null,
biteSizedPanelFocused: biteSizedLayout?.getState().panelFocused ?? null,
biteSizedEditorMode: biteSizedLayout?.getState().editorMode ?? null,
},
sessionMode: window.localStorage.getItem('healer-man-dungeon-simulation'),
familySelector: familySelector ? {
value: familySelector.value,
disabled: familySelector.disabled,
visible: Boolean(familySelector.getClientRects().length),
} : null,
engagedMobs: Object.values(runtime.mobs).filter((mob) => mob.engaged && !mob.dead && !mob.despawned).map((mob) => ({ id: mob.id, name: mob.name })),
family: runtime.hunterPetFamily,
pets: runtime.summons.filter((summon) => summon.hunterPet && summon.ownerActorId === '__player__').map((summon) => ({ id: summon.id, kind: summon.kind, name: summon.name, family: summon.hunterPet?.family, alive: summon.health > 0 })),
profiles: shell.getState().characters.map((character) => ({ name: character.name, family: character.hunterPetFamily ?? null, level: character.level })),
};
}).catch((diagnosticError) => ({ error: diagnosticError.message }));
let narrowEvidence = null;
if (page) {
await page.screenshot({ path: path.join(directory, '01-wailing-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined);
await page.waitForTimeout(350).catch(() => undefined);
narrowEvidence = await page.evaluate(() => {
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const selectorRect = rect(selector);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorRect,
selectorVisibleInViewport: Boolean(selectorRect && selectorRect.width > 0 && selectorRect.height > 0
&& selectorRect.left >= 0 && selectorRect.right <= innerWidth
&& selectorRect.top >= 0 && selectorRect.bottom <= innerHeight),
partyFrameRect: rect(partyFrames),
petFrameRect: rect(petFrame),
partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null,
};
}).catch(() => null);
await page.screenshot({ path: path.join(directory, '02-wailing-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
status: error.message.includes('party stack') ? 'failed_narrow_hud_layout_assertion' : 'failed_assertion',
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
};
fs.writeFileSync(path.join(directory, 'failure.json'), `${JSON.stringify(failure, null, 2)}\n`);
throw error;
} finally {
await browser?.close();
await new Promise((resolve) => runtime.server.close(resolve));
cleanupDataDir();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+449
View File
@@ -0,0 +1,449 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve(process.env.HUNTER_FAMILY_PLAYTEST_DIR || 'playtest-artifacts/hunter-families');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-families-'));
function cleanupDataDir() {
const resolvedDataDir = path.resolve(dataDir);
const resolvedTempDir = path.resolve(os.tmpdir());
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|| !path.basename(resolvedDataDir).startsWith('healer-man-hunter-families-')) {
throw new Error(`Refusing recursive cleanup outside the dedicated Hunter QA temp directory: ${resolvedDataDir}`);
}
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
}
const assetDirectory = path.join(staticDir, 'assets');
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
cleanupDataDir();
throw new Error('A fresh production build is required before this playtest. Build dist, then rerun.');
}
const assets = fs.readdirSync(assetDirectory);
const mainModule = `/assets/${assets.find((file) => /^index-.*\.js$/.test(file))}`;
const combatModule = `/assets/${assets.find((file) => /^combatStore-.*\.js$/.test(file))}`;
if (mainModule.endsWith('undefined') || combatModule.endsWith('undefined')) {
cleanupDataDir();
throw new Error('Could not locate the production app and combat store modules in dist/assets.');
}
fs.mkdirSync(directory, { recursive: true });
async function storeSnapshot(page) {
return page.evaluate(async () => {
const { shell, combat, game } = await window.hunterFamilyStores();
const roster = shell.getState();
const runtime = combat.getState();
const activePet = runtime.summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
const activeProfile = roster.characters.find((character) => character.id === roster.selectedCharacterId);
return {
phase: roster.phase,
selectedCharacter: activeProfile ? {
name: activeProfile.name,
classId: activeProfile.classId,
level: activeProfile.level,
specializationId: activeProfile.specializationId,
hunterPetFamily: activeProfile.hunterPetFamily ?? null,
} : null,
combat: {
classId: runtime.classId,
level: runtime.level,
family: runtime.hunterPetFamily,
activePet: activePet ? {
name: activePet.name,
family: activePet.hunterPet.family,
alive: activePet.health > 0,
health: activePet.health,
shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null,
furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null,
} : null,
},
assetStatus: game.getState().assetStatus,
};
});
}
async function waitForGameReady(page) {
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
const overlay = page.locator('.loading-screen');
let showedOverlay = await overlay.isVisible().catch(() => false);
if (!showedOverlay) {
showedOverlay = await overlay.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false);
}
if (showedOverlay) await overlay.waitFor({ state: 'hidden', timeout: 180_000 });
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
return game.getState().assetStatus === 'ready';
}, null, { timeout: 180_000 });
await page.locator('canvas').waitFor();
await page.waitForTimeout(800);
}
async function createHunter(page, name) {
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-list').getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-create-screen').waitFor();
await page.locator('.class-grid').getByRole('button', { name: /Hunter/ }).click();
await page.locator('.creator-specializations').getByRole('button', { name: /Marksmanship/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(name);
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).waitFor();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor();
await page.getByRole('button', { name: /Choose role/ }).click();
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.party-frame').nth(4).waitFor({ timeout: 180_000 });
}
async function activateSpellFromSpellbook(page, spellName) {
await page.locator('.scene-shell').focus();
await page.keyboard.press('Escape');
await page.locator('.pause-menu').waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Spellbook', exact: true }).click();
const spellbook = page.locator('.spellbook-panel');
await spellbook.waitFor();
const spell = spellbook.locator('.spellbook-ability').filter({ hasText: spellName }).first();
await spell.waitFor();
assert.match(await spell.getAttribute('aria-label'), new RegExp(`${spellName}, unlocked`), `${spellName} is unlocked at the seeded level`);
await spell.click();
await spellbook.waitFor({ state: 'detached' });
}
async function callPet(page, expectedFamily) {
await activateSpellFromSpellbook(page, 'Call Pet');
const selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
assert.equal(await selector.count(), 1, 'The player-controlled pet has one family selector.');
await selector.waitFor();
assert.equal(await selector.inputValue(), expectedFamily);
const current = await storeSnapshot(page);
assert.equal(current.combat.classId, 'hunter');
assert.ok(current.combat.level >= 10, 'Call Pet is unlocked by the documented level-10 test seed.');
assert.deepEqual(current.combat.activePet && {
name: current.combat.activePet.name,
family: current.combat.activePet.family,
}, { name: expectedFamily === 'turtle' ? 'Turtle' : 'Wolf', family: expectedFamily });
return selector;
}
async function capture(page, name, fullPage = false) {
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage });
console.log(`Captured ${name}`);
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
let browser;
let page;
const pageErrors = [];
const consoleErrors = [];
const httpErrors = [];
const recentRequests = [];
const keyEvents = [];
try {
browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
page = await context.newPage();
page.setDefaultTimeout(90_000);
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); });
page.on('request', (request) => {
recentRequests.push({ method: request.method(), url: request.url() });
if (recentRequests.length > 100) recentRequests.shift();
});
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() }); });
page.on('requestfailed', (request) => httpErrors.push({ status: 'failed', url: request.url(), failure: request.failure()?.errorText }));
await page.addInitScript(({ mainModule, combatModule }) => {
window.__hunterKeyEvents = [];
window.addEventListener('keydown', (event) => {
window.__hunterKeyEvents.push({ key: event.key, code: event.code, target: event.target?.nodeName ?? null });
}, true);
window.hunterFamilyStores = async () => {
const exports = [...Object.values(await import(mainModule)), ...Object.values(await import(combatModule))];
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
return {
shell: exports.find((value) => stateOf(value) && 'characters' in stateOf(value) && 'selectedCharacterId' in stateOf(value)),
combat: exports.find((value) => stateOf(value) && 'castAbility' in stateOf(value) && 'summons' in stateOf(value)),
game: exports.find((value) => stateOf(value) && 'assetStatus' in stateOf(value) && 'openOverlay' in stateOf(value)),
simulation: exports.find((value) => stateOf(value) && 'sessionMode' in stateOf(value) && 'runState' in stateOf(value)),
biteSizedLayout: exports.find((value) => stateOf(value) && 'panelFocused' in stateOf(value) && 'editorMode' in stateOf(value)),
};
};
}, { mainModule, combatModule });
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.locator('.character-select-screen').waitFor();
await createHunter(page, 'Shellguard');
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await createHunter(page, 'Wolfguard');
// QA-only eligibility seed: after normal UI creation, advance both temporary
// local profiles to level 10 through the repository-backed progression API.
// Family selection and persistence are exercised only through the rendered UI.
const seededProfiles = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
const names = ['Shellguard', 'Wolfguard'];
const seeded = [];
for (const name of names) {
const profile = shell.getState().characters.find((character) => character.name === name);
if (!profile) throw new Error(`Missing UI-created test character ${name}.`);
shell.setState({ activeCharacter: profile });
shell.getState().saveActiveProgression(10, 0, profile.talentRanks, profile.glyphIds);
shell.setState({ activeCharacter: null });
const saved = shell.getState().characters.find((character) => character.id === profile.id);
if (saved?.level !== 10) throw new Error(`${name} did not reach the level-10 QA fixture.`);
seeded.push({ name, level: saved.level, family: saved.hunterPetFamily ?? 'wolf' });
}
return seeded;
});
assert.deepEqual(seededProfiles, [
{ name: 'Shellguard', level: 10, family: 'wolf' },
{ name: 'Wolfguard', level: 10, family: 'wolf' },
]);
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-character').filter({ hasText: 'Shellguard' }).click();
await page.getByRole('button', { name: /Continue as Shellguard/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await enterWailingCaverns(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 30_000 });
await page.waitForTimeout(300);
const entranceLock = {
disabled: await selector.isDisabled(),
selected: await selector.inputValue(),
engagedMobs: await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
}),
};
assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.');
if (entranceLock.disabled) {
await capture(page, '01-wailing-entry-combat-locked-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
await capture(page, '02-wailing-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
// OOC family selector is used. This bounded wait never changes encounter state.
await page.waitForFunction(() => {
const select = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return Boolean(select && !select.disabled);
}, null, { timeout: 60_000 });
assert.equal(await selector.isEnabled(), true, 'Family selection unlocks naturally when the entrance encounter ends.');
const outOfCombatMobs = await page.evaluate(async () => {
const { combat } = await window.hunterFamilyStores();
return Object.values(combat.getState().mobs)
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
.map((mob) => ({ id: mob.id, name: mob.name }));
});
assert.deepEqual(outOfCombatMobs, [], 'Turtle is selected only after the actual encounter has ended.');
await selector.selectOption('turtle');
await page.waitForFunction(async () => {
const { shell, combat } = await window.hunterFamilyStores();
const profile = shell.getState().characters.find((character) => character.name === 'Shellguard');
const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
return profile?.hunterPetFamily === 'turtle'
&& combat.getState().hunterPetFamily === 'turtle'
&& pet?.name === 'Turtle'
&& pet.hunterPet.family === 'turtle'
&& !pet.hunterPet.furiousHowl;
}, null, { timeout: 10_000 });
let current = await storeSnapshot(page);
assert.equal(current.selectedCharacter?.hunterPetFamily, 'turtle');
assert.equal(current.combat.activePet?.shellShieldSpellId, 26_064);
assert.equal(current.combat.activePet?.furiousHowlSpellId, null);
const canvasSize = await page.locator('canvas').first().evaluate((element) => ({ width: element.width, height: element.height }));
assert.ok(canvasSize.width > 0 && canvasSize.height > 0, 'The rendered dungeon has an active WebGL canvas.');
await page.setViewportSize({ width: 1440, height: 1000 });
await capture(page, '03-turtle-family-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
const narrowLayout = await page.evaluate(() => {
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const topRightHud = document.querySelector('.hud-top-right');
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const overlaps = (first, second) => Boolean(first && second
&& first.left < second.right && first.right > second.left
&& first.top < second.bottom && first.bottom > second.top);
const controlRect = rect(selector);
const petRect = rect(petFrame);
const partyRect = rect(partyFrames);
const topRightRect = rect(topRightHud);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorVisibleInViewport: Boolean(controlRect && controlRect.width > 0 && controlRect.height > 0
&& controlRect.left >= 0 && controlRect.right <= innerWidth
&& controlRect.top >= 0 && controlRect.bottom <= innerHeight),
partyFrameRect: partyRect,
petFrameRect: petRect,
selectorRect: controlRect,
partyClearance: partyRect && petRect ? petRect.top - partyRect.bottom : null,
overlapsPartyFrames: overlaps(petRect, partyRect),
overlapsTopRightHud: overlaps(petRect, topRightRect),
};
});
assert.equal(narrowLayout.horizontalOverflow, false, 'The narrow HUD has no horizontal overflow.');
assert.equal(narrowLayout.selectorVisibleInViewport, true, 'The narrow family selector remains visible.');
assert.ok(narrowLayout.partyClearance >= 8, 'The player pet card sits below the party stack with clearance.');
assert.equal(narrowLayout.overlapsTopRightHud, false, 'The player pet card does not overlap buffs/debuffs or minimap HUD.');
await capture(page, '04-turtle-family-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.character-select-screen').waitFor();
let afterReload = await page.evaluate(async () => {
const { shell } = await window.hunterFamilyStores();
return shell.getState().characters.map((character) => ({
name: character.name,
family: character.hunterPetFamily ?? null,
level: character.level,
}));
});
assert.equal(afterReload.find((profile) => profile.name === 'Shellguard')?.family, 'turtle');
assert.equal(afterReload.find((profile) => profile.name === 'Wolfguard')?.family, 'wolf');
assert.deepEqual(pageErrors, [], 'No new browser page errors occurred.');
const unexpectedConsoleErrors = consoleErrors.filter((entry) => !entry.location.url.endsWith('/favicon.ico'));
assert.deepEqual(unexpectedConsoleErrors, [], 'No browser console errors beyond the local server’s missing favicon occurred.');
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({
passed: true,
server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context',
characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so sourced Call Pet (level 10) and Revive Pet (level 10) are available.',
scope: 'Manual Hunter family UI mechanics diagnostic only in disposable Wailing Caverns. Not a Hunter balance certification or Ready-party certification.',
familySelectionProof: 'Turtle was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.',
combatLockProof: entranceLock.disabled
? 'At entry, live Wailing Caverns mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
: 'The rendered family selector was enabled at the first post-intro observation with no engaged mobs. A separate earlier diagnostic screenshot and failure.json record the selector disabled while the entrance mobs were engaged; there is no narrow combat-lock screenshot.',
callAndRevive: 'Spellbook Call Pet / Revive Pet activation was not covered; the Escape shortcut reached the focused scene but did not open the pause menu in this route. Neither spell was activated by state injection.',
visual: { canvasSize, screenshots: [
'01-wailing-entry-combat-locked-desktop.png',
'02-wailing-entry-combat-locked-narrow.png',
'03-turtle-family-desktop.png',
'04-turtle-family-narrow.png',
] },
entranceLock,
outOfCombatMobs,
narrowLayout,
afterReload: afterReload,
finalState: current,
pageErrors,
consoleErrors,
httpErrors,
}, null, 2)}\n`);
console.log(`Hunter family browser playtest passed. Evidence: ${directory}`);
} catch (error) {
if (page) {
await page.screenshot({ path: path.join(directory, 'failure.png'), fullPage: true }).catch(() => undefined);
console.log((await page.locator('body').innerText().catch(() => '')).slice(-2_000));
}
console.log('Browser page errors:', pageErrors);
console.log('Browser console errors:', consoleErrors);
console.log('HTTP errors:', httpErrors);
console.log('Recent requests:', recentRequests.slice(-30));
const diagnostic = await page?.evaluate(async () => {
const { shell, combat, game, simulation, biteSizedLayout } = await window.hunterFamilyStores();
const runtime = combat.getState();
const familySelector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
return {
keyEvents: window.__hunterKeyEvents ?? [],
focusedElement: { tag: document.activeElement?.tagName, className: document.activeElement?.className },
game: { overlay: game.getState().overlay, dungeonIntroActive: game.getState().dungeonIntroActive, assetStatus: game.getState().assetStatus },
inputContext: {
sessionMode: simulation?.getState().sessionMode ?? null,
runState: simulation?.getState().runState ?? null,
biteSizedPanelFocused: biteSizedLayout?.getState().panelFocused ?? null,
biteSizedEditorMode: biteSizedLayout?.getState().editorMode ?? null,
},
sessionMode: window.localStorage.getItem('healer-man-dungeon-simulation'),
familySelector: familySelector ? {
value: familySelector.value,
disabled: familySelector.disabled,
visible: Boolean(familySelector.getClientRects().length),
} : null,
engagedMobs: Object.values(runtime.mobs).filter((mob) => mob.engaged && !mob.dead && !mob.despawned).map((mob) => ({ id: mob.id, name: mob.name })),
family: runtime.hunterPetFamily,
pets: runtime.summons.filter((summon) => summon.hunterPet && summon.ownerActorId === '__player__').map((summon) => ({ id: summon.id, kind: summon.kind, name: summon.name, family: summon.hunterPet?.family, alive: summon.health > 0 })),
profiles: shell.getState().characters.map((character) => ({ name: character.name, family: character.hunterPetFamily ?? null, level: character.level })),
};
}).catch((diagnosticError) => ({ error: diagnosticError.message }));
let narrowEvidence = null;
if (page) {
await page.screenshot({ path: path.join(directory, '01-wailing-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined);
await page.waitForTimeout(350).catch(() => undefined);
narrowEvidence = await page.evaluate(() => {
const rect = (element) => {
if (!element) return null;
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
return { top, right, bottom, left, width, height };
};
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
const petFrame = selector?.closest('.summon-frame');
const partyFrames = document.querySelector('.party-frames');
const selectorRect = rect(selector);
return {
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
selectorRect,
selectorVisibleInViewport: Boolean(selectorRect && selectorRect.width > 0 && selectorRect.height > 0
&& selectorRect.left >= 0 && selectorRect.right <= innerWidth
&& selectorRect.top >= 0 && selectorRect.bottom <= innerHeight),
partyFrameRect: rect(partyFrames),
petFrameRect: rect(petFrame),
partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null,
};
}).catch(() => null);
await page.screenshot({ path: path.join(directory, '02-wailing-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
status: error.message.includes('party stack') ? 'failed_narrow_hud_layout_assertion' : 'failed_assertion',
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
};
fs.writeFileSync(path.join(directory, 'failure.json'), `${JSON.stringify(failure, null, 2)}\n`);
throw error;
} finally {
await browser?.close();
await new Promise((resolve) => runtime.server.close(resolve));
cleanupDataDir();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+710
View File
@@ -0,0 +1,710 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// Run only after a fresh production build. All account and server data is isolated
// under this script's temporary directory; screenshots/results are the only output.
const directory = path.resolve('playtest-artifacts/stat-rulesets');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-stat-rulesets-'));
const databaseKey = 'healer-man.profile-database.v1';
const requiredNativeRows = Object.freeze({
attributes: [
'Strength (STR)', 'Stamina (STA)', 'Intellect (INT)', 'Wisdom (WIS)', 'Agility (AGI)', 'Health', 'Mana (MP)',
],
offense: [
'Physical attack (ATK)', 'Physical damage (DMG)', 'Magical attack (MATK)', 'Magical damage (MDMG)',
'Magical damage points', 'Magic power rate', 'Physical hit (points)', 'Magical hit (points)',
'Physical critical (points)', 'Magical critical (points)', 'Healing power rate', 'Healing points',
],
defense: [
'Physical defense (DEF)', 'Magical defense (MDEF)', 'Physical dodge (points)', 'Magical dodge (points)', 'Parry (points)',
],
});
function cleanupDataDir() {
const resolvedDataDir = path.resolve(dataDir);
const resolvedTempDir = path.resolve(os.tmpdir());
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|| !path.basename(resolvedDataDir).startsWith('healer-man-stat-rulesets-')) {
throw new Error(`Refusing cleanup outside dedicated QA temp directory: ${resolvedDataDir}`);
}
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
}
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
cleanupDataDir();
throw new Error('A fresh production build is required before this playtest.');
}
const assetsDirectory = path.join(staticDir, 'assets');
const assets = fs.readdirSync(assetsDirectory);
const mainFile = assets.find((file) => /^index-.*\.js$/.test(file));
const combatFile = assets.find((file) => /^combatStore-.*\.js$/.test(file));
if (!mainFile || !combatFile) {
cleanupDataDir();
throw new Error('Could not locate the production app and combat-store modules in dist/assets.');
}
const moduleUrls = assets
.filter((file) => /^(?:index|combatStore|partyStore)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
fs.mkdirSync(directory, { recursive: true });
async function capture(page, name, fullPage = false) {
const file = path.join(directory, `${name}.png`);
await page.screenshot({ path: file, fullPage });
return file;
}
async function waitForScreen(page, selector) {
await page.locator(selector).waitFor({ timeout: 180_000 });
}
async function waitForGameReady(page) {
await waitForScreen(page, '.party-frames');
await page.waitForFunction(async () => {
const stores = await window.statRulesetStores();
const overlay = document.querySelector('.loading-screen');
const visible = overlay && (() => {
const style = getComputedStyle(overlay);
const bounds = overlay.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
&& bounds.width > 0 && bounds.height > 0;
})();
const ready = stores.game?.getState().assetStatus === 'ready' && !visible;
if (!ready) {
window.__statRulesetVisualReadySince = 0;
return false;
}
if (!window.__statRulesetVisualReadySince) window.__statRulesetVisualReadySince = performance.now();
return performance.now() - window.__statRulesetVisualReadySince >= 900;
}, null, { timeout: 180_000, polling: 100 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
await page.locator('.scene-shell canvas').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.party-frame--player[aria-label^="Target "]').waitFor();
await page.locator('.party-frame--player .party-frame__health[aria-label="Player health"]').waitFor();
await page.waitForTimeout(1_200);
}
async function createRomPriest(page, name) {
await page.locator('.roster-new').click();
await waitForScreen(page, '.character-create-screen');
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(name);
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
await waitForScreen(page, '.character-select-screen');
const created = await page.evaluate(async (characterName) => {
const { shell } = await window.statRulesetStores();
const normalizedName = characterName.replace(/[^A-Za-z]/g, '').slice(0, 12).toLowerCase();
const character = shell.getState().characters.find((entry) => entry.name.toLowerCase() === normalizedName);
return character ? { id: character.id, name: character.name, classId: character.classId, statRuleset: character.statRuleset } : null;
}, name);
assert.deepEqual(created && { classId: created.classId, statRuleset: created.statRuleset }, {
classId: 'rom-priest',
statRuleset: 'rom',
}, 'New RoM characters are created with Native RoM stats.');
return created;
}
async function chooseRosterCharacter(page, name) {
await waitForScreen(page, '.character-select-screen');
await page.locator('.roster-character').filter({ hasText: name }).click();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
await waitForScreen(page, '.main-menu-screen');
await page.locator('.main-menu-character').waitFor();
}
async function seedProfilesAndRemoveLegacyRule(page, ids) {
return page.evaluate(({ nativeId, legacyId, databaseKey }) => {
const database = JSON.parse(localStorage.getItem(databaseKey) || 'null');
if (!database?.rosters) throw new Error('The offline profile database was not found.');
const owners = Object.entries(database.rosters);
const nativeRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === nativeId);
const legacyRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === legacyId);
if (!nativeRecord || !legacyRecord) throw new Error('Created test profiles were missing from local storage.');
const inventory = (suffix) => [{
instanceId: `qa-robes-${suffix}`,
itemId: 10001,
itemKey: `qa-robes-${suffix}`,
name: `Field-Test Robe ${suffix}`,
quality: 'rare',
inventorySlot: 'chest',
armorType: 'cloth',
weaponType: null,
itemLevel: 30,
sourceItemLevel: 30,
stats: {
health: 0, mana: 0, strength: 0, agility: 0, stamina: 7, intellect: 3, spirit: 0,
armor: 18, attackPower: 0, rangedAttackPower: 0, spellPower: 0, criticalStrikeRating: 0,
hitRating: 0, hasteRating: 0, expertiseRating: 0, armorPenetrationRating: 0,
defenseRating: 0, dodgeRating: 0, parryRating: 0, blockRating: 0, blockValue: 0,
resilienceRating: 0, spellPenetration: 0, mp5: 0, healthPer5: 0, arcaneResistance: 0,
fireResistance: 0, frostResistance: 0, natureResistance: 0, shadowResistance: 0,
meleeWeapon: null, rangedWeapon: null,
},
quantity: 1,
sourceBossKey: 'qa-stat-ruleset-fixture',
sourceBossName: 'Stat Ruleset QA Fixture',
acquiredAt: Date.now(),
}];
const prepare = (profile, suffix, ruleset) => {
const item = inventory(suffix)[0];
Object.assign(profile, {
level: 30,
experience: 0,
secondaryClassId: 'rom-mage',
talentRanks: { 'rom-priest-qa-fixture': 1 },
glyphIds: ['qa-preserved-glyph'],
actionBindings: { 'primary-1': 'rom-priest-primary-1' },
actionLoadouts: { 'rom-priest:rom-mage': { 'primary-1': 'rom-priest-primary-1' } },
inventory: [item],
equipment: { player: { chest: item.instanceId } },
});
if (ruleset) profile.statRuleset = ruleset;
else delete profile.statRuleset;
};
prepare(nativeRecord, 'native', 'rom');
prepare(legacyRecord, 'legacy', null);
localStorage.setItem(databaseKey, JSON.stringify(database));
return {
nativeId,
legacyId,
legacyRuleRemoved: !Object.hasOwn(legacyRecord, 'statRuleset'),
fixtureItemIds: ['qa-robes-native', 'qa-robes-legacy'],
};
}, { ...ids, databaseKey });
}
async function profileSnapshot(page, id) {
return page.evaluate(async (characterId) => {
const { shell } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === characterId);
if (!profile) throw new Error(`Profile ${characterId} was not found.`);
return {
id: profile.id,
name: profile.name,
classId: profile.classId,
statRuleset: profile.statRuleset,
level: profile.level,
secondaryClassId: profile.secondaryClassId,
talentRanks: profile.talentRanks,
glyphIds: profile.glyphIds,
actionBindings: profile.actionBindings,
actionLoadouts: profile.actionLoadouts,
inventory: profile.inventory,
equipment: profile.equipment,
settings: profile.settings,
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
};
}, id);
}
function buildSnapshot(profile) {
return {
level: profile.level,
secondaryClassId: profile.secondaryClassId,
talentRanks: profile.talentRanks,
glyphIds: profile.glyphIds,
actionBindings: profile.actionBindings,
actionLoadouts: profile.actionLoadouts,
inventory: profile.inventory,
equipment: profile.equipment,
settings: profile.settings,
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
};
}
async function chooseMode(page, ruleset) {
const label = ruleset === 'rom' ? 'Native RoM' : 'WoW compatibility';
const button = page.locator('.main-menu-character').getByRole('button', { name: label, exact: true });
await button.waitFor();
await button.click();
await page.waitForFunction(async (expected) => {
const { shell } = await window.statRulesetStores();
const state = shell.getState();
return state.characters.find((entry) => entry.id === state.selectedCharacterId)?.statRuleset === expected;
}, ruleset);
assert.equal(await button.getAttribute('aria-pressed'), 'true');
}
async function selectWailingCaverns(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await waitForScreen(page, '.dungeon-select-screen');
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.getByRole('button', { name: /Choose role/ }).click();
const damageRole = page.locator('.dungeon-role-option--damage');
if (await damageRole.count()) await damageRole.first().click();
else await page.locator('.dungeon-role-option').first().click();
await waitForGameReady(page);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
await page.waitForTimeout(750);
}
async function openStatsPanel(page) {
const scene = page.locator('.scene-shell');
await scene.focus();
for (let attempt = 0; attempt < 2; attempt += 1) {
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
if (overlay === 'pause') break;
await scene.press('Escape');
await page.waitForTimeout(100);
}
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
assert.equal(overlay, 'pause', 'Escape on the focused dungeon view opens the visible pause menu.');
await page.locator('.pause-menu').waitFor();
await page.getByRole('button', { name: 'Inventory & equipment', exact: true }).click();
await page.locator('.equipment-panel').waitFor();
await page.locator('.equipment-panel .equipment-character-stats').waitFor();
}
async function readStatsPanelPage(page) {
const panel = page.locator('.equipment-panel .equipment-character-stats');
const stats = await panel.evaluate((list) => ({
label: list.getAttribute('aria-label'),
rows: [...list.querySelectorAll('div')].map((row) => ({
label: row.querySelector('dt')?.textContent?.trim() ?? '',
value: row.querySelector('dd')?.textContent?.trim() ?? '',
})),
vitals: [...document.querySelectorAll('.equipment-character-vitals span')].map((element) => element.innerText.trim()),
}));
return {
...stats,
caption: await page.locator('.equipment-character-stats__note').innerText().catch(() => ''),
};
}
(async () => {
let runtime;
let browser;
let page;
const pageErrors = [];
const consoleErrors = [];
const httpErrors = [];
const responseErrors = [];
const results = {
outputDirectory: directory,
buildChunks: { main: mainFile, combat: combatFile, runtimeModules: moduleUrls },
fixtures: { legacyProfileIsSynthetic: true, liveCombatGuardIsSynthetic: true, onlineActivityLock: 'attempted only if its store is exposed by production entry chunks' },
screenshots: {},
observations: {},
};
try {
const { createGameServer } = await import('../server/server.mjs');
runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
await new Promise((resolve, reject) => {
runtime.server.once('error', reject);
runtime.server.listen(0, '127.0.0.1', resolve);
});
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
results.origin = origin;
browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
page = await context.newPage();
page.setDefaultTimeout(90_000);
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
});
page.on('response', (response) => {
if (response.status() >= 400) {
const error = { status: response.status(), url: response.url() };
responseErrors.push(error);
if (response.status() >= 500) httpErrors.push(error);
}
});
await page.addInitScript((urls) => {
window.statRulesetStores = async () => {
const exports = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const stores = exports.filter((value) => {
try { return typeof value?.getState === 'function'; } catch { return false; }
});
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
return {
shell: stores.find((value) => typeof stateOf(value)?.setSelectedCharacterStatRuleset === 'function'),
combat: stores.find((value) => typeof stateOf(value)?.castAbility === 'function' && 'mobs' in stateOf(value)),
game: stores.find((value) => typeof stateOf(value)?.openOverlay === 'function' && 'assetStatus' in stateOf(value)),
party: stores.find((value) => Array.isArray(stateOf(value)?.members) && typeof stateOf(value)?.setCommand === 'function'),
online: stores.find((value) => 'snapshot' in (stateOf(value) ?? {}) && typeof stateOf(value)?.setFillWithAi === 'function'),
storeCount: stores.length,
};
};
}, moduleUrls);
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await waitForScreen(page, '.character-select-screen');
const nativeCreated = await createRomPriest(page, 'Nativestats');
const legacyCreated = await createRomPriest(page, 'Legacystats');
const seeded = await seedProfilesAndRemoveLegacyRule(page, {
nativeId: nativeCreated.id,
legacyId: legacyCreated.id,
});
assert.equal(seeded.legacyRuleRemoved, true);
results.observations.createdNativeProfile = nativeCreated;
results.observations.legacyFixture = seeded;
// A reload forces the real repository read/migration path for the old RoM save.
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
await page.locator('.roster-character').filter({ hasText: nativeCreated.name }).click();
await page.waitForTimeout(3_000);
const rosterPreview = await page.locator('.character-preview').evaluate((preview) => {
const canvas = preview.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
caption: preview.querySelector('figcaption')?.innerText.trim() ?? '',
canvas: bounds ? { width: bounds.width, height: bounds.height } : null,
};
});
assert.ok(rosterPreview.canvas?.width > 100 && rosterPreview.canvas?.height > 100, 'The saved RoM character body preview has a rendered canvas.');
results.observations.rosterPreview = rosterPreview;
const migratedNative = await profileSnapshot(page, nativeCreated.id);
const migratedLegacy = await profileSnapshot(page, legacyCreated.id);
assert.equal(migratedNative.statRuleset, 'rom');
assert.equal(migratedLegacy.statRuleset, 'wow', 'A legacy saved RoM profile without the field retains WoW-compatible behavior.');
assert.equal(migratedLegacy.secondaryClassId, 'rom-mage');
assert.equal(migratedLegacy.inventory[0]?.instanceId, 'qa-robes-legacy');
assert.deepEqual(migratedLegacy.equipment.player, { chest: 'qa-robes-legacy' });
results.observations.afterLegacyMigration = {
native: { statRuleset: migratedNative.statRuleset, classId: migratedNative.classId },
legacy: { statRuleset: migratedLegacy.statRuleset, classId: migratedLegacy.classId, level: migratedLegacy.level },
savedRuleAfterMigration: JSON.parse(await page.evaluate((key) => localStorage.getItem(key), databaseKey))
.rosters['offline-roster'].find((entry) => entry.id === legacyCreated.id).statRuleset,
};
results.screenshots.rosterNative = await capture(page, '01-roster-native-rom');
await chooseRosterCharacter(page, nativeCreated.name);
await page.locator('.main-menu-character').getByRole('group', { name: 'RoM stat profile' }).waitFor();
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
const nativeBuild = buildSnapshot(await profileSnapshot(page, nativeCreated.id));
// Exercise the actual menu controls and browser persistence for one profile.
await chooseMode(page, 'wow');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow');
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow', 'The visible WoW-compatible selection survives reload.');
results.screenshots.menuWowCompatibility = await capture(page, '02-menu-wow-compatibility');
await chooseMode(page, 'rom');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
// The second RoM profile must retain its legacy WoW setting independently.
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, legacyCreated.name);
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
await chooseMode(page, 'rom');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
await chooseMode(page, 'wow');
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom', 'Changing the second profile never changes the first profile.');
// Measure the selector itself at desktop and narrow phone widths.
const desktopSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
const bounds = fieldset.getBoundingClientRect();
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
const overlapsSummary = [...fieldset.parentElement.children]
.filter((element) => element !== fieldset)
.some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
});
const controls = [...fieldset.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
});
return {
clientWidth: fieldset.clientWidth,
scrollWidth: fieldset.scrollWidth,
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
sidebar: { left: parent.left, right: parent.right },
overlapsSummary,
controls,
};
});
assert.ok(desktopSelectorLayout.scrollWidth <= desktopSelectorLayout.clientWidth + 1, 'Desktop selector content fits its fieldset.');
assert.equal(desktopSelectorLayout.overlapsSummary, false, 'Desktop selector does not overlap its character label.');
results.observations.desktopSelectorLayout = desktopSelectorLayout;
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
const narrowSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
const bounds = fieldset.getBoundingClientRect();
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
const overlapsSummary = [...fieldset.parentElement.children]
.filter((element) => element !== fieldset)
.some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
});
return {
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
clientWidth: fieldset.clientWidth,
scrollWidth: fieldset.scrollWidth,
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
sidebar: { left: parent.left, right: parent.right },
overlapsSummary,
controls: [...fieldset.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
}),
};
});
assert.ok(narrowSelectorLayout.fieldset.left >= narrowSelectorLayout.sidebar.left - 1);
assert.ok(narrowSelectorLayout.fieldset.right <= narrowSelectorLayout.sidebar.right + 1);
assert.ok(narrowSelectorLayout.scrollWidth <= narrowSelectorLayout.clientWidth + 1, 'Phone selector does not overflow its fieldset.');
assert.equal(narrowSelectorLayout.overlapsSummary, false, 'Phone selector does not overlap its character label.');
assert.ok(narrowSelectorLayout.controls.every((control) => control.left >= narrowSelectorLayout.fieldset.left - 1
&& control.right <= narrowSelectorLayout.fieldset.right + 1), 'Phone selector buttons stay inside the fieldset.');
results.observations.narrowSelectorLayout = narrowSelectorLayout;
results.screenshots.menuSelectorMobile = await capture(page, '03-menu-selector-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await selectWailingCaverns(page);
results.screenshots.nativeDungeonGameplay = await capture(page, '04-native-dungeon-gameplay-overlays-closed');
const nativeRuntime = await page.evaluate(async () => {
const { shell, combat, party } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
const state = combat.getState();
return {
phase: shell.getState().phase,
profile: profile && { name: profile.name, statRuleset: profile.statRuleset },
classId: state.classId,
statRuleset: state.statRuleset,
health: state.health,
maxHealth: state.maxHealth,
resource: state.resource,
maxResource: state.maxResource,
resourceName: state.resourceName,
resourcePools: state.resourcePools,
inventory: state.inventory.map((item) => ({ instanceId: item.instanceId, name: item.name })),
playerEquipment: state.equipment.player,
party: party?.getState().members.map((member) => ({
name: member.name,
classId: member.classId,
statRuleset: member.statRuleset ?? 'wow',
})) ?? [],
};
});
assert.equal(nativeRuntime.phase, 'game');
assert.equal(nativeRuntime.profile?.statRuleset, 'rom');
assert.equal(nativeRuntime.classId, 'rom-priest');
assert.equal(nativeRuntime.statRuleset, 'rom', 'The selected profile reaches the actual combat runtime.');
assert.ok(nativeRuntime.maxHealth > 0 && nativeRuntime.maxResource > 0);
assert.ok(nativeRuntime.inventory.some((item) => item.instanceId === 'qa-robes-native'));
assert.equal(nativeRuntime.playerEquipment.chest, 'qa-robes-native', 'The equipped test item reaches the actual dungeon runtime.');
for (const member of nativeRuntime.party) {
if (member.classId.startsWith('rom-')) assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
}
results.observations.nativeDungeonRuntime = nativeRuntime;
await openStatsPanel(page);
const nativePanelPages = {};
for (const [index, pageName] of ['attributes', 'offense', 'defense'].entries()) {
if (pageName !== 'attributes') {
const tab = page.locator('.equipment-stat-tabs').getByRole('tab', { name: pageName, exact: true });
await tab.click();
await page.waitForFunction((name) => {
const selected = document.querySelector('.equipment-stat-tabs [role="tab"][aria-selected="true"]');
return selected?.textContent?.trim() === name;
}, pageName);
}
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
const panelPage = await readStatsPanelPage(page);
const labels = new Set(panelPage.rows.map((row) => row.label));
const missing = requiredNativeRows[pageName].filter((label) => !labels.has(label));
assert.deepEqual(missing, [], `Native RoM ${pageName} page is missing fields: ${missing.join(', ')}`);
assert.equal(panelPage.caption, 'Native RoM stats · shared dungeon combat rules');
nativePanelPages[pageName] = panelPage;
results.screenshots[`nativeStats${pageName[0].toUpperCase()}${pageName.slice(1)}Desktop`] = await capture(
page,
`04-native-stats-${pageName}-desktop`,
);
}
const renderedMeters = await page.locator('.party-frame--player').evaluate((frame) => {
const health = frame.querySelector('.party-frame__health[aria-label="Player health"]');
const resource = frame.querySelector('.party-frame__resource');
return {
health: {
current: Number(health?.getAttribute('aria-valuenow')),
maximum: Number(health?.getAttribute('aria-valuemax')),
text: health?.querySelector('b')?.textContent?.trim() ?? '',
},
resourceTitle: resource?.getAttribute('title') ?? '',
};
});
assert.ok(Math.abs(renderedMeters.health.maximum - nativeRuntime.maxHealth) <= 1, 'Rendered health maximum agrees with combat state.');
assert.ok(Math.abs(renderedMeters.health.current - nativeRuntime.health) <= 2, 'Rendered health current agrees with combat state.');
const renderedResource = renderedMeters.resourceTitle.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(.+)$/);
assert.ok(renderedResource, 'Rendered resource meter exposes current, maximum, and resource type.');
assert.ok(Math.abs(Number(renderedResource[2]) - nativeRuntime.maxResource) <= 1, 'Rendered resource maximum agrees with combat state.');
assert.ok(Math.abs(Number(renderedResource[1]) - Math.floor(nativeRuntime.resource)) <= 1, 'Rendered resource current agrees with combat state.');
assert.equal(renderedResource[3], nativeRuntime.resourceName, 'Rendered resource type agrees with combat state.');
const attributeValues = new Map(nativePanelPages.attributes.rows.map((row) => [row.label, Number(row.value.replaceAll(',', ''))]));
assert.ok(Math.abs(attributeValues.get('Health') - nativeRuntime.maxHealth) <= 1, 'Native panel Health agrees with combat max health.');
assert.ok(Math.abs(attributeValues.get('Mana (MP)') - nativeRuntime.maxResource) <= 1, 'Native panel Mana agrees with combat max resource.');
assert.ok(nativePanelPages.attributes.vitals.some((row) => row.includes(`Combat health ${Math.round(nativeRuntime.maxHealth).toLocaleString()}`)));
results.observations.nativePanelPages = { ...nativePanelPages, renderedMeters };
await page.locator('.equipment-stat-tabs').getByRole('tab', { name: 'attributes', exact: true }).click();
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
results.screenshots.nativeDungeonStatsMobile = await capture(page, '07-native-stats-attributes-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('button', { name: 'Return to pause menu' }).click();
await page.locator('.pause-menu').waitFor();
// Synthetic shell-route fixture over an installed live combat runtime. It
// tests the runtime's engaged-mob guard without leaving or saving a real run.
const blockedBefore = await profileSnapshot(page, nativeCreated.id);
const syntheticLock = await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
shell.setState({ phase: 'main-menu', activeCharacter: null });
combat.setState({ mobs: { '__qa-synthetic-live-mob__': { engaged: true, dead: false } } });
return { phase: shell.getState().phase, statRuleset: shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId)?.statRuleset };
});
assert.equal(syntheticLock.phase, 'main-menu');
await waitForScreen(page, '.main-menu-screen');
await page.locator('.main-menu-character').getByRole('button', { name: 'WoW compatibility', exact: true }).click();
const blockedAttempt = await page.evaluate(async () => {
const { shell } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
return { phase: shell.getState().phase, statRuleset: profile?.statRuleset, notice: shell.getState().notice };
});
assert.equal(blockedAttempt.statRuleset, blockedBefore.statRuleset);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
results.observations.syntheticLiveCombatLock = {
beforeClick: syntheticLock,
afterClick: blockedAttempt,
fixture: 'Synthetic main-menu route and engaged mob injected over the local live runtime; no run was left or saved.',
};
await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
combat.setState({ mobs: {} });
shell.setState({ phase: 'game', activeCharacter: null });
});
await page.locator('.pause-menu').waitFor({ timeout: 30_000 });
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await waitForScreen(page, '.main-menu-screen');
// Optionally set a synthetic online activity if its store is exposed by the
// production entry chunks; absence is recorded rather than faked via UI state.
const onlineFixture = await page.evaluate(async () => {
const { online } = await window.statRulesetStores();
if (!online) return { available: false };
online.setState({ snapshot: {
group: { id: 'qa-synthetic-online-group', members: [], activity: { id: 'qa-synthetic-live-activity' } },
invitations: [],
} });
return { available: true };
});
if (onlineFixture.available) {
await page.waitForFunction(() => [...document.querySelectorAll('.main-menu-character button')].every((button) => button.disabled));
const blocked = await page.evaluate(async () => {
const { shell } = await window.statRulesetStores();
const accepted = shell.getState().setSelectedCharacterStatRuleset('wow');
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
return { accepted, statRuleset: profile?.statRuleset };
});
assert.equal(blocked.accepted, false);
assert.equal(blocked.statRuleset, 'rom');
const profileButtonDisabled = await page.locator('.main-menu-character [aria-label="RoM stat profile"] button').evaluateAll((buttons) => buttons.every((button) => button.disabled));
assert.equal(profileButtonDisabled, true, 'A synthetic online activity disables the profile selector.');
results.observations.syntheticOnlineActivityLock = { ...onlineFixture, ...blocked, profileButtonDisabled, fixture: 'Synthetic online group activity injected into the local store.' };
await page.evaluate(async () => (await window.statRulesetStores()).online.getState().reset());
} else {
results.observations.syntheticOnlineActivityLock = { available: false, note: 'The online-group store was not exported by production entry chunks.' };
}
// Visible WoW switch after leaving the run must preserve all saved build data.
const beforeWowSwitch = await profileSnapshot(page, nativeCreated.id);
const beforeWowBuild = buildSnapshot(beforeWowSwitch);
assert.deepEqual(
{ ...beforeWowBuild, dungeonRunCheckpoint: null },
{ ...nativeBuild, dungeonRunCheckpoint: null },
'The first dungeon run leaves the character build and inventory intact.',
);
await chooseMode(page, 'wow');
const afterWowSwitch = await profileSnapshot(page, nativeCreated.id);
assert.equal(afterWowSwitch.statRuleset, 'wow');
assert.deepEqual(buildSnapshot(afterWowSwitch), beforeWowBuild, 'Switching rulesets preserves class pair, talents, bars, inventory, gear, and checkpoint.');
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
results.observations.persistedBuildAfterWowSwitch = buildSnapshot(afterWowSwitch);
results.screenshots.menuWowAfterDungeon = await capture(page, '06-menu-wow-after-dungeon');
// A second actual run verifies the saved compatibility selection reaches runtime.
await selectWailingCaverns(page);
results.screenshots.wowDungeonGameplay = await capture(page, '08-wow-compatibility-dungeon-gameplay-overlays-closed');
const wowRuntime = await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
const state = combat.getState();
return { statRuleset: state.statRuleset, profileRuleset: profile?.statRuleset, classId: state.classId };
});
assert.deepEqual(wowRuntime, { statRuleset: 'wow', profileRuleset: 'wow', classId: 'rom-priest' });
results.observations.wowCompatibilityDungeonRuntime = wowRuntime;
await capture(page, '07-wow-compatibility-dungeon');
await page.keyboard.press('Escape');
await page.locator('.pause-menu').waitFor();
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await waitForScreen(page, '.main-menu-screen');
const profileBeforeFinalReload = await profileSnapshot(page, nativeCreated.id);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
const finalNative = await profileSnapshot(page, nativeCreated.id);
const finalLegacy = await profileSnapshot(page, legacyCreated.id);
assert.equal(finalNative.statRuleset, 'wow');
assert.equal(finalLegacy.statRuleset, 'wow');
assert.deepEqual(finalNative.dungeonRunCheckpoint, profileBeforeFinalReload.dungeonRunCheckpoint,
'The saved checkpoint survives a reload alongside the selected ruleset.');
assert.deepEqual(
{ ...buildSnapshot(finalNative), dungeonRunCheckpoint: null },
{ ...nativeBuild, dungeonRunCheckpoint: null },
'Native to WoW switching and the dungeon run preserve the character build and inventory.',
);
results.observations.finalProfiles = {
native: { name: finalNative.name, classId: finalNative.classId, statRuleset: finalNative.statRuleset },
legacy: { name: finalLegacy.name, classId: finalLegacy.classId, statRuleset: finalLegacy.statRuleset },
checkpointRetainedAfterReload: Boolean(finalNative.dungeonRunCheckpoint),
};
assert.deepEqual(pageErrors, [], 'No uncaught browser errors occurred.');
assert.deepEqual(httpErrors, [], 'No production asset or server failures occurred.');
const unexpectedResponseErrors = responseErrors.filter((error) => !(error.status === 404 && new URL(error.url).pathname === '/favicon.ico'));
assert.deepEqual(unexpectedResponseErrors, [], 'No app assets or API resources failed to load (the browser may request a missing optional favicon).');
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
results.status = 'passed';
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
console.log(`Stat ruleset browser playtest passed: ${directory}`);
} catch (error) {
results.status = 'failed';
results.error = error?.stack ?? String(error);
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
if (page) {
results.screenshots.failure = await capture(page, 'failure', true).catch(() => null);
}
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
throw error;
} finally {
await browser?.close().catch(() => {});
if (runtime?.server.listening) await new Promise((resolve) => runtime.server.close(resolve));
cleanupDataDir();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -42,6 +42,22 @@ const DRUID_WARRIOR_BRIAR_CHILD = {
passiveSourceSkillId: 499955,
childSourceSkillId: 499956,
};
const ROGUE_WARDEN_REVIVAL_STATE = {
mainClass: 3,
secondaryClass: 7,
level: 60,
keyItemId: 546772,
parentSourceSkillId: 499542,
childSourceSkillId: 499544,
};
const KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT = {
mainClass: 6,
secondaryClass: 3,
level: 50,
keyItemId: 542123,
parentSourceSkillId: 499934,
childSourceSkillId: 499909,
};
const RACE_MATRIX = {
"rom-human": [1, 2, 3, 4, 5, 6],
"rom-elf": [1, 2, 3, 4, 7, 8],
@@ -597,6 +613,75 @@ for (const key of [...eligiblePairs].sort()) {
iconResolution: "native-interface-fdb",
});
}
// RuneWaker's Rogue/Warden 499544 state has its own level-60 row behind
// 499542, sharing the exact elite key item. Preserve that second learned
// candidate instead of dropping it at the one-row milestone selector.
if (mapping.mainClass === ROGUE_WARDEN_REVIVAL_STATE.mainClass
&& mapping.secondaryClass === ROGUE_WARDEN_REVIVAL_STATE.secondaryClass
&& milestone.level === ROGUE_WARDEN_REVIVAL_STATE.level
&& milestone.keyItemId === ROGUE_WARDEN_REVIVAL_STATE.keyItemId
&& candidate.sourceSkillId === ROGUE_WARDEN_REVIVAL_STATE.parentSourceSkillId) {
const child = candidates.find((entry) => entry.classIndex === ROGUE_WARDEN_REVIVAL_STATE.mainClass
&& entry.sourceSkillId === ROGUE_WARDEN_REVIVAL_STATE.childSourceSkillId
&& entry.learnLevel === milestone.level
&& entry.keyItemId === milestone.keyItemId
&& entry.prerequisites.includes(ROGUE_WARDEN_REVIVAL_STATE.parentSourceSkillId));
if (!child) throw new Error("Rogue/Warden Revival state 499544 lost its source unlock row.");
const childSpell = sourceSpell(child.sourceSkillId, mapping.mainClass);
if (!childSpell) throw new Error("Rogue/Warden Revival state 499544 has no MagicCollect row.");
skills.push({
id: `${CLASS_IDS[mapping.mainClass - 1]}-${CLASS_IDS[mapping.secondaryClass - 1]}-elite-${milestone.level}-${child.sourceSkillId}`,
classId: CLASS_IDS[mapping.mainClass - 1],
secondaryClassId: CLASS_IDS[mapping.secondaryClass - 1],
group: childSpell.passive ? "passive" : "elite",
unlockLevel: child.learnLevel,
keyItemId: child.keyItemId,
...childSpell,
ranks: [{
rank: 1,
level: child.learnLevel,
prerequisites: child.prerequisites,
...nestedRankSpell(childSpell, childSpell.sourceSkillId),
}],
icon: `/assets/ui/rom-spells/${childSpell.source.imageId}.png`,
iconResolution: "native-interface-fdb",
});
}
// Knight/Rogue God's Vengeance explicitly unlocks its linked 499909
// Punishment as a second learned row on the same level-50 key item.
if (mapping.mainClass === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.mainClass
&& mapping.secondaryClass === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.secondaryClass
&& milestone.level === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.level
&& milestone.keyItemId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.keyItemId
&& candidate.sourceSkillId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.parentSourceSkillId) {
const child = candidates.find((entry) => entry.classIndex === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.mainClass
&& entry.sourceSkillId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.childSourceSkillId
&& entry.learnLevel === milestone.level
&& entry.keyItemId === milestone.keyItemId
&& entry.prerequisites.includes(KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.parentSourceSkillId));
if (!child) throw new Error("Knight/Rogue God's Vengeance Punishment 499909 lost its learned source row.");
const childSpell = sourceSpell(child.sourceSkillId, mapping.mainClass);
if (!childSpell) throw new Error("Knight/Rogue God's Vengeance Punishment 499909 has no MagicCollect row.");
skills.push({
id: `${CLASS_IDS[mapping.mainClass - 1]}-${CLASS_IDS[mapping.secondaryClass - 1]}-elite-${milestone.level}-${childSpell.sourceSkillId}`,
classId: CLASS_IDS[mapping.mainClass - 1],
secondaryClassId: CLASS_IDS[mapping.secondaryClass - 1],
group: childSpell.passive ? "passive" : "elite",
unlockLevel: child.learnLevel,
keyItemId: child.keyItemId,
...childSpell,
ranks: [{
rank: 1,
level: child.learnLevel,
prerequisites: child.prerequisites,
...nestedRankSpell(childSpell, childSpell.sourceSkillId),
}],
icon: `/assets/ui/rom-spells/${childSpell.source.imageId}.png`,
iconResolution: "native-interface-fdb",
});
}
}
}
@@ -639,9 +724,10 @@ const audit = {
},
adaptations,
};
// In addition to the 660 ordinary milestones, this source emits the Druid/Warrior
// Cross of Thorns Attacks row behind its explicit learned prerequisite.
if (eliteMilestoneCount !== 660 || supplementalEliteCount !== 1 || unresolvedVisible.length !== 0
// In addition to the 660 ordinary milestones, the source emits the Druid/Warrior
// Cross of Thorns Attacks row, Rogue/Warden Revival state, and linked Knight/Rogue
// Punishment row behind their learned prerequisites.
if (eliteMilestoneCount !== 660 || supplementalEliteCount !== 3 || unresolvedVisible.length !== 0
|| audit.coverage.iconResolved !== skills.length || iconCoverage.requested !== iconCoverage.resolved) {
throw new Error(`Coverage failed: ${JSON.stringify(audit.coverage)}`);
}
@@ -0,0 +1,204 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
const dataRoot = path.join(sourceRoot, "Resource", "data");
const ruleOutput = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
const gearOutput = path.join(projectRoot, "src", "game", "generated", "romNativeGearCurves.generated.json");
const referencePath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
const baselinePath = path.join(projectRoot, "src", "game", "romBaseStatGrowth.generated.json");
const DATA_START = 140;
const RECORD_SIZE = 408;
const CLASS_ROWS = [
["rom-warrior", 1], ["rom-scout", 2], ["rom-rogue", 3], ["rom-mage", 4], ["rom-priest", 5],
["rom-knight", 6], ["rom-warden", 7], ["rom-druid", 8], ["rom-warlock", 9], ["rom-champion", 10],
];
const RACE_ROWS = [["rom-human", 0], ["rom-elf", 1], ["rom-dwarf", 2]];
const GENDER_ROWS = [["male", 0], ["female", 1]];
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
const ARG_FIELDS = [
"baseStrengthLevelOne", "baseStaminaLevelOne", "baseIntellectLevelOne", "baseWisdomLevelOne", "baseAgilityLevelOne", "baseHealthLevelOne", "baseManaLevelOne",
"baseStrengthGrowth", "baseStaminaGrowth", "baseIntellectGrowth", "baseWisdomGrowth", "baseAgilityGrowth", "baseHealthGrowth", "baseManaGrowth",
"strengthGrowthRate", "staminaGrowthRate", "intellectGrowthRate", "wisdomGrowthRate", "agilityGrowthRate", "healthGrowthRate", "manaGrowthRate",
"hpStrength", "hpStamina", "mpIntellect", "mpWisdom", "attackStrength", "attackIntellect", "attackAgility", "defenseStamina",
"baseHealthRecovery", "baseManaRecovery", "healthRecoveryStamina", "manaRecoveryWisdom", "magicalAttackIntellect", "magicalDefenseWisdom",
"baseMoveSpeed", "recoverySpMove", "recoverySpStop", "recoverySpAttack", "recoverySpUnderAttack", "recoverySpAttackTimeMove", "recoverySpAttackTimeStop",
"maxSP", "damageRate",
];
const FIX_FIELDS = [
"damageAttackCoefficient", "magicalDamageAttackCoefficient", "criticalLevelCoefficient", "damageLevelCoefficient", "missLevelCoefficient",
"secondHandMissCoefficient", "secondHandDamageCoefficient", "basePhysicalCritical", "basePhysicalCriticalPower", "baseMagicalCritical", "baseMagicalCriticalPower",
"magicDamageCoefficient", "magicMagicalDamageCoefficient", "dotMagicalDamageCoefficient", "healingMagicalDamageCoefficient", "healingDotMagicalDamageCoefficient",
"baseRateCoefficient", "baseResistCoefficient", "levelRateCoefficient", "levelResistCoefficient", "physicalHitBase", "physicalHitRange",
"physicalHitAgility", "physicalDodgeAgility", "magicalHitBase", "magicalHitRange", "magicalHitWisdom", "magicalDodgeAgility", "magicalDodgeWisdom",
"phantomRankCoefficient", "phantomLevelCoefficient",
];
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function cleanFloat(value) {
if (!Number.isFinite(value)) throw new Error("Native source table contains a non-finite float.");
return Number(value.toFixed(5));
}
function database(buffer, name, recordSize) {
const count = buffer.readInt32LE(132);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) throw new Error(`${name} fixed-record layout changed.`);
return count;
}
function row(buffer, index, expectedId, label) {
const offset = DATA_START + index * RECORD_SIZE;
if (offset + RECORD_SIZE > buffer.length
|| buffer.readInt32LE(offset) !== expectedId
|| buffer.readInt32LE(offset + 4) !== expectedId) {
throw new Error(`${label} row sentinel drifted (expected ${expectedId}).`);
}
return Array.from({ length: 100 }, (_, field) => cleanFloat(buffer.readFloatLE(offset + 8 + field * 4)));
}
function named(values, names) {
return Object.fromEntries(names.map((name, index) => [name, values[index]]));
}
function validateGrowthMirror(variants, baseline) {
for (const [classId] of CLASS_ROWS) for (const [raceId] of RACE_ROWS) for (const [gender] of GENDER_ROWS) {
const row = variants[classId][raceId][gender];
for (const [statIndex, stat] of STAT_KEYS.entries()) {
if (raceId !== "rom-human" || gender !== "male") continue;
const expected = baseline[classId][stat];
const actual = row.growth[stat];
for (const key of ["levelOne", "flatGrowth", "percentGrowth"]) {
if (Math.abs(actual[key] - expected[key]) > 0.00001) {
throw new Error(`${classId}.${stat}.${key} no longer matches romBaseStatGrowth.generated.json.`);
}
}
if (statIndex < 0) throw new Error("Unreachable stat index.");
}
}
}
const filenames = ["racetable.db", "sextable.db", "voctable.db", "fixtable.db"];
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (name) => [
name, await readFile(path.join(dataRoot, name)),
])));
database(buffers["racetable.db"], "racetable.db", RECORD_SIZE);
database(buffers["sextable.db"], "sextable.db", RECORD_SIZE);
database(buffers["voctable.db"], "voctable.db", RECORD_SIZE * 2);
database(buffers["fixtable.db"], "fixtable.db", RECORD_SIZE);
const classRows = Object.fromEntries(CLASS_ROWS.map(([classId, sourceClassId]) => {
const args = row(buffers["voctable.db"], sourceClassId * 2 + 1, 301000 + sourceClassId * 2 + 1, `vocation ${classId}`);
return [classId, { sourceClassId, args: named(args.slice(0, ARG_FIELDS.length), ARG_FIELDS) }];
}));
const raceRows = Object.fromEntries(RACE_ROWS.map(([raceId, sourceRaceId]) => [
raceId, { sourceRaceId, args: named(row(buffers["racetable.db"], sourceRaceId, 300000 + sourceRaceId, `race ${raceId}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
]));
const genderRows = Object.fromEntries(GENDER_ROWS.map(([gender, sourceGenderId]) => [
gender, { sourceGenderId, args: named(row(buffers["sextable.db"], sourceGenderId, 302000 + sourceGenderId, `gender ${gender}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
]));
const fixedRow = row(buffers["fixtable.db"], 1, 303001, "player fixed formula");
const playerFix = named(fixedRow.slice(0, FIX_FIELDS.length), FIX_FIELDS);
const variants = {};
for (const [classId] of CLASS_ROWS) {
variants[classId] = {};
for (const [raceId] of RACE_ROWS) {
variants[classId][raceId] = {};
for (const [gender] of GENDER_ROWS) {
const classArgs = classRows[classId].args;
const raceArgs = raceRows[raceId].args;
const genderArgs = genderRows[gender].args;
const growth = Object.fromEntries(STAT_KEYS.map((stat, index) => [stat, {
levelOne: cleanFloat(classArgs[ARG_FIELDS[index]] + raceArgs[ARG_FIELDS[index]] + genderArgs[ARG_FIELDS[index]]),
flatGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 7]] + raceArgs[ARG_FIELDS[index + 7]] + genderArgs[ARG_FIELDS[index + 7]]),
percentGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 14]] + raceArgs[ARG_FIELDS[index + 14]] + genderArgs[ARG_FIELDS[index + 14]]),
}]));
const coefficients = Object.fromEntries(ARG_FIELDS.slice(21, 42).map((field) => [
field,
cleanFloat(classArgs[field] + raceArgs[field] + genderArgs[field]),
]));
coefficients.damageRate = cleanFloat(classArgs.damageRate * raceArgs.damageRate * genderArgs.damageRate);
variants[classId][raceId][gender] = { growth, coefficients };
}
}
}
validateGrowthMirror(variants, JSON.parse(await readFile(baselinePath, "utf8")));
if (Math.abs(playerFix.damageAttackCoefficient) > 1e-8
|| Math.abs(playerFix.magicalDamageAttackCoefficient) > 1e-8
|| Math.abs(playerFix.healingMagicalDamageCoefficient - 0.5) > 1e-8) {
throw new Error("Player formula row changed: DMG/MDMG/heal MDMG mapping needs a source review.");
}
const ruleData = {
schemaVersion: 1,
source: {
project: "Runewaker client/server source data",
formulaFiles: [
"SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp",
"SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
"SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
],
recordFiles: Object.fromEntries(filenames.map((name) => [name, sha256(buffers[name])])),
recordLayout: { dataStart: DATA_START, recordSize: RECORD_SIZE, vocationSubrecordSize: RECORD_SIZE },
playerFixRecordId: 303001,
raceRecordIds: Object.fromEntries(RACE_ROWS.map(([key, id]) => [key, 300000 + id])),
genderRecordIds: Object.fromEntries(GENDER_ROWS.map(([key, id]) => [key, 302000 + id])),
vocationRecordIds: Object.fromEntries(CLASS_ROWS.map(([key, id]) => [key, 301000 + id * 2 + 1])),
fieldOrder: "ObjectStruct.h FightArgumentStruct fields 0-43; player FIX fields 0-30",
},
classRows,
raceRows,
genderRows,
playerFix,
variants,
manaClassIds: ["rom-mage", "rom-priest", "rom-knight", "rom-warden", "rom-druid", "rom-warlock", "rom-champion"],
formulaNotes: {
secondaryBase: "Main base at Level + secondary base at Level_Sub divided by 10; runtime supplies the source secondary level.",
baseProgression: "For each level after 1: value = value * (1 + combined percent growth) + combined flat growth.",
maxHealth: "(base HP + STR*HpStrArg + STA*HpStaArg + flat MaxHP) * MaxHP rate; minimum 1.",
maxMana: "MP classes only: (base MP + INT*MPIntArg + WIS*MpMndArg + flat MaxMP) * MaxMP rate; minimum 0.",
physicalAttack: "(STR*AtkStrArg + INT*AtkIntArg + AGI*AtkAgiArg + flat ATK) * ATK rate; minimum 1.",
physicalDefense: "(STA*DefArg + flat DEF) * DEF rate; source also adds armor-category base-defense contributions not represented by this curve adapter.",
magicalDefense: "(WIS*MDefArg + flat MDEF) * MDEF rate; minimum 1.",
magicalAttack: "(INT*MAtkArg + flat MATK) * MATK rate; minimum 1.",
physicalDamage: "(ATK / DmgAtkArg when nonzero + flat DMG) * merged class/race/sex DmgRate * DMG rate; player FIX DmgAtkArg is zero.",
magicalDamage: "(MATK / MDmgMAtkArg when nonzero + flat MDMG) * MDMG rate; player FIX MDmgMAtkArg is zero.",
magicPower: "MPow is the native all-magic BodyRate, neutral 1; it is not MATK or MDMG.",
healer: "HolyHeal uses skill OrgPower, capped MND contribution, optional MDMG*Dmg_HealArg (player FIX Dmg_HealArg is 0.5), MPow*HealPowerRate, and HealPoint*SpellTime. Target HealAbsorbRate and HealAbsorb apply after the caster amount. Skill OrgPower/timing and other skill flags are outside this stat helper.",
secondaryGate: "The source formula assumes a present secondary vocation and uses Level_Sub; the level-10 unlock gate and same-level fallback are simulator policy.",
},
};
const reference = JSON.parse(await readFile(referencePath, "utf8"));
const legacyToNative = (stats) => {
const native = {};
const direct = [
"strength", "stamina", "intellect", "wisdom", "agility", "health", "mana", "healthRecovery", "manaRecovery",
"physicalAttack", "physicalDefense", "magicalDefense", "magicalAttack", "physicalHit", "magicalHit",
"physicalCritical", "magicalCritical", "parry",
];
for (const key of direct) if (stats[key] > 0) native[key] = stats[key];
if (stats.dodge > 0) native.physicalDodge = stats.dodge;
if (stats.weaponDamage > 0) native.physicalDamage = stats.weaponDamage;
if (stats.magicalDamage > 0) native.magicalDamagePoint = stats.magicalDamage;
if (stats.magicalWeaponDamage > 0) native.magicalDamage = stats.magicalWeaponDamage;
if (stats.healingPower > 0) native.healPoint = stats.healingPower;
return native;
};
const compactProfiles = Object.fromEntries(Object.entries(reference.profiles).map(([classId, slots]) => [
classId,
Object.fromEntries(Object.entries(slots).map(([slot, rows]) => [slot, rows.map((entry) => legacyToNative(entry.stats))])),
]));
const gearData = {
schemaVersion: 1,
status: "source-item stat references with synthetic class/slot selection and level interpolation",
sourceRecordSha256: reference.sourceFiles,
statTypeMap: {
"12": "physicalAttack", "13": "physicalDefense", "14": "magicalDefense", "15": "magicalAttack",
"16": "physicalHit", "17": "physicalDodge", "18": "physicalCritical", "20": "magicalCritical",
"22": "parry", "25": "physicalDamage", "148": "magicalDamagePoint", "150": "healPoint", "191": "magicalDamage", "195": "magicalHit",
},
profiles: compactProfiles,
};
await writeFile(ruleOutput, `${JSON.stringify(ruleData, null, 2)}\n`);
await writeFile(gearOutput, `${JSON.stringify(gearData)}\n`);
console.log(`Exported native formula rules for ${CLASS_ROWS.length} classes, ${RACE_ROWS.length} races, and ${GENDER_ROWS.length} genders; wrote compact gear curves for ${Object.keys(compactProfiles).length} reference classes.`);
@@ -25,10 +25,10 @@
"eliteMilestones": 660,
"ordinaryFamilies": 269,
"sourceRankRows": 274,
"iconResolved": 930,
"iconResolved": 932,
"uniqueNativeIcons": 918,
"interfaceFdbEntries": 34954,
"executableOrNonCombat": 930,
"executableOrNonCombat": 932,
"unresolvedVisible": 0
},
"adaptations": [
+131 -81
View File
@@ -1,5 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import {
collectRealmSpellIds,
expandCoaSpellIds,
resolveCoaSourcePaths,
sharedRealmSpellIds,
} from "./coa-source-policy.mjs";
const projectRoot = path.resolve(import.meta.dirname, "..");
const outputPath = path.join(projectRoot, "src", "game", "coaLiveData.generated.json");
@@ -10,63 +16,18 @@ const extractedClientRoot = path.resolve(
"LadiksMPQEditor",
"client-current",
);
const realmSpellDbc = path.join(
extractedClientRoot,
"area-52",
"patch-D",
"DBFilesClient",
"Spell.dbc",
);
const globalSpellDbc = path.join(
extractedClientRoot,
"patch-T",
"DBFilesClient",
"Spell.dbc",
);
const legacySpellDbc = path.resolve(
projectRoot,
"..",
"LadiksMPQEditor",
"patch-T",
"DBFilesClient",
"Spell.dbc",
);
const realmSkillLineAbilityDbc = path.join(
extractedClientRoot,
"area-52",
"patch-D",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const globalSkillLineAbilityDbc = path.join(
extractedClientRoot,
"patch-M",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const legacySkillLineAbilityDbc = path.resolve(
projectRoot,
"..",
"LadiksMPQEditor",
"patch-M",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const defaultSpellDbc = fs.existsSync(realmSpellDbc)
? realmSpellDbc
: fs.existsSync(globalSpellDbc)
? globalSpellDbc
: legacySpellDbc;
const defaultSkillLineAbilityDbc = fs.existsSync(realmSkillLineAbilityDbc)
? realmSkillLineAbilityDbc
: fs.existsSync(globalSkillLineAbilityDbc)
? globalSkillLineAbilityDbc
: legacySkillLineAbilityDbc;
const cliArguments = process.argv.slice(2);
const fetchLiveTooltips = process.env.ASCENSION_COA_FETCH_TOOLTIPS === "1"
|| cliArguments.includes("--tooltips");
const spellDbcArgument = cliArguments.find((argument) => argument !== "--tooltips");
const spellDbcPath = path.resolve(spellDbcArgument ?? defaultSpellDbc);
const legacyExtractedRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor");
const sourcePaths = resolveCoaSourcePaths({
extractedClientRoot,
legacyExtractedRoot,
classicSpellOverride: spellDbcArgument,
});
const classicSpellDbcPath = sourcePaths.classicSpellDbcPath;
const coaSpellDbcPath = sourcePaths.coaSpellDbcPath;
const legacySupportDbcDirectory = path.resolve(
projectRoot,
"..",
@@ -314,6 +275,26 @@ function dbcRankCandidates(buffer, names, spellSkillLines) {
return candidates;
}
function rankCandidatesForRows(classSpellRows, spellDbcBuffer, skillLineAbilityDbcPath) {
const progressionRows = classSpellRows.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& /^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
));
const progressionNames = new Set(progressionRows.map((spell) => spell.name));
const progressionSkillLines = new Map();
for (const spell of progressionRows) {
const skillLines = progressionSkillLines.get(spell.name) ?? new Set();
for (const skillLineId of spell.skillLineIds) skillLines.add(skillLineId);
progressionSkillLines.set(spell.name, skillLines);
}
const spellSkillLines = dbcSkillLines(skillLineAbilityDbcPath);
const candidates = dbcRankCandidates(spellDbcBuffer, progressionNames, spellSkillLines)
.filter((candidate) => candidate.skillLineIds.some((skillLineId) => progressionSkillLines.get(candidate.name)?.has(skillLineId)));
return { progressionNames, candidates };
}
function dbcSpells(buffer, wantedIds, castTimes, durations, radii, ranges) {
assert(buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not a WDBC file");
const recordCount = buffer.readUInt32LE(4);
@@ -501,38 +482,55 @@ const entries = Object.values(realm.talents.entriesByTab)
.sort((left, right) => left.classId - right.classId || left.tabId - right.tabId || left.y - right.y || left.x - right.x || left.id - right.id);
assert(entries.length >= 3_500, `Only received ${entries.length} CoA tree entries`);
const progressionNames = new Set(classSpellRows
.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& /^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
))
.map((spell) => spell.name));
assert(fs.existsSync(spellDbcPath), `Spell.dbc was not found at ${spellDbcPath}`);
const spellDbcBuffer = fs.readFileSync(spellDbcPath);
const siblingSkillLineAbilityDbc = path.join(path.dirname(spellDbcPath), "SkillLineAbility.dbc");
const spellSkillLines = dbcSkillLines(
fs.existsSync(siblingSkillLineAbilityDbc) ? siblingSkillLineAbilityDbc : defaultSkillLineAbilityDbc,
assert(fs.existsSync(classicSpellDbcPath), `Classic Spell.dbc was not found at ${classicSpellDbcPath}`);
assert(fs.existsSync(coaSpellDbcPath), `CoA base Spell.dbc was not found at ${coaSpellDbcPath}`);
assert(fs.existsSync(sourcePaths.classicSkillLineAbilityDbcPath), `Classic SkillLineAbility.dbc was not found at ${sourcePaths.classicSkillLineAbilityDbcPath}`);
assert(fs.existsSync(sourcePaths.coaSkillLineAbilityDbcPath), `CoA SkillLineAbility.dbc was not found at ${sourcePaths.coaSkillLineAbilityDbcPath}`);
const classicSpellDbcBuffer = fs.readFileSync(classicSpellDbcPath);
const coaSpellDbcBuffer = fs.readFileSync(coaSpellDbcPath);
const classicProgression = rankCandidatesForRows(
classSpellRows.filter((spell) => classicClassIds.includes(spell.classId)),
classicSpellDbcBuffer,
sourcePaths.classicSkillLineAbilityDbcPath,
);
const progressionSkillLines = new Map();
for (const spell of classSpellRows.filter((candidate) => progressionNames.has(candidate.name))) {
const skillLines = progressionSkillLines.get(spell.name) ?? new Set();
for (const skillLineId of spell.skillLineIds) skillLines.add(skillLineId);
progressionSkillLines.set(spell.name, skillLines);
}
const rankCandidates = dbcRankCandidates(spellDbcBuffer, progressionNames, spellSkillLines)
.filter((candidate) => candidate.skillLineIds.some((skillLineId) => progressionSkillLines.get(candidate.name)?.has(skillLineId)));
const coaProgression = rankCandidatesForRows(
classSpellRows.filter((spell) => coaClassIds.includes(spell.classId)),
coaSpellDbcBuffer,
sourcePaths.coaSkillLineAbilityDbcPath,
);
const progressionNames = new Set([...classicProgression.progressionNames, ...coaProgression.progressionNames]);
const classicRankCandidates = classicProgression.candidates;
const coaRankCandidates = coaProgression.candidates;
const rankCandidates = [...classicRankCandidates, ...coaRankCandidates];
const coaEntrySpellIds = entries.flatMap((entry) => entry.rankDescriptions.map((rank) => rank.spellId));
const { classicIds: classicSpellIds, coaIds: coaRootSpellIds } = collectRealmSpellIds({
classicClassIds,
coaClassIds,
classSpellRows,
classicRankCandidates,
coaRankCandidates,
coaEntrySpellIds,
});
const coaSpellIds = expandCoaSpellIds(coaSpellDbcBuffer, coaRootSpellIds);
const wantedSpellIds = new Set([
...classSpellRows.map((spell) => spell.id),
...entries.flatMap((entry) => entry.rankDescriptions.map((rank) => rank.spellId)),
...coaEntrySpellIds,
...rankCandidates.map((candidate) => candidate.id),
...coaSpellIds,
]);
const castTimes = dbcNumberTable(path.join(supportDbcDirectory, "SpellCastTimes.dbc"), 1);
const durations = dbcNumberTable(path.join(supportDbcDirectory, "SpellDuration.dbc"), 3);
const radii = dbcNumberTable(path.join(supportDbcDirectory, "SpellRadius.dbc"), 3, true);
const ranges = dbcRangeTable(path.join(supportDbcDirectory, "SpellRange.dbc"));
const dbcById = dbcSpells(spellDbcBuffer, wantedSpellIds, castTimes, durations, radii, ranges);
const classicDbcById = dbcSpells(classicSpellDbcBuffer, classicSpellIds, castTimes, durations, radii, ranges);
const coaDbcById = dbcSpells(coaSpellDbcBuffer, coaSpellIds, castTimes, durations, radii, ranges);
const dbcById = new Map();
for (const spellId of wantedSpellIds) {
const dbc = classicSpellIds.has(spellId)
? classicDbcById.get(spellId)
: coaDbcById.get(spellId);
if (dbc) dbcById.set(spellId, dbc);
}
const entryRankBySpellId = new Map(entries.flatMap((entry) => (
entry.rankDescriptions.map((rank) => [rank.spellId, { ...rank, entryId: entry.id }])
)));
@@ -554,8 +552,8 @@ const progressionTooltipIds = [...new Set([
...classSpellRows
.filter((spell) => progressionNames.has(spell.name) && /^Rank \d+$/i.test(spell.rank))
.map((spell) => spell.id),
...rankCandidates.map((candidate) => candidate.id),
])];
...classicRankCandidates.map((candidate) => candidate.id),
])].filter((id) => classicSpellIds.has(id));
if (fetchLiveTooltips) {
const uncached = progressionTooltipIds.filter((spellId) => !previousTooltips.has(spellId));
console.log(`Fetching ${uncached.length} live spell tooltips (${previousTooltips.size} cached)`);
@@ -582,12 +580,33 @@ if (fetchLiveTooltips) {
}
const spellClassIds = new Map();
const coaSpellClassIds = new Map();
for (const spell of classSpellRows) {
const existing = spellClassIds.get(spell.id) ?? new Set();
existing.add(spell.classId);
spellClassIds.set(spell.id, existing);
if (coaClassIds.includes(spell.classId)) {
const coaExisting = coaSpellClassIds.get(spell.id) ?? new Set();
coaExisting.add(spell.classId);
coaSpellClassIds.set(spell.id, coaExisting);
}
}
const classRowBySpellId = new Map(classSpellRows.map((spell) => [spell.id, spell]));
const coaClassSpellRows = classSpellRows.filter((spell) => coaClassIds.includes(spell.classId));
const coaClassRowBySpellId = new Map(coaClassSpellRows.map((spell) => [spell.id, spell]));
const coaEntryBySpellId = new Map();
for (const entry of entries) {
for (const rank of entry.rankDescriptions) {
coaSpellClassIds.set(rank.spellId, (coaSpellClassIds.get(rank.spellId) ?? new Set()).add(entry.classId));
if (!coaEntryBySpellId.has(rank.spellId)) coaEntryBySpellId.set(rank.spellId, entry);
}
}
for (const candidate of coaRankCandidates) {
for (const spell of coaClassSpellRows) {
if (spell.name !== candidate.name || !spell.skillLineIds.some((id) => candidate.skillLineIds.includes(id))) continue;
coaSpellClassIds.set(candidate.id, (coaSpellClassIds.get(candidate.id) ?? new Set()).add(spell.classId));
}
}
const spells = [...wantedSpellIds]
.sort((left, right) => left - right)
.map((id) => {
@@ -602,7 +621,29 @@ const spells = [...wantedSpellIds]
level: classRow?.level ?? dbc?.spellLevel ?? 0,
rank: classRow?.rank || dbc?.rankText || (entryRank ? `Rank ${entryRank.rank}` : ""),
description: entryRank?.description || dbc?.description || "",
...(previousTooltips.has(id) ? { liveTooltip: previousTooltips.get(id) } : {}),
...(classicSpellIds.has(id) && previousTooltips.has(id) ? { liveTooltip: previousTooltips.get(id) } : {}),
sourceIds: classRow?.sourceIds ?? [],
advancementType: classRow?.advancementType || "",
advancementTabId: classRow?.advancementTabId || 0,
talented: classRow?.talented ?? false,
excluded: classRow?.excluded ?? false,
...(dbc ? { dbc } : {}),
};
});
const coaSpellOverrides = sharedRealmSpellIds(classicSpellIds, coaSpellIds)
.map((id) => {
const classRow = coaClassRowBySpellId.get(id);
const entry = coaEntryBySpellId.get(id);
const entryRank = entryRankBySpellId.get(id);
const dbc = coaDbcById.get(id);
return {
id,
classIds: [...(coaSpellClassIds.get(id) ?? [])].sort((left, right) => left - right),
name: classRow?.name || entry?.name || dbc?.name || "",
iconBasename: classRow?.iconBasename || entry?.iconBasename || "",
level: classRow?.level ?? dbc?.spellLevel ?? 0,
rank: classRow?.rank || dbc?.rankText || (entryRank ? `Rank ${entryRank.rank}` : ""),
description: entryRank?.description || dbc?.description || "",
sourceIds: classRow?.sourceIds ?? [],
advancementType: classRow?.advancementType || "",
advancementTabId: classRow?.advancementTabId || 0,
@@ -683,6 +724,9 @@ for (const [classId, rows] of classPages) {
});
}
}
const classRankCandidates = classicClassIds.includes(classId)
? classicRankCandidates
: coaRankCandidates;
for (const chain of bySignature.values()) {
const {
name,
@@ -693,7 +737,7 @@ for (const [classId, rows] of classPages) {
advancementType,
ranks,
} = chain;
for (const candidate of rankCandidates.filter((rank) => (
for (const candidate of classRankCandidates.filter((rank) => (
rank.name === name
&& rank.skillLineIds.some((skillLineId) => chainSkillLines.has(skillLineId))
))) {
@@ -748,7 +792,11 @@ const output = {
maximumLevel: Number(realm.max_level),
schemaVersion: realm.schema_version,
synchronizedAt: new Date().toISOString(),
spellDbcPath: spellDbcPath.replaceAll("\\", "/"),
spellDbcPath: classicSpellDbcPath.replaceAll("\\", "/"),
classicSpellDbcPath: classicSpellDbcPath.replaceAll("\\", "/"),
classicSkillLineAbilityDbcPath: sourcePaths.classicSkillLineAbilityDbcPath.replaceAll("\\", "/"),
coaSpellDbcPath: coaSpellDbcPath.replaceAll("\\", "/"),
coaSkillLineAbilityDbcPath: sourcePaths.coaSkillLineAbilityDbcPath.replaceAll("\\", "/"),
supportDbcDirectory: supportDbcDirectory.replaceAll("\\", "/"),
},
counts: {
@@ -762,6 +810,7 @@ const output = {
classicClassSpellRows: classSpellRows.filter((spell) => classicClassIds.includes(spell.classId)).length,
coaClassSpellRows: classSpellRows.filter((spell) => coaClassIds.includes(spell.classId)).length,
uniqueSpells: spells.length,
coaSpellOverrides: coaSpellOverrides.length,
dbcMatchedSpells: spells.filter((spell) => spell.dbc).length,
progressionChains: progressionChains.length,
classicProgressionChains: progressionChains.filter((chain) => classicClassIds.includes(chain.classId)).length,
@@ -776,6 +825,7 @@ const output = {
entries,
progressionChains,
spells,
coaSpellOverrides,
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });