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