#!/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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = ` 关卡制作流程复盘

关卡制作流程复盘

这一轮做 50 关的过程里,最有用的结论是一个否定结论:原计划的「agent 打分 → 拟合权重 → 重选关卡」这个闭环合不上,而且不是样本量的问题。下面是它为什么合不上,以及流程该改成什么样。

实测批次 R1 · grok-4.6 xhigh · ${R1.n} 关 · 生成于 ${new Date().toLocaleString('sv-SE').slice(0, 16)}

R1 实测到了什么

通关
${R1.cleared}/${R1.n}
无死局
空点 / 关
${f2(R1.dead)}
目标 0
盲转 / 关
${f2(R1.blind)}
目标 0
转错 / 关
${f2(R1.wrong)}
目标 0
难度评分
${R1.flat[1]}/${R1.rated}
都是 ${R1.flat[0]} 分 — 不区分

机械面干净,说明生成期的闸门是有效的:「动了很多次没结果」这类结构缺陷已经被挡在关卡集之外。难度评分那一格是这一轮真正的发现——它不是不准(复测 r = 0.93,相当稳),它是不区分:机械上差着三倍的关卡,它一律给 ${R1.flat[0]} 分。

五条规则,各自的证据

${RULES.map(r => `

${r.n}${esc(r.rule)}

证据
${esc(r.ev)}
为什么
${esc(r.why)}
现在怎么做
${esc(r.now)}
`).join('\n')}

agent 能测什么,不能测什么

指标可用依据
盲转 / 多余转 / 转错方向可用复测 r ≈ 0.82–0.92;驱动了真实修复(1.17 / 1.22 / 0.44 → 0)
是否通关、空点次数可用直接观察,无主观成分
自由文本抱怨可用33 条里两个主题高度重复,指标抓不到;但须对照基线(规则 5)
难度评分只能看总体稳定但不区分;单关排序不可用,整批读数(偏易一档)可用
无解感评分谨慎复测 r = 0.85,与无效动作相关 0.66,可当挫败感的代理
好玩 / 清晰度 / 旋转评分不可用复测 r ≤ 0.56,是模型噪声

要做 1000 关的话

  1. 先把判据写成可执行的闸门,再开始生成。这一轮的闸门链是:长成 → 首转双向 → 锁住 ≤90% → 无不可解态 → 终局朝向 ≥2 → 每转释放 ≥2 → 反差 ≥12 → 开局可点 ≥3 → 开局演出 ≥0.9s → 长巡游 ≥3。每一级剩多少必须打印出来。
  2. 用规则机器人做全量验收(12 秒 / 全池),不用 agent。机械旋转指标已证明测的是关卡不是玩家。
  3. agent 只抽样跑,而且只看两样东西:机械缺陷指标,和自由文本抱怨。不要为难度标定花 agent 时间——那条路这一轮已经证明走不通。
  4. 难度曲线靠机械假设铺,靠人眼裁定。用 select-50.mjs 按特征打分并均匀铺开,家族上限防止一套模板占满(上一版 1000 关实际只有 30 套模板)。
  5. 每一轮只动一个变量,并且必须能在漏斗里看到它挡掉了什么,否则撤回。
  6. 人工审核要准备好材料:开局盘面从求解器取(不是示意图)、标明哪些数字可信、每关一个试玩直达链接。

被实测否掉的做法

试过什么结果
拟合 difficulty ~ 机械特征,用来重选关卡响应几乎无方差(${R1.flat[1]}/${R1.rated} 同分),回归的是常数
legibility(一眼看穿的成本)解释难度评分相关 -0.02,无效;降级为纯测量
路线交叠度当难度轴中位已达 89%,没有上升空间
换开局朝向来提高判断成本127 个候选里只有 7 个变好
「插入端多朝向出口」过滤器对漏斗零效果,已删除
把锁住比例当难度主轴排序方向反了:它奖励「开局什么都做不了」
`; writeFileSync(resolve('docs/level-process.html'), html); console.log(`流程复盘已生成:docs/level-process.html(R1 ${R1.n} 关实测,${RULES.length} 条规则)`);