第20章 - 踩坑记录与常见问题

这一章不是”注意事项清单”,而是事故复盘。下面 14 条都来自这个仓库真实发生过的问题,每一条都有对应的修复提交、测试或门禁。写法统一为「现象 / 根因 / 修法 / 教训」——现象写玩家或开发者看到的样子,根因写到文件和函数,修法写当时实际怎么改的,教训是下次能提前避开的那一句。

挑这 14 条的标准是:它曾经让门禁全绿却仍然出错,或者它会在真实使用中丢数据。纯粹的语法问题不在此列。

20.1 一页总表

# 现象 类别 影响
1 生产构建白屏,dev e2e 全绿 构建 P0,线上不可用
2 第 11 次起 run id 重复 运行时 对比/回看错乱
3 旧快照被后续动作改写 运行时 订阅方看到”历史变了”
4 工具面板状态残留 运行时 重玩后与运行时对不上
5 判定可被产物自报字段绕过 判定 反刷题门禁失效
6 “缺步骤”顶掉真实错误 判定 20 关的失败反馈不可达
7 死失败类:声明了却永不触发 判定 反馈形同虚设
8 证据裁剪丢历史,聚合恒为 0 存档 画像与徽章失真
9 报告包导入无体积/协议闸门 安全 恶意文件可注入或撑爆内存
10 “预览”打掉已提交状态 交互 看着像游戏坏了
11 “查看指标/预览”是死按钮 交互 玩家点了没反应
12 中文整句当 i18n key 触发门禁 双语 英文界面漏中文
13 关卡卸载不彻底 外壳 路由切换后泄漏
14 畸形 hash 让路由冻结 路由 整页失去响应

20.2 判定类:门禁要拦”绕过”,不是拦”看起来不对”

20.2.1 判定可被产物自报字段绕过

现象:分配关(k-modeling-share / g13-modeling-pack)的产物由客户端拼出来,校验时玩家多写一种未登记的物品就能凑够”种类”,把 total 写小就能绕过容量上限;论证关换个措辞就能通过;对局模拟写 n=1e9 能把页面跑死。

根因:三处 validateArtifact 信任了产物里的自报值,或者用了写死的选项表:

位置 具体问题
packages/templates/src/construct/distribution.ts 信任产物自报的 container.total 与未登记物品键
packages/templates/src/argue/logic.ts 证书选项写死成 coloring / parity / domino-tiling,没读 levelScript
packages/templates/src/experiment/duel.ts 只挡 n <= 0,没挡上限;n=5001 实测居然通过

修法:只统计 schema 里登记的物品,total 由引擎重算;论证证书的选项来自 levelScript 的 claims/warrants/scopes;对局按 schema 上限(5000)收敛,超限直接判 exp-not-run。

教训:产物是”玩家的声明”,不是”事实”。 凡是能从产物里读出来、又能被玩家随手改写的量(总数、种类、计数、选项),都必须由引擎按注册表重算或按脚本收敛。这条现在被 audit:levels 的 A3(两解产物互不相同)与 A1b(空产物必须被结构反馈拒绝)间接守着。

20.2.2 “缺步骤”顶掉真实错误

现象:玩家在路网关答错了,屏幕上出现的不是”关键站点还没接满”,而是”先完成工具步骤:提交满足预算的路网”;内容里写了 6 条失败反馈,玩家一条都遇不到。

根因:submit 的第一件事是查 missingEvidence,非空就直接返回”缺步骤”判决,根本不跑校验。四处实现都这样:packages/templates/src/shared/runtime.ts(4 个模板共用)、pattern/runtime.ts(pattern 自己又写了一遍)、以及宿主只显示第一条缺失。逐关核对的结果很刺眼:g79-structure-network 声明 6 条反馈、UI 可达 0 条;g1012-algorithm-frontier 4 条声明、0 条可达。

修法:Tier 0 全局改造里把 submit 改成先结构校验、再报缺步骤:有结构错误先报结构错误,缺步骤降级为第二行并指向具体控件(”在论证板上点「提交论证」”而不是”先完成工具步骤:提交论证证书”)。A1 审计(空产物必须给缺失证据)保持不变。

