feat(levels): give every level an opening worth watching
The chain locks all four of its pieces at the opening by construction, and the pieces grown around it are mostly locked too, so a level built from it hands a player two rim pieces and a third of a second of animation before any thinking starts. Measured across the 235-level pool: median two tappable pieces at the opening, and the best departure available plays for 0.57s. Growth now reserves a couple of slots for showcase pieces — tappable at the opening, with a route long enough to read as a journey and short enough to stay under the animation ceiling. Median opening choices go 2 to 3 and the best opening show 0.72s to 1.12s, without shortcutting the lock: each candidate is still put through the solver. Rotation quota is gone from the board. Turning is free and it is what the game is about, so depth is grown rather than capped; what is gated is whether each turn pays out, whether pieces swing between hopeless and trivial, and whether there is a show up front. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+62
-19
@@ -6,7 +6,7 @@ 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';
|
||||
import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats } 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);
|
||||
@@ -14,6 +14,9 @@ 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);
|
||||
const PER_TURN = Number(args.perTurn ?? 2);
|
||||
const SHOWCASE = Number(args.showcase ?? 2);
|
||||
|
||||
// Random player: taps a random piece; after three blocked taps in a row, turns randomly.
|
||||
function monkeyActionsPerPiece(level, runs = 8) {
|
||||
@@ -43,8 +46,11 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
|
||||
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 });
|
||||
// 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) {
|
||||
const cap = { 3: 11, 4: 16, 5: 18 }[radius];
|
||||
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);
|
||||
@@ -53,10 +59,14 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
|
||||
yieldTable[cellKey].grown++;
|
||||
const stats = deepLevelStats(level);
|
||||
const audit = deadTurnAudit(level);
|
||||
const contrast = contrastStats(level);
|
||||
const show = showStats(level);
|
||||
if (!show) continue;
|
||||
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 });
|
||||
level.prototype = { family, radius, seed, depth, cap, 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}`;
|
||||
rows.push({ level, stats, audit, contrast, show, apt, family, radius, seed, depth });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,39 +74,72 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
|
||||
// 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: with three pieces or fewer left, at least two of the six
|
||||
// orientations must release something. One is a 1-in-6 guess with a dead end
|
||||
// behind every other door — solvable, legible, and still the thing that made
|
||||
// an agent give up. It rejects exactly the levels every model stalled on.
|
||||
const MIN_RELEASE = Number(args.minRelease ?? 2);
|
||||
const MIN_CONTRAST = Number(args.minContrast ?? 12);
|
||||
const MIN_OPENING_SHOW = Number(args.minOpeningShow ?? 800);
|
||||
const MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3);
|
||||
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.endgameMinOpen >= 2);
|
||||
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));
|
||||
&& r.stats.lockedShare <= .9 && r.audit.endgameMinOpen >= 2
|
||||
&& 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
|
||||
&& r.show.openingBestMs >= MIN_OPENING_SHOW
|
||||
&& r.show.cruises >= MIN_CRUISES);
|
||||
// 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);
|
||||
console.log(`\n【可玩性门槛】长成 ${grownEnough.length} 关 → 首转双向 ${g1.length} → 开局锁住 ≤90% ${g2.length} → 无不可解可达态 ${g3.length} → 终局可走朝向 ≥2 ${playable.length}`);
|
||||
const g4 = g3.filter(r => r.audit.endgameMinOpen >= 2);
|
||||
const g5 = g4.filter(r => r.stats.minRelease >= MIN_RELEASE);
|
||||
console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.length} → 锁住≤90% ${g2.length} → 无不可解态 ${g3.length} → 终局朝向≥2 ${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);
|
||||
console.log(` → 开局可点≥${MIN_OPENING_CHOICES} ${g7.length} → 开局演出≥${(MIN_OPENING_SHOW / 1000).toFixed(1)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${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.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) {
|
||||
perFamily[row.family] = (perFamily[row.family] || 0);
|
||||
if (perFamily[row.family] >= Math.ceil(KEEP / ROUTE_FAMILIES.length) + 1) continue;
|
||||
perFamily[row.family]++;
|
||||
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 (kept.length >= KEEP) break;
|
||||
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 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; };
|
||||
const medAll = rows.map(r => r.stats);
|
||||
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(` 旋转次数分布: ${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弯');
|
||||
|
||||
Reference in New Issue
Block a user