Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
constants,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
@@ -13,8 +14,16 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
|
||||
const WOW_CAMPAIGN_CATALOG_PATH = path.join(
|
||||
PROJECT_ROOT,
|
||||
"src",
|
||||
"game",
|
||||
"generated",
|
||||
"dungeonCampaignCatalog.json",
|
||||
);
|
||||
export const CONTENT_SCHEMA_VERSION = 1;
|
||||
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
||||
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
|
||||
export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
@@ -179,15 +188,112 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
||||
return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
|
||||
}
|
||||
|
||||
function contentVersion(files) {
|
||||
function contentVersion(files, packs) {
|
||||
const digest = createHash("sha256");
|
||||
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`);
|
||||
for (const pack of packs) {
|
||||
digest.update(`pack\0${pack.id}\0${pack.label}\0${pack.bundledOnAndroid}\0${pack.dependencies.join(",")}\n`);
|
||||
}
|
||||
return `content-${digest.digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function assetPackId(url) {
|
||||
if (typeof url !== "string" || !url.startsWith("/")) return null;
|
||||
return contentPackForPath(url.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the logical install topology for the 54-dungeon WoW campaign.
|
||||
* Environment files intentionally remain in their independently downloadable
|
||||
* Manastorm packs; a dungeon pack owns its creatures and pulls in exactly the
|
||||
* environment/shared-creature packs referenced by the generated definition.
|
||||
*/
|
||||
export function campaignDungeonPackDefinitions(definitions) {
|
||||
const packs = new Map();
|
||||
for (const definition of definitions) {
|
||||
const dungeonPackId = `dungeon:${definition.id}`;
|
||||
if (definition.id === "wailing-caverns") {
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: true,
|
||||
dependencies: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const environmentUrls = [
|
||||
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
||||
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
||||
definition.assets?.navigation?.url,
|
||||
].filter(Boolean);
|
||||
const resolvedEnvironmentPacks = environmentUrls.map(assetPackId);
|
||||
if (!resolvedEnvironmentPacks.length || resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
|
||||
throw new Error(`${definition.id} has an environment asset outside a Manastorm content pack.`);
|
||||
}
|
||||
const environmentPackIds = new Set(resolvedEnvironmentPacks);
|
||||
if (environmentPackIds.size !== 1) {
|
||||
throw new Error(`${definition.id} must resolve to exactly one Manastorm environment pack.`);
|
||||
}
|
||||
const [environmentPackId] = environmentPackIds;
|
||||
packs.set(environmentPackId, {
|
||||
id: environmentPackId,
|
||||
label: packLabel(environmentPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
const creatureUrls = Object.values(definition.entities ?? {})
|
||||
.map((entity) => entity.visual?.model?.url)
|
||||
.filter(Boolean);
|
||||
const resolvedCreaturePacks = creatureUrls.map(assetPackId);
|
||||
if (resolvedCreaturePacks.some((id) => !id)) {
|
||||
throw new Error(`${definition.id} has a creature asset outside a content pack.`);
|
||||
}
|
||||
const referencedCreaturePacks = new Set(resolvedCreaturePacks.filter((id) => id !== dungeonPackId));
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks].filter((id) => id !== "shared:creatures");
|
||||
if (unexpectedCreaturePacks.length) {
|
||||
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
|
||||
}
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [environmentPackId, ...referencedCreaturePacks],
|
||||
});
|
||||
}
|
||||
return [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export async function loadCampaignDungeonPackDefinitions(
|
||||
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
|
||||
) {
|
||||
const definitions = await loadCampaignDungeonDefinitions(catalogPath);
|
||||
return campaignDungeonPackDefinitions(definitions);
|
||||
}
|
||||
|
||||
export async function loadCampaignDungeonDefinitions(
|
||||
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
|
||||
) {
|
||||
const campaign = JSON.parse(await readFile(catalogPath, "utf8"));
|
||||
if (!Array.isArray(campaign.definitions)) {
|
||||
throw new Error(`WoW campaign catalog is missing definitions: ${catalogPath}`);
|
||||
}
|
||||
if (campaign.definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
||||
throw new Error(
|
||||
`WoW campaign catalog must contain ${WOW_CAMPAIGN_DUNGEON_COUNT} dungeons; found ${campaign.definitions.length}.`,
|
||||
);
|
||||
}
|
||||
const dungeonIds = campaign.definitions.map((definition) => definition?.id);
|
||||
if (dungeonIds.some((id) => typeof id !== "string" || !id)
|
||||
|| new Set(dungeonIds).size !== dungeonIds.length) {
|
||||
throw new Error("WoW campaign catalog contains a missing or duplicate dungeon id.");
|
||||
}
|
||||
return campaign.definitions;
|
||||
}
|
||||
|
||||
export function createContentManifest(files, options = {}) {
|
||||
const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, "");
|
||||
const version = options.version ?? contentVersion(files);
|
||||
const packs = new Map();
|
||||
const fileEntries = {};
|
||||
|
||||
@@ -227,16 +333,93 @@ export function createContentManifest(files, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const declared of options.packDefinitions ?? []) {
|
||||
const current = packs.get(declared.id) ?? {
|
||||
id: declared.id,
|
||||
label: packLabel(declared.id),
|
||||
bytes: 0,
|
||||
fileCount: 0,
|
||||
bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(declared.id),
|
||||
dependencies: [],
|
||||
};
|
||||
packs.set(declared.id, {
|
||||
...current,
|
||||
label: declared.label ?? current.label,
|
||||
bundledOnAndroid: declared.bundledOnAndroid ?? current.bundledOnAndroid,
|
||||
dependencies: [...declared.dependencies],
|
||||
});
|
||||
}
|
||||
|
||||
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
const version = options.version ?? contentVersion(files, sortedPacks);
|
||||
|
||||
return {
|
||||
schemaVersion: CONTENT_SCHEMA_VERSION,
|
||||
version,
|
||||
createdAt: options.createdAt ?? new Date().toISOString(),
|
||||
minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION,
|
||||
packs: [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
||||
packs: sortedPacks,
|
||||
files: fileEntries,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertCampaignDungeonManifest(manifest, definitions) {
|
||||
if (!Array.isArray(definitions) || definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
||||
throw new Error(`Release content requires exactly ${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon definitions.`);
|
||||
}
|
||||
const expectedDefinitions = campaignDungeonPackDefinitions(definitions);
|
||||
const manifestPacks = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
||||
if (manifestPacks.size !== manifest.packs.length) {
|
||||
throw new Error("Release content manifest contains duplicate pack ids.");
|
||||
}
|
||||
|
||||
for (const expected of expectedDefinitions) {
|
||||
const actual = manifestPacks.get(expected.id);
|
||||
if (!actual) throw new Error(`Release content manifest is missing ${expected.id}.`);
|
||||
if (actual.bundledOnAndroid !== expected.bundledOnAndroid
|
||||
|| actual.dependencies.length !== expected.dependencies.length
|
||||
|| actual.dependencies.some((dependency, index) => dependency !== expected.dependencies[index])) {
|
||||
throw new Error(`Release content manifest has stale dependencies for ${expected.id}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pack of manifest.packs) {
|
||||
const packFiles = Object.values(manifest.files).filter((entry) => entry.packId === pack.id);
|
||||
const packBytes = packFiles.reduce((total, entry) => total + entry.bytes, 0);
|
||||
if (pack.fileCount !== packFiles.length || pack.bytes !== packBytes) {
|
||||
throw new Error(`Release content manifest has invalid file totals for ${pack.id}.`);
|
||||
}
|
||||
for (const dependency of pack.dependencies) {
|
||||
if (!manifestPacks.has(dependency)) {
|
||||
throw new Error(`Release content manifest is missing dependency ${dependency} for ${pack.id}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const definition of definitions) {
|
||||
const referencedUrls = [
|
||||
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
||||
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
||||
definition.assets?.navigation?.url,
|
||||
...Object.values(definition.entities ?? {}).map((entity) => entity.visual?.model?.url),
|
||||
].filter(Boolean);
|
||||
for (const logicalPath of referencedUrls) {
|
||||
if (!manifest.files[logicalPath]) {
|
||||
throw new Error(`Release content manifest is missing ${definition.id} asset ${logicalPath}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dungeonPackCount = definitions.filter((definition) => (
|
||||
manifestPacks.has(`dungeon:${definition.id}`)
|
||||
)).length;
|
||||
if (dungeonPackCount !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
||||
throw new Error(
|
||||
`Release content manifest contains ${dungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon packs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyObjectIfMissing(file, objectDirectory) {
|
||||
const objectName = `${file.sha256}${file.extension}`;
|
||||
const destination = path.join(objectDirectory, objectName);
|
||||
@@ -263,20 +446,35 @@ async function replaceJsonAtomically(destination, value) {
|
||||
|
||||
export async function publishContent(options = {}) {
|
||||
const contentDirectory = path.resolve(options.contentDirectory ?? process.env.CONTENT_DIR ?? path.join(PROJECT_ROOT, "content"));
|
||||
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
|
||||
const includeCampaignPacks = options.includeCampaignPacks !== false;
|
||||
const campaignDefinitions = includeCampaignPacks ? await loadCampaignDungeonDefinitions() : null;
|
||||
const campaignPackDefinitions = campaignDefinitions
|
||||
? campaignDungeonPackDefinitions(campaignDefinitions)
|
||||
: [];
|
||||
const packDefinitions = [
|
||||
...campaignPackDefinitions,
|
||||
...(options.packDefinitions ?? []),
|
||||
];
|
||||
const manifest = createContentManifest(files, { ...options, packDefinitions });
|
||||
if (campaignDefinitions) assertCampaignDungeonManifest(manifest, campaignDefinitions);
|
||||
|
||||
const objectDirectory = path.join(contentDirectory, "objects");
|
||||
const manifestDirectory = path.join(contentDirectory, "manifests");
|
||||
await mkdir(objectDirectory, { recursive: true });
|
||||
await mkdir(manifestDirectory, { recursive: true });
|
||||
|
||||
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
|
||||
let copiedObjects = 0;
|
||||
for (const file of files) {
|
||||
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
|
||||
}
|
||||
const manifest = createContentManifest(files, options);
|
||||
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
|
||||
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
|
||||
return { contentDirectory, copiedObjects, manifest };
|
||||
return {
|
||||
contentDirectory,
|
||||
copiedObjects,
|
||||
manifest,
|
||||
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCliArguments(argv) {
|
||||
@@ -296,6 +494,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
|
||||
const bytes = result.manifest.packs.reduce((total, pack) => total + pack.bytes, 0);
|
||||
console.log(`Published ${result.manifest.files ? Object.keys(result.manifest.files).length : 0} files (${bytes} bytes)`);
|
||||
console.log(`Content version: ${result.manifest.version}`);
|
||||
console.log(`WoW campaign dungeon packs: ${result.campaignDungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT}`);
|
||||
console.log(`New immutable objects: ${result.copiedObjects}`);
|
||||
console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BUNDLED_ANDROID_PACKS,
|
||||
PROJECT_ROOT,
|
||||
assertCampaignDungeonManifest,
|
||||
contentPackForPath,
|
||||
createContentManifest,
|
||||
loadCampaignDungeonPackDefinitions,
|
||||
publishContent,
|
||||
scanContentSources,
|
||||
} from "./publish-content.mjs";
|
||||
@@ -31,6 +34,160 @@ test("assigns stable player-facing packs from logical asset paths", () => {
|
||||
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
|
||||
});
|
||||
|
||||
test("gives every WoW campaign dungeon an exact install dependency closure", async () => {
|
||||
const campaign = JSON.parse(await readFile(path.join(
|
||||
PROJECT_ROOT,
|
||||
"src",
|
||||
"game",
|
||||
"generated",
|
||||
"dungeonCampaignCatalog.json",
|
||||
), "utf8"));
|
||||
const packDefinitions = await loadCampaignDungeonPackDefinitions();
|
||||
const dungeonDefinitions = packDefinitions.filter((pack) => pack.id.startsWith("dungeon:"));
|
||||
const wailingCreatureManifest = JSON.parse(await readFile(path.join(
|
||||
PROJECT_ROOT,
|
||||
"public",
|
||||
"assets",
|
||||
"creatures",
|
||||
"wailing-caverns",
|
||||
"animated-manifest.json",
|
||||
), "utf8"));
|
||||
assert.equal(campaign.definitions.length, 54);
|
||||
assert.equal(dungeonDefinitions.length, 54);
|
||||
assert.equal(wailingCreatureManifest.creatures.length, 16);
|
||||
|
||||
const referencedSourceFiles = [
|
||||
...campaign.definitions.flatMap((dungeon) => [
|
||||
...[
|
||||
...dungeon.assets.visual,
|
||||
...dungeon.assets.collision,
|
||||
dungeon.assets.navigation,
|
||||
].map((asset) => ({ dungeonId: dungeon.id, url: asset.url })),
|
||||
...Object.values(dungeon.entities).flatMap((entity) => (
|
||||
entity.visual?.model?.url ? [{ dungeonId: dungeon.id, url: entity.visual.model.url }] : []
|
||||
)),
|
||||
]),
|
||||
...wailingCreatureManifest.creatures.map((creature) => ({
|
||||
dungeonId: "wailing-caverns",
|
||||
url: creature.url,
|
||||
})),
|
||||
];
|
||||
await Promise.all(referencedSourceFiles.map(async ({ dungeonId, url }) => {
|
||||
try {
|
||||
await access(path.join(PROJECT_ROOT, "public", ...url.slice(1).split("/")));
|
||||
} catch {
|
||||
assert.fail(`${dungeonId} is missing ${url}`);
|
||||
}
|
||||
}));
|
||||
await Promise.all([
|
||||
"wailing-caverns-visual.glb",
|
||||
"wailing-caverns-collision.glb",
|
||||
"wailing-caverns-navigation.glb",
|
||||
].map((fileName) => access(path.join(
|
||||
PROJECT_ROOT,
|
||||
"src",
|
||||
"assets",
|
||||
"game",
|
||||
"dungeons",
|
||||
"wailing-caverns",
|
||||
fileName,
|
||||
))));
|
||||
|
||||
const logicalFiles = new Map();
|
||||
const addFile = (logicalPath) => logicalFiles.set(logicalPath, {
|
||||
logicalPath,
|
||||
sourcePath: logicalPath,
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 1,
|
||||
extension: path.extname(logicalPath),
|
||||
packId: contentPackForPath(logicalPath),
|
||||
});
|
||||
addFile("assets/game/dungeons/wailing-caverns/visual.glb");
|
||||
for (const dungeon of campaign.definitions) {
|
||||
for (const asset of [
|
||||
...dungeon.assets.visual,
|
||||
...dungeon.assets.collision,
|
||||
dungeon.assets.navigation,
|
||||
]) addFile(asset.url.slice(1));
|
||||
for (const entity of Object.values(dungeon.entities)) {
|
||||
if (entity.visual?.model?.url) addFile(entity.visual.model.url.slice(1));
|
||||
}
|
||||
}
|
||||
const manifest = createContentManifest([...logicalFiles.values()], {
|
||||
packDefinitions,
|
||||
version: "campaign-contract",
|
||||
});
|
||||
assert.doesNotThrow(() => assertCampaignDungeonManifest(manifest, campaign.definitions));
|
||||
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
||||
|
||||
for (const dungeon of campaign.definitions) {
|
||||
const pack = packs.get(`dungeon:${dungeon.id}`);
|
||||
assert.ok(pack, dungeon.id);
|
||||
if (dungeon.id === "wailing-caverns") {
|
||||
assert.equal(pack.bundledOnAndroid, true);
|
||||
assert.deepEqual(pack.dependencies, []);
|
||||
continue;
|
||||
}
|
||||
const environmentPack = contentPackForPath(dungeon.assets.visual[0].url.slice(1));
|
||||
const needsSharedCreatures = Object.values(dungeon.entities).some((entity) => (
|
||||
entity.visual?.model?.url?.startsWith("/assets/game/creatures/shared/")
|
||||
));
|
||||
assert.deepEqual(
|
||||
pack.dependencies,
|
||||
[environmentPack, ...(needsSharedCreatures ? ["shared:creatures"] : [])],
|
||||
dungeon.id,
|
||||
);
|
||||
assert.deepEqual(packs.get(environmentPack)?.dependencies, [], dungeon.id);
|
||||
}
|
||||
|
||||
for (const [dungeonId, environmentPack] of [
|
||||
["tinkertech-showdown", "manastorm:821-tinketechshowdown"],
|
||||
["frozen-reach", "manastorm:908-alvaencounter"],
|
||||
["forgotten-mine", "manastorm:1781-forgottenmine"],
|
||||
]) {
|
||||
const pack = packs.get(`dungeon:${dungeonId}`);
|
||||
assert.equal(pack.fileCount, 0);
|
||||
assert.equal(pack.bytes, 0);
|
||||
assert.deepEqual(pack.dependencies, [environmentPack]);
|
||||
}
|
||||
});
|
||||
|
||||
test("default release publishing fails before writing an incomplete campaign manifest", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-incomplete-content-test-"));
|
||||
const sourceDirectory = path.join(directory, "source");
|
||||
const contentDirectory = path.join(directory, "content");
|
||||
await mkdir(sourceDirectory, { recursive: true });
|
||||
await writeFile(path.join(sourceDirectory, "placeholder.glb"), Buffer.from("incomplete"));
|
||||
|
||||
try {
|
||||
await assert.rejects(publishContent({
|
||||
contentDirectory,
|
||||
sources: [{ directory: sourceDirectory, logicalPrefix: "assets/game/dungeons/placeholder" }],
|
||||
}), /Release content manifest is missing/);
|
||||
await assert.rejects(access(contentDirectory), { code: "ENOENT" });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("content versions change when only logical pack dependencies change", () => {
|
||||
const files = [{
|
||||
logicalPath: "assets/game/dungeons/test/creature.glb",
|
||||
sourcePath: "creature.glb",
|
||||
sha256: "b".repeat(64),
|
||||
bytes: 1,
|
||||
extension: ".glb",
|
||||
packId: "dungeon:test",
|
||||
}];
|
||||
const first = createContentManifest(files, {
|
||||
packDefinitions: [{ id: "dungeon:test", dependencies: ["manastorm:first"] }],
|
||||
});
|
||||
const second = createContentManifest(files, {
|
||||
packDefinitions: [{ id: "dungeon:test", dependencies: ["manastorm:second"] }],
|
||||
});
|
||||
assert.notEqual(first.version, second.version);
|
||||
});
|
||||
|
||||
test("publishes objects before a deterministic latest manifest", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-content-test-"));
|
||||
const sourceDirectory = path.join(directory, "source");
|
||||
@@ -45,7 +202,12 @@ test("publishes objects before a deterministic latest manifest", async () => {
|
||||
assert.equal(Object.keys(firstManifest.files).length, 1);
|
||||
assert.equal(firstManifest.packs[0].id, "dungeon:wailing-caverns");
|
||||
|
||||
const first = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" });
|
||||
const first = await publishContent({
|
||||
contentDirectory,
|
||||
sources,
|
||||
includeCampaignPacks: false,
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
});
|
||||
assert.equal(first.copiedObjects, 1);
|
||||
const latest = JSON.parse(await readFile(path.join(contentDirectory, "manifest.json"), "utf8"));
|
||||
assert.equal(latest.version, first.manifest.version);
|
||||
@@ -53,7 +215,12 @@ test("publishes objects before a deterministic latest manifest", async () => {
|
||||
assert.match(entry.url, /^\/content\/objects\/[a-f0-9]{64}\.glb$/);
|
||||
assert.equal(await readFile(path.join(contentDirectory, "objects", path.basename(entry.url)), "utf8"), "glb-one");
|
||||
|
||||
const second = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" });
|
||||
const second = await publishContent({
|
||||
contentDirectory,
|
||||
sources,
|
||||
includeCampaignPacks: false,
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
});
|
||||
assert.equal(second.copiedObjects, 0);
|
||||
assert.equal(second.manifest.version, first.manifest.version);
|
||||
} finally {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DUNGEON_DEFINITIONS } from "../../src/game/dungeonRegistry";
|
||||
import {
|
||||
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS,
|
||||
WOW_DUNGEON_NO_TRASH_EXCEPTIONS,
|
||||
} from "../../src/game/wowDungeonContentExceptions";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const publicRoot = path.join(projectRoot, "public");
|
||||
@@ -18,24 +23,14 @@ const validTargets = new Set([
|
||||
]);
|
||||
const validSchools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
|
||||
const validEffects = new Set([
|
||||
"damage", "heal", "aura", "control", "transform", "summon", "call-for-help", "encounter-event",
|
||||
]);
|
||||
const basicOnlyBossExceptions = new Map([
|
||||
[
|
||||
"wailing-caverns:entry-3653",
|
||||
"Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.",
|
||||
],
|
||||
[
|
||||
"wailing-caverns:deviate-faerie-dragon",
|
||||
"The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.",
|
||||
],
|
||||
]);
|
||||
const noTrashExceptions = new Map([
|
||||
[
|
||||
"trial-of-the-champion",
|
||||
"Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route.",
|
||||
],
|
||||
"damage", "heal", "aura", "target-aura", "control", "transform", "summon", "call-for-help", "encounter-event",
|
||||
]);
|
||||
const basicOnlyBossExceptions = new Map(
|
||||
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
|
||||
);
|
||||
const noTrashExceptions = new Map(
|
||||
WOW_DUNGEON_NO_TRASH_EXCEPTIONS.map(({ key, reason }) => [key, reason]),
|
||||
);
|
||||
const animationIds = {
|
||||
idle: new Set([0]),
|
||||
move: new Set([4, 5]),
|
||||
@@ -152,13 +147,21 @@ const [campaign, catalog, wailingFixture] = await Promise.all([
|
||||
readJson("dungeon-pipeline/fixtures/wailing-caverns/runtime-fixture.json"),
|
||||
]);
|
||||
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug));
|
||||
const definitions = catalog.definitions.filter((definition) => campaignIds.has(definition.id));
|
||||
const definitions = DUNGEON_DEFINITIONS.filter((definition) => campaignIds.has(definition.id));
|
||||
const coverage = new Map(catalog.coverage.map((entry) => [entry.dungeonId, entry]));
|
||||
const wailingModels = wailingFixtureModels(wailingFixture);
|
||||
const issues = [];
|
||||
if (basicOnlyBossExceptions.size !== WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.length) {
|
||||
issues.push("The shared basic-only boss exception contract contains a duplicate key.");
|
||||
}
|
||||
if (noTrashExceptions.size !== WOW_DUNGEON_NO_TRASH_EXCEPTIONS.length) {
|
||||
issues.push("The shared no-trash exception contract contains a duplicate key.");
|
||||
}
|
||||
const modelUsers = new Map();
|
||||
const animationFallbacks = [];
|
||||
const dungeons = [];
|
||||
const actualBasicOnlyBossKeys = new Set();
|
||||
const actualNoTrashDungeonKeys = new Set();
|
||||
|
||||
if (campaign.dungeons.length !== 54) {
|
||||
issues.push("Expected 54 campaign dungeons; found " + campaign.dungeons.length + ".");
|
||||
@@ -179,15 +182,15 @@ for (const dungeon of definitions) {
|
||||
let specialAttacks = 0;
|
||||
let nativeModels = 0;
|
||||
let proceduralModels = 0;
|
||||
const optionalBossEntityIds = new Set((dungeon.objectiveOrder ?? [])
|
||||
.filter((objective) => objective.optional)
|
||||
.map((objective) => dungeon.staticSpawns?.find((spawn) => spawn.id === objective.id)?.entityId)
|
||||
.filter(Boolean));
|
||||
|
||||
if (!entities.length) dungeonIssues.push("has no combat entities.");
|
||||
if (!bosses.length) dungeonIssues.push("has no bosses.");
|
||||
if (!mobs.length && !noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs.");
|
||||
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length)) dungeonIssues.push("has no combat spawns.");
|
||||
if (!mobs.length) {
|
||||
actualNoTrashDungeonKeys.add(dungeon.id);
|
||||
if (!noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs.");
|
||||
}
|
||||
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length || dungeon.encounterSpawnGroups?.some((group) => group.spawns?.length))) {
|
||||
dungeonIssues.push("has no combat spawns.");
|
||||
}
|
||||
|
||||
for (const spawn of dungeon.staticSpawns ?? []) {
|
||||
if (!dungeon.entities?.[spawn.entityId]) {
|
||||
@@ -201,6 +204,13 @@ for (const dungeon of definitions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of dungeon.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of group.spawns ?? []) {
|
||||
if (!dungeon.entities?.[spawn.entityId]) {
|
||||
dungeonIssues.push("encounter group " + group.id + " references missing entity " + spawn.entityId + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [entityId, entity] of entities) {
|
||||
const entityAttacks = entity.combat?.attacks ?? [];
|
||||
@@ -209,8 +219,9 @@ for (const dungeon of definitions) {
|
||||
attacks += entityAttacks.length;
|
||||
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
|
||||
specialAttacks += specials.length;
|
||||
if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) {
|
||||
if (entity.kind === "boss" && !specials.length) {
|
||||
const exceptionKey = dungeon.id + ":" + entityId;
|
||||
actualBasicOnlyBossKeys.add(exceptionKey);
|
||||
if (!basicOnlyBossExceptions.has(exceptionKey)) {
|
||||
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
|
||||
}
|
||||
@@ -247,6 +258,17 @@ for (const dungeon of definitions) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of basicOnlyBossExceptions.keys()) {
|
||||
if (!actualBasicOnlyBossKeys.has(key)) {
|
||||
issues.push("Stale basic-only boss exception does not match a runtime boss: " + key + ".");
|
||||
}
|
||||
}
|
||||
for (const key of noTrashExceptions.keys()) {
|
||||
if (!actualNoTrashDungeonKeys.has(key)) {
|
||||
issues.push("Stale no-trash exception does not match a runtime dungeon: " + key + ".");
|
||||
}
|
||||
}
|
||||
|
||||
for (const [url, users] of modelUsers) {
|
||||
try {
|
||||
const names = await readGlbAnimationNames(url);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,11 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "
|
||||
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
|
||||
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
|
||||
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
|
||||
const gameObjectAssetManifestFile = path.join(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"WOW_GAMEOBJECT_ASSET_MANIFEST.json",
|
||||
);
|
||||
const compressedOutputFile = path.join(
|
||||
projectRoot,
|
||||
"src",
|
||||
@@ -81,6 +86,19 @@ const epochSpellDbc = path.join(
|
||||
"DBFilesClient",
|
||||
"Spell.dbc",
|
||||
);
|
||||
const standardGameObjectDisplayDbc = path.join(
|
||||
workRoot,
|
||||
"deadmines",
|
||||
"client-dbc",
|
||||
"DBFilesClient",
|
||||
"GameObjectDisplayInfo.dbc",
|
||||
);
|
||||
const epochGameObjectDisplayDbc = path.join(
|
||||
workRoot,
|
||||
"epoch-client",
|
||||
"DBFilesClient",
|
||||
"GameObjectDisplayInfo.dbc",
|
||||
);
|
||||
|
||||
const WAILING_SERVER_ENCOUNTERS = Object.freeze([
|
||||
[3671, "Lady Anacondra"],
|
||||
@@ -200,6 +218,100 @@ function slugPart(value) {
|
||||
.replace(/^-|-$/g, "") || "unknown";
|
||||
}
|
||||
|
||||
function compileBossProgressionEncounter(recipe, bosses, syntheticTrash) {
|
||||
const runtimeBosses = bosses.filter((boss) => !boss.eventOnly);
|
||||
const requiredBosses = runtimeBosses.filter((boss) => !boss.optional);
|
||||
const rules = runtimeBosses.map((boss, index) => ({
|
||||
id: `boss-${index + 1}-defeated`,
|
||||
trigger: { kind: "entities-dead", entityIds: [boss.id] },
|
||||
actions: [
|
||||
{
|
||||
id: `record-boss-${index + 1}`,
|
||||
kind: "complete-objective",
|
||||
objectiveId: boss.id,
|
||||
persistence: "run",
|
||||
},
|
||||
...(!boss.optional ? [{
|
||||
id: `checkpoint-boss-${index + 1}`,
|
||||
kind: "set-checkpoint",
|
||||
checkpointId: boss.id,
|
||||
persistence: "checkpoint",
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
const finalBoss = requiredBosses.at(-1);
|
||||
// Event-only objectives belong to detailed instance orchestration. A
|
||||
// baseline must not declare the dungeon complete before that event fires.
|
||||
if (finalBoss && !bosses.some((boss) => boss.eventOnly)) {
|
||||
rules.push({
|
||||
id: "all-required-bosses-defeated",
|
||||
// Re-evaluate persisted objectives independently of kill order. Tying
|
||||
// completion to the catalog's final actor can strand a run when players
|
||||
// defeat that actor early and finish another required boss afterward.
|
||||
trigger: { kind: "tick" },
|
||||
guards: requiredBosses.map((boss) => ({
|
||||
kind: "objective-complete",
|
||||
objectiveId: boss.id,
|
||||
equals: true,
|
||||
})),
|
||||
actions: [
|
||||
{
|
||||
id: "mark-dungeon-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "dungeon-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
},
|
||||
{
|
||||
id: "enter-complete-phase",
|
||||
kind: "set-phase",
|
||||
phase: "complete",
|
||||
persistence: "run",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
const sourceBacked = !syntheticTrash;
|
||||
return {
|
||||
id: `${recipe.slug}:boss-progression`,
|
||||
dungeonId: recipe.slug,
|
||||
initialPhase: "progression",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
resetPolicy: { bossWipePhase: "progression", checkpointPhase: "progression" },
|
||||
source: {
|
||||
fidelity: sourceBacked ? "source-backed-adapted" : "healer-man-authored",
|
||||
scope: "boss-progression",
|
||||
files: sourceBacked
|
||||
? [
|
||||
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
|
||||
"src/game/generated/azerothCoreScriptSpells.json",
|
||||
"src/game/generated/azerothCoreWorldAi.json",
|
||||
]
|
||||
: [
|
||||
`dungeon-pipeline/recipes/${recipe.slug}.json`,
|
||||
"src/game/generated/manastormRuntimeCatalog.json",
|
||||
],
|
||||
reviewedBehavior: sourceBacked
|
||||
? "Live source-backed boss runtime IDs record persistent objective completion and stable post-boss checkpoints; all required non-event bosses complete the baseline run."
|
||||
: "Catalog boss identities record persistent objective completion and stable post-boss checkpoints; all required bosses complete the authored fallback run.",
|
||||
adaptations: [
|
||||
"Boss progression is intentionally order-independent unless a detailed encounter definition supplies an explicit gate.",
|
||||
"Optional rare bosses record completion when present but do not gate the final completion state.",
|
||||
"A checkpoint retains completed objectives and resets temporary combat state; it does not invent a source graveyard or teleport.",
|
||||
],
|
||||
remainingGaps: [
|
||||
...(sourceBacked ? [] : [
|
||||
"Replace the authored fallback with authoritative Ascension population and instance-script data when those sources become available.",
|
||||
]),
|
||||
"Port exact instance-script doors, waves, escorts, vehicles, dialogue, gossip, quests, area triggers, and event-only bosses where the source requires them.",
|
||||
"Enable source game-object visuals, collision, and state transitions only after their client models and script bindings are reviewed.",
|
||||
"Record an end-to-end traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest before marking the dungeon validated.",
|
||||
],
|
||||
},
|
||||
rules,
|
||||
};
|
||||
}
|
||||
|
||||
function inferEnvironmentMode(recipe, assetPackage) {
|
||||
if (recipe.environmentMode !== "detect") return recipe.environmentMode;
|
||||
const sourceFiles = assetPackage.visual.flatMap((chunk) => chunk.provenance?.sourceFiles ?? []);
|
||||
@@ -214,6 +326,395 @@ const ADT_SOURCE_BASES = new Set([
|
||||
]);
|
||||
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
|
||||
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]);
|
||||
const SOURCE_GAME_OBJECT_TYPES = new Set(Array.from({ length: 36 }, (_, type) => type));
|
||||
|
||||
function rowValue(row, names, fallback = undefined) {
|
||||
for (const name of names) {
|
||||
if (row?.[name] !== undefined && row[name] !== null) return row[name];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function sourceGameObjectPosition(row) {
|
||||
const wowX = finiteNumber(rowValue(row, ["position_x", "positionX", "x"]));
|
||||
const wowY = finiteNumber(rowValue(row, ["position_y", "positionY", "y"]));
|
||||
const wowZ = finiteNumber(rowValue(row, ["position_z", "positionZ", "z"]));
|
||||
return [-wowX, wowZ, wowY];
|
||||
}
|
||||
|
||||
function sourceGameObjectKind(type, name) {
|
||||
const normalized = normalizedName(name);
|
||||
if (type === 0 || type === 35) {
|
||||
if (normalized.includes("bridge") || normalized.includes("drawbridge")) return "bridge";
|
||||
if (normalized.includes("gate") || normalized.includes("portcullis")) return "gate";
|
||||
return "door";
|
||||
}
|
||||
if (type === 1 || type === 31) return "switch";
|
||||
if (type === 2 || type === 10 || type === 23) return "quest-object";
|
||||
if (type === 3) return /vein|deposit|herb|plant|school|node/i.test(name) ? "resource" : "chest";
|
||||
if (type === 5) return "generic";
|
||||
if (type === 6 || type === 12) return "trap";
|
||||
if (type === 7 || type === 32) return "chair";
|
||||
if (type === 8) return "spell-focus";
|
||||
if (type === 9) return "text";
|
||||
if (type === 11 || type === 15) return "transport";
|
||||
if (type === 13) return "camera";
|
||||
if (type === 18) return "ritual";
|
||||
if (type === 19) return "mailbox";
|
||||
if (type === 22) return "spellcaster";
|
||||
if (type === 25) return "fishing-hole";
|
||||
if (type === 33) return "destructible";
|
||||
return type === 30 ? "aura-generator" : "generic";
|
||||
}
|
||||
|
||||
function sourceGameObjectHalfExtents(kind, sourceSize) {
|
||||
const size = clamp(finiteNumber(sourceSize, 1), 0.1, 6);
|
||||
if (kind === "door" || kind === "gate") return vector([1.25 * size, 2 * size, 0.2 * size]);
|
||||
if (kind === "bridge") return vector([2.5 * size, 0.2 * size, 1.25 * size]);
|
||||
if (kind === "switch") return vector([0.35 * size, 0.5 * size, 0.35 * size]);
|
||||
if (kind === "chest" || kind === "resource") return vector([0.6 * size, 0.45 * size, 0.45 * size]);
|
||||
if (kind === "trap" || kind === "trigger") return vector([0.8 * size, 0.1 * size, 0.8 * size]);
|
||||
if (kind === "transport") return vector([2 * size, 0.25 * size, 2 * size]);
|
||||
if (kind === "destructible") return vector([1.5 * size, 1.5 * size, 1.5 * size]);
|
||||
return vector([0.45 * size, 0.65 * size, 0.45 * size]);
|
||||
}
|
||||
|
||||
function clientGameObjectHalfExtents(display, sourceSize) {
|
||||
if (!display?.bounds) return null;
|
||||
const size = clamp(finiteNumber(sourceSize, 1), 0.01, 100);
|
||||
const spans = display.bounds.max.map((value, axis) => value - display.bounds.min[axis]);
|
||||
if (!spans.every((value) => Number.isFinite(value) && value > 0 && value <= 1_000)) return null;
|
||||
// Client bounds are x/y/z. Runtime draft space is -x/z/y.
|
||||
return vector([
|
||||
clamp(spans[0] * size / 2, 0.025, 500),
|
||||
clamp(spans[2] * size / 2, 0.025, 500),
|
||||
clamp(spans[1] * size / 2, 0.025, 500),
|
||||
]);
|
||||
}
|
||||
|
||||
function parseGameObjectDisplays(buffer) {
|
||||
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
const stringSize = buffer.readUInt32LE(16);
|
||||
if (fieldCount !== 19 || recordSize !== fieldCount * 4) return new Map();
|
||||
const stringsOffset = 20 + recordCount * recordSize;
|
||||
const readString = (offset) => {
|
||||
if (!offset || offset >= stringSize) return "";
|
||||
const start = stringsOffset + offset;
|
||||
const end = buffer.indexOf(0, start);
|
||||
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||||
};
|
||||
const displays = new Map();
|
||||
for (let index = 0; index < recordCount; index += 1) {
|
||||
const base = 20 + index * recordSize;
|
||||
const id = buffer.readUInt32LE(base);
|
||||
const modelPath = readString(buffer.readUInt32LE(base + 4)).replace(/\\/g, "/");
|
||||
const min = [12, 13, 14].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
|
||||
const max = [15, 16, 17].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
|
||||
displays.set(id, {
|
||||
id,
|
||||
modelPath,
|
||||
bounds: { min, max },
|
||||
});
|
||||
}
|
||||
return displays;
|
||||
}
|
||||
|
||||
function sourceGameObjectStates(kind) {
|
||||
if (kind === "door" || kind === "gate" || kind === "bridge") {
|
||||
return [
|
||||
// Adapted source objects remain non-blocking until an instance-script
|
||||
// transition has been reviewed; otherwise a closed door with no opener
|
||||
// would turn an incomplete import into an unavoidable soft lock.
|
||||
{ id: "closed", label: "Closed", visible: true, blocksMovement: false, blocksSight: false },
|
||||
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false },
|
||||
];
|
||||
}
|
||||
if (kind === "trap") {
|
||||
return [
|
||||
{ id: "armed", label: "Armed", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "disabled", label: "Disabled", visible: false, blocksMovement: false, interactionEnabled: false },
|
||||
];
|
||||
}
|
||||
if (kind === "transport") {
|
||||
return [
|
||||
{ id: "stationary", label: "Stationary", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "moving", label: "Moving", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
];
|
||||
}
|
||||
if (kind === "destructible") {
|
||||
return [
|
||||
{ id: "intact", label: "Intact", visible: true, blocksMovement: false, blocksSight: false },
|
||||
{ id: "destroyed", label: "Destroyed", visible: false, blocksMovement: false, blocksSight: false },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: "available", label: "Available", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "inactive", label: "Inactive", visible: false, blocksMovement: false, interactionEnabled: false },
|
||||
];
|
||||
}
|
||||
|
||||
function sourceGameObjectInitialState(kind, row) {
|
||||
const sourceState = finiteNumber(rowValue(row, ["state", "State"]), 1);
|
||||
if (kind === "door" || kind === "gate" || kind === "bridge") return sourceState === 0 ? "open" : "closed";
|
||||
if (kind === "trap") return sourceState === 0 ? "disabled" : "armed";
|
||||
if (kind === "transport") return sourceState === 0 ? "moving" : "stationary";
|
||||
if (kind === "destructible") return sourceState === 2 ? "destroyed" : "intact";
|
||||
return sourceState === 0 ? "inactive" : "available";
|
||||
}
|
||||
|
||||
function sourceGameObjectVisual(kind) {
|
||||
const colors = {
|
||||
door: "#5f4932",
|
||||
gate: "#4d4a46",
|
||||
bridge: "#66543c",
|
||||
switch: "#c38d35",
|
||||
chest: "#8f682f",
|
||||
resource: "#54704b",
|
||||
trap: "#713b36",
|
||||
transport: "#53616b",
|
||||
camera: "#475f78",
|
||||
ritual: "#74518f",
|
||||
spellcaster: "#496e87",
|
||||
"spell-focus": "#5f698d",
|
||||
"aura-generator": "#586f80",
|
||||
destructible: "#6b5849",
|
||||
"quest-object": "#8a773b",
|
||||
generic: "#65584b",
|
||||
chair: "#725942",
|
||||
text: "#806c48",
|
||||
mailbox: "#596778",
|
||||
"fishing-hole": "#3d6878",
|
||||
trigger: "#58606a",
|
||||
};
|
||||
return { hidden: true, color: colors[kind] ?? "#65584b" };
|
||||
}
|
||||
|
||||
function compileSourceGameObjects(draft, mapPosition, recipe, gameObjectDisplays) {
|
||||
const templates = new Map((draft?.gameObjectTemplates ?? []).map((template) => [
|
||||
finiteNumber(rowValue(template, ["entry", "id"]), -1),
|
||||
template,
|
||||
]));
|
||||
return (draft?.gameObjects ?? [])
|
||||
.map((row, index) => {
|
||||
const templateId = finiteNumber(rowValue(row, ["id", "entry"]), -1);
|
||||
const template = templates.get(templateId);
|
||||
const type = finiteNumber(rowValue(template, ["type", "Type"]), -1);
|
||||
if (!template || !SOURCE_GAME_OBJECT_TYPES.has(type)) return null;
|
||||
const spawnId = finiteNumber(rowValue(row, ["guid", "spawnId"]), index + 1);
|
||||
const name = String(rowValue(template, ["name", "Name"], `Game object ${templateId}`)).trim()
|
||||
|| `Game object ${templateId}`;
|
||||
const kind = sourceGameObjectKind(type, name);
|
||||
const displayId = finiteNumber(rowValue(template, ["displayId", "displayID", "DisplayID"]), 0);
|
||||
const display = gameObjectDisplays.get(displayId);
|
||||
const sourceSize = rowValue(template, ["size", "Size"], 1);
|
||||
const clientHalfExtents = clientGameObjectHalfExtents(display, sourceSize);
|
||||
return {
|
||||
id: `gameobject-${spawnId}`,
|
||||
name,
|
||||
kind,
|
||||
position: mapPosition(sourceGameObjectPosition(row)),
|
||||
yaw: rounded(-finiteNumber(rowValue(row, ["orientation", "yaw"]))),
|
||||
halfExtents: clientHalfExtents ?? sourceGameObjectHalfExtents(kind, sourceSize),
|
||||
initialState: sourceGameObjectInitialState(kind, row),
|
||||
states: sourceGameObjectStates(kind),
|
||||
visual: sourceGameObjectVisual(kind),
|
||||
source: {
|
||||
fidelity: "source-backed-adapted",
|
||||
files: [
|
||||
`dungeon-pipeline/work/${recipe.slug}/source-snapshot.json`,
|
||||
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
|
||||
],
|
||||
templateId,
|
||||
spawnId,
|
||||
gameObjectType: type,
|
||||
...(displayId > 0 ? { clientDisplayId: displayId } : {}),
|
||||
...(display?.modelPath ? { clientModelPath: display.modelPath } : {}),
|
||||
...(clientHalfExtents ? { clientBounds: display.bounds } : {}),
|
||||
note: clientHalfExtents
|
||||
? "Source transform/state and exact client display bounds/model path are preserved. Visual assets and collision remain hidden until the model export and instance-script transition are reviewed; interactions remain disabled until ported."
|
||||
: "Source position, orientation, type, initial state, and template scale are preserved. Visuals and collision remain hidden until client-display bounds/models are reviewed; encounter-script interactions remain disabled until ported.",
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
function increment(map, key, amount = 1) {
|
||||
map.set(key, (map.get(key) ?? 0) + amount);
|
||||
}
|
||||
|
||||
function sortedCountEntries(map, keyName) {
|
||||
return [...map.entries()]
|
||||
.sort(([left], [right]) => String(left).localeCompare(String(right), undefined, { numeric: true }))
|
||||
.map(([key, instances]) => ({ [keyName]: key, instances }));
|
||||
}
|
||||
|
||||
function gameObjectAssetManifestFor(definitions) {
|
||||
const assets = new Map();
|
||||
const nonVisualObjects = new Map();
|
||||
const unresolvedModels = new Map();
|
||||
const unresolvedBounds = new Map();
|
||||
let typedInstances = 0;
|
||||
let visualDisplayInstances = 0;
|
||||
let resolvedModelInstances = 0;
|
||||
let boundedInstances = 0;
|
||||
|
||||
for (const dungeon of definitions) {
|
||||
for (const object of dungeon.serverGameObjects ?? []) {
|
||||
if (object.source?.fidelity !== "source-backed-adapted") continue;
|
||||
typedInstances += 1;
|
||||
const displayId = Number(object.source.clientDisplayId);
|
||||
const modelPath = String(object.source.clientModelPath ?? "").replace(/\\/g, "/");
|
||||
const hasBounds = Boolean(object.source.clientBounds);
|
||||
if (!Number.isFinite(displayId) || displayId <= 0) {
|
||||
const templateId = Number(object.source.templateId) || 0;
|
||||
let nonVisual = nonVisualObjects.get(templateId);
|
||||
if (!nonVisual) {
|
||||
nonVisual = {
|
||||
templateId,
|
||||
instances: 0,
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
};
|
||||
nonVisualObjects.set(templateId, nonVisual);
|
||||
}
|
||||
nonVisual.instances += 1;
|
||||
increment(nonVisual.dungeons, dungeon.id);
|
||||
increment(nonVisual.kinds, object.kind);
|
||||
continue;
|
||||
}
|
||||
visualDisplayInstances += 1;
|
||||
if (hasBounds) boundedInstances += 1;
|
||||
|
||||
if (!modelPath) {
|
||||
const key = displayId;
|
||||
let unresolved = unresolvedModels.get(key);
|
||||
if (!unresolved) {
|
||||
unresolved = {
|
||||
displayId: key,
|
||||
instances: 0,
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
templateIds: new Set(),
|
||||
};
|
||||
unresolvedModels.set(key, unresolved);
|
||||
}
|
||||
unresolved.instances += 1;
|
||||
increment(unresolved.dungeons, dungeon.id);
|
||||
increment(unresolved.kinds, object.kind);
|
||||
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
|
||||
} else {
|
||||
resolvedModelInstances += 1;
|
||||
const key = modelPath.toLowerCase();
|
||||
let asset = assets.get(key);
|
||||
if (!asset) {
|
||||
const extension = path.extname(modelPath).toLowerCase().replace(/^\./, "") || "unknown";
|
||||
const targetPath = modelPath.replace(/\.[^/.]+$/, ".glb");
|
||||
asset = {
|
||||
sourceModelPath: modelPath,
|
||||
sourceType: extension,
|
||||
targetUrl: `/assets/game/world-objects/${targetPath}`,
|
||||
instances: 0,
|
||||
boundedInstances: 0,
|
||||
displayIds: new Set(),
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
};
|
||||
assets.set(key, asset);
|
||||
}
|
||||
asset.instances += 1;
|
||||
if (hasBounds) asset.boundedInstances += 1;
|
||||
asset.displayIds.add(displayId);
|
||||
increment(asset.dungeons, dungeon.id);
|
||||
increment(asset.kinds, object.kind);
|
||||
}
|
||||
|
||||
if (!hasBounds) {
|
||||
const key = displayId;
|
||||
let unresolved = unresolvedBounds.get(key);
|
||||
if (!unresolved) {
|
||||
unresolved = {
|
||||
displayId: key,
|
||||
modelPath: modelPath || undefined,
|
||||
instances: 0,
|
||||
dungeons: new Map(),
|
||||
kinds: new Map(),
|
||||
templateIds: new Set(),
|
||||
};
|
||||
unresolvedBounds.set(key, unresolved);
|
||||
}
|
||||
unresolved.instances += 1;
|
||||
increment(unresolved.dungeons, dungeon.id);
|
||||
increment(unresolved.kinds, object.kind);
|
||||
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serializeUnresolved = (entry) => ({
|
||||
displayId: entry.displayId,
|
||||
...(entry.modelPath ? { modelPath: entry.modelPath } : {}),
|
||||
instances: entry.instances,
|
||||
templateIds: [...entry.templateIds].sort((left, right) => left - right),
|
||||
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
|
||||
kinds: sortedCountEntries(entry.kinds, "kind"),
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedBy: "scripts/dungeon-pipeline/compile-campaign-catalog.mjs",
|
||||
summary: {
|
||||
typedInstances,
|
||||
visualDisplayInstances,
|
||||
nonVisualInstances: typedInstances - visualDisplayInstances,
|
||||
resolvedModelInstances,
|
||||
boundedInstances,
|
||||
uniqueAssets: assets.size,
|
||||
unresolvedModelInstances: visualDisplayInstances - resolvedModelInstances,
|
||||
unresolvedBoundInstances: visualDisplayInstances - boundedInstances,
|
||||
unresolvedModelDisplayIds: unresolvedModels.size,
|
||||
unresolvedBoundDisplayIds: unresolvedBounds.size,
|
||||
},
|
||||
assets: [...assets.values()]
|
||||
.sort((left, right) => left.sourceModelPath.localeCompare(right.sourceModelPath))
|
||||
.map((asset) => ({
|
||||
sourceModelPath: asset.sourceModelPath,
|
||||
sourceType: asset.sourceType,
|
||||
targetUrl: asset.targetUrl,
|
||||
instances: asset.instances,
|
||||
boundedInstances: asset.boundedInstances,
|
||||
displayIds: [...asset.displayIds].sort((left, right) => left - right),
|
||||
dungeons: sortedCountEntries(asset.dungeons, "dungeonId"),
|
||||
kinds: sortedCountEntries(asset.kinds, "kind"),
|
||||
})),
|
||||
nonVisualObjects: [...nonVisualObjects.values()]
|
||||
.sort((left, right) => left.templateId - right.templateId)
|
||||
.map((entry) => ({
|
||||
templateId: entry.templateId,
|
||||
instances: entry.instances,
|
||||
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
|
||||
kinds: sortedCountEntries(entry.kinds, "kind"),
|
||||
})),
|
||||
unresolvedModels: [...unresolvedModels.values()]
|
||||
.sort((left, right) => left.displayId - right.displayId)
|
||||
.map(serializeUnresolved),
|
||||
unresolvedBounds: [...unresolvedBounds.values()]
|
||||
.sort((left, right) => left.displayId - right.displayId)
|
||||
.map(serializeUnresolved),
|
||||
};
|
||||
}
|
||||
|
||||
function sourceBasisFor(recipe, environmentMode) {
|
||||
if (environmentMode !== "adt-hybrid") {
|
||||
@@ -371,13 +872,44 @@ function uniqueDraftEncounters(encounterRows, mapId) {
|
||||
...(resolvedEntry > 0 ? {} : { syntheticGroupEncounter: true }),
|
||||
};
|
||||
const key = `${creatureId}:${normalizedName(encounter.name)}`;
|
||||
if (!byIdentity.has(key)) byIdentity.set(key, encounter);
|
||||
const previous = byIdentity.get(key);
|
||||
if (!previous) {
|
||||
byIdentity.set(key, encounter);
|
||||
continue;
|
||||
}
|
||||
byIdentity.set(key, {
|
||||
...previous,
|
||||
modeIds: [...new Set([...previous.modeIds, ...encounter.modeIds])].sort((left, right) => left - right),
|
||||
sourceRowIds: [...new Set([
|
||||
...(previous.sourceRowIds ?? []),
|
||||
...(encounter.sourceRowIds ?? []),
|
||||
])].sort((left, right) => left - right),
|
||||
orderIndex: Math.min(previous.orderIndex, encounter.orderIndex),
|
||||
});
|
||||
}
|
||||
return [...byIdentity.values()].sort((left, right) => (
|
||||
left.orderIndex - right.orderIndex || left.encounterId - right.encounterId
|
||||
));
|
||||
}
|
||||
|
||||
function encounterSpawnMask(recipe, encounter, candidate) {
|
||||
const declaredMask = recipe.difficultyVariants.reduce(
|
||||
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
|
||||
0,
|
||||
);
|
||||
const candidateMask = Number(candidate?.spawnMask ?? 0) & declaredMask;
|
||||
if (candidateMask > 0) return candidateMask;
|
||||
|
||||
const modeIds = new Set((encounter.modeIds ?? []).map(Number));
|
||||
const rowMask = recipe.difficultyVariants.reduce((mask, difficulty) => (
|
||||
difficulty.mapDifficultyId !== undefined
|
||||
&& modeIds.has(Number(difficulty.mapDifficultyId))
|
||||
? mask | Number(difficulty.spawnMask ?? 0)
|
||||
: mask
|
||||
), 0);
|
||||
return rowMask || declaredMask || 1;
|
||||
}
|
||||
|
||||
function archetypeFor(name) {
|
||||
const value = normalizedName(name);
|
||||
if (/murloc|murk|glub|fin/.test(value)) return "murloc";
|
||||
@@ -1032,6 +1564,12 @@ async function compile() {
|
||||
const epochSpells = await exists(epochSpellDbc)
|
||||
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
|
||||
: ascensionSpells;
|
||||
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
|
||||
? parseGameObjectDisplays(await readFile(standardGameObjectDisplayDbc))
|
||||
: new Map();
|
||||
const epochGameObjectDisplays = await exists(epochGameObjectDisplayDbc)
|
||||
? parseGameObjectDisplays(await readFile(epochGameObjectDisplayDbc))
|
||||
: standardGameObjectDisplays;
|
||||
|
||||
const previousOutput = await exists(outputFile)
|
||||
? await readJson(outputFile)
|
||||
@@ -1103,6 +1641,14 @@ async function compile() {
|
||||
?? draft?.spawns?.[0]?.position
|
||||
?? playerAnchor.position,
|
||||
);
|
||||
const serverGameObjects = compileSourceGameObjects(
|
||||
draft,
|
||||
mapPosition,
|
||||
recipe,
|
||||
recipe.clientBuild === "3.3.5a-Epoch"
|
||||
? epochGameObjectDisplays
|
||||
: standardGameObjectDisplays,
|
||||
);
|
||||
const sourceEntities = (draft?.entities ?? [])
|
||||
.filter((entity) => (
|
||||
!(recipe.slug === "wailing-caverns" && Number(entity.entry) === 5912)
|
||||
@@ -1230,6 +1776,11 @@ async function compile() {
|
||||
const usedBossSpawnIds = new Set();
|
||||
const bossObjectives = encounters.map((encounter, index) => {
|
||||
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
|
||||
const spawnMask = encounterSpawnMask(recipe, encounter, candidate);
|
||||
const declaredDifficultyMask = recipe.difficultyVariants.reduce(
|
||||
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
|
||||
0,
|
||||
);
|
||||
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
|
||||
const routeAlpha = 0.18 + alpha * 0.82;
|
||||
const nativeRoutePosition = nativeRoutePositions.length
|
||||
@@ -1253,7 +1804,10 @@ async function compile() {
|
||||
name: encounter.name,
|
||||
position,
|
||||
yaw: candidate?.yaw ?? 0,
|
||||
...(spawnChance < 1 ? { optional: true } : {}),
|
||||
spawnMask,
|
||||
...(spawnChance < 1 || (spawnMask & declaredDifficultyMask) !== declaredDifficultyMask
|
||||
? { optional: true }
|
||||
: {}),
|
||||
...(encounter.eventOnly ? { eventOnly: true } : {}),
|
||||
};
|
||||
});
|
||||
@@ -1313,7 +1867,7 @@ async function compile() {
|
||||
entityId: boss.entityId,
|
||||
position: boss.position,
|
||||
yaw: boss.yaw,
|
||||
spawnMask: 1,
|
||||
spawnMask: boss.spawnMask,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1426,7 +1980,8 @@ async function compile() {
|
||||
bosses,
|
||||
objectiveOrder: bosses,
|
||||
navigationLinks: [],
|
||||
serverGameObjects: draft?.gameObjects ?? [],
|
||||
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
||||
serverGameObjects,
|
||||
provenance: {
|
||||
clientBuild: recipe.clientBuild,
|
||||
coordinateAlignment,
|
||||
@@ -1490,7 +2045,13 @@ async function compile() {
|
||||
};
|
||||
const serialized = JSON.stringify(output);
|
||||
const compressed = gzipSync(Buffer.from(serialized), { level: 9 });
|
||||
const gameObjectAssetManifest = gameObjectAssetManifestFor(definitions);
|
||||
await writeFile(outputFile, `${serialized}\n`, "utf8");
|
||||
await writeFile(
|
||||
gameObjectAssetManifestFile,
|
||||
`${JSON.stringify(gameObjectAssetManifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
compressedOutputFile,
|
||||
`// Generated by npm run dungeon:catalog. Do not edit.\nexport default "${compressed.toString("base64")}";\n`,
|
||||
@@ -1517,6 +2078,7 @@ async function compile() {
|
||||
status: "complete",
|
||||
output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"),
|
||||
compressedOutput: path.relative(projectRoot, compressedOutputFile).replace(/\\/g, "/"),
|
||||
gameObjectAssetManifest: path.relative(projectRoot, gameObjectAssetManifestFile).replace(/\\/g, "/"),
|
||||
compressedBytes: compressed.length,
|
||||
dungeons: definitions.length,
|
||||
encounters: coverage.reduce((total, row) => total + row.encounters, 0),
|
||||
@@ -1525,6 +2087,11 @@ async function compile() {
|
||||
roamingPacks: coverage.reduce((total, row) => total + row.roamingPacks, 0),
|
||||
authoritativeDungeons: coverage.filter((row) => row.source === "azerothcore").length,
|
||||
fallbackDungeons: coverage.filter((row) => row.source === "procedural-fallback").length,
|
||||
gameObjectInstances: gameObjectAssetManifest.summary.typedInstances,
|
||||
nonVisualGameObjectInstances: gameObjectAssetManifest.summary.nonVisualInstances,
|
||||
gameObjectAssets: gameObjectAssetManifest.summary.uniqueAssets,
|
||||
unresolvedGameObjectModelInstances: gameObjectAssetManifest.summary.unresolvedModelInstances,
|
||||
unresolvedGameObjectBoundInstances: gameObjectAssetManifest.summary.unresolvedBoundInstances,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
clientRootForRecipe,
|
||||
@@ -10,24 +10,21 @@ import {
|
||||
recipesRoot,
|
||||
workRoot,
|
||||
} from "./lib.mjs";
|
||||
import { runWowExporter } from "./wow-export-tool.mjs";
|
||||
|
||||
async function runExporter(argumentsList) {
|
||||
const executable = path.resolve(projectRoot, "../wow335a/dungeon-export-work/tools/wow.export-0.2.19/wow.export.exe");
|
||||
if (!await exists(executable)) throw new Error("The patched portable wow.export executable was not found.");
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, ["--disable-auto-update", ...argumentsList], {
|
||||
cwd: path.dirname(executable),
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`wow.export exited with code ${code}.`)));
|
||||
});
|
||||
async function resultMtime(output) {
|
||||
const resultFile = path.join(output, "automation-result.json");
|
||||
return await exists(resultFile) ? (await stat(resultFile)).mtimeMs : 0;
|
||||
}
|
||||
|
||||
async function checkedResult(slug, output) {
|
||||
async function checkedResult(slug, output, previousMtime) {
|
||||
const resultFile = path.join(output, "automation-result.json");
|
||||
if (!await exists(resultFile)) return { status: "blocked", dungeonId: slug, blockers: ["wow.export produced no automation-result.json."] };
|
||||
if (!await exists(resultFile)) return { status: "blocked", dungeonId: slug, blockers: [`wow.export produced no automation result at ${resultFile}.`] };
|
||||
if ((await stat(resultFile)).mtimeMs <= previousMtime) return {
|
||||
status: "blocked",
|
||||
dungeonId: slug,
|
||||
blockers: [`wow.export did not refresh ${resultFile}; refusing to accept a stale result from an earlier run.`],
|
||||
};
|
||||
const result = await readJson(resultFile);
|
||||
if (!result.ok) return {
|
||||
status: "blocked",
|
||||
@@ -43,13 +40,14 @@ export async function exportDungeonSource(slug) {
|
||||
const recipe = await loadRecipe(slug);
|
||||
const clientRoot = clientRootForRecipe(recipe);
|
||||
const output = path.join(workRoot, slug, "source-export");
|
||||
await runExporter([
|
||||
const previousMtime = await resultMtime(output);
|
||||
await runWowExporter([
|
||||
"--export-dungeon",
|
||||
"--recipe", path.join(recipesRoot, `${slug}.json`),
|
||||
"--client-root", clientRoot,
|
||||
"--output", output,
|
||||
]);
|
||||
const blocked = await checkedResult(slug, output);
|
||||
], { projectRoot });
|
||||
const blocked = await checkedResult(slug, output, previousMtime);
|
||||
if (blocked) return blocked;
|
||||
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") };
|
||||
}
|
||||
@@ -58,12 +56,15 @@ export async function exportClientDiscoveryTables(slug) {
|
||||
const recipe = await loadRecipe(slug);
|
||||
const clientRoot = clientRootForRecipe(recipe);
|
||||
const output = path.join(workRoot, slug, "client-dbc");
|
||||
await runExporter([
|
||||
const previousMtime = await resultMtime(output);
|
||||
await runWowExporter([
|
||||
"--discover-dungeon",
|
||||
"--recipe", path.join(recipesRoot, `${slug}.json`),
|
||||
"--client-root", clientRoot,
|
||||
"--output", output,
|
||||
]);
|
||||
], { projectRoot });
|
||||
const blocked = await checkedResult(slug, output, previousMtime);
|
||||
if (blocked) throw new Error(blocked.blockers.join("\n"));
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -88,13 +89,14 @@ export async function exportCreatureSource(slug) {
|
||||
blockers: unresolved.map((creature) => `${creature.name ?? creature.slug} has an unresolved display/composite recipe.`),
|
||||
};
|
||||
const output = path.join(workRoot, slug, "creature-source");
|
||||
await runExporter([
|
||||
const previousMtime = await resultMtime(output);
|
||||
await runWowExporter([
|
||||
"--export-creatures",
|
||||
"--recipe", selected,
|
||||
"--client-root", clientRoot,
|
||||
"--output", output,
|
||||
]);
|
||||
const blocked = await checkedResult(slug, output);
|
||||
], { projectRoot });
|
||||
const blocked = await checkedResult(slug, output, previousMtime);
|
||||
if (blocked) return blocked;
|
||||
return { status: "green", dungeonId: slug, output: path.relative(projectRoot, output).replace(/\\/g, "/") };
|
||||
}
|
||||
@@ -45,6 +45,9 @@ function runNpm(script, argumentsList = []) {
|
||||
async function main() {
|
||||
if (options.has("--audit-only")) {
|
||||
await runNpm("dungeon:wow:audit");
|
||||
await runNpm("dungeon:wow:functionality:audit");
|
||||
await runNpm("dungeon:ascension:recovery:validate");
|
||||
await runNpm("dungeon:wow:export-tool:test");
|
||||
await runNpm("dungeon:wow:test");
|
||||
return;
|
||||
}
|
||||
@@ -63,8 +66,11 @@ async function main() {
|
||||
await runNpm("dungeon:catalog");
|
||||
await runNpm("dungeon:five-player:verify");
|
||||
await runNpm("dungeon:wow:audit");
|
||||
await runNpm("dungeon:wow:functionality:audit");
|
||||
await runNpm("dungeon:ascension:recovery:validate");
|
||||
await runNpm("dungeon:wow:export-tool:test");
|
||||
await runNpm("dungeon:wow:test");
|
||||
if (options.has("--build")) await runNpm("build");
|
||||
}
|
||||
|
||||
await main();
|
||||
await main();
|
||||
@@ -0,0 +1,300 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "..", "..");
|
||||
const manifestPath = path.join(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"ascension",
|
||||
"ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
|
||||
);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual, expected, message) {
|
||||
assert(JSON.stringify(actual) === JSON.stringify(expected), message);
|
||||
}
|
||||
|
||||
async function readJson(relativePath) {
|
||||
const contents = await readFile(path.join(projectRoot, relativePath), "utf8");
|
||||
return JSON.parse(contents);
|
||||
}
|
||||
|
||||
function resolveDbcPath(manifest) {
|
||||
const dbcArgumentIndex = process.argv.indexOf("--dbc");
|
||||
if (dbcArgumentIndex !== -1) {
|
||||
const value = process.argv[dbcArgumentIndex + 1];
|
||||
assert(value, "--dbc requires a path");
|
||||
return path.resolve(process.cwd(), value);
|
||||
}
|
||||
|
||||
if (process.env.ASCENSION_SPELL_DBC) {
|
||||
return path.resolve(process.env.ASCENSION_SPELL_DBC);
|
||||
}
|
||||
|
||||
const source = manifest.sources.find(
|
||||
(candidate) => candidate.id === "ascension-area52-spell-dbc",
|
||||
);
|
||||
assert(source?.pathHint, "Spell.dbc source path is missing from the manifest");
|
||||
return path.resolve(projectRoot, source.pathHint);
|
||||
}
|
||||
|
||||
function readCString(buffer, offset) {
|
||||
const end = buffer.indexOf(0, offset);
|
||||
assert(end !== -1, `Unterminated DBC string at byte ${offset}`);
|
||||
return buffer.toString("utf8", offset, end);
|
||||
}
|
||||
|
||||
async function validateSpellDbc(manifest) {
|
||||
if (process.argv.includes("--skip-dbc")) {
|
||||
return { skipped: true, reason: "requested with --skip-dbc" };
|
||||
}
|
||||
|
||||
const source = manifest.sources.find(
|
||||
(candidate) => candidate.id === "ascension-area52-spell-dbc",
|
||||
);
|
||||
assert(source, "Spell.dbc source metadata is missing");
|
||||
|
||||
const dbcPath = resolveDbcPath(manifest);
|
||||
let buffer;
|
||||
try {
|
||||
buffer = await readFile(dbcPath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return { skipped: true, reason: `not installed at ${dbcPath}` };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const digest = createHash("sha256").update(buffer).digest("hex");
|
||||
assert(digest === source.sha256, `Spell.dbc SHA-256 mismatch: ${digest}`);
|
||||
|
||||
const header = {
|
||||
signature: buffer.toString("ascii", 0, 4),
|
||||
recordCount: buffer.readUInt32LE(4),
|
||||
fieldCount: buffer.readUInt32LE(8),
|
||||
recordSize: buffer.readUInt32LE(12),
|
||||
stringBlockSize: buffer.readUInt32LE(16),
|
||||
};
|
||||
assertDeepEqual(header, source.header, "Spell.dbc header does not match the manifest");
|
||||
|
||||
const expectedNames = new Map();
|
||||
const summonLinks = new Map();
|
||||
for (const dungeon of manifest.dungeons) {
|
||||
for (const [spellId, name] of Object.entries(dungeon.sourceRecovery.spellRows)) {
|
||||
expectedNames.set(Number(spellId), name);
|
||||
}
|
||||
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
|
||||
const links = summonLinks.get(link.spellId) ?? [];
|
||||
links.push({ ...link, mapId: dungeon.mapId });
|
||||
summonLinks.set(link.spellId, links);
|
||||
}
|
||||
}
|
||||
|
||||
const recordStart = 20;
|
||||
const stringStart = recordStart + header.recordCount * header.recordSize;
|
||||
const expectedLength = stringStart + header.stringBlockSize;
|
||||
assert(
|
||||
expectedLength === buffer.length,
|
||||
`Spell.dbc length mismatch: expected ${expectedLength}, got ${buffer.length}`,
|
||||
);
|
||||
|
||||
const found = new Map();
|
||||
for (let recordIndex = 0; recordIndex < header.recordCount; recordIndex += 1) {
|
||||
const offset = recordStart + recordIndex * header.recordSize;
|
||||
const spellId = buffer.readUInt32LE(offset);
|
||||
if (!expectedNames.has(spellId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameOffset = buffer.readUInt32LE(offset + 136 * 4);
|
||||
found.set(spellId, {
|
||||
name: readCString(buffer, stringStart + nameOffset),
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [spellId, expectedName] of expectedNames) {
|
||||
const row = found.get(spellId);
|
||||
assert(row, `Spell.dbc row ${spellId} is missing`);
|
||||
assert(
|
||||
row.name === expectedName,
|
||||
`Spell.dbc row ${spellId} name mismatch: expected ${JSON.stringify(expectedName)}, got ${JSON.stringify(row.name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [spellId, links] of summonLinks) {
|
||||
const row = found.get(spellId);
|
||||
assert(row, `Summon spell ${spellId} was not loaded`);
|
||||
for (const link of links) {
|
||||
const effectType = buffer.readUInt32LE(row.offset + (71 + link.effectIndex) * 4);
|
||||
const entryId = buffer.readInt32LE(row.offset + (110 + link.effectIndex) * 4);
|
||||
assert(
|
||||
effectType === link.effectType,
|
||||
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} type mismatch: expected ${link.effectType}, got ${effectType}`,
|
||||
);
|
||||
assert(
|
||||
entryId === link.entryId,
|
||||
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} entry mismatch: expected ${link.entryId}, got ${entryId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
path: dbcPath,
|
||||
spellRows: found.size,
|
||||
summonLinks: [...summonLinks.values()].flat().length,
|
||||
};
|
||||
}
|
||||
|
||||
function validateCatalogs(manifest, campaign, runtime, assets) {
|
||||
assert(manifest.schemaVersion === 1, "Unsupported recovery manifest schema");
|
||||
assert(manifest.status === "partial-authoritative-recovery", "Unexpected manifest status");
|
||||
assertDeepEqual(
|
||||
manifest.dungeons.map((dungeon) => dungeon.mapId),
|
||||
[821, 908, 1781],
|
||||
"Recovery manifest must contain exactly maps 821, 908, and 1781 in that order",
|
||||
);
|
||||
|
||||
let spellRows = 0;
|
||||
let summonLinks = 0;
|
||||
for (const dungeon of manifest.dungeons) {
|
||||
const definition = campaign.definitions.find(
|
||||
(candidate) => candidate.mapId === dungeon.mapId,
|
||||
);
|
||||
assert(definition, `Campaign definition for map ${dungeon.mapId} is missing`);
|
||||
assert(definition.id === dungeon.id, `Map ${dungeon.mapId} campaign ID drifted`);
|
||||
assert(definition.title === dungeon.title, `Map ${dungeon.mapId} title drifted`);
|
||||
|
||||
const assetPackage = assets.packages.find(
|
||||
(candidate) => candidate.mapId === dungeon.mapId,
|
||||
);
|
||||
assert(assetPackage, `Asset package for map ${dungeon.mapId} is missing`);
|
||||
assert(
|
||||
assetPackage.source.mapDirectory === dungeon.mapDirectory,
|
||||
`Map ${dungeon.mapId} client map directory drifted`,
|
||||
);
|
||||
assert(
|
||||
Object.values(assetPackage.source.checksums).includes(dungeon.environment.sourceHash),
|
||||
`Map ${dungeon.mapId} source checksum drifted`,
|
||||
);
|
||||
for (const kind of ["player", "trash", "boss"]) {
|
||||
const anchor = assetPackage.anchors.find((candidate) => candidate.kind === kind);
|
||||
assert(anchor, `Map ${dungeon.mapId} ${kind} anchor is missing`);
|
||||
assertDeepEqual(
|
||||
anchor.position,
|
||||
dungeon.environment.anchors[kind],
|
||||
`Map ${dungeon.mapId} ${kind} anchor drifted`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const encounter of dungeon.encounters) {
|
||||
const runtimeEncounter = runtime.encounters.find(
|
||||
(candidate) =>
|
||||
candidate.encounterId === encounter.encounterId &&
|
||||
candidate.mapIds.includes(dungeon.mapId) &&
|
||||
candidate.modeIds.includes(encounter.modeId),
|
||||
);
|
||||
assert(
|
||||
runtimeEncounter,
|
||||
`Map ${dungeon.mapId} encounter ${encounter.encounterId} mode ${encounter.modeId} is missing`,
|
||||
);
|
||||
assert(
|
||||
runtimeEncounter.name === encounter.name,
|
||||
`Encounter ${encounter.encounterId} name drifted`,
|
||||
);
|
||||
assert(
|
||||
runtimeEncounter.creatureId === encounter.creatureId,
|
||||
`Encounter ${encounter.encounterId} creature ID drifted`,
|
||||
);
|
||||
}
|
||||
|
||||
const bossEntity = definition.entities[dungeon.currentRuntime.bossEntityId];
|
||||
const bossCreatureId = dungeon.encounters[0].creatureId;
|
||||
assert(bossEntity, `Map ${dungeon.mapId} current boss entity is missing`);
|
||||
assert(
|
||||
bossEntity.combat.serverEntry === bossCreatureId,
|
||||
`Map ${dungeon.mapId} current boss server entry drifted`,
|
||||
);
|
||||
|
||||
const generatedTrash = definition.staticSpawns.filter((spawn) =>
|
||||
spawn.id.startsWith("generated-trash-"),
|
||||
);
|
||||
assert(
|
||||
generatedTrash.length === dungeon.currentRuntime.proceduralTrashSpawnCount,
|
||||
`Map ${dungeon.mapId} procedural trash count drifted`,
|
||||
);
|
||||
for (const proceduralEntity of dungeon.currentRuntime.proceduralMobEntries) {
|
||||
const entity = definition.entities[proceduralEntity.entityId];
|
||||
assert(entity, `Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} is missing`);
|
||||
assert(
|
||||
entity.combat.serverEntry === proceduralEntity.serverEntry,
|
||||
`Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} server entry drifted`,
|
||||
);
|
||||
}
|
||||
|
||||
const rowIds = new Set(
|
||||
Object.keys(dungeon.sourceRecovery.spellRows).map((spellId) => Number(spellId)),
|
||||
);
|
||||
spellRows += rowIds.size;
|
||||
for (const [family, familyRows] of Object.entries(dungeon.sourceRecovery.families)) {
|
||||
for (const spellId of familyRows) {
|
||||
assert(
|
||||
rowIds.has(spellId),
|
||||
`Map ${dungeon.mapId} family ${family} references absent spell ${spellId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
|
||||
summonLinks += 1;
|
||||
assert(rowIds.has(link.spellId), `Map ${dungeon.mapId} summon spell ${link.spellId} is absent`);
|
||||
const targetCollection = link.targetKind === "gameobject" ? dungeon.gameObjects : dungeon.entities;
|
||||
assert(
|
||||
targetCollection?.some((target) => target.entryId === link.entryId),
|
||||
`Map ${dungeon.mapId} summon target ${link.targetKind} ${link.entryId} is absent`,
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
dungeon.adaptation.mustBeCustomAuthored.length > 0,
|
||||
`Map ${dungeon.mapId} must explicitly list custom-authored gaps`,
|
||||
);
|
||||
}
|
||||
|
||||
return { dungeons: manifest.dungeons.length, spellRows, summonLinks };
|
||||
}
|
||||
|
||||
const manifest = await readJson(
|
||||
"dungeon-pipeline/ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
|
||||
);
|
||||
const campaign = await readJson("src/game/generated/dungeonCampaignCatalog.json");
|
||||
const runtime = await readJson("src/game/generated/manastormRuntimeCatalog.json");
|
||||
const assets = await readJson("src/game/generated/manastormAssetPackages.json");
|
||||
|
||||
const catalogResult = validateCatalogs(manifest, campaign, runtime, assets);
|
||||
const dbcResult = await validateSpellDbc(manifest);
|
||||
|
||||
console.log(
|
||||
`Validated ${catalogResult.dungeons} Ascension custom dungeons, ${catalogResult.spellRows} recovered spell rows, and ${catalogResult.summonLinks} summon links.`,
|
||||
);
|
||||
if (dbcResult.skipped) {
|
||||
console.log(`Spell.dbc verification skipped: ${dbcResult.reason}.`);
|
||||
} else {
|
||||
console.log(
|
||||
`Spell.dbc verified (${dbcResult.spellRows} rows, ${dbcResult.summonLinks} summon links): ${dbcResult.path}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { spawn as spawnChild } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const WOW_EXPORT_AUTOMATION_VERSION = "0.2.19";
|
||||
export const WOW_EXPORT_REQUIRED_FLAGS = Object.freeze([
|
||||
"--disable-auto-update",
|
||||
"--discover-dungeon",
|
||||
"--export-dungeon",
|
||||
"--export-creatures",
|
||||
]);
|
||||
|
||||
export function defaultWowExportExecutable(projectRoot) {
|
||||
return path.resolve(
|
||||
projectRoot,
|
||||
`../wow335a/dungeon-export-work/tools/wow.export-${WOW_EXPORT_AUTOMATION_VERSION}/wow.export.exe`,
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectCandidate(file, statFile) {
|
||||
try {
|
||||
const info = await statFile(file);
|
||||
return info.isFile()
|
||||
? { usable: true, reason: null }
|
||||
: { usable: false, reason: "path exists but is not a file" };
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return { usable: false, reason: "file does not exist" };
|
||||
return { usable: false, reason: `cannot inspect file (${error?.code ?? error?.message ?? "unknown error"})` };
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableMessage(checks, configured) {
|
||||
const checked = checks.map(({ label, file, reason }) => ` - ${label}: ${file} (${reason})`).join("\n");
|
||||
const selection = configured
|
||||
? "WOW_EXPORT_BIN is set, so the default location was not used. Correct or unset that variable."
|
||||
: "Restore the patched portable bundle at the default location, or set WOW_EXPORT_BIN to its exact executable path.";
|
||||
return [
|
||||
"HealerMan's patched wow.export automation bridge is unavailable.",
|
||||
`Required build: wow.export ${WOW_EXPORT_AUTOMATION_VERSION} with these automation flags: ${WOW_EXPORT_REQUIRED_FLAGS.join(", ")}.`,
|
||||
"Checked:",
|
||||
checked,
|
||||
selection,
|
||||
"A stock GUI-only wow.export executable is not a drop-in replacement unless it includes the HealerMan automation patch.",
|
||||
"No export was started and no placeholder assets were created.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export class WowExportUnavailableError extends Error {
|
||||
constructor(checks, configured) {
|
||||
super(unavailableMessage(checks, configured));
|
||||
this.name = "WowExportUnavailableError";
|
||||
this.code = "WOW_EXPORT_AUTOMATION_UNAVAILABLE";
|
||||
this.checks = checks;
|
||||
this.requiredVersion = WOW_EXPORT_AUTOMATION_VERSION;
|
||||
this.requiredFlags = [...WOW_EXPORT_REQUIRED_FLAGS];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment = process.env,
|
||||
statFile = stat,
|
||||
} = {}) {
|
||||
if (!projectRoot) throw new TypeError("resolveWowExportExecutable requires projectRoot.");
|
||||
const configuredValue = environment.WOW_EXPORT_BIN?.trim();
|
||||
const candidates = configuredValue
|
||||
? [{ label: "WOW_EXPORT_BIN", file: path.resolve(projectRoot, configuredValue) }]
|
||||
: [{ label: "default portable path", file: defaultWowExportExecutable(projectRoot) }];
|
||||
const checks = [];
|
||||
for (const candidate of candidates) {
|
||||
const inspection = await inspectCandidate(candidate.file, statFile);
|
||||
if (inspection.usable) return candidate.file;
|
||||
checks.push({ ...candidate, reason: inspection.reason });
|
||||
}
|
||||
throw new WowExportUnavailableError(checks, Boolean(configuredValue));
|
||||
}
|
||||
|
||||
function exporterFailureMessage(executable, argumentsList, detail) {
|
||||
const operation = argumentsList.find((argument) => argument.startsWith("--")) ?? "unknown operation";
|
||||
return [
|
||||
`wow.export automation failed during ${operation}: ${detail}`,
|
||||
`Executable: ${executable}`,
|
||||
`Required automation flags: ${WOW_EXPORT_REQUIRED_FLAGS.join(", ")}.`,
|
||||
"Verify that WOW_EXPORT_BIN points to the HealerMan-patched build, not the stock GUI-only release.",
|
||||
"No placeholder assets were created.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function runWowExporter(argumentsList, {
|
||||
projectRoot,
|
||||
environment = process.env,
|
||||
statFile = stat,
|
||||
spawn = spawnChild,
|
||||
} = {}) {
|
||||
const executable = await resolveWowExportExecutable({ projectRoot, environment, statFile });
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, ["--disable-auto-update", ...argumentsList], {
|
||||
cwd: path.dirname(executable),
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
let settled = false;
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
child.once("error", (error) => fail(new Error(exporterFailureMessage(
|
||||
executable,
|
||||
argumentsList,
|
||||
`process could not start (${error?.code ?? error?.message ?? "unknown error"})`,
|
||||
))));
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(exporterFailureMessage(
|
||||
executable,
|
||||
argumentsList,
|
||||
signal ? `process exited after signal ${signal}` : `process exited with code ${code}`,
|
||||
)));
|
||||
});
|
||||
});
|
||||
return executable;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
defaultWowExportExecutable,
|
||||
resolveWowExportExecutable,
|
||||
runWowExporter,
|
||||
WOW_EXPORT_REQUIRED_FLAGS,
|
||||
} from "./wow-export-tool.mjs";
|
||||
|
||||
const projectRoot = path.resolve("fixture/HealerMan");
|
||||
const fileInfo = { isFile: () => true };
|
||||
|
||||
test("resolveWowExportExecutable uses the versioned portable path by default", async () => {
|
||||
const expected = defaultWowExportExecutable(projectRoot);
|
||||
const inspected = [];
|
||||
const resolved = await resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: {},
|
||||
statFile: async (file) => {
|
||||
inspected.push(file);
|
||||
return fileInfo;
|
||||
},
|
||||
});
|
||||
assert.equal(resolved, expected);
|
||||
assert.deepEqual(inspected, [expected]);
|
||||
});
|
||||
|
||||
test("resolveWowExportExecutable honors an explicit relative WOW_EXPORT_BIN", async () => {
|
||||
const expected = path.resolve(projectRoot, "tools/patched/wow.export.exe");
|
||||
const resolved = await resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: "tools/patched/wow.export.exe" },
|
||||
statFile: async (file) => {
|
||||
assert.equal(file, expected);
|
||||
return fileInfo;
|
||||
},
|
||||
});
|
||||
assert.equal(resolved, expected);
|
||||
});
|
||||
|
||||
test("resolveWowExportExecutable rejects a directory with actionable diagnostics", async () => {
|
||||
const configured = path.resolve(projectRoot, "tools/not-an-executable");
|
||||
await assert.rejects(
|
||||
resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: configured },
|
||||
statFile: async () => ({ isFile: () => false }),
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, "WOW_EXPORT_AUTOMATION_UNAVAILABLE");
|
||||
assert.match(error.message, /path exists but is not a file/);
|
||||
assert.match(error.message, /Correct or unset that variable/);
|
||||
assert.match(error.message, /No export was started and no placeholder assets were created/);
|
||||
for (const flag of WOW_EXPORT_REQUIRED_FLAGS) assert.ok(error.message.includes(flag));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveWowExportExecutable reports the exact missing default path", async () => {
|
||||
const expected = defaultWowExportExecutable(projectRoot);
|
||||
await assert.rejects(
|
||||
resolveWowExportExecutable({
|
||||
projectRoot,
|
||||
environment: {},
|
||||
statFile: async () => {
|
||||
const error = new Error("missing");
|
||||
error.code = "ENOENT";
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(error.message.includes(expected));
|
||||
assert.match(error.message, /set WOW_EXPORT_BIN/);
|
||||
assert.match(error.message, /stock GUI-only wow\.export executable is not a drop-in replacement/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("runWowExporter supplies the non-updating automation invocation", async () => {
|
||||
const executable = path.resolve(projectRoot, "tools/wow.export.exe");
|
||||
let invocation;
|
||||
const spawn = (file, args, options) => {
|
||||
invocation = { file, args, options };
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => child.emit("exit", 0, null));
|
||||
return child;
|
||||
};
|
||||
const result = await runWowExporter(["--export-dungeon", "--recipe", "fixture.json"], {
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: executable },
|
||||
statFile: async () => fileInfo,
|
||||
spawn,
|
||||
});
|
||||
assert.equal(result, executable);
|
||||
assert.deepEqual(invocation.args, ["--disable-auto-update", "--export-dungeon", "--recipe", "fixture.json"]);
|
||||
assert.equal(invocation.options.cwd, path.dirname(executable));
|
||||
assert.equal(invocation.options.windowsHide, true);
|
||||
});
|
||||
|
||||
test("runWowExporter identifies an incompatible executable after a failed launch", async () => {
|
||||
const executable = path.resolve(projectRoot, "tools/wow.export.exe");
|
||||
const spawn = () => {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => child.emit("exit", 2, null));
|
||||
return child;
|
||||
};
|
||||
await assert.rejects(
|
||||
runWowExporter(["--export-creatures"], {
|
||||
projectRoot,
|
||||
environment: { WOW_EXPORT_BIN: executable },
|
||||
statFile: async () => fileInfo,
|
||||
spawn,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /process exited with code 2/);
|
||||
assert.match(error.message, /HealerMan-patched build/);
|
||||
assert.match(error.message, /No placeholder assets were created/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
+23
-12
@@ -75,6 +75,18 @@ class ReleaseVersion:
|
||||
version_code: int
|
||||
|
||||
|
||||
def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
|
||||
"""Commands that must succeed before a release can be staged."""
|
||||
return (
|
||||
([npm, "run", "content:test"], "Content tests"),
|
||||
([npm, "run", "server:test"], "Server tests"),
|
||||
([npm, "test"], "Application tests"),
|
||||
([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"),
|
||||
([npm, "run", "build"], "Production build"),
|
||||
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
|
||||
)
|
||||
|
||||
|
||||
def parse_semver(value: str) -> tuple[int, int, int]:
|
||||
match = SEMVER_PATTERN.fullmatch(value.strip())
|
||||
if not match:
|
||||
@@ -239,11 +251,13 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||
cwd=project_root,
|
||||
env=environment,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
output = result.stdout.decode("utf-8", errors="replace").strip()
|
||||
output = b"\n".join((result.stdout, result.stderr)).decode(
|
||||
"utf-8", errors="replace"
|
||||
).strip()
|
||||
raise ReleaseError(
|
||||
f"Release-candidate Git check failed ({result.returncode}): "
|
||||
f"git {' '.join(args)}\n{output}"
|
||||
@@ -269,11 +283,15 @@ def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if whitespace_result.returncode != 0:
|
||||
detail = whitespace_result.stdout.strip()
|
||||
detail = "\n".join(
|
||||
part.strip()
|
||||
for part in (whitespace_result.stdout, whitespace_result.stderr)
|
||||
if part.strip()
|
||||
)
|
||||
raise ReleaseError(
|
||||
"Release-candidate whitespace check failed. This check includes "
|
||||
f"tracked and untracked files:\n{detail}"
|
||||
@@ -1096,14 +1114,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
f"Recorded Android version {version.version_name} / {version.version_code}."
|
||||
)
|
||||
|
||||
checks = (
|
||||
([npm, "run", "content:test"], "Content tests"),
|
||||
([npm, "run", "server:test"], "Server tests"),
|
||||
([npm, "test"], "Application tests"),
|
||||
([npm, "run", "build"], "Production build"),
|
||||
([npm, "run", "android:sync"], "Android web build and Capacitor sync"),
|
||||
)
|
||||
for command, label in checks:
|
||||
for command, label in full_release_checks(npm):
|
||||
self._run_command(command, label)
|
||||
self._run_command(["git", "add", "-A"], "Stage release")
|
||||
staged = True
|
||||
|
||||
@@ -47,6 +47,13 @@ class SafetyTests(unittest.TestCase):
|
||||
self.assertIn("pull --ff-only", block)
|
||||
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
||||
|
||||
def test_full_release_publishes_verified_content_before_building(self):
|
||||
checks = release_manager.full_release_checks("npm")
|
||||
commands = [command for command, _label in checks]
|
||||
publish_index = commands.index(["npm", "run", "content:publish"])
|
||||
self.assertLess(publish_index, commands.index(["npm", "run", "build"]))
|
||||
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
|
||||
|
||||
|
||||
class ReleaseCandidateTests(unittest.TestCase):
|
||||
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||
|
||||
Reference in new issue
Block a user