From fb5ded3e44f27c867fa9a092edce261bb4079f6a Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 20 Jul 2026 12:23:53 +0200 Subject: [PATCH] Fix: recover the Realm renderer without full-world fallback --- docs/design/realm-renderer-recovery.md | 28 +++ src/components/realm/RealmMapScreen.tsx | 192 ++++++++++++++++-- src/components/realm/createRealmScene.ts | 133 +++++++++--- src/components/realm/realmCameraController.ts | 15 ++ .../realm/realmMapPresentationHelpers.ts | 49 ++++- src/components/realm/realmRendererRecovery.ts | 123 +++++++++++ tests/realmRendererRecovery.test.ts | 62 ++++++ 7 files changed, 547 insertions(+), 55 deletions(-) create mode 100644 docs/design/realm-renderer-recovery.md create mode 100644 src/components/realm/realmRendererRecovery.ts create mode 100644 tests/realmRendererRecovery.test.ts diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md new file mode 100644 index 00000000..dffb189c --- /dev/null +++ b/docs/design/realm-renderer-recovery.md @@ -0,0 +1,28 @@ +# Realm renderer recovery + +The Realm keeps a real WebGL scene as the source of truth once it has become +ready. The renderer lifecycle is explicit: `probing`, `loading`, `ready`, +`recovering`, `static-unsupported`, and `failed`. + +`static-unsupported` is reserved for a device that cannot create WebGL before +the first successful scene. It is an accessible, bounded illustrated view; it +is never a post-ready error surface. A renderer construction error, failed +castle assembly, castle-count mismatch, or synchronization failure remains an +explicit loading/recovery/failed state instead of silently replacing a real +world with a full-world SVG. + +Context loss calls `preventDefault`, pauses ambient work and rendering, and +retains React selection, camera intent, and the scene attestation. The restored +event starts a bounded scene rebuild. If the browser does not restore the +context in time, the user sees an explicit retry surface. No code intentionally +calls `WEBGL_lose_context` during capability probing. + +Castle loading is staged: Compact is mandatory and retried once for transient +transport failures; Balanced and High are optional upgrades. A missing optional +LOD records the active quality in `data-realm-castle-active-lod` and continues +with Compact. Pairing, integrity, and Compact failures are reported with stable +failure codes for telemetry and QA. + +The recovery contract is intentionally frontend-only. Durable world state, +authorization, and SpacetimeDB subscriptions remain outside the renderer and +are never mutated by recovery code. diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index 4ad09e52..1308d694 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -143,6 +143,15 @@ import { resolveRealmEscape, type RealmCameraTarget } from './realmInteractionState'; +import { + classifyRealmRendererFailure, + initialRealmRendererLifecycle, + REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS, + shouldRetryRealmRenderer, + transitionRealmRendererLifecycle, + type RealmRendererFailure, + type RealmRendererLifecycle +} from './realmRendererRecovery'; import './RealmMapScreen.css'; import './RealmCastlePresentation.css'; @@ -593,9 +602,21 @@ function CanonicalRealmMapScreen({ }), [surface, terrainPlacements, tileMetadataByKey]); const quality = useMemo(() => initialQuality(qualityOverride), [qualityOverride]); const qualitySpec = REALM_QUALITY_SPECS[quality]; - const [rendererMode, setRendererMode] = useState('loading'); + const [rendererLifecycle, setRendererLifecycle] = useState( + initialRealmRendererLifecycle + ); + const rendererMode: RendererMode = rendererLifecycle.state === 'static-unsupported' + ? 'fallback' + : rendererLifecycle.state === 'ready' ? 'webgl' : 'loading'; const rendererModeRef = useRef('loading'); rendererModeRef.current = rendererMode; + const rendererLifecycleRef = useRef(rendererLifecycle); + rendererLifecycleRef.current = rendererLifecycle; + const rendererRecoveryTimerRef = useRef(null); + const rendererRecoveryNonceRef = useRef(0); + const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0); + const rendererEverReadyRef = useRef(false); + const rendererAttestationRef = useRef | null>(null); const [cameraMode, setCameraMode] = useState('realm'); const [interaction, dispatchInteraction] = useReducer( realmInteractionReducer, @@ -623,7 +644,13 @@ function CanonicalRealmMapScreen({ const handledKeyboardIntentSequenceRef = useRef(-1); const reducedMotion = useReducedMotionPreference(); const fallbackSurface = useMemo( - () => fallbackSurfacePresentation(surface), + () => fallbackSurfacePresentation(surface, { focusCoord: keepCoord, radius: 16 }), + [keepCoord, surface] + ); + // Keep direct-label accounting complete for assistive technology even when + // the visible unsupported-device SVG is region-bounded around the keep. + const fallbackProjectionViewBox = useMemo( + () => fallbackSurfacePresentation(surface).viewBox, [surface] ); const viewBox = fallbackSurface.viewBox; @@ -866,9 +893,64 @@ function CanonicalRealmMapScreen({ woodNodesBySiteId ]); - const markRendererUnavailable = useCallback(() => { - rendererModeRef.current = 'fallback'; - setRendererMode('fallback'); + const markRendererFailure = useCallback((failureInput?: RealmRendererFailure | unknown) => { + const current = rendererLifecycleRef.current; + const failure = failureInput && typeof failureInput === 'object' && 'code' in failureInput + ? failureInput as RealmRendererFailure + : classifyRealmRendererFailure(failureInput, current.state); + if (failure.code === 'webgl-unavailable') { + setRendererLifecycle(transitionRealmRendererLifecycle(current, { + type: 'webgl-unsupported', + failure + })); + return; + } + if (shouldRetryRealmRenderer(current, failure)) { + const nextAttempt = current.attempt + 1; + setRendererLifecycle(transitionRealmRendererLifecycle(current, { + type: 'recover', + failure, + attempt: nextAttempt + })); + if (failure.code !== 'context-lost') { + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); + } + if (failure.code === 'context-lost') { + if (rendererRecoveryTimerRef.current !== null) window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = window.setTimeout(() => { + rendererRecoveryTimerRef.current = null; + const latest = rendererLifecycleRef.current; + const timeoutFailure: RealmRendererFailure = { + code: 'context-restore-timeout', + retryable: false, + phase: latest.state, + message: 'The browser did not restore the Realm graphics context in time.' + }; + setRendererLifecycle(transitionRealmRendererLifecycle(latest, { + type: 'failed', + failure: timeoutFailure + })); + }, REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS); + } + return; + } + setRendererLifecycle(transitionRealmRendererLifecycle(current, { type: 'failed', failure })); + }, []); + + const retryRenderer = useCallback(() => { + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } + const next: RealmRendererLifecycle = { + state: 'loading', + attempt: 0, + everReady: rendererEverReadyRef.current + }; + setRendererLifecycle(next); + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); }, []); const isSceneCoordPassable = useCallback((coord: HexCoord) => ( @@ -1167,7 +1249,7 @@ function CanonicalRealmMapScreen({ height, castles: allCastles.map((castle) => fallbackCastleProjection( castle, - viewBox, + fallbackProjectionViewBox, { width, height }, svgViewport )) @@ -1184,19 +1266,26 @@ function CanonicalRealmMapScreen({ observer?.disconnect(); window.removeEventListener('resize', updateFallbackProjection); }; - }, [allCastles, rendererMode, updateCastleProjection, viewBox]); + }, [allCastles, fallbackProjectionViewBox, rendererMode, updateCastleProjection, viewBox]); useEffect(() => { const canvas = canvasRef.current; if (!canvas || !canUseWebGL()) { - markRendererUnavailable(); + markRendererFailure({ + code: 'webgl-unavailable', + retryable: false, + phase: 'probing', + message: 'WebGL is unavailable on this device.' + }); return undefined; } let scene: RealmSceneHandle | null = null; try { - rendererModeRef.current = 'loading'; - setRendererMode('loading'); + setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'load-start', + attempt: rendererLifecycleRef.current.attempt + })); latestProjectionRef.current = { width: 0, height: 0, castles: [] }; labelMembershipSignatureRef.current = ''; latestVisibleCastleLabelsRef.current = []; @@ -1257,7 +1346,6 @@ function CanonicalRealmMapScreen({ rootRef.current.dataset.stoneMarkerOnlySiteCount = String(stoneNodeCatalog.length); } setVisibleCastleLabels([]); - setCameraMode('realm'); scene = createRealmScene({ canvas, surface, @@ -1286,11 +1374,23 @@ function CanonicalRealmMapScreen({ onKeepStatusChange: () => undefined, onCastlesReady: (castleCount) => { if (castleCount !== expectedCastleCountRef.current) { - markRendererUnavailable(); + markRendererFailure({ + code: 'castle-count-mismatch', + retryable: true, + phase: 'loading', + message: `Expected ${expectedCastleCountRef.current} castles, received ${castleCount}.` + }); return; } rendererModeRef.current = 'webgl'; - setRendererMode('webgl'); + rendererEverReadyRef.current = true; + const activeLod = canvas.dataset.realmCastleActiveLod; + setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, { + type: 'ready', + degradedQuality: activeLod === 'compact' || activeLod === 'balanced' + ? activeLod + : undefined + })); updateSceneComposition(); }, onCastlePresentationTelemetry: updateCastlePresentationTelemetry, @@ -1300,7 +1400,16 @@ function CanonicalRealmMapScreen({ onStoneNodePresentationTelemetry: updateStoneNodePresentationTelemetry, onTerrainPresentationTelemetry: updateTerrainPresentationTelemetry, onCastleProjection: updateCastleProjection, - onRendererUnavailable: markRendererUnavailable, + onRendererFailure: markRendererFailure, + onRendererContextRestored: () => { + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } + rendererRecoveryNonceRef.current += 1; + setRendererRecoveryNonce(rendererRecoveryNonceRef.current); + }, + onRendererUnavailable: () => undefined, onSelect: () => undefined, onTargetSelect: handleSceneTargetSelect }); @@ -1343,15 +1452,30 @@ function CanonicalRealmMapScreen({ else if (cameraTarget.kind === 'keep') scene.recenterKeep(); else if (cameraTarget.kind === 'founding-district') scene.frameFoundingDistrict(); else scene.showRealm(); - } catch { - markRendererUnavailable(); + const attestation = rendererAttestationRef.current; + if (attestation && attestation.canvasId === canvas.dataset.realmCanvasIdentity) { + scene.restoreCameraAttestation?.(attestation); + } + } catch (error) { + markRendererFailure(classifyRealmRendererFailure(error, 'loading')); } return () => { + if (scene) { + try { + rendererAttestationRef.current = scene.getCameraAttestation(); + } catch { + rendererAttestationRef.current = null; + } + } scene?.dispose(); if (sceneRef.current === scene) sceneRef.current = null; + if (rendererRecoveryTimerRef.current !== null) { + window.clearTimeout(rendererRecoveryTimerRef.current); + rendererRecoveryTimerRef.current = null; + } }; - }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererUnavailable, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, sharedForestProjection, snapshot.realm.realmId, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); + }, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererFailure, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, rendererRecoveryNonce, sharedForestProjection, snapshot.realm.realmId, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]); useEffect(() => { sceneRef.current?.reconcileLiveGatheringState?.(liveGatheringState); @@ -1485,6 +1609,11 @@ function CanonicalRealmMapScreen({ className="realm-map-screen" data-presentation-mode={observerMode ? 'observer' : 'player'} data-renderer={rendererMode} + data-renderer-state={rendererLifecycle.state} + data-renderer-ever-ready={String(rendererLifecycle.everReady)} + data-renderer-recovery-attempt={String(rendererLifecycle.attempt)} + data-renderer-failure={rendererLifecycle.failure?.code ?? 'none'} + data-renderer-degraded-quality={rendererLifecycle.degradedQuality ?? 'none'} data-quality={quality} tabIndex={0} aria-label={observerMode ? 'Hegemony realm QA observer' : 'Hegemony realm'} @@ -1738,19 +1867,38 @@ function CanonicalRealmMapScreen({

