Files
HexTrails/scripts/design-airport-levels.mjs
T
toshiocn c99716b1d5 feat(airport): bind landings to the six visual flats
Entry side is the 60-degree wedge of the port, not the cell-edge direction. Levels regenerated; hint line joins the waiting plane to the on-board path.
2026-09-21 17:55:06 +08:00

219 lines
11 KiB
JavaScript

// 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 probe = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], []);
if (uniquenessIssues(probe).length) continue;
const cat = catalogs(probe);
for (let side = 0; side < 6; side++) {
const at0 = cat[0].arrivals.some(r => r.side === side);
const turn = cat[1].arrivals.some(r => r.side === side) ? 1 : cat[5].arrivals.some(r => r.side === side) ? -1 : 0;
if (at0 || !turn) 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');