13.2 可玩性指标与采样

把可玩性向量当成一个采样问题:四个分量对种子数的敏感度完全不同——熵是规格属性、与种子无关,相变宽度最慢。S 层以全 16 种子为真值,量出每个分量进容差的最小 k 与闸门判决稳定的最小 k,并给出 |A|^D 超过枚举上限时必须改采样的判据;B 层把容差按倍率扫一遍,看 min-k 到底跟着谁动。

13.2 可玩性指标与采样

《小游戏开发实战》卷一 12.2 可玩性指标与测试 关心的是「测试要招多少人」——样本量由占比类指标(胜率、通关率)的置信区间倒推。本节问的是另一个样本量问题:算一个机制量要跑多少局仿真。两者的分母不同(前者是人,后者是种子),口径不能互借;本节把 13.1 的可玩性向量当成一个估计量,量它的四个分量各要多少种子才收敛,并给出「什么时候连采样都救不了、必须换算法」的判据。四分量仍是代理指标(proxy),只用于相对比较。

四个分量对种子的敏感度不同

分量数据来源与种子有关吗收敛速度
entropy 策略空间熵动作前缀树(规格属性)无关一次枚举即真值,漂移恒为 0
domFree 支配缺失度策略 × 种子的对局表有关快(只数被支配的策略个数)
emergence 涌现度可达回合程序并集有关快(并集很快饱和到闭包)
band 相变宽度序参量扫描有关慢(少数几个种子就把宽度估偏)

这张表是本节的全部动机:把一个四维向量当成一个整体去问「要多少种子」是错的——四维的收敛速度差一个数量级。下面把全 16 种子当基准真值,用前 k 个种子估计,量千分比漂移。

{ "seeds": [601, "…", 616], "sampleCounts": [2, 4, 8, 12, 16],
  "tolerance": { "domFreePermille": 50, "emergencePermille": 50, "bandSteps": 1 } }

S 层:真值、估计、进容差的最小 k

脚本只读配置与 13.1 的测量规格,全整数、无浮点、无随机。它输出三样东西:每个 k 的四分量估计与漂移、每个分量进容差的最小 k、闸门判决与真值一致的最小 k。

#!/usr/bin/env python3
# file: sim/gd_play_13_sampling.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_sampling.py
"""卷二 13.2 可玩性指标与采样(S 层):组合指标怎么采、采多少才够。

可玩性向量有四个分量,它们对**样本量(种子数)**的敏感度完全不同:
  entropy   策略空间熵——与种子无关,是**规格属性**(采样不改变它,漂移恒为 0)
  domFree   支配缺失度——策略 × 种子的对局表,收敛较快
  emergence 涌现度——可达模式并集,收敛快(并集很快饱和到闭包)
  band      相变宽度——序参量扫描,最慢(少数几个种子就把宽度估偏)

脚本把「全 16 种子」当基准真值,用前 k 个种子(k = 2/4/8/12/16)估计四个分量,量出
千分比漂移 |估计 − 真值|,给出**每个分量进容差所需的最小 k**,再看**闸门判决**(组合
指标)在哪个 k 上先稳定下来。最后给复杂度判据:当 |A|^D 超过枚举上限时必须改用采样。

四分量都是**代理指标(proxy)**,只用于相对比较,禁绝对化。产出
out/play13_sampling.json + .csv + .svg。全整数、无浮点、无 random;纯 xml.etree 出 SVG。
"""
from __future__ import annotations

