14.3 从机制到体验的断言
《小游戏开发实战》卷一 12.3 原型迭代与决策 那篇讲的是「拿到指标后怎么决定继续还是止损」,是决策口径;本节把 14.2 的指标接成门禁——给每条体验约束配一条阈值,写成能自动判 true / false 的断言,并像 10.2 那样给每条断言配一个故意违反的负例。区别在于:10.2 的不变量约束的是状态合法性,本节的断言约束的是体验画像——它们不是「规则对不对」,而是「这个配置跑出来的过程,像不像我们要的体验」。
把体验约束写成可失败的断言
四条断言,全部作用在 14.2 的代理画像上(idleMean 是闲置率均值,其余取跨种子极值):
| # | 断言 | 判据(整数) | 代理的体验目标 |
|---|---|---|---|
| A1 | idleMean ≤ 300‰ | 闲置率均值不超过 300‰ | 能量不应大量白扔 |
| A2 | decisionMin ≥ 400‰ | 每一步至少 40% 有真选择 | 抉择密度下限 |
| A3 | actionMin ≥ 1500‰ | 每回合至少打出 1.5 张 | 操作密度下限 |
| A4 | turn ∈ [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 张同名 strike | A2 | 手牌永远只有一种 id,dec=0‰,没有任何「选择」可言 |
| 负例3 能量过剩 | energyMax 抬到 6 | A1 | 每回合预算翻倍却打不出那么多牌,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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。