diff --git a/public/portraits/aristotle.webp b/public/portraits/aristotle.webp deleted file mode 100644 index ed05a87..0000000 Binary files a/public/portraits/aristotle.webp and /dev/null differ diff --git a/public/portraits/elinor-ostrom.webp b/public/portraits/elinor-ostrom.webp deleted file mode 100644 index 1c22864..0000000 Binary files a/public/portraits/elinor-ostrom.webp and /dev/null differ diff --git a/public/portraits/jos-de-blok.webp b/public/portraits/jos-de-blok.webp deleted file mode 100644 index 9533057..0000000 Binary files a/public/portraits/jos-de-blok.webp and /dev/null differ diff --git a/public/portraits/jose-maria-arizmendiarrieta.webp b/public/portraits/jose-maria-arizmendiarrieta.webp deleted file mode 100644 index 5afc5bf..0000000 Binary files a/public/portraits/jose-maria-arizmendiarrieta.webp and /dev/null differ diff --git a/public/portraits/ricardo-semler.webp b/public/portraits/ricardo-semler.webp deleted file mode 100644 index 4d15b37..0000000 Binary files a/public/portraits/ricardo-semler.webp and /dev/null differ diff --git a/public/portraits/robert-greenleaf.webp b/public/portraits/robert-greenleaf.webp deleted file mode 100644 index 1314007..0000000 Binary files a/public/portraits/robert-greenleaf.webp and /dev/null differ diff --git a/public/portraits/rodrick-wallace.webp b/public/portraits/rodrick-wallace.webp deleted file mode 100644 index e166f6a..0000000 Binary files a/public/portraits/rodrick-wallace.webp and /dev/null differ diff --git a/public/portraits/w-edwards-deming.webp b/public/portraits/w-edwards-deming.webp deleted file mode 100644 index e4e7496..0000000 Binary files a/public/portraits/w-edwards-deming.webp and /dev/null differ diff --git a/scripts/validate-uncrewed-build.mjs b/scripts/validate-uncrewed-build.mjs index f4319c3..8e06572 100644 --- a/scripts/validate-uncrewed-build.mjs +++ b/scripts/validate-uncrewed-build.mjs @@ -12,7 +12,8 @@ const DESIGN_FILES = [ ]; const forbiddenPathPatterns = [ - { label: 'portrait directory', pattern: /(^|\/)assets\/workers(\/|$)/i }, + { label: 'worker asset directory', pattern: /(^|\/)assets\/workers(\/|$)/i }, + { label: 'portrait directory', pattern: /(^|\/)portraits(\/|$)/i }, { label: 'character model directory', pattern: /(^|\/)models\/worker(\/|$)/i }, { label: 'human-specific asset filename', @@ -27,7 +28,11 @@ const forbiddenRuntimePatterns = [ pattern: /WorkerSystemNew|WorkerDetailPanel|WorkforcePanel|WorkerModel|RemotePlayerAvatar|SeatedVehicleOperator|DockSpotter|WarehouseWorkerWithPalletJack/, }, - { label: 'human asset URL', pattern: /assets\/workers|models\/worker|worker-(masculine|feminine)/i }, + { + label: 'human asset URL', + pattern: /assets\/workers|models\/worker|worker-(masculine|feminine)/i, + }, + { label: 'portrait asset URL', pattern: /portraitPath|[/\\]portraits[/\\]/i }, { label: 'portrait roster', pattern: /marcus_chen|sarah_mitchell|james_rodriguez|emily_ronson|jennifer_lee/i, @@ -87,12 +92,14 @@ async function main() { for (const file of sourceFiles) { if (isArchivedPath(file.relative) || isTestSource(file.relative)) continue; for (const rule of forbiddenPathPatterns) { - if (rule.pattern.test(file.relative)) failures.push(`active source ${rule.label}: ${file.relative}`); + if (rule.pattern.test(file.relative)) + failures.push(`active source ${rule.label}: ${file.relative}`); } if (!/\.[cm]?[jt]sx?$/i.test(file.relative)) continue; const content = await readFile(file.absolute, 'utf8'); for (const rule of forbiddenRuntimePatterns) { - if (rule.pattern.test(content)) failures.push(`active source ${rule.label}: ${file.relative}`); + if (rule.pattern.test(content)) + failures.push(`active source ${rule.label}: ${file.relative}`); } } @@ -100,7 +107,8 @@ async function main() { for (const file of publicFiles) { if (isArchivedPath(file.relative)) continue; for (const rule of forbiddenPathPatterns) { - if (rule.pattern.test(file.relative)) failures.push(`current public ${rule.label}: ${file.relative}`); + if (rule.pattern.test(file.relative)) + failures.push(`current public ${rule.label}: ${file.relative}`); } } @@ -118,7 +126,7 @@ async function main() { } console.log( - `Uncrewed delivery contract passed: ${files.length} delivery files and ${sourceFiles.length} source files; no human assets, host voices, personnel modules, or personnel design studies.` + `Uncrewed v0.40 contract passed: ${files.length} current delivery files and ${sourceFiles.length} active source files contain no human assets, portraits, host voices, personnel modules, or personnel design studies. Archived releases and source provenance remain outside this current-release gate.` ); } diff --git a/src/components/FactoryExterior.tsx b/src/components/FactoryExterior.tsx index 653b2ec..9b2ae8e 100644 --- a/src/components/FactoryExterior.tsx +++ b/src/components/FactoryExterior.tsx @@ -1503,8 +1503,8 @@ const Lake: React.FC<{ const safeH = Number.isFinite(size?.[1]) && size[1] > 0 ? size[1] : 20; const mainRadiusX = Math.max(0.1, safeW / 2 - 1); const mainRadiusZ = Math.max(0.1, safeH / 2 - 1); - const shoreRadiusX = Math.max(0.1, safeW / 2 + 2); - const shoreRadiusZ = Math.max(0.1, safeH / 2 + 2); + const shoreRadiusX = Math.max(0.1, safeW / 2 + 1.2); + const shoreRadiusZ = Math.max(0.1, safeH / 2 + 1.2); const waterGeometry = useMemo( () => createOrganicLakeSurfaceGeometry(mainRadiusX, mainRadiusZ), [mainRadiusX, mainRadiusZ] diff --git a/src/components/ForkliftSystem.tsx b/src/components/ForkliftSystem.tsx index 53a6aa7..99cee1d 100644 --- a/src/components/ForkliftSystem.tsx +++ b/src/components/ForkliftSystem.tsx @@ -139,7 +139,10 @@ const BEACON_SWEEP_RATE = 4; * illumination while it is moving rather than only when it stops. * * The wedge is additive geometry rather than a spotlight for the same reason: - * a mesh's `visible` flag costs nothing, a light's changes every shader. + * a mesh's `visible` flag costs nothing, a light's changes every shader. The + * real point light is reserved for high and ultra because each point light + * adds a loop to every lit material in the complete scene. Medium retains the + * emissive beacon and sweeping pool without that scene-wide shader cost. */ const WarningLight = React.memo<{ isStopped: boolean; @@ -153,6 +156,8 @@ const WarningLight = React.memo<{ const lightRef = useRef(null); const sweepRef = useRef(null); const isTabVisible = useGameSimulationStore((state) => state.isTabVisible); + const graphicsQuality = useGraphicsStore((state) => state.graphics.quality); + const realLightEnabled = graphicsQuality === 'high' || graphicsQuality === 'ultra'; useFrame((state, delta) => { // PERFORMANCE: Skip animations when tab hidden @@ -220,7 +225,9 @@ const WarningLight = React.memo<{ /> - + {realLightEnabled && ( + + )} ); }); diff --git a/src/components/TruckBay.tsx b/src/components/TruckBay.tsx index 9a6988b..333a631 100644 --- a/src/components/TruckBay.tsx +++ b/src/components/TruckBay.tsx @@ -27,6 +27,7 @@ import { import { createTruckController, getTruckControllerPose, + interpolateTruckControllerState, stepTruckController, type TruckControllerState, } from './truckbay/truckController'; @@ -1157,63 +1158,68 @@ const TruckWashStation: React.FC<{ position: [number, number, number]; rotation? export const FleetTelemetryHub: React.FC<{ position: [number, number, number]; rotation?: number; -}> = ({ position, rotation = 0 }) => ( - - - - - - - - - - - - - - {[-0.82, 0, 0.82].map((x, index) => ( - - - - +}> = ({ position, rotation = 0 }) => { + const yardLampsEnabled = useYardLampsEnabled(); + return ( + + + + + + + + + + + + + + {[-0.82, 0, 0.82].map((x, index) => ( + + + + + + {[0.48, 0.12, -0.24, -0.6].map((y, lightIndex) => ( + + + + + ))} + + ))} + + + + - {[0.48, 0.12, -0.24, -0.6].map((y, lightIndex) => ( - - - + {[0, Math.PI / 2].map((rotationY) => ( + + + ))} + {yardLampsEnabled && ( + + )} - ))} - - - - - - {[0, Math.PI / 2].map((rotationY) => ( - - - + + + + - ))} - - - - - - - - - FLEET TELEMETRY - + + FLEET TELEMETRY + + - -); + ); +}; // Propane tank cage export const PropaneTankCage: React.FC<{ @@ -2124,6 +2130,7 @@ const GuardShack: React.FC<{ }> = ({ position, rotation = 0, gateOpen }) => { const gateRef = useRef(null); const guardId = useId(); + const yardLampsEnabled = useYardLampsEnabled(); useEffect(() => { if (!gateRef.current) return; @@ -2166,11 +2173,11 @@ const GuardShack: React.FC<{ - + - - + + {yardLampsEnabled && } @@ -2475,12 +2482,10 @@ const HeadlightBeam: React.FC<{ rotation: [number, number, number]; isOn: boolean; }> = ({ position, rotation, isOn }) => { - if (!isOn) return null; - return ( {/* Cone rotated to point forward (Z direction) - tip at light source, base spreading forward */} - + @@ -2743,6 +2748,8 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { const receivingTruckStateRef = useRef(initialReceivingState); const shippingControllerRef = useRef(initialShippingController); const receivingControllerRef = useRef(initialReceivingController); + const shippingPreviousControllerRef = useRef(initialShippingController); + const receivingPreviousControllerRef = useRef(initialReceivingController); const shippingAccumulatorRef = useRef(0); const receivingAccumulatorRef = useRef(0); @@ -2841,6 +2848,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { const advanceTruck = ( dock: 'shipping' | 'receiving', controllerRef: React.MutableRefObject, + previousControllerRef: React.MutableRefObject, accumulatorRef: React.MutableRefObject, truckStateRef: React.MutableRefObject, truckRef: React.MutableRefObject, @@ -2852,10 +2860,12 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { ): void => { const scheduleBefore = useTruckScheduleStore.getState().truckSchedule[dock]; let controller = controllerRef.current; + let previousController = previousControllerRef.current; let accumulator = accumulatorRef.current + controllerDelta; let departedThisFrame = false; while (accumulator >= TRUCK_CONTROLLER_STEP_SECONDS) { + previousController = controller; const result = stepTruckController(controller, { deltaSeconds: TRUCK_CONTROLLER_STEP_SECONDS, arrivalReady: scheduleBefore.arrivalReady, @@ -2868,6 +2878,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { accumulator -= TRUCK_CONTROLLER_STEP_SECONDS; } controllerRef.current = controller; + previousControllerRef.current = previousController; accumulatorRef.current = accumulator; if (scheduleBefore.arrivalReady && controller.active) { @@ -2877,9 +2888,14 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { useTruckScheduleStore.getState().recordTruckDeparture(dock, simulationMinutes); } - const truckState = getTruckControllerPose(controller, safetyHoldActive); + const displayController = interpolateTruckControllerState( + previousController, + controller, + accumulator / TRUCK_CONTROLLER_STEP_SECONDS + ); + const truckState = getTruckControllerPose(displayController, safetyHoldActive); truckStateRef.current = truckState; - wheelRotationRef.current = controller.wheelTravel / TRUCK_WHEEL_RADIUS; + wheelRotationRef.current = displayController.wheelTravel / TRUCK_WHEEL_RADIUS; if (truckRef.current) { truckRef.current.visible = truckState.active; @@ -2893,11 +2909,11 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { phase: truckState.phase, servicePhase: truckState.servicePhase, speed: truckState.speed, - acceleration: controller.motion.acceleration, + acceleration: displayController.motion.acceleration, steeringAngle: truckState.steeringAngle, wheelRotation: wheelRotationRef.current, - wheelTravel: controller.wheelTravel, - routeDistance: controller.phaseDistance, + wheelTravel: displayController.wheelTravel, + routeDistance: displayController.phaseDistance, trailerAngle: truckState.trailerAngle, articulation: truckState.articulation, parkingBrake: truckState.parkingBrake, @@ -3049,6 +3065,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { advanceTruck( 'shipping', shippingControllerRef, + shippingPreviousControllerRef, shippingAccumulatorRef, shippingTruckStateRef, shippingTruckRef, @@ -3061,6 +3078,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { advanceTruck( 'receiving', receivingControllerRef, + receivingPreviousControllerRef, receivingAccumulatorRef, receivingTruckStateRef, receivingTruckRef, diff --git a/src/components/environment/NearHorizonCity.test.ts b/src/components/environment/NearHorizonCity.test.ts index 49b5f72..599d6cf 100644 --- a/src/components/environment/NearHorizonCity.test.ts +++ b/src/components/environment/NearHorizonCity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { SITE_LAYOUT } from '../../constants/siteLayout'; import { buildNearCitySpecs } from './NearHorizonCity'; describe('near horizon city layout', () => { @@ -8,8 +9,11 @@ describe('near horizon city layout', () => { expect(first).toHaveLength(42); first.forEach((building) => { expect(Object.values(building).every(Number.isFinite)).toBe(true); - expect(Math.hypot(building.x, building.z)).toBeGreaterThanOrEqual(210); - expect(Math.hypot(building.x, building.z)).toBeLessThanOrEqual(245); + expect(Math.hypot(building.x, building.z)).toBeGreaterThanOrEqual(184); + expect(Math.hypot(building.x, building.z)).toBeLessThanOrEqual(224); + expect(building.height).toBeGreaterThanOrEqual(6.5); + expect(building.height).toBeLessThan(30); + expect(building.width).toBeGreaterThanOrEqual(5.5); expect(building.districtBand).toBeGreaterThanOrEqual(0); expect(building.districtBand).toBeLessThanOrEqual(2); expect(building.roofStyle).toBeGreaterThanOrEqual(0); @@ -31,8 +35,12 @@ describe('near horizon city layout', () => { }); it('stays clear of the authored castle footprint', () => { + const castle = SITE_LAYOUT.landmarks.castle; + const castleClearance = Math.max(...castle.footprint) * castle.scale * 0.5; buildNearCitySpecs().forEach((building) => { - expect(Math.hypot(building.x - 45, building.z + 200)).toBeGreaterThan(45); + expect( + Math.hypot(building.x - castle.position[0], building.z - castle.position[2]) + ).toBeGreaterThan(castleClearance); }); }); }); diff --git a/src/components/environment/NearHorizonCity.tsx b/src/components/environment/NearHorizonCity.tsx index a37a345..eb327d1 100644 --- a/src/components/environment/NearHorizonCity.tsx +++ b/src/components/environment/NearHorizonCity.tsx @@ -30,19 +30,20 @@ export const buildNearCitySpecs = (count = 42): NearCityBuildingSpec[] => const positionInBand = Math.floor(index / 3); const progress = buildingsInBand <= 1 ? 0.5 : positionInBand / (buildingsInBand - 1); const angle = - THREE.MathUtils.lerp(-1.06, -0.2, progress) + (deterministicNoise(index, 6) - 0.5) * 0.035; + THREE.MathUtils.lerp(-1, -0.12, progress) + (deterministicNoise(index, 6) - 0.5) * 0.045; // Three shallow depth bands create real parallax and an enclosing district // silhouette without bringing city geometry into the operational yard. - const radius = 210 + districtBand * 14 + deterministicNoise(index, 0) * 7; - const landmark = deterministicNoise(index, 1) > 0.9; - const height = 9 + deterministicNoise(index, 2) * 19 + districtBand * 1.5 + (landmark ? 8 : 0); + const radius = 184 + districtBand * 16 + deterministicNoise(index, 0) * 8; + const landmark = deterministicNoise(index, 1) > 0.93; + const height = + 6.5 + deterministicNoise(index, 2) * 13 + districtBand * 1.25 + (landmark ? 6 : 0); const x = Math.cos(angle) * radius; const z = Math.sin(angle) * radius; return { x, z, - width: 4 + deterministicNoise(index, 3) * 3.2, - depth: 4 + deterministicNoise(index, 4) * 4.5, + width: 5.5 + deterministicNoise(index, 3) * 5.5, + depth: 5 + deterministicNoise(index, 4) * 5.5, height, yaw: Math.atan2(-x, -z), tone: deterministicNoise(index, 5), @@ -54,38 +55,40 @@ export const buildNearCitySpecs = (count = 42): NearCityBuildingSpec[] => const UNIT_BOX = new THREE.BoxGeometry(1, 1, 1); const CITY_BODY_MATERIAL = new THREE.MeshStandardMaterial({ color: '#ffffff', + emissive: '#222927', + emissiveIntensity: 0.22, roughness: 0.88, metalness: 0.04, vertexColors: true, }); const CITY_ROOF_MATERIAL = new THREE.MeshStandardMaterial({ - color: '#4b5052', + color: '#666d6c', roughness: 0.82, metalness: 0.08, }); const CITY_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({ - color: '#263d48', + color: '#647d88', emissive: '#ffca72', emissiveIntensity: 0, roughness: 0.36, metalness: 0.06, }); const CITY_MECHANICAL_MATERIAL = new THREE.MeshStandardMaterial({ - color: '#626b6c', + color: '#7a8483', roughness: 0.7, metalness: 0.18, }); const CITY_STACK_MATERIAL = new THREE.MeshStandardMaterial({ - color: '#3f4547', + color: '#58605f', roughness: 0.58, metalness: 0.28, }); const CITY_BODY_COLOURS = [ - new THREE.Color('#858986'), - new THREE.Color('#91877c'), - new THREE.Color('#78858a'), - new THREE.Color('#827d78'), - new THREE.Color('#8c908a'), + new THREE.Color('#9ba39e'), + new THREE.Color('#aa9f91'), + new THREE.Color('#8fa1a6'), + new THREE.Color('#9e9690'), + new THREE.Color('#a5aaa2'), ] as const; interface WindowSpec { diff --git a/src/components/exterior/organicLakeGeometry.test.ts b/src/components/exterior/organicLakeGeometry.test.ts index 589032e..2450ed9 100644 --- a/src/components/exterior/organicLakeGeometry.test.ts +++ b/src/components/exterior/organicLakeGeometry.test.ts @@ -23,6 +23,13 @@ describe('organic lake geometry', () => { expectFiniteGeometry(bank); expect(water.getAttribute('uv').count).toBe(water.getAttribute('position').count); expect(bank.getAttribute('color').count).toBe(bank.getAttribute('position').count); + expect(water.getAttribute('position').count).toBeGreaterThan(72 * 4); + + const bankPositions = bank.getAttribute('position'); + const bankHeights = Array.from({ length: bankPositions.count }, (_, index) => + bankPositions.getZ(index) + ); + expect(Math.max(...bankHeights) - Math.min(...bankHeights)).toBeGreaterThan(0.1); water.dispose(); bank.dispose(); @@ -32,16 +39,14 @@ describe('organic lake geometry', () => { const segments = 24; const water = createOrganicLakeSurfaceGeometry(12, 8, segments); const positions = water.getAttribute('position'); - const boundaryOffset = 1; - - expect(positions.getX(boundaryOffset)).toBeCloseTo( - positions.getX(boundaryOffset + segments), - 6 - ); - expect(positions.getY(boundaryOffset)).toBeCloseTo( - positions.getY(boundaryOffset + segments), - 6 - ); + const radialSegments = (positions.count - 1) / (segments + 1); + expect(Number.isInteger(radialSegments)).toBe(true); + for (let ring = 0; ring < radialSegments; ring += 1) { + const first = 1 + ring * (segments + 1); + const last = first + segments; + expect(positions.getX(first)).toBeCloseTo(positions.getX(last), 6); + expect(positions.getY(first)).toBeCloseTo(positions.getY(last), 6); + } water.dispose(); }); diff --git a/src/components/exterior/organicLakeGeometry.ts b/src/components/exterior/organicLakeGeometry.ts index 8789a38..21cae87 100644 --- a/src/components/exterior/organicLakeGeometry.ts +++ b/src/components/exterior/organicLakeGeometry.ts @@ -11,33 +11,58 @@ function shorelineFactor(angle: number, seed: number): number { ); } -/** Build a softly irregular lake using the original proven fan topology. */ +/** + * Build a softly irregular lake with concentric radial subdivisions. The extra + * rings let vertex waves bend continuously across the surface instead of + * stretching one centre-to-shore triangle over the full lake radius. + */ export function createOrganicLakeSurfaceGeometry( radiusX: number, radiusZ: number, segments = DEFAULT_SEGMENTS, - seed = 0.74 + seed = 0.74, + radialSegments = 6 ): THREE.BufferGeometry { const safeRadiusX = Math.max(0.1, radiusX); const safeRadiusZ = Math.max(0.1, radiusZ); const safeSegments = Math.max(12, Math.floor(segments)); + const safeRadialSegments = Math.max(2, Math.floor(radialSegments)); const positions: number[] = [0, 0, 0]; const uvs: number[] = [0.5, 0.5]; const indices: number[] = []; - for (let segment = 0; segment <= safeSegments; segment += 1) { - const progress = segment === safeSegments ? 0 : segment / safeSegments; - const angle = progress * Math.PI * 2; - const edgeFactor = shorelineFactor(angle, seed); - const x = Math.cos(angle) * safeRadiusX * edgeFactor; - const z = Math.sin(angle) * safeRadiusZ * edgeFactor; - positions.push(x, z, 0); - uvs.push(0.5 + Math.cos(angle) * 0.5, 0.5 + Math.sin(angle) * 0.5); + for (let ring = 1; ring <= safeRadialSegments; ring += 1) { + const radialRatio = ring / safeRadialSegments; + for (let segment = 0; segment <= safeSegments; segment += 1) { + const progress = segment === safeSegments ? 0 : segment / safeSegments; + const angle = progress * Math.PI * 2; + const edgeFactor = shorelineFactor(angle, seed); + const factor = THREE.MathUtils.lerp(1, edgeFactor, radialRatio * radialRatio); + const x = Math.cos(angle) * safeRadiusX * radialRatio * factor; + const z = Math.sin(angle) * safeRadiusZ * radialRatio * factor; + positions.push(x, z, 0); + uvs.push( + 0.5 + Math.cos(angle) * radialRatio * 0.5, + 0.5 + Math.sin(angle) * radialRatio * 0.5 + ); + } } for (let segment = 0; segment < safeSegments; segment += 1) { indices.push(0, 1 + segment, 2 + segment); } + const ringStride = safeSegments + 1; + for (let ring = 1; ring < safeRadialSegments; ring += 1) { + const innerStart = 1 + (ring - 1) * ringStride; + const outerStart = innerStart + ringStride; + for (let segment = 0; segment < safeSegments; segment += 1) { + const innerLeft = innerStart + segment; + const innerRight = innerLeft + 1; + const outerLeft = outerStart + segment; + const outerRight = outerLeft + 1; + indices.push(innerLeft, outerLeft, innerRight, innerRight, outerLeft, outerRight); + } + } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); @@ -61,15 +86,18 @@ export function createOrganicLakeBankGeometry( seed = 0.74 ): THREE.BufferGeometry { const safeSegments = Math.max(12, Math.floor(segments)); - const rows = 3; + const rows = 5; const positions: number[] = []; const colors: number[] = []; const indices: number[] = []; const rowColors = [ - new THREE.Color('#4f655d'), - new THREE.Color('#9e9375'), - new THREE.Color('#5d744f'), + new THREE.Color('#425750'), + new THREE.Color('#6e7366'), + new THREE.Color('#928a70'), + new THREE.Color('#6f7759'), + new THREE.Color('#526c49'), ]; + const rowHeights = [-0.05, 0.015, 0.105, 0.055, 0] as const; for (let segment = 0; segment <= safeSegments; segment += 1) { const progress = segment === safeSegments ? 0 : segment / safeSegments; @@ -83,7 +111,11 @@ export function createOrganicLakeBankGeometry( const radiusX = THREE.MathUtils.lerp(waterRadiusX * 0.985, shoreRadiusX, ratio); const radiusZ = THREE.MathUtils.lerp(waterRadiusZ * 0.985, shoreRadiusZ, ratio); const factor = THREE.MathUtils.lerp(innerFactor, outerFactor, ratio); - positions.push(Math.cos(angle) * radiusX * factor, Math.sin(angle) * radiusZ * factor, 0); + positions.push( + Math.cos(angle) * radiusX * factor, + Math.sin(angle) * radiusZ * factor, + rowHeights[row] + ); const color = rowColors[row]; colors.push(color.r, color.g, color.b); } diff --git a/src/components/knowledge/Datalinks.tsx b/src/components/knowledge/Datalinks.tsx index 5bb0247..2a45181 100644 --- a/src/components/knowledge/Datalinks.tsx +++ b/src/components/knowledge/Datalinks.tsx @@ -326,18 +326,10 @@ export function Datalinks({ isOpen, onClose }: DatalinksProps) { : 'hover:bg-slate-800/50 border-l-2 border-transparent' }`} > - {entry.portraitPath ? ( - {entry.title} - ) : ( - - )} +
diff --git a/src/components/knowledge/KnowledgeEntryCard.tsx b/src/components/knowledge/KnowledgeEntryCard.tsx index b19cb85..eb7e63f 100644 --- a/src/components/knowledge/KnowledgeEntryCard.tsx +++ b/src/components/knowledge/KnowledgeEntryCard.tsx @@ -265,18 +265,7 @@ export function KnowledgeEntryCard({ entry, onClose, onNavigate }: KnowledgeEntr Back to list
- {entry.portraitPath ? ( - {entry.title} { - e.currentTarget.style.display = 'none'; - }} - /> - ) : ( - - )} +
{getCategoryLabel(entry.category)} diff --git a/src/components/scenery/FairytaleCastle.tsx b/src/components/scenery/FairytaleCastle.tsx index 6f33b15..a3651db 100644 --- a/src/components/scenery/FairytaleCastle.tsx +++ b/src/components/scenery/FairytaleCastle.tsx @@ -307,17 +307,19 @@ export const FairytaleCastle: React.FC = React.memo( scale = 1, rotation = [0, 0, 0] as [number, number, number], }) => { - // Neuschwanstein Palette + // Weathered heritage palette keeps the landmark grounded in the same + // overcast industrial valley as the mill instead of reading as white set + // dressing pasted over the mountain range. const colors = { - walls: '#e8e6e1', // Off-white limestone - roofs: '#2b5a75', // Deep blue slate - trim: '#c5a582', // Decorative sandstone trim - rock: '#5d5d5d', // Grey mountain rock - gold: '#ffd700', // Spires + walls: '#c6c3ba', + roofs: '#314c5a', + trim: '#9f8e76', + rock: '#505957', + gold: '#b89743', }; return ( - + {/* ==================== BASE MOUNTAIN ==================== */} {/* Stays at 7 sides. It is the widest part here (75 m across) but 7 is deliberate: with `flatShading` it reads as a crag rather than a cone, diff --git a/src/components/truckbay/truckController.test.ts b/src/components/truckbay/truckController.test.ts index 9f09576..b1f68e4 100644 --- a/src/components/truckbay/truckController.test.ts +++ b/src/components/truckbay/truckController.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createTruckController, getTruckControllerPose, + interpolateTruckControllerState, stepTruckController, } from './truckController'; @@ -207,4 +208,44 @@ describe('schedule-owned articulated truck controller', () => { expect(restricted.maximumArticulation).toBeLessThanOrEqual(0.7 + 1e-8); expect(restricted.maximumSteering).toBeLessThanOrEqual(0.55 + 1e-8); }); + + it('interpolates render poses smoothly across the angle wrap', () => { + const previous = { + ...createTruckController('shipping'), + x: 10, + z: 20, + tractorYaw: Math.PI - 0.1, + trailerYaw: Math.PI - 0.2, + steeringAngle: -0.2, + wheelTravel: 40, + phaseElapsed: 2, + motion: { speed: 4, acceleration: 0.5 }, + }; + const current = { + ...previous, + x: 14, + z: 24, + tractorYaw: -Math.PI + 0.1, + trailerYaw: -Math.PI + 0.2, + steeringAngle: 0.2, + wheelTravel: 44, + phaseElapsed: 3, + motion: { speed: 6, acceleration: 1.5 }, + }; + + const display = interpolateTruckControllerState(previous, current, 0.5); + expect(display.x).toBe(12); + expect(display.z).toBe(22); + expect(display.wheelTravel).toBe(42); + expect(display.steeringAngle).toBeCloseTo(0); + expect(display.motion).toEqual({ speed: 5, acceleration: 1 }); + expect(Math.abs(Math.abs(display.tractorYaw) - Math.PI)).toBeLessThan(1e-8); + expect(Math.abs(Math.abs(display.trailerYaw) - Math.PI)).toBeLessThan(1e-8); + }); + + it('keeps active transitions discrete so trucks never ghost into or out of the yard', () => { + const previous = createTruckController('shipping', false); + const current = createTruckController('shipping', true); + expect(interpolateTruckControllerState(previous, current, 0.25)).toBe(current); + }); }); diff --git a/src/components/truckbay/truckController.ts b/src/components/truckbay/truckController.ts index c1163ad..f5293c9 100644 --- a/src/components/truckbay/truckController.ts +++ b/src/components/truckbay/truckController.ts @@ -364,6 +364,53 @@ export function getTruckControllerPose( return poseFromState(state, safetyHold); } +const interpolateScalar = (from: number, to: number, alpha: number): number => + from + (to - from) * alpha; + +const interpolateAngle = (from: number, to: number, alpha: number): number => + normalizeVehicleAngle(from + shortestVehicleAngle(from, to) * alpha); + +/** + * Produces a render-only pose between deterministic 60 Hz controller ticks. + * Discrete phase and interlock authority comes from the current state while + * position, articulation, wheel travel and service mechanisms move at the + * display refresh rate. This removes repeated poses on 90/120/144 Hz displays + * without making simulation outcomes depend on render cadence. + */ +export function interpolateTruckControllerState( + previous: TruckControllerState, + current: TruckControllerState, + alpha: number +): TruckControllerState { + const t = Math.max(0, Math.min(1, Number.isFinite(alpha) ? alpha : 0)); + if (previous.active !== current.active || previous.dock !== current.dock) return current; + + const samePhase = previous.phase === current.phase; + const sameServicePhase = previous.servicePhase === current.servicePhase; + return { + ...current, + phaseDistance: samePhase + ? interpolateScalar(previous.phaseDistance, current.phaseDistance, t) + : current.phaseDistance, + phaseElapsed: samePhase + ? interpolateScalar(previous.phaseElapsed, current.phaseElapsed, t) + : current.phaseElapsed, + serviceElapsed: sameServicePhase + ? interpolateScalar(previous.serviceElapsed, current.serviceElapsed, t) + : current.serviceElapsed, + motion: { + speed: interpolateScalar(previous.motion.speed, current.motion.speed, t), + acceleration: interpolateScalar(previous.motion.acceleration, current.motion.acceleration, t), + }, + x: interpolateScalar(previous.x, current.x, t), + z: interpolateScalar(previous.z, current.z, t), + tractorYaw: interpolateAngle(previous.tractorYaw, current.tractorYaw, t), + trailerYaw: interpolateAngle(previous.trailerYaw, current.trailerYaw, t), + steeringAngle: interpolateScalar(previous.steeringAngle, current.steeringAngle, t), + wheelTravel: interpolateScalar(previous.wheelTravel, current.wheelTravel, t), + }; +} + export function stepTruckController( prior: TruckControllerState, input: TruckControllerInput diff --git a/src/components/ui-new/GameInterface.tsx b/src/components/ui-new/GameInterface.tsx index 1298673..5003d46 100644 --- a/src/components/ui-new/GameInterface.tsx +++ b/src/components/ui-new/GameInterface.tsx @@ -32,7 +32,7 @@ const INTRO_STEPS: OnboardingStep[] = [ title: 'Follow the process', icon: 'factory', content: - 'The camera is flying to the full site. Grain moves from the rear silos through milling and sifting, then reaches packing and shipping. Drag to orbit. Scroll or pinch to zoom.', + 'The camera is flying to the full site. Grain moves from the rear silos through milling and sifting, then reaches packing and shipping. Use W A S D to move, Q and E to descend or climb, drag to orbit, and scroll or pinch to zoom.', }, { title: 'Protect today’s target', diff --git a/src/components/ui-new/hud/StatusHUD.tsx b/src/components/ui-new/hud/StatusHUD.tsx index 58dc33b..4855688 100644 --- a/src/components/ui-new/hud/StatusHUD.tsx +++ b/src/components/ui-new/hud/StatusHUD.tsx @@ -209,7 +209,8 @@ export const StatusHUD: React.FC = () => {
{throughput.toLocaleString()} BAGS/H
diff --git a/src/config/humanPresencePolicy.test.ts b/src/config/humanPresencePolicy.test.ts index 519a12e..29b3e76 100644 --- a/src/config/humanPresencePolicy.test.ts +++ b/src/config/humanPresencePolicy.test.ts @@ -60,6 +60,7 @@ describe('v0.40 uncrewed-site contract', () => { it('does not expose character models, portraits, or host voices to production', () => { const prohibitedPublicPaths = [ 'public/assets/workers', + 'public/portraits', 'public/models/worker', 'public/textures/compressed/worker_color.ktx2', 'public/textures/compressed/worker_normal.ktx2', @@ -88,6 +89,13 @@ describe('v0.40 uncrewed-site contract', () => { expect(productionAudioSources).not.toMatch( /speechSynthesis|SpeechSynthesis|ttsEnabled|speakAnnouncement|startWorkerVoices|startRadioChatter|playRadioDispatch|WorkerModel|WORKER_ASSET_PATHS|WORKER_VARIANTS/ ); + + const knowledgeSources = [ + source('src/stores/knowledgeStore.ts'), + source('src/components/knowledge/Datalinks.tsx'), + source('src/components/knowledge/KnowledgeEntryCard.tsx'), + ].join('\n'); + expect(knowledgeSources).not.toMatch(/portraitPath|[/\\]portraits[/\\]| { diff --git a/src/constants/__tests__/siteLayout.test.ts b/src/constants/__tests__/siteLayout.test.ts index a5908fd..bc10a17 100644 --- a/src/constants/__tests__/siteLayout.test.ts +++ b/src/constants/__tests__/siteLayout.test.ts @@ -74,12 +74,32 @@ describe('canonical site layout', () => { const tankFarm = getServiceAssetBounds(SITE_LAYOUT.serviceYard.utilityTankFarm); expect(boundsOverlapXZ(tankFarm, propaneClearance)).toBe(false); - expect(propaneClearance.maxX).toBeLessThanOrEqual(SITE_LAYOUT.perimeter.maxX); - expect(tankFarm.maxX).toBeLessThan(SITE_LAYOUT.perimeter.maxX); - expect(tankFarm.minZ).toBeGreaterThan(SITE_LAYOUT.perimeter.minZ); - const telemetryHub = getServiceAssetBounds(SITE_LAYOUT.serviceYard.fleetTelemetryHub); - expect(telemetryHub.minX).toBeGreaterThan(SITE_LAYOUT.docks.shipping.apron.maxX); + for (const asset of bounds) { + expect(boundsOverlapXZ(asset.bounds, FACTORY_BOUNDS), `${asset.id} crosses factory`).toBe( + false + ); + expect(asset.bounds.minX, `${asset.id} west perimeter`).toBeGreaterThanOrEqual( + SITE_LAYOUT.perimeter.minX + ); + expect(asset.bounds.maxX, `${asset.id} east perimeter`).toBeLessThanOrEqual( + SITE_LAYOUT.perimeter.maxX + ); + expect(asset.bounds.minZ, `${asset.id} north perimeter`).toBeGreaterThanOrEqual( + SITE_LAYOUT.perimeter.minZ + ); + expect(asset.bounds.maxZ, `${asset.id} south perimeter`).toBeLessThanOrEqual( + SITE_LAYOUT.perimeter.maxZ + ); + expect( + boundsOverlapXZ(asset.bounds, SITE_LAYOUT.docks.shipping.apron), + `${asset.id} crosses shipping apron` + ).toBe(false); + expect( + boundsOverlapXZ(asset.bounds, SITE_LAYOUT.docks.receiving.apron), + `${asset.id} crosses receiving apron` + ).toBe(false); + } }); it('keeps canonical forklift swept corridors clear of machines and service assets', () => { diff --git a/src/constants/siteLayout.ts b/src/constants/siteLayout.ts index c734a7f..0435e9a 100644 --- a/src/constants/siteLayout.ts +++ b/src/constants/siteLayout.ts @@ -172,31 +172,31 @@ export const SITE_LAYOUT = { serviceYard: { maintenanceGarage: { id: 'maintenance-garage', - position: [85, 0, 30], + position: [83.5, 0, 36], rotation: -Math.PI / 2, - footprint: [12, 10], + footprint: [12.3, 10.8], height: 8, clearance: 6, }, propaneCompound: { id: 'propane-compound', - position: [85.5, 0, 10.5], + position: [83.5, 0, 14.5], rotation: 0, - footprint: [9, 5], + footprint: [12, 9], height: 5, - clearance: 5, + clearance: 4, }, utilityTankFarm: { id: 'utility-tank-farm', position: [75, 0, -15], rotation: 0, - footprint: [16, 36], + footprint: [22, 42], height: 12, clearance: 2, }, trailerDropYard: { id: 'trailer-drop-yard', - position: [-60, 0, 35], + position: [-78, 0, 35], rotation: 0, footprint: [20, 30], height: 5, @@ -341,7 +341,7 @@ export const SITE_LAYOUT = { water: { position: [158, 23, 154], target: [118, 0.08, 116] }, village: { position: [-142, 28, 64], target: [-190, 5, 0] }, farm: { position: [128, 26, 174], target: [75, 4, 120] }, - garage: { position: [69, 6.5, 30], target: [85, 3, 30] }, + garage: { position: [66, 7, 36], target: [83.5, 3, 36] }, celestial: { position: [90, 12, 72], target: [0, 12, 0] }, }, renderCells: { diff --git a/src/simulation/vehicles/forkliftController.test.ts b/src/simulation/vehicles/forkliftController.test.ts index d61802d..0def563 100644 --- a/src/simulation/vehicles/forkliftController.test.ts +++ b/src/simulation/vehicles/forkliftController.test.ts @@ -103,7 +103,7 @@ describe('forklift controller', () => { expect(disengaged.cargoEngaged).toBe(false); }); - it('preserves route progress across common rendering frequencies', () => { + it('preserves route progress across common and degraded rendering frequencies', () => { const plan = createForkliftRoutePlan(points, actions); const simulate = (framesPerSecond: number) => { let state = createInitialForkliftMotion(plan, [0, 0, 0]); @@ -118,9 +118,9 @@ describe('forklift controller', () => { return state; }; - const results = [30, 60, 120].map(simulate); + const results = [15, 30, 60, 120].map(simulate); const wheelTravel = results.map((state) => state.wheelTravel); - expect(Math.max(...wheelTravel) - Math.min(...wheelTravel)).toBeLessThan(0.15); + expect(Math.max(...wheelTravel) - Math.min(...wheelTravel)).toBeLessThan(0.05); for (const state of results) { expect(Number.isFinite(state.x)).toBe(true); expect(Number.isFinite(state.z)).toBe(true); @@ -189,4 +189,28 @@ describe('forklift controller', () => { expect(state.wheelTravel - wheelTravel).toBeCloseTo(0.01); expect(state.speed).toBe(0); }); + + it('substeps a dropped frame without violating a nearby movement authority', () => { + const plan = createForkliftRoutePlan(points, actions); + let state = createInitialForkliftMotion(plan, [0, 0, 0]); + for (let frame = 0; frame < 180; frame += 1) { + state = stepForkliftMotion(state, plan, { + targetSpeed: 2, + stopReason: 'none', + loaded: false, + deltaSeconds: 1 / 60, + }); + } + + const wheelTravel = state.wheelTravel; + state = stepForkliftMotion(state, plan, { + targetSpeed: 2, + stopReason: 'none', + loaded: false, + deltaSeconds: 0.2, + maximumTravelDistance: 0.08, + }); + expect(state.wheelTravel - wheelTravel).toBeCloseTo(0.08); + expect(state.speed).toBe(0); + }); }); diff --git a/src/simulation/vehicles/forkliftController.ts b/src/simulation/vehicles/forkliftController.ts index 3edff20..c32543a 100644 --- a/src/simulation/vehicles/forkliftController.ts +++ b/src/simulation/vehicles/forkliftController.ts @@ -79,6 +79,8 @@ export const FORKLIFT_WHEELBASE_METRES = 1.55; export const FORKLIFT_REAR_TRACK_METRES = 1.05; export const FORKLIFT_MAXIMUM_STEERING_RADIANS = 0.56; export const FORKLIFT_MAXIMUM_STEERING_RATE = 1.4; +const FORKLIFT_MAXIMUM_SUBSTEP_SECONDS = 1 / 60; +const FORKLIFT_MAXIMUM_REQUEST_SECONDS = 0.25; const UNLOADED_LIMITS: LongitudinalMotionLimits = { maximumForwardSpeed: 3.5, @@ -153,11 +155,11 @@ export function distanceAheadOnClosedPath( return direct >= 0 ? direct : path.totalLength + direct; } -export function stepForkliftMotion( +const stepForkliftMotionOnce = ( state: ForkliftMotionState, plan: ForkliftRoutePlan, request: ForkliftMotionRequest -): ForkliftMotionState { +): ForkliftMotionState => { const limits = request.loaded ? LOADED_LIMITS : UNLOADED_LIMITS; const lookAheadBeforeStep = sampleArcLengthPath( plan.path, @@ -214,6 +216,49 @@ export function stepForkliftMotion( z: sample.z, stopReason: request.stopReason, }; +}; + +/** + * Integrate at no more than 60 Hz per kinematic step. A dropped render frame + * can otherwise feed a 100 to 200 ms impulse into acceleration and steering, + * producing the visible lurch that makes an autonomous forklift look keyed by + * hand. Substeps retain render-rate motion while keeping jerk, braking and + * movement-authority results bounded across common display cadences. + */ +export function stepForkliftMotion( + state: ForkliftMotionState, + plan: ForkliftRoutePlan, + request: ForkliftMotionRequest +): ForkliftMotionState { + let remainingTime = Math.min( + FORKLIFT_MAXIMUM_REQUEST_SECONDS, + Math.max(0, Number.isFinite(request.deltaSeconds) ? request.deltaSeconds : 0) + ); + if (remainingTime <= 0) return state; + + let current = state; + let remainingAuthority = Number.isFinite(request.maximumTravelDistance) + ? Math.max(0, request.maximumTravelDistance ?? 0) + : undefined; + + while (remainingTime > 1e-9) { + const deltaSeconds = Math.min(FORKLIFT_MAXIMUM_SUBSTEP_SECONDS, remainingTime); + const wheelTravelBefore = current.wheelTravel; + current = stepForkliftMotionOnce(current, plan, { + ...request, + deltaSeconds, + maximumTravelDistance: remainingAuthority, + }); + if (remainingAuthority !== undefined) { + remainingAuthority = Math.max( + 0, + remainingAuthority - Math.max(0, current.wheelTravel - wheelTravelBefore) + ); + } + remainingTime -= deltaSeconds; + } + + return current; } /** diff --git a/src/stores/knowledgeStore.ts b/src/stores/knowledgeStore.ts index ab22d56..9f1e5b5 100644 --- a/src/stores/knowledgeStore.ts +++ b/src/stores/knowledgeStore.ts @@ -52,7 +52,6 @@ export interface KnowledgeEntry { seeInAction: string[]; unlockCondition: UnlockCondition; quote?: KnowledgeQuote; - portraitPath?: string; } export interface UnlockContext { diff --git a/src/systems/UnifiedGameTick.ts b/src/systems/UnifiedGameTick.ts index fbb0a91..ad6ebf7 100644 --- a/src/systems/UnifiedGameTick.ts +++ b/src/systems/UnifiedGameTick.ts @@ -24,7 +24,7 @@ import { import { useTruckScheduleStore } from '../stores/truckScheduleStore'; import { useBreakdownStore, type BreakdownType } from '../stores/breakdownStore'; import { useUIStore } from '../stores/uiStore'; -import type { MachineData } from '../types'; +import { BAG_WEIGHT_KG, type MachineData } from '../types'; import { useOperationsCampaignStore, type DispatchLoadSnapshot, @@ -51,37 +51,32 @@ const GRAIN_DELIVERY_KG = 15000; // A shipping truck can load up to 5 t of finished flour or semolina. const FINISHED_GOODS_SHIPMENT_KG = 5000; const SHIPPING_LOAD_RATE_KG_PER_SECOND = 400; -const BAGS_PER_SECOND_BASE = 12; -const NOMINAL_PACKER_KG_PER_SECOND = 25; -const NOMINAL_PACKER_COUNT = 3; let _bagProductionCarry = 0; export const calculateBagsProducedForTick = ( deltaSeconds: number, - productionSpeed: number, - gameSpeed: number, - runningPackerCount: number, - healthFactor: number + packerFlowKgPerSecond: number, + bagWeightKg: number = BAG_WEIGHT_KG ): number => { if ( !Number.isFinite(deltaSeconds) || - !Number.isFinite(productionSpeed) || - !Number.isFinite(gameSpeed) || - !Number.isFinite(runningPackerCount) || - !Number.isFinite(healthFactor) + !Number.isFinite(packerFlowKgPerSecond) || + !Number.isFinite(bagWeightKg) || + bagWeightKg <= 0 ) { return 0; } - return ( - BAGS_PER_SECOND_BASE * - Math.max(0, deltaSeconds) * - Math.max(0, productionSpeed) * - (Math.max(0, gameSpeed) / 60) * - (Math.max(0, runningPackerCount) / NOMINAL_PACKER_COUNT) * - Math.max(0, Math.min(1, healthFactor)) - ); + return (Math.max(0, deltaSeconds) * Math.max(0, packerFlowKgPerSecond)) / bagWeightKg; }; +export const calculatePackerThroughputBagsPerHour = ( + packerFlowKgPerSecond: number, + bagWeightKg: number = BAG_WEIGHT_KG +): number => + Number.isFinite(packerFlowKgPerSecond) && Number.isFinite(bagWeightKg) && bagWeightKg > 0 + ? Math.round((Math.max(0, packerFlowKgPerSecond) / bagWeightKg) * 3600) + : 0; + function sumMaterialInventory(flow: MaterialFlowState, materialType: MaterialType): number { let total = 0; flow.machineBuffers.forEach((buffer) => { @@ -490,12 +485,10 @@ function unifiedGameTick(ctx: TickContext): void { // 3. Count running packers for throughput calculation let runningPackerCount = 0; - let runningPackerEfficiencySum = 0; for (let i = 0; i < machines.length; i++) { const m = machines[i]; if (m.type === 'PACKER' && (m.status === 'running' || m.status === 'warning')) { runningPackerCount++; - runningPackerEfficiencySum += m.metrics.efficiency ?? 100; } } @@ -546,22 +539,12 @@ function unifiedGameTick(ctx: TickContext): void { ) / 10 : 100; - // Throughput: actual production rate in bags per game-hour - // Based on App.tsx production formula: 12 bags/sec base × productionSpeed × gameSpeedFactor × packerScale - // Converted to bags per game-hour for display - const gameSpeedFactor = safeGameSpeed / 60; - const packerScale = runningPackerCount / 3; // 3 packers at full capacity - - // Production per real second - const bagsPerRealSecond = - BAGS_PER_SECOND_BASE * effectiveProductionSpeed * gameSpeedFactor * packerScale; - - // Convert to bags per game-hour: realSeconds per gameHour = 3600 / safeGameSpeed - // Guard against division by zero (paused state handled above, but be safe) - const realSecondsPerGameHour = safeGameSpeed > 0 ? 3600 / safeGameSpeed : 0; - const bagsPerGameHour = bagsPerRealSecond * realSecondsPerGameHour; - - _metricsUpdate.throughput = Math.round(bagsPerGameHour); + // The operator KPI follows the same final-stage mass flow used by SCADA. + // This value is from the preceding material tick, so it is one 500 ms sample + // behind the scene while remaining stable and free of an extra store write. + _metricsUpdate.throughput = calculatePackerThroughputBagsPerHour( + useMaterialFlowStore.getState().currentPackerFlowRate + ); // 5. Update store - machines only if changed, metrics always if (anyMachineChanged) { @@ -659,27 +642,14 @@ function unifiedGameTick(ctx: TickContext): void { : undefined ); - // 4c. Count completed bags on the same central 500 ms cadence as the - // material network. The former five-second interval produced visually large - // jumps and created a second simulation clock. Fractional carry keeps this - // smooth without losing production to integer rounding. + // 4c. Convert the exact final-stage mass flow into completed 25 kg bags on + // the same central cadence. Fractional carry preserves mass across ticks. + // The HUD, SCADA packer-flow tag, finished inventory and bag counter now all + // describe the same packer output rather than parallel rate estimates. if (runningPackerCount > 0) { const liveFlow = useMaterialFlowStore.getState(); const flowRate = liveFlow.currentPackerFlowRate; - const flowSimulationLive = - Number.isFinite(flowRate) && (flowRate > 0 || liveFlow.totalMaterialProcessed > 0); - const fallbackEfficiency = runningPackerEfficiencySum / Math.max(1, runningPackerCount * 100); - const healthFactor = flowSimulationLive - ? Math.max(0, Math.min(1, flowRate / (NOMINAL_PACKER_KG_PER_SECOND * runningPackerCount))) - : Math.max(0, Math.min(1, fallbackEfficiency)); - - _bagProductionCarry += calculateBagsProducedForTick( - deltaSeconds, - effectiveProductionSpeed, - safeGameSpeed, - runningPackerCount, - healthFactor - ); + _bagProductionCarry += calculateBagsProducedForTick(deltaSeconds, flowRate); const completedBags = Math.floor(_bagProductionCarry); if (completedBags > 0) { _bagProductionCarry -= completedBags; diff --git a/src/systems/__tests__/UnifiedGameTick.production.test.ts b/src/systems/__tests__/UnifiedGameTick.production.test.ts index 7a6fbd2..1c85398 100644 --- a/src/systems/__tests__/UnifiedGameTick.production.test.ts +++ b/src/systems/__tests__/UnifiedGameTick.production.test.ts @@ -1,20 +1,24 @@ import { describe, expect, it } from 'vitest'; -import { calculateBagsProducedForTick } from '../UnifiedGameTick'; +import { + calculateBagsProducedForTick, + calculatePackerThroughputBagsPerHour, +} from '../UnifiedGameTick'; describe('UnifiedGameTick bag cadence', () => { - it('emits small half-second increments at the default simulation speed', () => { - const bags = calculateBagsProducedForTick(0.5, 0.8, 180, 3, 1); - expect(bags).toBeCloseTo(14.4); - expect(bags).toBeLessThan(20); + it('converts the exact packer mass flow into small half-second increments', () => { + const bags = calculateBagsProducedForTick(0.5, 60); + expect(bags).toBeCloseTo(1.2); + expect(bags).toBeLessThan(2); }); - it('tracks packer availability and flow health', () => { - expect(calculateBagsProducedForTick(0.5, 0.8, 180, 1, 0.5)).toBeCloseTo(2.4); - expect(calculateBagsProducedForTick(0.5, 0.8, 180, 0, 1)).toBe(0); + it('keeps the headline throughput on the same mass-flow authority', () => { + expect(calculatePackerThroughputBagsPerHour(60)).toBe(8640); + expect(calculateBagsProducedForTick(0.5, 0)).toBe(0); }); it('rejects invalid values instead of contaminating the counter', () => { - expect(calculateBagsProducedForTick(Number.NaN, 1, 180, 3, 1)).toBe(0); - expect(calculateBagsProducedForTick(0.5, 1, 180, 3, Number.POSITIVE_INFINITY)).toBe(0); + expect(calculateBagsProducedForTick(Number.NaN, 60)).toBe(0); + expect(calculateBagsProducedForTick(0.5, 60, Number.POSITIVE_INFINITY)).toBe(0); + expect(calculatePackerThroughputBagsPerHour(60, 0)).toBe(0); }); });