Files
HexTrails/scripts/build-pool.mjs
T
cabinwandClaude Opus 5 83796804d0 feat(levels): gate on crossing points, and lay the campaign out as segments
trackShape moves the two drawing measurements into src so the sweep, the pool
build, the selector and the gallery share one definition. They are one property
seen twice: a chord joining opposite ports is a straight line, and two of those
in a cell always cross, which is why the filler that paired leftovers straight
across produced a board that is both all-straight and all-crossings.

Crossings per cell is now a gate at 1.0. Bending the leftover ports alone takes
the median from 2.36 to 0.93, so the gate costs a third of the candidates rather
than most of them, and it is the difference the boards were described by.

Selection gains --plan, an ordered list of segments each naming how many levels
it wants and of what kind. Depth quotas cannot express the shape this campaign
needs, because the two good things pull against each other: a crowded board
clears beautifully and caps at three turns, while a deep lock needs room and so
stays sparse — at five turns and eight pieces per orientation, 16 of 18
candidates fail the release gate. Sorting by depth would file every crowded
level first and every deep one last; segments alternate them deliberately, and a
plan is left in the order it was written rather than re-sorted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:05:54 +08:00

80 lines
4.3 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
// Merge a sharded prototype sweep into one candidate pool, and apply the gate
// that only a wide sweep can afford.
//
// node scripts/build-pool.mjs --in=<dir> [--out=bench/pool.json]
// [--maxTightShare=0.005] [--maxDeadTurns=1]
//
// prototype-d5 gates what a single sweep can pay for. Two more are left to this
// step because together they cost nearly 60% of the yield.
//
// `maxDeadTurns` is the longest run of turns a player can make, from a stuck
// state, before anything opens — the design brief's 无效转动就会很无聊, counted.
// At 1 the worst case is one turn of suspense and then a release. Round 1 shipped
// eleven levels at 2, and those are the ones the agent kept writing
// 短暂以为转错了方向 about.
//
// It is deliberately NOT `doubleDead`, which was the first thing tried here.
// That counts dead states rather than consecutive dead turns, so it scales with
// the size of the state space: depth-3 levels have ~187 reachable stuck states
// and depth-5 ones ~739, which made the raw count look eight times worse for
// deep levels when the *rate* is 2% against 5%. Gating on it wiped out every
// level past three turns — the opposite of what the brief asks for, since
// turning is the game's signature and should not be rationed.
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { deadTurnAudit, deepLevelKey, 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.length ? v.join('=') : true]; }));
const OUT = resolve(args.out ?? 'bench/pool.json');
const MAX_TIGHT = Number(args.maxTightShare ?? 0.005);
const MAX_DEAD = Number(args.maxDeadTurns ?? 1);
const MAX_CROSS = Number(args.maxCrossPerCell ?? 1.0);
// Shards from a sweep, or level files listed directly.
const sources = String(args.in ?? 'bench/shards').split(',').map(s => resolve(s.trim()));
const files = sources.flatMap(src => {
try { return readdirSync(src).filter(f => f.endsWith('.json')).map(f => resolve(src, f)); }
catch { return [src]; } // not a directory: a file path
});
const all = files.flatMap(f => {
try { const raw = JSON.parse(readFileSync(f, 'utf8')); return Array.isArray(raw) ? raw : raw.levels ?? []; }
catch { return []; }
});
const seen = new Set();
const unique = [];
for (const level of all) {
const key = deepLevelKey(level);
if (seen.has(key)) continue;
seen.add(key);
unique.push(level);
}
// Levels harvested from the shipped set arrive without an audit. Being shipped
// is not evidence of passing a gate that did not exist when they shipped.
let audited = 0;
for (const level of unique) {
if (level.prototype?.audit) continue;
level.prototype = { ...(level.prototype ?? {}), audit: deadTurnAudit(level) };
audited++;
}
const open = unique.filter(l => (l.prototype.audit.endgameTightShare ?? 1) <= MAX_TIGHT);
const quiet = open.filter(l => (l.prototype.audit.maxDeadTurns ?? Infinity) <= MAX_DEAD);
const kept = quiet.filter(l => (l.prototype.shape ?? trackShape(l)).crossPerCell <= MAX_CROSS);
const tally = (rows, f) => rows.reduce((m, l) => { const k = f(l); m[k] = (m[k] || 0) + 1; return m; }, {});
const rot = l => l.prototype?.stats?.rotations ?? '?';
const fam = l => l.prototype?.family ?? (String(l.id).startsWith('trail-') ? 'v05' : '?');
console.log(`${files.length} 个来源合计 ${all.length} → 去重 ${unique.length}${audited ? `(其中 ${audited} 关现场补算了 audit)` : ''}`);
console.log(` 终局紧局 ≤${(MAX_TIGHT*100).toFixed(1)}% → ${open.length}(${Math.round(100 * open.length / unique.length)}%)`);
console.log(` 连续空转 ≤${MAX_DEAD} → ${quiet.length}(${Math.round(100 * quiet.length / unique.length)}%)`);
console.log(` 交叉/格 ≤${MAX_CROSS} → ${kept.length}(${Math.round(100 * kept.length / unique.length)}%)`);
console.log(`旋转深度 ${JSON.stringify(tally(kept, rot))}`);
console.log(`家族 ${JSON.stringify(tally(kept, fam))}`);
writeFileSync(OUT, JSON.stringify({ generated: 'build-pool', sources: files.length, swept: all.length, count: kept.length,
gates: { maxTightShare: MAX_TIGHT, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
console.log(`已写入 ${OUT}`);