import { createHash } from "node:crypto"; import { constants, copyFile, mkdir, readdir, rename, stat, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../.."); export const CONTENT_SCHEMA_VERSION = 1; export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0"; export const BUNDLED_ANDROID_PACKS = Object.freeze([ "core:ui", "core:equipment", "dungeon:wailing-caverns", ]); const RUNTIME_EXTENSIONS = new Set([ ".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png", ".svg", ".wav", ".webm", ".webp", ]); 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 === "characters") return `${name} Characters`; 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"; return name; } export function contentPackForPath(logicalPath) { const segments = normalizedRelativePath(logicalPath).split("/"); if (segments[0] !== "assets") return "core:misc"; if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`; if (segments[1] === "creatures" && segments[2]) { return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`; } if (segments[1] === "game" && segments[2] === "creatures" && segments[3]) { return segments[3] === "shared" ? "shared:creatures" : `dungeon:${segments[3]}`; } if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) { return `dungeon:${segments[3]}`; } if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) { return `manastorm:${segments[3]}`; } if (segments[1] === "ui") return "core:ui"; if (segments[1] === "equipment") return "core:equipment"; return "core:misc"; } async function walkFiles(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(`${source.logicalPrefix}/${relative}`); 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) { const digest = createHash("sha256"); for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`); return `content-${digest.digest("hex").slice(0, 16)}`; } 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 = {}; 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, }; 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.startsWith("manastorm:")) { pack.dependencies = ["shared:creatures"]; } } } 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)), files: fileEntries, }; } async function copyObjectIfMissing(file, objectDirectory) { const objectName = `${file.sha256}${file.extension}`; const destination = path.join(objectDirectory, objectName); try { const destinationStats = await stat(destination); if (destinationStats.size !== file.bytes) throw new Error(`Published object has the wrong size: ${destination}`); return false; } catch (error) { if (error?.code !== "ENOENT") throw error; } try { await copyFile(file.sourcePath, destination, constants.COPYFILE_EXCL); } catch (error) { if (error?.code !== "EEXIST") throw error; } return true; } 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 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 }; } 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(`New immutable objects: ${result.copiedObjects}`); console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`); }