12.3 从测试到决策
《游戏设计》卷三《原型可玩性与品类》 讲的是通用的可玩性测试设计——怎么设计一次测试、怎么取样本、怎么读结果。本章收窄口径:这里测的是叙事规格的路径覆盖与结局分布,被证伪的假设要么改规格、要么改假设。真人玩家测试需真实业务环境,命令行不可复现,标 X。
12.2 的批跑不是「跑绿了就收工」:它交回一张成绩单,四条假设里有一条被证伪。测试的价值恰恰在这里——被证伪的假设逼出一个决定。本节把决定做完整:先判定,再诊断,最后落到「这份规格改不改」。
判定:四条假设谁通过谁被证伪
脚本 sim/nm_12_3_decision.py 读三份产物(12.1 的假设表、12.2 的 Python 批跑、12.2 的浏览器批跑),把每条假设的实测值与阈值对照,给出「通过 / 被证伪」。这一步不含判断,只是读数:
#!/usr/bin/env python3
# file: sim/nm_12_3_decision.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_12_3_decision.py
"""卷三 12.3 从测试到决策(S 层):假设被证伪时,是改规格还是改假设?
12.1 落了四条可证伪假设,12.2 把 24 条路径在浏览器里真跑了一遍。本节做**判定**:逐条读出
实测值,对照阈值给出「通过 / 被证伪」。被证伪的假设不能含糊过去——它逼出一个二选一的决定:
**这份规格有缺陷**(改规格),还是**这次测试的设计有缺陷**(改假设)?
判据不是拍脑袋,而是一次**样本量扫描**:把被证伪的指标在 N = 24 / 100 / 500 / 2000 / 8000
上各算一遍。若指标随 N 增大收敛到阈值之上,说明**指标本身没问题、是样本太小**——那是测试
设计缺陷,决定是改假设(提高 N 或降阈值),**规格一行不改**;若无论 N 多大都压在阈值之下,
才是规格缺陷,决定是改规格。本节实测:H4(结局分布熵)属于前者。
读 out/nm_12_1_hypothesis.json + out/nm_12_2_batch_paths.json + out/nm_12_2_w_batch.json,
产出 out/nm_12_3_decision.json。公共底座见 sim/nm_12_common.py。全部整数。
"""
from __future__ import annotations
import json
import os
import sys
import nm_12_common as cm
SEED = 20261016
SWEEP = [24, 100, 500, 2000, 8000]
def read_json(name: str) -> dict:
with open(os.path.join(cm.ROOT, "out", name), encoding="utf-8") as fh:
return json.load(fh)
def entropy_at(spec: dict, n: int) -> tuple[int, dict]:
"""同一 PRNG 序列在 N 条路径上的结局分布与熵(前缀一致,可逐 N 对照)。"""
dist: dict[str, int] = {}
for p in cm.sample_paths(spec, n, SEED):
dist[p["ending"]] = dist.get(p["ending"], 0) + 1
return cm.entropy_milli_bits(list(dist.values()), n), dist
def main() -> int:
hyp, batch, wbatch, spec = (read_json("nm_12_1_hypothesis.json"), read_json("nm_12_2_batch_paths.json"),
read_json("nm_12_2_w_batch.json"), cm.load_spec())
measured = {"coverage_permille": batch["coverage"]["permille"],
"endings_reached": batch["endings_reached"],
"browser_python_match": wbatch["match"],
"ending_entropy_milli_bits": batch["ending_entropy_milli_bits"]}
verdicts = []
for h in hyp["hypotheses"]:
v = measured[h["metric"]]
passed = v >= h["threshold"] if h["op"] == ">=" else v <= h["threshold"]
verdicts.append({"id": h["id"], "metric": h["metric"], "value": v, "op": h["op"],
"threshold": h["threshold"], "verdict": "通过" if passed else "被证伪"})
print(f"批跑规模 N={batch['n']} seed={batch['seed']}")
print("假设 指标 实测值 判据 阈值 结论")
for v in verdicts:
print(f" {v['id']} {v['metric']:<26}{v['value']:>6} {v['op']:>2} {v['threshold']:>6} {v['verdict']}")
# 样本量扫描:只对被证伪的假设做诊断
sweep = []
for v in [x for x in verdicts if x["verdict"] == "被证伪"]:
if v["metric"] == "ending_entropy_milli_bits":
print(f"== 诊断:{v['id']} 被证伪,做样本量扫描(熵是否随 N 收敛到阈值之上)")
for n in SWEEP:
e, dist = entropy_at(spec, n)
sweep.append({"n": n, "entropy_milli_bits": e, "above_threshold": e >= v["threshold"],
"endings": {k: c for k, c in sorted(dist.items())}})
print(f" N={n:>5} 熵 {e:>5} 毫比特 {'>=' if e >= v['threshold'] else '< '} 阈值 "
f"{v['threshold']} 结局 {json.dumps({k: c for k, c in sorted(dist.items())}, ensure_ascii=False)}")
decisions = []
for v in verdicts:
if v["verdict"] == "通过":
decisions.append({"id": v["id"], "action": "保持", "diagnosis": "pass",
"reason": f"{v['metric']}={v['value']} 满足 {v['op']} {v['threshold']}"})
elif (conv := [s for s in sweep if s["above_threshold"]]):
decisions.append({"id": v["id"], "action": "改假设", "diagnosis": "test_design_defect",
"reason": f"{v['metric']} 在 N={conv[0]['n']} 起越过阈值"
f"({conv[0]['entropy_milli_bits']} 毫比特),是样本太小而非规格缺陷"})
else:
decisions.append({"id": v["id"], "action": "改规格", "diagnosis": "spec_defect",
"reason": f"{v['metric']} 在任何 N 下都不达阈值,属规格缺陷"})
print("== 决策")
for d in decisions:
print(f" {d['id']} {d['action']:<4}({d['diagnosis']}) {d['reason']}")
report = {"batch_n": batch["n"], "seed": batch["seed"], "measured": measured,
"verdicts": verdicts, "sample_sweep": sweep, "decisions": decisions,
"proxy": "所有被测指标都是结构量代理,只做相对比较;熵是分布量,样本量不足时估计偏低"}
with open(os.path.join(cm.ROOT, "out", "nm_12_3_decision.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
n_pass = sum(1 for v in verdicts if v["verdict"] == "通过")
print("wrote out/nm_12_3_decision.json")
print(f"OK nm_12_3_decision:{n_pass}/{len(verdicts)} 条假设通过,"
f"{len(verdicts) - n_pass} 条被证伪并已给出诊断与决策")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑:
批跑规模 N=24 seed=20261016
假设 指标 实测值 判据 阈值 结论
H1 coverage_permille 982 >= 850 通过
H2 endings_reached 4 >= 4 通过
H3 browser_python_match 24 >= 24 通过
H4 ending_entropy_milli_bits 1351 >= 1500 被证伪
== 诊断:H4 被证伪,做样本量扫描(熵是否随 N 收敛到阈值之上)
N= 24 熵 1351 毫比特 < 阈值 1500 结局 {"沉没": 5, "灯守": 1, "离港": 16, "缄默": 2}
N= 100 熵 1567 毫比特 >= 阈值 1500 结局 {"沉没": 32, "灯守": 6, "离港": 53, "缄默": 9}
N= 500 熵 1747 毫比特 >= 阈值 1500 结局 {"沉没": 143, "灯守": 32, "真相": 7, "离港": 250, "缄默": 68}
N= 2000 熵 1721 毫比特 >= 阈值 1500 结局 {"沉没": 526, "灯守": 117, "真相": 21, "离港": 1032, "缄默": 304}
N= 8000 熵 1718 毫比特 >= 阈值 1500 结局 {"沉没": 2166, "灯守": 417, "真相": 75, "离港": 4048, "缄默": 1294}
== 决策
H1 保持 (pass) coverage_permille=982 满足 >= 850
H2 保持 (pass) endings_reached=4 满足 >= 4
H3 保持 (pass) browser_python_match=24 满足 >= 24
H4 改假设 (test_design_defect) ending_entropy_milli_bits 在 N=100 起越过阈值(1567 毫比特),是样本太小而非规格缺陷
wrote out/nm_12_3_decision.json
OK nm_12_3_decision:3/4 条假设通过,1 条被证伪并已给出诊断与决策
被证伪之后:二选一
一条假设被证伪,只有两种可能,必须选一个:
- 规格缺陷(spec_defect):这份叙事结构真的不行——比如某条结局的触发条件过严,以致任何规模的采样都到不了它。此时改规格。
- 测试设计缺陷(test_design_defect):规格没问题,是这次测试设计的问题——样本太小、指标选错、阈值定歪。此时改假设,规格一行不改。
含糊过去的代价很大:若把测试设计缺陷误判成规格缺陷,就会为了「让指标好看」去改一份本来健康的叙事,把结构改坏。
样本量扫描:是样本太小还是规格缺陷
区分两者的判据不是拍脑袋,而是一次样本量扫描:把被证伪的指标在 N = 24 / 100 / 500 / 2000 / 8000 上各算一遍,看它随 N 往哪走。
H4 的扫描结果很清楚:
| N | 熵(毫比特) | ≥ 1500? | 到达结局数 |
|---|---|---|---|
| 24 | 1351 | 否 | 4 |
| 100 | 1567 | 是 | 4 |
| 500 | 1747 | 是 | 5 |
| 2000 | 1721 | 是 | 5 |
| 8000 | 1718 | 是 | 5 |
熵在 N=100 起就越过阈值,并在 500 之后稳定在 1718–1747 之间。这说明:指标本身没问题,是 N=24 太小。样本量不足时,稀有结局(真相)根本没被采到——直到 N=500 才出现,分布才「撑开」,熵才上去。所以 H4 属于 test_design_defect,决定是改假设(把 N 提到 100 以上,或把阈值降到 1500 以下),config/story_spec.json 一行不改。
反例:如果扫描不收敛
扫描的走向才是判据,不是某一个点的高低。把两种走向并排放:
| 走向 | 观察到的现象 | 诊断 | 决定 |
|---|---|---|---|
| 随 N 上升并收敛到阈值之上 | 熵 1351 → 1567 → 1747 → 1721 → 1718 | 样本太小 | 改假设 |
| 无论 N 多大都压在阈值之下 | 熵长期停在 1000 上下、且结局分布不随 N 变宽 | 规格缺陷 | 改规格 |
第二种情况在本次实测里没有出现,但它的形态要预先想清楚:如果某个结局的触发条件写得过严——比如要求 tension 在极窄的窗口内——那么再大的 N 也采不到它,分布永远撑不开,熵会封顶。那时问题在 story_spec.json,提高 N 只是徒劳地烧算力。
一个更隐蔽的中间态是振荡:熵随 N 忽上忽下、不收敛。这通常意味着采样本身有偏(比如 PRNG 周期与图结构共振),属于测试设计缺陷的另一种形态,处置同样是改假设(换种子、换采样策略),而不是改规格。
决策表
| 假设 | 实测值 | 阈值 | 结论 | 决策 |
|---|---|---|---|---|
| H1 覆盖率 | 982‰ | ≥ 850 | 通过 | 保持 |
| H2 到达结局数 | 4 | ≥ 4 | 通过 | 保持 |
| H3 跨宿主一致 | 24 | ≥ 24 | 通过 | 保持 |
| H4 结局分布熵 | 1351 毫比特 | ≥ 1500 | 被证伪 | 改假设(N→100+,或阈值→1500 以下) |
四条里三条通过,说明这套规格在结构层面是站得住的;唯一被证伪的一条,诊断也指向测试设计而非规格。决策的产物是一份 JSON(out/nm_12_3_decision.json),它记下每条假设的实测值、判定、扫描轨迹与动作,供下一次迭代复用。
阈值是怎么定的
H4 的阈值 1500 毫比特不是随手写的。5 个结局若均匀分布,熵是 int(log2 5 × 1000) = 2321 毫比特;若只有 1 个结局,熵是 0。1500 大约落在「分布已经明显撑开、但离均匀还有距离」的位置——它是一条下限线,不追求漂亮,只用来区分「撑开了」和「没撑开」。
阈值该定在哪,本身也是测试设计的一部分:定得太低,真相 这类稀有结局永远测不出来;定得太高,就要用很大的 N 才能过线。本节的处置——保持阈值、把 N 提上去——是两者折中的结果:N=100 已足以越过 1500,而 N≥500 的稳定值(1718–1747)说明还有余量。
X 层:这里到底测不了什么
上面整条链路——S 层采路径、W 层浏览器真跑、本节的样本量扫描——测的全是结构量:覆盖率、结局数、分布熵。它们都是代理指标,只能做相对比较,不能回答「玩家会不会觉得这条线好玩」「真相 结局该不该更难触发」。
那类问题属于体验,必须由真人玩家在真实业务环境里回答:真实玩家、真实时长、真实留存。它命令行不可复现,本卷一律标 X,不写进可跑的断言里。测试能守的是结构的下限;体验的上限得靠 X 层。
X 层需要什么真实业务环境:一批真实玩家(而非 24 条注入序列)、一段真实时长(而非一次点击到终局)、以及真实留存 / 付费 / 回访这类行为信号。这些信号的共同点是只能从线上埋点里长出来,没法在本地造:注入脚本能模拟「点了哪个选项」,但模拟不出「玩家点完之后会不会第二天再回来」。
为什么批跑替代不了它:本章的批跑全部基于同一份 story_spec.json 的确定性采样——同一个种子跑出的 24 条路径,今天和明天逐位相同。而体验是随人、随时、随情境变化的:同一份规格,换一批玩家、换一个渠道、换一个赛季,结论可能完全相反。确定性的东西能守结构,但守不了「这次改动会不会让玩家流失」。
两者的接口:X 层的结论不能直接写回规格,而要先被转译成一条可证伪的结构假设(回到 12.1),再交给本章这条链路去测。比如「玩家在 真相 结局的留存明显高于其他结局」是 X 层观察;它要被翻译成「真相 结局的到达率应 ≥ X‰」这样的结构假设,才能在 S/W 层被批跑验证。X 层出题,结构层判卷——这是本章与体验测试的边界。
自检表
| 检查项 | 合格标准 |
|---|---|
| 判定有据 | 每条假设的实测值来自产物,非手填 |
| 诊断有据 | 被证伪项做样本量扫描,看随 N 的走向 |
| 决策明确 | 每条假设落到「保持 / 改假设 / 改规格」三选一 |
| 规格不动 | 判为测试设计缺陷时不改 story_spec.json |
| X 层标注 | 体验类问题显式标 X,不混进可跑断言 |
小结
- 被证伪不是失败:四条假设里 H4 被证伪,正是它逼出了「改规格还是改假设」的决定。
- 样本量扫描是判据:熵在 N=100 起越过 1500 毫比特、N≥500 稳定在 1718–1747,判定为测试设计缺陷。
- 决定是改假设:提高 N 或降低阈值,
story_spec.json一行不改——因为规格没有病。 - X 层要认账:结构量只是代理指标,体验必须真人实测,命令行不可复现,标 X。
到此,第 12 章把「规格的可玩性测试」闭环了:12.1 定假设、12.2 在浏览器里真跑、12.3 把结果变成决定。下一章换一个角度——不再看整批路径,而是看曲线上的异常点。
阅读导航:上一节:12.2 W 层路径批跑与断言 · 下一节:13.1 曲线的异常点定位 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。