教训:门禁的顺序本身就是产品决策。 “先检查流程”看起来更友好,实际上把所有真实错误都换成了同一句流程提示。按改造前的静态核对,construct 6 关共 31 条声明反馈中 UI 只到得了 9 条;改提交顺序后文档给出的估算是约 17/31——这是静态推算,不是逐条复测,剩下那些仍受内容与面板结构限制。顺带说明:content:check 与 A4 只验证”可达失败类 ⊆ feedback“(声明层),不验 UI 能不能显示,这是当时全绿的原因。

20.2.3 死失败类:声明了却永远触发不到

现象:queue-unserved 这个失败类在内容里有反馈、在 schema 里合法、在审计里”有反馈”,但玩家永远遇不到。

根因:排队模型没有接待上限,served 恒等于到达数,所以 queue-unserved 在结构上不可达。

修法:从 reachableFailureClasses 移除,并在源码里写明原因;内容里的 feedback 条目保留(content:check 只要求”可达类 ⊆ feedback”,多余条目无害)。

教训:“写了反馈”和”反馈可达”是两件事。 让一个失败类不可达而不删它,是诚实;假装它可达,就是给未来的自己埋雷。

20.3 运行时类:id 与快照的”看着没问题”

20.3.1 run id 在第 11 次之后重复

现象:同一关连跑多次后,回看对比错乱;实测 12 次 run 只有 9 个唯一 id。

根因:run id 用 runs.length + 1 生成,而 runs 被 slice(-10) 封顶。前 10 次 length 递增,第 11 次开始窗口满了,length 恒为 10,于是 id 回到 11 并开始重复。

修法:改用单调递增的 runSeq,reset 时归零。

教训:不要用”集合长度”推导”下一个 id”。 只要集合会裁剪,长度就不是计数器。凡是”id 由数组长度算出”的地方,都该换成显式计数器。

20.3.2 旧快照被后续动作改写

现象:订阅方拿到 snapshot() 之后,过一会儿再看同一份快照,toolUsage 的数字自己变了(0 → 1)。

根因:snapshot().toolUsage 直接透传内部引用,没有克隆。订阅方保存的是引用,运行时后续 dispatch 改的是同一个对象。

修法:逐工具克隆。

教训:快照的语义是”那一刻的深拷贝”,只要有一个字段是引用,”不可变快照”就是假的。这类 bug 在单测里很难自然暴露,需要专门写”保存旧快照 → 再 dispatch → 断言旧快照没变”的用例。

20.3.3 工具台面板的状态残留

现象:重玩一关时,上一轮选的变换/论证还留着,和运行时对不上;骨牌同毫秒连点两格会生成同一个 id,删一块连坐另一块。

根因:PatternTool.vue 与 ArgumentBoard.vue 的本地状态没有随 reset 清理;DominoTool.vue 用 Date.now() 当 id。

修法:由快照回填(watch),下拉/变换随 reset 清空;骨牌改模块级自增 id。

教训:组件本地状态是运行时的缓存,不是事实来源。 事实在 RunSnapshot 里,重玩时应该”快照说没有就是没有”。同理,Date.now() 不是唯一 id 生成器——同一毫秒内可以发生很多事。

20.4 存档与证据类:会真实丢数据

20.4.1 证据裁剪丢历史,聚合恒为 0

现象:玩了很久以后,画像里的”总尝试次数”不增长,甚至归零。

根因:证据裁剪到 3000 条时直接丢弃最旧事件,而这些事件从未被并入聚合。明细没了,聚合也没算过它们,历史计数永久丢失。

修法:裁剪量并入 EvidenceAggregate(total/按关卡/工具/阶段/失败类/时间),并保证先写明细后写聚合,避免重复计数。

教训:“上限 + 裁剪”必须配套”聚合”。 只做裁剪就是把历史静默删掉。同类问题还有三处一起修的:并发完成时两次写盘无序(改串行队列 + 失败吞错)、refreshBadges 取 recent(2000) 少取(改成取满 MAX_EVIDENCE_EVENTS,引用取最新 20 条)、单条证据无体积约束(字段白名单 + metrics ≤8 键 + UTF-8 ≤1536 字节的分级裁剪)。

20.4.2 报告包导入缺闸门

现象:任何 JSON 都能被”导入报告”接受;畸形文件可以让 uses 变成 NaN;带 HTML 的昵称会被渲染。

根因:parseReportPack 没有体积上限、没有 HTML/脚本协议拒绝、数值未归一、昵称未截断。

修法:512 KB 闸门(先于任何解析)、HTML/脚本协议整包拒绝、数值归一、按码点截断、昵称清洗移到拒绝判定之前。

