12.1 目标与假设

一次可玩性测试如果没有假设,就只是点着玩。本节把《雾港夜航》这次测试要验的东西落成四条可证伪的命题——覆盖率、结局覆盖、跨宿主一致、分布熵,每条都给出可测整数、判据与证伪条件,并算出零假设基线 446‰。真人玩家测试需真实业务环境,命令行不可复现,标 X。

12.1 目标与假设

《游戏设计》卷三《原型可玩性与品类》 做的是通用可玩性测试设计——怎么设计一次可玩性测试、怎么取样本、怎么读结果,闸门开在品类的可玩性上。本章与它划界清楚:不重新设计测试方法,只把口径收窄到叙事路径的覆盖与结局分布,把一批选择序列塞进无头浏览器真跑、逐条断言。真人玩家测试需要真实业务环境,命令行不可复现,本章逐处标 X。

没有假设的测试只是「点着玩」

第 11 章给出了模拟玩家的路径策略;把策略产出的路径丢进浏览器点一遍、看着它跑完,这不叫测试。测试的门槛在于先写下一个可能被推翻的命题:它必须可证伪——给出可测的整数、一个判据(比较算子 + 阈值)和一条「什么情况下算被证伪」。写不出证伪条件的命题不是假设,是信念。

本节把《雾港夜航》这次可玩性测试要验的东西落成四条可证伪命题,全部挂在结构量上:走过多少条对话边、到达几种结局、浏览器与 Python 是否算得一样、结局分布有多均匀。它们都不碰「玩家觉得好不好玩」——那是体验,本机测不了,标 X。

先定基线:零假设长什么样

假设要和什么比?和零假设比——即「什么都不做、只走一条确定性路径」能得到什么。用「每步选第一个可用选项」的策略走一条路径,在 config/story_spec.json 上量出:

量值说明
去重对话边56115 个选项指向同一目标去重后的边数
确定性单路径覆盖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 层路径批跑与断言 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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