import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, rename, stat, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { prepareContentObjects } from "./content-objects.mjs"; import contentPolicy from "../../shared/content-policy.json" with { type: "json" }; 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([...contentPolicy.bootstrapPackIds]); export const RANDOMIZED_BITE_SIZED_PACK_DEFINITION = Object.freeze({ id: "activity:randomized-bite-sized", label: "Randomized Bite-Sized Base", bundledOnAndroid: false, dependencies: ["dungeon:stormwind-stockade"], }); const RUNTIME_EXTENSIONS = new Set([ ".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png", ".svg", ".wasm", ".wav", ".webm", ".webp", ]); export const BASIC_HUMAN_TEXTURE_PATHS = Object.freeze([ "assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp", "assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp", "assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp", "assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp", "assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp", "assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp", "assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp", "assets/characters/wow335a-v1/textures/character/human/hair00_00.webp", "assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp", "assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp", "assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp", "assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp", "assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp", "assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp", ]); const BASIC_HUMAN_TEXTURE_PATH_SET = new Set(BASIC_HUMAN_TEXTURE_PATHS); export const DEFAULT_CONTENT_SOURCES = Object.freeze([ { directory: path.join(PROJECT_ROOT, "public", "assets"), logicalPrefix: "assets", }, { directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons"), logicalPrefix: "assets/game/dungeons", }, ]); function normalizedRelativePath(value) { return value.split(path.sep).join("/").replace(/^\/+/, ""); } function encodeUrlPath(value) { return value.split("/").map(encodeURIComponent).join("/"); } function packLabel(packId) { const [category, id = category] = packId.split(":", 2); const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" "); if (category === "dungeon") return name; if (category === "creatures") return `${name} Creatures`; if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins"; if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins"; if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins"; if (category === "characters") return `${name} Character Models & Skins`; if (category === "manastorm") return `${name} Manastorm`; if (packId === "shared:creatures") return "Shared Creatures"; if (packId === "core:ui") return "Interface"; if (packId === "core:equipment") return "Starter Equipment"; if (packId === "core:character-basic") return "Basic Human Character Model"; if (packId === "core:runtime") return "Game Runtime"; if (packId === "core:arenas") return "Rated Arenas"; if (packId === "core:combat-media") return "Original Spell Effects & Sounds"; return name; } export function contentPackForPath(logicalPath) { const normalizedPath = normalizedRelativePath(logicalPath); const segments = normalizedPath.split("/"); if (segments[0] === "basis" || segments[0] === "data") return "core:runtime"; if (segments[0] !== "assets") return "core:misc"; if (segments[1] === "characters" && segments[2]) { const basicWrathHuman = segments[2] === "wow335a-v1" && ( segments[3] === "manifest.json" || segments[3] === "human" || BASIC_HUMAN_TEXTURE_PATH_SET.has(normalizedPath) ); return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`; } if (segments[1] === "creatures" && segments[2]) { return segments[2] === "shared" ? "shared:creatures" : `creatures:${segments[2]}`; } if (segments[1] === "game" && segments[2] === "creatures" && segments[3]) { return segments[3] === "shared" ? "shared:creatures" : `creatures:${segments[3]}`; } if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) { if (segments[4] === "creatures") return `creatures:${segments[3]}`; return `dungeon:${segments[3]}`; } if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas"; if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) { return `manastorm:${segments[3]}`; } if (segments[1] === "game" && segments[2] === "combat-effects") return "core:combat-media"; if (segments[1] === "ui") return "core:ui"; if (segments[1] === "equipment") return "core:equipment"; return "core:misc"; } async function walkFiles(directory) { const rootStats = await stat(directory); if (rootStats.isFile()) { return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : []; } const files = []; async function visit(current) { const entries = await readdir(current, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const fullPath = path.join(current, entry.name); if (entry.isDirectory()) await visit(fullPath); else if (entry.isFile() && RUNTIME_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(fullPath); } } await visit(directory); return files; } async function sha256File(filePath) { const { createReadStream } = await import("node:fs"); return new Promise((resolve, reject) => { const hash = createHash("sha256"); const stream = createReadStream(filePath); stream.on("error", reject); stream.on("data", (chunk) => hash.update(chunk)); stream.on("end", () => resolve(hash.digest("hex"))); }); } export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) { const discovered = new Map(); for (const source of sources) { const sourceDirectory = path.resolve(source.directory); const files = await walkFiles(sourceDirectory); for (const sourcePath of files) { const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath)); const logicalPath = normalizedRelativePath(relative ? `${source.logicalPrefix}/${relative}` : source.logicalPrefix); const fileStats = await stat(sourcePath); const sha256 = await sha256File(sourcePath); const prior = discovered.get(logicalPath); if (prior) { if (prior.sha256 !== sha256) { throw new Error(`Two content sources map different files to ${logicalPath}`); } continue; } discovered.set(logicalPath, { logicalPath, sourcePath, sha256, bytes: fileStats.size, extension: path.extname(sourcePath).toLowerCase(), packId: contentPackForPath(logicalPath), }); } } return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)); } function contentVersion(files, packs) { const digest = createHash("sha256"); for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\0${JSON.stringify(file.download ?? null)}\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 and creature files remain independently downloadable. A dungeon * pack pulls in exactly the environment and creature packs referenced by its * generated definition, while randomized modes can install creatures alone. */ export function campaignDungeonPackDefinitions(definitions) { const packs = new Map(); for (const definition of definitions) { const dungeonPackId = `dungeon:${definition.id}`; if (definition.id === "wailing-caverns") { const referencedCreaturePacks = new Set([ "creatures:wailing-caverns", ...Object.values(definition.entities ?? {}) .map((entity) => entity.visual?.model?.url) .filter(Boolean) .map(assetPackId), ]); const unexpectedCreaturePacks = [...referencedCreaturePacks] .filter((id) => id !== "creatures:wailing-caverns" && 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: true, dependencies: [...referencedCreaturePacks], }); 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 its expected 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; if (environmentPackId !== dungeonPackId) { 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); const expectedCreaturePackId = `creatures:${definition.id}`; const unexpectedCreaturePacks = [...referencedCreaturePacks] .filter((id) => id !== expectedCreaturePackId && 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: definition.id === "wailing-caverns", dependencies: [ ...(environmentPackId === dungeonPackId ? [] : [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 packs = new Map(); const fileEntries = {}; for (const file of files) { const objectName = `${file.sha256}${file.extension}`; const logicalUrl = `/${normalizedRelativePath(file.logicalPath)}`; fileEntries[logicalUrl] = { packId: file.packId, url: `${baseUrl}/${encodeUrlPath(objectName)}`, sha256: file.sha256, bytes: file.bytes, ...(file.download ? { download: file.download } : {}), }; const pack = packs.get(file.packId) ?? { id: file.packId, label: packLabel(file.packId), bytes: 0, fileCount: 0, bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(file.packId), dependencies: [], }; pack.bytes += file.bytes; pack.fileCount += 1; packs.set(file.packId, pack); } if (packs.has("shared:creatures")) { for (const pack of packs.values()) { if ((pack.id.startsWith("dungeon:") && pack.id !== "dungeon:wailing-caverns") || pack.id.startsWith("manastorm:")) { pack.dependencies = ["shared:creatures"]; } } } if (packs.has("core:character-basic")) { for (const pack of packs.values()) { if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"]; } } 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], }); } // RuneWaker dungeon definitions are generated TypeScript rather than part of // the WoW JSON catalog. Their matching creature dependency is recoverable // from the stable pack namespace without importing runtime code here. for (const creaturePack of [...packs.values()].filter((pack) => pack.id.startsWith("creatures:"))) { const dungeonPack = packs.get(`dungeon:${creaturePack.id.slice("creatures:".length)}`); if (!dungeonPack || dungeonPack.dependencies.includes(creaturePack.id)) continue; dungeonPack.dependencies = [...dungeonPack.dependencies, creaturePack.id]; } const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)); for (const pack of sortedPacks) { const objects = new Map(files.filter((file) => file.packId === pack.id) .map((file) => [file.sha256, file.download?.bytes ?? file.bytes])); pack.downloadBytes = [...objects.values()].reduce((sum, bytes) => sum + bytes, 0); } 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: 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 replaceJsonAtomically(destination, value) { const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`; await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); await rename(temporary, destination); } 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, ...(campaignDefinitions ? [RANDOMIZED_BITE_SIZED_PACK_DEFINITION] : []), ...(options.packDefinitions ?? []), ]; if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions); const objectDirectory = path.join(contentDirectory, "objects"); const manifestDirectory = path.join(contentDirectory, "manifests"); await mkdir(objectDirectory, { recursive: true }); await mkdir(manifestDirectory, { recursive: true }); const prepared = await prepareContentObjects(files, { directory: objectDirectory, baseUrl: String(options.baseUrl ?? "/content/objects").replace(/\/+$/, ""), keepOriginal: true, }); const manifest = createContentManifest(prepared.files, { ...options, packDefinitions }); await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest); await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest); return { contentDirectory, copiedObjects: prepared.writtenObjects, storage: { logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes }, manifest, campaignDungeonPackCount: campaignDefinitions?.length ?? 0, }; } function parseCliArguments(argv) { const options = {}; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === "--content-dir") options.contentDirectory = argv[++index]; else if (value === "--base-url") options.baseUrl = argv[++index]; else if (value === "--minimum-app-version") options.minimumAppVersion = argv[++index]; else throw new Error(`Unknown content publishing argument: ${value}`); } return options; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const result = await publishContent(parseCliArguments(process.argv.slice(2))); 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")}`); }