Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
contentPackIsBundled,
|
||||
contentPackIsInstalled,
|
||||
contentPackIsUsable,
|
||||
dungeonContentIsUsable,
|
||||
getContentManagerSnapshot,
|
||||
installContentPack,
|
||||
removeContentPack,
|
||||
@@ -81,6 +82,7 @@ function contentManifest(version: string, objectName: string, bytes: Uint8Array)
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("browser content packs", () => {
|
||||
@@ -122,6 +124,7 @@ describe("browser content packs", () => {
|
||||
|
||||
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
|
||||
await checkForContentUpdates();
|
||||
expect(contentPackIsUsable("characters:test")).toBe(false);
|
||||
expect(contentPackHasUpdate("characters:test")).toBe(true);
|
||||
|
||||
corruptUpdate = true;
|
||||
@@ -147,4 +150,14 @@ describe("browser content packs", () => {
|
||||
await expect(removeContentPack("core:ui")).resolves.toBeUndefined();
|
||||
expect(contentPackIsUsable("core:ui")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses source-served dungeons in development and the content gate in shipped builds", () => {
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
vi.stubEnv("DEV", true);
|
||||
expect(dungeonContentIsUsable("deadmines")).toBe(true);
|
||||
|
||||
vi.stubEnv("DEV", false);
|
||||
expect(dungeonContentIsUsable("deadmines")).toBe(false);
|
||||
expect(dungeonContentIsUsable("wailing-caverns")).toBe(true);
|
||||
});
|
||||
});
|
||||
+235
-23
@@ -9,7 +9,7 @@ import {
|
||||
} from "./contentManifest";
|
||||
import { NativeContentStorage } from "./nativeContentStorage";
|
||||
|
||||
const CONTENT_STATE_KEY = "healer-man.content-state.v1";
|
||||
export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
|
||||
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||
const STATIC_ANDROID_BUNDLED_PACKS = new Set([
|
||||
"core:ui",
|
||||
@@ -30,6 +30,8 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
|
||||
|
||||
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
|
||||
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
|
||||
const BROWSER_CONTENT_SHA256_HEADER = "x-healer-man-content-sha256";
|
||||
const BROWSER_CONTENT_BYTES_HEADER = "x-healer-man-content-bytes";
|
||||
|
||||
export interface InstalledContentFile {
|
||||
readonly packId: string;
|
||||
@@ -39,9 +41,20 @@ export interface InstalledContentFile {
|
||||
readonly extension: string;
|
||||
}
|
||||
|
||||
interface InstalledContentFileIdentity {
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
interface InstalledContentPackReceipt {
|
||||
readonly manifestVersion: string;
|
||||
readonly dependencies: readonly string[];
|
||||
readonly files: Readonly<Record<string, InstalledContentFileIdentity>>;
|
||||
}
|
||||
|
||||
interface PersistedContentState {
|
||||
readonly version: 1;
|
||||
readonly packVersions: Readonly<Record<string, string>>;
|
||||
readonly version: 2;
|
||||
readonly packs: Readonly<Record<string, InstalledContentPackReceipt>>;
|
||||
readonly files: Readonly<Record<string, InstalledContentFile>>;
|
||||
}
|
||||
|
||||
@@ -57,7 +70,7 @@ export interface ContentManagerSnapshot {
|
||||
readonly error: string;
|
||||
}
|
||||
|
||||
const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files: {} };
|
||||
const EMPTY_STATE: PersistedContentState = { version: 2, packs: {}, files: {} };
|
||||
let installedState = readInstalledState();
|
||||
let snapshot: ContentManagerSnapshot = {
|
||||
revision: 0,
|
||||
@@ -72,16 +85,69 @@ let snapshot: ContentManagerSnapshot = {
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
let initialization: Promise<void> | null = null;
|
||||
let installedStorageValidation: Promise<void> | null = null;
|
||||
let installedStorageValidationComplete = Object.keys(installedState.packs).length === 0;
|
||||
const validatedInstalledFiles = new Set<string>();
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function isByteCount(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function readInstalledState(): PersistedContentState {
|
||||
try {
|
||||
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as Partial<PersistedContentState> | null;
|
||||
if (!value || value.version !== 1 || !value.files || !value.packVersions) return EMPTY_STATE;
|
||||
return { version: 1, files: value.files, packVersions: value.packVersions };
|
||||
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as unknown;
|
||||
if (!isRecord(value) || value.version !== 2 || !isRecord(value.files) || !isRecord(value.packs)) {
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
const files: Record<string, InstalledContentFile> = {};
|
||||
for (const [logicalPath, entry] of Object.entries(value.files)) {
|
||||
if (!logicalPath.startsWith("/")
|
||||
|| !isRecord(entry)
|
||||
|| typeof entry.packId !== "string"
|
||||
|| !isSha256(entry.sha256)
|
||||
|| !isByteCount(entry.bytes)
|
||||
|| typeof entry.fileUri !== "string"
|
||||
|| !entry.fileUri
|
||||
|| typeof entry.extension !== "string"
|
||||
|| !entry.extension.startsWith(".")) return EMPTY_STATE;
|
||||
files[logicalPath] = entry as unknown as InstalledContentFile;
|
||||
}
|
||||
const packs: Record<string, InstalledContentPackReceipt> = {};
|
||||
for (const [packId, receipt] of Object.entries(value.packs)) {
|
||||
if (!packId
|
||||
|| !isRecord(receipt)
|
||||
|| typeof receipt.manifestVersion !== "string"
|
||||
|| !receipt.manifestVersion
|
||||
|| !Array.isArray(receipt.dependencies)
|
||||
|| !receipt.dependencies.every((dependency) => typeof dependency === "string")
|
||||
|| !isRecord(receipt.files)) return EMPTY_STATE;
|
||||
const receiptFiles: Record<string, InstalledContentFileIdentity> = {};
|
||||
for (const [logicalPath, identity] of Object.entries(receipt.files)) {
|
||||
if (!logicalPath.startsWith("/")
|
||||
|| !isRecord(identity)
|
||||
|| !isSha256(identity.sha256)
|
||||
|| !isByteCount(identity.bytes)) return EMPTY_STATE;
|
||||
receiptFiles[logicalPath] = { sha256: identity.sha256, bytes: identity.bytes };
|
||||
}
|
||||
packs[packId] = {
|
||||
manifestVersion: receipt.manifestVersion,
|
||||
dependencies: [...receipt.dependencies],
|
||||
files: receiptFiles,
|
||||
};
|
||||
}
|
||||
return { version: 2, files, packs };
|
||||
} catch {
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
@@ -116,6 +182,60 @@ function browserCacheStorage(): CacheStorage | null {
|
||||
return typeof caches === "undefined" ? null : caches;
|
||||
}
|
||||
|
||||
async function validateInstalledContentStorage(): Promise<void> {
|
||||
if (!installedStorageValidation) {
|
||||
installedStorageValidation = (async () => {
|
||||
const validFiles: Record<string, InstalledContentFile> = {};
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
|
||||
try {
|
||||
const result = await NativeContentStorage.has({
|
||||
sha256: entry.sha256,
|
||||
extension: entry.extension,
|
||||
bytes: entry.bytes,
|
||||
});
|
||||
if (!result.exists) continue;
|
||||
validFiles[logicalPath] = {
|
||||
...entry,
|
||||
fileUri: result.fileUri || entry.fileUri,
|
||||
};
|
||||
} catch {
|
||||
// A file that cannot be verified must not make its pack launchable.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const storage = browserCacheStorage();
|
||||
const active = storage ? await storage.open(BROWSER_CONTENT_CACHE_NAME) : null;
|
||||
if (active) {
|
||||
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
|
||||
const cached = await active.match(browserCacheRequest(logicalPath));
|
||||
if (cached?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
|
||||
&& cached.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes)) {
|
||||
validFiles[logicalPath] = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache access failure is handled as unavailable content, never as a valid install.
|
||||
}
|
||||
}
|
||||
|
||||
validatedInstalledFiles.clear();
|
||||
Object.keys(validFiles).forEach((logicalPath) => validatedInstalledFiles.add(logicalPath));
|
||||
const filesChanged = Object.keys(validFiles).length !== Object.keys(installedState.files).length
|
||||
|| Object.entries(validFiles).some(([logicalPath, entry]) => (
|
||||
installedState.files[logicalPath]?.fileUri !== entry.fileUri
|
||||
));
|
||||
installedState = { ...installedState, files: validFiles };
|
||||
installedStorageValidationComplete = true;
|
||||
if (filesChanged) persistInstalledState();
|
||||
publish({ installedRevision: snapshot.installedRevision + 1 });
|
||||
})();
|
||||
}
|
||||
await installedStorageValidation;
|
||||
}
|
||||
|
||||
async function registerBrowserContentWorker(): Promise<void> {
|
||||
if (Capacitor.isNativePlatform()
|
||||
|| import.meta.env.DEV
|
||||
@@ -137,6 +257,19 @@ function extensionForEntry(entry: ContentManifestFile): string {
|
||||
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
|
||||
}
|
||||
|
||||
function packReceiptForManifest(manifest: ContentManifest, packId: string): InstalledContentPackReceipt {
|
||||
const pack = manifest.packs.find((candidate) => candidate.id === packId);
|
||||
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
|
||||
return {
|
||||
manifestVersion: manifest.version,
|
||||
dependencies: [...pack.dependencies],
|
||||
files: Object.fromEntries(filesForContentPack(manifest, packId).map(([logicalPath, entry]) => [
|
||||
logicalPath,
|
||||
{ sha256: entry.sha256, bytes: entry.bytes },
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
|
||||
if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
|
||||
if (!snapshot.manifest) return {};
|
||||
@@ -166,7 +299,9 @@ export function resolveContentUrl(logicalUrl: string): string {
|
||||
let pathname = logicalUrl;
|
||||
try { pathname = new URL(logicalUrl, "https://local.invalid").pathname; } catch { /* preserve original */ }
|
||||
const installed = installedState.files[pathname];
|
||||
return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl;
|
||||
return installed?.fileUri && validatedInstalledFiles.has(pathname)
|
||||
? Capacitor.convertFileSrc(installed.fileUri)
|
||||
: logicalUrl;
|
||||
}
|
||||
|
||||
export function contentPackIsBundled(packId: string): boolean {
|
||||
@@ -176,20 +311,66 @@ export function contentPackIsBundled(packId: string): boolean {
|
||||
}
|
||||
|
||||
export function contentPackIsInstalled(packId: string): boolean {
|
||||
return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]);
|
||||
return contentPackIsBundled(packId) || Boolean(installedState.packs[packId]);
|
||||
}
|
||||
|
||||
function matchingReceiptFiles(
|
||||
packId: string,
|
||||
receipt: InstalledContentPackReceipt,
|
||||
expectedFiles: Readonly<Record<string, InstalledContentFileIdentity>>,
|
||||
): boolean {
|
||||
const expectedEntries = Object.entries(expectedFiles);
|
||||
if (Object.keys(receipt.files).length !== expectedEntries.length) return false;
|
||||
return expectedEntries.every(([logicalPath, expected]) => {
|
||||
const recorded = receipt.files[logicalPath];
|
||||
const installed = installedState.files[logicalPath];
|
||||
return recorded?.sha256 === expected.sha256
|
||||
&& recorded.bytes === expected.bytes
|
||||
&& installed?.packId === packId
|
||||
&& installed.sha256 === expected.sha256
|
||||
&& installed.bytes === expected.bytes
|
||||
&& validatedInstalledFiles.has(logicalPath);
|
||||
});
|
||||
}
|
||||
|
||||
function contentPackIsUsableInternal(packId: string, visiting: ReadonlySet<string>): boolean {
|
||||
if (contentPackIsBundled(packId)) return true;
|
||||
if (!installedStorageValidationComplete || visiting.has(packId)) return false;
|
||||
const receipt = installedState.packs[packId];
|
||||
if (!receipt) return false;
|
||||
|
||||
const manifestPack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
||||
if (snapshot.manifest && !manifestPack) return false;
|
||||
const dependencies = manifestPack?.dependencies ?? receipt.dependencies;
|
||||
if (dependencies.length !== receipt.dependencies.length
|
||||
|| dependencies.some((dependency, index) => receipt.dependencies[index] !== dependency)) return false;
|
||||
const expectedFiles = manifestPack
|
||||
? Object.fromEntries(filesForContentPack(snapshot.manifest as ContentManifest, packId).map(([logicalPath, entry]) => [
|
||||
logicalPath,
|
||||
{ sha256: entry.sha256, bytes: entry.bytes },
|
||||
]))
|
||||
: receipt.files;
|
||||
if (!matchingReceiptFiles(packId, receipt, expectedFiles)) return false;
|
||||
|
||||
const nextVisiting = new Set(visiting);
|
||||
nextVisiting.add(packId);
|
||||
return dependencies.every((dependency) => contentPackIsUsableInternal(dependency, nextVisiting));
|
||||
}
|
||||
|
||||
export function contentPackIsUsable(packId: string): boolean {
|
||||
if (contentPackIsBundled(packId)) return true;
|
||||
if (!installedState.packVersions[packId]) return false;
|
||||
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
||||
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
|
||||
return contentPackIsUsableInternal(packId, new Set());
|
||||
}
|
||||
|
||||
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
|
||||
export function dungeonContentIsUsable(dungeonId: string): boolean {
|
||||
return import.meta.env.DEV || contentPackIsUsable(`dungeon:${dungeonId}`);
|
||||
}
|
||||
|
||||
export function contentPackHasUpdate(packId: string): boolean {
|
||||
if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
|
||||
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
|
||||
return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
||||
return Boolean(!contentPackIsBundled(packId) && !contentPackIsUsable(packId))
|
||||
|| changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
||||
|| Boolean(!contentPackIsBundled(packId)
|
||||
&& pack?.dependencies.some((dependency) => (
|
||||
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
|
||||
@@ -224,7 +405,11 @@ async function loadBundledManifest(): Promise<void> {
|
||||
export function initializeContentSystem(): Promise<void> {
|
||||
if (!initialization) {
|
||||
initialization = (async () => {
|
||||
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
|
||||
await Promise.all([
|
||||
loadBundledManifest(),
|
||||
registerBrowserContentWorker(),
|
||||
validateInstalledContentStorage(),
|
||||
]);
|
||||
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
||||
})();
|
||||
}
|
||||
@@ -268,6 +453,8 @@ async function installBrowserContentPack(
|
||||
const headers = new Headers();
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
headers.set(BROWSER_CONTENT_SHA256_HEADER, entry.sha256);
|
||||
headers.set(BROWSER_CONTENT_BYTES_HEADER, String(entry.bytes));
|
||||
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
|
||||
headers,
|
||||
status: 200,
|
||||
@@ -289,6 +476,7 @@ async function installBrowserContentPack(
|
||||
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
|
||||
await active.delete(browserCacheRequest(logicalPath));
|
||||
delete pendingFiles[logicalPath];
|
||||
validatedInstalledFiles.delete(logicalPath);
|
||||
}
|
||||
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
||||
pendingFiles[logicalPath] = {
|
||||
@@ -298,11 +486,15 @@ async function installBrowserContentPack(
|
||||
extension: extensionForEntry(entry),
|
||||
fileUri: absoluteContentUrl(logicalPath),
|
||||
};
|
||||
validatedInstalledFiles.add(logicalPath);
|
||||
}
|
||||
installedState = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
files: pendingFiles,
|
||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||
packs: {
|
||||
...installedState.packs,
|
||||
[packId]: packReceiptForManifest(manifest, packId),
|
||||
},
|
||||
};
|
||||
persistInstalledState();
|
||||
publish({
|
||||
@@ -321,6 +513,7 @@ async function installBrowserContentPack(
|
||||
}
|
||||
|
||||
export async function installContentPack(packId: string): Promise<void> {
|
||||
await validateInstalledContentStorage();
|
||||
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
||||
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
|
||||
if (!appVersionMeetsMinimum(import.meta.env.VITE_APP_VERSION, manifest.minimumAppVersion)) {
|
||||
@@ -380,12 +573,30 @@ export async function installContentPack(packId: string): Promise<void> {
|
||||
|
||||
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
|
||||
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
|
||||
if (installed.packId === packId && !latestPaths.has(logicalPath)) delete pendingFiles[logicalPath];
|
||||
if (installed.packId === packId && !latestPaths.has(logicalPath)) {
|
||||
delete pendingFiles[logicalPath];
|
||||
validatedInstalledFiles.delete(logicalPath);
|
||||
}
|
||||
}
|
||||
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
||||
const installed = pendingFiles[logicalPath];
|
||||
if (!installed) throw new Error(`Verified content is missing after installation: ${logicalPath}`);
|
||||
pendingFiles[logicalPath] = {
|
||||
...installed,
|
||||
packId,
|
||||
sha256: entry.sha256,
|
||||
bytes: entry.bytes,
|
||||
extension: extensionForEntry(entry),
|
||||
};
|
||||
validatedInstalledFiles.add(logicalPath);
|
||||
}
|
||||
installedState = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
files: pendingFiles,
|
||||
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
|
||||
packs: {
|
||||
...installedState.packs,
|
||||
[packId]: packReceiptForManifest(manifest, packId),
|
||||
},
|
||||
};
|
||||
persistInstalledState();
|
||||
publish({
|
||||
@@ -424,9 +635,10 @@ export async function removeContentPack(packId: string): Promise<void> {
|
||||
delete remainingFiles[logicalPath];
|
||||
}
|
||||
}
|
||||
const packVersions = { ...installedState.packVersions };
|
||||
delete packVersions[packId];
|
||||
installedState = { version: 1, files: remainingFiles, packVersions };
|
||||
targetFiles.forEach(([logicalPath]) => validatedInstalledFiles.delete(logicalPath));
|
||||
const packs = { ...installedState.packs };
|
||||
delete packs[packId];
|
||||
installedState = { version: 2, files: remainingFiles, packs };
|
||||
persistInstalledState();
|
||||
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./nativeContentStorage", () => ({ NativeContentStorage: {} }));
|
||||
|
||||
const STATE_KEY = "healer-man.content-state.v2";
|
||||
const ENVIRONMENT_PATH = "/assets/game/manastorm/test/visual.glb";
|
||||
const CREATURE_PATH = "/assets/game/creatures/shared/test.glb";
|
||||
const ENVIRONMENT_HASH = "a".repeat(64);
|
||||
const CREATURE_HASH = "b".repeat(64);
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length(): number { return this.values.size; }
|
||||
clear(): void { this.values.clear(); }
|
||||
getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
|
||||
removeItem(key: string): void { this.values.delete(key); }
|
||||
setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
class MemoryCache {
|
||||
readonly responses = new Map<string, Response>();
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.responses.get(this.key(request))?.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.responses.set(this.key(request), response.clone());
|
||||
}
|
||||
|
||||
async delete(request: RequestInfo | URL): Promise<boolean> {
|
||||
return this.responses.delete(this.key(request));
|
||||
}
|
||||
|
||||
private key(request: RequestInfo | URL): string {
|
||||
if (request instanceof Request) return request.url;
|
||||
return new URL(String(request), "http://localhost").toString();
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryCacheStorage {
|
||||
readonly stores = new Map<string, MemoryCache>();
|
||||
|
||||
async open(name: string): Promise<Cache> {
|
||||
const cache = this.stores.get(name) ?? new MemoryCache();
|
||||
this.stores.set(name, cache);
|
||||
return cache as unknown as Cache;
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.stores.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
function installedState() {
|
||||
return {
|
||||
version: 2,
|
||||
packs: {
|
||||
"dungeon:test": {
|
||||
manifestVersion: "content-one",
|
||||
dependencies: ["manastorm:test", "shared:creatures"],
|
||||
files: {},
|
||||
},
|
||||
"manastorm:test": {
|
||||
manifestVersion: "content-one",
|
||||
dependencies: [],
|
||||
files: { [ENVIRONMENT_PATH]: { sha256: ENVIRONMENT_HASH, bytes: 11 } },
|
||||
},
|
||||
"shared:creatures": {
|
||||
manifestVersion: "content-one",
|
||||
dependencies: [],
|
||||
files: { [CREATURE_PATH]: { sha256: CREATURE_HASH, bytes: 12 } },
|
||||
},
|
||||
},
|
||||
files: {
|
||||
[ENVIRONMENT_PATH]: {
|
||||
packId: "manastorm:test",
|
||||
sha256: ENVIRONMENT_HASH,
|
||||
bytes: 11,
|
||||
fileUri: `http://localhost${ENVIRONMENT_PATH}`,
|
||||
extension: ".glb",
|
||||
},
|
||||
[CREATURE_PATH]: {
|
||||
packId: "shared:creatures",
|
||||
sha256: CREATURE_HASH,
|
||||
bytes: 12,
|
||||
fileUri: `http://localhost${CREATURE_PATH}`,
|
||||
extension: ".glb",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOfflineManager(
|
||||
state: unknown,
|
||||
cachedPaths: readonly string[],
|
||||
) {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("DEV", false);
|
||||
vi.stubEnv("VITE_CONTENT_MODE", "bundled");
|
||||
const localStorage = new MemoryStorage();
|
||||
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||
vi.stubGlobal("window", { localStorage, location: { origin: "http://localhost" } });
|
||||
vi.stubGlobal("navigator", { onLine: false });
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
vi.stubGlobal("caches", cacheStorage as unknown as CacheStorage);
|
||||
const { Capacitor } = await import("@capacitor/core");
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
const manager = await import("./contentManager");
|
||||
const active = await cacheStorage.open(manager.BROWSER_CONTENT_CACHE_NAME);
|
||||
for (const logicalPath of cachedPaths) {
|
||||
const entry = installedState().files[logicalPath as keyof ReturnType<typeof installedState>["files"]];
|
||||
await active.put(new Request(`http://localhost${logicalPath}`), new Response("cached", {
|
||||
headers: {
|
||||
"x-healer-man-content-sha256": entry.sha256,
|
||||
"x-healer-man-content-bytes": String(entry.bytes),
|
||||
},
|
||||
}));
|
||||
}
|
||||
return { localStorage, manager };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("persisted content readiness", () => {
|
||||
it("restores a complete dependency-only dungeon closure offline after verifying storage", async () => {
|
||||
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
|
||||
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a persisted install when a required cached file is missing", async () => {
|
||||
const { localStorage, manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH]);
|
||||
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
expect(JSON.parse(localStorage.getItem(STATE_KEY) ?? "null").files[CREATURE_PATH]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a missing dependency receipt", async () => {
|
||||
const state = installedState();
|
||||
delete (state.packs as Partial<typeof state.packs>)["shared:creatures"];
|
||||
const { manager } = await loadOfflineManager(state, [ENVIRONMENT_PATH, CREATURE_PATH]);
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a receipt whose dependency topology is stale", async () => {
|
||||
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(true);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({
|
||||
schemaVersion: 1,
|
||||
version: "content-two",
|
||||
createdAt: "2026-09-08T00:00:00.000Z",
|
||||
minimumAppVersion: "0.1.0",
|
||||
packs: [
|
||||
{
|
||||
id: "dungeon:test",
|
||||
label: "Test",
|
||||
bytes: 0,
|
||||
fileCount: 0,
|
||||
bundledOnAndroid: false,
|
||||
dependencies: ["manastorm:new"],
|
||||
},
|
||||
],
|
||||
files: {},
|
||||
})));
|
||||
await manager.checkForContentUpdates();
|
||||
expect(manager.contentPackIsUsable("dungeon:test")).toBe(false);
|
||||
expect(manager.contentPackHasUpdate("dungeon:test")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not accept a legacy packVersions marker as an install receipt", async () => {
|
||||
const { manager } = await loadOfflineManager({
|
||||
version: 2,
|
||||
packVersions: { "dungeon:test": "content-one" },
|
||||
files: {},
|
||||
}, []);
|
||||
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.contentPackIsInstalled("dungeon:test")).toBe(false);
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user