161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
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<Record<string, ContentManifestFile>>;
|
|
}
|
|
|
|
/** 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<string>();
|
|
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<string, unknown> {
|
|
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<ContentManifest["files"], ReadonlyMap<string, ContentPackFiles>>();
|
|
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<string, [string, ContentManifestFile][]>();
|
|
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<Record<string, ContentFileIdentity>>,
|
|
): readonly (readonly [string, ContentManifestFile])[] {
|
|
return filesForContentPack(manifest, packId).filter(([logicalPath, entry]) => (
|
|
activeFiles[logicalPath]?.sha256 !== entry.sha256
|
|
));
|
|
}
|