From 83796804d02f7bd743421de6f39cd67869a73a53 Mon Sep 17 00:00:00 2001 From: cabinw Date: Mon, 14 Sep 2026 20:05:54 +0800 Subject: [PATCH] feat(levels): gate on crossing points, and lay the campaign out as segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/build-pool.mjs | 9 ++++++--- scripts/prototype-d5.mjs | 20 +++++++++++++++----- scripts/select-50.mjs | 37 +++++++++++++++++++++++++++++++------ src/deep-lock.js | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/scripts/build-pool.mjs b/scripts/build-pool.mjs index 6640dca..ce40d4b 100644 --- a/scripts/build-pool.mjs +++ b/scripts/build-pool.mjs @@ -23,12 +23,13 @@ // 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 } from '../src/deep-lock.js'; +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())); @@ -60,14 +61,16 @@ for (const level of unique) { } 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 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} → ${kept.length}(${Math.round(100 * kept.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))}`); diff --git a/scripts/prototype-d5.mjs b/scripts/prototype-d5.mjs index 37e646e..7775dcc 100644 --- a/scripts/prototype-d5.mjs +++ b/scripts/prototype-d5.mjs @@ -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, contrastStats, showStats, openingShowFloor } from '../src/deep-lock.js'; +import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats, openingShowFloor, 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 ?? true]; })); const SEEDS = Number(args.seeds ?? 8), TARGET = Number(args.target ?? 12), KEEP = Number(args.keep ?? 18); @@ -73,12 +73,13 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) { const stats = deepLevelStats(level); const audit = deadTurnAudit(level); const contrast = contrastStats(level); + const shape = trackShape(level); const show = showStats(level); if (!show) continue; const apt = monkeyActionsPerPiece(level); - 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.prototype = { family, radius, seed, depth, perTurn: PER_TURN, cap, shape, 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 }); + rows.push({ level, stats, audit, contrast, show, apt, shape, family, radius, seed, depth }); } } } @@ -98,6 +99,12 @@ const grownEnough = rows.filter(r => r.stats.pieces >= MIN_PIECES); // 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); +// How many crossing points a cell may average. Straight filler put this at 2.36 +// and the boards read as a net; bending the leftover ports alone brings it to +// 0.93. Holding it under 1.0 costs a third of the candidates and is the +// difference between a drawing you can follow and one that makes your head +// hurt, which is how the boards were described. +const MAX_CROSS = Number(args.maxCrossPerCell ?? 1.0); 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 @@ -117,7 +124,8 @@ const playable = grownEnough.filter(r => r.audit.unsolvable === 0 && r.stats.fir // 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); + && r.show.cruises >= MIN_CRUISES + && r.shape.crossPerCell <= MAX_CROSS); // 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, @@ -135,8 +143,10 @@ console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.le 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(2)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${playable.length}`); +const g9 = g8.filter(r => r.show.cruises >= MIN_CRUISES); +console.log(` → 开局可点≥${MIN_OPENING_CHOICES} ${g7.length} → 开局演出≥${(MIN_OPENING_SHOW / 1000).toFixed(2)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${g9.length} → 交叉/格≤${MAX_CROSS} ${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.shape.crossPerCell)).toFixed(2)} · 直线占比 中位 ${(med(playable.map(r => r.shape.straightShare))*100).toFixed(0)}% · 密度 中位 ${(med(playable.map(r => r.level.tiles.length/r.level.cells.length))*100).toFixed(0)}%`); 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 = []; diff --git a/scripts/select-50.mjs b/scripts/select-50.mjs index 65df2ea..6e2245e 100644 --- a/scripts/select-50.mjs +++ b/scripts/select-50.mjs @@ -22,6 +22,14 @@ const FAMILY_CAP = Number(args.familyCap ?? 0.36); // no matter how the score is weighted. A quota per depth states the curve // instead of hoping it falls out of the ranking. const CURVE = args.curve ? JSON.parse(args.curve) : null; +// A plan lays the campaign out as segments played in order, each naming how +// many levels it wants and what kind. Depth alone cannot express the shape the +// brief asks for, because the two things that make a level good pull against +// each other: a crowded board clears beautifully but caps at three turns, while +// a deep lock needs room to breathe and so stays sparse. Sorting by depth would +// file every crowded level first and every deep one last. Segments alternate +// them on purpose. +const PLAN = args.plan ? JSON.parse(readFileSync(resolve(args.plan), 'utf8')) : null; // Within one depth, what to take. Spreading evenly across the ranked slice was // the default, and it quietly dilutes the thing the design brief calls the // point of the game: levels the agent rated hardest carried 59% more 反差 than @@ -109,7 +117,22 @@ const spread = (rows, want) => { }; const shortfall = []; -if (CURVE) { +if (PLAN) { + const density = row => row.level.tiles.length / row.level.cells.length; + for (const seg of PLAN) { + const want = Number(seg.count); + const rows = scored.filter(r => { + if (seg.rotations && !seg.rotations.includes(r.stats.rotations)) return false; + if (seg.minDensity != null && density(r) < seg.minDensity) return false; + if (seg.maxDensity != null && density(r) > seg.maxDensity) return false; + return true; + }); + const got = spread(rows, want); + picked.push(...got); + if (got.length < want) shortfall.push({ depth: seg.name ?? '段', want, got: got.length, pool: rows.length }); + } + if (picked.length < COUNT) picked.push(...spread(scored, COUNT - picked.length)); +} else if (CURVE) { // Deepest first: the deep end is the scarce one, so it gets to pick before a // family cap can be spent on levels the shallow end has hundreds of. const depths = Object.keys(CURVE).map(Number).sort((a, b) => b - a); @@ -126,7 +149,9 @@ if (CURVE) { } else { picked.push(...spread(scored, COUNT)); } -picked.sort((a, b) => (a.stats.rotations ?? 0) - (b.stats.rotations ?? 0) || a.score - b.score); +// A plan is an arrangement, not a bag: re-sorting it by depth would undo the +// alternation it exists to create. +if (!PLAN) picked.sort((a, b) => (a.stats.rotations ?? 0) - (b.stats.rotations ?? 0) || a.score - b.score); const levels = picked.map((row, i) => { const level = JSON.parse(JSON.stringify(row.level)); @@ -160,12 +185,12 @@ const avg = (rows, f) => rows.reduce((s, r) => s + f(r), 0) / Math.max(1, rows.l console.log(`已选 ${levels.length} 关 → ${OUT}`); console.log(`家族分布 ${JSON.stringify(used)}(单家族上限 ${cap})`); console.log(`旋转深度 ${JSON.stringify(picked.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}${CURVE ? `(目标 ${JSON.stringify(CURVE)})` : ''}`); -for (const s of shortfall) console.log(` !! ${s.depth} 转只凑到 ${s.got}/${s.want} 关 —— 池子里总共才 ${s.pool} 关,缺口已由其他深度补上`); +for (const s of shortfall) console.log(` !! ${s.depth} 只凑到 ${s.got}/${s.want} 关 —— 符合条件的候选总共才 ${s.pool} 个,缺口已由其他条件补上`); console.log(''); -console.log('| 档 | 关号 | 难度分 | 旋转 | 棋子 | 锁住% | 反差 | 开局可点 | 开局演出 |'); -console.log('|---|---|---:|---:|---:|---:|---:|---:|---:|'); +console.log('| 档 | 关号 | 难度分 | 旋转 | 棋子 | 密度 | 锁住% | 反差 | 开局可点 | 开局演出 |'); +console.log('|---|---|---:|---:|---:|---:|---:|---:|---:|---:|'); for (const [label, a, b] of [['前档', 0, 12], ['中档', 12, 32], ['后档', 32, levels.length]]) { const rows = band(a, b); if (!rows.length) continue; - console.log(`| ${label} | L${a + 1}–L${b} | ${avg(rows, r => r.score).toFixed(2)} | ${avg(rows, r => r.stats.rotations).toFixed(1)} | ${avg(rows, r => r.stats.pieces).toFixed(1)} | ${(avg(rows, r => r.stats.lockedShare) * 100).toFixed(0)}% | ${avg(rows, r => r.contrast).toFixed(1)} | ${avg(rows, r => r.show?.openingChoices ?? 0).toFixed(1)} | ${(avg(rows, r => r.show?.openingBestMs ?? 0) / 1000).toFixed(2)}s |`); + console.log(`| ${label} | L${a + 1}–L${b} | ${avg(rows, r => r.score).toFixed(2)} | ${avg(rows, r => r.stats.rotations).toFixed(1)} | ${avg(rows, r => r.stats.pieces).toFixed(1)} | ${(avg(rows, r => r.level.tiles.length / r.level.cells.length) * 100).toFixed(0)}% | ${(avg(rows, r => r.stats.lockedShare) * 100).toFixed(0)}% | ${avg(rows, r => r.contrast).toFixed(1)} | ${avg(rows, r => r.show?.openingChoices ?? 0).toFixed(1)} | ${(avg(rows, r => r.show?.openingBestMs ?? 0) / 1000).toFixed(2)}s |`); } diff --git a/src/deep-lock.js b/src/deep-lock.js index ad096c7..c5dc965 100644 --- a/src/deep-lock.js +++ b/src/deep-lock.js @@ -344,6 +344,42 @@ export function deepLevelStats(level) { // A level is judged on its endgame with this many pieces or fewer left. const ENDGAME_PIECES = 3; +// How busy the drawing is, as two numbers taken from the same data. A cell's +// six ports are paired into three chords; a chord joining opposite ports is a +// diameter, drawn as a straight line, and two diameters in one cell always +// cross. So "the board is all straight lines" and "there are too many crossing +// points" are one property seen twice — which is why the filler that paired +// leftover ports straight across produced both at once. +export function trackShape(level) { + let pairs = 0, straight = 0, crossings = 0, busiest = 0; + for (const cell of level.cells) { + const seen = new Set(), chords = []; + for (let port = 0; port < 6; port++) { + if (seen.has(port)) continue; + const other = cell.tracks[port]; + seen.add(port); seen.add(other); + chords.push([port, other]); + } + const between = (x, a, b) => { for (let k = (a + 1) % 6; k !== b; k = (k + 1) % 6) if (k === x) return true; return false; }; + let here = 0; + for (let i = 0; i < chords.length; i++) { + pairs++; + if ((chords[i][0] + 3) % 6 === chords[i][1]) straight++; + for (let j = i + 1; j < chords.length; j++) { + const [a, b] = chords[i], [c, d] = chords[j]; + if (between(c, a, b) !== between(d, a, b)) here++; + } + } + crossings += here; + busiest = Math.max(busiest, here); + } + return { + straightShare: pairs ? straight / pairs : 0, + crossPerCell: level.cells.length ? crossings / level.cells.length : 0, + crossings, busiestCell: busiest, + }; +} + // 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