4.1 测试的目标与假设

「我想验证难度会不会太高」不是假设,是愿望——它没法被判伪。本节把测试目标写成可机检的五元组:指标、方向、阈值、样本量与一对互斥命题;先做可证伪性自检,再用 32 个种子的试点反推所需样本量,实跑得 nRequired=256,与设计门槛 Δ=150‰ 对齐。

4.1 测试的目标与假设

《小游戏开发实战》卷一 12.2 可玩性指标测试 那篇把「占比类指标 + 置信区间」这条路走通了,指标偏胜率、完成率,落点在「这个比例有多准」。本节做的是测试设计本身:在跑任何采样之前,先把「这次到底想验证什么」写成一条可证伪的假设,再据此定样本量与判据。指标也换了一类——本节量的是机制量(可解率、支配缺失度、策略熵),回答的是「机制有没有被改动影响」,而不是「玩家喜不喜欢」。

假设不是愿望

「我想验证难度会不会太高」不是假设,是愿望。它没法被判伪:无论测出什么,都能说「再测测看」。可证伪的假设必须写死三件事——量哪个指标、哪个方向的多少算效果、采多少样本才够。三件事都写死,测试才有「结论」可言,否则只剩「数据」。

把这三件事连同两个互斥命题凑成一个五元组,就是本节要的形式:metric(量什么)、direction(哪个方向算效果)、thresholdPermille(多大算效果)、sampleSize(计划采多少)、h0 / h1(一对互斥命题)。五元组里任何一项缺失,假设就不可证伪,脚本会直接判不合格。

一条合格的假设长什么样

以本章的贯穿测试为例:候选改动是把敌人攻击从 20 提到 22(难度上调),要回答的是「可解率会不会被击穿」。写成假设:

  • H0:改动后两臂可解率之差 < 150‰(难度与可解率无关)。
  • H1:改动后两臂可解率之差 ≥ 150‰(难度上调击穿底线)。

150‰ 就是最小可分辨差 Δ——它是设计相关的门槛,不是统计量:低于这个差,改动在体验上等于没改,不值得为它承担风险。Δ 必须在测试前写死,事后调 Δ 就是挑数据。

假设的机器可读形式

假设写进 config/gd_proto_04_hypothesis.json,一台机器就能检:五元组齐不齐、Δ 和样本量是多少、两臂差落在哪个参数上。三个指标各登记一条假设,主指标 solveRate 是占比类,另两个是非占比类。

{ "testId": "gd_proto_04_difficulty_ab", "referenceStrategy": "maxdmg",
  "scenario": { "enemyHp": 65, "turnLimit": 16, "energyMax": 3, "playerHp": 60 },
  "arms": { "control": { "enemyAttack": 20 }, "treat": { "enemyAttack": 22 } },
  "seedStart": 401, "seedCount": 256, "pilotSeeds": 32, "sampleLadder": [8, 16, 32, 64, 128, 256],
  "minDiscerniblePermille": 150, "z": 2, "zNote": "整数近似,对应约 95% 水平,不等同教科书 1.96",
  "hypotheses": [ { "id": "H-solve", "metric": "solveRate", "direction": "decrease",
    "thresholdPermille": 150, "sampleSize": 256,
    "h0": "可解率下降 < 150‰(难度与可解率无关)",
    "h1": "可解率下降 >= 150‰(难度上调击穿底线)" } ] }

S 层:可证伪性自检与试点

脚本分两步。第一步自检:逐条检查五元组是否齐备,不齐备直接标 NO。第二步试点:用 32 个种子跑两臂,拿到三个指标的基线值,再由试点比例反推所需样本量 nRequired——在样本阶梯里找最小的 n,使两臂整数 Wilson 半宽之和 ≤ Δ。公共库 sim/gd_proto_04_common.py 提供四个确定性策略与整数区间,本章三节共用。