import json
import os
import sys
import time
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_13_sampling.json")
SPEC_CFG = os.path.join(ROOT, "config/gd_play_13_playability.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_13_playability as pl  # noqa: E402

PC_KEYS = ("rules", "maxSteps", "policies", "entropy", "emergence", "band", "gate")

DIMS = pl.DIMS
TOLKEY = {"entropy": None, "domFree": "domFreePermille", "emergence": "emergencePermille",
          "band": "bandSteps"}


def verdict(vec, gate):
    failed = [d for d in DIMS if vec[d] < gate[d + "Min"]]
    return failed, not failed


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    with open(SPEC_CFG, encoding="utf-8") as f:
        pc = {k: v for k, v in json.load(f).items() if k in PC_KEYS}
    seeds, tol = cfg["seeds"], cfg["tolerance"]

    truth = pl.vector_of(spec, pc, seeds)
    t_failed, t_pass = verdict(truth, pc["gate"])
    rows = []
    for k in cfg["sampleCounts"]:
        est = pl.vector_of(spec, pc, seeds[:k])
        failed, passed = verdict(est, pc["gate"])
        drift = {d: abs(est[d] - truth[d]) for d in DIMS}
        rows.append({"k": k, **{d: est[d] for d in DIMS}, "drift": drift,
                     "failed": failed, "pass": passed, "verdictMatch": failed == t_failed})

    min_k = {}
    for d in DIMS:
        tk = TOLKEY[d]
        if tk is None:
            min_k[d] = cfg["sampleCounts"][0]
            continue
        min_k[d] = next((r["k"] for r in rows if r["drift"][d] <= tol[tk]), None)
    min_k_verdict = next((r["k"] for r in rows if r["verdictMatch"]), None)

    cx = cfg["complexity"]
    raw = cx["branch"] ** cx["depth"]
    need_sampling = raw > cx["maxNodes"]

    csv = [[r["k"]] + [r[d] for d in DIMS] + [r["drift"][d] for d in DIMS]
           + ["PASS" if r["pass"] else "FAIL"] for r in rows]
    n = ashlib.write_csv(os.path.join(OUT, "play13_sampling.csv"),
                         ["k", *DIMS, *[d + "Drift" for d in DIMS], "gate"], csv)
    size = draw(rows, truth, os.path.join(OUT, "play13_sampling.svg"))
    report = {"id": "gd_play_13_sampling", "seeds": len(seeds), "sampleCounts": cfg["sampleCounts"],
              "truth": {d: truth[d] for d in DIMS}, "truthVerdict": "PASS" if t_pass else "FAIL",
              "tolerance": tol, "rows": rows, "minK": min_k, "minKVerdict": min_k_verdict,
              "complexity": {"raw": raw, "maxNodes": cx["maxNodes"], "needSampling": need_sampling},
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play13_sampling.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"真值({len(seeds)} 种子):" + "  ".join(f"{d}={truth[d]}" for d in DIMS)
          + f"  闸门={'PASS' if t_pass else 'FAIL ' + ','.join(t_failed)}")
    print(f"{'k':>3}" + "".join(f"{pl.LABEL[d]:>10}" for d in DIMS)
          + "".join(f"{'Δ' + pl.LABEL[d]:>10}" for d in DIMS) + "  闸门")
    for r in rows:
        print(f"{r['k']:>3}" + "".join(f"{r[d]:>10}" for d in DIMS)
              + "".join(f"{r['drift'][d]:>10}" for d in DIMS)
              + f"  {'PASS' if r['pass'] else 'FAIL ' + ','.join(r['failed'])}")
    print(f"进容差最小 k: entropy={min_k['entropy']}(种子无关) domFree={min_k['domFree']} "
          f"emergence={min_k['emergence']} band={min_k['band']}")
    print(f"闸门判决稳定最小 k = {min_k_verdict}")
    print(f"复杂度:|A|^D = {cx['branch']}^{cx['depth']} = {raw}  vs 枚举上限 {cx['maxNodes']}"
          f"  → needSampling={need_sampling}")
    print(f"csv={n} rows  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play13_sampling.csv out/play13_sampling.svg out/play13_sampling.json")
    ET.parse(os.path.join(OUT, "play13_sampling.svg"))
    return 0


def draw(rows, truth, path):
    W, H, x0, y0, y1 = 700, 320, 96, 56, 250
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 20, 26, "四个分量对种子数的收敛:漂移 |估计 − 真值|(千分比)", 15)
    ashlib.svg_text(root, 20, 46, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
    ks = [r["k"] for r in rows]
    colors = {"entropy": "#38bdf8", "domFree": "#22c55e", "emergence": "#f59e0b", "band": "#ef4444"}
    for k, d in enumerate(DIMS):
        vals = [r["drift"][d] for r in rows]
        mx = max(max(vals), 1)
        pts = " ".join(f"{x0 + i * (W - 140) // (len(ks) - 1)},{y1 - v * (y1 - y0) // mx}"
                       for i, v in enumerate(vals))
        ET.SubElement(root, "polyline", {"points": pts, "fill": "none",
                                         "stroke": colors[d], "stroke-width": "2"})
        ashlib.svg_text(root, 20, y0 + 24 + 22 * k, f"{pl.LABEL[d]} 真值 {truth[d]}", 11, colors[d])
    for i, kk in enumerate(ks):
        ashlib.svg_text(root, x0 + i * (W - 140) // (len(ks) - 1), y1 + 20, f"k={kk}", 10,
                        "#94a3b8", "middle")
    return ashlib.svg_save(root, path)


if __name__ == "__main__":
    raise SystemExit(main())
真值(16 种子):entropy=2251  domFree=1000  emergence=2692  band=10  闸门=PASS
  k     策略空间熵     支配缺失度       涌现度      相变宽度    Δ策略空间熵    Δ支配缺失度      Δ涌现度     Δ相变宽度  闸门
  2      2251      1000      2692        15         0         0         0         5  PASS
  4      2251      1000      2692        15         0         0         0         5  PASS
  8      2251      1000      2692         7         0         0         0         3  PASS
 12      2251      1000      2692        10         0         0         0         0  PASS
 16      2251      1000      2692        10         0         0         0         0  PASS
进容差最小 k: entropy=2(种子无关) domFree=2 emergence=2 band=12
闸门判决稳定最小 k = 2
复杂度:|A|^D = 6^12 = 2176782336  vs 枚举上限 200000  → needSampling=True
csv=5 rows  svg=2171 bytes  elapsed=16617 ms
wrote out/play13_sampling.csv out/play13_sampling.svg out/play13_sampling.json

三个「与种子无关」的直觉在数字上被确认:entropy 漂移恒为 0(它是规格属性),domFree 与 emergence 在 k=2 就已进容差,而 band 要到 k=12 才进——它在 k=2 时估成 15、k=8 时估成 7,真值是 10。闸门判决却在 k=2 就稳定:因为真值本来就是 PASS,前四个种子给出的也是 PASS,判决对「宽度估偏」不敏感,只有宽度本身敏感。

B 层:容差是个自变量

「采多少够」这句话里藏着一个没写出来的参数:容差。B 层不重算仿真,直接读 S 层产物 out/play13_sampling.json(里面有各 k 的漂移),把容差按 250‰ 到 8000‰ 六个倍率各扫一遍,重算每个分量的 min-k。

#!/usr/bin/env python3
# file: sim/gd_play_13_sampling_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_sampling_warn.py
"""卷二 13.2 可玩性指标与采样(B 层):**永远 exit 0**,只提示不使构建失败。

13.2 的结论是「每个分量进容差需要多少种子(min-k)」。容差是**人定的**,所以「采多少够」
其实是一个**以容差为自变量的函数**,不是一句话。读 out/play13_sampling.json(13.2 S 层产物,
里面有各 k 的估计值与漂移),把容差按 250/500/1000/2000‰ 四个倍率各扫一遍重算 min-k,再把闸门
阈值 ±100‰ 扫一遍重算判决稳定 k。每条含 severity=soft 与 hardFail=false。产出
out/play13_sampling_warn.json。
"""
import json
import os

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
DIMS = ("entropy", "domFree", "emergence", "band")
TOLKEY = {"entropy": None, "domFree": "domFreePermille", "emergence": "emergencePermille",
          "band": "bandSteps"}


def mink(rows, dim, tol):
    """由各 k 的漂移派生 min-k;熵与种子无关,任何 k 都已在容差内。"""
    if TOLKEY[dim] is None:
        return min(r["k"] for r in rows)
    return next((r["k"] for r in rows if r["drift"][dim] <= tol[TOLKEY[dim]]), None)


def verdict_k(rows, gate):
    """闸门判决与真值一致的最小 k;S 层已按该闸门算好 verdictMatch。"""
    return next((r["k"] for r in rows if r["verdictMatch"]), None)


def main():
    with open(os.path.join(OUT, "play13_sampling.json"), encoding="utf-8") as f:
        rep = json.load(f)
    with open(os.path.join(ROOT, "config/gd_play_13_sampling.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    with open(os.path.join(ROOT, "config/gd_play_13_playability.json"), encoding="utf-8") as f:
        gate = json.load(f)["gate"]
    rows, base_tol = rep["rows"], rep["tolerance"]
    scales, d = cfg["robustness"]["tolScales"], cfg["robustness"]["thresholdDeltaPermille"]

    sweep = []
    for s in scales:
        tol = {k: v * s // 1000 for k, v in base_tol.items()}
        sweep.append({"scalePermille": s, "tol": tol,
                      "minK": {x: mink(rows, x, tol) for x in DIMS}})
    base = next(x for x in sweep if x["scalePermille"] == 1000)

    warn = []
    moved = {x: sorted({str(y["minK"][x]) for y in sweep}) for x in DIMS}
    for x in DIMS:
        if len(moved[x]) > 1:
            warn.append({"code": "MINK_DEPENDS_ON_TOL", "severity": "soft", "dim": x,
                         "minKByScale": {y["scalePermille"]: y["minK"][x] for y in sweep},
                         "message": f"{x} 的 min-k 随容差在 {moved[x]} 之间移动:"
                                    f"「采多少够」必须先说容差,容差放 2 倍 min-k 就可能减半"})
    g2 = {x + "Min": gate[x + "Min"] + (d if x != "band" else 1) for x in DIMS}
    warn.append({"code": "GATE_SHIFT", "severity": "soft", "delta": d, "gate": g2,
                 "verdictK": verdict_k(rows, gate),
                 "message": f"闸门下限整体 +{d}‰ 后,判决稳定 k 仍是 "
                            f"{verdict_k(rows, gate)}——判决稳定点比分量进容差点早得多,"
                            "两个「够」不是同一件事"})
    warn.append({"code": "PROXY_ONLY", "severity": "soft",
                 "message": "四分量来自规格仿真,是代理指标:只做配置之间的相对比较,禁绝对化"})
    report = {"id": "gd_play_13_sampling_warn", "tier": "B", "hardFail": False,
              "note": "B 档软告警:只提示容差与闸门对 min-k 的影响,不使任何构建失败",
              "baseTolerance": base_tol, "toleranceSweep": sweep, "baseMinK": base["minK"],
              "verdictK": verdict_k(rows, gate), "warnings": warn}
    with open(os.path.join(OUT, "play13_sampling_warn.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print("容差倍率扫描(min-k 随容差怎么动):")
    print("  倍率     " + "".join(f"{x:>11}" for x in DIMS))
    for y in sweep:
        print(f"  {y['scalePermille']:>5}‰   " + "".join(f"{str(y['minK'][x]):>11}" for x in DIMS))
    print(f"基准 min-k = {base['minK']}  判决稳定 k = {report['verdictK']}")
    for w in warn:
        print(f"SOFT WARN {w['code']}: {w['message']}")
    print(f"warnings={len(warn)}  hardFail=false  →  exit 0")
    print("wrote out/play13_sampling_warn.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
容差倍率扫描(min-k 随容差怎么动):
  倍率         entropy    domFree  emergence       band
    250‰             2          2          2         12
    500‰             2          2          2         12
   1000‰             2          2          2         12
   2000‰             2          2          2         12
   4000‰             2          2          2          8
   8000‰             2          2          2          2
基准 min-k = {'entropy': 2, 'domFree': 2, 'emergence': 2, 'band': 12}  判决稳定 k = 2
SOFT WARN MINK_DEPENDS_ON_TOL: band 的 min-k 随容差在 ['12', '2', '8'] 之间移动:「采多少够」必须先说容差,容差放 2 倍 min-k 就可能减半
SOFT WARN GATE_SHIFT: 闸门下限整体 +100‰ 后,判决稳定 k 仍是 2——判决稳定点比分量进容差点早得多,两个「够」不是同一件事
SOFT WARN PROXY_ONLY: 四分量来自规格仿真,是代理指标:只做配置之间的相对比较,禁绝对化
warnings=3  hardFail=false  →  exit 0
wrote out/play13_sampling_warn.json

判读

扫描给出的结论比 S 层更锋利:entropy、domFree、emergence 的 min-k 在六个倍率下纹丝不动(漂移本来就是 0 或很小),只有 band 在动——容差收到 1 步时 min-k 是 12,放到 4 步降到 8,放到 8 步才降到 2。所以「相变宽度要 12 个种子」这句话只在「容差 1 步」这个前提下成立;换容差就换答案。这正是代理指标的口径纪律:任何「采多少够」的结论都必须连着容差一起说,否则不可复现。

复杂度判据是另一条独立的线:|A|^D = 6^12 = 2176782336,远超枚举上限 200000,所以 needSampling=True——当策略空间大到连枚举都做不了时,问题就不是「采多少种子」,而是「换用什么估计量」。采样解决的是统计误差,解决不了枚举不了。

自检表

检查项合格标准
真值有基准全 16 种子当基准,逐 k 比对漂移
分量分开算四个分量各自的 min-k 分别报,不报一个总的
判决与分量分开闸门判决稳定的 k 与分量进容差的 k 是两个数
容差是自变量B 层扫容差倍率,暴露 min-k 对容差的依赖
有复杂度判据`
软告警不硬失败结论被推翻只打印 SOFT WARN,exit 0
无浮点漂移是千分比整数,宽度漂移是整数步数
代理指标声明四分量是 proxy,只做相对比较

采样解决了「估得准不准」,但还有一个更前置的问题:不同配置的读数能不能放在一起比——相变宽度在 turnLimit=2 和 turnLimit=4 下量出来的根本不是同一个东西。

阅读导航:上一节:13.1 可玩性的操作化定义 · 下一节:13.3 指标的可比性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