14.3 从机制到体验的断言

把体验约束写成能失败的断言:闲置率 ≤ 300‰、决策点密度 ≥ 400‰、每回合动作 ≥ 1500‰、回合数落在 [3,8]。设计工作点四条全过,三个故意违反的负例各自让目标断言 fail;B 层软告警指出断言有效域在 enemyHp 75→80 之间断裂。这些断言是代理性的,不保证与真实玩家感受一致。

14.3 从机制到体验的断言

《小游戏开发实战》卷一 12.3 原型迭代与决策 那篇讲的是「拿到指标后怎么决定继续还是止损」,是决策口径;本节把 14.2 的指标接成门禁——给每条体验约束配一条阈值,写成能自动判 true / false 的断言,并像 10.2 那样给每条断言配一个故意违反的负例。区别在于:10.2 的不变量约束的是状态合法性,本节的断言约束的是体验画像——它们不是「规则对不对」,而是「这个配置跑出来的过程,像不像我们要的体验」。

把体验约束写成可失败的断言

四条断言,全部作用在 14.2 的代理画像上(idleMean 是闲置率均值,其余取跨种子极值):

#断言判据(整数)代理的体验目标
A1idleMean ≤ 300‰闲置率均值不超过 300‰能量不应大量白扔
A2decisionMin ≥ 400‰每一步至少 40% 有真选择抉择密度下限
A3actionMin ≥ 1500‰每回合至少打出 1.5 张操作密度下限
A4turn ∈ [3, 8]回合数落在带宽内战斗不长不短

阈值写进 config,改口径只改配置。每条断言都必须能被证伪:一条永远为真的断言不拦任何东西,只会稀释信号——所以脚本里除了正例,还内置三个故意违反的负例,并断言它们必须 fail。

断言脚本(S 层)

脚本先在设计工作点(enemyHp=70)上算真实画像,再跑三个负例,最后用 assert 把「真实全过」与「负例必挂」两条都钉死——任何一条不成立,脚本以非零码退出。

#!/usr/bin/env python3
# file: sim/gd_mech_14_assert.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_14_assert.py
"""卷一 14.3 从机制到体验的断言(S 层):把体验约束写成**可失败**的断言。

断言只对**代理画像**下判断(画像由 14.2 的四个指标聚合而来,全整数):
    A1 资源闲置率均值 ≤ idlePermilleMax
    A2 决策点密度最小值 ≥ decisionPermilleMin
    A3 每回合有效动作率最小值 ≥ actionPermilleMin
    A4 回合数落在 [turnBandMin, turnBandMax] 内
设计工作点的真实画像必须四条全过;三个**故意违反**的负例必须各自让目标断言 fail——
否则该断言只是摆设(与 10.2 同一方法论:没有反面的断言等于没说)。
诚实标注:这些断言是**代理性**的,只保证「机制在仿真里没跑偏」,
不保证与真实玩家的感受一致——它们是验收面,不是体验的度量。
产出 out/mech14_assert.csv + out/mech14_assert.json(供 14.3 的 B 层读)。
"""
import copy
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_mech_14_assert.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

HEADER = ["case", "enemyHp", "energyMax", "turnMin", "turnMax", "idleMean", "decisionMin",
          "actionMin", "A1", "A2", "A3", "A4", "passed"]


