feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+331
View File
@@ -0,0 +1,331 @@
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<car.path.length) waited=true;
car.distance=next;
assert.ok(Math.hypot(at(a.path,a.distance).x-at(b.path,b.distance).x,at(a.path,a.distance).y-at(b.path,b.distance).y)>=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);
});