#!/usr/bin/env python3
# file: sim/gd_proto_04_common.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_04_common.py
"""卷三第 4 章公共库:四个确定性参考策略 + 三个机制指标 + 整数 Wilson 区间。

  strategies(spec)        最大伤害 / 优先格挡 / 最省费用 / 每费伤害 四个确定性策略
  play / solve_rate       按策略序列跑一局;参考策略在种子池上的可解率(千分比)
  mechanism_metrics(...)  可解率 + 支配缺失度 + 策略熵(后两者带块极差)
  wilson / wilson_half    整数 Wilson 区间(z 为整数,用 isqrt 求,整数近似而非 1.96)

全部整数、无浮点、无 random。指标是代理指标(proxy),只做相对比较,禁绝对化。
"""
import copy
from math import isqrt

import ashlib

SCALE = 1000000
PREC = 16
ONE = 1 << PREC


def log2_fixed(x: int) -> int:
    """floor(log2(x) * 2^PREC) 的整数实现(纯移位与平方,无浮点)。"""
    b = x.bit_length() - 1
    y = x << (PREC - b) if b <= PREC else x >> (b - PREC)
    res = b << PREC
    for i in range(PREC):
        y = (y * y) >> PREC
        if y >= (ONE << 1):
            res += 1 << (PREC - 1 - i)
            y >>= 1
    return res


def entropy_permille(counts: list) -> int:
    """策略胜出计数的 Shannon 熵 ×1000(底 2)。"""
    w = sum(counts)
    if w == 0:
        return 0
    acc = sum(c * (log2_fixed(c) - log2_fixed(w)) for c in counts if c > 0)
    return (-acc * 1000) // (w * ONE)


