Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/components/FactoryExterior.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -3680,6 +3681,18 @@ function GroundPathGeometry({
return <primitive object={geometry} attach="geometry" />;
}

// 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];
Expand Down Expand Up @@ -8751,7 +8764,7 @@ export const FactoryExterior: React.FC<FactoryExteriorProps> = ({ showFactoryShe
/>

{/* Path from back gate to river */}
<GravelPath start={[0, 0, -85]} end={[0, 0, -125]} width={3} type="paved" />
<GravelPath {...RIVER_FOOTBRIDGE_APPROACH} type="paved" />

{/* Riverbank path removed - was crossing through river */}

Expand Down
76 changes: 64 additions & 12 deletions src/components/LoadingScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -27,6 +41,7 @@ const resetPreparation = vi.hoisted(() =>
);
vi.mock('../utils/startupReadiness', () => ({
getStartupSnapshot: () => state,
beginStartupTask: beginTask,
subscribeStartup: () => () => undefined,
openPreparedStartup: openPrepared,
resetStartupPreparation: resetPreparation,
Expand All @@ -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');
Expand All @@ -63,6 +84,39 @@ afterEach(() => {
});

describe('LoadingScreen final readiness', () => {
it('keeps one truthful status across late asset and preparation waves', async () => {
const view = render(<LoadingScreen />);
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(<LoadingScreen />);
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(<LoadingScreen recoveryDelayMs={1} minimumLoadTimeMs={0} />);
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(<LoadingScreen recoveryDelayMs={1} minimumLoadTimeMs={0} />);
Expand Down Expand Up @@ -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(
<>
<LoadingScreen />
Expand All @@ -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();
Expand All @@ -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(<LoadingScreen />);
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);
Expand All @@ -167,7 +219,7 @@ describe('LoadingScreen final readiness', () => {
render(<LoadingScreen />);
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();
});
Expand Down
91 changes: 41 additions & 50 deletions src/components/LoadingScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import React, { Suspense, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import React, { lazy, Suspense, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import {
getStartupSnapshot,
openPreparedStartup,
resetStartupPreparation,
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 {
Expand All @@ -35,7 +39,7 @@ export const LoadingScreen: React.FC<LoadingScreenProps> = ({
);
const loadingRef = useRef<HTMLDivElement>(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<HTMLElement>, showLoading, () =>
loadingRef.current?.focus()
);
Expand Down Expand Up @@ -67,13 +71,7 @@ export const LoadingScreen: React.FC<LoadingScreenProps> = ({
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 (
<>
Expand Down Expand Up @@ -167,44 +165,37 @@ export const LoadingScreen: React.FC<LoadingScreenProps> = ({
/>
</div>

{showRecovery && !startup.ready && (
<div className="mt-6 flex max-w-md flex-col items-center gap-3 text-center text-sm text-slate-300">
<p>
{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.'}
</p>
{startup.errors > 0 && <p>Some resources could not be loaded.</p>}
{quality !== 'low' && (
<button
type="button"
onClick={() => {
resetStartupPreparation();
useGraphicsStore.getState().setGraphicsQuality('low');
}}
className="min-h-11 rounded-md border border-slate-500 px-4 py-2 text-slate-100 transition-colors hover:border-amber-400 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
>
Use low graphics
</button>
)}
{startup.prepared && startup.errors === 0 && (
<button
type="button"
onClick={openPreparedStartup}
className="min-h-11 rounded-md border border-slate-500 px-4 py-2 text-slate-100 transition-colors hover:border-amber-400 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
>
Open loaded scene (slow)
</button>
)}
<button
type="button"
onClick={() => window.location.reload()}
className="min-h-11 rounded-md border border-slate-500 px-4 py-2 text-slate-100 transition-colors hover:border-amber-400 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
>
Reload
</button>
</div>
)}
{showRecovery &&
!startup.ready &&
(startup.prepared || startup.errors > 0 || quality !== 'low') && (
<div className="mt-6 flex max-w-md flex-col items-center gap-3 text-center text-sm text-slate-300">
{startup.prepared && (
<p>The scene is loaded, but animation is still slow on this device.</p>
)}
{startup.errors > 0 && <p>Some resources could not be loaded.</p>}
{quality !== 'low' && (
<button
type="button"
onClick={() => {
resetStartupPreparation();
useGraphicsStore.getState().setGraphicsQuality('low');
}}
className="min-h-11 rounded-md border border-slate-500 px-4 py-2 text-slate-100 transition-colors hover:border-amber-400 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
>
Use low graphics
</button>
)}
{startup.prepared && startup.errors === 0 && (
<button
type="button"
onClick={openPreparedStartup}
className="min-h-11 rounded-md border border-slate-500 px-4 py-2 text-slate-100 transition-colors hover:border-amber-400 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
>
Open loaded scene (slow)
</button>
)}
</div>
)}
</div>
)}
</>
Expand Down
22 changes: 21 additions & 1 deletion src/components/exterior/GroundAlignment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ import {
} from 'three';
import {
createGroundPathGeometry,
RIVER_FOOTBRIDGE_APPROACH,
EAST_POND_APPROACH,
GravelPath,
HedgeRow,
setFenceRailInstances,
} from '../FactoryExterior';
import { SITE_LAYOUT, getServiceAssetBounds, boundsOverlapXZ } from '../../constants/siteLayout';
import {
SITE_LAYOUT,
RIVER_FOOTBRIDGE_DECK,
RIVER_FOOTBRIDGE_LANDINGS,
getServiceAssetBounds,
boundsOverlapXZ,
} from '../../constants/siteLayout';
import { sampleTerrainGroundHeight } from '../terrain/splatMapGenerator';
import { readFileSync } from 'node:fs';
import {
Expand Down Expand Up @@ -105,6 +112,19 @@ describe('exterior path and hedge centreline placement', () => {
});
});

it('meets the rear bridge landing edge without layering the path underneath it', () => {
const path = RIVER_FOOTBRIDGE_APPROACH;
const outerEdge = Math.max(...RIVER_FOOTBRIDGE_LANDINGS.map((landing) => landing.maxZ));
expect(path.start).toEqual([0, 0, -85]);
expect(path.end).toEqual([RIVER_FOOTBRIDGE_DECK.centre[0], 0, outerEdge]);
expect(path.width).toBeLessThan(RIVER_FOOTBRIDGE_DECK.size[0]);
for (const landing of RIVER_FOOTBRIDGE_LANDINGS)
expect(Math.min(path.start[2], path.end[2])).toBeGreaterThanOrEqual(landing.maxZ);
expect(readFileSync('src/components/FactoryExterior.tsx', 'utf8')).toContain(
'<GravelPath {...RIVER_FOOTBRIDGE_APPROACH} type="paved" />'
);
});

it('writes depth for the opaque paths and edging before the late terrain draw', () => {
const path = GravelPath({ start: [95, 0, 85], end: [120, 0, 92] }) as ReactElement<Placement>;
for (const child of Children.toArray(path.props.children)) {
Expand Down
16 changes: 12 additions & 4 deletions src/simulation/__tests__/ambientWorld.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ describe('ambient world simulation', () => {
});

it('maps wind and rainfall to finite mechanical and drainage responses', () => {
expect(getWindmillAngularSpeed(0.2)).toBeCloseTo(0.26);
expect(getWindmillAngularSpeed(0.92)).toBeCloseTo(1.196);
expect(getWindmillAngularSpeed(0.2)).toBeCloseTo(0.5);
expect(getWindmillAngularSpeed(0.92)).toBeCloseTo(1.256302521);
expect(getWindmillAngularSpeed(5)).toBe(1.3);

const dry = getCulvertWaterHeight(0.6, 0, 0);
Expand Down Expand Up @@ -73,7 +73,15 @@ it('fades animal activity smoothly across dawn/dusk and wraps the day without sp
it('keeps wind power finite and truly calm, without a perpetual-motion minimum', () => {
for (const wind of [NaN, Infinity, -Infinity, -2, 0])
expect(getWindmillAngularSpeed(wind)).toBe(0);
for (let i = 0; i <= 100; i++)
expect(getWindmillAngularSpeed(i / 100)).toBeCloseTo(i * 0.013, 10);
for (let i = 0; i <= 100; i++) {
const speed = getWindmillAngularSpeed(i / 100);
expect(speed).toBeGreaterThanOrEqual(0);
expect(speed).toBeLessThanOrEqual(1.3);
if (i > 0) {
const preceding = getWindmillAngularSpeed((i - 1) / 100);
expect(speed).toBeGreaterThan(preceding);
expect(speed - preceding).toBeLessThan(0.033);
}
}
expect(getWindmillAngularSpeed(2)).toBe(1.3);
});
7 changes: 4 additions & 3 deletions src/simulation/ambientWorld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ export function getAnimalActivityMultiplier(weather: AtmosphereWeather, hour: nu

/** Windmill speed in radians per second, driven by the canonical atmosphere wind scalar. */
export function getWindmillAngularSpeed(wind: number): number {
// Calm cannot power a windmill. Working if zero wind stops the sails,
// finite wind stays continuous, and maximum speed remains1.3rad/s.
return 1.3 * (Number.isFinite(wind) ? Math.max(0, Math.min(1, wind)) : 0);
// The ordinary 0.2 breeze drives the authored mill at 0.5 rad/s (about 5 RPM).
// Working if calm still stops it, response is smooth, and storms stay <= 1.3 rad/s.
const strength = Number.isFinite(wind) ? Math.max(0, Math.min(1, wind)) : 0;
return (1.3 * strength) / (0.4 + 0.6 * strength);
}

/** Local water height within a culvert, from dry-weather trickle to storm discharge. */
Expand Down
Loading
Loading