diff --git a/src/components/FactoryExterior.tsx b/src/components/FactoryExterior.tsx
index d4887da7..18d5f5e4 100644
--- a/src/components/FactoryExterior.tsx
+++ b/src/components/FactoryExterior.tsx
@@ -29,6 +29,7 @@ import {
CANAL_BOAT_BERTH,
GAS_STATION_SITE,
RIVER_FOOTBRIDGE_DECK,
+ RIVER_FOOTBRIDGE_LANDINGS,
SITE_LAYOUT,
STATION_ROAD_EDGE_SEGMENTS,
} from '../constants/siteLayout';
@@ -3680,6 +3681,18 @@ function GroundPathGeometry({
return ;
}
+// Meet the physical landing edge instead of drawing a second top beneath it.
+// Working if the receiving approach ends at the shared landing with no overlap.
+export const RIVER_FOOTBRIDGE_APPROACH = {
+ start: [0, 0, -85] as [number, number, number],
+ end: [
+ RIVER_FOOTBRIDGE_DECK.centre[0],
+ 0,
+ Math.max(...RIVER_FOOTBRIDGE_LANDINGS.map((landing) => landing.maxZ)),
+ ] as [number, number, number],
+ width: 3,
+};
+
// Gravel/paved path component
export const GravelPath: React.FC<{
start: [number, number, number];
@@ -8751,7 +8764,7 @@ export const FactoryExterior: React.FC = ({ showFactoryShe
/>
{/* Path from back gate to river */}
-
+
{/* Riverbank path removed - was crossing through river */}
diff --git a/src/components/LoadingScreen.test.tsx b/src/components/LoadingScreen.test.tsx
index 4731065f..2f99832c 100644
--- a/src/components/LoadingScreen.test.tsx
+++ b/src/components/LoadingScreen.test.tsx
@@ -13,6 +13,20 @@ const state = vi.hoisted(() => ({
opened: false,
ready: false,
}));
+const beginTask = vi.hoisted(() =>
+ vi.fn(() => {
+ state.pendingTasks++;
+ state.revision++;
+ let finished = false;
+ return (failed = false) => {
+ if (finished) return;
+ finished = true;
+ state.pendingTasks--;
+ state.errors += Number(failed);
+ state.revision++;
+ };
+ })
+);
const flags = vi.hoisted(() => ({ KNOWLEDGE_LOADING_QUOTES_ENABLED: false }));
const openPrepared = vi.hoisted(() =>
vi.fn(() => {
@@ -27,6 +41,7 @@ const resetPreparation = vi.hoisted(() =>
);
vi.mock('../utils/startupReadiness', () => ({
getStartupSnapshot: () => state,
+ beginStartupTask: beginTask,
subscribeStartup: () => () => undefined,
openPreparedStartup: openPrepared,
resetStartupPreparation: resetPreparation,
@@ -47,6 +62,12 @@ beforeEach(() => {
state.prepared = false;
state.opened = false;
state.errors = 0;
+ state.pendingAssets = 0;
+ state.pendingTasks = 0;
+ state.loadedAssets = 10;
+ state.totalAssets = 10;
+ state.revision = 0;
+ beginTask.mockClear();
openPrepared.mockClear();
resetPreparation.mockClear();
useGraphicsStore.getState().setGraphicsQuality('low');
@@ -63,6 +84,39 @@ afterEach(() => {
});
describe('LoadingScreen final readiness', () => {
+ it('keeps one truthful status across late asset and preparation waves', async () => {
+ const view = render();
+ for (const next of [
+ { pendingAssets: 2, loadedAssets: 8, totalAssets: 10, pendingTasks: 0 },
+ { pendingAssets: 0, loadedAssets: 10, totalAssets: 10, pendingTasks: 1 },
+ { pendingAssets: 3, loadedAssets: 10, totalAssets: 13, pendingTasks: 0 },
+ { pendingAssets: 0, loadedAssets: 13, totalAssets: 13, pendingTasks: 0 },
+ ]) {
+ Object.assign(state, next);
+ view.rerender();
+ expect(screen.getByText('Preparing the complete scene')).toBeVisible();
+ expect(screen.getByRole('progressbar')).toHaveAttribute(
+ 'aria-valuetext',
+ 'Preparing the complete scene'
+ );
+ }
+ await act(async () => vi.advanceTimersByTimeAsync(60000));
+ expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull();
+ expect(screen.queryByText(/keep waiting or reload/)).toBeNull();
+ expect(screen.getByRole('progressbar')).toBeVisible();
+ });
+
+ it('never lets optional quote failure invalidate scene startup', async () => {
+ flags.KNOWLEDGE_LOADING_QUOTES_ENABLED = true;
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ render();
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
+ expect(beginTask).not.toHaveBeenCalled();
+ expect(state.pendingTasks).toBe(0);
+ expect(state.errors).toBe(0);
+ expect(screen.getByRole('dialog', { name: 'Loading MillOS' })).toBeVisible();
+ });
+
it('offers explicit access only to a prepared slow world and leaves strict readiness false', async () => {
state.prepared = true;
const view = render();
@@ -112,7 +166,7 @@ describe('LoadingScreen final readiness', () => {
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
});
- it('keeps keyboard focus on the cover before recovery and cycles the live Reload control', async () => {
+ it('keeps keyboard focus on the cover without a routine reload control', async () => {
render(
<>
@@ -127,14 +181,12 @@ describe('LoadingScreen final readiness', () => {
expect(cover).toHaveFocus();
await act(async () => vi.advanceTimersByTimeAsync(31000));
- const reload = screen.getByRole('button', { name: 'Reload' });
+ expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull();
fireEvent.keyDown(cover, { key: 'Tab' });
- expect(reload).toHaveFocus();
- fireEvent.keyDown(reload, { key: 'Tab' });
- expect(reload).toHaveFocus();
- fireEvent.keyDown(reload, { key: 'Tab', shiftKey: true });
- expect(reload).toHaveFocus();
- fireEvent.keyDown(reload, { key: 'Escape' });
+ expect(cover).toHaveFocus();
+ fireEvent.keyDown(cover, { key: 'Tab', shiftKey: true });
+ expect(cover).toHaveFocus();
+ fireEvent.keyDown(cover, { key: 'Escape' });
expect(cover).toHaveFocus();
expect(screen.getByRole('progressbar')).toBeVisible();
expect(screen.getByRole('button', { name: 'Covered application control' })).not.toHaveFocus();
@@ -148,14 +200,14 @@ describe('LoadingScreen final readiness', () => {
expect(screen.queryByRole('button', { name: 'Continue while preparing' })).toBeNull();
});
- it('offers recovery after prolonged startup without automatically revealing the scene', async () => {
+ it('keeps prolonged preparation covered without a reload prompt or automatic reveal', async () => {
render();
await act(async () => vi.advanceTimersByTimeAsync(60000));
expect(screen.getByRole('progressbar')).toBeVisible();
- expect(screen.getByRole('button', { name: 'Reload' })).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull();
expect(document.documentElement.dataset.loaderFallback).toBeUndefined();
expect(screen.queryByRole('button', { name: 'Continue while preparing' })).toBeNull();
- expect(screen.getAllByRole('button')).toHaveLength(1);
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
await act(async () => vi.advanceTimersByTimeAsync(250));
expect(screen.getByRole('progressbar')).toBeVisible();
expect(state.ready).toBe(false);
@@ -167,7 +219,7 @@ describe('LoadingScreen final readiness', () => {
render();
await act(async () => vi.advanceTimersByTimeAsync(60000));
expect(screen.getByText('Some resources could not be loaded.')).toBeVisible();
- expect(screen.getByRole('button', { name: 'Reload' })).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull();
expect(screen.getByRole('progressbar')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Continue while preparing' })).toBeNull();
});
diff --git a/src/components/LoadingScreen.tsx b/src/components/LoadingScreen.tsx
index 00138599..31d96262 100644
--- a/src/components/LoadingScreen.tsx
+++ b/src/components/LoadingScreen.tsx
@@ -1,4 +1,4 @@
-import React, { Suspense, useEffect, useRef, useState, useSyncExternalStore } from 'react';
+import React, { lazy, Suspense, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import {
getStartupSnapshot,
openPreparedStartup,
@@ -6,13 +6,17 @@ import {
subscribeStartup,
} from '../utils/startupReadiness';
import { FEATURE_FLAGS } from '../config/featureFlags';
-import { recoverableLazy } from '../utils/recoverableLazy';
+import { importWithBoundedRetry } from '../utils/recoverableLazy';
import { useFocusTrap } from '../hooks/useFocusTrap';
import ErrorBoundary from './ErrorBoundary';
import { useGraphicsStore } from '../stores/graphicsStore';
-const DeferredLoadingQuote = recoverableLazy(() =>
- import('./knowledge/LoadingQuote').then((module) => ({ default: module.LoadingQuote }))
+// Decorative DOM content must never invalidate the 3D preparation window.
+// Working if a delayed or failed quote leaves scene task/error counts unchanged.
+const DeferredLoadingQuote = lazy(() =>
+ importWithBoundedRetry(() =>
+ import('./knowledge/LoadingQuote').then((module) => ({ default: module.LoadingQuote }))
+ )
);
interface LoadingScreenProps {
@@ -35,7 +39,7 @@ export const LoadingScreen: React.FC = ({
);
const loadingRef = useRef(null);
// Loading owns keyboard focus until readiness; Escape cannot skip preparation.
- // Working if Tab stays on the cover, then cycles the delayed Reload control.
+ // Working if Tab stays on the cover until an explicit recovery control is available.
useFocusTrap(loadingRef as React.RefObject, showLoading, () =>
loadingRef.current?.focus()
);
@@ -67,13 +71,7 @@ export const LoadingScreen: React.FC = ({
const safeProgress = startup.ready
? 100
: Math.min(95, startup.totalAssets > 0 ? (startup.loadedAssets / startup.totalAssets) * 95 : 0);
- const progressText = startup.ready
- ? 'Ready'
- : startup.pendingAssets > 0
- ? `Loading scene assets, ${startup.loadedAssets} of ${startup.totalAssets}`
- : startup.pendingTasks > 0
- ? 'Building the complete mill'
- : 'Warming up the scene and checking animation';
+ const progressText = startup.ready ? 'Ready' : 'Preparing the complete scene';
return (
<>
@@ -167,44 +165,37 @@ export const LoadingScreen: React.FC = ({
/>
- {showRecovery && !startup.ready && (
-
-
- {startup.prepared
- ? 'The scene is loaded, but animation is still slow on this device.'
- : 'The scene is still preparing. You can keep waiting or reload.'}
-