From 0aa080c7c36e95c4b4cdd113fd34dd30269316bd Mon Sep 17 00:00:00 2001 From: cabinw Date: Mon, 14 Sep 2026 16:45:24 +0800 Subject: [PATCH] feat(levels): give every level an opening worth watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chain locks all four of its pieces at the opening by construction, and the pieces grown around it are mostly locked too, so a level built from it hands a player two rim pieces and a third of a second of animation before any thinking starts. Measured across the 235-level pool: median two tappable pieces at the opening, and the best departure available plays for 0.57s. Growth now reserves a couple of slots for showcase pieces — tappable at the opening, with a route long enough to read as a journey and short enough to stay under the animation ceiling. Median opening choices go 2 to 3 and the best opening show 0.72s to 1.12s, without shortcutting the lock: each candidate is still put through the solver. Rotation quota is gone from the board. Turning is free and it is what the game is about, so depth is grown rather than capped; what is gated is whether each turn pays out, whether pieces swing between hopeless and trivial, and whether there is a show up front. Co-Authored-By: Claude Opus 5 --- bench/board.json | 123 ++++++++++++++++++++++++++------------- docs/level-board.html | 57 ++++++++++-------- scripts/build-board.mjs | 10 ++-- scripts/prototype-d5.mjs | 81 ++++++++++++++++++++------ src/deep-lock.js | 123 +++++++++++++++++++++++++++++++++++---- 5 files changed, 296 insertions(+), 98 deletions(-) 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产能:解除旋转上限,把反差感做进筛选

    -
  • T1.1深锁关(3 转)扩产到 ≥60 个候选待办
    当前 14 种子出 17 关,需扩大种子与半径扫描
  • -
  • T1.2中档(2 转)生成路径待办
    deep-lock 固定 3 转,v0.5 最多 2 转 —— 中档要么放宽锁链,要么从 v0.5 筛
  • -
  • T1.3简单档(0–1 转)候选池待办
    从 v0.5 筛,但必须过猴子 APT 门槛(v0.5 全库中位 1.67,大部分会被刷掉)
  • -
  • T1.4去重与家族均衡待办
    6 个路线家族不能偏科;v0.5 曾出现 1000 关只有 30 个模板
  • +
  • T1.1growDeepLevel 扩展到 6 个朝向完成
    现在只在朝向 0/1/2 插棋子,所以最少旋转恒等于 3 —— 这是「太简单」的根因
  • +
  • T1.2解除 rotations===3 的硬限制,允许 4/5/6+完成
  • +
  • T1.3反差感指标进 deepLevelStats 并作门槛完成
    contrastStats 已进 deepLevelStats 并作门槛;实测 weave 22–33、switchback 29、spiral 18–24、orbits 12–17,petals 仅 2.0–5.7、crossing 3.8–5.5
  • +
  • T1.4每转释放 ≥2 枚的门槛完成
    改为按朝向配额插棋(每个朝向 ≥2 枚在等),取代原来的随机比例——随机比例会留下空转的朝向
  • +
  • T1.5扩产到 ≥120 候选并去重、家族均衡进行中
    候选池已达 235 关(深度 {3:165,4:46,5:22,7:2}),正用含开局展示位的构造重扫
  • +
  • T1.6终局三枚互锁(hero+gate+tail)受阻
    深关卡的主要死因:5 转以上有 60% 死在「终局单出口」。实测终局是锁链自己的三枚棋子在 6 个朝向里堵死 5 个。双向释放约束只覆盖了 hero/tail 两枚,三枚组合覆盖不到——这是可达状态的性质,构造时难保证。当前对策是提高扫描量靠筛选拿到,不是修构造
  • +
  • T1.7开局展示位(快速爽到)完成
    实测整个构造的开局中位只有 2 枚可点、最佳演出 0.57s——锁链开局全锁死。在构造里预留 2 个开局位(立即可点且路线长到值得看),实测开局可点 2→3 枚、最佳演出 0.72s→1.12s
  • +
  • T1.8离场演出封顶完成
    计时原本线性无上限,最长一次 17.4s,正式关 18.8% 超过 2s。拐点 2.4s 以下完全不变,以上加速逼近 3.2s 上限。labs/exit-timing 可同步对比
@@ -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];