Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+206 -7
View File
@@ -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")}`);
}
+170 -3
View File
@@ -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));
}
+25 -23
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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):