Files
HexTrails/scripts/prototype-d5.mjs
T
cabinwandClaude Opus 5 643cba0ed9 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>
2026-09-14 14:00:33 +08:00

101 lines
6.9 KiB
JavaScript
Raw 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 } 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}`);