diff --git a/bench/board.json b/bench/board.json index 57db9cb..e178d5c 100644 --- a/bench/board.json +++ b/bench/board.json @@ -1,63 +1,73 @@ { "goal": "调出 50 关难度合适、好玩的关卡,交付人工审核", - "updated": "09-14 16:21", + "updated": "09-14 16:45", "round": 0, "definition": { - "title": "「难度合适且好玩」在这套度量下的操作定义", - "why": "评分里只有 difficulty(复测 r=0.93)和 stuck(r=0.85)可信;rotation / clarity / fun 的复测 r 只有 0.32 / 0.19 / 0.56,是模型噪声,不能拿来调参。所以「好玩」必须换成机械代理。", + "title": "「难度合适且好玩」的操作定义", + "why": "旋转是免费的、是这个游戏的特色,玩家应该频繁做——所以不该限制旋转次数。该限制的是每一次旋转值不值:转了有没有棋子出来、能不能看出来该转哪、以及转完之后有没有「原来这么简单」的反差。评分里只有 difficulty(复测 r=0.93)和 stuck(r=0.85)可信,rotation / clarity / fun 是噪声(0.32 / 0.19 / 0.56),所以「好玩」全部换成机械代理。 另外,演出本身就是奖励——棋子沿弯路走出去的那几秒是减压点,所以开局必须有得看,而不是只有两枚贴边棋子和 0.3 秒。", "rules": [ { - "k": "难度", - "v": "agent 的 difficulty 评分沿关号上升,末档逼近 3.0(3 = 刚刚好),全程不超过 3.2", - "how": "grok 抽样评分" + "k": "旋转要深", + "v": "难度来自「需要转很多次」,不是「转错受罚」。旋转数随关号上升,不设上限", + "how": "求解器最少旋转数" }, { - "k": "不挫败", - "v": "stuck ≤ 2.0,盲转 = 0,转错方向 = 0", - "how": "规则机器人全量扫,12 秒" + "k": "每转都有回报", + "v": "每一次旋转至少放出 2 枚棋子;无效转动 = 0", + "how": "deepLevelStats.releasePerTurn / 规则机器人" + }, + { + "k": "演出是回报", + "v": "开局就要有得看:可点 ≥3 枚、其中最好的一条离场演出 ≥0.9s;全关长巡游(≥0.9s)≥3 次", + "how": "showStats,用真实动画计时" + }, + { + "k": "反差感", + "v": "平均反差 ≥12:棋子在某个朝向看着像噩梦(长路线多弯),转过去就是几步走出(代价 = 格数 + 2×弯数)", + "how": "六朝向逐棋子代价差" }, { "k": "不无脑", - "v": "猴子玩家 动作÷棋子 ≥ 3.0(v0.5 全库 1.67 = 乱点就能过)", + "v": "猴子 动作÷棋子 ≥3.0(v0.5 全库 1.67 = 乱点就能过)", "how": "生成时算" }, { - "k": "旋转是主线", - "v": "0 旋转关 ≤ 5 关(10%),且随关号推进旋转数递增", - "how": "求解器" + "k": "不挫败", + "v": "stuck ≤2.0;终局任一朝向卡住时,左右转都能开", + "how": "grok 抽样 + 求解器" }, { "k": "不重复", - "v": "50 关盘面签名互不相同,路线家族分布不偏科", - "how": "deepLevelKey 去重" + "v": "盘面签名互不相同,路线家族分布不偏科", + "how": "deepLevelKey" } ] }, "targets": { "levels": 50, - "rotationQuota": { - "0": 5, - "1": 12, - "2": 15, - "3": 18 - }, - "difficultyCurve": [ + "rotationDepth": [ { "band": "L1–L12", - "rot": "0–1 转", + "rot": "2–3 转", "difficulty": "1.5 – 2.0" }, { "band": "L13–L32", - "rot": "1–2 转", + "rot": "3–5 转", "difficulty": "2.0 – 2.6" }, { "band": "L33–L50", - "rot": "2–3 转", + "rot": "5–8 转", "difficulty": "2.6 – 3.0" } - ] + ], + "gates": { + "releasePerTurn": "≥2", + "blindRotations": 0, + "contrast": "≥12", + "monkeyAPT": "≥3.0" + } }, "phases": [ { @@ -86,31 +96,54 @@ }, { "id": "P1", - "title": "产能:先得有足够多的候选", + "title": "产能:解除旋转上限,把反差感做进筛选", "tasks": [ { "id": "T1.1", - "title": "深锁关(3 转)扩产到 ≥60 个候选", - "status": "todo", - "note": "当前 14 种子出 17 关,需扩大种子与半径扫描" + "title": "growDeepLevel 扩展到 6 个朝向", + "status": "done", + "note": "现在只在朝向 0/1/2 插棋子,所以最少旋转恒等于 3 —— 这是「太简单」的根因" }, { "id": "T1.2", - "title": "中档(2 转)生成路径", - "status": "todo", - "note": "deep-lock 固定 3 转,v0.5 最多 2 转 —— 中档要么放宽锁链,要么从 v0.5 筛" + "title": "解除 rotations===3 的硬限制,允许 4/5/6+", + "status": "done" }, { "id": "T1.3", - "title": "简单档(0–1 转)候选池", - "status": "todo", - "note": "从 v0.5 筛,但必须过猴子 APT 门槛(v0.5 全库中位 1.67,大部分会被刷掉)" + "title": "反差感指标进 deepLevelStats 并作门槛", + "status": "done", + "note": "contrastStats 已进 deepLevelStats 并作门槛;实测 weave 22–33、switchback 29、spiral 18–24、orbits 12–17,petals 仅 2.0–5.7、crossing 3.8–5.5" }, { "id": "T1.4", - "title": "去重与家族均衡", - "status": "todo", - "note": "6 个路线家族不能偏科;v0.5 曾出现 1000 关只有 30 个模板" + "title": "每转释放 ≥2 枚的门槛", + "status": "done", + "note": "改为按朝向配额插棋(每个朝向 ≥2 枚在等),取代原来的随机比例——随机比例会留下空转的朝向" + }, + { + "id": "T1.5", + "title": "扩产到 ≥120 候选并去重、家族均衡", + "status": "doing", + "note": "候选池已达 235 关(深度 {3:165,4:46,5:22,7:2}),正用含开局展示位的构造重扫" + }, + { + "id": "T1.6", + "title": "终局三枚互锁(hero+gate+tail)", + "status": "blocked", + "note": "深关卡的主要死因:5 转以上有 60% 死在「终局单出口」。实测终局是锁链自己的三枚棋子在 6 个朝向里堵死 5 个。双向释放约束只覆盖了 hero/tail 两枚,三枚组合覆盖不到——这是可达状态的性质,构造时难保证。当前对策是提高扫描量靠筛选拿到,不是修构造" + }, + { + "id": "T1.7", + "title": "开局展示位(快速爽到)", + "status": "done", + "note": "实测整个构造的开局中位只有 2 枚可点、最佳演出 0.57s——锁链开局全锁死。在构造里预留 2 个开局位(立即可点且路线长到值得看),实测开局可点 2→3 枚、最佳演出 0.72s→1.12s" + }, + { + "id": "T1.8", + "title": "离场演出封顶", + "status": "done", + "note": "计时原本线性无上限,最长一次 17.4s,正式关 18.8% 超过 2s。拐点 2.4s 以下完全不变,以上加速逼近 3.2s 上限。labs/exit-timing 可同步对比" } ] }, @@ -190,6 +223,18 @@ { "at": "起点", "text": "已完成前置工作:擦碰漏报修复(113/581 例)、终局单出口门槛、锁结构改为双向可开 —— 规则机器人盲转/转错/多转已全部归零" + }, + { + "at": "方向修正", + "text": "旋转配额的思路被推翻:旋转免费且是核心特色,不该限次数,该限的是每次旋转的回报与可读性。实测发现 17 关最少旋转全部恒为 3(生成器写死),且 petals/crossing 两个家族几乎没有反差感(平均 2.0–5.7 对比 weave 的 22–33)" + }, + { + "at": "P1 进展", + "text": "旋转深度打开:生成分布从「全部恒为3」变成 {3,4,5,6,7,8,9}。两次结构性猜测失败并已撤回(插入端多朝向出口过滤实测零效果)。深关卡的瓶颈定位为终局三枚互锁,改用扩大扫描量应对" + }, + { + "at": "演出", + "text": "离场动画封顶定稿(拐点 2.4s / 上限 3.2s),最长 17.4s→3.5s;拐点以下手感不变。同时发现开局演出是结构性缺陷并在构造里修掉" } ] } \ No newline at end of file diff --git a/docs/level-board.html b/docs/level-board.html index d046555..b9338e9 100644 --- a/docs/level-board.html +++ b/docs/level-board.html @@ -98,46 +98,46 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
HEX TRAILS · 关卡调优

