跳转至

没有玩家,怎么证明游戏平衡没被调坏

算法实验室 TSP 复盘 · 第 1 篇。十二款小游戏/仿真全部做完、真人玩家为零的阶段,我靠什么保证"聪明策略能赢、鲁莽策略吃亏"这条底线没有在一次调参中悄悄破掉。答案:把平衡写成单元测试——以及承认这套测试测不到什么。

现象

每款游戏的可调参数全部集中在各自 engine 的 DEFAULT_PARAMS 里,这是当初为了方便调参做的设计。但它带来一个当时没预料到的问题:任何一个数字都不只属于一个功能。静默监听站里改一个测向命中基率,同时影响"暴露涨多快""情报攒多快""误差圆收多紧"三条曲线的相对关系;断踪里改一个痕迹沉积系数,谨慎玩家的生存空间和鲁莽玩家的死亡速度一起变。

发布前没有真人玩家。每次改参数都是盲改:手动回归等于把每款游戏各重玩若干局,而人对"难度变化"的主观记忆靠不住——上周觉得偏难、这周觉得刚好,很可能只是我状态不同。

我一开始的错误判断

有两层,第一层差点犯、第二层真犯了。

第一层(差点犯):把仿真时代的测试观直接带进游戏。 这个项目从一单普惠经历经济仿真起步,当时定的验收清单是典型的不变量思维:门槛公式、约束违例、转单接受条件、同种子双模式确定性、守恒式(补贴消耗 = Σg、交易总额 = ΣB、FAIR 外部租金恒为 0)。这套清单对仿真够用——仿真的正确性就是守恒与确定。但游戏不一样:游戏有策略生态,"所有守恒式成立"完全推不出"聪明策略还能赢、鲁莽策略还会输"。幸运的是这个缺口在第一款勘测者游戏立项时就被补上了——评审文档里静默监听站的交付标准写着"平衡验证(固定 seed 自动策略对局)",策略 bot 扫描从此成了系列标准件。说"差点犯",是因为转向发生在第一款游戏而非第三款。

第二层(真犯了):以为"写个 bot 替我玩"就等于玩家测过了。 bot 确实解决了回归问题,但它引入了新问题——bot 不是人。打脸记录有两处,都在试玩报告里:低迹勘测的 bot 十二个 seed 全部成功返航,"人手键盘操控的坠机/超时才是真实难度,此项只能人工验证";断踪的识路 bot 全程未被追,"追击压力对人玩家才会出现(人不会最优绕行)"。

排查与关键证据

第一步:让"一局"可以被程序跑。 前提早在架构里埋好了,而且是被一场"框架之争"逼出来的——立项时的问题原话是"框架是资产还是负担",最终结论写成了一条铁律:仿真引擎是纯 TS,永远不知道 React 的存在。engine 目录禁止 import React/DOM,每一局都能在 Node 里跑完。没有这一步,后面全部免谈。

第二步:引擎级试玩。 没有浏览器自动化的阶段,我为每款写"玩家策略脚本"跑完整一局,提取逐回合体验数据。试玩报告开头的原话:

方式:本会话无浏览器工具,采用「引擎级试玩」——为每款写玩家策略脚本跑完整一局,提取逐回合体验数据。 已验证:节奏、压力曲线、经济、事件时点、结算路径。未验证:UI 点击精度、FPV 相机滚转符号、HUD 布局(需人工)。

这份报告最有价值的部分其实是"未验证"那一行——它逼我承认引擎级测试的边界。

第三步:策略机器人。 每款至少两个 bot,卡在策略族两端。以静默监听站为例(bots.ts 头注释):

recklessBot —— 鲁莽策略:高功率一直开机原地不动(预期大多数 seed 被定位)。 cautiousBot —— 谨慎策略:低功率试探频点、确认有效后中功率监听、暴露临界即静默、早找遮挡、误差圆逼近就转移(预期大多数 seed 能胜利)。

一条关键纪律写在类型签名里:BotPolicy = (state) => action | undefined——bot 只能拿和真人一样的 GameState,禁止全图信息。断踪的平衡测试里甚至专门挂了一行注释,提醒读断言的人 bot 与人的信息面差异:

12 seed 中 ≥6 局胜利(bot 无全图信息,人类玩家可见整图,实际难度更低)

第四步:固定 seed 扫描 + 带宽断言。 shared/rng.ts 是 mulberry32,同 seed 产生完全一致的随机序列。测试里固化了一组 seed 常量(静默监听站与断踪用的是同一组),断言写的是分布带宽而不是某局结果:

  • 静默监听站:高功率常开不移动,12 seed 中 ≥8 局 LOCATED;试探+静默+转移,12 seed 中 ≥7 局胜利。
  • 断踪:谨慎穿越者 ≥6 局胜、鲁莽穿越者 ≤3 局胜。

注意断言锁的是底线(鲁莽必须输得难看、谨慎必须能赢),不是"人类会觉得多难"——这一区分来自第二层错误判断的打脸记录。

第五步:把调参文档和断言焊在一起。 docs/tuning-guide.md 给每个旋钮标了含义和"手感问题 → 调法",开头第一段就写死流程:

每款的平衡测试(engine/balance.test.ts)会告诉你改动是否破坏了「聪明策略能赢、鲁莽策略吃亏」的底线——改完跑一次 npx vitest run <模块名>。