教训:导入通道是攻击面,即使它只是”只读查看”。 而且判定顺序很重要:先做体积与协议这类”不用理解内容就能判”的拒绝,再谈字段清洗。

20.5 外壳与构建类:dev 全绿不等于产物能用

20.5.1 P0:生产构建白屏

现象:pnpm e2e(dev)全绿;pnpm e2e:dist(生产产物)每条都超时,页面 #app 为空。

根因:CSP 用正则替换 </head> 注入,而 apps/site/index.html 的注释里恰好写了 </head> 这个字面量。替换命中注释内部,把 <script type="module"> 与注入的 CSP meta 一起塞进注释,同时吃掉了真正的 </head>。

为什么门禁没拦住:体积门禁只量文件大小(白屏产物反而更小);dev e2e 走 Vite dev server,不经过 transformIndexHtml 的生产注入路径。

修法:改成 Vite 的标签描述符注入(transformIndexHtml: () => [{ tag: 'meta', attrs: {...}, injectTo: 'head' }]),由 HTML parser 负责插入;注释里的字面量一并去掉。

防回归:check-size.mjs 增加”剥掉注释后必须仍有 module script 与 CSP meta”的产物结构检查;csp.test.ts 断言注入方式是标签描述符;CI 的 preview job 跑 e2e:dist。

教训:对 HTML 做字符串级注入,等于把解析器的工作交给运气。 只要一段用户可见的 HTML 里可能出现目标字面量(注释、文案、代码示例),就必须交给真正的 HTML parser。

20.5.2 关卡卸载不彻底

现象:路由切走后仍有残留:canvas、requestAnimationFrame、订阅回调、debug 桥闭包。

根因:apps/site/src/views/WorkshopView.vue 的卸载只做了”移除组件”,没有同步退订、注销 submit、清定时器、unmount()、清 DOM;异步 mount 的竞态也没有补销毁。

修法:同步 teardown + 异步竞态后补销毁;完成事件也在落盘 + 刷新徽章之后才发 level:complete(否则 e2e 双页用例可能读到旧档)。

教训:“卸载”是一个清单,不是一个函数调用。 每个 onMounted 里注册的东西,都要在 onBeforeUnmount 里逐项对上。

20.5.3 畸形 hash 让路由冻结

现象:访问 #/play/%E0%A4%A 这类畸形编码后,之后的 hashchange 不再更新,整页”卡”在原页面。

根因:decodeURIComponent 遇畸形编码抛错,异常发生在路由解析里,于是后续监听失效。

修法:把路由解析抽成纯函数 apps/site/src/shell/routePath.ts,解码失败回大厅,并配单测(六路由 + 畸形编码)。同时修了另外两处外壳问题:深链首帧语言错误(语言初始化移到 main.ts)、同一宿主在 #/play/a → #/play/b 复用(给 WorkshopView 加:key)。

教训:URL 是用户可编辑的输入。 任何”解析后直接用于渲染”的函数都必须是全函数——对任意字符串都有返回值,而不是抛异常。

20.6 交互与双语类:门禁也会误伤

20.6.1 “预览”打掉已提交状态

现象:玩家先点「用这条规律」提交,再点「预览一下」,之后无论点”完成”还是”试一下”,都报”先提交一条规律”/”先完成工具步骤:运行一次”——看着像游戏坏了。

根因:pattern/setCycle 会把 committed 清成 false,而 PatternTool 的预览按钮先发 setCycle 再发 preview。于是”预览”这个纯观察动作,副作用是撤销提交。

修法:pattern/preview 改为可携带 cycle/transform,预览时直接写规则;只有预览的规则与已提交的不一致时才让旧提交失效。PatternTool 预览不再先发 setCycle。

教训:“查看类”动作不该改变判定状态。 如果它的确需要写状态(把预测结果画出来),那就写”预览态”,不要碰”已提交态”。这条是验收脚本跑出来的:改完之后”提交 → 预览 → 运行 → 完成”整条链路才真的通。

20.6.2 死按钮:点了屏幕没变化

现象:排队关的”查看指标”、推断关的”查看区间”、胜率关的”用这次数据下结论”,点完什么都没发生;更糟的是队列关显示”还没有运行记录”,因为 KnobTool 只认 sim-runs-v1,而 queue 的数据集是 queue-runs-v1。

根因:按钮只置了 focused/compared=true 之类的内部标记,渲染层没有消费它;数据集 key 不一致导致面板永远读空。