def profile(spec, seq, seeds):
    """把 14.2 的四个指标在种子集上聚合成一张整数画像。"""
    cost = {c["id"]: c["cost"] for c in spec["cards"]}
    turns, idles, decs, acts = [], [], [], []
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        tr = [ashlib.run_spec(s, seq[:k]) for k in range(len(seq) + 1)]
        final = tr[-1]
        turn = final["turn"]
        avail = s["params"]["energyMax"] * max(1, turn)
        spent = sum(max(0, tr[k - 1]["energy"] - tr[k]["energy"]) for k in range(1, len(tr)))
        opp = dec = 0
        for k in range(len(seq)):
            st = tr[k]
            if st["over"] or not seq[k].startswith("p"):
                continue
            opp += 1
            if len({cid for cid in st["hand"] if cost[cid] <= st["energy"]}) >= 2:
                dec += 1
        turns.append(turn)
        idles.append(1000 * max(0, avail - spent) // avail if avail else 0)
        decs.append(1000 * dec // opp if opp else 0)
        acts.append(1000 * final["played"] // max(1, turn))
    n = len(seeds)
    return {"turnMin": min(turns), "turnMax": max(turns), "idleMean": sum(idles) // n,
            "decisionMin": min(decs), "actionMin": min(acts)}


def checks(p, th):
    return {
        "A1": p["idleMean"] <= th["idlePermilleMax"],
        "A2": p["decisionMin"] >= th["decisionPermilleMin"],
        "A3": p["actionMin"] >= th["actionPermilleMin"],
        "A4": p["turnMin"] >= th["turnBandMin"] and p["turnMax"] <= th["turnBandMax"],
    }


def negatives(spec, cfg):
    """三个故意违反的负例:各自只针对一条断言,返回 (名字, 变体 spec, 变体策略, 目标断言)。"""
    allpass = copy.deepcopy(spec)
    single = copy.deepcopy(spec)
    single["cards"] = [{"id": "strike", "cost": 1, "damage": 6, "block": 0,
                        "count": 12, "rarity": "common"}]
    flood = copy.deepcopy(spec)
    flood["params"]["energyMax"] = 6
    return [("负例1 全过牌", allpass, ["e"] * cfg["probeTurns"], "A1"),
            ("负例2 单卡池", single, cfg["probePattern"] * cfg["probeTurns"], "A2"),
            ("负例3 能量过剩", flood, cfg["probePattern"] * cfg["probeTurns"], "A1")]


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    th = cfg["thresholds"]
    op = cfg["operatingPoint"]
    seeds = cfg["seeds"]
    seq = cfg["probePattern"] * cfg["probeTurns"]

    # 设计工作点:断言必须在这里全过。
    s0 = copy.deepcopy(spec)
    s0["params"][op["param"]] = op["value"]
    p0 = profile(s0, seq, seeds)
    real = [("真实@" + op["param"] + "=" + str(op["value"]), op["value"],
             spec["params"]["energyMax"], p0, checks(p0, th), None)]

    # 工作点扫描:只报告,供 B 层定位「断言有效域」的断裂处,不作为门禁。
    sweep = []
    for ehp in cfg["sweep"]:
        s = copy.deepcopy(spec)
        s["params"][op["param"]] = ehp
        p = profile(s, seq, seeds)
        c = checks(p, th)
        sweep.append({"enemyHp": ehp, "profile": p, "checks": c, "passed": sum(c.values())})

    rows, csv = list(real), []
    for name, s, sq, target in negatives(spec, cfg):
        s["params"][op["param"]] = op["value"]
        p = profile(s, sq, seeds)
        c = checks(p, th)
        rows.append((name, op["value"], s["params"]["energyMax"], p, c, target))

    for name, ehp, emax, p, c, target in rows:
        csv.append([name, ehp, emax, p["turnMin"], p["turnMax"], p["idleMean"], p["decisionMin"],
                    p["actionMin"], c["A1"], c["A2"], c["A3"], c["A4"], sum(c.values())])
    for sw in sweep:
        p, c = sw["profile"], sw["checks"]
        csv.append(["扫描@" + op["param"] + "=" + str(sw["enemyHp"]), sw["enemyHp"],
                    spec["params"]["energyMax"], p["turnMin"], p["turnMax"], p["idleMean"],
                    p["decisionMin"], p["actionMin"], c["A1"], c["A2"], c["A3"], c["A4"],
                    sum(c.values())])
    wrote = ashlib.write_csv(os.path.join(OUT, "mech14_assert.csv"), HEADER, csv)

    neg = [r for r in rows if r[5] is not None]
    for name, _e, _m, p, c, _t in rows:
        print(f"{name:<16} turn[{p['turnMin']},{p['turnMax']}] idle={p['idleMean']}‰ "
              f"dec={p['decisionMin']}‰ act={p['actionMin']}‰  "
              f"A1={c['A1']} A2={c['A2']} A3={c['A3']} A4={c['A4']}  pass={sum(c.values())}/4")

    assert sum(real[0][4].values()) == 4, "设计工作点的真实画像必须四条全过"
    for name, _e, _m, _p, c, target in neg:
        assert c[target] is False, f"{name} 没有让目标断言 {target} fail"
        assert sum(c.values()) < 4, f"{name} 没有让任何断言 fail"

    report = {"id": "gd_mech_14_assert", "thresholds": th, "operatingPoint": op,
              "seeds": len(seeds), "sweep": sweep,
              "designProfile": p0, "designChecks": real[0][4],
              "negatives": [{"case": r[0], "target": r[5], "checks": r[4]} for r in neg],
              "csvRows": wrote, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "mech14_assert.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(neg)} 个负例各自让目标断言 fail")
    print(f"csv={wrote} 行  elapsed={report['elapsedMs']} ms")
    print("wrote out/mech14_assert.csv out/mech14_assert.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_14_assert.py,退出码 0(elapsed 是墙钟采样,每次运行都不同):

真实@enemyHp=70    turn[4,6] idle=204‰ dec=428‰ act=2000‰  A1=True A2=True A3=True A4=True  pass=4/4
负例1 全过牌          turn[10,10] idle=1000‰ dec=0‰ act=0‰  A1=False A2=False A3=False A4=False  pass=0/4
负例2 单卡池          turn[5,5] idle=200‰ dec=0‰ act=2400‰  A1=True A2=False A3=True A4=True  pass=3/4
负例3 能量过剩         turn[4,5] idle=513‰ dec=588‰ act=2400‰  A1=False A2=True A3=True A4=True  pass=3/4
设计工作点四条全过;3 个负例各自让目标断言 fail
csv=11 行  elapsed=162 ms
wrote out/mech14_assert.csv out/mech14_assert.json

负例:每条断言都要咬得住

三个负例各自只改一处,专门去触发一条断言——这样断言 fail 时,能确定是哪一处触发的:

负例怎么造触发为什么它一定挂
负例1 全过牌策略换成 20 个 e,从不打牌A1 / A3 / A4能量一点没花(idle=1000‰)、没出过牌(act=0)、必然拖到阵亡(turn=10)
负例2 单卡池卡池塌成 12 张同名 strikeA2手牌永远只有一种 id,dec=0‰,没有任何「选择」可言
负例3 能量过剩energyMax 抬到 6A1每回合预算翻倍却打不出那么多牌,idle=513‰ 越过 300‰

负例2 最能说明问题:它只挂了 A2,另外三条照过。这正是「每条断言盯一个具体的错」的验证——如果一个负例同时挂掉四条,你就分不清到底是哪条断言在工作。反过来,负例1 挂掉三条也不矛盾:一个从不打牌的配置本来就同时违反多条体验约束,脚本用 target 字段只钉住其中最关键的一条。

有效域的软告警(B 层)

断言只在某个工作点上被验证过,越过它就未必成立。B 层脚本读 S 层的扫描产物,沿工作点找翻转处,只提示、不使构建失败(永远 exit 0)。

#!/usr/bin/env python3
# file: sim/gd_mech_14_robustness.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_mech_14_robustness.py
"""卷一 14.3 代理断言的稳健性(B 层):**永远 exit 0**,只提示不使构建失败。

读 out/mech14_assert.json 的工作点扫描(14.3 S 层产物),沿工作点找断言的翻转处:
    1) 有效域边界:第一个四条不全过的工作点——它是「断言还能用」与「不能再用」的分界;
    2) 逐条翻转:哪一条断言在相邻两格之间变了结论;
    3) 恒常提醒:这四条断言全是代理性的,翻转的存在本身就说明它们不能跨工作点外推。
每条含 severity=soft 与 hardFail=false;本脚本永远 exit 0。
产出 out/mech14_robustness.json。
"""
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)


def main():
    with open(os.path.join(OUT, "mech14_assert.json"), encoding="utf-8") as f:
        rep = json.load(f)
    sweep = rep["sweep"]
    names = ("A1", "A2", "A3", "A4")

    warn = []
    boundary = next((s for s in sweep if s["passed"] < 4), None)
    if boundary is not None:
        warn.append({"code": "VALID_REGION_BOUNDARY", "severity": "soft",
                     "enemyHp": boundary["enemyHp"], "passed": boundary["passed"],
                     "message": f"断言在 enemyHp={boundary['enemyHp']} 处只剩 "
                                f"{boundary['passed']}/4 条成立:有效域在此断裂,"
                                f"越过它再引用本节结论即为外推"})
    for a, b in zip(sweep, sweep[1:]):
        for k in names:
            if a["checks"][k] != b["checks"][k]:
                warn.append({"code": "ASSERTION_FLIP", "severity": "soft", "assertion": k,
                             "from": a["enemyHp"], "to": b["enemyHp"],
                             "message": f"{k} 在 enemyHp {a['enemyHp']}→{b['enemyHp']} 之间翻转"
                                        f"({a['checks'][k]} → {b['checks'][k]})"})
    warn.append({"code": "PROXY_ONLY", "severity": "soft",
                 "message": "四条断言全部建立在代理指标上:它们只保证仿真里机制没跑偏,"
                            "不保证与真实玩家感受一致,不得写成「体验提升 N 倍」"})

    report = {"id": "gd_mech_14_robustness", "tier": "B", "hardFail": False,
              "note": "B 档软告警:只提示代理断言的有效域,不使任何构建失败",
              "sweep": [{"enemyHp": s["enemyHp"], "passed": s["passed"]} for s in sweep],
              "warnings": warn}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "mech14_robustness.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    for w in warn:
        print(f"SOFT WARN {w['code']}: {w['message']}")
    print(f"warnings={len(warn)}  hardFail=false  →  exit 0")
    print("wrote out/mech14_robustness.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_14_robustness.py,退出码 0:

SOFT WARN VALID_REGION_BOUNDARY: 断言在 enemyHp=80 处只剩 0/4 条成立:有效域在此断裂,越过它再引用本节结论即为外推
SOFT WARN ASSERTION_FLIP: A1 在 enemyHp 75→80 之间翻转(True → False)
SOFT WARN ASSERTION_FLIP: A2 在 enemyHp 75→80 之间翻转(True → False)
SOFT WARN ASSERTION_FLIP: A3 在 enemyHp 75→80 之间翻转(True → False)
SOFT WARN ASSERTION_FLIP: A4 在 enemyHp 75→80 之间翻转(True → False)
SOFT WARN PROXY_ONLY: 四条断言全部建立在代理指标上:它们只保证仿真里机制没跑偏,不保证与真实玩家感受一致,不得写成「体验提升 N 倍」
warnings=6  hardFail=false  →  exit 0
wrote out/mech14_robustness.json

四条断言在 enemyHp ≤ 75 上全过,一到 80 就集体翻面——这就是 6.2 讲过的相变:75→80 之间玩家从必胜跌到必败,四个指标同时换语义。软告警不拦住构建,但它把「这四条断言只在 enemyHp ≤ 75 有效」这句话写进了产物里。

诚实标注:断言能证明什么、不能证明什么

断言能证明断言不能证明
机制在仿真里没跑偏(能量、动作、选择、长度都在阈值内)真实玩家是否觉得紧张、爽快、有得选
配置改动没有把某个代理指标推出阈值配置改动改善了玩家的主观体验
阈值在工作点附近成立阈值换工作点仍成立(14.1 已证明会翻)
负例被抓住(断言确实在拦东西)阈值本身选得对(300‰ 只是个设计判断)

这张表是本节最重要的一页。断言是一层代理门禁:它能自动挡住明显的退化(能量白扔、没得选、一回合秒杀),但它挡不住「机制全绿、玩家不喜欢」——那需要真实玩家测试,属于 X 档,命令行不可复现。把代理门禁当成体验认证,是本卷反复警告的那一类错误。

检查项合格标准
断言可失败每条断言有整数阈值,能被一个具体配置推翻
每条有负例三个负例各自钉住一条断言,脚本 assert 负例必挂
正例必过设计工作点四条全过,否则脚本非零退出
代理显式正文声明断言是代理性的,不保证真实玩家感受
有效域入档B 层软告警记下断言在哪一格外断裂,永远 exit 0

阅读导航:上一节:14.2 可观测的体验指标 · 下一节:15.1 跨语言摘要与 digest 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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