From cdd997ce700eda31292f0b0de32cfc7eb96d889b Mon Sep 17 00:00:00 2001 From: NellInc Date: Sat, 17 Oct 2026 21:01:10 -0300 Subject: [PATCH 1/2] Stabilize startup and refine windmill and bridge approach --- src/components/FactoryExterior.tsx | 15 ++- src/components/LoadingScreen.test.tsx | 76 +++++++++++++--- src/components/LoadingScreen.tsx | 91 +++++++++---------- .../exterior/GroundAlignment.test.tsx | 22 ++++- src/simulation/__tests__/ambientWorld.test.ts | 16 +++- src/simulation/ambientWorld.ts | 7 +- 6 files changed, 156 insertions(+), 71 deletions(-) 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.'} -

- {startup.errors > 0 &&

Some resources could not be loaded.

} - {quality !== 'low' && ( - - )} - {startup.prepared && startup.errors === 0 && ( - - )} - -
- )} + {showRecovery && + !startup.ready && + (startup.prepared || startup.errors > 0 || quality !== 'low') && ( +
+ {startup.prepared && ( +

The scene is loaded, but animation is still slow on this device.

+ )} + {startup.errors > 0 &&

Some resources could not be loaded.

} + {quality !== 'low' && ( + + )} + {startup.prepared && startup.errors === 0 && ( + + )} +
+ )} )} diff --git a/src/components/exterior/GroundAlignment.test.tsx b/src/components/exterior/GroundAlignment.test.tsx index 961620a7..c240c5c7 100644 --- a/src/components/exterior/GroundAlignment.test.tsx +++ b/src/components/exterior/GroundAlignment.test.tsx @@ -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 { @@ -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( + '' + ); +}); + 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; for (const child of Children.toArray(path.props.children)) { diff --git a/src/simulation/__tests__/ambientWorld.test.ts b/src/simulation/__tests__/ambientWorld.test.ts index cf900224..97d71a46 100644 --- a/src/simulation/__tests__/ambientWorld.test.ts +++ b/src/simulation/__tests__/ambientWorld.test.ts @@ -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); @@ -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); }); diff --git a/src/simulation/ambientWorld.ts b/src/simulation/ambientWorld.ts index 0f2fe8d9..246c013f 100644 --- a/src/simulation/ambientWorld.ts +++ b/src/simulation/ambientWorld.ts @@ -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. */ From 435276475bbf7ef43413243a169d8251641dc83c Mon Sep 17 00:00:00 2001 From: NellInc Date: Sat, 17 Oct 2026 21:19:53 -0300 Subject: [PATCH 2/2] Align simulation readiness test with streamlined loader --- src/systems/__tests__/CentralTickProvider.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/systems/__tests__/CentralTickProvider.test.tsx b/src/systems/__tests__/CentralTickProvider.test.tsx index ed364fe3..ce05866d 100644 --- a/src/systems/__tests__/CentralTickProvider.test.tsx +++ b/src/systems/__tests__/CentralTickProvider.test.tsx @@ -73,12 +73,13 @@ afterEach(() => { }); describe('CentralTickProvider player-ready simulation barrier', () => { - it('offers reload recovery while keeping the real shift frozen until readiness', async () => { + it('keeps the real shift frozen behind the streamlined loader until readiness', async () => { render(); frame(90); expect(onTick).not.toHaveBeenCalled(); - expect(await screen.findByRole('button', { name: 'Reload' })).toBeVisible(); + expect(await screen.findByText('Preparing the complete scene')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Continue while preparing' })).toBeNull(); frame(90.5);