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,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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user