feat(levels): D5 three-rotation prototype
The shipped 1000 levels have a hole: 568 of them need no rotation at all, and none needs more than two — the mechanic the game is built on is mostly decoration. This fills the empty band with levels that need at least three. createDeepLock builds a four-piece chain where each link only releases at a later orientation, and growDeepLevel packs the rest of the board around it without opening a shortcut. Candidate pools are built per orientation and left untruncated; ranking by bend count first starved the packer and capped levels at four to six pieces instead of twelve. deadTurnAudit enumerates every reachable (orientation, remaining-set) state to prove no dead ends exist, and counts how long a player can be left with nothing to do — solvable and feeling solvable are different things, which is what "level 2 is impossible" turned out to be about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
#!/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 } 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);
|
||||
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);
|
||||
|
||||
// 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 = 0; seed < SEEDS; seed++) {
|
||||
const base = createRouteBoard(seed, { family, radius });
|
||||
const lock = createDeepLock(base, { seed, maxChecks: 600000 });
|
||||
if (!lock) continue;
|
||||
yieldTable[cellKey].locks++;
|
||||
const target = Math.min(TARGET, { 3: 9, 4: 12, 5: 14 }[radius]);
|
||||
const grown = growDeepLevel(base, lock, { seed, target });
|
||||
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 apt = monkeyActionsPerPiece(level);
|
||||
level.id = `proto-d5-${family}-r${radius}-s${seed}`;
|
||||
level.prototype = { family, radius, seed, target, lock: { hero: lock.hero.id, heroBends: lock.heroRoute.bends, search: lock.search }, placements: grown.placements, rejected: grown.rejected, stats, audit, monkeyActionsPerPiece: apt };
|
||||
rows.push({ level, stats, audit, apt, family, radius, seed });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 grownEnough = rows.filter(r => r.stats.pieces >= MIN_PIECES);
|
||||
const playable = grownEnough.filter(r => r.audit.unsolvable === 0 && r.stats.firstTurnDirections === 2 && r.stats.lockedShare <= .9);
|
||||
playable.sort((a, b) => (a.audit.doubleDead - b.audit.doubleDead) || (b.stats.minRelease - a.stats.minRelease) || (b.stats.lockedShare - a.stats.lockedShare) || (a.apt - b.apt));
|
||||
console.log(`\n【可玩性门槛】长成 ${grownEnough.length} 关 → 首转双向 ${grownEnough.filter(r => r.stats.firstTurnDirections === 2).length} → 开局锁住 ≤90% ${grownEnough.filter(r => r.stats.firstTurnDirections === 2 && r.stats.lockedShare <= .9).length} → 无不可解可达态 ${playable.length}`);
|
||||
const kept = [];
|
||||
const perFamily = {};
|
||||
for (const row of playable) {
|
||||
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 (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 all = rows.map(r => r.stats);
|
||||
const med = arr => { const s = [...arr].sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : NaN; };
|
||||
console.log(`\n【全部 ${rows.length} 个 R≥3 关卡】`);
|
||||
console.log(` 旋转次数分布: ${JSON.stringify(all.reduce((m, s) => (m[s.rotations] = (m[s.rotations] || 0) + 1, m), {}))}`);
|
||||
console.log(` 锁住占比(首转时剩余)中位 ${(med(all.map(s => s.lockedShare)) * 100).toFixed(0)}% (v0.5 现状中位 22%)`);
|
||||
console.log(` 每次旋转最少释放 ≥2 枚的关: ${all.filter(s => s.minRelease >= 2).length}/${all.length}`);
|
||||
console.log(` 首转只有一侧能开路的关: ${all.filter(s => s.firstTurnDirections === 1).length}/${all.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(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}`);
|
||||
Reference in New Issue
Block a user