feat(levels): gate the dead turn, and state the curve instead of hoping for it

Round 1 played all fifty levels and came back with the mechanics clean and one
complaint repeated across the reports: 短暂以为转错了方向 — a turn that changed
nothing. That one is mechanical rather than perceptual, so it can be gated.

The first attempt gated `doubleDead`, and it was wrong. That counts dead states,
which scales with the state space: three-turn levels have ~187 reachable stuck
states and five-turn ones ~739, so a raw count made deep levels look eight times
worse when the rate is 2% against 5%. It would have erased every level past
three turns — the opposite of the brief, where turning is the signature and is
not to be rationed. The gate is `maxDeadTurns`, the longest run of turns before
something opens, at 1: one beat of suspense, then a release. Eleven of the fifty
shipped at 2.

Selection could not express the curve either. Spreading picks evenly over the
ranked pool returns whatever the pool is made of, and the pool is 88%
three-turn, so no weighting moved the distribution off {3: 46}. A quota per
depth states the curve outright, deepest first so the scarce end picks before a
family cap is spent on levels the shallow end has hundreds of.

Same rule bot, both sets: blind rotations 0.24 → 0.12, worst blind streak 4 → 1,
decisions unchanged at 10.3. The same amount to do, half the wasted motion.
Rotation now runs 1.8 / 3.0 / 4.1 across the bands against 1.8 / 3.0 / 3.5.

