import test from 'node:test'; import assert from 'node:assert/strict'; import { generate, solve, trace, rotate, curve, center, directions, port } from '../labs/car-loop/model.js'; for (const kind of ['square', 'hex']) { test(`${kind}: all six authored samples/variants fully solve with actual curved travel`, () => { for (const [index, seed] of [71, 233, 907].entries()) for (const offset of [0, 37]) { const board = generate(kind, seed + offset, [10, 16, 22][index]); const solution = solve(board); assert.equal(board.cars.length, [10, 16, 22][index]); assert.equal(solution.solvable, true); assert.equal(solution.paths.length, board.cars.length); assert.ok(solution.paths.filter(p => p.bends >= 2).length >= board.cars.length / 2); assert.ok(Math.max(...solution.paths.map(p => p.bends)) >= 5); } }); test(`${kind}: full rotation preserves board and parked cars`, () => { const board = generate(kind, 233, 16); let state = board; for (let i = 0; i < directions(kind).length; i++) state = rotate(state); assert.deepEqual(state, board); assert.equal(rotate(board).cars, board.cars); }); test(`${kind}: neighboring road endpoints coincide`, () => { for (const [d, [x, y]] of directions(kind).entries()) { const n = directions(kind).length; const a = curve(kind, { x: 0, y: 0 }, (d + n / 2) % n, d).at(-1); const b = curve(kind, { x, y }, (d + n / 2) % n, d)[0]; assert.ok(Math.hypot(a.x - b.x, a.y - b.y) < 1e-10); } }); } test('square: crossing stays straight and collision stops before the parked car', () => { const cells = Array.from({ length: 3 }, (_, y) => ({ x: 0, y, tracks: [2, 3, 0, 1] })); const car = { id: 'a', x: 0, y: 2, dir: 0 }, blocker = { id: 'b', x: 0, y: 0, dir: 0 }; const board = { kind: 'square', cells, cars: [car, blocker] }; const blocked = trace(board, car); assert.equal(blocked.status, 'blocked'); assert.equal(blocked.blocker, 'b'); assert.ok(Math.abs(blocked.points.at(-1).y - .72) < 1e-6); const clear = trace(board, car, [car]); assert.equal(clear.status, 'exit'); assert.equal(clear.bends, 0); }); test('square: closed paired route is reported as a loop instead of hanging', () => { const cells = [ { x: 0, y: 0, tracks: [3, 2, 1, 0] }, { x: 1, y: 0, tracks: [1, 0, 3, 2] }, { x: 1, y: 1, tracks: [3, 2, 1, 0] }, { x: 0, y: 1, tracks: [1, 0, 3, 2] }, ]; const car = { id: 'a', x: 0, y: 0, dir: 1 }; assert.equal(trace({ kind: 'square', cells, cars: [car] }, car).status, 'loop'); }); import { createPerimeter } from '../labs/car-loop/perimeter.js'; for (const kind of ['square', 'hex']) { test(`${kind}: clockwise polygon lane closes, and every boundary exit joins it`, () => { const size = kind === 'square' ? 68 : 74; const lane = createPerimeter(kind, 384, 430, size), board = generate(kind, 233, 16); assert.deepEqual(lane.at(0), lane.at(Math.PI * 2)); let area = 0; for (let i = 1; i < lane.points.length; i++) area += lane.points[i-1].x * lane.points[i].y - lane.points[i].x * lane.points[i-1].y; assert.ok(area > 0, 'clockwise in screen coordinates'); const occupied = new Set(board.cells.map(c => `${c.x},${c.y}`)); for (const cell of board.cells) directions(kind).forEach(([dx,dy], d) => { if (occupied.has(`${cell.x+dx},${cell.y+dy}`)) return; const c = center(kind, cell.x, cell.y), a = -Math.PI/2 + d * Math.PI*2/directions(kind).length; const direction = {x:Math.cos(a),y:Math.sin(a)}; const origin = {x:384+(c.x+direction.x/2)*size,y:430+(c.y+direction.y/2)*size}; const hit = lane.intersect(origin,direction), p = lane.at(hit.parameter); assert.ok(hit.distance > 0 && hit.distance < 80, 'short perimeter ramp'); assert.ok(Math.hypot(p.x-origin.x-direction.x*hit.distance,p.y-origin.y-direction.y*hit.distance)<1e-6); }); }); } import { measure, at, advance, hasPriority, passageOrder } from '../labs/car-loop/motion.js'; test('cars launch concurrently on independent roads, without a departure queue', () => { const a = measure([{x:0,y:0},{x:400,y:0}]); const b = measure([{x:0,y:100},{x:400,y:100}]); assert.equal(advance(a,0,30,[at(b,0)],[],40),30); assert.equal(advance(b,0,30,[at(a,30)],[],40),30); }); test('fast movement stops at physical contact and resumes from that position when the blocker leaves', () => { const path = measure([{x:0,y:0},{x:400,y:0}]); const stopped = advance(path,0,300,[{x:100,y:0}],[],40); assert.ok(stopped > 0 && stopped <= 60 && stopped >= 58.5); assert.equal(advance(path,stopped,300,[{x:100,y:0}],[],40),stopped); assert.equal(advance(path,stopped,300,[],[],40),300); }); test('crossing vehicles wait at the junction, then both pass without clipping or deadlock', () => { const a = {car:{id:'a'},order:0,path:measure([{x:0,y:0},{x:300,y:0}]),boardLength:300,distance:0}; const b = {car:{id:'b'},order:1,path:measure([{x:150,y:-150},{x:150,y:150}]),boardLength:300,distance:0}; let waited = false; for(let frame=0;frame<500;frame++) for(const car of [a,b]) { const other = car === a ? b : a; const next = advance(car.path,car.distance,Math.min(car.path.length,car.distance+8),[at(other.path,other.distance)],hasPriority(other,car)?[other]:[],40); if(car===b && next===car.distance && next=40); } assert.ok(waited); assert.equal(a.distance,300); assert.equal(b.distance,300); }); test('enhanced square samples contain substantially longer actual multi-bend routes', () => { for(const [i,seed] of [59,107,107].entries()) { const board=generate('square',seed,[10,16,22][i]), solution=solve(board); assert.ok(solution.solvable); const mean=solution.paths.reduce((sum,p)=>sum+p.bends,0)/board.cars.length; assert.ok(mean>=7, `mean ${mean}`); assert.ok(Math.max(...solution.paths.map(p=>p.bends))>=12); } }); test('all 16 cars on the complex square board can drive simultaneously and clear', () => { const board=generate('square',107,16), order=passageOrder(board,board.cars,trace); let actors=board.cars.map(car=>{ const route=trace(board,car,[]), p=route.points.at(-1), a=-Math.PI/2+route.exitDir*Math.PI/2; const path=measure([...route.points,{x:p.x+Math.cos(a)*2,y:p.y+Math.sin(a)*2}]); return {car,order:order.get(car.id),path,boardLength:path.length,distance:0}; }); let finished=0; for(let frame=0;frame<12000 && actors.length;frame++) { for(const actor of actors) { const others=actors.filter(other=>other!==actor); actor.distance=advance(actor.path,actor.distance,Math.min(actor.path.length,actor.distance+.12),others.map(other=>at(other.path,other.distance)),others.filter(other=>hasPriority(other,actor)),.72); } const gone=actors.filter(actor=>actor.distance>=actor.path.length-1e-6); finished+=gone.length; actors=actors.filter(actor=>!gone.includes(actor)); } assert.equal(finished,16, `stuck: ${actors.map(a=>a.car.id+':'+a.distance).join(',')}`); }); import { generateSample } from '../labs/car-loop/model.js'; test('changing square layouts preserves the long-route selection floor', () => { for (const scenario of [0,1,2]) { const board = generateSample('square',scenario,1), solution = solve(board); assert.ok(solution.solvable); assert.ok(solution.paths.reduce((sum,p)=>sum+p.bends,0)/board.cars.length>=6); assert.ok(Math.max(...solution.paths.map(p=>p.bends))>=12); } }); test('desktop samples contain 96 cars and solve across every road orientation', () => { for (const kind of ['square', 'hex']) { const board = generateSample(kind, 3); assert.equal(board.cells.length, kind === 'square' ? 225 : 169); assert.equal(board.cars.length, 96); for (let turn = 0; turn < directions(kind).length; turn++) { const solution = solve(rotate(board, turn)); assert.equal(solution.solvable, true); assert.equal(solution.paths.length, 96); } const perimeter = createPerimeter(kind, 660, 690, kind === 'square' ? 68 : 74, 7); for (const car of board.cars) { const route = trace(board, car, []); if (route.status !== 'exit') continue; const end = route.points.at(-1), d = port(kind, route.exitDir), size = kind === 'square' ? 68 : 74; const hit = perimeter.intersect({x:660 + end.x * size, y:690 + end.y * size}, {x:d.x * 2, y:d.y * 2}); assert.ok(hit.distance >= 0); } } }); import { departure } from '../labs/car-loop/routes.js'; import { activeCorridors } from '../labs/car-loop/motion.js'; test('hex desktop red c88 exits without waiting for parked cars c28 and c84', () => { const board = generateSample('hex', 3), size = 74; const screen = p => ({ x: p.x * size, y: p.y * size }); const perimeter = createPerimeter('hex', 0, 0, size, 7); const order = passageOrder(board, board.cars, trace); const routes = board.cars.map(car => ({ car, ...departure(trace(board, car, []), 'hex', screen, perimeter), distance: 0, order: order.get(car.id), stage: 'parked', })); const red = routes.find(r => r.car.id === 'c88'); red.stage = 'board'; const parked = routes.filter(r => r !== red); const obstacles = parked.map(r => screen(center('hex', r.car.x, r.car.y))); const target = red.path.length - 34; // Reproduce the former phantom reservation within the first 9 pixels. const phantom = parked.find(r => r.car.id === 'c84'); assert.ok(advance(red.path, 0, target, obstacles, [phantom], size * .72 + 1) < 10); const corridors = activeCorridors(routes, red); assert.equal(corridors.length, 0); assert.equal(advance(red.path, 0, target, obstacles, corridors, size * .72 + 1), target); // Once the other car really launches, its conflict reservation is legitimate. phantom.stage = 'board'; assert.deepEqual(activeCorridors(routes, red), [phantom]); phantom.distance = phantom.boardLength; assert.equal(activeCorridors(routes, red).length, 0); }); test('all clear sample exits reach the actual perimeter merge with parked neighbors', () => { let checked = 0; for (const kind of ['hex', 'square']) for (const scenario of [0, 1, 2, 3]) { const sample = generateSample(kind, scenario), size = kind === 'hex' ? 74 : 68; const screen = p => ({ x: p.x * size, y: p.y * size }); const perimeter = createPerimeter(kind, 0, 0, size, scenario === 3 ? 7 : 3); for (let turn = 0; turn < directions(kind).length; turn++) { const board = rotate(sample, turn); for (const car of board.cars) { if (trace(board, car).status !== 'exit') continue; const route = departure(trace(board, car, []), kind, screen, perimeter); const obstacles = board.cars.filter(o => o !== car).map(o => screen(center(kind, o.x, o.y))); const target = route.path.length - 34; assert.equal(advance(route.path, 0, target, obstacles, [], size * .72 + 1), target, `${kind} sample ${scenario} turn ${turn} ${car.id}`); checked++; } } } assert.equal(checked, 206); }); import { resolveGridlock } from '../labs/car-loop/motion.js'; test('late arrivals already inside a crossing back off and both pass without collision', () => { for (const swapped of [false, true]) for (const rearBlocked of [false, true]) { const actors = [ { path: measure([{x:-120,y:0},{x:120,y:0}]), order: swapped ? 1 : 0 }, { path: measure([{x:0,y:-120},{x:0,y:120}]), order: swapped ? 0 : 1 }, ].map(a => ({...a, stage:'board', boardLength:240, distance:84, stopped:true, blockedFor:1})); const position = a => at(a.path,a.distance); const clearance = 50; // Both forward moves are physically blocked, irrespective of reservation order. for (const actor of actors) assert.equal(advance(actor.path,84,90,[position(actors.find(o=>o!==actor))],[],clearance),84); const fixed = rearBlocked ? [swapped ? {x:-87,y:0} : {x:0,y:-87}] : []; let retreats = 0; for (let frame=0;frame<1200 && actors.some(a=>a.stage==='board');frame++) { const reversed = resolveGridlock(actors,fixed,position,clearance,3); retreats += reversed.size; for (const actor of actors) { if (actor.stage !== 'board' || reversed.has(actor)) continue; const target = Math.min(actor.path.length,actor.distance+4); const next = advance(actor.path,actor.distance,target,[...fixed,...actors.filter(o=>o!==actor&&o.stage==='board').map(position)],activeCorridors(actors,actor),clearance); actor.stopped = next < target - .1; actor.blockedFor = actor.stopped ? actor.blockedFor + 1/60 : 0; actor.distance = next; if (next === actor.path.length) actor.stage = 'done'; } for (const actor of actors) for (const obstacle of fixed) { const p = position(actor); assert.ok(Math.hypot(p.x-obstacle.x,p.y-obstacle.y)>=clearance-1e-6); } if (actors.every(a=>a.stage==='board')) { const [p,q]=actors.map(position); assert.ok(Math.hypot(p.x-q.x,p.y-q.y)>=clearance-1e-6); } } assert.ok(retreats > 0); assert.ok(actors.every(a=>a.stage==='done')); } }); import { journey, journeyPosition, scheduleDepartures, tripsConflict } from '../labs/car-loop/schedule.js'; import { garagePath } from '../labs/car-loop/routes.js'; import { batchSeeds, rotationPlan, drainOrientation } from '../labs/car-loop/batch.js'; function straightTrip(id, points) { const path = measure(points); return { car: { id }, journey: { path, boardLength: path.length, times: path.lengths.map(d => d / 240), duration: path.length / 240 } }; } test('timed departures overlap independent routes and reserve crossings only when occupied', () => { const a = straightTrip('a', [{ x: -200, y: 0 }, { x: 200, y: 0 }]); const parallel = straightTrip('b', [{ x: -200, y: 100 }, { x: 200, y: 100 }]); const earlyCrossing = straightTrip('c', [{ x: 0, y: -50 }, { x: 0, y: 350 }]); for (const other of [parallel, earlyCrossing]) { assert.deepEqual(scheduleDepartures([a, other], { clearance: 60 }).map(t => t.start), [0, 0]); } const crossing = straightTrip('d', [{ x: 0, y: -200 }, { x: 0, y: 200 }]); assert.equal(tripsConflict({ ...a, start: 0 }, { ...crossing, start: 0 }, 0, 60), true, 'swept crossing is detected even though both segment endpoints are clear'); const [first, second] = scheduleDepartures([a, crossing], { clearance: 60 }); assert.ok(second.start > 0 && second.start < first.journey.duration, 'conflicting trips still overlap in time'); for (let time = 0; time < first.journey.duration; time += .003) { const p = journeyPosition(first.journey, time), q = journeyPosition(second.journey, time - second.start); assert.ok(Math.hypot(p.x - q.x, p.y - q.y) >= 60); } const later = scheduleDepartures([crossing], { now: .3, active: [first], clearance: 60 })[0]; assert.ok(later.start > .3, 'previously running traffic also reserves the crossing'); assert.throws(() => scheduleDepartures([a], { clearance: 60, parked: [{ id: 'fixed', x: 0, y: 0 }] }), /parked car/); }); test('all batch trips maintain physical clearance through curves, merges and the garage across waves', () => { let overlap = false, checked = 0; for (const kind of ['hex', 'square']) for (const seed of batchSeeds[kind]) { let board = generate(kind, seed, 16), cars = board.cars, now = 0, active = []; const size = kind === 'hex' ? 74 : 68, clearance = size * .92 + 2, parkedClearance = size * .8 + 1; const screen = p => ({ x: 384 + p.x * size, y: 360 + p.y * size }); const perimeter = createPerimeter(kind, 384, 360, size), finish = garagePath(perimeter, 768); const plan = rotationPlan(board); for (const [wave, turn] of [0, ...plan.turns].entries()) { board = rotate(board, turn); const ids = drainOrientation(board, cars).departed; const candidates = ids.map(id => { const car = cars.find(c => c.id === id), route = departure(trace(board, car, []), kind, screen, perimeter); return { car, journey: journey(route, perimeter, finish) }; }); const planned = scheduleDepartures(candidates, { now, active, clearance, parkedClearance, parked: cars.map(c => ({ id: c.id, ...screen(center(kind, c.x, c.y)) })), }); assert.equal(planned.length, ids.length); active.push(...planned); cars = cars.filter(c => !ids.includes(c.id)); const internalEnd = Math.max(now, ...planned.map(t => t.start + t.journey.boardLength / 240 + 27 / 180)); const end = wave === plan.turns.length ? Math.max(...active.map(t => t.start + t.journey.duration)) : internalEnd + .9; const lastDistances = new Map(); for (let time = now; time < end; time += .02) { const present = active.filter(t => time < t.start + t.journey.duration).map(t => ({ id: t.car.id, moving: time >= t.start, ...journeyPosition(t.journey, time - t.start), })); if (present.filter(p => p.moving).length > 1) overlap = true; for (const p of present) { if (p.moving && p.distance > 0 && lastDistances.has(p.id)) assert.ok(p.distance > lastDistances.get(p.id), 'no stopping or reversing after departure'); lastDistances.set(p.id, p.distance); } present.push(...cars.map(c => ({ id: c.id, moving: false, ...screen(center(kind, c.x, c.y)) }))); for (let i = 0; i < present.length; i++) for (let j = i + 1; j < present.length; j++) { const a = present[i], b = present[j], gap = a.moving && b.moving ? clearance : parkedClearance; assert.ok(Math.hypot(a.x - b.x, a.y - b.y) >= gap - 1e-6, `${kind} ${seed} wave ${wave}: ${a.id}/${b.id} at ${time}`); checked++; } } now = end; active = active.filter(t => now < t.start + t.journey.duration); } assert.equal(cars.length, 0); assert.equal(active.length, 0); } assert.ok(overlap); assert.ok(checked > 10000); });