diff --git a/labs/route-gallery/index.html b/labs/route-gallery/index.html
index 0d85b41..545f5dd 100644
--- a/labs/route-gallery/index.html
+++ b/labs/route-gallery/index.html
@@ -161,12 +161,15 @@ async function renderCampaign() {
const levels = (await res.json()).levels;
$('campaign').innerHTML = levels.map((l, i) =>
card(String(i + 1).padStart(2, '0'), l.campaign?.family ?? '', l,
- `棋子 / 格子${l.tiles.length} / ${l.cells.length}`)).join('');
+ `棋子密度${l.tiles.length} / ${l.cells.length} = ${Math.round(100 * l.tiles.length / l.cells.length)}%`)).join('');
[...$('campaign').querySelectorAll('canvas')].forEach((cv, i) => paint(cv, levels[i]));
const s = levels.map(shape);
const avg = k => s.reduce((n, x) => n + x[k], 0) / s.length;
- $('meta').textContent = `当前 50 关:直线占比均值 ${Math.round(avg('straight') * 100)}%、交叉每格 ${avg('cross').toFixed(2)}`
- + ` · 超过 60% 直线的有 ${s.filter(x => x.straight > .6).length} 关`;
+ const dens = levels.map(l => l.tiles.length / l.cells.length);
+ const mean = a => a.reduce((n, x) => n + x, 0) / a.length;
+ $('meta').textContent = `当前 50 关:直线占比均值 ${Math.round(avg('straight') * 100)}%(超过 60% 的 ${s.filter(x => x.straight > .6).length} 关)`
+ + ` · 交叉每格 ${avg('cross').toFixed(2)}`
+ + ` · 棋子密度均值 ${Math.round(mean(dens) * 100)}%,最高 ${Math.round(Math.max(...dens) * 100)}%,35% 以上 ${dens.filter(x => x >= .35).length} 关`;
} catch (e) {
$('campaign').innerHTML = `
读不到 levels/campaign-50.json:${e.message}
`;
}
diff --git a/scripts/build-pool.mjs b/scripts/build-pool.mjs
index 4609183..6640dca 100644
--- a/scripts/build-pool.mjs
+++ b/scripts/build-pool.mjs
@@ -3,7 +3,7 @@
// that only a wide sweep can afford.
//
// node scripts/build-pool.mjs --in= [--out=bench/pool.json]
-// [--endgameMinOpen=3] [--maxDeadTurns=1]
+// [--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.
@@ -27,7 +27,7 @@ import { deadTurnAudit, deepLevelKey } 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 MIN_OPEN = Number(args.endgameMinOpen ?? 3);
+const MAX_TIGHT = Number(args.maxTightShare ?? 0.005);
const MAX_DEAD = Number(args.maxDeadTurns ?? 1);
// Shards from a sweep, or level files listed directly.
@@ -59,18 +59,18 @@ for (const level of unique) {
audited++;
}
-const open = unique.filter(l => (l.prototype.audit.endgameMinOpen ?? 0) >= MIN_OPEN);
+const open = unique.filter(l => (l.prototype.audit.endgameTightShare ?? 1) <= MAX_TIGHT);
const kept = open.filter(l => (l.prototype.audit.maxDeadTurns ?? Infinity) <= MAX_DEAD);
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(` 终局可放行朝向 ≥${MIN_OPEN} → ${open.length}(${Math.round(100 * open.length / unique.length)}%)`);
+console.log(` 终局紧局 ≤${(MAX_TIGHT*100).toFixed(1)}% → ${open.length}(${Math.round(100 * open.length / unique.length)}%)`);
console.log(` 连续空转 ≤${MAX_DEAD} → ${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: { endgameMinOpen: MIN_OPEN, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
+ gates: { maxTightShare: MAX_TIGHT, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
console.log(`已写入 ${OUT}`);
diff --git a/scripts/prototype-d5.mjs b/scripts/prototype-d5.mjs
index b4db869..37e646e 100644
--- a/scripts/prototype-d5.mjs
+++ b/scripts/prototype-d5.mjs
@@ -17,7 +17,12 @@ const OUT = resolve(args.out ?? 'levels/prototypes/d5.json');
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);
+// How many pieces each orientation may hold. It is the density dial: the
+// schedule is depth × perTurn placements, so perTurn 2 could never build a board
+// more than a fifth pieces no matter what `target` said. Swept, so the pool
+// carries both sparse and crowded boards to choose between.
+const PER_TURNS = String(args.perTurns ?? args.perTurn ?? '2,5,8').split(',').map(Number);
+const CAP = args.cap === undefined ? null : Number(args.cap);
const SHOWCASE = Number(args.showcase ?? 2);
// How leftover ports are paired. 'straight' was the only behaviour and it is
// what made boards read as a grid of long lines crossing everywhere.
@@ -53,8 +58,11 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
yieldTable[cellKey].locks++;
// 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];
+ for (const depth of DEPTHS) for (const PER_TURN of PER_TURNS) {
+ // A hard piece ceiling, from when crowded boards could not pass the endgame
+ // gate anyway. It truncated the placement schedule, so raising perTurn did
+ // nothing while it stood. Density is now a dial (perTurn), not a limit.
+ const cap = CAP;
const grown = growDeepLevel(base, lock, { seed, depth, perTurn: PER_TURN, target: cap, showcase: SHOWCASE });
const level = grown.level;
if (grown.solution.rotations < 3) continue;
@@ -68,8 +76,8 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
const show = showStats(level);
if (!show) continue;
const apt = monkeyActionsPerPiece(level);
- 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}`;
+ level.prototype = { family, radius, seed, depth, perTurn: PER_TURN, 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}-p${PER_TURN}`;
rows.push({ level, stats, audit, contrast, show, apt, family, radius, seed, depth });
}
}
@@ -81,10 +89,15 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
// 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.
+// The endgame gate. It used to demand a minimum over reachable endgames, read
+// off the same breadth search that counts dead turns — and on a crowded board
+// that search never reaches an endgame, so it answered null and the comparison
+// rejected the level for an endgame nobody had measured. Now the endgames are
+// enumerated outright and the gate is the SHARE that are tight (one orientation
+// or none). A minimum over more subsets falls for arithmetic reasons; the share
+// does not, and by share the crowded boards are the better ones — worst 0.33%
+// against 1.73% for the sparse.
+const MAX_TIGHT_SHARE = Number(args.maxTightShare ?? 0.005);
const MIN_RELEASE = Number(args.minRelease ?? 2);
const MIN_CONTRAST = Number(args.minContrast ?? 12);
// Expressed at the speed it was calibrated at (120ms per unit) and scaled to
@@ -98,7 +111,7 @@ const MIN_CRUISES = Number(args.minCruises ?? 3);
// 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
+ && r.stats.lockedShare <= .9 && r.audit.endgameTightShare <= MAX_TIGHT_SHARE
&& 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.
@@ -116,9 +129,9 @@ playable.sort((a, b) => (b.show.openingBestMs - a.show.openingBestMs)
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);
-const g4 = g3.filter(r => r.audit.endgameMinOpen >= 2);
+const g4 = g3.filter(r => r.audit.endgameTightShare <= MAX_TIGHT_SHARE);
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}`);
+console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.length} → 锁住≤90% ${g2.length} → 无不可解态 ${g3.length} → 终局紧局≤${(MAX_TIGHT_SHARE*100).toFixed(1)}% ${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);
diff --git a/src/deep-lock.js b/src/deep-lock.js
index 6b1cd64..ad096c7 100644
--- a/src/deep-lock.js
+++ b/src/deep-lock.js
@@ -344,6 +344,46 @@ export function deepLevelStats(level) {
// A level is judged on its endgame with this many pieces or fewer left.
const ENDGAME_PIECES = 3;
+// The endgame question — with a handful of pieces left, how many of the six
+// orientations release anything — used to be answered from the same breadth
+// search that counts dead turns. On a dense board that search hits its state
+// budget long before it reaches any endgame, so it reported `null`, and the
+// gate `endgameMinOpen >= 3` read null as a failure. Every level past about
+// eleven pieces was rejected for an endgame nobody had measured. That is why
+// no level in this project has ever been more than a third pieces.
+//
+// Enumerating the endgames directly costs nothing by comparison — C(26,3) is
+// 2600 — and it finishes, so a dense board gets a real answer. Reachability is
+// not checked: the enumeration is a superset of the endgames a player can
+// arrive at, which errs toward caution rather than toward a silent pass.
+//
+// The statistic is the SHARE of endgames that are tight, not the minimum over
+// them. A minimum taken over more subsets falls for arithmetic reasons alone —
+// the same confound that made `doubleDead` look eight times worse for deep
+// levels when the rate was 2% against 5%. By share, 26-piece boards come in at
+// 0.0% against 0.1% for 11-piece ones: they are not worse, they were unmeasured.
+export function endgameScan(level, k = ENDGAME_PIECES) {
+ const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
+ const ids = level.tiles.map(t => t.id);
+ const counts = {};
+ let subsets = 0, minOpen = 6, worst = null;
+ const visit = (from, pick) => {
+ if (pick.length) {
+ const tiles = level.tiles.filter(t => pick.includes(t.id));
+ let open = 0;
+ for (let o = 0; o < 6; o++) if (available(boards[o], tiles).length) open++;
+ counts[open] = (counts[open] || 0) + 1;
+ subsets++;
+ if (open < minOpen) { minOpen = open; worst = [...pick].sort().join(','); }
+ }
+ if (pick.length === k) return;
+ for (let i = from; i < ids.length; i++) visit(i + 1, [...pick, ids[i]]);
+ };
+ visit(0, []);
+ const tight = (counts[0] || 0) + (counts[1] || 0);
+ return { subsets, minOpen, worst, tightStates: tight, tightShare: subsets ? tight / subsets : 0 };
+}
+
export function deadTurnAudit(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const keyOf = (o, rem) => o + ':' + [...rem].sort().join(',');
@@ -391,9 +431,13 @@ export function deadTurnAudit(level, maxStates = 20000) {
if (!seen.has(k)) { const next = { o, rem: state.rem }; seen.set(k, next); queue.push(next); }
}
}
+ const endgame = endgameScan(level);
return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable,
- endgameMinOpen: endgameMinOpen === 6 && !openCache.size ? null : endgameMinOpen,
- endgameTightStates, endgameWorst, exhausted: seen.size >= maxStates };
+ // Endgame numbers come from the complete enumeration, never from this
+ // search — it may not have reached an endgame at all.
+ endgameMinOpen: endgame.minOpen, endgameTightStates: endgame.tightStates,
+ endgameTightShare: endgame.tightShare, endgameSubsets: endgame.subsets,
+ endgameWorst: endgame.worst, exhausted: seen.size >= maxStates };
}
export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|');