12.3 从测试到决策
假设被证伪之后必须二选一:改规格还是改假设。用样本量扫描 N=24/100/500/2000/8000 诊断 H4(结局分布熵)——熵在 N=100 起越过 1500 毫比特、N≥500 稳定在 1718–1747,判定为测试设计缺陷、改假设而非改规格,story_spec.json 一行不改;真人测试标 X。
tag
假设被证伪之后必须二选一:改规格还是改假设。用样本量扫描 N=24/100/500/2000/8000 诊断 H4(结局分布熵)——熵在 N=100 起越过 1500 毫比特、N≥500 稳定在 1718–1747,判定为测试设计缺陷、改假设而非改规格,story_spec.json 一行不改;真人测试标 X。
把规格与内核内联进单文件 HTML,用 Playwright 无头注入 24 条选择序列真玩:逐条断言终局节点、结局与状态摘要与 Python 侧一致,两遍确定性 24/24,覆盖率 982‰,截图与批跑 JSON 落盘。真人玩家测试标 X。
本章的「模拟玩家」是采样器,不是玩家模型:它只做路径选择策略(随机 / 贪心 / 覆盖导向),不建模动因、不做玩家分型。本节把这条分工写成一份可 lint 的 D 层声明表,再用 S 层脚本做硬断言,并与 11.1、11.2 真跑产物交叉核对,证明声明与脚本做的是同一件事。
同一份规格、同一个种子,只换「怎么选」这一条规则:随机、贪心、覆盖导向各跑 5000 条路径,量结局分布、结局分布熵与路径长度分布。实测贪心 5000 条里一次都没走到「真相」,而随机与覆盖导向都到达全部 5 个结局;再把三种策略按 7 档混合比扫描,只有纯贪心丢结局。全部指标都是 proxy。
给采样装上「策略」这个旋钮:随机(可播种 PRNG 选可用选项)、贪心(总是最大化好感度增量)、覆盖导向(优先走尚未覆盖的边)三种模拟玩家策略,在同一份规格下各走 10 与 200 条路径,对照覆盖率、结局数与路径长度区间。全部指标都是 proxy。
把 96 条采样路径在各场景上的经过次数画成 14×96 的矩阵热力图,cell 为整数、颜色按整数档位查表(档位定义在 config/nm_10_3_heatmap.json,全程无浮点调色)。矩阵元素之和 2544 恰等于所有路径的节点数之和;按结局排序后同结局的路径聚成一块。
把 48 个对话节点 / 56 条去重对话边按场景分层画成一张 SVG 分支图,边按是否被一条确定性路径走过着色:走过 25 条(446‰),未走过 31 条;随机采样 200 条即可铺满 56/56。布局坐标由场景序号与场景内序号直接算出,纯 xml.etree 建树、回读校验。
一次通关可以被拆成三种表示:玩家按下的选择序列、引擎走出的节点序列、以及每一步之后的完整状态轨迹。本节用《雾港夜航》的首选项确定性策略走完一条 25 步路径,逐步骤打印好感度、置位 flag、持有物品与 FNV-1a 状态摘要,并给出节点数等于选择数加一的整数不变量。
规格演化后(新增 flag、新增物品、新增好感度变量、新增节点),旧存档必须仍能读。本节实跑兼容性矩阵:四种新规格下旧存档全部读入成功并给出补默认值规则;迁移函数三条判据——缺失字段补默认、多余字段丢弃并报告、类型不符拒绝——逐条在本机实跑验证。
浏览器里走完同一条确定性策略路径(总是选第一个可用选项,共 25 个事件),读出终局状态,与 Python 侧产物比对 canonical 串与 FNV-1a 摘要。三侧锚点一致:摘要 4162796975、节点 e_lit、结局 灯守。只对整数状态写逐位一致。
给可玩原型挂一套稳定的 data-* 选择器契约:当前节点、每个选择按钮、四个好感度、已置位 flag、持有物品各有一个可寻址的属性。再用 Playwright 无头打开这页单文件 HTML,断言 DOM 已绘制、按钮数等于当前可用选项数、点击后节点推进。
把 config/story_spec.json 与 core/src/inkwell/engine.ts 一起内联进一个单文件 HTML,产出 web/dist/ne09.html。本节实测产物 41641 字节、script src 0、外部 http(s) 链接 0,给出零外部依赖的自检口径与双击即玩的最小交付形态。
把分支宽度与深度当两条扰动轴:宽度因子 k 把每个有选项节点的出度乘以 2、3、4,路径数从约 4.50×10 的 11 次方一路涨到 10 的 30 次方量级,而去重跳转覆盖率始终 1000‰ 纹丝不动;深度预算 L 把探索限制在 L 步内,可达覆盖率随 L 上升、在 L=22 触顶。B 层只打印软告警,恒 exit 0。
路径数有两层上界:朴素乘积上界 5 的 31 次方约 4.66×10 的 21 次方,真实结构路径数 450208471680,剪枝后 BFS 可达边 115/115 即 1000‰。本节推导优惠券收集器的采样上界(覆盖 115 条边约 613 条路径),并实测随机与贪心两种策略在 N=100/1000/10000/20000 下的覆盖率收敛曲线。
5 个结局(灯守 / 离港 / 真相 / 缄默 / 沉没)全部可达,但可达不等于易达。本节为每个结局给出一条真实可回放的到达路径,再用 20000 条均匀随机采样算分布:离港 10074、沉没 5507、缄默 3223、灯守 1018、真相仅 178。并厘清 endings 的 id 与 dialogue 的 ending 两个口径。
汇聚本身不是缺陷——115 条选项去重后只剩 56 条唯一跳转。真正的非法汇流是汇聚之后状态不可区分:两条不同入边到达同一节点后,下游全部条件的求值完全一致,信息被丢弃。本节给出可判定口径 D(M),实测 34 个汇聚节点里 22 个冗余、3 个跨源冗余,并用合成规格负例证明检测器会开火。
死路是一个节点在当前状态下没有任何可用选项、且不是结局节点,玩家就卡死了。本节把静态死路与动态死路分开查:静态 0 个;动态用投影状态上的完整 BFS 与 20000 条采样,实测卡死 0 条。再用三组注入负例证明检测器确实会开火,而不是一个永远返回空的摆设。
对同一串选择序列,Python 与 TypeScript 各自推进一遍,比较 canonical 串与 FNV-1a 32 位摘要。本节实测 500 条路径全部逐位相同,并给出前 3 条摘要值对照表;FNV-1a 的 Math.imul 与 Python 的 & 0xFFFFFFFF 是两侧一致的关键。
随机路径采样只用自实现的 Lehmer MINSTD,种子一固定,回放就必然复现。本节实测同一 (seed=20261016, n=500) 采样两次 500 条选择序列逐条相同,而换到 seed=20261017 后逐条相同数为 0、结局分布也整体改变。
快照是某一刻的完整状态,差量是相邻两快照间真正改变的字段。本节沿一条 25 步的确定性路径输出逐步骤差量表,并点出 7 个 flag 与 1 个物品被写却从未被任何条件读取——这类「只增不减的信息」按声明未用=报告处理,不判 FAIL,只如实报出。