diff --git a/src/game/dungeonSession.ts b/src/game/dungeonSession.ts
index 7fe6f905..e1491b16 100644
--- a/src/game/dungeonSession.ts
+++ b/src/game/dungeonSession.ts
@@ -77,3 +77,16 @@ export function activateDungeonSession(
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
return true;
}
+
+/**
+ * Rebuilds the current encounter after an asset/render failure without
+ * discarding the selected character, party composition, or run progression.
+ */
+export function restartActiveDungeonSession(): boolean {
+ const game = useGameStore.getState();
+ return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
+ gameMode: game.gameMode,
+ spawn: game.activeSpawn,
+ preserveCharacter: true,
+ });
+}
diff --git a/src/game/dungeonSessionAssetCache.test.ts b/src/game/dungeonSessionAssetCache.test.ts
index 3e60e68b..1e2d03e6 100644
--- a/src/game/dungeonSessionAssetCache.test.ts
+++ b/src/game/dungeonSessionAssetCache.test.ts
@@ -2,7 +2,8 @@ import { useGLTF } from "@react-three/drei";
import { afterEach, describe, expect, it, vi } from "vitest";
import { dungeonGltfUrls } from "./dungeonAssets";
import { dungeonDefinitionById } from "./dungeonRegistry";
-import { activateDungeonSession } from "./dungeonSession";
+import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
+import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
describe("dungeon session asset eviction", () => {
@@ -28,4 +29,19 @@ describe("dungeon session asset eviction", () => {
expect(activateDungeonSession("not-a-dungeon")).toBe(false);
expect(clear).not.toHaveBeenCalled();
});
+
+ it("restarts the active dungeon in place while preserving its party", () => {
+ const active = dungeonDefinitionById("deadmines")!;
+ useGameStore.getState().activateDungeon(active.id);
+ usePartyStore.getState().initializeParty("retry-party", 20, 600, "healer");
+ const members = usePartyStore.getState().members.map((member) => member.id);
+ const revision = useGameStore.getState().sessionRevision;
+ const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
+
+ expect(restartActiveDungeonSession()).toBe(true);
+
+ expect(clear.mock.calls.map(([url]) => url)).toEqual(dungeonGltfUrls(active));
+ expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
+ expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
+ });
});
diff --git a/src/scene/GameScene.tsx b/src/scene/GameScene.tsx
index 37c25f94..b8fc7756 100644
--- a/src/scene/GameScene.tsx
+++ b/src/scene/GameScene.tsx
@@ -36,6 +36,7 @@ import {
wailingStaticSpawnsForPhase,
} from "../game/wailingCavernsEncounter";
import { WailingNaralexEvent } from "./WailingNaralexEvent";
+import { GameGltfLoaderLifecycle } from "./useGameGLTF";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -198,45 +199,51 @@ export function GameScene() {
powerPreference: safeGraphics ? "default" : "high-performance",
}}
>
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+ {worldReady && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
{graphicsRecovery && (
{
+ state = { failed: false };
+
+ static getDerivedStateFromError() {
+ return { failed: true };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo): void {
+ console.warn("Creature model fell back to procedural rendering", error, info.componentStack);
+ }
+
+ componentDidUpdate(previous: Readonly
): void {
+ if (previous.resetKey !== this.props.resetKey && this.state.failed) {
+ this.setState({ failed: false });
+ }
+ }
+
+ render(): ReactNode {
+ return this.state.failed ? this.props.fallback : this.props.children;
+ }
+}
+
function prepareCreatureModel(root: Object3D): Object3D {
const cachedTemplate = preparedCreatureTemplates.get(root);
if (cachedTemplate) return cachedTemplate;
@@ -902,18 +933,20 @@ function ProxyMob({
forceFullFidelity={forceFullFidelity}
>
{model ? (
-
-
-
+
+
+
+
+
) : (
proxyBody
)}
diff --git a/src/scene/ProductionDungeon.tsx b/src/scene/ProductionDungeon.tsx
index d1240164..8b35f2fe 100644
--- a/src/scene/ProductionDungeon.tsx
+++ b/src/scene/ProductionDungeon.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef } from "react";
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RigidBody, TrimeshCollider } from "@react-three/rapier";
import {
AdditiveBlending,
@@ -15,6 +15,11 @@ import { useGameStore } from "../game/store";
import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision";
import { disposeObject3DResources } from "./threeResourceDisposal";
import { useGameGLTF } from "./useGameGLTF";
+import {
+ DUNGEON_COLLISION_LOAD_BATCH_SIZE,
+ DUNGEON_VISUAL_LOAD_BATCH_SIZE,
+ advanceDungeonAssetCount,
+} from "./dungeonLoadSequencing";
function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean {
const lower = name.toLowerCase();
@@ -90,6 +95,40 @@ function VisualChunk({ url, definition }: { readonly url: string; readonly defin
return ;
}
+function AssetProbe({ url }: { readonly url: string }) {
+ useGameGLTF(url);
+ return null;
+}
+
+function CommitLoadedBatch({ onReady }: { readonly onReady: () => void }) {
+ useEffect(() => {
+ const frame = window.requestAnimationFrame(onReady);
+ return () => window.cancelAnimationFrame(frame);
+ }, [onReady]);
+ return null;
+}
+
+/**
+ * Suspends only the small batch currently being fetched. Previously prepared
+ * visuals and colliders stay mounted while the next batch enters the GLTF
+ * cache, avoiding one cold-start burst across the entire dungeon package.
+ */
+function AssetBatchLoader({
+ urls,
+ onReady,
+}: {
+ readonly urls: readonly string[];
+ readonly onReady: () => void;
+}) {
+ if (!urls.length) return null;
+ return (
+
+ {urls.map((url) => )}
+
+
+ );
+}
+
function CollisionChunk({
url,
definition,
@@ -146,8 +185,53 @@ export function ProductionDungeon({
if (!assets.visual.length || !assets.collision.length) {
throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`);
}
+ const [loadedVisualCount, setLoadedVisualCount] = useState(0);
+ const [loadedCollisionCount, setLoadedCollisionCount] = useState(0);
const preparedCollisions = useRef(new Set());
const signalledFirstFrame = useRef(false);
+ const pendingVisuals = assets.visual.slice(
+ loadedVisualCount,
+ advanceDungeonAssetCount(
+ loadedVisualCount,
+ assets.visual.length,
+ DUNGEON_VISUAL_LOAD_BATCH_SIZE,
+ ),
+ );
+ const visualsLoaded = loadedVisualCount >= assets.visual.length;
+ const pendingCollisions = visualsLoaded
+ ? assets.collision.slice(
+ loadedCollisionCount,
+ advanceDungeonAssetCount(
+ loadedCollisionCount,
+ assets.collision.length,
+ DUNGEON_COLLISION_LOAD_BATCH_SIZE,
+ ),
+ )
+ : [];
+ const pendingUrls = pendingVisuals.length ? pendingVisuals : pendingCollisions;
+ const commitLoadedBatch = useCallback(() => {
+ if (!visualsLoaded) {
+ setLoadedVisualCount((current) => advanceDungeonAssetCount(
+ current,
+ assets.visual.length,
+ DUNGEON_VISUAL_LOAD_BATCH_SIZE,
+ ));
+ return;
+ }
+ setLoadedCollisionCount((current) => advanceDungeonAssetCount(
+ current,
+ assets.collision.length,
+ DUNGEON_COLLISION_LOAD_BATCH_SIZE,
+ ));
+ }, [assets.collision.length, assets.visual.length, visualsLoaded]);
+
+ useEffect(() => {
+ useGameStore.getState().setAssetStatus(
+ "loading",
+ `Preparing ${loadedVisualCount}/${assets.visual.length} visual and ${loadedCollisionCount}/${assets.collision.length} collision chunks.`,
+ );
+ }, [assets.collision.length, assets.visual.length, loadedCollisionCount, loadedVisualCount]);
+
const registerPrepared = useCallback((url: string, ready: boolean) => {
if (!ready) {
preparedCollisions.current.delete(url);
@@ -163,8 +247,10 @@ export function ProductionDungeon({
return (
- {assets.visual.map((url) => )}
- {assets.collision.map((url) => (
+ {assets.visual.slice(0, loadedVisualCount).map((url) => (
+
+ ))}
+ {assets.collision.slice(0, loadedCollisionCount).map((url) => (
))}
+
);
}
diff --git a/src/scene/dungeonLoadSequencing.test.ts b/src/scene/dungeonLoadSequencing.test.ts
new file mode 100644
index 00000000..d4b35597
--- /dev/null
+++ b/src/scene/dungeonLoadSequencing.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import {
+ DUNGEON_COLLISION_LOAD_BATCH_SIZE,
+ DUNGEON_VISUAL_LOAD_BATCH_SIZE,
+ advanceDungeonAssetCount,
+} from "./dungeonLoadSequencing";
+
+describe("dungeon asset load sequencing", () => {
+ it("loads visuals individually and collision meshes in bounded batches", () => {
+ expect(DUNGEON_VISUAL_LOAD_BATCH_SIZE).toBe(1);
+ expect(DUNGEON_COLLISION_LOAD_BATCH_SIZE).toBe(4);
+ expect(advanceDungeonAssetCount(0, 48, DUNGEON_COLLISION_LOAD_BATCH_SIZE)).toBe(4);
+ expect(advanceDungeonAssetCount(44, 48, DUNGEON_COLLISION_LOAD_BATCH_SIZE)).toBe(48);
+ });
+
+ it("clamps malformed counters instead of creating an unbounded window", () => {
+ expect(advanceDungeonAssetCount(-3, 2, 0)).toBe(1);
+ expect(advanceDungeonAssetCount(8, 2, 4)).toBe(2);
+ });
+});
diff --git a/src/scene/dungeonLoadSequencing.ts b/src/scene/dungeonLoadSequencing.ts
new file mode 100644
index 00000000..24ad7e20
--- /dev/null
+++ b/src/scene/dungeonLoadSequencing.ts
@@ -0,0 +1,14 @@
+export const DUNGEON_VISUAL_LOAD_BATCH_SIZE = 1;
+export const DUNGEON_COLLISION_LOAD_BATCH_SIZE = 4;
+
+/** Advances a staged asset window without overshooting the available files. */
+export function advanceDungeonAssetCount(
+ loaded: number,
+ total: number,
+ batchSize: number,
+): number {
+ const safeLoaded = Math.max(0, Math.trunc(loaded));
+ const safeTotal = Math.max(0, Math.trunc(total));
+ const safeBatchSize = Math.max(1, Math.trunc(batchSize));
+ return Math.min(safeTotal, safeLoaded + safeBatchSize);
+}
diff --git a/src/scene/useGameGLTF.ts b/src/scene/useGameGLTF.ts
index 9b0a52f2..e6dbdac2 100644
--- a/src/scene/useGameGLTF.ts
+++ b/src/scene/useGameGLTF.ts
@@ -1,6 +1,6 @@
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
-import { useCallback } from "react";
+import { useCallback, useEffect, type ReactNode } from "react";
import type { WebGLRenderer } from "three";
import { KTX2Loader, type GLTFLoader } from "three-stdlib";
import { resolveContentUrl } from "../content/contentManager";
@@ -20,6 +20,18 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader {
return loader;
}
+/** Owns the decoder worker pool for one Canvas and releases it on renderer teardown. */
+export function GameGltfLoaderLifecycle({ children }: { readonly children: ReactNode }) {
+ const renderer = useThree((state) => state.gl);
+ useEffect(() => () => {
+ const loader = ktx2Loaders.get(renderer);
+ if (!loader) return;
+ loader.dispose();
+ ktx2Loaders.delete(renderer);
+ }, [renderer]);
+ return children;
+}
+
/**
* Loads shipped world and creature GLBs with Meshopt and KTX2 support.
* Keeping one KTX2Loader per renderer also keeps Three from creating a worker
diff --git a/src/ui/Hud.tsx b/src/ui/Hud.tsx
index 95fdf498..4c4bd774 100644
--- a/src/ui/Hud.tsx
+++ b/src/ui/Hud.tsx
@@ -29,12 +29,7 @@ function LoadingOverlay() {
const encounter = useManastormStore((state) => state.currentEncounter);
const dungeon = requireDungeonDefinition(activeDungeonId);
const assetStatus = useGameStore((state) => state.assetStatus);
- const setAssetStatus = useGameStore((state) => state.setAssetStatus);
- const { active, progress, item, errors } = useProgress();
-
- useEffect(() => {
- if (errors.length) setAssetStatus("error", `Could not load ${errors[0]}`);
- }, [errors, setAssetStatus]);
+ const { active, progress, item } = useProgress();
if (assetStatus !== "checking" && assetStatus !== "loading") return null;
const networkComplete = !active && progress >= 100;
diff --git a/src/ui/SceneErrorBoundary.test.ts b/src/ui/SceneErrorBoundary.test.ts
new file mode 100644
index 00000000..076b5c4c
--- /dev/null
+++ b/src/ui/SceneErrorBoundary.test.ts
@@ -0,0 +1,55 @@
+import type { ErrorInfo } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { restartActiveDungeonSession } from "../game/dungeonSession";
+import { SceneErrorBoundary } from "./SceneErrorBoundary";
+
+vi.mock("../game/dungeonSession", () => ({
+ restartActiveDungeonSession: vi.fn(() => true),
+}));
+
+describe("scene error recovery", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("automatically rebuilds the active dungeon without reloading the page", () => {
+ vi.useFakeTimers();
+ vi.stubGlobal("window", {
+ setTimeout,
+ clearTimeout,
+ location: { reload: vi.fn() },
+ });
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const boundary = new SceneErrorBoundary({ children: null });
+ boundary.state = {
+ error: new Error("transient GLB failure"),
+ retryCount: 0,
+ retryScheduled: false,
+ };
+ boundary.setState = vi.fn((patch) => {
+ const next = typeof patch === "function"
+ ? patch(boundary.state, boundary.props)
+ : patch;
+ if (next) boundary.state = { ...boundary.state, ...next };
+ }) as typeof boundary.setState;
+
+ boundary.componentDidCatch(
+ boundary.state.error!,
+ { componentStack: "\n at DungeonEnvironment" } as ErrorInfo,
+ );
+ expect(boundary.state.retryScheduled).toBe(true);
+
+ vi.advanceTimersByTime(500);
+
+ expect(restartActiveDungeonSession).toHaveBeenCalledOnce();
+ expect(window.location.reload).not.toHaveBeenCalled();
+ expect(boundary.state).toMatchObject({
+ error: null,
+ retryCount: 1,
+ retryScheduled: false,
+ });
+ boundary.componentWillUnmount();
+ });
+});
diff --git a/src/ui/SceneErrorBoundary.tsx b/src/ui/SceneErrorBoundary.tsx
index 85f82ab2..be9bd95a 100644
--- a/src/ui/SceneErrorBoundary.tsx
+++ b/src/ui/SceneErrorBoundary.tsx
@@ -1,19 +1,69 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
+import { restartActiveDungeonSession } from "../game/dungeonSession";
import { useGameStore } from "../game/store";
interface Props { children: ReactNode }
-interface State { error: Error | null }
+interface State {
+ error: Error | null;
+ retryCount: number;
+ retryScheduled: boolean;
+}
+
+const MAX_AUTOMATIC_RETRIES = 1;
+const AUTOMATIC_RETRY_DELAY_MS = 500;
+const RETRY_BUDGET_RESET_MS = 60_000;
export class SceneErrorBoundary extends Component {
- state: State = { error: null };
+ state: State = { error: null, retryCount: 0, retryScheduled: false };
+ private retryTimer: number | null = null;
+ private retryBudgetTimer: number | null = null;
- static getDerivedStateFromError(error: Error): State {
+ static getDerivedStateFromError(error: Error): Partial {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Healer Man scene failed", error, info.componentStack);
useGameStore.getState().setAssetStatus("error", error.message);
+ if (this.retryBudgetTimer !== null) {
+ window.clearTimeout(this.retryBudgetTimer);
+ this.retryBudgetTimer = null;
+ }
+ if (this.state.retryCount < MAX_AUTOMATIC_RETRIES) this.scheduleRetry();
+ }
+
+ componentWillUnmount(): void {
+ if (this.retryTimer !== null) window.clearTimeout(this.retryTimer);
+ if (this.retryBudgetTimer !== null) window.clearTimeout(this.retryBudgetTimer);
+ }
+
+ private scheduleRetry(): void {
+ if (this.retryTimer !== null) return;
+ this.setState({ retryScheduled: true });
+ this.retryTimer = window.setTimeout(() => {
+ this.retryTimer = null;
+ this.retryScene();
+ }, AUTOMATIC_RETRY_DELAY_MS);
+ }
+
+ private retryScene = (): void => {
+ const retryCount = this.state.retryCount + 1;
+ try {
+ if (!restartActiveDungeonSession()) {
+ this.setState({ retryScheduled: false });
+ return;
+ }
+ this.setState({ error: null, retryCount, retryScheduled: false });
+ this.retryBudgetTimer = window.setTimeout(() => {
+ this.retryBudgetTimer = null;
+ if (!this.state.error) this.setState({ retryCount: 0 });
+ }, RETRY_BUDGET_RESET_MS);
+ } catch (error) {
+ const nextError = error instanceof Error ? error : new Error("The dungeon retry failed.");
+ console.error("Healer Man scene retry failed", nextError);
+ useGameStore.getState().setAssetStatus("error", nextError.message);
+ this.setState({ error: nextError, retryCount, retryScheduled: false });
+ }
}
render() {
@@ -21,9 +71,17 @@ export class SceneErrorBoundary extends Component {
return (
Dungeon load interrupted
-
The cavern could not be assembled.
-
{this.state.error.message}
-
+
{this.state.retryScheduled ? "Recovering the expedition..." : "The cavern could not be assembled."}
+
+ {this.state.retryScheduled
+ ? "Clearing the failed asset cache and rebuilding the dungeon in smaller batches."
+ : this.state.error.message}
+
+ {!this.state.retryScheduled && (
+
+ )}
);
}