240 lines
9.6 KiB
JavaScript
240 lines
9.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
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,
|
|
RANDOMIZED_BITE_SIZED_PACK_DEFINITION,
|
|
assertCampaignDungeonManifest,
|
|
contentPackForPath,
|
|
createContentManifest,
|
|
loadCampaignDungeonPackDefinitions,
|
|
publishContent,
|
|
scanContentSources,
|
|
} from "./publish-content.mjs";
|
|
|
|
test("gives randomized bite-sized its base location dependency closure", () => {
|
|
assert.deepEqual(RANDOMIZED_BITE_SIZED_PACK_DEFINITION, {
|
|
id: "activity:randomized-bite-sized",
|
|
label: "Randomized Bite-Sized Base",
|
|
bundledOnAndroid: false,
|
|
dependencies: ["dungeon:stormwind-stockade"],
|
|
});
|
|
});
|
|
|
|
test("assigns stable player-facing packs from logical asset paths", () => {
|
|
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
|
|
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
|
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
|
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
|
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
|
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
|
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
|
|
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
|
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
|
|
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), false);
|
|
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
|
|
assert.equal(
|
|
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
|
"core:combat-media",
|
|
);
|
|
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:combat-media"), false);
|
|
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");
|
|
const contentDirectory = path.join(directory, "content");
|
|
await mkdir(path.join(sourceDirectory, "wailing-caverns"), { recursive: true });
|
|
await writeFile(path.join(sourceDirectory, "wailing-caverns", "visual.glb"), Buffer.from("glb-one"));
|
|
const sources = [{ directory: sourceDirectory, logicalPrefix: "assets/game/dungeons" }];
|
|
|
|
try {
|
|
const scanned = await scanContentSources(sources);
|
|
const firstManifest = createContentManifest(scanned, { createdAt: "2026-08-14T00:00:00.000Z" });
|
|
assert.equal(Object.keys(firstManifest.files).length, 1);
|
|
assert.equal(firstManifest.packs[0].id, "dungeon:wailing-caverns");
|
|
|
|
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);
|
|
const entry = latest.files["/assets/game/dungeons/wailing-caverns/visual.glb"];
|
|
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,
|
|
includeCampaignPacks: false,
|
|
createdAt: "2026-08-14T00:00:00.000Z",
|
|
});
|
|
assert.equal(second.copiedObjects, 0);
|
|
assert.equal(second.manifest.version, first.manifest.version);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|