12.3 从测试到决策

假设被证伪之后必须二选一:改规格还是改假设。用样本量扫描 N=24/100/500/2000/8000 诊断 H4(结局分布熵)——熵在 N=100 起越过 1500 毫比特、N≥500 稳定在 1718–1747,判定为测试设计缺陷、改假设而非改规格,story_spec.json 一行不改;真人测试标 X。

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?到达结局数
241351否4
1001567是4
5001747是5
20001721是5
80001718是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 曲线的异常点定位 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长