import { describe, expect, it } from 'vitest'; import type { EnvironmentChapter } from '@/journey/types'; import { BRAKE_GLOBAL_PER_SECOND, BRAKE_MIN_ON_SECONDS, BRAKE_MIN_SPACING_SECONDS, CENTRE_DEADLINE_Z, CENTRE_ENTRY_Z, FADE_IN_SECONDS, FORCED_LANE_CHANGE_SECONDS, HERO_HALF_WIDTH, HERO_NOSE_Z, HERO_TAIL_Z, INDICATOR_DUTY, INDICATOR_HZ, TRAFFIC_ASPHALT_HALF_WIDTH, TRAFFIC_BEHIND, TRAFFIC_CONVOY_GAP_MAX, TRAFFIC_LITE_CONVOY_NEAR_Z, TRAFFIC_LITE_DROUGHT_SECONDS, TRAFFIC_LITE_STAGE_SECONDS, TRAFFIC_DENSITY, TRAFFIC_ENTRY_FAR_Z, TRAFFIC_ENTRY_NEAR_Z, TRAFFIC_ENTRY_NEAR_SPAN, TRAFFIC_EXIT_NOSE_Z, TRAFFIC_KINDS, TRAFFIC_LANE_X, TRAFFIC_NEAR_CAMERA_SIDE, TRAFFIC_NEAR_OPEN, TRAFFIC_PENDING, TRAFFIC_TRUCK, TRAFFIC_VISIBLE, createFlashDetector, createTrafficModel, indicatorBlink, type TrafficConfig, type TrafficFrame, type TrafficInput, } from '@/scene/world/traffic-model'; const CRUISE = 18 + 0.18 * 54; const CHAPTERS: EnvironmentChapter[] = ['deep-night', 'systems', 'infrastructure', 'builds', 'pre-dawn', 'destination']; const input = (patch: Partial = {}): TrafficInput => ({ dt: 1 / 60, heroSpeed: CRUISE, chapter: 'deep-night', sweeping: false, flash: false, cameraX: -5.6, engineOff: 0, ...patch, }); const drawn = (f: TrafficFrame, i: number): boolean => f.active[i] !== TRAFFIC_PENDING && f.active[i] !== TRAFFIC_BEHIND; const size = (f: TrafficFrame, i: number) => TRAFFIC_KINDS[f.kind[i]!]!; /** Invariants 1-4 and the lamp limits for one frame; returns a message or null. */ function violation(f: TrafficFrame): string | null { let corridor = 0; let indicators = 0; for (let i = 0; i < f.count; i += 1) { if (f.active[i] === TRAFFIC_PENDING) continue; const { halfWidth, length } = size(f, i); const x = f.x[i]!; const z = f.z[i]!; if (!Number.isFinite(x) || !Number.isFinite(z)) return `vehicle ${i} non-finite`; if (Math.abs(x) + halfWidth > TRAFFIC_ASPHALT_HALF_WIDTH) return `vehicle ${i} off the asphalt at x ${x}`; const inCorridor = Math.abs(x) < HERO_HALF_WIDTH + halfWidth; if (inCorridor && z > HERO_NOSE_Z && z - length < HERO_TAIL_Z) return `vehicle ${i} overlaps the hero at x ${x} z ${z}`; if (inCorridor && f.active[i] !== TRAFFIC_BEHIND) corridor += 1; if (f.indicator[i] !== 0) indicators += 1; for (let j = i + 1; j < f.count; j += 1) { if (f.active[j] === TRAFFIC_PENDING) continue; const other = size(f, j); const lateral = Math.abs(x - f.x[j]!) < halfWidth + other.halfWidth; const along = z > f.z[j]! - other.length && f.z[j]! > z - length; if (lateral && along) return `vehicles ${i} and ${j} overlap (x ${x}/${f.x[j]} z ${z}/${f.z[j]})`; } } if (corridor > 1) return `${corridor} vehicles in the centre lane`; if (indicators > 2) return `${indicators} indicators at once`; return null; } /** A small deterministic generator for the drive scripts. */ function rng(seed: number) { let s = seed % 2147483647 || 1; return () => { s = (s * 48271) % 2147483647; return (s - 1) / 2147483646; }; } describe('traffic model: long randomised drives', () => { it('never overlaps, stays on the asphalt and keeps the lamp limits (20 seeds x 10 min)', () => { for (let seed = 1; seed <= 20; seed += 1) { const pool = ([12, 10, 6] as const)[seed % 3]!; const model = createTrafficModel({ pool, seed: seed * 7919, lite: pool === 6 }); const random = rng(seed * 104729); const step = input(); let time = 0; let hero = CRUISE; let target = CRUISE; let nextSpeed = 0; let nextChapter = 20; let nextFlash = 3; let sweepUntil = -1; const onsetsAt: number[] = []; const lastOnset = new Float64Array(pool).fill(Number.NEGATIVE_INFINITY); const brakeWas = new Uint8Array(pool); const brakeOnAt = new Float64Array(pool); const lastZ = new Float64Array(pool); let previousOnsets = 0; const end = 600; while (time < end) { const dt = random() < 0.01 ? 0.1 : 1 / 60; time += dt; if (time >= nextSpeed) { const roll = random(); target = roll < 0.35 ? CRUISE : roll < 0.6 ? 120 : 18 + random() * 102; if (random() < 0.4) hero = target; // instant steps nextSpeed = time + 0.3 + random() * 3.7; } hero += (target - hero) * Math.min(1, dt * 3); if (time >= nextChapter) { step.chapter = CHAPTERS[Math.floor(random() * CHAPTERS.length)]!; // A chapter entry: lite stages a pass (the invariants hold through it). step.cue = true; nextChapter = time + 20 + random() * 40; if (random() < 0.4) sweepUntil = time + 0.5 + random(); } step.flash = time >= nextFlash; if (step.flash) nextFlash = time + 0.6 + random() * 5; step.sweeping = time < sweepUntil; step.engineOff = time > end - 30 ? Math.min(1, (time - (end - 30)) / 10) : 0; if (step.engineOff > 0) step.chapter = 'destination'; step.heroSpeed = hero * (1 - step.engineOff); step.cameraX = Math.sin(time * 0.05) * 5.6; step.dt = dt; const f = model.step(step); step.cue = false; const problem = violation(f); if (problem) throw new Error(`seed ${seed} t ${time.toFixed(2)}: ${problem}`); const events = model.events(); if (events.brakeOnsets > previousOnsets) { for (let k = previousOnsets; k < events.brakeOnsets; k += 1) onsetsAt.push(time); previousOnsets = events.brakeOnsets; } for (let i = 0; i < pool; i += 1) { const on = f.brake[i]! > 0 ? 1 : 0; const respawned = Math.abs(f.z[i]! - lastZ[i]!) > 20; lastZ[i] = f.z[i]!; if (on === 1 && brakeWas[i] === 0 && !respawned) { expect(time - lastOnset[i]!).toBeGreaterThanOrEqual(BRAKE_MIN_SPACING_SECONDS - 1e-6); lastOnset[i] = time; brakeOnAt[i] = time; } if (on === 0 && brakeWas[i] === 1 && f.active[i] !== TRAFFIC_PENDING && !respawned) { // The lamp has ramped down to zero: it was held on for the minimum first. expect(time - brakeOnAt[i]!).toBeGreaterThanOrEqual(BRAKE_MIN_ON_SECONDS); } brakeWas[i] = on; } } for (let k = BRAKE_GLOBAL_PER_SECOND; k < onsetsAt.length; k += 1) { expect(onsetsAt[k]! - onsetsAt[k - BRAKE_GLOBAL_PER_SECOND]!).toBeGreaterThanOrEqual(1 - 1e-6); } const events = model.events(); expect(events.laneChanges, `seed ${seed} lane changes`).toBeGreaterThan(0); expect(events.passes, `seed ${seed} passes`).toBeGreaterThan(0); expect(events.brakeOnsets, `seed ${seed} brake onsets`).toBeGreaterThan(0); expect(events.emergencyExits, `seed ${seed} emergency exits`).toBeLessThanOrEqual(1); } }); }); describe('traffic model: adversarial', () => { /** Drive the model until a car is in the centre lane, then return it. */ function withCentreCar(config: TrafficConfig) { const model = createTrafficModel(config); const step = input({ chapter: 'infrastructure' }); for (let t = 0; t < 400; t += 1 / 60) { const f = model.step(step); for (let i = 0; i < f.count; i += 1) { if (drawn(f, i) && Math.abs(f.x[i]!) < 0.5) return { model, step, index: i }; } } throw new Error('no overtake in 400 s'); } it('a right-lane car pulls into the centre to pass a truck at idle', () => { const { model, index } = withCentreCar({ pool: 12, seed: 3, lite: false }); expect(model.frame.kind[index]).toBe(0); expect(model.frame.z[index]!).toBeLessThan(-150); }); it('an instant step to 120 u/s with a car in the centre still never overlaps', () => { for (const seed of [3, 11, 29, 41]) { const { model, step, index } = withCentreCar({ pool: 12, seed, lite: false }); step.heroSpeed = 120; for (let t = 0; t < 12; t += 1 / 60) { const f = model.step(step); expect(violation(f)).toBeNull(); } expect(Math.abs(model.frame.x[index]!) > 4 || model.frame.active[index] !== TRAFFIC_VISIBLE).toBe(true); } }); it('0.1 s frames at 120 u/s keep every invariant', () => { const model = createTrafficModel({ pool: 12, seed: 5, lite: false }); const step = input({ chapter: 'infrastructure', dt: 0.1, heroSpeed: 120 }); for (let k = 0; k < 3000; k += 1) { step.heroSpeed = k % 40 < 20 ? 120 : CRUISE; expect(violation(model.step(step))).toBeNull(); } }); it('clamps dt and ignores non-finite input', () => { const model = createTrafficModel({ pool: 10, seed: 9, lite: false }); model.step(input()); const before = Array.from(model.frame.z); model.step(input({ dt: Number.NaN, heroSpeed: Number.NaN, cameraX: Number.POSITIVE_INFINITY, engineOff: Number.NaN })); expect(Array.from(model.frame.z)).toEqual(before); model.step(input({ dt: 5 })); for (let i = 0; i < model.frame.count; i += 1) { if (model.frame.active[i] === TRAFFIC_PENDING) continue; // At most one 0.1 s step of relative motion (40 u/s fast lane - 27.7 + jitter). expect(Math.abs(model.frame.z[i]! - before[i]!)).toBeLessThan(4); } expect(violation(model.frame)).toBeNull(); }); }); describe('traffic model: determinism and allocation', () => { it('the same seed and inputs give the same frames', () => { const run = () => { const model = createTrafficModel({ pool: 12, seed: 77, lite: false }); const out: number[] = []; for (let k = 0; k < 3600; k += 1) { const f = model.step(input({ heroSpeed: k % 600 < 300 ? CRUISE : 90, chapter: k < 1800 ? 'systems' : 'infrastructure' })); if (k % 60 === 0) out.push(...f.z, ...f.x, ...f.active); } return out; }; expect(run()).toEqual(run()); }); it('returns the same preallocated frame every step', () => { const model = createTrafficModel({ pool: 6, seed: 2, lite: true }); const first = model.step(input()); const z = first.z; for (let k = 0; k < 600; k += 1) expect(model.step(input())).toBe(first); expect(first.z).toBe(z); expect(model.frame).toBe(first); }); it('freezes its tables', () => { expect(Object.isFrozen(TRAFFIC_LANE_X)).toBe(true); expect(Object.isFrozen(TRAFFIC_KINDS)).toBe(true); expect(Object.isFrozen(TRAFFIC_DENSITY)).toBe(true); expect(Object.isFrozen(TRAFFIC_DENSITY.infrastructure)).toBe(true); }); it('reset replays the run', () => { const model = createTrafficModel({ pool: 10, seed: 13, lite: false }); const take = () => { for (let k = 0; k < 1200; k += 1) model.step(input({ chapter: 'builds' })); return Array.from(model.frame.z); }; const a = take(); model.reset(); expect(take()).toEqual(a); }); }); describe('traffic model: density', () => { const visible = (f: TrafficFrame, side: number, truck?: boolean): number => { let count = 0; for (let i = 0; i < f.count; i += 1) { if (f.active[i] !== TRAFFIC_VISIBLE) continue; if (Math.sign(f.x[i]!) !== side) continue; if (truck !== undefined && (f.kind[i] === TRAFFIC_TRUCK) !== truck) continue; count += 1; } return count; }; it('converges to each chapter target within 30 s at cruise', () => { for (const chapter of CHAPTERS) { const model = createTrafficModel({ pool: 12, seed: 21, lite: false }); let f = model.frame; for (let t = 0; t < 30; t += 1 / 60) f = model.step(input({ chapter })); const want = TRAFFIC_DENSITY[chapter]; // Vehicles mid-pass on the hero's side may sit in the centre, so allow one short. expect(visible(f, -1), `${chapter} left`).toBeGreaterThanOrEqual(want.left - 1); expect(visible(f, -1), `${chapter} left`).toBeLessThanOrEqual(want.left + 1); expect(visible(f, 1) + visible(f, 0), `${chapter} right`).toBeGreaterThanOrEqual(want.right - 1); } }); it('never despawns a drawn vehicle on screen when the chapter thins out', () => { const model = createTrafficModel({ pool: 12, seed: 4, lite: false }); for (let t = 0; t < 30; t += 1 / 60) model.step(input({ chapter: 'infrastructure' })); for (let t = 0; t < 20; t += 1 / 60) { const before = Array.from(model.frame.active); const z = Array.from(model.frame.z); const f = model.step(input({ chapter: 'destination' })); for (let i = 0; i < f.count; i += 1) { if (before[i] === TRAFFIC_VISIBLE && f.active[i] === TRAFFIC_PENDING) { // Leaving is only allowed at the far haze or behind the camera. expect(z[i]! < -600 || z[i]! - size(f, i).length > TRAFFIC_EXIT_NOSE_Z - 1).toBe(true); } } } }); it('empties the road once the engine is off', () => { const model = createTrafficModel({ pool: 12, seed: 8, lite: false }); for (let t = 0; t < 120; t += 1 / 60) model.step(input({ chapter: 'destination', engineOff: 1, heroSpeed: 0 })); // Nothing new enters; what is left drives off. const events = model.events(); let drawnNow = 0; for (let i = 0; i < model.frame.count; i += 1) if (drawn(model.frame, i)) drawnNow += 1; expect(drawnNow).toBeLessThanOrEqual(2); expect(events.laneChanges).toBe(0); }); it('the low tier caps trucks and lanes', () => { const model = createTrafficModel({ pool: 6, seed: 6, lite: true }); let maxTrucks = 0; let maxRight = 0; for (let t = 0; t < 300; t += 1 / 60) { const f = model.step(input({ chapter: 'infrastructure', heroSpeed: t % 40 < 10 ? 90 : CRUISE })); maxTrucks = Math.max(maxTrucks, visible(f, 1, true) + visible(f, -1, true)); maxRight = Math.max(maxRight, visible(f, 1)); } expect(maxTrucks).toBeLessThanOrEqual(2); expect(maxRight).toBeLessThanOrEqual(5); }); }); describe('traffic model: interaction', () => { it('a flash sends a centre-lane car back to its lane', () => { for (const seed of [3, 11, 29]) { const model = createTrafficModel({ pool: 12, seed, lite: false }); const step = input({ chapter: 'infrastructure' }); let index = -1; for (let t = 0; t < 400 && index < 0; t += 1 / 60) { const f = model.step(step); for (let i = 0; i < f.count; i += 1) if (drawn(f, i) && Math.abs(f.x[i]!) < 0.5 && f.z[i]! > -320) index = i; } if (index < 0) continue; model.step(input({ chapter: 'infrastructure', flash: true })); for (let t = 0; t < 2.5; t += 1 / 60) model.step(step); expect(Math.abs(model.frame.x[index]!), `seed ${seed}`).toBeGreaterThan(4); return; } throw new Error('no centre-lane car within flash reach'); }); it('sweeping starts no manoeuvre', () => { const model = createTrafficModel({ pool: 12, seed: 3, lite: false }); for (let t = 0; t < 300; t += 1 / 60) model.step(input({ chapter: 'infrastructure', sweeping: true })); expect(model.events().laneChanges).toBe(0); }); it('nothing in the centre lane survives past the deadline', () => { const model = createTrafficModel({ pool: 12, seed: 17, lite: false }); for (let t = 0; t < 600; t += 1 / 60) { const f = model.step(input({ chapter: 'infrastructure', heroSpeed: Math.floor(t / 7) % 2 ? 110 : CRUISE })); for (let i = 0; i < f.count; i += 1) { if (f.active[i] !== TRAFFIC_VISIBLE) continue; if (Math.abs(f.x[i]!) < HERO_HALF_WIDTH + size(f, i).halfWidth) expect(f.z[i]!).toBeLessThan(CENTRE_DEADLINE_Z + 160); } } }); }); describe('traffic model: idle choreography', () => { it('a left-lane car passes the reader within 60 s and a right-lane truck is passed', () => { const model = createTrafficModel({ pool: 12, seed: 1, lite: false }); const mid = new Float64Array(12).fill(Number.NaN); let leftPass = false; let truckPassed = false; for (let t = 0; t < 90; t += 1 / 60) { const f = model.step(input({ chapter: 'deep-night' })); for (let i = 0; i < f.count; i += 1) { if (f.active[i] === TRAFFIC_PENDING) { mid[i] = Number.NaN; continue; } const now = f.z[i]! - size(f, i).length / 2; const was = mid[i]!; if (Number.isFinite(was)) { if (t < 60 && f.x[i]! < -3 && was > 2 && now <= 2) leftPass = true; if (f.kind[i] === TRAFFIC_TRUCK && f.x[i]! > 3 && was < 2 && now >= 2) truckPassed = true; } mid[i] = now; } } expect(leftPass).toBe(true); expect(truckPassed).toBe(true); }); }); describe('traffic model: lamps', () => { it('indicators blink at 1.5 Hz with a 40 % duty', () => { const period = 1 / INDICATOR_HZ; let on = 0; let rises = 0; let was = 0; const samples = 6000; for (let k = 0; k < samples; k += 1) { const level = indicatorBlink((k / samples) * period * 4); if (level > 0.5) on += 1; if (level > 0.5 && was <= 0.5) rises += 1; was = level; } expect(on / samples).toBeCloseTo(INDICATOR_DUTY, 1); expect(rises).toBe(4); expect(indicatorBlink(0.02)).toBeGreaterThan(0); }); it('the high-mount stop lamp is dark at steady cruise', () => { const model = createTrafficModel({ pool: 12, seed: 31, lite: false }); for (let t = 0; t < 30; t += 1 / 60) model.step(input({ chapter: 'deep-night' })); let lit = 0; for (let i = 0; i < model.frame.count; i += 1) if (drawn(model.frame, i) && model.frame.brake[i]! > 0.5) lit += 1; expect(lit).toBeLessThanOrEqual(1); }); it('a queue behind a truck lights brake lamps, with hysteresis', () => { const model = createTrafficModel({ pool: 12, seed: 2, lite: false }); for (let t = 0; t < 120; t += 1 / 60) model.step(input({ chapter: 'infrastructure' })); expect(model.events().brakeOnsets).toBeGreaterThan(0); // Brake levels stay in range. for (let i = 0; i < model.frame.count; i += 1) { expect(model.frame.brake[i]!).toBeGreaterThanOrEqual(0); expect(model.frame.brake[i]!).toBeLessThanOrEqual(1); } }); }); describe('traffic model: near limit', () => { it('latches a camera-side truck to the near band and leaves cars open', () => { const model = createTrafficModel({ pool: 12, seed: 12, lite: false }); // Camera over the right lane: right-lane trucks are camera side. let latched = false; for (let t = 0; t < 200 && !latched; t += 1 / 60) { const f = model.step(input({ chapter: 'infrastructure', cameraX: 4.5 })); for (let i = 0; i < f.count; i += 1) { if (!drawn(f, i)) continue; if (f.kind[i] !== TRAFFIC_TRUCK) expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_OPEN); else if (f.x[i]! > 3 && f.z[i]! > -40) { expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_CAMERA_SIDE); latched = true; } } } expect(latched).toBe(true); }); it('a truck on the far side of the camera stays open', () => { const model = createTrafficModel({ pool: 12, seed: 12, lite: false }); for (let t = 0; t < 200; t += 1 / 60) { const f = model.step(input({ chapter: 'infrastructure', cameraX: -5.6 })); for (let i = 0; i < f.count; i += 1) { if (drawn(f, i) && f.x[i]! > 3) expect(f.nearLimit[i]).toBe(TRAFFIC_NEAR_OPEN); } } }); it('keeps every lane on the asphalt and clear of the hero', () => { for (const side of [0, 2]) { for (const kind of TRAFFIC_KINDS) { const x = TRAFFIC_LANE_X[side]!; expect(Math.abs(x) + kind.halfWidth + 0.1).toBeLessThanOrEqual(TRAFFIC_ASPHALT_HALF_WIDTH); expect(Math.abs(x) - kind.halfWidth - 0.1).toBeGreaterThanOrEqual(HERO_HALF_WIDTH); } } expect(HERO_TAIL_Z - HERO_NOSE_Z).toBe(16); }); }); describe('traffic model: the interaction is in frame', () => { /** Centre-lane entries and exits (rear z) of right-lane cars over a cruise. */ function passes(config: TrafficConfig, seconds: number) { const model = createTrafficModel(config); const step = input({ chapter: 'infrastructure' }); const prev = new Float64Array(config.pool); const entries: number[] = []; const exits: number[] = []; for (let t = 0; t < seconds; t += 1 / 60) { const f = model.step(step); for (let i = 0; i < f.count; i += 1) { const x = f.active[i] === TRAFFIC_VISIBLE ? f.x[i]! : 9; // A pull-out from the right lane (not a pooled slot reappearing in another lane). if (prev[i]! > 3 && prev[i]! !== 9 && x <= 3) entries.push(f.z[i]!); if (Math.abs(prev[i]!) <= 3 && Math.abs(x) > 3 && x !== 9) exits.push(f.z[i]!); prev[i] = x; } } return { entries, exits }; } it('overtakes start 150-215 m ahead and end before the deadline, on every tier', () => { const configs: TrafficConfig[] = [ { pool: 12, seed: 3, lite: false }, { pool: 12, seed: 9, lite: false }, { pool: 6, seed: 5, lite: true }, { pool: 6, seed: 8, lite: true }, ]; for (const config of configs) { const { entries, exits } = passes(config, 240); expect(entries.length, `seed ${config.seed} entries`).toBeGreaterThanOrEqual(2); for (const z of entries) { expect(z, `seed ${config.seed} entry`).toBeGreaterThan(-215); expect(z, `seed ${config.seed} entry`).toBeLessThan(CENTRE_ENTRY_Z + 20); } for (const z of exits) expect(z, `seed ${config.seed} exit`).toBeLessThan(CENTRE_DEADLINE_Z + 1); } }); it('a right car joining a truck in the haze fades in, in the haze, and the road refills from the near entry', () => { const model = createTrafficModel({ pool: 6, seed: 5, lite: true }); const step = input({ chapter: 'infrastructure' }); const seen = new Uint8Array(6); let faded = 0; const fading = new Float64Array(6).fill(-1); for (let t = 0; t < 400; t += 1 / 60) { const f = model.step(step); for (let i = 0; i < f.count; i += 1) { if (f.active[i] !== TRAFFIC_VISIBLE) { seen[i] = 0; continue; } if (!seen[i]) { seen[i] = 1; if (t === 0) continue; if (f.z[i]! > TRAFFIC_ENTRY_FAR_Z + 1 && f.z[i]! < TRAFFIC_EXIT_NOSE_Z - 1) { // Nearer than the far entry: screen-doored in, at the haze. Lite // queues its convoy car (and stages its truck) a little nearer: // the rear of a car behind a truck at the window's near edge. const liteNear = TRAFFIC_LITE_CONVOY_NEAR_Z + TRAFFIC_KINDS[0]!.length + TRAFFIC_CONVOY_GAP_MAX; expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(Math.max(TRAFFIC_ENTRY_NEAR_Z, liteNear) + 2); expect(f.z[i]!).toBeGreaterThan(TRAFFIC_ENTRY_NEAR_Z - TRAFFIC_ENTRY_NEAR_SPAN - 50); expect(f.dither[i]!).toBeGreaterThan(0.9); fading[i] = t; faded += 1; } else expect(f.dither[i]!).toBe(0); } else if (fading[i]! >= 0) { if (t - fading[i]! > FADE_IN_SECONDS + 0.05) { expect(f.dither[i]!).toBe(0); fading[i] = -1; } else expect(f.dither[i]!).toBeLessThanOrEqual(1); } } } expect(faded).toBeGreaterThan(2); }); it('a yield the reader forces is a swerve that clears the corridor in under a second', () => { for (const seed of [3, 11, 29]) { const model = createTrafficModel({ pool: 12, seed, lite: false }); const step = input({ chapter: 'infrastructure' }); let index = -1; for (let t = 0; t < 400 && index < 0; t += 1 / 60) { const f = model.step(step); for (let i = 0; i < f.count; i += 1) if (drawn(f, i) && Math.abs(f.x[i]!) < 0.3 && f.z[i]! > -200) index = i; } if (index < 0) continue; step.heroSpeed = 120; let clearAt = -1; for (let t = 0; t < 3 && clearAt < 0; t += 1 / 60) { const f = model.step(step); expect(violation(f)).toBeNull(); if (Math.abs(f.x[index]!) >= 4.47 || f.active[index] !== TRAFFIC_VISIBLE) clearAt = t; } expect(clearAt, `seed ${seed}`).toBeGreaterThanOrEqual(0); expect(clearAt, `seed ${seed}`).toBeLessThan(FORCED_LANE_CHANGE_SECONDS * 0.71 + 0.25); expect(model.events().emergencyExits).toBe(0); return; } throw new Error('no centre-lane car to force'); }); }); describe('flash detector', () => { it('ignores the intro sweep (a climb from dark) and fires on a flash from the dipped beam', () => { const detect = createFlashDetector(); const fired: number[] = []; // Sweep: 0 -> 1 over 96 frames, held at 1, then the fall to the dipped 0.72. for (let k = 0; k <= 96; k += 1) if (detect(k / 96)) fired.push(k); for (let k = 0; k < 5; k += 1) if (detect(1)) fired.push(100 + k); let i = 1; for (let k = 0; k < 120; k += 1) { i = 0.72 + (i - 0.72) * 0.9; if (detect(i)) fired.push(200 + k); } expect(fired).toEqual([]); // A flash: 0.72 -> 1 (rise), hold, fall. i = 0.72; for (let k = 0; k < 30; k += 1) { i = 1 + (i - 1) * 0.8; if (detect(i)) fired.push(300 + k); } for (let k = 0; k < 25; k += 1) if (detect(1)) fired.push(400 + k); expect(fired.length).toBe(1); // Nothing more until it has fallen back below 0.85. for (let k = 0; k < 20; k += 1) { i = 0.9 + (i - 0.9) * 0.8; if (detect(i)) fired.push(500 + k); } expect(fired.length).toBe(1); for (let k = 0; k < 60; k += 1) { i = 0.72 + (i - 0.72) * 0.8; if (detect(i)) fired.push(600 + k); } for (let k = 0; k < 30; k += 1) { i = 1 + (i - 1) * 0.8; if (detect(i)) fired.push(700 + k); } expect(fired.length).toBe(2); }); }); describe('traffic model: lite passes (phones)', () => { const LIVE: EnvironmentChapter[] = ['deep-night', 'systems', 'infrastructure', 'builds', 'pre-dawn']; it('stages a pass at each chapter entry: a car pulls out within 30 s, 20 s on average', () => { // A truck staged at the haze takes ~15 s to reach the 150-215 m entry band. const waits: number[] = []; for (const seed of [1, 5, 8, 13, 21]) { const model = createTrafficModel({ pool: 6, seed, lite: true }); const step = input(); for (let t = 0; t < 5; t += 1 / 60) model.step(step); for (let entry = 0; entry < 8; entry += 1) { step.chapter = LIVE[(entry + seed) % LIVE.length]!; step.cue = true; const before = model.events().laneChanges; let waited = 0; while (model.events().laneChanges === before && waited < 40) { const f = model.step(step); step.cue = false; waited += 1 / 60; const problem = violation(f); if (problem) throw new Error(`seed ${seed} entry ${entry}: ${problem}`); } expect(waited, `seed ${seed} entry ${entry} (${step.chapter})`).toBeLessThan(30); waits.push(waited); // Read the chapter for a while before the next entry. for (let t = 0; t < 12; t += 1 / 60) model.step(step); } } expect(waits.reduce((a, b) => a + b, 0) / waits.length).toBeLessThan(20); expect(TRAFFIC_LITE_STAGE_SECONDS).toBeGreaterThanOrEqual(15); }); it('never goes long without a pass at a steady cruise (the drought rule)', () => { for (const seed of [2, 5, 8, 17, 3, 11, 29, 42]) { const model = createTrafficModel({ pool: 6, seed, lite: true }); const step = input({ chapter: 'systems' }); let last = 0; let longest = 0; let changes = 0; for (let t = 0; t < 600; t += 1 / 60) { const f = model.step(step); const problem = violation(f); if (problem) throw new Error(`seed ${seed} t ${t.toFixed(1)}: ${problem}`); if (model.events().laneChanges !== changes) { changes = model.events().laneChanges; longest = Math.max(longest, t - last); last = t; } } longest = Math.max(longest, 600 - last); // Lane changes count the return too: drought, then ~15 s for a staged truck to reach the band, then the pass. // (Without staging this runs 82-88 s on these seeds.) expect(longest, `seed ${seed}`).toBeLessThan(55); } expect(TRAFFIC_LITE_DROUGHT_SECONDS).toBeLessThanOrEqual(15); }); it('staging stays inside the low-tier caps and never with the engine off', () => { const model = createTrafficModel({ pool: 6, seed: 6, lite: true }); const step = input({ chapter: 'infrastructure' }); let maxRight = 0; for (let t = 0; t < 300; t += 1 / 60) { step.cue = Math.floor(t * 60) % 600 === 0; const f = model.step(step); let right = 0; for (let i = 0; i < f.count; i += 1) if (f.active[i] === TRAFFIC_VISIBLE && f.x[i]! > 0) right += 1; maxRight = Math.max(maxRight, right); } expect(maxRight).toBeLessThanOrEqual(5); const parked = createTrafficModel({ pool: 6, seed: 6, lite: true }); for (let t = 0; t < 60; t += 1 / 60) parked.step(input({ chapter: 'destination', engineOff: 1, heroSpeed: 0, cue: t < 0.02 })); expect(parked.events().laneChanges).toBe(0); }); });