Verdicts are keyed by the board rather than by its id, because a reselect
re-issues ids from the ordinal and `c50-01-v05` is not the same board before and
after — the review sheet was one reselect away from printing one level's verdict
under another's thumbnail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 17:13:02 +08:00
co-authored by Claude Opus 5
parent d374c208f0
commit ce7f6d30cf
24 changed files with 749603 additions and 60627 deletions
+6 -2
View File
@@ -125,7 +125,7 @@
"id": "T1.5",
"title": "扩产到 ≥120 候选并去重、家族均衡",
"status": "doing",
"note": "候选池已达 235 关(深度 {3:165,4:46,5:22,7:2}),正用含开局展示位的构造重扫"
"note": "扩产到 400 种子(8 片并行)。目的不是多,是为了付得起两道新闸门的代价:双向死转态 ≤2 只留 16%,在 94 个候选上会只剩 8 关"
},
{
"id": "T1.6",
@@ -185,7 +185,7 @@
"id": "T3.2",
"title": "按实测曲线调整选关,重选",
"status": "doing",
"note": "按 R1 唯一可用的总体读数「偏易」调整:扩池到 400 种子后把旋转深度整体上推,而不是按关排名重选"
"note": "R1 唯一可用的是总体读数「偏易」,不是逐关排名。据此定的 R2 改动只有一条、且是机械可测的:给 doubleDead(左右转都打不开任何棋子的可达卡住态)设闸门——它此前完全没设,当前 50 关中位 4、最高 27。这正是用户说的「无效转动就会很无聊」,也是 agent 后段反复写的「短暂以为转错了方向」"
},
{
"id": "T3.3",
@@ -247,6 +247,10 @@
{
"at": "未擅自改动",
"text": "agent 33 条抱怨里两大主题是路线太长(68 格 27 弯)与紧贴易看漏。但已上线的 v0.5 在这两项上更严重(最长 88 格 vs 84 格),属于游戏本来的性格;且文字盘面会同时夸大和缩小感知问题。因此只在审核页标出,不擅自设闸门"
},
{
"at": "R2 依据",
"text": "后段 14 条抱怨里两类:路线太长(感知,已对照 v0.5 基线否掉)与「转了没反应」(机械,可设闸门)。只改后者:endgameMinOpen 2→3、doubleDead 无闸门→≤2。两条合计只留 16% 候选,所以先扩池"
}
]
}
File diff suppressed because it is too large Load Diff
+22989
View File
File diff suppressed because it is too large Load Diff
+150442
View File
File diff suppressed because it is too large Load Diff
+359513
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -162,7 +162,7 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
<li class="done"><div class="t"><span class="tid">T1.2</span><span class="tt">解除 rotations===3 的硬限制,允许 4/5/6+</span><span class="tag good">完成</span></div></li>
<li class="done"><div class="t"><span class="tid">T1.3</span><span class="tt">反差感指标进 deepLevelStats 并作门槛</span><span class="tag good">完成</span></div><div class="note">contrastStats 已进 deepLevelStats 并作门槛;实测 weave 22–33、switchback 29、spiral 18–24、orbits 12–17,petals 仅 2.0–5.7、crossing 3.8–5.5</div></li>
<li class="done"><div class="t"><span class="tid">T1.4</span><span class="tt">每转释放 ≥2 枚的门槛</span><span class="tag good">完成</span></div><div class="note">改为按朝向配额插棋(每个朝向 ≥2 枚在等),取代原来的随机比例——随机比例会留下空转的朝向</div></li>
<li class="doing"><div class="t"><span class="tid">T1.5</span><span class="tt">扩产到 ≥120 候选并去重、家族均衡</span><span class="tag live">进行中</span></div><div class="note">候选池已达 235 关(深度 {3:165,4:46,5:22,7:2}),正用含开局展示位的构造重扫</div></li>
<li class="doing"><div class="t"><span class="tid">T1.5</span><span class="tt">扩产到 ≥120 候选并去重、家族均衡</span><span class="tag live">进行中</span></div><div class="note">扩产到 400 种子(8 片并行)。目的不是多,是为了付得起两道新闸门的代价:双向死转态 ≤2 只留 16%,在 94 个候选上会只剩 8 关</div></li>
<li class="blocked"><div class="t"><span class="tid">T1.6</span><span class="tt">终局三枚互锁(hero+gate+tail)</span><span class="tag bad">受阻</span></div><div class="note">深关卡的主要死因:5 转以上有 60% 死在「终局单出口」。实测终局是锁链自己的三枚棋子在 6 个朝向里堵死 5 个。双向释放约束只覆盖了 hero/tail 两枚,三枚组合覆盖不到——这是可达状态的性质,构造时难保证。当前对策是提高扫描量靠筛选拿到,不是修构造</div></li>
<li class="done"><div class="t"><span class="tid">T1.7</span><span class="tt">开局展示位(快速爽到)</span><span class="tag good">完成</span></div><div class="note">实测整个构造的开局中位只有 2 枚可点、最佳演出 0.57s——锁链开局全锁死。在构造里预留 2 个开局位(立即可点且路线长到值得看),实测开局可点 2→3 枚、最佳演出 0.72s→1.12s</div></li>
<li class="done"><div class="t"><span class="tid">T1.8</span><span class="tt">离场演出封顶</span><span class="tag good">完成</span></div><div class="note">计时原本线性无上限,最长一次 17.4s,正式关 18.8% 超过 2s。拐点 2.4s 以下完全不变,以上加速逼近 3.2s 上限。labs/exit-timing 可同步对比</div></li>
@@ -182,7 +182,7 @@ footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--rule);color:v
<h3><span class="pid">P3</span>迭代:跑几轮把曲线调平</h3>
<ul class="tasks">
<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="doing"><div class="t"><span class="tid">T3.2</span><span class="tt">按实测曲线调整选关,重选</span><span class="tag live">进行中</span></div><div class="note">R1 唯一可用的是总体读数「偏易」,不是逐关排名。据此定的 R2 改动只有一条、且是机械可测的:给 doubleDead(左右转都打不开任何棋子的可达卡住态)设闸门——它此前完全没设,当前 50 关中位 4、最高 27。这正是用户说的「无效转动就会很无聊」,也是 agent 后段反复写的「短暂以为转错了方向」</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>
@@ -204,7 +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>R1 · grok xhigh</td><td class="n">49</td><td class="n">2.06</td><td class="n">1.41</td><td class="n">0.16</td><td class="n">0.31</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>
@@ -219,6 +219,7 @@ 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>R2 依据</b>后段 14 条抱怨里两类:路线太长(感知,已对照 v0.5 基线否掉)与「转了没反应」(机械,可设闸门)。只改后者:endgameMinOpen 2→3、doubleDead 无闸门→≤2。两条合计只留 16% 候选,所以先扩池</li>
<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>
+156
View File
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>关卡制作流程复盘</title>
<style>
:root{
--ground:#EFF1F3; --surface:#FFF; --panel:#F6F8F9; --ink:#131C24; --ink2:#465766; --ink3:#6D7C89;
--rule:#D4DAE0; --rail:#3F5E79; --good:#226D63; --bad:#AF3A26; --warn:#895B0E;
color-scheme:light;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--ground:#0E151B; --surface:#131C24; --panel:#131C24; --ink:#E3E9EE; --ink2:#ADBCC8; --ink3:#8393A1;
--rule:#27333E; --rail:#8BAFCB; --good:#4EC6B6; --bad:#E3826B; --warn:#DFAD5D;
color-scheme:dark;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);
font-family:"PingFang SC","Hiragino Sans GB",ui-sans-serif,system-ui,sans-serif;line-height:1.7}
.wrap{max-width:920px;margin:0 auto;padding:44px 22px 96px}
h1{font-size:31px;margin:0 0 12px;letter-spacing:-.02em}
.lede{color:var(--ink2);font-size:15.5px;max-width:68ch;margin:0 0 6px}
.meta{font-size:12.5px;color:var(--ink3);font-family:ui-monospace,monospace;margin:0 0 34px}
h2{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:1px solid var(--rule)}
p{max-width:70ch}
.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin:18px 0 8px}
.tile{background:var(--surface);border:1px solid var(--rule);border-radius:10px;padding:13px 15px}
.tile .k{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink3);font-weight:600}
.tile .v{font-size:25px;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums;margin:3px 0 1px}
.tile .n{font-size:11.5px;color:var(--ink3)}
.tile.ok .v{color:var(--good)} .tile.miss .v{color:var(--bad)}
.rule{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:17px 20px;margin:14px 0}
.rule h3{margin:0 0 9px;font-size:16.5px;display:flex;gap:10px;align-items:baseline}
.rule h3 .no{font-family:ui-monospace,monospace;color:var(--rail);font-weight:700}
.rule dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:5px 13px;font-size:13.5px}
.rule dt{color:var(--ink3);white-space:nowrap;font-size:11.5px;letter-spacing:.04em;padding-top:3px}
.rule dd{margin:0;color:var(--ink2)}
.rule dd.ev{color:var(--ink)}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:14px 0 8px}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--rule);vertical-align:top}
th{font-size:11px;letter-spacing:.06em;color:var(--ink3);text-transform:uppercase;font-weight:600}
td.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap}
.no{color:var(--bad);font-weight:600}.yes{color:var(--good);font-weight:600}
ol.recipe{counter-reset:s;list-style:none;padding:0;margin:14px 0}
ol.recipe li{counter-increment:s;position:relative;padding:0 0 13px 40px;font-size:14px;color:var(--ink2);max-width:70ch}
ol.recipe li::before{content:counter(s);position:absolute;left:0;top:0;width:25px;height:25px;border-radius:50%;
background:var(--rail);color:#fff;font-family:ui-monospace,monospace;font-size:12.5px;font-weight:700;
display:grid;place-items:center}
ol.recipe b{color:var(--ink)}
code{font-family:ui-monospace,monospace;font-size:.92em;background:var(--panel);padding:1px 5px;border-radius:4px;border:1px solid var(--rule)}
footer{margin-top:54px;padding-top:18px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
a{color:var(--rail)}
</style>
</head>
<body>
<div class="wrap">
<h1>关卡制作流程复盘</h1>
<p class="lede">这一轮做 50 关的过程里,最有用的结论是一个<b>否定结论</b>:原计划的「agent 打分 → 拟合权重 → 重选关卡」这个闭环<b>合不上</b>,而且不是样本量的问题。下面是它为什么合不上,以及流程该改成什么样。</p>
<p class="meta">实测批次 R1 · grok-4.6 xhigh · 50 关 · 生成于 2026-09-14 17:06</p>
<h2>R1 实测到了什么</h2>
<div class="tiles">
<div class="tile ok"><div class="k">通关</div><div class="v">50/50</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.24</div><div class="n">目标 0</div></div>
<div class="tile ok"><div class="k">转错 / 关</div><div class="v">0.14</div><div class="n">目标 0</div></div>
<div class="tile miss"><div class="k">难度评分</div><div class="v">46/50</div><div class="n">都是 2 分 — 不区分</div></div>
</div>
<p>机械面干净,说明生成期的闸门是有效的:「动了很多次没结果」这类结构缺陷已经被挡在关卡集之外。<b>难度评分那一格是这一轮真正的发现</b>——它不是不准(复测 r = 0.93,相当稳),它是<b>不区分</b>:机械上差着三倍的关卡,它一律给 2 分。</p>
<h2>五条规则,各自的证据</h2>
<div class="rule">
<h3><span class="no">1</span>agent 轮次用来抓缺陷,不用来排难度</h3>
<dl>
<dt>证据</dt><dd class="ev">R1 的 50 关里有 46 关难度评分是 2 分,而这些关卡的决策数从 3 到 15。评分不是不准,是不区分。</dd>
<dt>为什么</dt><dd>文字盘面把每颗棋子的路线、挡路者、紧贴者列成清单,agent 不必用眼睛顺线走一遍——而那正是这游戏的难点。agent 自己写过:「挡路名单已经写全,不用顺着格子数」。</dd>
<dt>现在怎么做</dt><dd>难度排序改为机械假设(旋转深度、棋子数、互锁),由人工审核裁定;agent 轮次只看死局、盲转、转错、空点、演出。</dd>
</dl>
</div>
<div class="rule">
<h3><span class="no">2</span>先用规则机器人扫,再花 agent 时间</h3>
<dl>
<dt>证据</dt><dd class="ev">机械旋转指标已被证明测的是关卡而不是玩家:4 次运行、3 家厂商,18 关里 17 关结论一致。</dd>
<dt>为什么</dt><dd>既然同一关在不同模型下得出同样的机械读数,那这个读数不需要模型来产生。</dd>
<dt>现在怎么做</dt><dd>12 秒的规则机器人扫描替代一整轮 agent 跑批;agent 只用于抽样确认和读自由文本抱怨。</dd>
</dl>
</div>
<div class="rule">
<h3><span class="no">3</span>门槛写在生成期,不写在筛选期</h3>
<dl>
<dt>证据</dt><dd class="ev">把开局门槛提到「可点≥3 枚、演出≥0.8s」后筛选写出了 0 关——池子的中位数是 2 枚 / 0.57s。</dd>
<dt>为什么</dt><dd>筛选只能从池子里挑,挑不出池子里没有的东西。分布不对时,要改的是构造。</dd>
<dt>现在怎么做</dt><dd>开局问题由构造里的展示位插入解决,不是靠筛。</dd>
</dl>
</div>
<div class="rule">
<h3><span class="no">4</span>每加一个门槛,先证明它挡掉了东西</h3>
<dl>
<dt>证据</dt><dd class="ev">两次结构性猜测被实测推翻:「插入端多朝向出口」过滤器对漏斗零效果;为解释难度评分而建的 legibility 指标与评分相关 -0.02。</dd>
<dt>为什么</dt><dd>一个不改变漏斗的门槛,只会让人以为问题已经解决了。</dd>
<dt>现在怎么做</dt><dd>两者都已撤回/降级为纯测量。漏斗每一级的剩余数量都打印出来。</dd>
</dl>
</div>
<div class="rule">
<h3><span class="no">5</span>抱怨要对照基线,不要直接当缺陷</h3>
<dl>
<dt>证据</dt><dd class="ev">agent 33 条抱怨里最集中的是「路线太长读不完」。但已上线的 v0.5 最长路线 88 格,比这批新关卡的 84 格还长。</dd>
<dt>为什么</dt><dd>那是这个游戏本来的性格。把它当缺陷修掉,改的是游戏而不是关卡。</dd>
<dt>现在怎么做</dt><dd>只在审核页标出,交给人裁定。</dd>
</dl>
</div>
<h2>agent 能测什么,不能测什么</h2>
<table>
<thead><tr><th>指标</th><th class="n">可用</th><th>依据</th></tr></thead>
<tbody>
<tr><td>盲转 / 多余转 / 转错方向</td><td class="n yes">可用</td><td>复测 r ≈ 0.82–0.92;驱动了真实修复(1.17 / 1.22 / 0.44 → 0)</td></tr>
<tr><td>是否通关、空点次数</td><td class="n yes">可用</td><td>直接观察,无主观成分</td></tr>
<tr><td>自由文本抱怨</td><td class="n yes">可用</td><td>33 条里两个主题高度重复,指标抓不到;但须对照基线(规则 5)</td></tr>
<tr><td>难度评分</td><td class="n no">只能看总体</td><td>稳定但不区分;单关排序不可用,整批读数(偏易一档)可用</td></tr>
<tr><td>无解感评分</td><td class="n">谨慎</td><td>复测 r = 0.85,与无效动作相关 0.66,可当挫败感的代理</td></tr>
<tr><td>好玩 / 清晰度 / 旋转评分</td><td class="n no">不可用</td><td>复测 r ≤ 0.56,是模型噪声</td></tr>
</tbody>
</table>
<h2>要做 1000 关的话</h2>
<ol class="recipe">
<li><b>先把判据写成可执行的闸门</b>,再开始生成。这一轮的闸门链是:长成 → 首转双向 → 锁住 ≤90% → 无不可解态 → 终局朝向 ≥2 → 每转释放 ≥2 → 反差 ≥12 → 开局可点 ≥3 → 开局演出 ≥0.9s → 长巡游 ≥3。每一级剩多少必须打印出来。</li>
<li><b>用规则机器人做全量验收</b>(12 秒 / 全池),不用 agent。机械旋转指标已证明测的是关卡不是玩家。</li>
<li><b>agent 只抽样跑</b>,而且只看两样东西:机械缺陷指标,和自由文本抱怨。不要为难度标定花 agent 时间——那条路这一轮已经证明走不通。</li>
<li><b>难度曲线靠机械假设铺,靠人眼裁定。</b>用 <code>select-50.mjs</code> 按特征打分并均匀铺开,家族上限防止一套模板占满(上一版 1000 关实际只有 30 套模板)。</li>
<li><b>每一轮只动一个变量</b>,并且必须能在漏斗里看到它挡掉了什么,否则撤回。</li>
<li><b>人工审核要准备好材料</b>:开局盘面从求解器取(不是示意图)、标明哪些数字可信、每关一个试玩直达链接。</li>
</ol>
<h2>被实测否掉的做法</h2>
<table>
<thead><tr><th>试过什么</th><th>结果</th></tr></thead>
<tbody>
<tr><td>拟合 difficulty ~ 机械特征,用来重选关卡</td><td>响应几乎无方差(46/50 同分),回归的是常数</td></tr>
<tr><td>legibility(一眼看穿的成本)解释难度评分</td><td>相关 -0.02,无效;降级为纯测量</td></tr>
<tr><td>路线交叠度当难度轴</td><td>中位已达 89%,没有上升空间</td></tr>
<tr><td>换开局朝向来提高判断成本</td><td>127 个候选里只有 7 个变好</td></tr>
<tr><td>「插入端多朝向出口」过滤器</td><td>对漏斗零效果,已删除</td></tr>
<tr><td>把锁住比例当难度主轴排序</td><td>方向反了:它奖励「开局什么都做不了」</td></tr>
</tbody>
</table>
<footer>
<p class="mono">生成:node scripts/build-process-report.mjs · 页面所有数字由脚本从 levels/campaign-50.json 与 bench/runs 直接测得</p>
<p>相关:<a href="review-50.html">五十关人工审核页</a> · <a href="level-board.html">任务看板</a> · <a href="bench-reliability.html">bench 复测可信度</a></p>
</footer>
</div>
</body>
</html>
+482 -449
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+54309 -55634
View File
File diff suppressed because it is too large Load Diff
+7783 -4461
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
// Merge a sharded prototype sweep into one candidate pool, and apply the gate
// that only a wide sweep can afford.
//
// node scripts/build-pool.mjs --in=<dir> [--out=bench/pool.json]
// [--endgameMinOpen=3] [--maxDeadTurns=1]
//
// prototype-d5 gates what a single sweep can pay for. Two more are left to this
// step because together they cost nearly 60% of the yield.
//
// `maxDeadTurns` is the longest run of turns a player can make, from a stuck
// state, before anything opens — the design brief's 无效转动就会很无聊, counted.
// At 1 the worst case is one turn of suspense and then a release. Round 1 shipped
// eleven levels at 2, and those are the ones the agent kept writing
// 短暂以为转错了方向 about.
//
// It is deliberately NOT `doubleDead`, which was the first thing tried here.
// That counts dead states rather than consecutive dead turns, so it scales with
// the size of the state space: depth-3 levels have ~187 reachable stuck states
// and depth-5 ones ~739, which made the raw count look eight times worse for
// deep levels when the *rate* is 2% against 5%. Gating on it wiped out every
// level past three turns — the opposite of what the brief asks for, since
// turning is the game's signature and should not be rationed.
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { deadTurnAudit, deepLevelKey } from '../src/deep-lock.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const OUT = resolve(args.out ?? 'bench/pool.json');
const MIN_OPEN = Number(args.endgameMinOpen ?? 3);
const MAX_DEAD = Number(args.maxDeadTurns ?? 1);
// Shards from a sweep, or level files listed directly.
const sources = String(args.in ?? 'bench/shards').split(',').map(s => resolve(s.trim()));
const files = sources.flatMap(src => {
try { return readdirSync(src).filter(f => f.endsWith('.json')).map(f => resolve(src, f)); }
catch { return [src]; } // not a directory: a file path
});
const all = files.flatMap(f => {
try { const raw = JSON.parse(readFileSync(f, 'utf8')); return Array.isArray(raw) ? raw : raw.levels ?? []; }
catch { return []; }
});
const seen = new Set();
const unique = [];
for (const level of all) {
const key = deepLevelKey(level);
if (seen.has(key)) continue;
seen.add(key);
unique.push(level);
}
// Levels harvested from the shipped set arrive without an audit. Being shipped
// is not evidence of passing a gate that did not exist when they shipped.
let audited = 0;
for (const level of unique) {
if (level.prototype?.audit) continue;
level.prototype = { ...(level.prototype ?? {}), audit: deadTurnAudit(level) };
audited++;
}
const open = unique.filter(l => (l.prototype.audit.endgameMinOpen ?? 0) >= MIN_OPEN);
const kept = open.filter(l => (l.prototype.audit.maxDeadTurns ?? Infinity) <= MAX_DEAD);
const tally = (rows, f) => rows.reduce((m, l) => { const k = f(l); m[k] = (m[k] || 0) + 1; return m; }, {});
const rot = l => l.prototype?.stats?.rotations ?? '?';
const fam = l => l.prototype?.family ?? (String(l.id).startsWith('trail-') ? 'v05' : '?');
console.log(`${files.length} 个来源合计 ${all.length} → 去重 ${unique.length}${audited ? `(其中 ${audited} 关现场补算了 audit)` : ''}`);
console.log(` 终局可放行朝向 ≥${MIN_OPEN} → ${open.length}(${Math.round(100 * open.length / unique.length)}%)`);
console.log(` 连续空转 ≤${MAX_DEAD} → ${kept.length}(${Math.round(100 * kept.length / unique.length)}%)`);
console.log(`旋转深度 ${JSON.stringify(tally(kept, rot))}`);
console.log(`家族 ${JSON.stringify(tally(kept, fam))}`);
writeFileSync(OUT, JSON.stringify({ generated: 'build-pool', sources: files.length, swept: all.length, count: kept.length,
gates: { endgameMinOpen: MIN_OPEN, maxDeadTurns: MAX_DEAD }, levels: kept }, null, 1));
console.log(`已写入 ${OUT}`);
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env node
// Build docs/level-process.html — what this round changed about how levels
// get made. Every figure is measured here, not typed in.
//
// node scripts/build-process-report.mjs
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { trace } from '../src/core.js';
import { metrics } from '../src/bench.js';
import { legibility } from '../src/deep-lock.js';
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
const f1 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 10) / 10).toFixed(1);
const f2 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 100) / 100).toFixed(2);
const pct = (a, b) => b ? Math.round(100 * a / b) + '%' : '—';
const levels = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8')).levels;
const byId = new Map(levels.map(l => [l.id, l]));
// Every round on record, so the claim "agent rounds catch defects" is shown
// against the rounds rather than asserted.
const runs = [];
for (const f of existsSync(resolve('bench/runs')) ? readdirSync(resolve('bench/runs')) : []) {
if (!f.endsWith('.json') || f === 'index.json') continue;
try {
const d = JSON.parse(readFileSync(resolve('bench/runs', f), 'utf8'));
if (d.status === 'playing') continue;
runs.push({ raw: d, m: metrics(d) });
} catch { /* torn read */ }
}
const r1 = runs.filter(r => r.raw.agent === 'R1 · grok xhigh' && byId.has(r.raw.level?.id));
const rated = r1.filter(r => r.m.rating && Number.isFinite(Number(r.m.rating.difficulty)));
const byDiff = rated.reduce((m, r) => (m[r.m.rating.difficulty] = (m[r.m.rating.difficulty] || 0) + 1, m), {});
const topDiff = Object.entries(byDiff).sort((a, b) => b[1] - a[1])[0] ?? ['—', 0];
const longest = l => Math.max(...l.tiles.map(t => trace(l, t, [], true).steps.length));
const c50Longest = Math.max(...levels.map(longest));
const loadPool = p => { try { const r = JSON.parse(readFileSync(resolve(p), 'utf8')); return Array.isArray(r) ? r : r.levels; } catch { return []; } };
const v05 = loadPool('bench/pool-easy.json');
const v05Longest = v05.length ? Math.max(...v05.map(longest)) : null;
const traps = levels.map(l => legibility(l).grazeTraps);
const decisions = rated.map(r => r.m.decisions ?? 0);
const R1 = {
n: r1.length, rated: rated.length,
cleared: r1.filter(r => r.m.cleared).length,
dead: mean(r1.map(r => r.m.deadTaps ?? 0)),
blind: mean(r1.map(r => r.m.blindRotations ?? 0)),
wrong: mean(r1.map(r => r.m.wrongWayRotations ?? 0)),
flat: topDiff, decMin: Math.min(...decisions), decMax: Math.max(...decisions),
};
// The rules, each with the observation that forced it. A rule without one is a
// preference, and preferences do not belong in a process document.
const RULES = [
{ n: 1, rule: 'agent 轮次用来抓缺陷,不用来排难度',
ev: `R1 的 ${R1.rated} 关里有 ${R1.flat[1]} 关难度评分是 ${R1.flat[0]} 分,而这些关卡的决策数从 ${R1.decMin} 到 ${R1.decMax}。评分不是不准,是不区分。`,
why: '文字盘面把每颗棋子的路线、挡路者、紧贴者列成清单,agent 不必用眼睛顺线走一遍——而那正是这游戏的难点。agent 自己写过:「挡路名单已经写全,不用顺着格子数」。',
now: '难度排序改为机械假设(旋转深度、棋子数、互锁),由人工审核裁定;agent 轮次只看死局、盲转、转错、空点、演出。' },
{ n: 2, rule: '先用规则机器人扫,再花 agent 时间',
ev: '机械旋转指标已被证明测的是关卡而不是玩家:4 次运行、3 家厂商,18 关里 17 关结论一致。',
why: '既然同一关在不同模型下得出同样的机械读数,那这个读数不需要模型来产生。',
now: '12 秒的规则机器人扫描替代一整轮 agent 跑批;agent 只用于抽样确认和读自由文本抱怨。' },
{ n: 3, rule: '门槛写在生成期,不写在筛选期',
ev: '把开局门槛提到「可点≥3 枚、演出≥0.8s」后筛选写出了 0 关——池子的中位数是 2 枚 / 0.57s。',
why: '筛选只能从池子里挑,挑不出池子里没有的东西。分布不对时,要改的是构造。',
now: '开局问题由构造里的展示位插入解决,不是靠筛。' },
{ n: 4, rule: '每加一个门槛,先证明它挡掉了东西',
ev: '两次结构性猜测被实测推翻:「插入端多朝向出口」过滤器对漏斗零效果;为解释难度评分而建的 legibility 指标与评分相关 -0.02。',
why: '一个不改变漏斗的门槛,只会让人以为问题已经解决了。',
now: '两者都已撤回/降级为纯测量。漏斗每一级的剩余数量都打印出来。' },
{ n: 5, rule: '抱怨要对照基线,不要直接当缺陷',
ev: `agent 33 条抱怨里最集中的是「路线太长读不完」。但已上线的 v0.5 最长路线 ${v05Longest ?? '—'} 格,比这批新关卡的 ${c50Longest} 格还长。`,
why: '那是这个游戏本来的性格。把它当缺陷修掉,改的是游戏而不是关卡。',
now: '只在审核页标出,交给人裁定。' },
];
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>关卡制作流程复盘</title>
<style>
:root{
--ground:#EFF1F3; --surface:#FFF; --panel:#F6F8F9; --ink:#131C24; --ink2:#465766; --ink3:#6D7C89;
--rule:#D4DAE0; --rail:#3F5E79; --good:#226D63; --bad:#AF3A26; --warn:#895B0E;
color-scheme:light;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--ground:#0E151B; --surface:#131C24; --panel:#131C24; --ink:#E3E9EE; --ink2:#ADBCC8; --ink3:#8393A1;
--rule:#27333E; --rail:#8BAFCB; --good:#4EC6B6; --bad:#E3826B; --warn:#DFAD5D;
color-scheme:dark;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);
font-family:"PingFang SC","Hiragino Sans GB",ui-sans-serif,system-ui,sans-serif;line-height:1.7}
.wrap{max-width:920px;margin:0 auto;padding:44px 22px 96px}
h1{font-size:31px;margin:0 0 12px;letter-spacing:-.02em}
.lede{color:var(--ink2);font-size:15.5px;max-width:68ch;margin:0 0 6px}
.meta{font-size:12.5px;color:var(--ink3);font-family:ui-monospace,monospace;margin:0 0 34px}
h2{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:1px solid var(--rule)}
p{max-width:70ch}
.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin:18px 0 8px}
.tile{background:var(--surface);border:1px solid var(--rule);border-radius:10px;padding:13px 15px}
.tile .k{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink3);font-weight:600}
.tile .v{font-size:25px;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums;margin:3px 0 1px}
.tile .n{font-size:11.5px;color:var(--ink3)}
.tile.ok .v{color:var(--good)} .tile.miss .v{color:var(--bad)}
.rule{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:17px 20px;margin:14px 0}
.rule h3{margin:0 0 9px;font-size:16.5px;display:flex;gap:10px;align-items:baseline}
.rule h3 .no{font-family:ui-monospace,monospace;color:var(--rail);font-weight:700}
.rule dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:5px 13px;font-size:13.5px}
.rule dt{color:var(--ink3);white-space:nowrap;font-size:11.5px;letter-spacing:.04em;padding-top:3px}
.rule dd{margin:0;color:var(--ink2)}
.rule dd.ev{color:var(--ink)}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:14px 0 8px}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--rule);vertical-align:top}
th{font-size:11px;letter-spacing:.06em;color:var(--ink3);text-transform:uppercase;font-weight:600}
td.n{text-align:right;font-family:ui-monospace,monospace;white-space:nowrap}
.no{color:var(--bad);font-weight:600}.yes{color:var(--good);font-weight:600}
ol.recipe{counter-reset:s;list-style:none;padding:0;margin:14px 0}
ol.recipe li{counter-increment:s;position:relative;padding:0 0 13px 40px;font-size:14px;color:var(--ink2);max-width:70ch}
ol.recipe li::before{content:counter(s);position:absolute;left:0;top:0;width:25px;height:25px;border-radius:50%;
background:var(--rail);color:#fff;font-family:ui-monospace,monospace;font-size:12.5px;font-weight:700;
display:grid;place-items:center}
ol.recipe b{color:var(--ink)}
code{font-family:ui-monospace,monospace;font-size:.92em;background:var(--panel);padding:1px 5px;border-radius:4px;border:1px solid var(--rule)}
footer{margin-top:54px;padding-top:18px;border-top:1px solid var(--rule);color:var(--ink3);font-size:12px}
a{color:var(--rail)}
</style>
</head>
<body>
<div class="wrap">
<h1>关卡制作流程复盘</h1>
<p class="lede">这一轮做 50 关的过程里,最有用的结论是一个<b>否定结论</b>:原计划的「agent 打分 → 拟合权重 → 重选关卡」这个闭环<b>合不上</b>,而且不是样本量的问题。下面是它为什么合不上,以及流程该改成什么样。</p>
<p class="meta">实测批次 R1 · grok-4.6 xhigh · ${R1.n} 关 · 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}</p>
<h2>R1 实测到了什么</h2>
<div class="tiles">
<div class="tile ${R1.cleared === R1.n ? 'ok' : 'miss'}"><div class="k">通关</div><div class="v">${R1.cleared}/${R1.n}</div><div class="n">无死局</div></div>
<div class="tile ${R1.dead === 0 ? 'ok' : 'miss'}"><div class="k">空点 / 关</div><div class="v">${f2(R1.dead)}</div><div class="n">目标 0</div></div>
<div class="tile ${R1.blind < .5 ? 'ok' : 'miss'}"><div class="k">盲转 / 关</div><div class="v">${f2(R1.blind)}</div><div class="n">目标 0</div></div>
<div class="tile ${R1.wrong < .5 ? 'ok' : 'miss'}"><div class="k">转错 / 关</div><div class="v">${f2(R1.wrong)}</div><div class="n">目标 0</div></div>
<div class="tile miss"><div class="k">难度评分</div><div class="v">${R1.flat[1]}/${R1.rated}</div><div class="n">都是 ${R1.flat[0]} 分 — 不区分</div></div>
</div>
<p>机械面干净,说明生成期的闸门是有效的:「动了很多次没结果」这类结构缺陷已经被挡在关卡集之外。<b>难度评分那一格是这一轮真正的发现</b>——它不是不准(复测 r = 0.93,相当稳),它是<b>不区分</b>:机械上差着三倍的关卡,它一律给 ${R1.flat[0]} 分。</p>
<h2>五条规则,各自的证据</h2>
${RULES.map(r => `<div class="rule">
<h3><span class="no">${r.n}</span>${esc(r.rule)}</h3>
<dl>
<dt>证据</dt><dd class="ev">${esc(r.ev)}</dd>
<dt>为什么</dt><dd>${esc(r.why)}</dd>
<dt>现在怎么做</dt><dd>${esc(r.now)}</dd>
</dl>
</div>`).join('\n')}
<h2>agent 能测什么,不能测什么</h2>
<table>
<thead><tr><th>指标</th><th class="n">可用</th><th>依据</th></tr></thead>
<tbody>
<tr><td>盲转 / 多余转 / 转错方向</td><td class="n yes">可用</td><td>复测 r ≈ 0.82–0.92;驱动了真实修复(1.17 / 1.22 / 0.44 → 0)</td></tr>
<tr><td>是否通关、空点次数</td><td class="n yes">可用</td><td>直接观察,无主观成分</td></tr>
<tr><td>自由文本抱怨</td><td class="n yes">可用</td><td>33 条里两个主题高度重复,指标抓不到;但须对照基线(规则 5)</td></tr>
<tr><td>难度评分</td><td class="n no">只能看总体</td><td>稳定但不区分;单关排序不可用,整批读数(偏易一档)可用</td></tr>
<tr><td>无解感评分</td><td class="n">谨慎</td><td>复测 r = 0.85,与无效动作相关 0.66,可当挫败感的代理</td></tr>
<tr><td>好玩 / 清晰度 / 旋转评分</td><td class="n no">不可用</td><td>复测 r ≤ 0.56,是模型噪声</td></tr>
</tbody>
</table>
<h2>要做 1000 关的话</h2>
<ol class="recipe">
<li><b>先把判据写成可执行的闸门</b>,再开始生成。这一轮的闸门链是:长成 → 首转双向 → 锁住 ≤90% → 无不可解态 → 终局朝向 ≥2 → 每转释放 ≥2 → 反差 ≥12 → 开局可点 ≥3 → 开局演出 ≥0.9s → 长巡游 ≥3。每一级剩多少必须打印出来。</li>
<li><b>用规则机器人做全量验收</b>(12 秒 / 全池),不用 agent。机械旋转指标已证明测的是关卡不是玩家。</li>
<li><b>agent 只抽样跑</b>,而且只看两样东西:机械缺陷指标,和自由文本抱怨。不要为难度标定花 agent 时间——那条路这一轮已经证明走不通。</li>
<li><b>难度曲线靠机械假设铺,靠人眼裁定。</b>用 <code>select-50.mjs</code> 按特征打分并均匀铺开,家族上限防止一套模板占满(上一版 1000 关实际只有 30 套模板)。</li>
<li><b>每一轮只动一个变量</b>,并且必须能在漏斗里看到它挡掉了什么,否则撤回。</li>
<li><b>人工审核要准备好材料</b>:开局盘面从求解器取(不是示意图)、标明哪些数字可信、每关一个试玩直达链接。</li>
</ol>
<h2>被实测否掉的做法</h2>
<table>
<thead><tr><th>试过什么</th><th>结果</th></tr></thead>
<tbody>
<tr><td>拟合 difficulty ~ 机械特征,用来重选关卡</td><td>响应几乎无方差(${R1.flat[1]}/${R1.rated} 同分),回归的是常数</td></tr>
<tr><td>legibility(一眼看穿的成本)解释难度评分</td><td>相关 -0.02,无效;降级为纯测量</td></tr>
<tr><td>路线交叠度当难度轴</td><td>中位已达 89%,没有上升空间</td></tr>
<tr><td>换开局朝向来提高判断成本</td><td>127 个候选里只有 7 个变好</td></tr>
<tr><td>「插入端多朝向出口」过滤器</td><td>对漏斗零效果,已删除</td></tr>
<tr><td>把锁住比例当难度主轴排序</td><td>方向反了:它奖励「开局什么都做不了」</td></tr>
</tbody>
</table>
<footer>
<p class="mono">生成:node scripts/build-process-report.mjs · 页面所有数字由脚本从 levels/campaign-50.json 与 bench/runs 直接测得</p>
<p>相关:<a href="review-50.html">五十关人工审核页</a> · <a href="level-board.html">任务看板</a> · <a href="bench-reliability.html">bench 复测可信度</a></p>
</footer>
</div>
</body>
</html>`;
writeFileSync(resolve('docs/level-process.html'), html);
console.log(`流程复盘已生成:docs/level-process.html(R1 ${R1.n} 关实测,${RULES.length} 条规则)`);
+13 -4
View File
@@ -9,7 +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 { deepLevelKey, legibility } from '../src/deep-lock.js';
import { layout } from '../src/geometry.js';
import { metrics } from '../src/bench.js';
@@ -20,7 +20,12 @@ const OUT = resolve(args.out ?? 'docs/review-50.html');
const raw = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8'));
const levels = raw.levels;
// Agent verdicts, keyed by level id.
// Agent verdicts, keyed by the board itself rather than by its id. Re-running
// the selection re-issues ids from the ordinal, so `c50-01-v05` after a reselect
// is a different board than `c50-01-v05` before it — matching on the id would
// quietly print one level's verdict under another level's thumbnail. Runs store
// the whole level, so the pieces and their positions are the key.
const boardKey = new Map(levels.map(l => [deepLevelKey(l), l.id]));
const verdicts = {};
const RUNS = resolve('bench/runs');
if (existsSync(RUNS)) {
@@ -28,8 +33,12 @@ if (existsSync(RUNS)) {
if (!f.endsWith('.json') || f === 'index.json') continue;
try {
const d = JSON.parse(readFileSync(resolve(RUNS, f), 'utf8'));
if (d.agent !== AGENT || !d.level?.id) continue;
verdicts[d.level.id] = metrics(d);
if (d.agent !== AGENT || !d.level?.tiles || d.status === 'playing') continue;
const id = boardKey.get(deepLevelKey(d.level));
if (!id) continue; // a level this edition no longer ships
// Two runs of one level (a retest) both count; the later one wins the card.
const m = metrics(d);
if (!verdicts[id] || String(d.updatedAt) > String(verdicts[id].updatedAt)) verdicts[id] = { ...m, updatedAt: d.updatedAt };
} catch { /* torn read */ }
}
}
+8 -5
View File
@@ -22,7 +22,7 @@
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';
import { deepLevelKey, 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';
@@ -46,7 +46,8 @@ const featureOf = level => {
};
const levels = JSON.parse(readFileSync(resolve('levels/campaign-50.json'), 'utf8')).levels;
const byId = new Map(levels.map(l => [l.id, l]));
// Keyed by board, not by id — a reselect re-issues ids from the ordinal.
const byId = new Map(levels.map(l => [deepLevelKey(l), l]));
const played = new Map();
const RUNS = resolve('bench/runs');
@@ -54,12 +55,14 @@ 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));
if (d.agent !== AGENT || d.status === 'playing' || !d.level?.tiles) continue;
const key = deepLevelKey(d.level);
if (!byId.has(key)) continue;
played.set(key, metrics(d));
} catch { /* torn read */ }
}
const rows = [...played].map(([id, m]) => ({ id, m, x: featureOf(byId.get(id)) }))
const rows = [...played].map(([key, m]) => ({ id: byId.get(key).id, m, x: featureOf(byId.get(key)) }))
.filter(r => Number.isFinite(Number(r.m.rating?.difficulty)));
if (rows.length < 12) {
console.error(`只有 ${rows.length} 关有评分,样本太少,不拟合。`);
+49 -16
View File
@@ -15,6 +15,13 @@ const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v]
const OUT = resolve(args.out ?? 'levels/campaign-50.json');
const COUNT = Number(args.count ?? 50);
const FAMILY_CAP = Number(args.familyCap ?? 0.36);
// Rotation depth is the campaign's difficulty axis, because the design brief
// says turning is the game's signature and should be frequent. Spreading picks
// evenly over the ranked pool cannot express that: the pool is 88% three-turn
// levels, so an even spread returns a campaign that is 88% three-turn levels
// no matter how the score is weighted. A quota per depth states the curve
// instead of hoping it falls out of the ranking.
const CURVE = args.curve ? JSON.parse(args.curve) : null;
// Starting weights, replaced by the fit after a round with agents.
const DEFAULT_WEIGHTS = { rotations: 1.0, pieces: 0.10, lockedShare: 1.6, monkeyAPT: 0.14, cells: 0.004 };
@@ -55,27 +62,52 @@ scored.sort((a, b) => a.score - b.score);
const cap = Math.ceil(COUNT * FAMILY_CAP);
const used = {};
const picked = [];
const takeable = i => {
const row = scored[i];
const takeable = (rows, i) => {
const row = rows[i];
if (!row || row.taken) return false;
return (used[row.family] ?? 0) < cap;
};
for (let slot = 0; slot < COUNT; slot++) {
const ideal = Math.round(slot * (scored.length - 1) / Math.max(1, COUNT - 1));
let choice = null;
for (let radius = 0; radius < scored.length; radius++) {
for (const i of [ideal - radius, ideal + radius]) {
if (i < 0 || i >= scored.length) continue;
if (takeable(i)) { choice = i; break; }
// Even spread across a ranked list, taking `want` of it. Used on the whole pool
// when there is no curve, and on one depth's slice when there is.
const spread = (rows, want) => {
const out = [];
for (let slot = 0; slot < want; slot++) {
const ideal = Math.round(slot * (rows.length - 1) / Math.max(1, want - 1));
let choice = null;
for (let radius = 0; radius < rows.length; radius++) {
for (const i of [ideal - radius, ideal + radius]) {
if (i < 0 || i >= rows.length) continue;
if (takeable(rows, i)) { choice = i; break; }
}
if (choice != null) break;
}
if (choice != null) break;
if (choice == null) break;
rows[choice].taken = true;
used[rows[choice].family] = (used[rows[choice].family] ?? 0) + 1;
out.push(rows[choice]);
}
if (choice == null) break;
scored[choice].taken = true;
used[scored[choice].family] = (used[scored[choice].family] ?? 0) + 1;
picked.push(scored[choice]);
return out;
};
const shortfall = [];
if (CURVE) {
// Deepest first: the deep end is the scarce one, so it gets to pick before a
// family cap can be spent on levels the shallow end has hundreds of.
const depths = Object.keys(CURVE).map(Number).sort((a, b) => b - a);
for (const depth of depths) {
const want = CURVE[depth];
const rows = scored.filter(r => (r.stats.rotations ?? 0) === depth);
const got = spread(rows, want);
picked.push(...got);
if (got.length < want) shortfall.push({ depth, want, got: got.length, pool: rows.length });
}
// Whatever the quotas could not fill comes from the rest of the pool, so a
// thin depth costs the campaign levels rather than a curve that lies.
if (picked.length < COUNT) picked.push(...spread(scored, COUNT - picked.length));
} else {
picked.push(...spread(scored, COUNT));
}
picked.sort((a, b) => a.score - b.score);
picked.sort((a, b) => (a.stats.rotations ?? 0) - (b.stats.rotations ?? 0) || a.score - b.score);
const levels = picked.map((row, i) => {
const level = JSON.parse(JSON.stringify(row.level));
@@ -108,7 +140,8 @@ const band = (a, b) => picked.slice(a, b);
const avg = (rows, f) => rows.reduce((s, r) => s + f(r), 0) / Math.max(1, rows.length);
console.log(`已选 ${levels.length} 关 → ${OUT}`);
console.log(`家族分布 ${JSON.stringify(used)}(单家族上限 ${cap})`);
console.log(`旋转深度 ${JSON.stringify(picked.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}`);
console.log(`旋转深度 ${JSON.stringify(picked.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}${CURVE ? `(目标 ${JSON.stringify(CURVE)})` : ''}`);
for (const s of shortfall) console.log(` !! ${s.depth} 转只凑到 ${s.got}/${s.want} 关 —— 池子里总共才 ${s.pool} 关,缺口已由其他深度补上`);
console.log('');
console.log('| 档 | 关号 | 难度分 | 旋转 | 棋子 | 锁住% | 反差 | 开局可点 | 开局演出 |');
console.log('|---|---|---:|---:|---:|---:|---:|---:|---:|');
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFile, stat } from 'node:fs/promises';
import { resolve, extname, sep } from 'node:path';
const root=resolve(process.argv.includes('--dist')?'dist':'.');
const port=Number(process.env.PORT||4173);
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png'};
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.woff2':'font/woff2','.woff':'font/woff','.ttf':'font/ttf'};
http.createServer(async(req,res)=>{
try {
const url=new URL(req.url,'http://localhost');
+1 -1
View File
@@ -117,7 +117,7 @@ function renderStats(){
}
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}<small>/100</small>`;$('editor-analysis-text').textContent=`观察难度 ${a.score}/100 · ${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转。工作量 ${a.minimumActions??'—'} 次操作,参考 ${a.workload.estimatedSeconds} 秒(估算)。仅成功离场使用 1 额度,旋转与试探免费${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 离场额度不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
}
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);const base=layout((mode==='edit'?draft:game.level).cells,width,height);baseView=base;view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();placeRotation();draw();}
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);const base=layout((mode==='edit'?draft:game.level).cells,width,height,mode==='edit'?0:requestedDesign.layout?.pad||0);baseView=base;view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();placeRotation();draw();}
// Designs with layout.rotation 'under-board' (signpost, macaron) hang the rotate controls under the board's lower slanted
// edges: from each edge midpoint drop a vertical to the tool discs' top edge and centre the button on that line's midpoint.
// layout.rim is the design's own margin around the cells. Other designs keep their layout row.
+1 -1
View File
@@ -94,7 +94,7 @@ export const ART_DIRECTIONS=Object.freeze([
[[0,2,5,3,1,8,6],[1,0,2,6,3,9,7],[3,6,1,0,4,10,2],[6,0,3,5,2,11,8],[2,5,0,6,1,9,3]],
['晨雾蓝灰世界色配奶白卡片,靛蓝、松石与琥珀领色。','杏仁暖砂世界色,朱砂与靛蓝领色,像午后的纸面。','苔原灰绿世界色,紫藤与玫红提亮。','暮砂玫瑰灰世界色,玫红与靛蓝对照。','夜航深蓝灰世界色,奶白卡片像被灯照亮的图。']),
pigments:makePigments(['#2F5FBF','#C4432F','#1C8378','#7A4EB8','#4A8235','#A8690C','#BF3A6B','#4A6B8A','#B0572F','#357358','#8A5C92','#5A6D7C'],['靛蓝','朱砂','松石','紫藤','苔绿','琥珀','玫红','钢蓝','赤陶','墨绿','藕紫','铁灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
{id:'macaron',code:'07',name:'马卡龙',en:'MACARON',tag:'参考 Meowdoku',style:'macaron',accent:'#E07416',paper:'#FBF6F0',journeyWorlds:true,layout:{rotation:'under-board',rim:.40},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'},
{id:'macaron',code:'07',name:'马卡龙',en:'MACARON',tag:'参考 Meowdoku',style:'macaron',accent:'#E07416',paper:'#FBF6F0',journeyWorlds:true,layout:{rotation:'under-board',rim:.40,pad:1.1},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'},
description:'奶油色世界上托一张白色软托盘,六角格像带白缝的软糖砖,藕紫轨道压在糖砖上。棋子是糖果色圆角六角,白色贴纸边,粗圆头白箭头。反馈用果糖色说话:路线点亮、糖砖出场、离场撒星。',
principles:['白托盘加白缝,格与格靠留白分开,不画格线','十二色统一白色粗箭头,与白缝同一套白','状态用颜色和弹跳表达:路线点亮、碰撞橘红、出场从左上角一波弹开'],
route:{blocked:'#F0663A',hint:'#F7C948'},
+3 -2
View File
@@ -1,11 +1,12 @@
import { position, PORTS, trackPoints } from './path-math.js';
export { hexPoints, trackPoints, PORTS } from './path-math.js';
export function layout(cells,width,height) {
// pad adds that many cell radii of margin on every side (macaron keeps its dissolve inside the canvas with 1.1).
export function layout(cells,width,height,pad=0) {
const raw=cells.map(c=>position(c.q,c.r));
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
const minY=Math.min(...raw.map(p=>p.y))-.866,maxY=Math.max(...raw.map(p=>p.y))+.866;
const cellLimit=Math.max(47,Math.min(76,Math.min(width,height)*.1));
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),cellLimit);
const size=Math.min((width-48)/(maxX-minX+2*pad),(height-50)/(maxY-minY+2*pad),cellLimit);
const ox=width/2-(minX+maxX)/2*size,oy=height/2-(minY+maxY)/2*size;
return {size,ox,oy,bounds:{top:minY*size+oy,bottom:maxY*size+oy}};
}
+2 -2
View File
File diff suppressed because one or more lines are too long
+20 -20
View File
@@ -247,27 +247,27 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
}
}
// Macaron exit: the token reaches its port, swells like a bubble, pops, and drifts off as smaller bubbles with
// highlights that fade out. Circles with alpha only; every bubble's offsets are fixed so all frames agree.
// Macaron exit: the token stops at its port and dissolves like a sugar cube. The solid face, rim and arrow fade in the
// first fifth while a lattice of dots in the token colour takes over; the dots then erode from the rim inward, each
// shrinking, thinning towards white and drifting a little as it goes. Circles, polygons and alpha only; the noise is a
// fixed hash of the lattice position, so every frame agrees.
function bubbleDissolve(p,edge,angle,size,tile,color,t){
const dx=Math.cos(angle),dy=Math.sin(angle),alphaHex=v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0'),base=color.slice(0,7);
const reach=smoothMotion(Math.min(1,t/.38)),at={x:edge.x+dx*size*.22*reach,y:edge.y+dy*size*.22*reach};
const swell=t<.38?1+.14*smoothMotion(t/.38):Math.max(0,1.14*(1-(t-.38)/.10));
if(swell>0)drawTile(p,at.x,at.y,size,tile,angle,swell);
const pop=(t-.36)/.30;
if(pop>0&&pop<1){const r=size*(.5+.9*(1-Math.pow(1-pop,3))),ring=Array.from({length:36},(_,i)=>({x:at.x+Math.cos(i/36*Math.PI*2)*r,y:at.y+Math.sin(i/36*Math.PI*2)*r}));p.line([...ring,ring[0]],tint(base,.45)+alphaHex(.8*(1-pop)),Math.max(.6,size*.09*(1-pop)));}
// Ten bubbles leave the popped face together: three large, the rest small, spread across the face and drifting
// outward past the port, upward, with a slow wobble, so the cloud stays on screen long enough to read.
for(let i=0;i<10;i++){
const s1=(i*7919%97)/97,s2=(i*104729%89)/89,s3=(i*15485863%83)/83;
const born=.34+.12*s1,life=.48+.18*s2,u=(t-born)/life;
if(u<=0||u>=1)continue;
const side=(s3-.5)*2.4,along=(s1-.5)*1.4,r0=size*(i<3?.22+.12*s2:.11+.12*s2);
const drift=size*(.05+(.35+.5*s2)*(1-Math.pow(1-u,2))),wobble=Math.sin(u*Math.PI*1.5+s1*6.28)*size*.12;
const bx=at.x+dx*(along*size*.35+drift)-dy*(side*size*.5+wobble),by=at.y+dy*(along*size*.35+drift)+dx*(side*size*.5+wobble)-size*.42*u;
const grow=Math.min(1,u/.18),fade=u>.6?1-(u-.6)/.4:1,r=r0*grow*fade;
if(r<=0)continue;
p.circle(bx,by,r,tint(base,.28)+alphaHex(.92*fade));
p.circle(bx-r*.32,by-r*.34,r*.28,'#FFFFFF'+alphaHex(.9*fade));
const x=edge.x+dx*size*.06,y=edge.y+dy*size*.06,r=size*.90,solid=1-smoothMotion(t/.18);
if(solid>0){const face=roundedHex(x,y,r,.16);p.polygon(face,base+alphaHex(solid));p.line([...face,face[0]],'#FFFFFF'+alphaHex(solid),Math.max(1,r*.10));}
const step=size*.17,inset=r*.82,rows=Math.ceil(inset/(step*.866))+1,cols=Math.ceil(inset/step)+1;
for(let j=-rows;j<=rows;j++)for(let i=-cols;i<=cols;i++){
const ox=(i+(j&1?.5:0))*step,oy=j*step*.866,ax=Math.abs(ox),ay=Math.abs(oy);
if(ay>inset*.866||ax*.866+ay*.5>inset*.866)continue;
let h=(i*374761393+j*668265263)|0;h=Math.imul(h^(h>>>13),1274126177);h^=h>>>16;
const n1=((h>>>0)%1000)/1000,n2=(((h>>>10)>>>0)%1000)/1000;
const rim=Math.max(ay,ax*.866+ay*.5)/(inset*.866);
const u=(t-(.05+.40*(1-rim)+.20*n1))/.33;
if(u>=1)continue;
if(u<=0){p.circle(x+ox,y+oy,size*.115,base);continue;}
const k=1-smoothMotion(u),drift=size*.14*u,a=n2*Math.PI*2;
p.circle(x+ox+Math.cos(a)*drift,y+oy+Math.sin(a)*drift-size*.10*u,size*.115*k,tint(base,.45*u)+alphaHex(.95*(1-u*.6)));
}
const arrow=1-smoothMotion(t/.3);
if(arrow>0){const rot=pts=>pts.map(([u,v])=>({x:x+r*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+r*(u*Math.sin(angle)+v*Math.cos(angle))})),w=Math.max(1.5,r*.20),white='#FFFFFF'+alphaHex(arrow);p.line(rot([[-.34,0],[.30,0]]),white,w);p.line(rot([[.02,-.30],[.30,0],[.02,.30]]),white,w);}
}
+1 -1
View File
@@ -56,7 +56,7 @@ function paintSamples(){
document.querySelectorAll('[data-sample]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;const v=layout(studyLevel.cells,width,width);drawScene(p,studyLevel,studyLevel.tiles,v,{theme:directionTheme(direction,c.dataset.sample,colorCount)});});
document.querySelectorAll('[data-pigment]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;drawTile(p,width/2,width/2,width*.46,appearanceTile({color:Number(c.dataset.pigment),dir:1},theme));});
}
function resize(){const {width,height}=fit(canvas);const base=layout(game.level.cells,width,height);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();paintSamples();}
function resize(){const {width,height}=fit(canvas);const base=layout(game.level.cells,width,height,direction.layout?.pad||0);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();paintSamples();}
function controls(){const busy=!!animation||!!rotation;for(const id of ['demo','left','right','undo','hint'])$(id).disabled=busy; $('undo').disabled=busy||!game.history.length;$('moves').textContent=game.moves;}
function draw(now=performance.now()){
if(!view)return;