Files
cabinwandClaude Opus 5 de485c0c34 fix(levels): let a packed board pay its opening as a cascade, not one long trip
The opening gate asks for a departure of at least 0.57s, which is how a sparse
board pays: one piece threading a long route while you watch. A packed board
cannot pay that way — the pieces free at the opening are the ones at the rim,
and their routes are short by construction. So the gate rejected 29 of 29
crowded candidates for lacking a payoff they deliver in a different currency:
the cascade of pieces leaving one after another, which is what the brief singles
out as 多个棋子连环出去,非常赏心悦目.

openingRunMs totals every departure available before a turn is needed, and the
gate takes either that or the single long show. Crowded boards come through at
46% density, 0.92 crossings per cell and 14% straight track.

Both thresholds scale with the motion dial the way the first one already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:14:33 +08:00

187 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// D5 (three-turn) construction prototype. Sweeps route families and seeds,
// grows each chain lock into a full level, and writes a small playable set.
// Usage: node scripts/prototype-d5.mjs [--seeds=8] [--target=12] [--keep=18] [--out=levels/prototypes/d5.json]
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { Game, random } from '../src/core.js';
import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js';
import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats, openingShowFloor, trackShape } from '../src/deep-lock.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
const SEEDS = Number(args.seeds ?? 8), TARGET = Number(args.target ?? 12), KEEP = Number(args.keep ?? 18);
// Seeds are independent, so a wide sweep shards across cores by seed range.
const SEED_FROM = Number(args.seedFrom ?? 0);
const OUT = resolve(args.out ?? 'levels/prototypes/d5.json');
// 91-cell boards stay out by default: they are a readability problem, not a difficulty band.
const RADII = String(args.radii ?? '3,4').split(',').map(Number);
const MIN_PIECES = Number(args.minPieces ?? 8);
const DEPTHS = String(args.depths ?? '3,4,5,6').split(',').map(Number);
// How many pieces each orientation may hold. It is the density dial: the
// schedule is depth × perTurn placements, so perTurn 2 could never build a board
// more than a fifth pieces no matter what `target` said. Swept, so the pool
// carries both sparse and crowded boards to choose between.
const PER_TURNS = String(args.perTurns ?? args.perTurn ?? '2,5,8').split(',').map(Number);
const CAP = args.cap === undefined ? null : Number(args.cap);
const SHOWCASE = Number(args.showcase ?? 2);
// How leftover ports are paired. 'straight' was the only behaviour and it is
// what made boards read as a grid of long lines crossing everywhere.
const FILL = String(args.fill ?? 'curve');
// Random player: taps a random piece; after three blocked taps in a row, turns randomly.
function monkeyActionsPerPiece(level, runs = 8) {
let total = 0;
for (let run = 0; run < runs; run++) {
const rng = random(4242 + run * 31), game = new Game(level);
let actions = 0, streak = 0;
while (!game.won && !game.lost && actions < 800) {
if (streak >= 3) { game.rotate(rng() < .5 ? 1 : -1); actions++; streak = 0; continue; }
const tile = game.tiles[Math.floor(rng() * game.tiles.length)];
const result = game.tap(tile.id); actions++;
if (result.status === 'exit') streak = 0; else streak++;
}
total += game.won ? actions / level.tiles.length : 999;
}
return total / runs;
}
const started = Date.now();
const rows = [], seen = new Set();
const yieldTable = {};
for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
const cellKey = `${family}/r${radius}`;
yieldTable[cellKey] = { locks: 0, grown: 0, seeds: SEEDS };
for (let seed = SEED_FROM; seed < SEED_FROM + SEEDS; seed++) {
const base = createRouteBoard(seed, { family, radius, fill: FILL });
const lock = createDeepLock(base, { seed, maxChecks: 600000 });
if (!lock) continue;
yieldTable[cellKey].locks++;
// Sweep depth: each extra orientation populated is one more turn the
// player must take, and each of those turns has pieces waiting.
for (const depth of DEPTHS) for (const PER_TURN of PER_TURNS) {
// A hard piece ceiling, from when crowded boards could not pass the endgame
// gate anyway. It truncated the placement schedule, so raising perTurn did
// nothing while it stood. Density is now a dial (perTurn), not a limit.
const cap = CAP;
const grown = growDeepLevel(base, lock, { seed, depth, perTurn: PER_TURN, target: cap, showcase: SHOWCASE });
const level = grown.level;
if (grown.solution.rotations < 3) continue;
const signature = deepLevelKey(level);
if (seen.has(signature)) continue;
seen.add(signature);
yieldTable[cellKey].grown++;
const stats = deepLevelStats(level);
const audit = deadTurnAudit(level);
const contrast = contrastStats(level);
const shape = trackShape(level);
const show = showStats(level);
if (!show) continue;
const apt = monkeyActionsPerPiece(level);
level.prototype = { family, radius, seed, depth, perTurn: PER_TURN, cap, shape, lock: { hero: lock.hero.id, heroBends: lock.heroRoute.bends, search: lock.search }, placements: grown.placements, rejected: grown.rejected, stats, audit, contrast, show, monkeyActionsPerPiece: apt };
level.id = `proto-d5-${family}-r${radius}-s${seed}-d${depth}-p${PER_TURN}`;
rows.push({ level, stats, audit, contrast, show, apt, shape, family, radius, seed, depth });
}
}
}
// A bare four-piece lock is 100% locked by definition; only fully grown levels
// count. The playable set also refuses what reads as "unsolvable": a fully
// locked opening, a first turn that only works one way, or any stuck state
// where neither adjacent orientation opens anything.
const med = arr => { const s = [...arr].sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : NaN; };
const grownEnough = rows.filter(r => r.stats.pieces >= MIN_PIECES);
// The endgame gate. It used to demand a minimum over reachable endgames, read
// off the same breadth search that counts dead turns — and on a crowded board
// that search never reaches an endgame, so it answered null and the comparison
// rejected the level for an endgame nobody had measured. Now the endgames are
// enumerated outright and the gate is the SHARE that are tight (one orientation
// or none). A minimum over more subsets falls for arithmetic reasons; the share
// does not, and by share the crowded boards are the better ones — worst 0.33%
// against 1.73% for the sparse.
const MAX_TIGHT_SHARE = Number(args.maxTightShare ?? 0.005);
// How many crossing points a cell may average. Straight filler put this at 2.36
// and the boards read as a net; bending the leftover ports alone brings it to
// 0.93. Holding it under 1.0 costs a third of the candidates and is the
// difference between a drawing you can follow and one that makes your head
// hurt, which is how the boards were described.
const MAX_CROSS = Number(args.maxCrossPerCell ?? 1.0);
const MIN_RELEASE = Number(args.minRelease ?? 2);
const MIN_CONTRAST = Number(args.minContrast ?? 12);
// Expressed at the speed it was calibrated at (120ms per unit) and scaled to
// whatever the speed dial is now, so re-tuning motion does not quietly reject
// levels that were fine. See openingShowFloor in deep-lock.
const MIN_OPENING_SHOW = openingShowFloor(Number(args.minOpeningShow ?? 800));
const MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3);
// The cascade alternative to a single long show, in the same calibrated units.
const MIN_OPENING_RUN = openingShowFloor(Number(args.minOpeningRun ?? 2600));
const MIN_CRUISES = Number(args.minCruises ?? 3);
// Rotation count is deliberately not capped: turning is free and it is the
// point of the game. What has to hold is that every turn pays out (minRelease),
// that no turn is a punishment (endgameMinOpen, firstTurnDirections), and that
// pieces actually swing between hopeless and trivial (contrast).
const playable = grownEnough.filter(r => r.audit.unsolvable === 0 && r.stats.firstTurnDirections === 2
&& r.stats.lockedShare <= .9 && r.audit.endgameTightShare <= MAX_TIGHT_SHARE
&& r.stats.minRelease >= MIN_RELEASE && r.contrast.contrast >= MIN_CONTRAST
// The show is the payout. An opening that offers two rim pieces and a 0.3s
// hop gives a casual player nothing to enjoy before the thinking starts.
&& r.show.openingChoices >= MIN_OPENING_CHOICES
// One long departure, or a cascade of short ones — a packed board pays the
// other way round and the single-best test cannot see it.
&& (r.show.openingBestMs >= MIN_OPENING_SHOW || r.show.openingRunMs >= MIN_OPENING_RUN)
&& r.show.cruises >= MIN_CRUISES
&& r.shape.crossPerCell <= MAX_CROSS);
// Locked-share used to sort first and highest, which is backwards for a casual
// player: it rewards an opening where nothing can be done. Rank by what the
// player gets — a show up front, pieces that swing from hopeless to trivial,
// and turns that pay out — and treat lock depth as a tiebreak only.
playable.sort((a, b) => (b.show.openingBestMs - a.show.openingBestMs)
|| (b.contrast.contrast - a.contrast.contrast)
|| (b.show.cruises - a.show.cruises)
|| (a.audit.doubleDead - b.audit.doubleDead));
const g1 = grownEnough.filter(r => r.stats.firstTurnDirections === 2);
const g2 = g1.filter(r => r.stats.lockedShare <= .9);
const g3 = g2.filter(r => r.audit.unsolvable === 0);
const g4 = g3.filter(r => r.audit.endgameTightShare <= MAX_TIGHT_SHARE);
const g5 = g4.filter(r => r.stats.minRelease >= MIN_RELEASE);
console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.length} → 锁住≤90% ${g2.length} → 无不可解态 ${g3.length} → 终局紧局≤${(MAX_TIGHT_SHARE*100).toFixed(1)}% ${g4.length} → 每转释放≥${MIN_RELEASE} ${g5.length} → 反差≥${MIN_CONTRAST} ${playable.length}`);
const g6 = g5.filter(r => r.contrast.contrast >= MIN_CONTRAST);
const g7 = g6.filter(r => r.show.openingChoices >= MIN_OPENING_CHOICES);
const g8 = g7.filter(r => r.show.openingBestMs >= MIN_OPENING_SHOW || r.show.openingRunMs >= MIN_OPENING_RUN);
const g9 = g8.filter(r => r.show.cruises >= MIN_CRUISES);
console.log(` → 开局可点≥${MIN_OPENING_CHOICES} ${g7.length} → 开局演出≥${(MIN_OPENING_SHOW/1000).toFixed(2)}s 或连锁≥${(MIN_OPENING_RUN/1000).toFixed(1)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${g9.length} → 交叉/格≤${MAX_CROSS} ${playable.length}`);
console.log(`【候选池】旋转深度 ${JSON.stringify(playable.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}`);
console.log(` 交叉/格 中位 ${med(playable.map(r => r.shape.crossPerCell)).toFixed(2)} · 直线占比 中位 ${(med(playable.map(r => r.shape.straightShare))*100).toFixed(0)}% · 密度 中位 ${(med(playable.map(r => r.level.tiles.length/r.level.cells.length))*100).toFixed(0)}%`);
console.log(` 反差 中位 ${med(playable.map(r => r.contrast.contrast)).toFixed(1)} · 家族 ${JSON.stringify(playable.reduce((m, r) => (m[r.family] = (m[r.family] || 0) + 1, m), {}))}`);
const KEEP_ALL = String(args.keep) === 'all';
const kept = [];
const perFamily = {};
for (const row of playable) {
if (!KEEP_ALL) {
perFamily[row.family] = (perFamily[row.family] || 0);
if (perFamily[row.family] >= Math.ceil(KEEP / ROUTE_FAMILIES.length) + 1) continue;
perFamily[row.family]++;
}
kept.push(row);
if (!KEEP_ALL && kept.length >= KEEP) break;
}
const pad = (v, w) => String(v).padStart(w);
console.log(`D5 三转构造原型 · ${ROUTE_FAMILIES.length} 形态 × ${RADII.length} 尺寸 × ${SEEDS} 种子 · ${((Date.now() - started) / 1000).toFixed(1)} s`);
console.log('\n【产出率】形态/尺寸 找到锁 长成 R=3 关');
for (const [k, v] of Object.entries(yieldTable)) console.log(' ' + k.padEnd(18) + pad(v.locks, 5) + '/' + v.seeds + pad(v.grown, 9) + '/' + v.seeds);
const medAll = rows.map(r => r.stats);
console.log(`\n【全部 ${rows.length} 个 R≥3 关卡】`);
console.log(` 旋转次数分布: ${JSON.stringify(medAll.reduce((m, s) => (m[s.rotations] = (m[s.rotations] || 0) + 1, m), {}))}`);
console.log(` 锁住占比(首转时剩余)中位 ${(med(medAll.map(s => s.lockedShare)) * 100).toFixed(0)}% (v0.5 现状中位 22%)`);
console.log(` 每次旋转最少释放 ≥2 枚的关: ${medAll.filter(s => s.minRelease >= 2).length}/${medAll.length}`);
console.log(` 首转只有一侧能开路的关: ${medAll.filter(s => s.firstTurnDirections === 1).length}/${medAll.length}`);
console.log(` 猴子 动作/棋子 中位 ${med(rows.map(r => r.apt)).toFixed(2)} (v0.5 全库 1.67 · v0.4 手作 3.42)`);
console.log(`\n【写入 ${kept.length} 关】`);
console.log(' id 格 枚 R 锁住% 每转释放 首转方向 双向死转态 最长盲转 终局路口 猴子APT hero弯');
for (const { level, stats, audit, apt } of kept) console.log(' ' + level.id.padEnd(36) + pad(level.cells.length, 3) + pad(stats.pieces, 4) + pad(stats.rotations, 3) + pad((stats.lockedShare * 100).toFixed(0) + '%', 7) + ' ' + stats.releasePerTurn.join('/').padEnd(12) + pad(stats.firstTurnDirections === 1 ? '单向' : '双向', 6) + pad(audit.doubleDead, 10) + pad(audit.maxDeadTurns, 9) + pad(audit.endgameMinOpen, 9) + pad(apt.toFixed(2), 9) + pad(level.prototype.lock.heroBends, 7));
console.log('\n 「双向死转态」= 可达的卡住状态里,左右各转一次都打不开任何棋子的状态数;「最长盲转」= 从某个可达卡住态朝错误方向连转、遇到第一个有效朝向前经过的空朝向数。');
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify({ generated: 'prototype-d5', version: 1, options: { seeds: SEEDS, target: TARGET, radii: RADII }, summary: { candidates: rows.length, kept: kept.length, yieldTable }, levels: kept.map(r => r.level) }, null, 1));
console.log(`\n已写入 ${OUT}`);