export const CONTENT_MANIFEST_SCHEMA_VERSION = 1; export interface ContentManifestFile { readonly packId: string; readonly url: string; readonly sha256: string; readonly bytes: number; readonly download?: { readonly url: string; readonly encoding: "gzip"; readonly sha256: string; readonly bytes: number; }; } export interface ContentManifestPack { readonly id: string; readonly label: string; readonly bytes: number; readonly downloadBytes?: number; readonly fileCount: number; readonly bundledOnAndroid: boolean; readonly dependencies: readonly string[]; } export interface ContentManifest { readonly schemaVersion: number; readonly version: string; readonly createdAt: string; readonly minimumAppVersion: string; readonly packs: readonly ContentManifestPack[]; readonly files: Readonly>; } /** Resolves runtime logical asset URLs to the independently installable packs that own them. */ export function contentPackIdsForLogicalUrls( manifest: ContentManifest, logicalUrls: readonly string[], ): readonly string[] { const packIds = new Set(); for (const logicalUrl of logicalUrls) { let pathname: string; try { pathname = new URL(logicalUrl, "https://healer-man.invalid").pathname; } catch { throw new Error(`Invalid content asset URL: ${logicalUrl}`); } const entry = manifest.files[pathname]; if (!entry) throw new Error(`The content catalog is missing randomized asset ${pathname}.`); packIds.add(entry.packId); } return [...packIds].sort(); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } export function parseContentManifest(value: unknown): ContentManifest { if (!isRecord(value) || value.schemaVersion !== CONTENT_MANIFEST_SCHEMA_VERSION) { throw new Error("This content catalog uses an unsupported schema."); } if (typeof value.version !== "string" || typeof value.createdAt !== "string" || typeof value.minimumAppVersion !== "string") { throw new Error("The content catalog is missing version information."); } if (!Array.isArray(value.packs) || !isRecord(value.files)) { throw new Error("The content catalog is malformed."); } const packs = value.packs.map((pack) => { if (!isRecord(pack) || typeof pack.id !== "string" || typeof pack.label !== "string" || typeof pack.bytes !== "number" || typeof pack.fileCount !== "number" || typeof pack.bundledOnAndroid !== "boolean" || !Array.isArray(pack.dependencies) || !pack.dependencies.every((dependency) => typeof dependency === "string")) { throw new Error("The content catalog contains a malformed pack."); } return pack as unknown as ContentManifestPack; }); const files = Object.fromEntries(Object.entries(value.files).map(([logicalPath, entry]) => { if (!logicalPath.startsWith("/") || !isRecord(entry) || typeof entry.packId !== "string" || typeof entry.url !== "string" || !/^[a-f0-9]{64}$/.test(String(entry.sha256)) || typeof entry.bytes !== "number" || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0) { throw new Error(`The content catalog contains a malformed file: ${logicalPath}`); } if (entry.download !== undefined && (!isRecord(entry.download) || entry.download.encoding !== "gzip" || typeof entry.download.url !== "string" || !/^[a-f0-9]{64}$/.test(String(entry.download.sha256)) || !Number.isSafeInteger(entry.download.bytes) || Number(entry.download.bytes) < 0)) { throw new Error(`The content catalog contains a malformed compressed file: ${logicalPath}`); } return [logicalPath, entry as unknown as ContentManifestFile]; })); return { ...value, packs, files } as unknown as ContentManifest; } function numericVersion(value: string): readonly number[] { const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.trim()); return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0]; } export function appVersionMeetsMinimum(current: string, minimum: string): boolean { const left = numericVersion(current); const right = numericVersion(minimum); for (let index = 0; index < 3; index += 1) { if (left[index] > right[index]) return true; if (left[index] < right[index]) return false; } return true; } type ContentPackFiles = readonly (readonly [string, ContentManifestFile])[]; const packFileIndexes = new WeakMap>(); const EMPTY_PACK_FILES: ContentPackFiles = []; /** Catalog file maps are immutable; index once instead of scanning during gameplay. */ export function filesForContentPack( manifest: ContentManifest, packId: string, ): readonly (readonly [string, ContentManifestFile])[] { let index = packFileIndexes.get(manifest.files); if (!index) { const groups = new Map(); for (const [logicalPath, entry] of Object.entries(manifest.files)) { let files = groups.get(entry.packId); if (!files) { files = []; groups.set(entry.packId, files); } files.push([logicalPath, entry]); } for (const files of groups.values()) files.sort(([left], [right]) => left.localeCompare(right)); index = groups; packFileIndexes.set(manifest.files, index); } return index.get(packId) ?? EMPTY_PACK_FILES; } export interface ContentFileIdentity { readonly sha256: string; } export function changedFilesForPack( manifest: ContentManifest, packId: string, activeFiles: Readonly>, ): readonly (readonly [string, ContentManifestFile])[] { return filesForContentPack(manifest, packId).filter(([logicalPath, entry]) => ( activeFiles[logicalPath]?.sha256 !== entry.sha256 )); }