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