- Detailed terrain is unavailable. Showing the canonical Genesis 001 realm map. + WebGL is unavailable on this device. Showing a bounded, accessible view of the + canonical Genesis 001 region around your keep. +

) : null} - {rendererMode === 'loading' ? ( + {rendererLifecycle.state !== 'ready' && rendererLifecycle.state !== 'static-unsupported' ? (
- Surveying the bright lowlands… - Preparing every canonical castle before the realm is revealed. + {rendererLifecycle.state === 'failed' ? ( + The 3D realm needs another attempt + ) : rendererLifecycle.state === 'recovering' ? ( + Recovering the 3D realm… + ) : ( + Surveying the bright lowlands… + )} + + {rendererLifecycle.state === 'failed' + ? 'The renderer stopped before the world was ready. Your world state is safe.' + : rendererLifecycle.state === 'recovering' + ? 'Restoring the graphics context without replacing the realm with a flat map.' + : 'Preparing every canonical castle before the realm is revealed.'} + + {rendererLifecycle.state === 'failed' ? ( + + ) : null} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 386c036a..48ffa5e3 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -148,6 +148,7 @@ import type { RealmCastleProjectionFrame, RealmCastleScreenBounds } from './realmTypes'; +import type { RealmRendererFailure } from './realmRendererRecovery'; const HEX_SIZE = 1; const OVERLAY_LIFT = 0.026; @@ -416,6 +417,7 @@ export type RealmSceneHandle = Readonly<{ dispose: () => void; reconcileLiveGatheringState: (state: RealmLiveGatheringState) => void; getCameraAttestation: () => RealmCameraAttestation; + restoreCameraAttestation?: (attestation: RealmCameraAttestation) => void; getSceneBuildSequence: () => number; focusCastle: (castleId: number) => void; focusCell: (coord: HexCoord) => void; @@ -552,6 +554,12 @@ export type CreateRealmSceneOptions = Readonly<{ onFoodNodePresentationTelemetry?: (telemetry: RealmFoodNodePresentationTelemetry) => void; onWoodNodePresentationTelemetry?: (telemetry: RealmWoodNodePresentationTelemetry) => void; onStoneNodePresentationTelemetry?: (telemetry: RealmStoneNodePresentationTelemetry) => void; + /** Structured renderer lifecycle signal. The scene remains alive during a + * recoverable context loss so camera/selection state can be attested. */ + onRendererFailure?: (failure: RealmRendererFailure) => void; + onRendererContextRestored?: () => void; + /** Legacy callback retained for integrations that only understand a boolean + * renderer-unavailable signal. It is never used for context loss. */ onRendererUnavailable: () => void; /** @deprecated Prefer onTargetSelect for castle identity-aware interaction. */ onSelect: (coord: HexCoord) => void; @@ -974,7 +982,7 @@ function initializeRealmScene( }; // Pure quality policy is needed by the first resize/projection callback; // initialize it before any observer or render loop can run. - const castleLodPolicy = castleLodPolicyForQuality(runtimeQuality); + let castleLodPolicy = castleLodPolicyForQuality(runtimeQuality); const terrainSemantics = indexRealmTerrainSemantics( options.surface, options.terrainMetadata @@ -1680,6 +1688,7 @@ function initializeRealmScene( let presentedCastleKey = '*'; let renderPendingWhileHidden = false; let pendingCastlesReadyCount: number | null = null; + let contextLost = false; let ambientScheduler: RealmAmbientScheduler | null = null; const ambientIsNeeded = () => !options.reducedMotion && renderPlan.grass.animationFrameCap > 0 @@ -1800,6 +1809,7 @@ function initializeRealmScene( }; const render = () => { if (cleanup.isDisposed()) return; + if (contextLost) return; if (document.hidden) { renderPendingWhileHidden = true; return; @@ -1924,7 +1934,16 @@ function initializeRealmScene( try { renderer.render(scene, cameraController.camera); } catch (error) { - if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) throw error; + if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) { + options.onRendererFailure?.({ + code: 'scene-build-failed', + retryable: true, + phase: 'ready', + message: error instanceof Error ? error.message : String(error) + }); + disposeScene(); + return; + } // `onBeforeCompile` runs during rendering. Retry the same frame without // only the grass layer if its pinned shader chunk contract has changed. renderer.render(scene, cameraController.camera); @@ -1937,13 +1956,31 @@ function initializeRealmScene( && (presentedCastleIds === null || presentedCastleIds.size > 0) && (castleLayer?.getPacking().totalVisible ?? 0) === 0 ) { - throw new Error('Hegemony castle instances produced no visible rendered packing.'); + pendingCastlesReadyCount = null; + const failure: RealmRendererFailure = { + code: 'castle-pairing-failed', + retryable: false, + phase: 'loading', + message: 'Hegemony castle instances produced no visible rendered packing.' + }; + options.onRendererFailure?.(failure); + options.onRendererUnavailable(); + return; } if ( castleCount > 0 && !castleLayer?.hasExactCastleLandscapeBasePairing() ) { - throw new Error('Hegemony castle landscape-base presentation is incomplete.'); + pendingCastlesReadyCount = null; + const failure: RealmRendererFailure = { + code: 'castle-pairing-failed', + retryable: false, + phase: 'loading', + message: 'Hegemony castle landscape-base presentation is incomplete.' + }; + options.onRendererFailure?.(failure); + options.onRendererUnavailable(); + return; } pendingCastlesReadyCount = null; options.onCastlesReady?.(castleCount); @@ -2540,8 +2577,18 @@ function initializeRealmScene( const handleContextLost = (event: Event) => { event.preventDefault(); if (cleanup.isDisposed()) return; - disposeScene(); - options.onRendererUnavailable(); + contextLost = true; + ambientScheduler?.setActive(false); + options.onRendererFailure?.({ + code: 'context-lost', + retryable: true, + phase: pendingCastlesReadyCount === null ? 'ready' : 'loading', + message: 'The WebGL context was lost; waiting for the browser to restore it.' + }); + }; + const handleContextRestored = () => { + if (cleanup.isDisposed() || !contextLost) return; + options.onRendererContextRestored?.(); }; interactionRoot.addEventListener('pointerdown', handlePointerDown, { @@ -2581,6 +2628,8 @@ function initializeRealmScene( cleanup.add(() => interactionRoot.removeEventListener('wheel', handleWheel, true)); options.canvas.addEventListener('webglcontextlost', handleContextLost); cleanup.add(() => options.canvas.removeEventListener('webglcontextlost', handleContextLost)); + options.canvas.addEventListener('webglcontextrestored', handleContextRestored); + cleanup.add(() => options.canvas.removeEventListener('webglcontextrestored', handleContextRestored)); const resize = () => { if (cleanup.isDisposed()) return; @@ -2653,35 +2702,55 @@ function initializeRealmScene( } const leases: HegemonyKeepPrefabLease[] = []; - let acquisitionStopped = false; - try { - await Promise.all(usedCastleLods.map(async (lod) => { + const compact = usedCastleLods.includes('compact') ? 'compact' : usedCastleLods[0]; + if (!compact) throw new Error('No compact castle LOD is configured.'); + const acquireWithRetry = async (lod: CastleLod) => { + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { try { - const lease = await prefabRepository.acquire( - lod, - castleLoadAbortController.signal - ); - if (acquisitionStopped || cleanup.isDisposed()) { - releaseLeases([lease]); - return; - } - leases.push(lease); + return await prefabRepository.acquire(lod, castleLoadAbortController.signal); } catch (error) { - acquisitionStopped = true; - throw error; + lastError = error; + if (lod !== 'compact' || attempt !== 0) break; } - })); - } catch (error) { - acquisitionStopped = true; - releaseLeases(leases); - throw error; + } + throw lastError ?? new Error(`Unable to load castle ${lod} LOD.`); + }; + const compactLease = await acquireWithRetry(compact); + if (cleanup.isDisposed()) { + releaseLeases([compactLease]); + return; } + leases.push(compactLease); + const optionalLods = usedCastleLods.filter((lod) => lod !== compact); + const optionalResults = await Promise.allSettled( + optionalLods.map((lod) => acquireWithRetry(lod)) + ); + optionalResults.forEach((result, index) => { + if (result.status === 'fulfilled') leases.push(result.value); + else options.canvas.dataset[`realmCastle${optionalLods[index]}Lod`] = 'unavailable'; + }); if (cleanup.isDisposed()) { releaseLeases(leases); return; } const prefabs = new Map(leases.map((lease) => [lease.prefab.lod, lease.prefab])); + const activeLod = prefabs.has(castleLodPolicy.maximumLod) + ? castleLodPolicy.maximumLod + : prefabs.has('balanced') ? 'balanced' : 'compact'; + castleLodPolicy = Object.freeze({ + ...castleLodPolicy, + maximumLod: activeLod, + selectedMinimumLod: activeLod, + highInstanceBudget: activeLod === 'high' + ? DEFAULT_CASTLE_LOD_POLICY.highInstanceBudget + : 0, + balancedInstanceBudget: activeLod === 'compact' + ? 0 + : DEFAULT_CASTLE_LOD_POLICY.balancedInstanceBudget + }); + options.canvas.dataset.realmCastleActiveLod = activeLod; const nextProjectionEnvelopeByLod = new Map([...prefabs].map(([lod, prefab]) => [ lod, prefab.projectionEnvelope @@ -2763,15 +2832,21 @@ function initializeRealmScene( render(); }; - void initializeCastleInstances().catch(() => { + void initializeCastleInstances().catch((error) => { if (cleanup.isDisposed()) return; try { options.onKeepStatusChange('fallback'); } catch { // Renderer fallback still has to engage when a status observer fails. } - disposeScene(); + options.onRendererFailure?.({ + code: 'castle-compact-load-failed', + retryable: true, + phase: 'loading', + message: error instanceof Error ? error.message : String(error) + }); options.onRendererUnavailable(); + disposeScene(); }); function disposeScene() { @@ -2870,6 +2945,10 @@ function initializeRealmScene( dispose: disposeScene, reconcileLiveGatheringState, getCameraAttestation, + restoreCameraAttestation: (attestation) => { + if (cleanup.isDisposed()) return; + cameraController.restorePose?.(attestation); + }, getSceneBuildSequence: () => sceneBuildSequence, focusCastle: (castleId) => { if (cleanup.isDisposed()) return; diff --git a/src/components/realm/realmCameraController.ts b/src/components/realm/realmCameraController.ts index da1ab4e7..10e55c57 100644 --- a/src/components/realm/realmCameraController.ts +++ b/src/components/realm/realmCameraController.ts @@ -983,6 +983,11 @@ export type RealmCameraController = Readonly<{ panByPixels: (deltaX: number, deltaY: number) => void; projectPoint: (point: RealmCameraPoint) => RealmScreenProjection; recenterKeep: () => void; + restorePose?: (pose: Readonly<{ + position: RealmCameraPoint; + target: RealmCameraPoint; + fov: number; + }>) => void; setComposition: (composition: RealmCameraComposition) => void; setKeepFocus: (focus: RealmKeepFocus) => void; setViewport: (width: number, height: number) => void; @@ -1582,6 +1587,16 @@ export function createRealmCameraController( zoomAnchor = null; invalidate(); }, + restorePose: (pose) => { + if (disposed) return; + camera.position.set(pose.position.x, pose.position.y, pose.position.z); + targetVector.set(pose.target.x, pose.target.y, pose.target.z); + camera.fov = clamp(finite(pose.fov, camera.fov), 1, 120); + camera.lookAt(targetVector); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + options.render(); + }, setComposition: (next) => { // Insets change the projection beneath a screen-space zoom anchor. Drop // the old anchor so the camera can converge on the newly composed target diff --git a/src/components/realm/realmMapPresentationHelpers.ts b/src/components/realm/realmMapPresentationHelpers.ts index c4686f99..f1ab5e2b 100644 --- a/src/components/realm/realmMapPresentationHelpers.ts +++ b/src/components/realm/realmMapPresentationHelpers.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import type { HexCoord } from '../../game/map/hexCoordinates'; +import { axialToWorld, type HexCoord } from '../../game/map/hexCoordinates'; import { terrainCellByCoord } from '../../game/map/generateTerrainMap'; import type { RealmTerrainSurface } from '../../game/map/realmTerrainSurface'; import type { TerrainCell } from '../../game/map/terrainTypes'; @@ -28,6 +28,12 @@ export type RealmFallbackSurfacePresentation = Readonly<{ playableHullPoints: string; }>; +export type RealmFallbackSurfaceOptions = Readonly<{ + /** Keep unsupported-device mode readable around the player's region. */ + focusCoord?: HexCoord; + radius?: number; +}>; + function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, value)); } @@ -70,15 +76,27 @@ export function directionForKey(key: string): HexCoord | null { } } +let cachedWebGlCapability: boolean | undefined; + +/** + * Probe once per document without deliberately destroying a context. The old + * probe used WEBGL_lose_context as a feature test, which could leave the next + * real canvas in the exact terminal state we are trying to recover from. + */ export function canUseWebGL() { + if (cachedWebGlCapability !== undefined) return cachedWebGlCapability; try { const canvas = document.createElement('canvas'); - const context = canvas.getContext('webgl2'); - context?.getExtension('WEBGL_lose_context')?.loseContext(); - return Boolean(context); + const context = canvas.getContext('webgl2') ?? canvas.getContext('webgl'); + cachedWebGlCapability = Boolean(context); } catch { - return false; + cachedWebGlCapability = false; } + return cachedWebGlCapability; +} + +export function resetWebGLCapabilityForTests() { + cachedWebGlCapability = undefined; } export function pointsForSvg(coord: HexCoord) { @@ -92,7 +110,8 @@ function svgHullPoints(points: readonly Readonly<{ x: number; z: number }>[]) { } export function fallbackSurfacePresentation( - surface: RealmTerrainSurface + surface: RealmTerrainSurface, + options: RealmFallbackSurfaceOptions = {} ): RealmFallbackSurfacePresentation { const renderHull = createTerrainOverviewHull(surface.renderMap, REALM_HEX_SIZE); const playableHull = createTerrainOverviewHull(surface.playableMap, REALM_HEX_SIZE); @@ -115,6 +134,24 @@ export function fallbackSurfacePresentation( maxZ = Math.max(maxZ, point.z); }); const padding = 0.88; + const focus = options.focusCoord; + const radius = Number.isFinite(options.radius) && (options.radius ?? 0) > 0 + ? options.radius! + : undefined; + if (focus && radius) { + const center = axialToWorld(focus, REALM_HEX_SIZE); + const span = Math.max(4, radius * 2.15); + return { + viewBox: { + x: center.x - span * 0.5, + y: -center.z - span * 0.5, + width: span, + height: span + }, + renderHullPoints: svgHullPoints(renderHull), + playableHullPoints: svgHullPoints(playableHull) + }; + } return { viewBox: { x: minX - padding, diff --git a/src/components/realm/realmRendererRecovery.ts b/src/components/realm/realmRendererRecovery.ts new file mode 100644 index 00000000..973610bd --- /dev/null +++ b/src/components/realm/realmRendererRecovery.ts @@ -0,0 +1,123 @@ +export type RealmRendererLifecycleState = + | 'probing' + | 'loading' + | 'ready' + | 'recovering' + | 'static-unsupported' + | 'failed'; + +export type RealmRendererFailureCode = + | 'webgl-unavailable' + | 'renderer-construction-failed' + | 'context-lost' + | 'context-restore-timeout' + | 'castle-count-mismatch' + | 'castle-prefab-assembly-failed' + | 'castle-pairing-failed' + | 'castle-compact-load-failed' + | 'castle-integrity-failed' + | 'scene-build-failed' + | 'sync-failed'; + +export type RealmRendererFailure = Readonly<{ + code: RealmRendererFailureCode; + message?: string; + retryable: boolean; + phase: RealmRendererLifecycleState; + attempt?: number; +}>; + +export type RealmRendererLifecycle = Readonly<{ + state: RealmRendererLifecycleState; + attempt: number; + failure?: RealmRendererFailure; + everReady: boolean; + degradedQuality?: 'compact' | 'balanced'; +}>; + +export const REALM_RENDERER_MAX_RECOVERY_ATTEMPTS = 2; +export const REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS = 8_000; + +export function initialRealmRendererLifecycle(): RealmRendererLifecycle { + return Object.freeze({ state: 'probing', attempt: 0, everReady: false }); +} + +export function transitionRealmRendererLifecycle( + current: RealmRendererLifecycle, + event: + | { type: 'probe-start' } + | { type: 'webgl-unsupported'; failure?: RealmRendererFailure } + | { type: 'load-start'; attempt?: number } + | { type: 'ready'; degradedQuality?: 'compact' | 'balanced' } + | { type: 'recover'; failure: RealmRendererFailure; attempt?: number } + | { type: 'failed'; failure: RealmRendererFailure } +): RealmRendererLifecycle { + switch (event.type) { + case 'probe-start': + return Object.freeze({ ...current, state: 'probing', failure: undefined }); + case 'webgl-unsupported': + return Object.freeze({ + ...current, + state: 'static-unsupported', + failure: event.failure, + everReady: false + }); + case 'load-start': + return Object.freeze({ + ...current, + state: 'loading', + attempt: event.attempt ?? current.attempt, + failure: undefined + }); + case 'ready': + return Object.freeze({ + ...current, + state: 'ready', + failure: undefined, + everReady: true, + degradedQuality: event.degradedQuality + }); + case 'recover': + return Object.freeze({ + ...current, + state: 'recovering', + attempt: event.attempt ?? current.attempt + 1, + failure: event.failure + }); + case 'failed': + return Object.freeze({ ...current, state: 'failed', failure: event.failure }); + default: + return current; + } +} + +export function classifyRealmRendererFailure( + error: unknown, + phase: RealmRendererLifecycleState, + fallbackCode: RealmRendererFailureCode = 'scene-build-failed' +): RealmRendererFailure { + const message = error instanceof Error ? error.message : String(error ?? 'Unknown renderer failure'); + const normalized = message.toLowerCase(); + let code = fallbackCode; + if (/integrity|sha-?256|content-addressed|digest/.test(normalized)) code = 'castle-integrity-failed'; + else if (/pair|landscape base|landscape-base/.test(normalized)) code = 'castle-pairing-failed'; + else if (/prefab|assembly|no renderable meshes|normalized bounds/.test(normalized)) { + code = 'castle-prefab-assembly-failed'; + } else if (/timeout|timed out|network|fetch|response body/.test(normalized)) { + code = 'castle-compact-load-failed'; + } else if (/webgl|renderer/.test(normalized)) code = 'renderer-construction-failed'; + return Object.freeze({ + code, + message, + retryable: !['castle-integrity-failed', 'castle-pairing-failed'].includes(code), + phase + }); +} + +export function shouldRetryRealmRenderer( + lifecycle: RealmRendererLifecycle, + failure: RealmRendererFailure +) { + return failure.retryable + && lifecycle.attempt < REALM_RENDERER_MAX_RECOVERY_ATTEMPTS; +} diff --git a/tests/realmRendererRecovery.test.ts b/tests/realmRendererRecovery.test.ts new file mode 100644 index 00000000..06235620 --- /dev/null +++ b/tests/realmRendererRecovery.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyRealmRendererFailure, + initialRealmRendererLifecycle, + REALM_RENDERER_MAX_RECOVERY_ATTEMPTS, + shouldRetryRealmRenderer, + transitionRealmRendererLifecycle +} from '../src/components/realm/realmRendererRecovery'; + +describe('Realm renderer recovery lifecycle', () => { + it('keeps no-WebGL devices in the explicit static mode', () => { + const initial = initialRealmRendererLifecycle(); + const state = transitionRealmRendererLifecycle(initial, { + type: 'webgl-unsupported', + failure: { + code: 'webgl-unavailable', + retryable: false, + phase: 'probing' + } + }); + expect(state.state).toBe('static-unsupported'); + expect(state.everReady).toBe(false); + }); + + it('recovers after a loss without changing the ready history', () => { + const ready = transitionRealmRendererLifecycle(initialRealmRendererLifecycle(), { + type: 'ready' + }); + const recovering = transitionRealmRendererLifecycle(ready, { + type: 'recover', + attempt: 1, + failure: { code: 'context-lost', retryable: true, phase: 'ready' } + }); + expect(recovering.state).toBe('recovering'); + expect(recovering.everReady).toBe(true); + expect(transitionRealmRendererLifecycle(recovering, { type: 'ready' }).state).toBe('ready'); + }); + + it('classifies integrity and pairing failures as explicit non-retryable failures', () => { + expect(classifyRealmRendererFailure(new Error('sha256 integrity mismatch'), 'loading').code) + .toBe('castle-integrity-failed'); + expect(classifyRealmRendererFailure(new Error('landscape base pairing failed'), 'loading').code) + .toBe('castle-pairing-failed'); + expect(classifyRealmRendererFailure(new Error('request timed out'), 'loading').code) + .toBe('castle-compact-load-failed'); + }); + + it('bounds automatic retries and leaves manual retry available', () => { + const current = { + ...initialRealmRendererLifecycle(), + state: 'recovering' as const, + attempt: REALM_RENDERER_MAX_RECOVERY_ATTEMPTS, + everReady: true + }; + expect(shouldRetryRealmRenderer(current, { + code: 'scene-build-failed', + retryable: true, + phase: 'recovering' + })).toBe(false); + }); +});