#!/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 打分 → 拟合权重 → 重选关卡」这个闭环合不上,而且不是样本量的问题。下面是它为什么合不上,以及流程该改成什么样。
机械面干净,说明生成期的闸门是有效的:「动了很多次没结果」这类结构缺陷已经被挡在关卡集之外。难度评分那一格是这一轮真正的发现——它不是不准(复测 r = 0.93,相当稳),它是不区分:机械上差着三倍的关卡,它一律给 ${R1.flat[0]} 分。
| 指标 | 可用 | 依据 |
|---|---|---|
| 盲转 / 多余转 / 转错方向 | 可用 | 复测 r ≈ 0.82–0.92;驱动了真实修复(1.17 / 1.22 / 0.44 → 0) |
| 是否通关、空点次数 | 可用 | 直接观察,无主观成分 |
| 自由文本抱怨 | 可用 | 33 条里两个主题高度重复,指标抓不到;但须对照基线(规则 5) |
| 难度评分 | 只能看总体 | 稳定但不区分;单关排序不可用,整批读数(偏易一档)可用 |
| 无解感评分 | 谨慎 | 复测 r = 0.85,与无效动作相关 0.66,可当挫败感的代理 |
| 好玩 / 清晰度 / 旋转评分 | 不可用 | 复测 r ≤ 0.56,是模型噪声 |
select-50.mjs 按特征打分并均匀铺开,家族上限防止一套模板占满(上一版 1000 关实际只有 30 套模板)。| 试过什么 | 结果 |
|---|---|
| 拟合 difficulty ~ 机械特征,用来重选关卡 | 响应几乎无方差(${R1.flat[1]}/${R1.rated} 同分),回归的是常数 |
| legibility(一眼看穿的成本)解释难度评分 | 相关 -0.02,无效;降级为纯测量 |
| 路线交叠度当难度轴 | 中位已达 89%,没有上升空间 |
| 换开局朝向来提高判断成本 | 127 个候选里只有 7 个变好 |
| 「插入端多朝向出口」过滤器 | 对漏斗零效果,已删除 |
| 把锁住比例当难度主轴排序 | 方向反了:它奖励「开局什么都做不了」 |