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:
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||
const out=new URL('../dist/',import.meta.url);
|
||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
||||
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/car-loop','labs/variants','labs/flower','labs/campaign-review','labs/hex-video/designv2.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/car-loop','labs/variants','labs/flower','labs/campaign-review','labs/airport','labs/hex-video/designv2.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
console.log('Static build ready in dist/');
|
||||
|
||||
@@ -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');
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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');
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { LEVELS } from '../labs/airport/levels.js';
|
||||
import { validateAirportLevel, solveAirport, replay, createState } from '../labs/airport/core.js';
|
||||
const report=[];
|
||||
for(const level of LEVELS){
|
||||
assert.deepEqual(validateAirportLevel(level),[]);
|
||||
const solved=solveAirport(level),clear=solveAirport(level,createState(level),{clearFirst:true});
|
||||
assert.equal(solved.status,'solved');assert.equal(solved.minimumActions,level.evidence.minimumActions);
|
||||
assert.ok(replay(level,level.solution).won);assert.ok(replay(level,level.experience).won);
|
||||
assert.equal(clear.status,'solved');assert.ok(clear.minimumActions>level.moveLimit);
|
||||
report.push({level:level.id,minimum:solved.minimumActions,budget:level.moveLimit,experience:level.experience.length,
|
||||
clearFirst:clear.minimumActions,remainingOriginals:solved.remainingOriginals,unique:'all 6 sides × 6 orientations'});
|
||||
}
|
||||
console.table(report);
|
||||
Reference in New Issue
Block a user