调出 50 关难度合适、好玩的关卡,交付人工审核

-

第 0 轮 · 更新于 09-14 16:21 · 本页每 60 秒自动刷新

+

第 0 轮 · 更新于 09-14 16:45 · 本页每 60 秒自动刷新

-
-
3 / 17 项完成18%
+
+
9 / 21 项完成43%

A现在的状态

-
候选关卡
17 / 50
当前关卡集
+
候选关卡
18 / 50
当前关卡集
盲转 / 关
0.00
目标 0
转错方向 / 关
0.00
目标 0
-
猴子 动作÷棋子
5.07
目标 ≥3.0(不无脑)
+
猴子 动作÷棋子
4.96
目标 ≥3.0(不无脑)
- + - - - - + +
旋转数目标关数现有缺口
最少旋转数候选关数说明
0 转50还差 5
1 转120还差 12
2 转150还差 15
3 转1817还差 1
3 转18浅
旋转深度没有梯度——生成器把最少旋转写死了,这正是「太简单」的根因
-

路线家族分布:weave ×4 · switchback ×1 · petals ×4 · orbits ×4 · crossing ×2 · spiral ×2 终局可走朝向分布:{"2":3,"3":13,"4":1}

+

路线家族分布:switchback ×4 · weave ×4 · petals ×4 · orbits ×4 · crossing ×2 终局可走朝向分布:{"2":7,"3":11}

