updated the dungeon loading so it doesnt fail first try

This commit is contained in:
phenom
2026-08-17 13:51:12 -04:00
parent e39aebf3a1
commit 7e60864e70
11 changed files with 383 additions and 69 deletions
+13
View File
@@ -77,3 +77,16 @@ export function activateDungeonSession(
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition); useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
return true; 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,
});
}
+17 -1
View File
@@ -2,7 +2,8 @@ import { useGLTF } from "@react-three/drei";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { dungeonGltfUrls } from "./dungeonAssets"; import { dungeonGltfUrls } from "./dungeonAssets";
import { dungeonDefinitionById } from "./dungeonRegistry"; import { dungeonDefinitionById } from "./dungeonRegistry";
import { activateDungeonSession } from "./dungeonSession"; import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
describe("dungeon session asset eviction", () => { describe("dungeon session asset eviction", () => {
@@ -28,4 +29,19 @@ describe("dungeon session asset eviction", () => {
expect(activateDungeonSession("not-a-dungeon")).toBe(false); expect(activateDungeonSession("not-a-dungeon")).toBe(false);
expect(clear).not.toHaveBeenCalled(); 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);
});
}); });
+7
View File
@@ -36,6 +36,7 @@ import {
wailingStaticSpawnsForPhase, wailingStaticSpawnsForPhase,
} from "../game/wailingCavernsEncounter"; } from "../game/wailingCavernsEncounter";
import { WailingNaralexEvent } from "./WailingNaralexEvent"; import { WailingNaralexEvent } from "./WailingNaralexEvent";
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
export function GameScene() { export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -198,6 +199,7 @@ export function GameScene() {
powerPreference: safeGraphics ? "default" : "high-performance", powerPreference: safeGraphics ? "default" : "high-performance",
}} }}
> >
<GameGltfLoaderLifecycle>
<color attach="background" args={[dungeon.presentation.background]} /> <color attach="background" args={[dungeon.presentation.background]} />
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} /> <fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
<ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} /> <ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} />
@@ -225,6 +227,8 @@ export function GameScene() {
timeStep={1 / 60} timeStep={1 / 60}
> >
<DungeonEnvironment onReady={markWorldReady} /> <DungeonEnvironment onReady={markWorldReady} />
{worldReady && (
<>
<PlayerRig /> <PlayerRig />
<PartyPopulation active={!simulationBlocked} /> <PartyPopulation active={!simulationBlocked} />
<MobPopulation <MobPopulation
@@ -236,7 +240,10 @@ export function GameScene() {
/> />
<WailingNaralexEvent /> <WailingNaralexEvent />
<ManastormPortal /> <ManastormPortal />
</>
)}
</Physics> </Physics>
</GameGltfLoaderLifecycle>
</Canvas> </Canvas>
{graphicsRecovery && ( {graphicsRecovery && (
<div <div
+35 -2
View File
@@ -1,7 +1,7 @@
import { Html } from "@react-three/drei"; import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Component, Suspense, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ErrorInfo, ReactNode } from "react";
import { import {
AdditiveBlending, AdditiveBlending,
AnimationMixer, AnimationMixer,
@@ -138,6 +138,37 @@ interface ProxyMobProps {
readonly useOriginalModels: boolean; readonly useOriginalModels: boolean;
} }
interface CreatureModelErrorBoundaryProps {
readonly children: ReactNode;
readonly fallback: ReactNode;
readonly resetKey: string;
}
class CreatureModelErrorBoundary extends Component<
CreatureModelErrorBoundaryProps,
{ readonly failed: boolean }
> {
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<CreatureModelErrorBoundaryProps>): 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 { function prepareCreatureModel(root: Object3D): Object3D {
const cachedTemplate = preparedCreatureTemplates.get(root); const cachedTemplate = preparedCreatureTemplates.get(root);
if (cachedTemplate) return cachedTemplate; if (cachedTemplate) return cachedTemplate;
@@ -902,6 +933,7 @@ function ProxyMob({
forceFullFidelity={forceFullFidelity} forceFullFidelity={forceFullFidelity}
> >
{model ? ( {model ? (
<CreatureModelErrorBoundary fallback={proxyBody} resetKey={model.url}>
<Suspense fallback={proxyBody}> <Suspense fallback={proxyBody}>
<OriginalCreatureModel <OriginalCreatureModel
model={model} model={model}
@@ -914,6 +946,7 @@ function ProxyMob({
forceFullFidelity={forceFullFidelity} forceFullFidelity={forceFullFidelity}
/> />
</Suspense> </Suspense>
</CreatureModelErrorBoundary>
) : ( ) : (
proxyBody proxyBody
)} )}
+94 -3
View File
@@ -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 { RigidBody, TrimeshCollider } from "@react-three/rapier";
import { import {
AdditiveBlending, AdditiveBlending,
@@ -15,6 +15,11 @@ import { useGameStore } from "../game/store";
import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision"; import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision";
import { disposeObject3DResources } from "./threeResourceDisposal"; import { disposeObject3DResources } from "./threeResourceDisposal";
import { useGameGLTF } from "./useGameGLTF"; 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 { function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean {
const lower = name.toLowerCase(); const lower = name.toLowerCase();
@@ -90,6 +95,40 @@ function VisualChunk({ url, definition }: { readonly url: string; readonly defin
return <primitive object={visual} position={position} rotation={rotation} scale={scale} />; return <primitive object={visual} position={position} rotation={rotation} scale={scale} />;
} }
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 (
<Suspense fallback={null}>
{urls.map((url) => <AssetProbe key={url} url={url} />)}
<CommitLoadedBatch onReady={onReady} />
</Suspense>
);
}
function CollisionChunk({ function CollisionChunk({
url, url,
definition, definition,
@@ -146,8 +185,53 @@ export function ProductionDungeon({
if (!assets.visual.length || !assets.collision.length) { if (!assets.visual.length || !assets.collision.length) {
throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`); 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<string>()); const preparedCollisions = useRef(new Set<string>());
const signalledFirstFrame = useRef(false); 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) => { const registerPrepared = useCallback((url: string, ready: boolean) => {
if (!ready) { if (!ready) {
preparedCollisions.current.delete(url); preparedCollisions.current.delete(url);
@@ -163,8 +247,10 @@ export function ProductionDungeon({
return ( return (
<group name={`${definition.id}-production`}> <group name={`${definition.id}-production`}>
{assets.visual.map((url) => <VisualChunk key={url} url={url} definition={definition} />)} {assets.visual.slice(0, loadedVisualCount).map((url) => (
{assets.collision.map((url) => ( <VisualChunk key={url} url={url} definition={definition} />
))}
{assets.collision.slice(0, loadedCollisionCount).map((url) => (
<CollisionChunk <CollisionChunk
key={url} key={url}
url={url} url={url}
@@ -172,6 +258,11 @@ export function ProductionDungeon({
registerPrepared={registerPrepared} registerPrepared={registerPrepared}
/> />
))} ))}
<AssetBatchLoader
key={`${loadedVisualCount}:${loadedCollisionCount}:${pendingUrls.join("|")}`}
urls={pendingUrls}
onReady={commitLoadedBatch}
/>
</group> </group>
); );
} }
+20
View File
@@ -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);
});
});
+14
View File
@@ -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);
}
+13 -1
View File
@@ -1,6 +1,6 @@
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import { useCallback } from "react"; import { useCallback, useEffect, type ReactNode } from "react";
import type { WebGLRenderer } from "three"; import type { WebGLRenderer } from "three";
import { KTX2Loader, type GLTFLoader } from "three-stdlib"; import { KTX2Loader, type GLTFLoader } from "three-stdlib";
import { resolveContentUrl } from "../content/contentManager"; import { resolveContentUrl } from "../content/contentManager";
@@ -20,6 +20,18 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader {
return loader; 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. * Loads shipped world and creature GLBs with Meshopt and KTX2 support.
* Keeping one KTX2Loader per renderer also keeps Three from creating a worker * Keeping one KTX2Loader per renderer also keeps Three from creating a worker
+1 -6
View File
@@ -29,12 +29,7 @@ function LoadingOverlay() {
const encounter = useManastormStore((state) => state.currentEncounter); const encounter = useManastormStore((state) => state.currentEncounter);
const dungeon = requireDungeonDefinition(activeDungeonId); const dungeon = requireDungeonDefinition(activeDungeonId);
const assetStatus = useGameStore((state) => state.assetStatus); const assetStatus = useGameStore((state) => state.assetStatus);
const setAssetStatus = useGameStore((state) => state.setAssetStatus); const { active, progress, item } = useProgress();
const { active, progress, item, errors } = useProgress();
useEffect(() => {
if (errors.length) setAssetStatus("error", `Could not load ${errors[0]}`);
}, [errors, setAssetStatus]);
if (assetStatus !== "checking" && assetStatus !== "loading") return null; if (assetStatus !== "checking" && assetStatus !== "loading") return null;
const networkComplete = !active && progress >= 100; const networkComplete = !active && progress >= 100;
+55
View File
@@ -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();
});
});
+64 -6
View File
@@ -1,19 +1,69 @@
import { Component, type ErrorInfo, type ReactNode } from "react"; import { Component, type ErrorInfo, type ReactNode } from "react";
import { restartActiveDungeonSession } from "../game/dungeonSession";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
interface Props { children: ReactNode } 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<Props, State> { export class SceneErrorBoundary extends Component<Props, State> {
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<State> {
return { error }; return { error };
} }
componentDidCatch(error: Error, info: ErrorInfo) { componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Healer Man scene failed", error, info.componentStack); console.error("Healer Man scene failed", error, info.componentStack);
useGameStore.getState().setAssetStatus("error", error.message); 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() { render() {
@@ -21,9 +71,17 @@ export class SceneErrorBoundary extends Component<Props, State> {
return ( return (
<div className="scene-error" role="alert"> <div className="scene-error" role="alert">
<p className="eyebrow">Dungeon load interrupted</p> <p className="eyebrow">Dungeon load interrupted</p>
<h1>The cavern could not be assembled.</h1> <h1>{this.state.retryScheduled ? "Recovering the expedition..." : "The cavern could not be assembled."}</h1>
<p>{this.state.error.message}</p> <p>
<button type="button" className="button button--primary" onClick={() => window.location.reload()}>Retry loading</button> {this.state.retryScheduled
? "Clearing the failed asset cache and rebuilding the dungeon in smaller batches."
: this.state.error.message}
</p>
{!this.state.retryScheduled && (
<button type="button" className="button button--primary" onClick={this.retryScene}>
Retry dungeon
</button>
)}
</div> </div>
); );
} }