Six-side aprons, frozen levels, solver, rewards and verify scripts; the edge-runway readability work continues from here.
149 lines
8.7 KiB
JavaScript
149 lines
8.7 KiB
JavaScript
// 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');
|