def strategies(spec: dict) -> list:
    cost = {c["id"]: c["cost"] for c in spec["cards"]}
    dmg = {c["id"]: c["damage"] for c in spec["cards"]}
    blk = {c["id"]: c["block"] for c in spec["cards"]}

    def argmax(s, key, pred=None):
        idx = [i for i, c in enumerate(s["hand"])
               if cost[c] <= s["energy"] and (pred is None or pred(c))]
        return None if not idx else f"p{max(idx, key=lambda i: key(s['hand'][i]))}"

    def maxdmg(s):
        return argmax(s, lambda c: (dmg[c], -cost[c])) or "e"

    def maxblk(s):
        return argmax(s, lambda c: (blk[c], -cost[c]), lambda c: blk[c] > 0) or maxdmg(s)

    def cheapest(s):
        return argmax(s, lambda c: (-cost[c], dmg[c])) or "e"

    def dmgper(s):
        return argmax(s, lambda c: (dmg[c] * 1000 // max(1, cost[c]), dmg[c])) or "e"

    return [("maxdmg", maxdmg), ("maxblk", maxblk), ("cheapest", cheapest), ("dmgper", dmgper)]


def play(spec, seed, params, fn, max_steps=80):
    s = copy.deepcopy(spec)
    s["seed"] = seed
    s["params"].update(params)
    seq = []
    for _ in range(max_steps):
        sn = ashlib.run_spec(s, seq)
        if sn["over"]:
            break
        seq.append(fn(sn))
    return ashlib.run_spec(s, seq)


def solve_rate(spec, seeds, params, fn) -> int:
    won = sum(1 for sd in seeds if play(spec, sd, params, fn)["won"])
    return 1000 * won // len(seeds)


def mechanism_metrics(spec, seeds, params, strats, ref, blocks) -> dict:
    wins = {name: 0 for name, _ in strats}
    unique, ref_won = 0, 0
    for sd in seeds:
        res = {name: play(spec, sd, params, fn)["won"] for name, fn in strats}
        for name in wins:
            wins[name] += 1 if res[name] else 0
        ref_won += 1 if res[ref] else 0
        if len([name for name in wins if res[name]]) == 1:
            unique += 1
    n = len(seeds)
    bs = max(1, n // blocks)
    dom, ent = [], []
    for b in range(blocks):
        blk = seeds[b * bs:(b + 1) * bs]
        w2 = {name: 0 for name, _ in strats}
        u2 = 0
        for sd in blk:
            res = {name: play(spec, sd, params, fn)["won"] for name, fn in strats}
            for name in w2:
                w2[name] += 1 if res[name] else 0
            if len([name for name in w2 if res[name]]) == 1:
                u2 += 1
        dom.append(1000 - 1000 * u2 // len(blk))
        ent.append(entropy_permille([w2[name] for name, _ in strats]))
    return {"solveRate": 1000 * ref_won // n, "dominanceAbsence": 1000 - 1000 * unique // n,
            "strategyEntropy": entropy_permille([wins[name] for name, _ in strats]),
            "domBlockLo": min(dom), "domBlockHi": max(dom),
            "entBlockLo": min(ent), "entBlockHi": max(ent)}


def wilson(k: int, n: int, z: int) -> tuple:
    """整数 Wilson 区间(z 为整数),返回千分比 [lo, hi] 与半宽。"""
    num = k * (n - k) + n
    s_scaled = isqrt(num * SCALE * SCALE // n)
    center = 1000 * (k + z * z // 2) // (n + z * z)
    half = (1000 * z * s_scaled) // (SCALE * (n + z * z))
    return max(0, center - half), min(1000, center + half), half


def wilson_half(k: int, n: int, z: int) -> int:
    return wilson(k, n, z)[2]


if __name__ == "__main__":
    _spec = ashlib.load_spec()
    print("strategies:", " ".join(n for n, _ in strategies(_spec)),
          "| wilson(50,100,2)=", wilson(50, 100, 2),
          "| entropy([1,1,1,1])=", entropy_permille([1, 1, 1, 1]))
#!/usr/bin/env python3
# file: sim/gd_proto_04_hypothesis.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_04_hypothesis.py
"""卷三 4.1 测试的目标与假设(S 层):把「这次测试想验证什么」写成可证伪的假设。

假设不是一句「我想验证难度会不会太高」,而是可机检的五元组:
  metric(量哪个指标)/ direction(哪个方向算效果)/ threshold(多大算效果)
  / sampleSize(计划采多少样本)/ h0 + h1(一对互斥命题)。
本脚本先对登记表做**可证伪性自检**(五元组齐备才算合格),再跑一次**试点**
(pilotSeeds 个种子)拿到两臂的机制指标基线,最后由试点比例反推**所需样本量**
nRequired:在样本阶梯里找最小的 n,使两臂整数 Wilson 半宽之和 <= Δ。

指标是代理指标(proxy),只做相对比较,禁绝对化;区间只刻画采样不确定性、不修正偏差。
产出 out/proto04_hypothesis.json。全整数、无浮点、无 random。
"""
import json
import os
import sys
import time

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_proto_04_hypothesis.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_proto_04_common as C  # noqa: E402

REQUIRED = ("id", "metric", "direction", "thresholdPermille", "sampleSize", "h0", "h1")
METRICS = ("solveRate", "dominanceAbsence", "strategyEntropy")


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    strats = C.strategies(spec)
    ref = cfg["referenceStrategy"]
    seeds = list(range(cfg["seedStart"], cfg["seedStart"] + cfg["seedCount"]))
    delta, z = cfg["minDiscerniblePermille"], cfg["z"]

    print("== 假设登记表:可证伪性自检(五元组 + h0/h1 齐备)")
    print(f"{'id':<10}{'metric':<20}{'dir':<10}{'thr‰':>6}{'n':>6}  {'齐备':<4}")
    checklist = []
    for h in cfg["hypotheses"]:
        ok = all(h.get(k) not in (None, "") for k in REQUIRED)
        checklist.append({"id": h["id"], "falsifiable": ok})
        print(f"{h['id']:<10}{h['metric']:<20}{h['direction']:<10}"
              f"{h['thresholdPermille']:>6}{h['sampleSize']:>6}  {'yes' if ok else 'NO':<4}")

    print(f"\n== 试点(pilotSeeds={cfg['pilotSeeds']}):两臂机制指标基线")
    pilot = seeds[:cfg["pilotSeeds"]]
    arms = {a: C.mechanism_metrics(spec, pilot, {**cfg["scenario"], **cfg["arms"][a]}, strats, ref, 4)
            for a in ("control", "treat")}
    print(f"{'arm':<10}{'solveRate‰':>12}{'domAbs‰':>10}{'entropy‰':>11}")
    for a in ("control", "treat"):
        m = arms[a]
        print(f"{a:<10}{m['solveRate']:>12}{m['dominanceAbsence']:>10}{m['strategyEntropy']:>11}")
    diffs = {k: arms["control"][k] - arms["treat"][k] for k in METRICS}
    print(f"{'diff(降)':<10}{diffs['solveRate']:>12}{diffs['dominanceAbsence']:>10}"
          f"{diffs['strategyEntropy']:>11}")

    pa, pb = arms["control"]["solveRate"], arms["treat"]["solveRate"]
    print(f"\n== 由试点比例反推样本量(Δ={delta}‰,z={z},主指标 solveRate)")
    print(f"{'n':>5}{'hA‰':>6}{'hB‰':>6}{'hA+hB‰':>9}  {'<=Δ':<5}")
    n_req = None
    for n in cfg["sampleLadder"]:
        ha = C.wilson_half(pa * n // 1000, n, z)
        hb = C.wilson_half(pb * n // 1000, n, z)
        ok = (ha + hb) <= delta
        if ok and n_req is None:
            n_req = n
        print(f"{n:>5}{ha:>6}{hb:>6}{ha + hb:>9}  {'yes' if ok else 'no':<5}")
    print(f"nRequired = {n_req}(阶梯内最小可分辨样本量;"
          f"计划 sampleSize={cfg['hypotheses'][0]['sampleSize']})")

    report = {"id": "gd_proto_04_hypothesis", "testId": cfg["testId"], "reference": ref,
              "pilotSeeds": cfg["pilotSeeds"], "minDiscerniblePermille": delta, "z": z,
              "falsifiability": checklist, "pilot": arms, "pilotDiff": diffs, "nRequired": n_req,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "proto04_hypothesis.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"\nwrote out/proto04_hypothesis.json  elapsed={report['elapsedMs']} ms")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_proto_04_hypothesis.py:

== 假设登记表:可证伪性自检(五元组 + h0/h1 齐备)
id        metric              dir         thr‰     n  齐备  
H-solve   solveRate           decrease     150   256  yes 
H-dom     dominanceAbsence    decrease     100   256  yes 
H-ent     strategyEntropy     decrease      60   256  yes 

== 试点(pilotSeeds=32):两臂机制指标基线
arm         solveRate‰   domAbs‰   entropy‰
control            500       532       1507
treat              218       469       1352
diff(降)            282        63        155

== 由试点比例反推样本量(Δ=150‰,z=2,主指标 solveRate)
    n   hA‰   hB‰   hA+hB‰  <=Δ  
    8   288   228      516  no   
   16   223   185      408  no   
   32   166   134      300  no   
   64   121    99      220  no   
  128    87    71      158  no   
  256    62    51      113  yes  
nRequired = 256(阶梯内最小可分辨样本量;计划 sampleSize=256)

wrote out/proto04_hypothesis.json  elapsed=58 ms

试点读出了什么

三件事。一、主指标的差远大于 Δ:试点 32 个种子里,两臂可解率是 500‰ 与 218‰,差 282‰,比 Δ=150‰ 大一截——这条假设有戏。二、非占比指标在试点尺度上还不显著:支配缺失度差 63‰(< 其阈值 100‰),策略熵差 155(×1000,> 其阈值 60)——一个够一个不够,说明不同指标的样本量需求不一样,不能一刀切。三、nRequired = 256:试点比例代进整数 Wilson,n=128 时半宽之和还有 158‰(> 150),到 n=256 才降到 113‰——看得见的差有多大,取决于看了多少。

自检表

检查项合格标准
可证伪五元组 + h0/h1 齐备,缺一即不合格
Δ 前置最小可分辨差在测试前写死,事后不改
方向明确每个指标写清「哪个方向算效果」
样本量有据nRequired 由试点比例反推,不是拍脑袋
代理指标声明指标声明为 proxy,只做相对比较;真人数据标 X

本节只回答「测什么、怎么算够」。样本量怎么落到区间上、区间怎么变成决策,是后两节的事——三节共用同一批种子与同一套整数纪律,所以读数可以互相引用。

阅读导航:上一节:3.3 原型的可分享与可回放 · 下一节:4.2 采样与置信区间 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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