// 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');