第六步:后补 GUI 冒烟层——它很快证明了自己不是奢侈品。 引擎级测不了渲染、交互和打包产物,后来用 Playwright 补了一层:真实点击/键盘把每款过一遍,收集 console 错误并截图(scripts/gui-playtest.mjs)。冒烟结果文件里至今留着一条失败记录:断踪在浏览器里首屏即崩,壳层错误边界捕获的异常是 "Vitest failed to access its internal state"——测试库被打进了浏览器产物。这类错误在 vitest 环境里永远不会失败(测试库当然能在测试环境里跑),只有浏览器打包会炸:引擎测试全绿与浏览器首屏崩溃,完全可以同时成立。仓库根目录的 gui-test-screenshots/ 还留着另一轮逐款排查的截图,每款都有首屏 / 崩溃态 / 按钮缺失态三类留档。后来复查源码,非测试文件里的 vitest import 已经清零——这道闸门现在归 GUI 冒烟层管。

顺带的红利:测试资产二次复用。 平衡 bot 后来长成了玩家可见功能——"AI 军师"提示卡的"代走一步"按钮,复用的正是"已验证的平衡 bot"(组件头注释原话);"AI 全自动"模式以固定间隔步进,注释写着"人眼看得到过程,不是瞬间跳结果"。给测试写的策略,成了给玩家的演示。

根因

想通的是三件事:

平衡不是一组"好数值",是"多类策略相对胜负的不变量"。 不变量必须可执行才能守住;可执行的前提是确定性——同 seed 同局,改参数前后的胜负分布才可比。

bot 与人类的差异是系统性的,不是可以修掉的 bug。 bot 走完美航线、最优绕行,信息面也和人类不同(断踪那条测试注释就是为此写的)。所以断言只能锁底线、不能锁上限:bot 全赢不说明对人太难,bot 全输也不说明对人太松。超出底线的部分,只能留给真人试玩。

每一层测试只能看见自己那一层的世界。 引擎测试看不见浏览器打包,GUI 冒烟看不见手感,bot 看不见人类的笨拙。断踪那场首屏崩溃是最好的一课:它在引擎测试的盲区里活得很好,直到被另一层测试逮住。

约束与取舍

  • 放弃真人测试前置。 代价:手感、点击精度、渲染符号(比如 FPV 相机滚转方向)永远在盲区里,只能靠试玩报告末尾那张人工验证清单逐项兜底。换来:每次改参数有一道几秒钟的自动闸门。
  • 放弃参数全局寻优。 断言只保证底线带宽,不保证当前参数最优。换来:调参从"心惊胆战"变成"跑一遍测试"。
  • bot 只用玩家可见信息。 代价:某些机制问题(上帝视角才暴露的)测不到。换来:bot 胜负对人类有参考意义,而不是自欺。
  • 多养一层 GUI 冒烟。 代价:十二款逐款写点击脚本、维护定位器;换来打包层和交互层的崩溃在发布前被拦住——断踪那次首屏崩溃就是它拦的。

可复用检查清单

  1. 每个策略族至少一个 bot,且 bot 只读玩家可见状态——禁止全图信息,这条要写进类型签名。
  2. seed 集固化成测试常量,禁止随版本漂移;断言锁带宽(≥N / ≤N),不锁精确值。
  3. 每个可调参数在文档里标注:是否手感锚点、影响哪些 bot 断言、改后必跑哪条测试。
  4. 引擎测不了的(手感/渲染/点击精度)单列人工清单,白纸黑字写"未验证",不假装测过。
  5. bot 与人类的已知差异(信息面、完美操作)写成测试注释,防止后人误读断言含义。
  6. 从仿真迁移到游戏时,检查测试清单里有没有"策略族胜负"这一类断言——守恒式推不出平衡。

记录核对情况(发布前删除本节)

已按仓库记录落实:

  • "框架之争"铁律与验收清单:.zcode/plans/(experience-fair 实施规划,"回答你的问题"一节、第五节测试清单)。
  • 策略 bot 成为系列交付标准的起点:docs/surveyor-games-review.md §四(静默监听站 MVP 实现说明)。
  • bot ≠ 玩家的两处打脸记录:docs/playtest-report.md #8、#12。
  • GUI 冒烟抓到的断踪首屏崩溃:docs/screenshots/results.json(唯一一条失败记录,错误 headline 原文引用);当前代码已无非测试文件的 vitest import(已复查)。
  • 逐款排查截图留档:gui-test-screenshots/(initial / crash / no_button 命名系列)。
  • bot 二次复用为 AI 军师 / 全自动:src/shared/AICoach.tsx、src/shared/AutoPlay.tsx 头注释。

仍属重建、无记录支撑(发布前请按真实经历改写或删除):

  • "第一层错误判断"里"差点犯"的心理过程是我的重建,事实部分(仿真验收清单无策略 bot → 第一款游戏起出现 bot 扫描)有据。
  • 两层错误判断的先后顺序按记录推断,若与实际顺序不符请调整。

引用出处

  • src/algorithms/silent-listener/engine/balance.test.ts、bots.ts
  • src/algorithms/trace-break/engine/balance.test.ts
  • src/shared/rng.ts、src/shared/AICoach.tsx、src/shared/AutoPlay.tsx
  • docs/playtest-report.md(引言、#8、#12、人工验证清单)
  • docs/tuning-guide.md(开篇流程段)
  • docs/surveyor-games-review.md(§四)
  • docs/screenshots/results.json、gui-test-screenshots/
  • scripts/gui-playtest.mjs、scripts/mobile-check.mjs
  • .zcode/plans/(experience-fair 实施规划)
  • README.md(架构约束:engine 禁止 import React/DOM)