修法:删掉纯观察按钮,把指标/区间/目标线直接渲染在面板上(”数据上屏”);统一数据集 key;新增 e2e/ui-smoke.spec.ts——8 个面板”关键动作后可见区必须变化”。

教训:一个控件如果点完屏幕没变化,它就不该存在。 这条门禁很便宜(动作前后各取一次 innerText 比较),却能一劳永逸拦住”死按钮”和”运行完没反馈”两类问题——而这两类问题在 audit:levels 的 autoplay 路径下全绿,因为 autoplay 绕开了 UI。

20.6.3 中文整句当 i18n key

现象:英文界面漏出中文;pnpm verify 在 i18n-coverage 上失败。

根因:模板层不自带 i18n,它把中文整句放进 ActionResult.message。英文界面是否漏中文,取决于兜底表是否收录了这些整句。更隐蔽的是注释里的中文整句也会被扫到:i18n-coverage.test.ts 用正则扫模板源码里的引号字符串,代码注释里带引号的中文句子同样命中。

修法:工具面板的错误消息统一走 t()(之前 error = result.message 直接渲染,绕过了翻译);英文兜底表补齐模板侧整句与场景短句;新增 i18n-coverage.test.ts,模板新增中文整句却没给英文就红。踩坑那一轮里,最后是改注释表述(去掉引号)过的门禁。

教训:源码扫描型门禁会有”误伤”,但误伤比漏网好。 遇到误伤要改代码适配门禁,而不是放宽门禁。另外要接受一个已知缺口:纯拼接片段(" / 守 "、" 条边 · 成本 ")无法查表,需要把模板改成”整句模板 + 参数”,那是一次会动 5 个 runtime 的重构,至今未做。