B什么叫「难度合适且好玩」

-

评分里只有 difficulty(复测 r=0.93)和 stuck(r=0.85)可信;rotation / clarity / fun 的复测 r 只有 0.32 / 0.19 / 0.56,是模型噪声,不能拿来调参。所以「好玩」必须换成机械代理。

+

旋转是免费的、是这个游戏的特色,玩家应该频繁做——所以不该限制旋转次数。该限制的是每一次旋转值不值:转了有没有棋子出来、能不能看出来该转哪、以及转完之后有没有「原来这么简单」的反差。评分里只有 difficulty(复测 r=0.93)和 stuck(r=0.85)可信,rotation / clarity / fun 是噪声(0.32 / 0.19 / 0.56),所以「好玩」全部换成机械代理。 另外,演出本身就是奖励——棋子沿弯路走出去的那几秒是减压点,所以开局必须有得看,而不是只有两枚贴边棋子和 0.3 秒。

- - - + + +
关号区间旋转数目标 difficulty 评分
L1–L120–1 转1.5 – 2.0
L13–L321–2 转2.0 – 2.6
L33–L502–3 转2.6 – 3.0
L1–L122–3 转1.5 – 2.0
L13–L323–5 转2.0 – 2.6
L33–L505–8 转2.6 – 3.0

C任务

@@ -152,12 +152,16 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
-

P1产能:先得有足够多的候选

+

P1产能:解除旋转上限,把反差感做进筛选

@@ -210,6 +214,9 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v

F日志

