12.1 目标与假设
《游戏设计》卷三《原型可玩性与品类》 做的是通用可玩性测试设计——怎么设计一次可玩性测试、怎么取样本、怎么读结果,闸门开在品类的可玩性上。本章与它划界清楚:不重新设计测试方法,只把口径收窄到叙事路径的覆盖与结局分布,把一批选择序列塞进无头浏览器真跑、逐条断言。真人玩家测试需要真实业务环境,命令行不可复现,本章逐处标 X。
没有假设的测试只是「点着玩」
第 11 章给出了模拟玩家的路径策略;把策略产出的路径丢进浏览器点一遍、看着它跑完,这不叫测试。测试的门槛在于先写下一个可能被推翻的命题:它必须可证伪——给出可测的整数、一个判据(比较算子 + 阈值)和一条「什么情况下算被证伪」。写不出证伪条件的命题不是假设,是信念。
本节把《雾港夜航》这次可玩性测试要验的东西落成四条可证伪命题,全部挂在结构量上:走过多少条对话边、到达几种结局、浏览器与 Python 是否算得一样、结局分布有多均匀。它们都不碰「玩家觉得好不好玩」——那是体验,本机测不了,标 X。
先定基线:零假设长什么样
假设要和什么比?和零假设比——即「什么都不做、只走一条确定性路径」能得到什么。用「每步选第一个可用选项」的策略走一条路径,在 config/story_spec.json 上量出:
| 量 | 值 | 说明 |
|---|---|---|
| 去重对话边 | 56 | 115 个选项指向同一目标去重后的边数 |
| 确定性单路径覆盖 | 25 条 = 446‰ | 零假设基线 |
| 到达结局 | 1 / 5 | 只落到「灯守」 |
446‰ 离规格自己的目标 850‰ 还差一半——这就是假设要有张力的地方:如果零假设已经达标,假设就没有信息量。规格 metrics 里同时给了另外两个阈值:覆盖率目标 coverage_target_permille = 850、结局分布熵下限 ending_entropy_min_milli_bits = 1500。
四条可证伪假设
| # | 指标 | 判据 | 阈值 | 证伪条件 |
|---|---|---|---|---|
| H1 | 覆盖率(去重边,‰) | ≥ | 850 | 批跑覆盖率 < 850‰ |
| H2 | 到达的不同结局数 | ≥ | 4 | 到达结局数 < 4 |
| H3 | 浏览器与 Python 一致条数 | = | 24 | 一致条数 < 24 |
| H4 | 结局分布熵(毫比特) | ≥ | 1500 | 批跑分布熵 < 1500 毫比特 |
批跑规模定为 N = 24 条路径,种子 20261016——与全卷一致的可播种 PRNG(Lehmer MINSTD)。H3 是这四条里最特殊的一条:它不比阈值高多少,而是要求 24 条逐条一致,因为它是跨宿主同源的地基(卷二第 13.1 节
)。
假设不是凭空写的:三个来源
四条假设各有出处,不是拍脑袋定的:
| 来源 | 假设 | 说明 |
|---|---|---|
| 规格自己的目标 | H1、H4 | 直接取 metrics.coverage_target_permille / ending_entropy_min_milli_bits |
| 设计约定 | H2 | 「批跑至少覆盖多数结局」——5 个里摸到 4 个 |
| 跨宿主不变量 | H3 | 三处消费同源,要求逐条一致而非达到某个阈值 |
第一类是规格已经声明的目标,测试只是去验它是否被满足;第二类是人定的验收线,改它不影响规格;第三类是工程不变量,它没有「阈值」可调——要么 24 条全一致,要么就有 1 条说明同源被打破。把三种来源分清,是为了在假设被证伪时知道该动哪一样(12.3 会用到)。
为什么批跑规模是 24
为什么不是 10 或 100?两个约束夹出来的:
- 下界:第 2.1 节实测,随机采样 N=25 才把去重边铺到 982‰;N=10 只有 964‰,虽然也过 850‰ 的线,但离得近、余量小。取 24 与 25 同量级,覆盖率这条线才站得稳。
- 上界:本章的批跑要在浏览器里逐条注入点击,每条路径要一次
goto+ 二十几次click。规模越大,批跑越慢;24 条够覆盖 5 个结局里的 4 个,再多对 H1/H2 的结论没有增量。
24 这个数不是「熵收敛所需」的数——恰恰相反,12.3 会证明 24 对熵来说太小。这正是本节要立的规矩:同一个 N 服务不了所有假设,计数型假设小样本就够,分布型假设要另配规模。
S 层:把假设落成可复算的对象
下面的脚本 sim/nm_12_1_hypothesis.py 只读规格,算出零假设基线,再把四条假设连同判据、阈值、证伪条件一起落成 out/nm_12_1_hypothesis.json——12.2 的批跑与 12.3 的决策都读这份文件。它依赖公共底座 sim/nm_12_common.py(路径推进 / 规范串 / 摘要 / 熵 / 采样,与 core/src/inkwell/engine.ts 同算法同种子)。
#!/usr/bin/env python3
# file: sim/nm_12_1_hypothesis.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_12_1_hypothesis.py
"""卷三 12.1 目标与假设(S 层):把「这次可玩性测试要验什么」写成可证伪的命题。
一次可玩性测试如果没有假设,就只是一次「点着玩」。本节把要验的东西落成四条**可证伪**
的命题:每条都给出可测的整数、判据(op + 阈值)与**证伪条件**——只要批跑结果越过阈值,
命题就被证伪,脚本与人都无法含糊过去。
四条假设(全部挂在叙事路径的**结构量**上,不碰任何主观体验):
H1 覆盖率:N=24 条采样路径走过的去重对话边 ≥ 850‰(规格目标 coverage_target_permille)
H2 结局覆盖:N=24 条路径到达的**不同结局数** ≥ 4(5 个结局里至少摸到 4 个)
H3 跨宿主一致:24 条路径在浏览器注入后的终局摘要与 Python 侧**逐位一致**(= 24)
H4 分布熵:24 条路径的结局分布熵 ≥ 1500 毫比特(规格阈值 ending_entropy_min_milli_bits)
脚本只读 config/story_spec.json,算出**零假设基线**(确定性单路径能覆盖多少),再把四条
假设与阈值落成 out/nm_12_1_hypothesis.json,供 12.2 的批跑与 12.3 的决策读。公共底座见
sim/nm_12_common.py。全部整数;熵公式用 math.log2(不进入状态,同卷三 3.1 口径)。
"""
from __future__ import annotations
import json
import os
import sys
import nm_12_common as cm
BATCH_N, SEED = 24, 20261016
def null_baseline(spec: dict) -> tuple[int, int]:
"""零假设:每步选第一个可用选项走一条路径,返回(走过的去重边数, 到达结局数)。"""
s, edges = cm.init_state(spec), set()
for _ in range(cm.STEP_CAP):
cur = cm.node_of(spec, s["node"])
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if cm.cond_ok(c.get("when"), s)]
if not opts:
break
edges.add((s["node"], opts[0]["goto"]))
s = cm.step(s, opts[0])
return len(edges), 1 if cm.node_of(spec, s["node"]).get("ending") else 0
def main() -> int:
spec = cm.load_spec()
E = len(cm.dedup_edges(spec))
m = spec["metrics"]
det_edges, det_ends = null_baseline(spec)
det_permille = det_edges * 1000 // E
hyps = [
{"id": "H1", "metric": "coverage_permille", "op": ">=", "threshold": m["coverage_target_permille"],
"statement": f"N={BATCH_N} 条采样路径的去重对话边覆盖率 ≥ {m['coverage_target_permille']}‰",
"falsified_if": f"批跑覆盖率 < {m['coverage_target_permille']}‰",
"source": "config/story_spec.json#metrics.coverage_target_permille"},
{"id": "H2", "metric": "endings_reached", "op": ">=", "threshold": 4,
"statement": f"N={BATCH_N} 条路径到达的不同结局数 ≥ 4(共 {len(spec['endings'])} 个结局)",
"falsified_if": "批跑到达的不同结局数 < 4",
"source": "设计约定:批跑须覆盖多数结局"},
{"id": "H3", "metric": "browser_python_match", "op": ">=", "threshold": BATCH_N,
"statement": f"浏览器注入的 {BATCH_N} 条路径终局摘要与 Python 侧逐位一致(= {BATCH_N})",
"falsified_if": f"一致条数 < {BATCH_N}",
"source": "跨语言 digest 同源(卷二 13.1)"},
{"id": "H4", "metric": "ending_entropy_milli_bits", "op": ">=",
"threshold": m["ending_entropy_min_milli_bits"],
"statement": f"N={BATCH_N} 条路径的结局分布熵 ≥ {m['ending_entropy_min_milli_bits']} 毫比特",
"falsified_if": f"批跑分布熵 < {m['ending_entropy_min_milli_bits']} 毫比特",
"source": "config/story_spec.json#metrics.ending_entropy_min_milli_bits"},
]
report = {
"seed": SEED, "batch_n": BATCH_N, "step_cap": cm.STEP_CAP, "edges_total": E,
"null_baseline": {"strategy": "deterministic-first", "edges": det_edges,
"permille": det_permille, "endings_reached": det_ends},
"targets": {"coverage_target_permille": m["coverage_target_permille"],
"ending_entropy_min_milli_bits": m["ending_entropy_min_milli_bits"],
"endings_total": len(spec["endings"])},
"hypotheses": hyps,
"proxy": "覆盖率 / 结局数 / 分布熵均为结构量代理指标,只做相对比较,不声称体验好坏",
}
print(f"故事图 {len(spec['dialogue'])} 节点 去重对话边 {E} 结局 {len(spec['endings'])}")
print(f"零假设基线 确定性单路径 {det_edges}/{E} = {det_permille}‰ 到达结局 {det_ends}")
print(f"批跑规模 N={BATCH_N} seed={SEED}")
print("假设 判据 阈值 证伪条件")
for h in hyps:
print(f" {h['id']} {h['metric']:<26}{h['op']}{h['threshold']:>6} {h['falsified_if']}")
assert det_permille < m["coverage_target_permille"], "零假设基线应低于目标,否则假设无张力"
assert 0 < det_ends < 4, "确定性单路径不应已覆盖 4 个结局"
assert len({h["id"] for h in hyps}) == 4, "四条假设 id 应互不相同"
os.makedirs(cm.ROOT + "/out", exist_ok=True)
with open(cm.ROOT + "/out/nm_12_1_hypothesis.json", "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/nm_12_1_hypothesis.json")
print(f"OK nm_12_1_hypothesis:4 条可证伪假设已落盘,零假设基线 {det_permille}‰ "
f"(目标 {m['coverage_target_permille']}‰)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_12_1_hypothesis.py:
故事图 48 节点 去重对话边 56 结局 5
零假设基线 确定性单路径 25/56 = 446‰ 到达结局 1
批跑规模 N=24 seed=20261016
假设 判据 阈值 证伪条件
H1 coverage_permille >= 850 批跑覆盖率 < 850‰
H2 endings_reached >= 4 批跑到达的不同结局数 < 4
H3 browser_python_match >= 24 一致条数 < 24
H4 ending_entropy_milli_bits >= 1500 批跑分布熵 < 1500 毫比特
wrote out/nm_12_1_hypothesis.json
OK nm_12_1_hypothesis:4 条可证伪假设已落盘,零假设基线 446‰ (目标 850‰)
判读:四条假设各自在赌什么
H1 赌的是「采样够密」。 零假设 446‰,目标 850‰——只要随机采样 24 条就能把去重边铺到目标线以上(第 2.1 节 实测 N=25 到 982‰)。这条假设若被证伪,说明规格里有一条极难撞到的边。
H2 赌的是「结局不止一种」。 5 个结局里至少摸到 4 个。零假设只到 1 个,所以 H2 天然有张力。
H3 赌的是「三处消费同源」。 24 条路径的终局摘要在浏览器里必须与 Python 侧逐位一致——这一条把「同一份内核内联进页面后还对不对」变成一个可断言的数。
H4 赌的是「分布够均匀」。 这是四条里唯一依赖样本量的:熵是分布量,N 太小估计必然偏低。第 3.1 节 已经证明熵要够多路径才收敛——所以 H4 是本章唯一有真实被证伪风险的假设(12.3 会实测它)。
本节四条假设的被测指标——覆盖率、结局数、一致条数、分布熵——全是代理指标(proxy):只度量路径的结构,只做相对比较,不声称任何「体验更好」。
真人测试标 X
本节与本章的一切都可命令行复现,唯有一类不行:真人玩家测试。真人测试要招募真实玩家、在真实业务环境里采集真实路径数据与主观反馈——需真实业务环境,命令行不可复现。本章的批跑只是用模拟路径替代真人路径做结构性断言,它不能替代真人测试,也不声称与真人结果等价。
自检表
| 检查项 | 合格标准 |
|---|---|
| 假设可证伪 | 每条都有可测整数、判据、阈值与证伪条件 |
| 基线已算 | 零假设 446‰、1 个结局,均来自实跑 |
| 规模一致 | 四条假设同批跑规模 N=24、同种子 20261016 |
| 整数口径 | 覆盖率千分位、熵毫比特,无浮点 |
| proxy 声明 | 明示四条指标都是结构量代理 |
| X 已标注 | 真人测试显式标 X |
这套口径从哪来
本章不新造规格字段,只把前两卷的产物接起来测:假设的被测对象全部来自卷一与卷二已经落盘的东西。
| 层 | 提供什么 | 本节的用法 |
|---|---|---|
| 卷一 | metrics 里的覆盖率 / 熵阈值、beats / dialogue 的结构 | H1、H4 的阈值来源 |
| 卷二 | engine.ts 内核与 W 层可玩原型 | H3 的跨宿主对照底座 |
| 卷三(本节) | 把「验什么」写成可证伪命题并算零假设基线 | 四条假设 + 基线 |
小结
本节只做了一件事:把「这次测试要验什么」写成四条可证伪的命题。
- 零假设是 446‰ / 1 个结局:确定性单路径远低于 850‰ 的目标,假设因此有张力。
- 假设有三个来源:规格声明的目标(H1、H4)、人定的验收线(H2)、工程不变量(H3)。
- 四条假设各赌一件事:覆盖率、结局覆盖、跨宿主一致、分布均匀;H4 是唯一依赖样本量的。
- 它们全是 proxy:只度量结构,不做体验断言;真人测试标 X。
假设落定,下一步是真跑:把 24 条路径的规格与内核内联进单文件 HTML,用无头浏览器逐条注入点击,读回终局与 Python 侧比对。
阅读导航:上一节:11.3 与玩家动因模型的分工 · 下一节:12.2 W 层路径批跑与断言 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。