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
A现在的状态
-路线家族分布: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 秒。
-- 难度:agent 的 difficulty 评分沿关号上升,末档逼近 3.0(3 = 刚刚好),全程不超过 3.2(grok 抽样评分)
-- 不挫败:stuck ≤ 2.0,盲转 = 0,转错方向 = 0(规则机器人全量扫,12 秒)
-- 不无脑:猴子玩家 动作÷棋子 ≥ 3.0(v0.5 全库 1.67 = 乱点就能过)(生成时算)
-- 旋转是主线:0 旋转关 ≤ 5 关(10%),且随关号推进旋转数递增(求解器)
-- 不重复:50 关盘面签名互不相同,路线家族分布不偏科(deepLevelKey 去重)
+- 旋转要深:难度来自「需要转很多次」,不是「转错受罚」。旋转数随关号上升,不设上限(求解器最少旋转数)
+- 每转都有回报:每一次旋转至少放出 2 枚棋子;无效转动 = 0(deepLevelStats.releasePerTurn / 规则机器人)
+- 演出是回报:开局就要有得看:可点 ≥3 枚、其中最好的一条离场演出 ≥0.9s;全关长巡游(≥0.9s)≥3 次(showStats,用真实动画计时)
+- 反差感:平均反差 ≥12:棋子在某个朝向看着像噩梦(长路线多弯),转过去就是几步走出(代价 = 格数 + 2×弯数)(六朝向逐棋子代价差)
+- 不无脑:猴子 动作÷棋子 ≥3.0(v0.5 全库 1.67 = 乱点就能过)(生成时算)
+- 不挫败:stuck ≤2.0;终局任一朝向卡住时,左右转都能开(grok 抽样 + 求解器)
+- 不重复:盘面签名互不相同,路线家族分布不偏科(deepLevelKey)
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日志
+- 演出离场动画封顶定稿(拐点 2.4s / 上限 3.2s),最长 17.4s→3.5s;拐点以下手感不变。同时发现开局演出是结构性缺陷并在构造里修掉
+- P1 进展旋转深度打开:生成分布从「全部恒为3」变成 {3,4,5,6,7,8,9}。两次结构性猜测失败并已撤回(插入端多朝向出口过滤实测零效果)。深关卡的瓶颈定位为终局三枚互锁,改用扩大扫描量应对
+- 方向修正旋转配额的思路被推翻:旋转免费且是核心特色,不该限次数,该限的是每次旋转的回报与可读性。实测发现 17 关最少旋转全部恒为 3(生成器写死),且 petals/crossing 两个家族几乎没有反差感(平均 2.0–5.7 对比 weave 的 22–33)
- 起点已完成前置工作:擦碰漏报修复(113/581 例)、终局单出口门槛、锁结构改为双向可开 —— 规则机器人盲转/转错/多转已全部归零
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
路线家族分布:${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)}
| 关号区间 | 旋转数 | 目标 difficulty 评分 |
-${board.targets.difficultyCurve.map(b => `| ${esc(b.band)} | ${esc(b.rot)} | ${esc(b.difficulty)} |
`).join('\n')}
+${board.targets.rotationDepth.map(b => `| ${esc(b.band)} | ${esc(b.rot)} | ${esc(b.difficulty)} |
`).join('\n')}
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];