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>
187 lines
13 KiB
JavaScript
187 lines
13 KiB
JavaScript
#!/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}`);
|