import { describe, expect, it } from 'vitest'; import * as THREE from 'three'; import { SceneResourceBag } from '@/scene/resources'; import { createYard, railYaw } from '@/scene/world/yard'; import { STACK_TIERS, STACK_ZS, YARD_STOPS, yardStopsPassed } from '@/journey/yard-progress'; interface Node { name: string; count?: number; isInstancedMesh?: boolean; geometry?: Disposable; material?: Disposable; } interface Disposable { addEventListener(type: string, listener: () => void): void; } const nodes = (group: unknown): Node[] => { const found: Node[] = []; (group as { traverse(visit: (child: Node) => void): void }).traverse((child) => found.push(child)); return found; }; const instancedMeshes = (group: unknown) => nodes(group).filter((child) => child.isInstancedMesh === true); describe('night freight yard', () => { it('draws the stacks as one InstancedMesh of 3 + 3 + 2 + 1 containers', () => { const yard = createYard(new SceneResourceBag(), true); const stacks = instancedMeshes(yard.group).filter((mesh) => mesh.name === 'yard-stacks'); expect(stacks).toHaveLength(1); expect(stacks[0]!.count).toBe(STACK_TIERS.reduce((sum, tiers) => sum + tiers, 0)); }); it('is visible only in Additional work, and fades at the chapter edges', () => { const yard = createYard(new SceneResourceBag(), true); yard.update('toolkit', 0.5, 0, 1 / 60); expect(yard.group.visible).toBe(false); yard.update('additional-work', null, 0, 1 / 60); expect(yard.group.visible).toBe(false); yard.update('additional-work', 0, 0, 1 / 60); expect(yard.group.visible).toBe(false); yard.update('additional-work', 0.5, 0, 1 / 60); expect(yard.group.visible).toBe(true); expect(yard.inspect().presence).toBe(1); }); it('lights one plate per stop passed, forward and back', () => { const yard = createYard(new SceneResourceBag(), true); const ps = Array.from({ length: 101 }, (_, i) => i / 100); for (const p of [...ps, ...[...ps].reverse()]) { yard.update('additional-work', p, 0, 1 / 60); expect(yard.inspect().litPlates).toBe(yardStopsPassed(p)); } }); it('lowers the spreader and strikes the work lamps at a stop, lifts and dims them after', () => { const yard = createYard(new SceneResourceBag(), true); for (let frame = 0; frame < 60; frame += 1) yard.update('additional-work', YARD_STOPS[1], 0, 1 / 60); const working = yard.inspect(); expect(working.spreader).toBe(1); expect(working.craneZ).toBe(STACK_ZS[1]); expect(working.lamp).toBe(1); expect(working.strikes).toBe(1); expect(working.flood).toBeCloseTo(1 / 1.5, 2); for (let frame = 0; frame < 60; frame += 1) yard.update('additional-work', 0.2, 0, 1 / 60); expect(yard.inspect().spreader).toBe(0); expect(yard.inspect().lamp).toBe(0); }); it('hides the crane (not the yard) while an underpass deck is near it', () => { const yard = createYard(new SceneResourceBag(), true); yard.update('additional-work', 0.9, 0, 1 / 60, null); expect(yard.inspect().cranePresence).toBe(1); const z = yard.inspect().craneWorldZ; yard.update('additional-work', 0.9, 0, 1 / 60, z + 4); expect(yard.inspect().cranePresence).toBe(0); expect(yard.inspect().presence).toBe(1); }); it('lite: crane, stacks and plates; no lamps, floods or pools', () => { const yard = createYard(new SceneResourceBag(), false); expect(instancedMeshes(yard.group)).toHaveLength(1); for (let frame = 0; frame < 120; frame += 1) yard.update('additional-work', YARD_STOPS[0], 1, 1 / 60); expect(yard.inspect()).toMatchObject({ litPlates: 1, lamp: 0, flood: 0, spreader: 1 }); }); it('turns the rails to the viewport aspect so the yard keeps its place in the left margin', () => { const yard = createYard(new SceneResourceBag(), true); const yawAt = (aspect: number) => { yard.update('additional-work', 0.5, 0, 1 / 60, null, aspect); return (yard.group as { rotation: { y: number } }).rotation.y; }; const wide = yawAt(1920 / 1080); const design = yawAt(1440 / 900); const laptop = yawAt(1024 / 700); expect(wide).toBeGreaterThan(design); expect(design).toBeGreaterThan(laptop); expect(design).toBeCloseTo(railYaw(1.6), 6); expect(yawAt(390 / 844)).toBeGreaterThan(0); expect(yawAt(Number.NaN)).toBeCloseTo(railYaw(), 6); }); it('aims the rails from the live lens, so the yard holds its screen x while the camera eases', () => { const yard = createYard(new SceneResourceBag(), true); const group = yard.group as unknown as InstanceType; const camera = new THREE.PerspectiveCamera(61, 1440 / 900, 0.1, 2000); const railScreenX = (s: number) => { group.updateMatrixWorld(true); return new THREE.Vector3(0, 5, s).applyMatrix4(group.matrixWorld).project(camera).x; }; // Mid-ease lenses: position, aim, fov and view offset all off their settled values. const lenses = [ { x: 6, z: 40, fov: 48, shift: 0.1 }, { x: 0, z: 30, fov: 55, shift: -0.02 }, { x: -3.2, z: 24, fov: 61, shift: -0.12 }, ]; for (const lens of lenses) { camera.fov = lens.fov; camera.setViewOffset(1440, 900, -1440 * lens.shift, 0, 1440, 900); camera.position.set(lens.x, 30, lens.z); camera.lookAt(0, 18, -220); yard.update('additional-work', 0.5, 0, 1 / 60, null, camera.aspect, camera); for (const s of [-150, -110, -70]) expect(railScreenX(s)).toBeCloseTo(-0.86, 1); } }); it('keeps every container square to the road at any rail yaw', () => { const yard = createYard(new SceneResourceBag(), true); const stacks = instancedMeshes(yard.group).find((mesh) => mesh.name === 'yard-stacks') as unknown as InstanceType; const group = yard.group as unknown as InstanceType; const local = new THREE.Matrix4(); const world = new THREE.Matrix4(); const axis = new THREE.Vector3(); for (const aspect of [1920 / 1080, 1440 / 900, 1024 / 700]) { yard.update('additional-work', 0.5, 0, 1 / 60, null, aspect); group.updateMatrixWorld(true); for (let i = 0; i < stacks.count; i += 1) { stacks.getMatrixAt(i, local); world.multiplyMatrices(group.matrixWorld, local); // The box's long axis (local z) runs down the road: no more than the // bottom tier's hair of slop (0.75 degrees) off world z. axis.set(0, 0, 1).transformDirection(world); expect(Math.abs(Math.atan2(axis.x, axis.z))).toBeLessThan(0.013); } } }); it('frees every geometry and material on dispose', () => { const resources = new SceneResourceBag(); const yard = createYard(resources, true); const owned = new Set(); for (const child of nodes(yard.group)) { if (child.geometry) owned.add(child.geometry); if (child.material) owned.add(child.material); } expect(owned.size).toBeGreaterThan(6); let disposed = 0; owned.forEach((resource) => resource.addEventListener('dispose', () => { disposed += 1; })); resources.dispose(); expect(disposed).toBe(owned.size); }); });