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:
@@ -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');
|
||||
Reference in New Issue
Block a user