Files
cabinwandClaude Opus 5 96e5e4c21f feat(levels): assemble the 50-level campaign and the sheet to review it
The deep-lock construction cannot go below three turns, so it cannot supply an
opening band — every level it makes lands mid-difficulty and the curve comes
out flat. harvest-easy pulls the shallow end from the shipped v0.5 set, but
only the 3% of it that holds up: 568 of 1000 need no rotation at all and 51
more are clearable by tapping at random, which is what the monkey threshold is
for. 33 levels survive, at one and two turns.

select-50 scores candidates on mechanical features only, spreads the pick
evenly across that range, and caps any one route grammar at 36% so the campaign
cannot quietly become one template repeated — the shipped 1000 turned out to be
thirty. Weights are a flag, so refitting them from a round of agent ratings is
a re-run rather than an edit.

The result is a curve rather than a plateau: difficulty score 3.85 / 6.24 /
7.60 across the three bands, rotations 1.8 / 3.0 / 3.5, and every level keeps
three or more opening choices with a show of at least a second.

The 50 register as a playable campaign edition, so review happens in the real
game; docs/review-50.html is the handover sheet, one card per level with the
opening position drawn from the solver rather than sketched.

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

65 lines
3.4 KiB
JavaScript

#!/usr/bin/env node
// Harvest the shallow end of the campaign from the shipped v0.5 set.
//
// node scripts/harvest-easy.mjs [--set=levels/v0.5.json] [--out=bench/pool-easy.json]
//
// The deep-lock construction always needs three turns, so it cannot supply the
// opening dozen levels. v0.5 can — but only the small fraction of it that holds
// up: most of that set is clearable by tapping at random, which is the failure
// the monkey threshold exists to catch.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { Game, random, solveRotations } from '../src/core.js';
import { showStats, contrastStats } 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 SET = resolve(args.set ?? 'levels/v0.5.json');
const OUT = resolve(args.out ?? 'bench/pool-easy.json');
const MIN_APT = Number(args.minAPT ?? 3), MIN_CONTRAST = Number(args.minContrast ?? 10);
const MIN_OPENING_SHOW = Number(args.minOpeningShow ?? 900), MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3);
const MIN_CRUISES = Number(args.minCruises ?? 3);
function monkeyActionsPerPiece(level, runs = 4) {
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)];
actions++;
if (game.tap(tile.id).status === 'exit') streak = 0; else streak++;
}
total += game.won ? actions / level.tiles.length : 999;
}
return total / runs;
}
const raw = JSON.parse(readFileSync(SET, 'utf8'));
const levels = Array.isArray(raw) ? raw : raw.levels;
const kept = [];
const rejected = { unsolvable: 0, zeroRotation: 0, show: 0, contrast: 0, monkey: 0 };
for (const level of levels) {
const solution = solveRotations(level);
if (!solution.solvable) { rejected.unsolvable++; continue; }
if (solution.rotations < 1) { rejected.zeroRotation++; continue; }
const show = showStats(level);
if (!show || show.openingChoices < MIN_OPENING_CHOICES || show.openingBestMs < MIN_OPENING_SHOW || show.cruises < MIN_CRUISES) { rejected.show++; continue; }
const contrast = contrastStats(level);
if (contrast.contrast < MIN_CONTRAST) { rejected.contrast++; continue; }
const apt = monkeyActionsPerPiece(level);
if (apt < MIN_APT) { rejected.monkey++; continue; }
const copy = JSON.parse(JSON.stringify(level));
copy.prototype = {
family: 'v05', radius: null, source: 'v0.5',
stats: { rotations: solution.rotations, pieces: level.tiles.length, lockedShare: 0, minRelease: 1 },
contrast, show, monkeyActionsPerPiece: apt,
};
kept.push(copy);
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify({ generated: 'harvest-easy', source: SET, count: kept.length, levels: kept }, null, 1));
console.log(`采集 ${kept.length} / ${levels.length} 关 → ${OUT}`);
console.log(` 刷掉:0 转 ${rejected.zeroRotation} · 演出 ${rejected.show} · 反差 ${rejected.contrast} · 乱点即过 ${rejected.monkey}`);
console.log(` 旋转分布 ${JSON.stringify(kept.reduce((m, l) => (m[l.prototype.stats.rotations] = (m[l.prototype.stats.rotations] || 0) + 1, m), {}))}`);