diff --git a/scripts/build-board.mjs b/scripts/build-board.mjs index 8857351..cbe2a61 100644 --- a/scripts/build-board.mjs +++ b/scripts/build-board.mjs @@ -72,8 +72,7 @@ const STATUS = { done: ['完成', 'good'], doing: ['进行中', 'live'], todo: ['待办', 'idle'], blocked: ['受阻', 'bad'], }; -const quota = board.targets.rotationQuota; -const quotaRows = Object.entries(quota).map(([r, want]) => ({ r, want, have: pool.rot[r] || 0 })); +const depthRows = Object.entries(pool.rot).sort((a, b) => Number(a[0]) - Number(b[0])); const html = ` @@ -191,9 +190,10 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
- + -${quotaRows.map(q => ``).join('\n')} +${depthRows.map(([r, n]) => ``).join('\n')} +${depthRows.length <= 1 ? '' : ''}
旋转数目标关数现有缺口
最少旋转数候选关数说明
${q.r} 转${q.want}${q.have}${q.have >= q.want ? '已满足' : `还差 ${q.want - q.have}`}
${r} 转${n}${Number(r) <= 3 ? '浅' : Number(r) <= 5 ? '中' : '深'}
旋转深度没有梯度——生成器把最少旋转写死了,这正是「太简单」的根因

路线家族分布:${Object.entries(pool.families).map(([k, v]) => `${esc(k)} ×${v}`).join(' · ') || '—'} 终局可走朝向分布:${JSON.stringify(pool.endgame)}

@@ -205,7 +205,7 @@ ${board.definition.rules.map(r => `
  • ${esc(r.k)}:${esc(r.v)} -${board.targets.difficultyCurve.map(b => ``).join('\n')} +${board.targets.rotationDepth.map(b => ``).join('\n')}
    关号区间旋转数目标 difficulty 评分
    ${esc(b.band)}${esc(b.rot)}${esc(b.difficulty)}
    ${esc(b.band)}${esc(b.rot)}${esc(b.difficulty)}

    C任务

    diff --git a/scripts/prototype-d5.mjs b/scripts/prototype-d5.mjs index 476d531..856fd00 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 } from '../src/deep-lock.js'; +import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats } 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); @@ -14,6 +14,9 @@ const OUT = resolve(args.out ?? 'levels/prototypes/d5.json'); // 91-cell boards stay out by default: they are a readability problem, not a difficulty band. 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); +const SHOWCASE = Number(args.showcase ?? 2); // Random player: taps a random piece; after three blocked taps in a row, turns randomly. function monkeyActionsPerPiece(level, runs = 8) { @@ -43,8 +46,11 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) { const lock = createDeepLock(base, { seed, maxChecks: 600000 }); if (!lock) continue; yieldTable[cellKey].locks++; - const target = Math.min(TARGET, { 3: 9, 4: 12, 5: 14 }[radius]); - const grown = growDeepLevel(base, lock, { seed, target }); + // 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]; + const grown = growDeepLevel(base, lock, { seed, depth, perTurn: PER_TURN, target: cap, showcase: SHOWCASE }); const level = grown.level; if (grown.solution.rotations < 3) continue; const signature = deepLevelKey(level); @@ -53,10 +59,14 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) { yieldTable[cellKey].grown++; const stats = deepLevelStats(level); const audit = deadTurnAudit(level); + const contrast = contrastStats(level); + const show = showStats(level); + if (!show) continue; const apt = monkeyActionsPerPiece(level); - level.id = `proto-d5-${family}-r${radius}-s${seed}`; - level.prototype = { family, radius, seed, target, lock: { hero: lock.hero.id, heroBends: lock.heroRoute.bends, search: lock.search }, placements: grown.placements, rejected: grown.rejected, stats, audit, monkeyActionsPerPiece: apt }; - rows.push({ level, stats, audit, apt, family, radius, seed }); + 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}`; + rows.push({ level, stats, audit, contrast, show, apt, family, radius, seed, depth }); + } } } @@ -64,39 +74,72 @@ for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) { // count. The playable set also refuses what reads as "unsolvable": a fully // locked opening, a first turn that only works one way, or any stuck state // 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. +const MIN_RELEASE = Number(args.minRelease ?? 2); +const MIN_CONTRAST = Number(args.minContrast ?? 12); +const MIN_OPENING_SHOW = Number(args.minOpeningShow ?? 800); +const MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3); +const MIN_CRUISES = Number(args.minCruises ?? 3); +// Rotation count is deliberately not capped: turning is free and it is the +// point of the game. What has to hold is that every turn pays out (minRelease), +// 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); -playable.sort((a, b) => (a.audit.doubleDead - b.audit.doubleDead) || (b.stats.minRelease - a.stats.minRelease) || (b.stats.lockedShare - a.stats.lockedShare) || (a.apt - b.apt)); + && r.stats.lockedShare <= .9 && r.audit.endgameMinOpen >= 2 + && 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. + && r.show.openingChoices >= MIN_OPENING_CHOICES + && r.show.openingBestMs >= MIN_OPENING_SHOW + && r.show.cruises >= MIN_CRUISES); +// 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, +// and turns that pay out — and treat lock depth as a tiebreak only. +playable.sort((a, b) => (b.show.openingBestMs - a.show.openingBestMs) + || (b.contrast.contrast - a.contrast.contrast) + || (b.show.cruises - a.show.cruises) + || (a.audit.doubleDead - b.audit.doubleDead)); 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); -console.log(`\n【可玩性门槛】长成 ${grownEnough.length} 关 → 首转双向 ${g1.length} → 开局锁住 ≤90% ${g2.length} → 无不可解可达态 ${g3.length} → 终局可走朝向 ≥2 ${playable.length}`); +const g4 = g3.filter(r => r.audit.endgameMinOpen >= 2); +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}`); +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(1)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${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.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 = []; const perFamily = {}; for (const row of playable) { - perFamily[row.family] = (perFamily[row.family] || 0); - if (perFamily[row.family] >= Math.ceil(KEEP / ROUTE_FAMILIES.length) + 1) continue; - perFamily[row.family]++; + if (!KEEP_ALL) { + perFamily[row.family] = (perFamily[row.family] || 0); + if (perFamily[row.family] >= Math.ceil(KEEP / ROUTE_FAMILIES.length) + 1) continue; + perFamily[row.family]++; + } kept.push(row); - if (kept.length >= KEEP) break; + if (!KEEP_ALL && kept.length >= KEEP) break; } const pad = (v, w) => String(v).padStart(w); console.log(`D5 三转构造原型 · ${ROUTE_FAMILIES.length} 形态 × ${RADII.length} 尺寸 × ${SEEDS} 种子 · ${((Date.now() - started) / 1000).toFixed(1)} s`); console.log('\n【产出率】形态/尺寸 找到锁 长成 R=3 关'); for (const [k, v] of Object.entries(yieldTable)) console.log(' ' + k.padEnd(18) + pad(v.locks, 5) + '/' + v.seeds + pad(v.grown, 9) + '/' + v.seeds); -const all = rows.map(r => r.stats); -const med = arr => { const s = [...arr].sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : NaN; }; +const medAll = rows.map(r => r.stats); console.log(`\n【全部 ${rows.length} 个 R≥3 关卡】`); -console.log(` 旋转次数分布: ${JSON.stringify(all.reduce((m, s) => (m[s.rotations] = (m[s.rotations] || 0) + 1, m), {}))}`); -console.log(` 锁住占比(首转时剩余)中位 ${(med(all.map(s => s.lockedShare)) * 100).toFixed(0)}% (v0.5 现状中位 22%)`); -console.log(` 每次旋转最少释放 ≥2 枚的关: ${all.filter(s => s.minRelease >= 2).length}/${all.length}`); -console.log(` 首转只有一侧能开路的关: ${all.filter(s => s.firstTurnDirections === 1).length}/${all.length}`); +console.log(` 旋转次数分布: ${JSON.stringify(medAll.reduce((m, s) => (m[s.rotations] = (m[s.rotations] || 0) + 1, m), {}))}`); +console.log(` 锁住占比(首转时剩余)中位 ${(med(medAll.map(s => s.lockedShare)) * 100).toFixed(0)}% (v0.5 现状中位 22%)`); +console.log(` 每次旋转最少释放 ≥2 枚的关: ${medAll.filter(s => s.minRelease >= 2).length}/${medAll.length}`); +console.log(` 首转只有一侧能开路的关: ${medAll.filter(s => s.firstTurnDirections === 1).length}/${medAll.length}`); console.log(` 猴子 动作/棋子 中位 ${med(rows.map(r => r.apt)).toFixed(2)} (v0.5 全库 1.67 · v0.4 手作 3.42)`); console.log(`\n【写入 ${kept.length} 关】`); console.log(' id 格 枚 R 锁住% 每转释放 首转方向 双向死转态 最长盲转 终局路口 猴子APT hero弯'); diff --git a/src/deep-lock.js b/src/deep-lock.js index 1ce930e..ed870ca 100644 --- a/src/deep-lock.js +++ b/src/deep-lock.js @@ -1,4 +1,5 @@ -import { available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js'; +import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js'; +import { exitTiming } from './exit-animation.js'; const positionKey = tile => `${tile.q},${tile.r}`; const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length; @@ -109,11 +110,17 @@ export function createDeepLock(base, { // Reverse insertion keeps the chain intact while raising how much of the // board depends on turning. Each inserted piece must leave at its scheduled // orientation, and the solver must still need exactly three turns afterwards. +// Rotation is free and it is what the game is about, so depth is the thing to +// grow, not cap. Scheduling pieces at orientations 3/4/5 as well as 0/1/2 is +// what lets a level need five or six turns instead of always exactly three — +// and each of those extra turns pays out, because the piece scheduled there is +// waiting to leave. export function growDeepLevel(base, lock, { - seed = 0, target = 12, layerShares = { 0: .35, 1: .35, 2: .3 }, maxHeroBends = 70, minRotations = 3, + seed = 0, depth = 3, perTurn = 2, maxHeroBends = 70, minRotations = 3, target = null, + showcase = 2, showcaseMinDistance = 6, showcaseMaxDistance = 20, } = {}) { const rng = random(seed + 7919); - const boards = [0, 1, 2].map(turn => rotateBoard(base, turn)); + const boards = [0, 1, 2, 3, 4, 5].map(turn => rotateBoard(base, turn)); // A layer piece only needs a departure at its own orientation. Requiring // exits at every orientation would leave almost nothing to insert. const layerPool = boards.map(board => { @@ -125,13 +132,14 @@ export function growDeepLevel(base, lock, { return pool; }); const tiles = [...lock.tiles], placements = [...lock.placements]; - const extras = Math.max(0, target - tiles.length); + // Depth comes from a quota per orientation, not from random shares. Shares + // left some orientations empty, and an orientation with nothing waiting is a + // turn that pays nothing — the one thing that reliably reads as boring. const schedule = []; - for (const [turn, share] of Object.entries(layerShares)) for (let i = 0; i < Math.round(extras * share); i++) schedule.push(Number(turn)); - while (schedule.length < extras) schedule.push(0); - schedule.length = extras; - let rejected = { orientationless: 0, shortcut: 0 }; - for (const orientation of shuffled(schedule, rng)) { + for (let turn = 0; turn < depth; turn++) for (let i = 0; i < perTurn; i++) schedule.push(turn); + if (target != null) schedule.length = Math.max(0, Math.min(schedule.length, target - tiles.length)); + let rejected = { orientationless: 0, shortcut: 0, unfilled: 0 }; + for (const orientation of [...schedule].sort((a, b) => b - a)) { const occupied = new Set(tiles.map(positionKey)); const choices = shuffled(layerPool[orientation].filter(c => !occupied.has(c.position)), rng); let chosen = null, fallback = null; @@ -147,9 +155,33 @@ export function growDeepLevel(base, lock, { fallback = tile; } chosen = chosen || fallback; - if (!chosen) break; + // One orientation running out of candidates must not abandon the rest. + if (!chosen) { rejected.unfilled++; continue; } tiles.push(chosen); placements.push(orientation); } + // The chain locks everything at the opening by construction, so a level built + // only from it hands a casual player two rim pieces and a third of a second + // of animation before the thinking starts. Reserve a couple of slots for + // pieces that are tappable immediately and whose route is worth watching: + // long enough to read as a journey, short enough not to hit the ceiling. + for (let i = 0; i < showcase; i++) { + const occupied = new Set(tiles.map(positionKey)); + let best = null; + for (const c of shuffled(layerPool[0].filter(x => !occupied.has(x.position)), rng)) { + const tile = { ...c.tile, id: `t${tiles.length}`, color: tiles.length % 7 }, proposed = [...tiles, tile]; + const route = trace(boards[0], tile, proposed); + if (route.status !== 'exit') continue; + const { distance } = exitTiming(route); + if (distance < showcaseMinDistance || distance > showcaseMaxDistance) continue; + // It must not shortcut the lock it is decorating. + const solution = solveRotations({ ...base, tiles: proposed, moveLimit: proposed.length }); + if (!solution.solvable || solution.exhausted || solution.rotations < minRotations) continue; + if (!best || distance > best.distance) best = { tile, distance }; + } + if (!best) break; + tiles.push(best.tile); placements.push(0); + } + const level = { ...base, tiles, moveLimit: tiles.length, name: `${(base.name || 'route').replace(' · 路线草图', '')} · 深旋转`, @@ -165,6 +197,77 @@ export function growDeepLevel(base, lock, { // Structural evidence for the difficulty bands: how much of the board waits // for a turn, how many pieces each turn releases, and whether the direction // of the first turn is a real choice. +// How far a piece swings between "looks hopeless" and "walks straight out". +// Cost is what the eye judges from: cells travelled plus twice the bends. A +// piece that reads as a 128-cost tangle at one orientation and a 5-cost stroll +// at another is where the satisfaction comes from; families whose pieces all +// sit around the same cost no matter how the board turns feel mechanical. +export function contrastStats(level) { + const boards = [0, 1, 2, 3, 4, 5].map(k => rotateBoard(level, k)); + const cost = route => route.status === 'exit' ? route.steps.length + 2 * countBends(route) : null; + const HOPELESS = 60; + const swings = []; + for (const tile of level.tiles) { + const costs = boards.map(board => cost(trace(board, tile, [], true))); + const reachable = costs.filter(c => c != null); + if (!reachable.length) continue; + const best = Math.min(...reachable); + const opening = trace(level, tile, level.tiles); + const seen = opening.status === 'exit' ? cost(opening) : (costs[0] ?? HOPELESS); + swings.push(seen - best); + } + if (!swings.length) return { contrast: 0, maxSwing: 0 }; + return { + contrast: swings.reduce((a, b) => a + b, 0) / swings.length, + maxSwing: Math.max(...swings), + }; +} + +// What the player actually watches. Rotation costs no steps precisely so that +// people try things; the payout is the animation of a piece threading a winding +// route and leaving. So the show is measurable: how long each departure plays, +// whether the level opens with one, and — the failure the design brief names +// outright — how many actions can pass with nothing happening at all. +export function showStats(level) { + const solution = solveRotations(level); + if (!solution.solvable || !solution.actions) return null; + const game = new Game(level); + const shows = []; + let dry = 0, maxDry = 0, firstShow = null, sinceStart = 0; + for (const action of solution.actions) { + if (action.type === 'rotate') { game.rotate(action.direction); dry++; sinceStart++; maxDry = Math.max(maxDry, dry); continue; } + const result = game.tap(action.id); + sinceStart++; + if (result.status === 'exit') { + const ms = exitTiming(result).duration; + shows.push(ms); + if (firstShow === null) firstShow = { ms, afterActions: sinceStart }; + dry = 0; + } else { dry++; maxDry = Math.max(maxDry, dry); } + } + if (!shows.length) return null; + const sorted = [...shows].sort((a, b) => a - b); + // What the player can see straight away if they pick well, rather than what + // the optimal script happens to tap first. An opening whose only departure is + // a piece already touching the rim plays for 0.3s and shows nothing. + const opening = new Game(level); + const openingShows = available(level, opening.tiles) + .map(t => exitTiming(trace(level, t, opening.tiles)).duration); + return { + exits: shows.length, + openingChoices: openingShows.length, + openingBestMs: openingShows.length ? Math.round(Math.max(...openingShows)) : 0, + totalMs: Math.round(shows.reduce((a, b) => a + b, 0)), + medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]), + longestMs: Math.round(sorted[sorted.length - 1]), + // A cruise: long enough that watching it is the point, not a blink. + cruises: shows.filter(ms => ms >= 900).length, + firstShowMs: firstShow ? Math.round(firstShow.ms) : null, + firstShowAfter: firstShow ? firstShow.afterActions : null, + maxDryActions: maxDry, + }; +} + export function deepLevelStats(level) { const n = level.tiles.length, solution = solveRotations(level); let board = level, remaining = [...level.tiles];