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