feat(bench): fit difficulty from a played round, and report when it cannot

refit-weights regresses the weights select-50 scores with against what agents
actually experienced, so a round of play is a flag on the next selection rather
than an edit. Ridge, because seven collinear features on fifty levels otherwise
hand back large coefficients that cancel.

Most of the work here is in refusing to produce a number. The response averages
the difficulty rating with two continuous measures of the same thing, and a
component with no spread is dropped by name instead of being silently averaged
in as zeros — which is what the first version did, reporting R² = 0.75 for a
fit that never saw a rating. When the rating is flat the script now says so and
says what the remaining fit is really measuring, which is board size.

legibility() asks what it costs to know what you may tap: how far the eye must
follow the cheapest free piece, and how many pieces are held by somebody their
line never touches. It was built to explain why ratings came back flat. It does
not — it correlates -0.02 with them — so it stays a measurement, printed on the
review sheet beside the longest route on the board, and gates nothing.

prototype-d5 takes --seedFrom so a wide sweep shards across cores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 17:03:43 +08:00
co-authored by Claude Opus 5
parent b35ea201db
commit d374c208f0
10 changed files with 3886 additions and 287 deletions
+249 -237
View File
@@ -1,240 +1,252 @@
{
"goal": "调出 50 关难度合适、好玩的关卡,交付人工审核",
"updated": "09-14 16:45",
"round": 0,
"definition": {
"title": "「难度合适且好玩」的操作定义",
"why": "旋转是免费的、是这个游戏的特色,玩家应该频繁做——所以不该限制旋转次数。该限制的是每一次旋转值不值:转了有没有棋子出来、能不能看出来该转哪、以及转完之后有没有「原来这么简单」的反差。评分里只有 difficulty(复测 r=0.93)和 stuck(r=0.85)可信,rotation / clarity / fun 是噪声(0.32 / 0.19 / 0.56),所以「好玩」全部换成机械代理。 另外,演出本身就是奖励——棋子沿弯路走出去的那几秒是减压点,所以开局必须有得看,而不是只有两枚贴边棋子和 0.3 秒。",
"rules": [
{
"k": "旋转要深",
"v": "难度来自「需要转很多次」,不是「转错受罚」。旋转数随关号上升,不设上限",
"how": "求解器最少旋转数"
},
{
"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 = 乱点就能过)",
"how": "生成时算"
},
{
"k": "不挫败",
"v": "stuck ≤2.0;终局任一朝向卡住时,左右转都能开",
"how": "grok 抽样 + 求解器"
},
{
"k": "不重复",
"v": "盘面签名互不相同,路线家族分布不偏科",
"how": "deepLevelKey"
}
]
},
"targets": {
"levels": 50,
"rotationDepth": [
{
"band": "L1–L12",
"rot": "2–3 转",
"difficulty": "1.5 – 2.0"
},
{
"band": "L13–L32",
"rot": "3–5 转",
"difficulty": "2.0 – 2.6"
},
{
"band": "L33–L50",
"rot": "5–8 转",
"difficulty": "2.6 – 3.0"
}
],
"gates": {
"releasePerTurn": "≥2",
"blindRotations": 0,
"contrast": "≥12",
"monkeyAPT": "≥3.0"
}
},
"phases": [
{
"id": "P0",
"title": "判据:先说清楚什么叫「好」",
"tasks": [
{
"id": "T0.1",
"title": "难度曲线与旋转配额定义",
"status": "done",
"note": "50 关分三档,0 转关卡死 10% 上限(用户硬要求)"
},
{
"id": "T0.2",
"title": "机械验收门槛",
"status": "done",
"note": "盲转=0、转错=0、终局可走朝向≥2 —— 前两项已由改锁结构达成"
},
{
"id": "T0.3",
"title": "「好玩」的机械代理指标",
"status": "done",
"note": "猴子 APT ≥3.0。fun 评分复测 r=0.56 不可用,改用「乱点过不了」当代理"
}
]
},
{
"id": "P1",
"title": "产能:解除旋转上限,把反差感做进筛选",
"tasks": [
{
"id": "T1.1",
"title": "growDeepLevel 扩展到 6 个朝向",
"status": "done",
"note": "现在只在朝向 0/1/2 插棋子,所以最少旋转恒等于 3 —— 这是「太简单」的根因"
},
{
"id": "T1.2",
"title": "解除 rotations===3 的硬限制,允许 4/5/6+",
"status": "done"
},
{
"id": "T1.3",
"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": "每转释放 ≥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 可同步对比"
}
]
},
{
"id": "P2",
"title": "标定:把机械指标映射到真实难度",
"tasks": [
{
"id": "T2.1",
"title": "grok 抽样跑候选池,收集 difficulty / stuck",
"status": "todo",
"note": "分层抽样,覆盖各旋转档与各家族"
},
{
"id": "T2.2",
"title": "拟合 difficulty ~ 机械特征",
"status": "todo",
"note": "候选特征:旋转数、锁住率、猴子 APT、棋子数、路线长度、终局路口数"
},
{
"id": "T2.3",
"title": "用模型给全部候选打分并按曲线选 50 关",
"status": "todo"
}
]
},
{
"id": "P3",
"title": "迭代:跑几轮把曲线调平",
"tasks": [
{
"id": "T3.1",
"title": "第 1 轮:grok 全量跑 50 关",
"status": "todo"
},
{
"id": "T3.2",
"title": "按实测曲线调整选关,重选",
"status": "todo"
},
{
"id": "T3.3",
"title": "第 2 轮验证",
"status": "todo"
},
{
"id": "T3.4",
"title": "收敛判定(难度曲线单调、stuck 达标)",
"status": "todo"
}
]
},
{
"id": "P4",
"title": "交付",
"tasks": [
{
"id": "T4.1",
"title": "50 关关卡集",
"status": "todo"
},
{
"id": "T4.2",
"title": "人工审核页(可直接试玩每一关)",
"status": "todo"
},
{
"id": "T4.3",
"title": "后续关卡制作流程的优化总结",
"status": "todo"
}
]
}
],
"rounds": [],
"log": [
{
"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;拐点以下手感不变。同时发现开局演出是结构性缺陷并在构造里修掉"
}
"goal": "调出 50 关难度合适、好玩的关卡,交付人工审核",
"updated": "09-14 16:45",
"round": 1,
"definition": {
"title": "「难度合适且好玩」的操作定义",
"why": "旋转是免费的、是这个游戏的特色,玩家应该频繁做——所以不该限制旋转次数。该限制的是每一次旋转值不值:转了有没有棋子出来、能不能看出来该转哪、以及转完之后有没有「原来这么简单」的反差。评分里只有 difficulty(复测 r=0.93)和 stuck(r=0.85)可信,rotation / clarity / fun 是噪声(0.32 / 0.19 / 0.56),所以「好玩」全部换成机械代理。 另外,演出本身就是奖励——棋子沿弯路走出去的那几秒是减压点,所以开局必须有得看,而不是只有两枚贴边棋子和 0.3 秒。",
"rules": [
{
"k": "旋转要深",
"v": "难度来自「需要转很多次」,不是「转错受罚」。旋转数随关号上升,不设上限",
"how": "求解器最少旋转数"
},
{
"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 = 乱点就能过)",
"how": "生成时算"
},
{
"k": "不挫败",
"v": "stuck ≤2.0;终局任一朝向卡住时,左右转都能开",
"how": "grok 抽样 + 求解器"
},
{
"k": "不重复",
"v": "盘面签名互不相同,路线家族分布不偏科",
"how": "deepLevelKey"
}
]
},
"targets": {
"levels": 50,
"rotationDepth": [
{
"band": "L1–L12",
"rot": "2–3 转",
"difficulty": "1.5 – 2.0"
},
{
"band": "L13–L32",
"rot": "3–5 转",
"difficulty": "2.0 – 2.6"
},
{
"band": "L33–L50",
"rot": "5–8 转",
"difficulty": "2.6 – 3.0"
}
],
"gates": {
"releasePerTurn": "≥2",
"blindRotations": 0,
"contrast": "≥12",
"monkeyAPT": "≥3.0"
}
},
"phases": [
{
"id": "P0",
"title": "判据:先说清楚什么叫「好」",
"tasks": [
{
"id": "T0.1",
"title": "难度曲线与旋转配额定义",
"status": "done",
"note": "50 关分三档,0 转关卡死 10% 上限(用户硬要求)"
},
{
"id": "T0.2",
"title": "机械验收门槛",
"status": "done",
"note": "盲转=0、转错=0、终局可走朝向≥2 —— 前两项已由改锁结构达成"
},
{
"id": "T0.3",
"title": "「好玩」的机械代理指标",
"status": "done",
"note": "猴子 APT ≥3.0。fun 评分复测 r=0.56 不可用,改用「乱点过不了」当代理"
}
]
},
{
"id": "P1",
"title": "产能:解除旋转上限,把反差感做进筛选",
"tasks": [
{
"id": "T1.1",
"title": "growDeepLevel 扩展到 6 个朝向",
"status": "done",
"note": "现在只在朝向 0/1/2 插棋子,所以最少旋转恒等于 3 —— 这是「太简单」的根因"
},
{
"id": "T1.2",
"title": "解除 rotations===3 的硬限制,允许 4/5/6+",
"status": "done"
},
{
"id": "T1.3",
"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": "每转释放 ≥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 可同步对比"
}
]
},
{
"id": "P2",
"title": "标定:把机械指标映射到真实难度",
"tasks": [
{
"id": "T2.1",
"title": "grok 抽样跑候选池,收集 difficulty / stuck",
"status": "done",
"note": "R1:grok-4.6 xhigh 全量跑 50 关,收齐 difficulty / stuck / 机械指标与自由文本抱怨"
},
{
"id": "T2.2",
"title": "拟合 difficulty ~ 机械特征",
"status": "blocked",
"note": "做不成,且不是样本量问题:27 关里 26 关 difficulty 都打 2 分,响应几乎无方差,回归的是常数。根因在 bench 本身——文字盘面把每颗棋子的路线、挡路者、紧贴者直接列成清单,agent 不必用眼睛顺线走,而那正是这游戏的难点。agent 原话:「挡路名单已经写全,不用顺着格子数」。为解释它另建的 legibility 指标与评分相关 -0.02,同样无效,已记录为负结果不采用"
},
{
"id": "T2.3",
"title": "用模型给全部候选打分并按曲线选 50 关",
"status": "done",
"note": "select-50 按机械特征打分并均匀铺开,家族上限 36%。权重是设计假设而非拟合结果(见 T2.2),由人工审核裁定"
}
]
},
{
"id": "P3",
"title": "迭代:跑几轮把曲线调平",
"tasks": [
{
"id": "T3.1",
"title": "第 1 轮:grok 全量跑 50 关",
"status": "done",
"note": "50 关全部跑完;机械面干净:空点 0、盲转 ~0.3/关、全部通关。总体读数:整批偏易一档(2 分 vs 刚好的 3 分)"
},
{
"id": "T3.2",
"title": "按实测曲线调整选关,重选",
"status": "doing",
"note": "按 R1 唯一可用的总体读数「偏易」调整:扩池到 400 种子后把旋转深度整体上推,而不是按关排名重选"
},
{
"id": "T3.3",
"title": "第 2 轮验证",
"status": "todo"
},
{
"id": "T3.4",
"title": "收敛判定(难度曲线单调、stuck 达标)",
"status": "todo"
}
]
},
{
"id": "P4",
"title": "交付",
"tasks": [
{
"id": "T4.1",
"title": "50 关关卡集",
"status": "todo"
},
{
"id": "T4.2",
"title": "人工审核页(可直接试玩每一关)",
"status": "done",
"note": "docs/review-50.html:50 张开局盘面缩略图 + 机械指标 + agent 实测 + 试玩直达;并标注「哪些数字能信」,把感知类问题(路线长度、紧贴)显式交给人眼"
},
{
"id": "T4.3",
"title": "后续关卡制作流程的优化总结",
"status": "todo"
}
]
}
],
"rounds": [],
"log": [
{
"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;拐点以下手感不变。同时发现开局演出是结构性缺陷并在构造里修掉"
},
{
"at": "R1 结论",
"text": "难度评分在这批关卡上无区分度(26/27 都是 2 分),原因是文字 bench 把判断白送给了 agent。结论:agent 轮次用于抓缺陷(死局、盲转、空点、演出),难度排序只能靠机械假设 + 人工审核。这条改变了后续流程的设计"
},
{
"at": "未擅自改动",
"text": "agent 33 条抱怨里两大主题是路线太长(68 格 27 弯)与紧贴易看漏。但已上线的 v0.5 在这两项上更严重(最长 88 格 vs 84 格),属于游戏本来的性格;且文字盘面会同时夸大和缩小感知问题。因此只在审核页标出,不擅自设闸门"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2350
View File
File diff suppressed because one or more lines are too long
+21 -14
View File
@@ -98,28 +98,32 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
<header class="head">
<div class="eyebrow">HEX TRAILS · 关卡调优</div>
<h1>调出 50 关难度合适、好玩的关卡,交付人工审核</h1>
<p class="meta mono">第 0 轮 · 更新于 09-14 16:45 · 本页每 60 秒自动刷新</p>
<p class="meta mono">第 1 轮 · 更新于 09-14 16:45 · 本页每 60 秒自动刷新</p>
<div class="progress">
<div class="bar"><i style="width:43%"></i></div>
<div class="lbl"><span>9 / 21 项完成</span><span class="mono">43%</span></div>
<div class="bar"><i style="width:62%"></i></div>
<div class="lbl"><span>13 / 21 项完成</span><span class="mono">62%</span></div>
</div>
</header>
<h2><span class="num">A</span>现在的状态</h2>
<div class="grid">
<div class="tile "><div class="k">候选关卡</div><div class="v">18 <span style="font-size:14px;color:var(--ink3)">/ 50</span></div><div class="n">当前关卡集</div></div>
<div class="tile ok"><div class="k">候选关卡</div><div class="v">50 <span style="font-size:14px;color:var(--ink3)">/ 50</span></div><div class="n">当前关卡集</div></div>
<div class="tile ok"><div class="k">盲转 / 关</div><div class="v">0.00</div><div class="n">目标 0</div></div>
<div class="tile ok"><div class="k">转错方向 / 关</div><div class="v">0.00</div><div class="n">目标 0</div></div>
<div class="tile ok"><div class="k">猴子 动作÷棋子</div><div class="v">4.96</div><div class="n">目标 ≥3.0(不无脑)</div></div>
<div class="tile ok"><div class="k">猴子 动作÷棋子</div><div class="v">6.53</div><div class="n">目标 ≥3.0(不无脑)</div></div>
</div>
<div class="scroll"><table>
<thead><tr><th>最少旋转数</th><th class="n">候选关数</th><th>说明</th></tr></thead>
<tbody>
<tr><td>3 转</td><td class="n">18</td><td class="sub">浅</td></tr>
<tr><td colspan="3" class="sub">旋转深度没有梯度——生成器把最少旋转写死了,这正是「太简单」的根因</td></tr>
<tr><td>1 转</td><td class="n">3</td><td class="sub">浅</td></tr>
<tr><td>2 转</td><td class="n">10</td><td class="sub">浅</td></tr>
<tr><td>3 转</td><td class="n">31</td><td class="sub">浅</td></tr>
<tr><td>4 转</td><td class="n">3</td><td class="sub">中</td></tr>
<tr><td>5 转</td><td class="n">3</td><td class="sub">中</td></tr>
</tbody></table></div>
<p class="sub">路线家族分布:switchback ×4 · weave ×4 · petals ×4 · orbits ×4 · crossing ×2 终局可走朝向分布:{"2":7,"3":11}</p>
<p class="sub">路线家族分布:v05 ×13 · switchback ×7 · weave ×18 · orbits ×10 · spiral ×2 终局可走朝向分布:{"1":5,"2":22,"3":21,"4":1,"5":1}</p>
<h2><span class="num">B</span>什么叫「难度合适且好玩」</h2>
<div class="callout"><p class="sub">旋转是免费的、是这个游戏的特色,玩家应该频繁做——所以不该限制旋转次数。该限制的是每一次旋转值不值:转了有没有棋子出来、能不能看出来该转哪、以及转完之后有没有「原来这么简单」的反差。评分里只有 difficulty(复测 r=0.93)和 stuck(r=0.85)可信,rotation / clarity / fun 是噪声(0.32 / 0.19 / 0.56),所以「好玩」全部换成机械代理。 另外,演出本身就是奖励——棋子沿弯路走出去的那几秒是减压点,所以开局必须有得看,而不是只有两枚贴边棋子和 0.3 秒。</p></div>
@@ -168,17 +172,17 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
<div class="phase">
<h3><span class="pid">P2</span>标定:把机械指标映射到真实难度</h3>
<ul class="tasks">
<li class="todo"><div class="t"><span class="tid">T2.1</span><span class="tt">grok 抽样跑候选池,收集 difficulty / stuck</span><span class="tag idle">待办</span></div><div class="note">分层抽样,覆盖各旋转档与各家族</div></li>
<li class="todo"><div class="t"><span class="tid">T2.2</span><span class="tt">拟合 difficulty ~ 机械特征</span><span class="tag idle">待办</span></div><div class="note">候选特征:旋转数、锁住率、猴子 APT、棋子数、路线长度、终局路口数</div></li>
<li class="todo"><div class="t"><span class="tid">T2.3</span><span class="tt">用模型给全部候选打分并按曲线选 50 关</span><span class="tag idle">待办</span></div></li>
<li class="done"><div class="t"><span class="tid">T2.1</span><span class="tt">grok 抽样跑候选池,收集 difficulty / stuck</span><span class="tag good">完成</span></div><div class="note">R1:grok-4.6 xhigh 全量跑 50 关,收齐 difficulty / stuck / 机械指标与自由文本抱怨</div></li>
<li class="blocked"><div class="t"><span class="tid">T2.2</span><span class="tt">拟合 difficulty ~ 机械特征</span><span class="tag bad">受阻</span></div><div class="note">做不成,且不是样本量问题:27 关里 26 关 difficulty 都打 2 分,响应几乎无方差,回归的是常数。根因在 bench 本身——文字盘面把每颗棋子的路线、挡路者、紧贴者直接列成清单,agent 不必用眼睛顺线走,而那正是这游戏的难点。agent 原话:「挡路名单已经写全,不用顺着格子数」。为解释它另建的 legibility 指标与评分相关 -0.02,同样无效,已记录为负结果不采用</div></li>
<li class="done"><div class="t"><span class="tid">T2.3</span><span class="tt">用模型给全部候选打分并按曲线选 50 关</span><span class="tag good">完成</span></div><div class="note">select-50 按机械特征打分并均匀铺开,家族上限 36%。权重是设计假设而非拟合结果(见 T2.2),由人工审核裁定</div></li>
</ul>
</div>
<div class="phase">
<h3><span class="pid">P3</span>迭代:跑几轮把曲线调平</h3>
<ul class="tasks">
<li class="todo"><div class="t"><span class="tid">T3.1</span><span class="tt">第 1 轮:grok 全量跑 50 关</span><span class="tag idle">待办</span></div></li>
<li class="todo"><div class="t"><span class="tid">T3.2</span><span class="tt">按实测曲线调整选关,重选</span><span class="tag idle">待办</span></div></li>
<li class="done"><div class="t"><span class="tid">T3.1</span><span class="tt">第 1 轮:grok 全量跑 50 关</span><span class="tag good">完成</span></div><div class="note">50 关全部跑完;机械面干净:空点 0、盲转 ~0.3/关、全部通关。总体读数:整批偏易一档(2 分 vs 刚好的 3 分)</div></li>
<li class="doing"><div class="t"><span class="tid">T3.2</span><span class="tt">按实测曲线调整选关,重选</span><span class="tag live">进行中</span></div><div class="note">按 R1 唯一可用的总体读数「偏易」调整:扩池到 400 种子后把旋转深度整体上推,而不是按关排名重选</div></li>
<li class="todo"><div class="t"><span class="tid">T3.3</span><span class="tt">第 2 轮验证</span><span class="tag idle">待办</span></div></li>
<li class="todo"><div class="t"><span class="tid">T3.4</span><span class="tt">收敛判定(难度曲线单调、stuck 达标)</span><span class="tag idle">待办</span></div></li>
</ul>
@@ -188,7 +192,7 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
<h3><span class="pid">P4</span>交付</h3>
<ul class="tasks">
<li class="todo"><div class="t"><span class="tid">T4.1</span><span class="tt">50 关关卡集</span><span class="tag idle">待办</span></div></li>
<li class="todo"><div class="t"><span class="tid">T4.2</span><span class="tt">人工审核页(可直接试玩每一关)</span><span class="tag idle">待办</span></div></li>
<li class="done"><div class="t"><span class="tid">T4.2</span><span class="tt">人工审核页(可直接试玩每一关)</span><span class="tag good">完成</span></div><div class="note">docs/review-50.html:50 张开局盘面缩略图 + 机械指标 + agent 实测 + 试玩直达;并标注「哪些数字能信」,把感知类问题(路线长度、紧贴)显式交给人眼</div></li>
<li class="todo"><div class="t"><span class="tid">T4.3</span><span class="tt">后续关卡制作流程的优化总结</span><span class="tag idle">待办</span></div></li>
</ul>
</div>
@@ -200,6 +204,7 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
<div class="scroll"><table>
<thead><tr><th>批次</th><th class="n">关数</th><th class="n">difficulty</th><th class="n">stuck</th><th class="n">盲转/关</th><th class="n">多转/关</th></tr></thead>
<tbody>
<tr><td>R1 · grok xhigh</td><td class="n">39</td><td class="n">2.05</td><td class="n">1.36</td><td class="n">0.15</td><td class="n">0.18</td></tr>
<tr><td>Codex CLI · gpt-6-astra low</td><td class="n">18</td><td class="n">2.44</td><td class="n">1.44</td><td class="n">1.17</td><td class="n">1.22</td></tr>
<tr><td>Codex CLI · spark xhigh</td><td class="n">18</td><td class="n">3.11</td><td class="n">2.28</td><td class="n">1.17</td><td class="n">1.17</td></tr>
<tr><td>Grok Build CLI · xhigh</td><td class="n">18</td><td class="n">2.39</td><td class="n">1.83</td><td class="n">1.17</td><td class="n">1.22</td></tr>
@@ -214,6 +219,8 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
<h2><span class="num">F</span>日志</h2>
<ul class="log">
<li><b>未擅自改动</b>agent 33 条抱怨里两大主题是路线太长(68 格 27 弯)与紧贴易看漏。但已上线的 v0.5 在这两项上更严重(最长 88 格 vs 84 格),属于游戏本来的性格;且文字盘面会同时夸大和缩小感知问题。因此只在审核页标出,不擅自设闸门</li>
<li><b>R1 结论</b>难度评分在这批关卡上无区分度(26/27 都是 2 分),原因是文字 bench 把判断白送给了 agent。结论:agent 轮次用于抓缺陷(死局、盲转、空点、演出),难度排序只能靠机械假设 + 人工审核。这条改变了后续流程的设计</li>
<li><b>演出</b>离场动画封顶定稿(拐点 2.4s / 上限 3.2s),最长 17.4s→3.5s;拐点以下手感不变。同时发现开局演出是结构性缺陷并在构造里修掉</li>
<li><b>P1 进展</b>旋转深度打开:生成分布从「全部恒为3」变成 {3,4,5,6,7,8,9}。两次结构性猜测失败并已撤回(插入端多朝向出口过滤实测零效果)。深关卡的瓶颈定位为终局三枚互锁,改用扩大扫描量应对</li>
<li><b>方向修正</b>旋转配额的思路被推翻:旋转免费且是核心特色,不该限次数,该限的是每次旋转的回报与可读性。实测发现 17 关最少旋转全部恒为 3(生成器写死),且 petals/crossing 两个家族几乎没有反差感(平均 2.0–5.7 对比 weave 的 22–33)</li>
+225 -35
View File
File diff suppressed because one or more lines are too long
+24
View File
@@ -9,6 +9,7 @@
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { trace, rotateBoard } from '../src/core.js';
import { legibility } from '../src/deep-lock.js';
import { layout } from '../src/geometry.js';
import { metrics } from '../src/bench.js';
@@ -62,6 +63,15 @@ function thumbnail(level, size = 168) {
+ `<g class="cells">${cells}</g><g class="pieces">${pieces}</g></svg>`;
}
// The two things the agent complained about in all 33 reports it filed, and the
// two the text bench is least able to judge for us: how long the longest line
// on the board is, and how many pieces are held by somebody their line never
// touches. Both are perceptual, so they are printed rather than gated on.
const perceptual = Object.fromEntries(levels.map(l => [l.id, {
longest: Math.max(...l.tiles.map(t => trace(l, t, [], true).steps.length)),
...legibility(l),
}]));
const rated = levels.map(l => verdicts[l.id]).filter(Boolean);
const band = (a, b) => levels.slice(a, b).map(l => verdicts[l.id]).filter(Boolean);
const bandRow = (label, a, b) => {
@@ -116,6 +126,7 @@ td.n,th.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap
.thumb text{fill:#fff;text-anchor:middle;font-family:system-ui}
.kv{display:grid;grid-template-columns:auto auto;gap:1px 8px;font-size:11.5px;color:var(--ink2)}
.kv b{font-family:ui-monospace,monospace;color:var(--ink);font-weight:600;text-align:right}
.kv b.hot{color:var(--warn)}
.verdict{font-size:11.5px;border-top:1px dashed var(--rule);padding-top:7px;color:var(--ink2)}
.verdict .row{display:flex;gap:9px;flex-wrap:wrap;font-family:ui-monospace,monospace}
.verdict .worst{margin-top:4px;color:var(--ink3);line-height:1.5}
@@ -123,6 +134,9 @@ td.n,th.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap
.pill{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:20px;font-weight:600}
.pill.bad{background:var(--bad);color:#fff}
.pill.warn{background:var(--warn);color:#fff}
.note{background:var(--panel);border:1px solid var(--rule);border-left:3px solid var(--rail);border-radius:10px;padding:4px 18px;margin:12px 0 8px}
.note p{font-size:13.5px;color:var(--ink2);max-width:78ch}
.note b{color:var(--ink)}
footer{margin-top:50px;padding-top:18px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
</style>
</head>
@@ -143,6 +157,14 @@ ${bandRow('后档 L33–L50', 32, 50)}
</table>
<p class="meta">难度 3 = 刚刚好;无解感越低越好。这两项是评分里唯二复测可信的(r = 0.93 / 0.85),其余几项是模型噪声,不列。</p>
<h2>这批数字里,哪些能信</h2>
<div class="note">
<p><b>机械面是干净的,可以信。</b>agent 跑完 ${rated.length} 关:空点 ${f1(mean(rated.map(m => m.deadTaps)))} 次/关、盲转 ${f1(mean(rated.map(m => m.blindRotations)))} 次/关、反向转 ${f1(mean(rated.map(m => m.wrongWayRotations)))} 次/关,${rated.filter(m => m.cleared).length}/${rated.length} 关通关。「动了半天没结果」这类结构缺陷已经被生成期的闸门挡掉了。</p>
<p><b>难度评分不能信,它区分不了这批关卡。</b>${rated.length} 关里有 ${rated.filter(m => Number(m.rating?.difficulty) === 2).length} 关被打成 2 分,而这些关卡机械上差着三倍(决策数 0–8)。原因不在关卡,在 bench:文字盘面会把每颗棋子的路线、挡路者、紧贴者<b>直接列成清单</b>,agent 从来不需要用眼睛顺着线走一遍——而那正是这个游戏的难点所在。agent 自己在报告里写过这句:「挡路名单已经写全,不用顺着格子数」。</p>
<p><b>所以难度顺序是机械假设,等你的判定。</b>L1 到 L50 是按旋转深度、棋子数、互锁程度排的,不是按谁测出来更难。评分唯一能用的是它的<b>总体读数</b>:整批偏易一档(2 分 vs 刚刚好的 3 分)。</p>
<p><b>两件事我没有擅自改,留给你定。</b>agent 的 33 条抱怨里反复出现两个主题:路线太长看不完(「68 格 27 弯」「42 格 42 弯」),以及紧贴容易看漏(「看着能出界却会擦到」)。两项都是感知问题,而<b>已上线的 v0.5 在这两项上更严重</b>(最长路线 88 格 vs 这里 ${Math.max(...Object.values(perceptual).map(p => p.longest))} 格)——它是这个游戏本来的性格,不是这次构造引入的。卡片上「最长路线」超 45 格、「紧贴棋子」超 1 枚会标黄,你试玩时留意。</p>
</div>
<h2>逐关</h2>
<div class="grid">
${levels.map((l, i) => {
@@ -160,6 +182,8 @@ ${levels.map((l, i) => {
<span>开局可点</span><b>${c.openingChoices ?? '?'} 枚</b>
<span>开局演出</span><b>${c.openingBestMs != null ? (c.openingBestMs / 1000).toFixed(1) + 's' : '?'}</b>
<span>长巡游 / 反差</span><b>${c.cruises ?? '?'} · ${c.contrast ?? '?'}</b>
<span>最长路线</span><b class="${perceptual[l.id].longest > 45 ? 'hot' : ''}">${perceptual[l.id].longest} 格</b>
<span>紧贴棋子</span><b class="${perceptual[l.id].grazeTraps > 1 ? 'hot' : ''}">${perceptual[l.id].grazeTraps} 枚</b>
</div>
${v ? `<div class="verdict">
<div class="row"><span>难度 ${v.rating?.difficulty ?? '—'}</span><span>无解感 ${v.rating?.stuck ?? '—'}</span><span>${v.steps} 步</span><span>${v.decisions} 决策</span></div>
+3 -1
View File
@@ -10,6 +10,8 @@ import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAu
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);
// Seeds are independent, so a wide sweep shards across cores by seed range.
const SEED_FROM = Number(args.seedFrom ?? 0);
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);
@@ -41,7 +43,7 @@ const yieldTable = {};
for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
const cellKey = `${family}/r${radius}`;
yieldTable[cellKey] = { locks: 0, grown: 0, seeds: SEEDS };
for (let seed = 0; seed < SEEDS; seed++) {
for (let seed = SEED_FROM; seed < SEED_FROM + SEEDS; seed++) {
const base = createRouteBoard(seed, { family, radius });
const lock = createDeepLock(base, { seed, maxChecks: 600000 });
if (!lock) continue;
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
// Refit the difficulty weights that select-50 scores with, from a round of
// agent play.
//
// node scripts/refit-weights.mjs [--agent="R1 · grok xhigh"] [--ridge=0.3]
//
// select-50 scores a candidate from mechanical features, but the weights on
// those features started as guesses. This fits them against what agents
// actually experienced, and prints the --weights flag for the next round.
//
// The response is not the difficulty rating alone. That rating is reliable
// (r = 0.93 on retest) but it is a 1-5 integer, so on fifty levels it is nearly
// constant and a fit against it alone would be fitting rounding. It is averaged
// with two continuous measures of the same thing — how many decisions the level
// forced, and how much action went nowhere — each standardised first.
//
// Ridge, because seven features on fifty levels with collinear inputs (bigger
// boards hold more pieces) will otherwise hand back large coefficients that
// cancel. The printed R² says whether the fit earned its keep; when it is near
// zero, the honest move is to keep the old weights and say the round carried no
// signal.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { metrics } from '../src/bench.js';
import { legibility } 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 AGENT = args.agent ?? 'R1 · grok xhigh';
const RIDGE = Number(args.ridge ?? 0.3);
// The features select-50 scores with, plus the two the design corrections
// argued matter — how much the opening offers, and how much a winding route
// pays off over a straight one.
const FEATURES = ['rotations', 'pieces', 'lockedShare', 'monkeyAPT', 'cells', 'contrast', 'openingChoices',
'glanceCost', 'medianVerify', 'grazeTraps'];
const featureOf = level => {
const c = level.campaign ?? {};
const p = level.prototype ?? {};
return {
rotations: c.rotations ?? 3, pieces: c.pieces ?? level.tiles.length,
lockedShare: c.lockedShare ?? 0, monkeyAPT: p.monkeyActionsPerPiece ?? 0,
cells: level.cells.length, contrast: c.contrast ?? 0,
openingChoices: c.openingChoices ?? 0,
...(({ glanceCost, medianVerify, grazeTraps }) => ({ glanceCost: glanceCost ?? 0, medianVerify, grazeTraps }))(legibility(level)),
};
};
const levels = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8')).levels;
const byId = new Map(levels.map(l => [l.id, l]));
const played = new Map();
const RUNS = resolve('bench/runs');
for (const f of existsSync(RUNS) ? readdirSync(RUNS) : []) {
if (!f.endsWith('.json') || f === 'index.json') continue;
try {
const d = JSON.parse(readFileSync(resolve(RUNS, f), 'utf8'));
if (d.agent !== AGENT || !byId.has(d.level?.id)) continue;
played.set(d.level.id, metrics(d));
} catch { /* torn read */ }
}
const rows = [...played].map(([id, m]) => ({ id, m, x: featureOf(byId.get(id)) }))
.filter(r => Number.isFinite(Number(r.m.rating?.difficulty)));
if (rows.length < 12) {
console.error(`只有 ${rows.length} 关有评分,样本太少,不拟合。`);
process.exit(1);
}
const mean = a => a.reduce((x, y) => x + y, 0) / a.length;
const sd = a => { const m = mean(a); return Math.sqrt(mean(a.map(v => (v - m) ** 2))); };
const z = a => { const m = mean(a), s = sd(a); return s === 0 ? a.map(() => 0) : a.map(v => (v - m) / s); };
// Three views of the same quantity: what the agent said, how much it had to
// decide, and how much of what it did led nowhere.
const parts = {
difficulty: rows.map(r => Number(r.m.rating.difficulty)),
decisions: rows.map(r => Math.log1p(r.m.decisions ?? 0)),
wasted: rows.map(r => Math.log1p((r.m.deadTaps ?? 0) + (r.m.blindRotations ?? 0) + (r.m.wrongWayRotations ?? 0))),
};
// A part with no spread contributes nothing but still divides the average, so
// it is dropped by name rather than silently zeroed. This matters: a round
// where every level came back rated 2 leaves an effort-only response, and
// effort alone largely re-measures board size.
const liveParts = Object.entries(parts).filter(([, v]) => sd(v) > 1e-9);
const flatParts = Object.keys(parts).filter(k => !liveParts.some(([n]) => n === k));
if (!liveParts.length) { console.error('本轮没有任何一项有方差,无从拟合。'); process.exit(1); }
const zparts = liveParts.map(([, v]) => z(v));
const y = rows.map((_, i) => mean(zparts.map(p => p[i])));
const zy = z(y);
// Drop features the round could not separate: a column that never varies
// carries no information and a ridge fit would still hand it a coefficient.
const live = FEATURES.filter(f => sd(rows.map(r => r.x[f])) > 1e-9);
const dropped = FEATURES.filter(f => !live.includes(f));
const cols = live.map(f => rows.map(r => r.x[f]));
const sds = Object.fromEntries(live.map((f, j) => [f, sd(cols[j])]));
const Z = live.map((_, j) => z(cols[j]));
// beta = (Z'Z + λI)^-1 Z'y, solved by Gaussian elimination with partial pivoting.
const p = live.length, n = rows.length;
const A = Array.from({ length: p }, (_, i) => Array.from({ length: p + 1 }, (_, j) => {
if (j === p) return Z[i].reduce((s, v, k) => s + v * zy[k], 0) / n;
return Z[i].reduce((s, v, k) => s + v * Z[j][k], 0) / n + (i === j ? RIDGE : 0);
}));
for (let i = 0; i < p; i++) {
let piv = i;
for (let k = i + 1; k < p; k++) if (Math.abs(A[k][i]) > Math.abs(A[piv][i])) piv = k;
[A[i], A[piv]] = [A[piv], A[i]];
for (let k = 0; k < p; k++) {
if (k === i || A[i][i] === 0) continue;
const f = A[k][i] / A[i][i];
for (let j = i; j <= p; j++) A[k][j] -= f * A[i][j];
}
}
const beta = A.map((row, i) => row[i] === 0 ? 0 : row[p] / row[i]);
const fitted = rows.map((_, k) => live.reduce((s, _f, j) => s + beta[j] * Z[j][k], 0));
const ssRes = zy.reduce((s, v, k) => s + (v - fitted[k]) ** 2, 0);
const r2 = 1 - ssRes / zy.reduce((s, v) => s + v * v, 0);
// Back to raw-feature units, then rescaled so scores land in the same range as
// the round just played — the score is only ever used to rank and to spread
// picks evenly, so its scale is free, and holding it steady keeps the printed
// band numbers comparable from round to round.
const OLD = { rotations: 1.0, pieces: 0.10, lockedShare: 1.6, monkeyAPT: 0.14, cells: 0.004, contrast: 0, openingChoices: 0, glanceCost: 0, medianVerify: 0, grazeTraps: 0 };
const raw = Object.fromEntries(live.map((f, j) => [f, beta[j] / sds[f]]));
const scoreWith = w => rows.map(r => live.reduce((s, f) => s + (w[f] ?? 0) * r.x[f], 0));
const gain = sd(scoreWith(OLD)) / (sd(scoreWith(raw)) || 1);
const fit = Object.fromEntries(live.map(f => [f, Number((raw[f] * gain).toFixed(4))]));
for (const f of dropped) fit[f] = OLD[f] ?? 0;
const corr = (a, b) => { const za = z(a), zb = z(b); return mean(za.map((v, i) => v * zb[i])); };
console.log(`样本 ${n} 关 · agent ${AGENT} · ridge ${RIDGE}`);
const NAME = { difficulty: '评分难度', decisions: 'log 决策数', wasted: 'log 无效动作' };
console.log(`合成难度 = ${liveParts.map(([k]) => 'z(' + NAME[k] + ')').join(' + ')} 的均值`);
if (flatParts.length) {
console.log(` !! ${flatParts.map(k => NAME[k]).join('、')} 本轮方差为 0,没参与合成`);
if (flatParts.includes('difficulty')) {
console.log(' !! 难度评分全同分 —— 这一轮拿不到难度信号,剩下的只是「这关有多少事要做」,');
console.log(' 而那主要由棋子数和格子数决定。照这组权重重选,等于按盘面大小排序。');
}
}
for (const [k, v] of liveParts) for (const [k2, v2] of liveParts) if (k < k2) console.log(` ${NAME[k]} × ${NAME[k2]}:${corr(v, v2).toFixed(2)}`);
console.log(`拟合 R² = ${r2.toFixed(3)}${r2 < 0.25 ? ' ← 太低,这一轮没给出可用信号,别换权重' : ''}`);
if (flatParts.includes('difficulty')) console.log('R² 高不代表可用:被拟合的是工作量,不是难度。');
console.log('');
console.log('| 特征 | 单独相关 | 标准化系数 | 旧权重 | 新权重 |');
console.log('|---|---:|---:|---:|---:|');
for (const f of FEATURES) {
const j = live.indexOf(f);
const c = j < 0 ? null : corr(rows.map(r => r.x[f]), y);
console.log(`| ${f} | ${c == null ? '—(本轮无变化)' : c.toFixed(2)} | ${j < 0 ? '—' : beta[j].toFixed(3)} | ${(OLD[f] ?? 0)} | ${fit[f]} |`);
}
console.log('');
console.log(`--weights='${JSON.stringify(fit)}'`);
+34
View File
@@ -223,6 +223,40 @@ export function contrastStats(level) {
};
}
// What it costs to know what you may tap. A player verifies a piece by
// following its line until it leaves the board or meets somebody; length and
// bends are what that costs the eye. This is the difficulty the design brief
// asks for — 无法一下子判断 — and it is not board size: a wide board whose
// nearest free piece is three cells away reads instantly, while a small one
// where every free piece hides behind a thirty-cell switchback does not.
//
// Grazes are counted apart because they are not difficulty at all, they are a
// lie. A piece can have an empty line and still be held, by a neighbour the
// route never touches; following that line tells the player it is free, and
// the tap does nothing. That is the "many actions, no result" the brief rules
// out, so a level wants none of these at its opening.
export function legibility(level) {
const occupant = new Map(level.tiles.map(t => [`${t.q},${t.r}`, t.id]));
const costs = [];
let grazeTraps = 0;
for (const tile of level.tiles) {
const geo = trace(level, tile, [], true); // the drawn line, ignoring who sits on it
const real = trace(level, tile, level.tiles); // where the piece would actually stop
const onRoute = geo.steps.some(s => { const who = occupant.get(`${s.q},${s.r}`); return who && who !== tile.id; });
if (real.status !== 'exit' && !onRoute) grazeTraps++;
// Verification stops where the eye stops: at the blocker, or at the edge.
const seen = real.status === 'exit' ? geo : real;
costs.push({ id: tile.id, free: real.status === 'exit', cost: seen.steps.length + 2 * countBends(seen) });
}
const free = costs.filter(c => c.free).map(c => c.cost);
const all = costs.map(c => c.cost).sort((a, b) => a - b);
return {
glanceCost: free.length ? Math.min(...free) : null, // the cheapest correct move to find
medianVerify: all.length ? all[Math.floor(all.length / 2)] : 0,
grazeTraps,
};
}
// 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,