feat(airport): add the Hex Airport dispatch lab as a baseline snapshot

Six-side aprons, frozen levels, solver, rewards and verify scripts; the edge-runway readability work continues from here.
This commit is contained in:
toshiocn
2026-09-21 14:25:09 +08:00
parent 6e72b95ad8
commit 1be0e9caf1
18 changed files with 5229 additions and 43 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { cp, mkdir, rm } from 'node:fs/promises';
const out=new URL('../dist/',import.meta.url);
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/car-loop','labs/variants','labs/flower','labs/campaign-review','labs/hex-video/designv2.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/car-loop','labs/variants','labs/flower','labs/campaign-review','labs/airport','labs/hex-video/designv2.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
console.log('Static build ready in dist/');
+216
View File
@@ -0,0 +1,216 @@
// Deterministic offline design of the four teaching levels.
// L1/L2 hand-authored (straight board, single direct landing). L3/L4 searched
// for the "rotate to connect" and "connect + clear a blocker" teaching beats.
import { writeFile } from 'node:fs/promises';
import { hexagon, random, randomTracks, rotateBoard, DIRS, key, opposite } from '../src/core.js';
import { catalogs, createState, checkLanding, checkDeparture, solveAirport, replay, validateAirportLevel, uniquenessIssues } from '../labs/airport/core.js';
const rng = random(9172026);
const straight = [3, 4, 5, 0, 1, 2];
const turn = [1, 0, 3, 2, 5, 4];
const pick = a => a[Math.floor(rng() * a.length)];
function boardWith(r, overrides = {}) {
const cells = hexagon(r).map(c => ({ ...c, tracks: [...straight] }));
for (const [k, t] of Object.entries(overrides)) {
const [q, r] = k.split(',').map(Number);
cells.find(c => c.q === q && c.r === r).tracks = [...t];
}
return cells;
}
function exits(board, cell, dir) {
const map = new Map(board.cells.map(c => [key(c.q, c.r), c]));
let { q, r } = cell, d = dir; const seen = new Set();
while (true) {
const mark = `${q},${r},${d}`; if (seen.has(mark)) return -1; seen.add(mark);
q += DIRS[d][0]; r += DIRS[d][1];
const next = map.get(key(q, r));
if (!next) return d;
d = next.tracks[opposite(d)];
}
}
function candidates(cells) {
const boards = Array.from({ length: 6 }, (_, o) => rotateBoard({ cells }, o));
return cells.map(c => ({ q: c.q, r: c.r, sides: boards.map(b => Array.from({ length: 6 }, (_, d) => exits(b, c, d)).filter(d => d >= 0)) }))
.filter(c => c.sides.some(s => s.length) && c.sides.every(s => new Set(s).size === s.length));
}
const base = (cells, stands, arrivals) => ({ id: 'search', name: 'search', cells, stands, arrivals, originals: [], moveLimit: 40, reward: { steps: 5, maxClaims: 3 } });
// Add `count` originals that are individually clearable and never block a landing
// that was legal before they were placed. Used for the no-blocking teaching levels.
function addSafeOriginals(level, count, palette) {
const cat = catalogs(level);
const board = cat[0].board;
const used = new Set([...level.stands.map(s => key(s.q, s.r))]);
const arrivals = level.arrivals.map(a => {
const st = createState(level); st.orientation = 0;
const r = checkLanding(level, st, a.id);
return { id: a.id, okStatus: r.status === 'land' };
});
for (let i = 0; i < count; i++) {
const options = board.cells.filter(c => !used.has(key(c.q, c.r)))
.flatMap(c => Array.from({ length: 6 }, (_, d) => d).filter(d => exits(board, c, d) >= 0).map(d => ({ q: c.q, r: c.r, dir: d })));
let placed = false;
while (options.length && !placed) {
const o = options.splice(Math.floor(rng() * options.length), 1)[0];
const cand = { id: `out${level.originals.length + 1}`, q: o.q, r: o.r, dir: o.dir, color: palette[i] ?? 4 };
level.originals.push(cand);
const st = createState(level); st.orientation = 0;
let safe = checkDeparture(level, st, cand.id).status === 'exit';
for (const a of arrivals) {
const s = createState(level); s.orientation = 0;
const r = checkLanding(level, s, a.id);
if (a.okStatus && r.status !== 'land') safe = false;
}
if (safe) { used.add(key(cand.q, cand.r)); placed = true; }
else level.originals.pop();
}
if (!placed) throw new Error(`cannot place safe original ${i}`);
}
}
function finish(level, id, name, lesson) {
level.id = id; level.name = name; level.lesson = lesson;
const solution = solveAirport(level, createState(level));
if (solution.status !== 'solved') throw new Error(`${name}: not solvable (${solution.status})`);
const cleared = solveAirport(level, createState(level), { maxStates: 250000, clearFirst: true });
if (cleared.status !== 'solved') throw new Error(`${name}: clear-first not solvable`);
if (!(cleared.minimumActions > level.moveLimit)) throw new Error(`${name}: clear-first ${cleared.minimumActions} not > moveLimit ${level.moveLimit}`);
const remainingOriginals = replay(level, solution.actions).state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length;
if (remainingOriginals < 1) throw new Error(`${name}: no originals remain`);
level.solution = solution.actions;
level.experience = solution.actions;
level.evidence = {
minimumActions: solution.minimumActions,
remainingOriginals,
clearFirst: { status: 'solved', minimumActions: cleared.minimumActions },
experienceActions: solution.actions.length,
uniqueEmptyBoard: true,
};
return level;
}
const output = [];
// ---- L1: one off-board plane, direct landing into the same-colour stand. ----
{
const cells = boardWith(2);
const level = base(cells, [{ id: 'P1', q: 0, r: 0, color: 8 }], [{ id: 'in1', color: 8, side: 0 }]);
addSafeOriginals(level, 3, [2, 5, 3]);
level.moveLimit = 3;
const done = finish(level, 'airport-1', '留一点空间', '点击场外的可可色飞机,它会飞进同色的可可色机位。');
if (done.evidence.minimumActions !== 1) throw new Error('L1 must be a single landing');
output.push(done);
}
// ---- L2: same teaching, stronger colour discrimination (rose decoy near strawberry). ----
{
const cells = boardWith(2);
const level = base(cells, [{ id: 'P1', q: 0, r: 0, color: 0 }], [{ id: 'in1', color: 0, side: 2 }]);
addSafeOriginals(level, 3, [7, 5, 3]);
level.moveLimit = 3;
const done = finish(level, 'airport-2', '同色才停靠', '只有草莓色机位能停草莓色飞机,别被玫瑰色飞机混淆。');
if (done.evidence.minimumActions !== 1) throw new Error('L2 must be a single landing');
output.push(done);
}
// ---- L3: one off-board plane, must rotate the board to connect the runway. ----
{
let found = null;
for (let seed = 0; seed < 200000 && !found; seed++) {
const cells = hexagon(2).map(c => ({ ...c, tracks: [...turn] }));
for (let j = 0, n = 2 + Math.floor(rng() * 7); j < n; j++) { const c = cells[Math.floor(rng() * cells.length)]; c.tracks = randomTracks(rng); }
const options = candidates(cells);
for (const opt of options) {
const s0 = opt.sides[0];
for (let side = 0; side < 6; side++) {
if (s0.includes(side)) continue;
const d = opt.sides[1].includes(side) ? 1 : opt.sides[5].includes(side) ? -1 : 0;
if (!d) continue;
const level = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], [{ id: 'in1', color: 8, side }]);
if (uniquenessIssues(level).length) continue;
const st0 = createState(level); st0.orientation = 0;
if (checkLanding(level, st0, 'in1').status !== 'no-route') continue;
addSafeOriginals(level, 3, [2, 5, 3]);
level.moveLimit = 4;
const done = finish(level, 'airport-3', '转一下再接入', '跑道还没连通。旋转棋盘,让来机所在边通到停机位。');
const rotations = done.solution.filter(a => a.type === 'rotate').length;
const clears = done.solution.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
if (rotations !== 1 || clears !== 0 || done.evidence.minimumActions !== 2) continue;
found = done;
break;
}
if (found) break;
}
}
if (!found) throw new Error('L3 not found');
output.push(found);
}
// ---- L4: two off-board planes plus blockers; runway must connect and stay clear. ----
{
let found = null;
for (let seed = 0; seed < 200000 && !found; seed++) {
const cells = hexagon(2).map(c => ({ ...c, tracks: [...turn] }));
for (let j = 0, n = 2 + Math.floor(rng() * 7); j < n; j++) { const c = cells[Math.floor(rng() * cells.length)]; c.tracks = randomTracks(rng); }
const options = candidates(cells);
if (options.length < 2) continue;
const pairs = options.flatMap((a, i) => options.slice(i + 1).filter(b => a.sides.every((s, o) => s.every(d => !b.sides[o].includes(d)))).map(b => [a, b]));
for (const pair of pairs) {
const stands = pair.map((s, i) => ({ id: `P${i + 1}`, q: s.q, r: s.r, color: i === 0 ? 8 : 0 }));
const level = base(cells, stands, []);
const cat = catalogs(level);
const arrivals = stands.map((s, i) => {
const routes = cat.flatMap(c => c.arrivals.filter(r => r.standId === s.id));
return { id: `in${i + 1}`, color: s.color, side: pick(routes).side };
});
if (arrivals[0].side === arrivals[1].side) continue;
level.arrivals = arrivals;
// A blocker must occupy a cell on one arrival's landing path (teaching: clear the blocker).
const used = new Set(stands.map(s => key(s.q, s.r)));
const catBoard = catalogs(level)[0].board;
const pathCells = new Set();
for (const a of arrivals) {
const st = createState(level); st.orientation = 0;
const r = checkLanding(level, st, a.id);
if (r.status === 'land') for (const s of r.steps) pathCells.add(`${s.q},${s.r}`);
}
const blocker = cells.find(c => !used.has(key(c.q, c.r)) && pathCells.has(key(c.q, c.r)));
if (!blocker) continue;
// Every original gets a real exit direction so clear-first stays solvable.
const validDir = c => Array.from({ length: 6 }, (_, d) => d).find(d => exits(catBoard, c, d) >= 0) ?? 0;
level.originals.push({ id: 'out1', q: blocker.q, r: blocker.r, dir: validDir(blocker), color: 2 });
used.add(key(blocker.q, blocker.r));
for (let i = 1; i < 5; i++) {
const free = cells.filter(c => !used.has(key(c.q, c.r)));
const c = pick(free); used.add(key(c.q, c.r));
level.originals.push({ id: `out${i + 1}`, q: c.q, r: c.r, dir: validDir(c), color: [5, 3, 4, 6][i - 1] });
}
if (validateAirportLevel(level).length) continue;
const solution = solveAirport(level, createState(level));
if (solution.status !== 'solved' || solution.remainingOriginals < 1) continue;
const rotations = solution.actions.filter(a => a.type === 'rotate').length;
const clears = solution.actions.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
if (rotations < 1 || clears < 1) continue;
level.moveLimit = Math.max(6, solution.minimumActions + 2);
try {
const done = finish(level, 'airport-4', '清障再接入', '跑道要连通,通路也不能被飞机挡住。先移开阻挡,再旋转接入两架来机。');
if (done.evidence.minimumActions !== solution.minimumActions) continue;
found = done;
break;
} catch { continue; }
}
if (found) break;
}
if (!found) throw new Error('L4 not found');
output.push(found);
}
if (output.length !== 4) throw new Error(`Expected 4 levels, got ${output.length}`);
for (const l of output) {
if (validateAirportLevel(l).length) throw new Error(`invalid ${l.id}`);
if (!replay(l, l.solution).won) throw new Error(`replay fail ${l.id}`);
console.log(`${l.id} "${l.name}": optimum ${l.evidence.minimumActions}, budget ${l.moveLimit}, remaining ${l.evidence.remainingOriginals}, clear-first ${l.evidence.clearFirst.minimumActions}, actions ${l.solution.length}`);
}
await writeFile(new URL('../labs/airport/levels.js', import.meta.url),
'// Frozen, deterministic airport fixtures. Regenerate with node scripts/design-airport-levels.mjs.\nexport const LEVELS = ' + JSON.stringify(output, null, 2) + ';\n');
+148
View File
@@ -0,0 +1,148 @@
// Append two radius-3 practice levels. Leaves the 19-cell teaching trio untouched.
import { writeFile } from 'node:fs/promises';
import { hexagon, random, randomTracks, rotateBoard, DIRS, key, opposite } from '../src/core.js';
import { catalogs, createState, solveAirport, replay, validateAirportLevel } from '../labs/airport/core.js';
import { LEVELS as teaching } from '../labs/airport/levels.js';
const rng = random(9172027), turn = [1, 0, 3, 2, 5, 4];
const pick = a => a[Math.floor(rng() * a.length)];
function exits(board, cell, dir) {
const map = new Map(board.cells.map(c => [key(c.q, c.r), c]));
let { q, r } = cell, d = dir; const seen = new Set();
while (true) {
const mark = `${q},${r},${d}`; if (seen.has(mark)) return -1; seen.add(mark);
q += DIRS[d][0]; r += DIRS[d][1];
const next = map.get(key(q, r));
if (!next) return d;
d = next.tracks[opposite(d)];
}
}
function candidates(cells) {
const boards = Array.from({ length: 6 }, (_, o) => rotateBoard({ cells }, o));
return cells.map(c => ({ q: c.q, r: c.r, sides: boards.map(b => Array.from({ length: 6 }, (_, d) => exits(b, c, d)).filter(d => d >= 0)) }))
.filter(c => c.sides.some(s => s.length) && c.sides.every(s => new Set(s).size === s.length));
}
const base = (cells, stands, arrivals) => ({ id: 'search', name: 'search', cells, stands, arrivals, originals: [], moveLimit: 40, reward: { steps: 5, maxClaims: 3 } });
const TEACHING = 4; // The four 19-cell lessons are always the first slice.
const extra = [];
const names = ['更大的停机坪', '绕一圈再接入'];
const lessons = ['棋盘变大了,规则不变:清出通路,把来机停到同色机位。', '在 37 格上转动轨道,让不同边的来机各自停好。'];
for (let seed = 0; seed < 12000 && extra.length < 2; seed++) {
const cells = hexagon(3).map(c => ({ ...c, tracks: [...turn] }));
for (let j = 0, n = 4 + Math.floor(rng() * 8); j < n; j++) pick(cells).tracks = randomTracks(rng);
const options = candidates(cells); if (options.length < 2) continue;
const pairs = options.flatMap((a, i) => options.slice(i + 1).filter(b => a.sides.every((s, o) => s.every(d => !b.sides[o].includes(d)))).map(b => [a, b]));
if (!pairs.length) continue;
const spots = pick(pairs);
const twoColor = extra.length === 1;
const stands = spots.map((s, i) => ({ id: `P${i + 1}`, q: s.q, r: s.r, color: twoColor && i === 1 ? 0 : 8 }));
let level = base(cells, stands, []);
const cat = catalogs(level);
const arrivals = stands.map((s, i) => {
const routes = cat.flatMap(c => c.arrivals.filter(r => r.standId === s.id));
return { id: `in${i + 1}`, color: s.color, side: pick(routes).side };
});
if (arrivals[0].side === arrivals[1].side) continue;
level = base(cells, stands, arrivals);
const used = new Set(stands.map(s => key(s.q, s.r)));
for (let i = 0; i < 8; i++) {
const free = cells.filter(c => !used.has(key(c.q, c.r))); if (!free.length) break;
const c = pick(free); used.add(key(c.q, c.r));
level.originals.push({ id: `out${i + 1}`, q: c.q, r: c.r, dir: Math.floor(rng() * 6), color: [2, 3, 4, 5, 1, 6, 7, 9][i] });
}
if (validateAirportLevel(level).length) continue;
const solution = solveAirport(level, createState(level), { maxStates: 25000, maxDepth: 16 });
if (solution.status !== 'solved' || solution.remainingOriginals < 2) continue;
const rotations = solution.actions.filter(a => a.type === 'rotate').length;
const clearCount = solution.actions.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
if (clearCount < 1 || rotations < 1) continue;
level.moveLimit = solution.minimumActions + 4;
const cleared = solveAirport(level, createState(level), { maxStates: 40000, maxDepth: 28, clearFirst: true });
if (cleared.status !== 'solved' || cleared.minimumActions <= level.moveLimit) continue;
if (!replay(level, solution.actions).won) continue;
const stage = extra.length;
extra.push({
...level,
id: `airport-${TEACHING + stage + 1}`,
name: names[stage],
lesson: lessons[stage],
solution: solution.actions,
experience: solution.actions,
evidence: {
seedAttempt: seed, radius: 3, cells: 37,
minimumActions: solution.minimumActions,
remainingOriginals: replay(level, solution.actions).state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length,
clearFirst: { status: 'solved', minimumActions: cleared.minimumActions },
experienceActions: solution.actions.length, uniqueEmptyBoard: true,
},
});
console.log(`Practice ${stage + 1}: seed ${seed}, optimum ${solution.minimumActions}, remaining ${extra.at(-1).evidence.remainingOriginals}`);
}
if (extra.length !== 2) throw Error(`Only found ${extra.length} practice levels`);
// Third practice level: radius 4 (61 cells). The original campaign leaves the
// 19-cell teaching arc for 37-cell chapters and later ships 61-cell boards, so
// the airport roster grows the board instead of stacking more rules on 19 cells.
const large = [];
const largeNames = ['空港全景'];
const largeLessons = ['61 格空港:原有飞机更多,先在人群里清出通路,再把两色来机各自接好。'];
const palette = [2, 3, 4, 5, 1, 6, 7, 9, 10, 11, 0, 8, 2, 3, 4, 5];
for (let seed = 0; seed < 60000 && large.length < 1; seed++) {
const cells = hexagon(4).map(c => ({ ...c, tracks: [...turn] }));
for (let j = 0, n = 6 + Math.floor(rng() * 10); j < n; j++) pick(cells).tracks = randomTracks(rng);
const options = candidates(cells); if (options.length < 2) continue;
const pairs = options.flatMap((a, i) => options.slice(i + 1).filter(b => a.sides.every((s, o) => s.every(d => !b.sides[o].includes(d)))).map(b => [a, b]));
if (!pairs.length) continue;
const spots = pick(pairs);
const stands = spots.map((s, i) => ({ id: `P${i + 1}`, q: s.q, r: s.r, color: i === 0 ? 8 : 0 }));
let level = base(cells, stands, []);
const cat = catalogs(level);
const arrivals = stands.map((s, i) => {
const routes = cat.flatMap(c => c.arrivals.filter(r => r.standId === s.id));
return { id: `in${i + 1}`, color: s.color, side: pick(routes).side };
});
if (arrivals[0].side === arrivals[1].side) continue;
level = base(cells, stands, arrivals);
const used = new Set(stands.map(s => key(s.q, s.r)));
// Ground planes are placed on cells that actually have an exit, then given one
// of those headings. Random headings would leave most of a 61-cell board stuck.
const dirOptions = cells.map(c => ({ q: c.q, r: c.r, dirs: Array.from({ length: 6 }, (_, d) => exits(cat[0].board, c, d)).filter(d => d >= 0) })).filter(c => c.dirs.length);
for (let i = 0; i < 13; i++) {
const free = dirOptions.filter(o => !used.has(key(o.q, o.r))); if (!free.length) break;
const c = pick(free); used.add(key(c.q, c.r));
level.originals.push({ id: `out${i + 1}`, q: c.q, r: c.r, dir: pick(c.dirs), color: palette[i] });
}
if (validateAirportLevel(level).length) continue;
const solution = solveAirport(level, createState(level), { maxStates: 80000, maxDepth: 14 });
if (solution.status !== 'solved' || solution.remainingOriginals < 2) continue;
const rotations = solution.actions.filter(a => a.type === 'rotate').length;
const clearCount = solution.actions.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
if (clearCount < 1 || rotations < 1) continue;
level.moveLimit = solution.minimumActions + 4;
const cleared = solveAirport(level, createState(level), { maxStates: 200000, maxDepth: 30, clearFirst: true });
if (cleared.status !== 'solved' || cleared.minimumActions <= level.moveLimit) continue;
const won = replay(level, solution.actions);
if (!won.won) continue;
large.push({
...level,
id: `airport-${TEACHING + extra.length + 1}`,
name: largeNames[0],
lesson: largeLessons[0],
solution: solution.actions,
experience: solution.actions,
evidence: {
seedAttempt: seed, radius: 4, cells: 61,
minimumActions: solution.minimumActions,
remainingOriginals: won.state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length,
clearFirst: { status: 'solved', minimumActions: cleared.minimumActions },
experienceActions: solution.actions.length, uniqueEmptyBoard: true,
},
});
console.log(`Practice 3: seed ${seed}, optimum ${solution.minimumActions}, remaining ${large.at(-1).evidence.remainingOriginals}, clear-first ${cleared.minimumActions}`);
}
if (large.length !== 1) throw Error(`Only found ${large.length} radius-4 practice levels`);
await writeFile(new URL('../labs/airport/levels.js', import.meta.url), '// Frozen, deterministic airport fixtures. Four teaching lessons plus radius-3 and radius-4 practice.\nexport const LEVELS = ' + JSON.stringify([...teaching.slice(0, TEACHING), ...extra, ...large], null, 2) + ';\n');
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { LEVELS } from '../labs/airport/levels.js';
import { validateAirportLevel, solveAirport, replay, createState } from '../labs/airport/core.js';
const report=[];
for(const level of LEVELS){
assert.deepEqual(validateAirportLevel(level),[]);
const solved=solveAirport(level),clear=solveAirport(level,createState(level),{clearFirst:true});
assert.equal(solved.status,'solved');assert.equal(solved.minimumActions,level.evidence.minimumActions);
assert.ok(replay(level,level.solution).won);assert.ok(replay(level,level.experience).won);
assert.equal(clear.status,'solved');assert.ok(clear.minimumActions>level.moveLimit);
report.push({level:level.id,minimum:solved.minimumActions,budget:level.moveLimit,experience:level.experience.length,
clearFirst:clear.minimumActions,remainingOriginals:solved.remainingOriginals,unique:'all 6 sides × 6 orientations'});
}
console.table(report);