20.7 元教训:从这些事故里抽出来的五条

  1. “dev 能跑”不等于”产物能用”。 凡是构建期改写 HTML / CSP / 资源路径的逻辑,必须有针对产物的验证(e2e:dist + 产物结构检查),dev e2e 天然覆盖不到。
  2. “测试没被 include”和”没写测试”一样危险。 外壳那三个测试写了但 vitest.config.ts 的 include 没覆盖,静默不执行,CI 一路假绿。改配置时那一行注释值得每年读一次。
  3. “声明层”的门禁不等于”UI 层”的保障。 content:check 与 A4 只验”失败类有反馈”,不验”玩家能不能看到”;audit:levels 的 autoplay 绕开 UI,所以 27 击、死按钮都能全绿。补洞的方式是新增 UI 冒烟与交互预算两条 e2e,而不是继续加强声明层。
  4. 文档里没有实现的东西要删掉或标注。 docs/10 修正了 11 处文档漂移:删掉了从未实现的 construct/rotate、不存在的 i18n/en/*.level.json 目录、10 条路由中的 4 条、(不存在的)apps/desktop 与 apps/mobile。不删的代价是下一轮审查把”计划”当”现状”,继续在上面垒代码。
  5. 修 bug 时先问”为什么之前没红”。 生产白屏那次,59 条 dev e2e 全绿——因为它们跑的路径根本不经过构建期注入。用例守得住它看见的那条路,守不住它压根不经过的那条;所以”为什么没红”的答案往往是”它测错了地方”,而不是”这个 bug 很特殊”。

20.8 FAQ

Q1:pnpm dev 报端口被占,起不来怎么办?

两个端口都是 strictPort: true,被占时不会自动换端口,会直接失败。这是刻意的:e2e 的 baseURL 写死 127.0.0.1:5192,安静地换端口只会让测试连到别的服务上。

# Windows
netstat -ano | findstr :5192
taskkill /PID <pid> /F

# macOS / Linux
lsof -i :5192
kill -9 <pid>

如果是上一轮 Playwright 残留的 dev server,playwright.config.ts 的 reuseExistingServer: !process.env.CI 会直接复用它——本地开发时这很方便,但改了 Vite 配置后记得手动重启,否则复用的是旧配置。

Q2:页面白屏,控制台报 CSP 或资源 404,怎么查?

先分清楚是 dev 还是产物:

# 1. 确认是产物问题还是 dev 问题
pnpm build && pnpm preview    # 打开 http://127.0.0.1:5193

# 2. 看产物结构:有没有 module script、有没有 CSP meta
pnpm size                     # 结构体检失败会直接指出"可能被注释掉了"

# 3. 看首屏引用了哪些资源
grep -o 'assets/[^"]*\.\(js\|css\)' apps/site/dist/index.html

最可能的三个原因:base 被改回 '/'(子路径部署时全 404);CSP 注入落进注释(check-size 会拦);构建产物是旧的需要重新 pnpm build(e2e:dist 里已带 build,但手动起 preview 不会)。

Q3:存档里有旧版本数据,会不会崩?

不会。packages/progress/src/save.ts 的 migrate 做字段级 sanitize:坏字段只丢自己,同一关的其他字段与其他关都保留;未来版本号回空档;updatedAt 归一成当前时间。单测里就是喂一份”全乱”的存档来验的。

但有一个前提:schema 版本升级必须写迁移。如果直接把 SAVE_SCHEMA_VERSION 加一却不处理旧字段,旧档会被当成”未来版本”回空——不崩,但进度没了。要重来一次就清 IndexedDB 里的存档键;浏览器开发者工具 → Application → IndexedDB → 删除对应库即可。

Q4:e2e 报浏览器没装 / 启动失败怎么办?

pnpm exec playwright install --with-deps chromium

CI 里这一步是显式的,并且先用 actions/cache 缓存 ~/.cache/ms-playwright(key 基于 pnpm-lock.yaml 的 hash),所以升级 Playwright 版本会自动重装。本地如果只想重跑某几条:

pnpm e2e -- e2e/budget.spec.ts
pnpm e2e -- --grep "无工具提交被拒"

失败时 playwright-report/ 与 test-results/ 会保留 trace 与截图,CI 上传 7 天。

Q5:pnpm size 报体积超预算,怎么定位?

先读它打印的分类清单——它会分别列出首屏引用文件、懒加载 chunk、非代码资源,超限的那一行前面是 ✗。然后按顺序排查:

pnpm build
pnpm size          # 看是哪一类超了

# 首屏超了:多半是又 import 了 zod / Phaser / 某个关卡模块到外壳
grep -rn "from '@mmf/templates\|from 'phaser'\|from '@mmf/content/full'" apps/site/src --include='*.ts' --include='*.vue'

# 最大 chunk 超了:看它是不是 Phaser(220.3 KB)或某个模板
ls -S apps/site/dist/assets/*.js | head -5

# dist 总量超了:通常是新增了图片/字体这类非代码资源(预算本来是 0)
find apps/site/dist -type f -not -name '*.js' -not -name '*.css' -not -name '*.html'

三条纪律:不要为了让门禁变绿就改预算(改数字必须同时改 docs/02 §13.2 并写明原因);不要把大依赖塞进首屏(@mmf/content/full 是给 Node CLI 与单测用的,进首屏就是事故);英文兜底表已经接近预算(首屏 69.5 KB / 85 KB),往里加译文前先量一次。

小结

这 14 条事故可以归成四类,每类对应一种思维方式:

  • 判定类:产物是玩家的声明,不是事实;门禁顺序本身就是产品决策;”有反馈”不等于”反馈可达”。修法是把量算权收回引擎、把结构校验提到缺步骤之前。
  • 运行时类:不要用集合长度推导 id,快照必须是深拷贝,组件本地状态只是缓存。这些都不会自然被单测暴露,需要专门写”旧快照不变”“重玩清空”这类用例。
  • 存档与安全类:裁剪必须配聚合,导入通道必须先是攻击面。丢数据的问题一旦发生就不可逆,所以这部分测试专门喂”坏数据”。
  • 外壳与构建类:dev 全绿不等于产物能用,卸载是清单不是函数,URL 是用户可编辑的输入。修法几乎都是”抽出纯函数 + 补一条针对产物的验证”。

最后两条元教训值得单独记住:修 bug 时先问”为什么之前没红”——如果旧用例本来就靠 bug 通过,那它守的是缺陷,不是行为;门禁本身也要被检验——故意做坏一次,看它会不会红,是这套体系里最便宜的自查。

至此,从模式到边界的这条链就走完了一遍:契约与运行时(第 4 章)、六个模板与工具(第 7–12 章)、画布与双视图(第 13 章)、证据与报告(第 14–16 章)、内容生产与门禁(第 17–18 章)、构建发布(第 19 章),以及这一章的失败清单。剩下的工作写在仓库的 docs/09 里:真实用户测试、原生壳、英文人工校读——三件都还没做,也都不该被”完成了”的说法掩盖。