6.3 稳健性与告警
《小游戏开发实战》卷一 7.2 难度曲线与心流 那篇的 B 层停在「敏感性」这一步:给出指标随参数的变化,再由设计者自己判断哪一段危险。本节把「判断」也做成可跑的产物:逐格分类 + 软告警清单。这里的分工是——6.1 给出斜率、6.2 给出临界点,6.3 回答设计者真正要问的那句「我把参数放在这里,安全吗」。
稳健 / 脆弱:把逐格步差分类
判据只有两条线,都是千分比整数,写在只读配置里:
| 类别 | 判据 | 含义 |
|---|---|---|
| 稳健步 | 相邻网格点之间 |Δ胜率| ≤ 100‰ | 参数动一格,胜率几乎不动 |
| 过渡步 | 100‰ < |Δ胜率| < 200‰ | 有变化但不到危险 |
| 脆弱步 | |Δ胜率| ≥ 200‰ | 参数动一格,难度显著改变 |
稳健区间取连续稳健步的极大区间,脆弱区间取含脆弱步的极大区间。分类结果在临界点附近长这样(网格在临界点邻域加密到步长 1):
enemyHp | 胜率 | 平均剩余 HP | Δ胜率 | 判定 |
|---|---|---|---|---|
| 74 | 937‰ | 44 | 0 | 稳健 |
| 75 | 937‰ | 44 | 0 | 稳健 |
| 76 | 875‰ | 43 | -62 | 稳健 |
| 77 | 875‰ | 43 | 0 | 稳健 |
| 78 | 875‰ | 43 | 0 | 稳健 |
| 79 | 0‰ | 39 | -875 | 脆弱 |
| 80 | 0‰ | 39 | 0 | 稳健 |
表里只有一个脆弱步:78 → 79,一步掉 875‰——「enemyHp 在 78–79 区间内胜率跨越 500‰,此处任何 ±1 的改动都会显著改变难度」,这正是软告警清单的第一条。
S 层:分类与告警清单
下面这段是 S 层脚本 sim/gd_mech_06_robustness.py。它自带粗扫 + 二分定位临界点,在临界点邻域加密网格,分类逐格步差,产出 out/mech06_warnings.json 与一张步差条形图。注意脚本末尾那行注释——它永远 exit 0。
#!/usr/bin/env python3
# file: sim/gd_mech_06_robustness.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_06_robustness.py
"""卷一 6.3 稳健性与告警(S 层):临界点附近的胜率分类成稳健 / 脆弱区间。
判据(整数千分比):稳健步 = |Δ胜率| ≤ 100‰;脆弱步 = |Δ胜率| ≥ 200‰;中间为过渡步;
稳健区间 = 连续稳健步的极大区间;脆弱区间 = 含脆弱步的极大区间。产出
out/mech06_warnings.json(hardFail=false)与 out/mech06_robustness.svg。
**永远 exit 0**:B 档软告警不硬失败。自带粗扫 + 二分,故可独立运行。
"""
import copy
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_mech_06_perturb.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def scan(spec, cfg):
"""粗扫 + 二分定位临界点 → 并集网格。返回 (critical, table, cache)。"""
param, seeds = cfg["param"], cfg["seeds"]
lo, hi, step = cfg["coarse"]["from"], cfg["coarse"]["to"], cfg["coarse"]["step"]
cross, radius = cfg["bisect"]["crossPermille"], cfg["fine"]["radius"]
cache = {}
def cell(v):
if v not in cache:
wins = php = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"][param] = v
snap = ashlib.run_spec(s)
wins += 1 if snap["won"] else 0
php += snap["playerHp"]
cache[v] = (1000 * wins // len(seeds), php // len(seeds))
return cache[v]
coarse = [(v, cell(v)[0]) for v in range(lo, hi + 1, step)]
a, b = next((a, b) for a, b in zip(coarse, coarse[1:]) if a[1] >= cross > b[1])
lo_b, hi_b = a[0], b[0]
while hi_b - lo_b > 1:
mid = (lo_b + hi_b) // 2
lo_b, hi_b = (mid, hi_b) if cell(mid)[0] >= cross else (lo_b, mid)
grid = sorted(set(range(lo, hi + 1, step))
| set(range(max(lo, hi_b - radius), min(hi, hi_b + radius) + 1)))
return hi_b, [{"value": v, "winPermille": cell(v)[0], "avgPlayerHp": cell(v)[1]}
for v in grid], cache
def classify(table, stable_step, fragile_step):
"""相邻网格点 → 步差表:稳健 / 过渡 / 脆弱。"""
out = []
for a, b in zip(table, table[1:]):
d = b["winPermille"] - a["winPermille"]
kind = "fragile" if abs(d) >= fragile_step else ("stable" if abs(d) <= stable_step else "mixed")
out.append({"from": a["value"], "to": b["value"], "deltaPermille": d, "kind": kind})
return out
def runs(steps, kinds):
"""连续同类步的极大区间 [(from, to), ...]。"""
out, cur = [], None
for s in steps:
if s["kind"] in kinds:
cur = (s["from"], s["to"]) if cur is None else (cur[0], s["to"])
elif cur:
out.append(cur)
cur = None
if cur:
out.append(cur)
return out
def draw(steps, fragile_step, param, path):
"""逐点步差 |Δ胜率| 条形图:脆弱步涂红。"""
root = ashlib.svg_begin(700, 300)
ashlib.svg_text(root, 62, 24, f"{param} 逐点步差 |Δ胜率|(红 = 脆弱步 ≥ {fragile_step}‰)", 14)
for i, s in enumerate(steps):
h = min(abs(s["deltaPermille"]), 1000) * 200 // 1000
ET.SubElement(root, "rect", {"x": str(62 + i * 28), "y": str(240 - h), "width": "24",
"height": str(max(h, 1)),
"fill": "#ef4444" if s["kind"] == "fragile" else "#38bdf8"})
return ashlib.svg_save(root, path)
def jarr(key, rows):
body = ",\n".join(" " + json.dumps(r, ensure_ascii=False, sort_keys=True) for r in rows)
return f' "{key}": [\n{body}\n ]'
def main():
t0 = time.perf_counter_ns()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
param, seeds = cfg["param"], cfg["seeds"]
cross, radius = cfg["bisect"]["crossPermille"], cfg["fine"]["radius"]
stable_step, fragile_step = cfg["classify"]["stableStepPermille"], cfg["classify"]["fragileStepPermille"]
critical, table, cache = scan(ashlib.load_spec(), cfg)
steps = classify(table, stable_step, fragile_step)
span = lambda f, t: [s for s in steps if f <= s["from"] and s["to"] <= t] # noqa: E731
robust = [{"from": f, "to": t, "maxAbsStepPermille": max(abs(s["deltaPermille"]) for s in span(f, t))}
for f, t in runs(steps, {"stable"})]
fragile = [{"from": f, "to": t, "deltaPermille": min(s["deltaPermille"] for s in span(f, t))}
for f, t in runs(steps, {"fragile"})]
fine = [r for r in table if abs(r["value"] - critical) <= radius]
h_high = max(r["value"] for r in fine if r["winPermille"] >= cfg["bisect"]["bandHighPermille"])
h_low = min(r["value"] for r in fine if r["winPermille"] <= cfg["bisect"]["bandLowPermille"])
width = h_low - h_high
drop = min((table[i + 1]["avgPlayerHp"] - table[i]["avgPlayerHp"], table[i]["value"], table[i + 1]["value"])
for i in range(len(table) - 1))
warnings = []
for f in fragile:
warnings.append({"code": "FRAGILE_STEP", "severity": "soft", "from": f["from"], "to": f["to"],
"deltaPermille": f["deltaPermille"],
"message": f"{param} 在 {f['from']}–{f['to']} 区间内胜率变化 {f['deltaPermille']}‰,"
f"此处任何 ±1 的改动都会显著改变难度"})
warnings.append({"code": "CRITICAL_POINT", "severity": "soft", "value": critical,
"message": f"{param}={critical} 是胜率跌破 {cross}‰ 的临界点,"
f"把初始值钉在临界点 ±1 内等于把难度钉在刀尖上"})
warnings.append({"code": "NARROW_TRANSITION", "severity": "soft", "width": width,
"message": f"临界宽度只有 {width}:{param} 从 {h_high} 到 {h_low} 就走完 900‰→100‰,"
f"过渡带太窄,难度没有可调的中间档"})
warnings.append({"code": "AVG_HP_DROP", "severity": "soft", "from": drop[1], "to": drop[2],
"deltaPlayerHp": drop[0],
"message": f"平均剩余 HP 在 {drop[1]}–{drop[2]} 上掉 {drop[0]},跨过临界点后玩家受伤明显变多"})
text = "{\n" + ",\n".join([
' "id": "gd_mech_06_warnings"',
' "tier": "B"',
' "hardFail": false',
' "note": "B 档软告警:只提示风险区间,不使任何构建失败"',
f' "param": "{param}"',
f' "seeds": {len(seeds)}',
f' "criticalPoint": {critical}',
f' "criticalWidth": {width}',
f' "classify": {{"stableStepPermille": {stable_step}, "fragileStepPermille": {fragile_step}}}',
jarr("robustIntervals", robust),
jarr("fragileIntervals", fragile),
jarr("warnings", warnings),
]) + "\n}\n"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "mech06_warnings.json"), "w", encoding="utf-8") as f:
f.write(text)
size = draw(steps, fragile_step, param, os.path.join(OUT, "mech06_robustness.svg"))
for r in table:
print(f"{param}={r['value']:>3} winRate={r['winPermille']:>4}‰ avgPlayerHp={r['avgPlayerHp']:>2}")
for r in robust:
print(f"ROBUST {param} [{r['from']}, {r['to']}] maxAbsStep={r['maxAbsStepPermille']}‰")
for f in fragile:
print(f"FRAGILE {param} [{f['from']}, {f['to']}] delta={f['deltaPermille']}‰")
for w in warnings:
print(f"SOFT WARN {w['code']}: {w['message']}")
print(f"criticalPoint={critical} criticalWidth={width} warnings={len(warnings)} hardFail=false")
print(f"cells={len(cache)} runs={len(cache) * len(seeds)} "
f"elapsed={(time.perf_counter_ns() - t0) // 1000000} ms svg={size} bytes")
print("wrote out/mech06_warnings.json out/mech06_robustness.svg")
ET.parse(os.path.join(OUT, "mech06_robustness.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_06_robustness.py(完整 22 行逐格表见上方 Markdown 表,这里只截分类与告警段):
ROBUST enemyHp [20, 78] maxAbsStep=63‰
ROBUST enemyHp [79, 80] maxAbsStep=0‰
FRAGILE enemyHp [78, 79] delta=-875‰
SOFT WARN FRAGILE_STEP: enemyHp 在 78–79 区间内胜率变化 -875‰,此处任何 ±1 的改动都会显著改变难度
SOFT WARN CRITICAL_POINT: enemyHp=79 是胜率跌破 500‰ 的临界点,把初始值钉在临界点 ±1 内等于把难度钉在刀尖上
SOFT WARN NARROW_TRANSITION: 临界宽度只有 4:enemyHp 从 75 到 79 就走完 900‰→100‰,过渡带太窄,难度没有可调的中间档
SOFT WARN AVG_HP_DROP: 平均剩余 HP 在 78–79 上掉 -4,跨过临界点后玩家受伤明显变多
criticalPoint=79 criticalWidth=4 warnings=4 hardFail=false
cells=22 runs=352 elapsed=16 ms svg=1720 bytes
wrote out/mech06_warnings.json out/mech06_robustness.svg
B 层:软告警不硬失败
out/mech06_warnings.json 的第一行字段就是口径声明:"hardFail": false。B 档告警永远不阻断任何流程——不进 CI 红灯、不使构建失败、不返回非零退出码。原因很简单:100‰ / 200‰ 这两条线是设计者定的,不是物理常数;把人的判断做成硬失败,只会逼人去调阈值,而不是去看风险。每条告警都带 "severity": "soft":
"warnings": [
{"code": "FRAGILE_STEP", "deltaPermille": -875, "from": 78, "message": "enemyHp 在 78–79 区间内胜率变化 -875‰,此处任何 ±1 的改动都会显著改变难度", "severity": "soft", "to": 79},
{"code": "CRITICAL_POINT", "message": "enemyHp=79 是胜率跌破 500‰ 的临界点,把初始值钉在临界点 ±1 内等于把难度钉在刀尖上", "severity": "soft", "value": 79},
{"code": "NARROW_TRANSITION", "message": "临界宽度只有 4:enemyHp 从 75 到 79 就走完 900‰→100‰,过渡带太窄,难度没有可调的中间档", "severity": "soft", "width": 4},
{"code": "AVG_HP_DROP", "deltaPlayerHp": -4, "from": 78, "message": "平均剩余 HP 在 78–79 上掉 -4,跨过临界点后玩家受伤明显变多", "severity": "soft", "to": 79}
]
}
四条告警覆盖四种风险:FRAGILE_STEP 指出脆弱的位置,CRITICAL_POINT 指出临界点本身,NARROW_TRANSITION 指出过渡带太窄(宽度只有 4,难度没有可调的中间档),AVG_HP_DROP 指出跨过临界点后平均剩余 HP 掉了 4 点——这是体感层面的证据,与胜率这个机制层面的量互相印证。
B 层基准:阈值本身也是可扰动参数
既然告警依赖人定的阈值,就该问「阈值换一个,结论翻盘吗」。sim/gd_mech_06_robustness_bench.py 复用 6.3 的 scan / classify / runs,把两条阈值线同时缩放 50% 到 200% 重跑分类:
#!/usr/bin/env python3
# file: sim/gd_mech_06_robustness_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_mech_06_robustness_bench.py
"""卷一 6.3 稳健性基准(B 层):把**分类阈值本身**当成可扰动参数。
告警清单依赖两个人的判断(stableStepPermille=100 / fragileStepPermille=200),
所以真正该问的是——阈值同时缩放 50% / 75% / 150% / 200% 时,脆弱区间会不会翻盘?
复用 sim/gd_mech_06_robustness.py 的 scan / classify / runs,不重写扫描逻辑。
**永远 exit 0**:B 档软告警不硬失败——翻盘只打印 SOFT,不抛异常、不返回非零。
"""
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_mech_06_robustness as rb # noqa: E402
def main():
t0 = time.perf_counter_ns()
with open(rb.CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
critical, table, _ = rb.scan(ashlib.load_spec(), cfg)
s0, f0 = cfg["classify"]["stableStepPermille"], cfg["classify"]["fragileStepPermille"]
rows = []
for pct in (50, 75, 100, 150, 200):
steps = rb.classify(table, s0 * pct // 100, f0 * pct // 100)
rows.append({"scale": pct, "stable": s0 * pct // 100, "fragileLine": f0 * pct // 100,
"robustSegments": len(rb.runs(steps, {"stable"})),
"fragile": [list(r) for r in rb.runs(steps, {"fragile"})]})
base = next(r["fragile"] for r in rows if r["scale"] == 100)
flipped = [r["scale"] for r in rows if r["fragile"] != base]
for r in rows:
print(f"scale={r['scale']:>3}% stable={r['stable']:>3}‰ fragile={r['fragileLine']:>3}‰ "
f"robustSegments={r['robustSegments']} fragileIntervals={r['fragile']} "
f"{'SAME' if r['fragile'] == base else 'FLIPPED'}")
seg = sorted({r["robustSegments"] for r in rows})
print(f"criticalPoint={critical} fragileIntervalStable={not flipped} flippedScales={flipped}")
if flipped:
print(f"SOFT WARN THRESHOLD_FLIP: 阈值缩放到 {flipped} 时脆弱区间改变,告警清单需人工复核")
else:
print(f"SOFT OK: 脆弱区间在 50%–200% 阈值扰动下不变;"
f"稳健区间段数在 {min(seg)}–{max(seg)} 之间浮动(阈值越低切得越碎)")
print(f"SOFT: hardFail=false(B 档软告警不硬失败) elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_06_robustness_bench.py:
scale= 50% stable= 50‰ fragile=100‰ robustSegments=4 fragileIntervals=[[78, 79]] SAME
scale= 75% stable= 75‰ fragile=150‰ robustSegments=2 fragileIntervals=[[78, 79]] SAME
scale=100% stable=100‰ fragile=200‰ robustSegments=2 fragileIntervals=[[78, 79]] SAME
scale=150% stable=150‰ fragile=300‰ robustSegments=2 fragileIntervals=[[78, 79]] SAME
scale=200% stable=200‰ fragile=400‰ robustSegments=2 fragileIntervals=[[78, 79]] SAME
criticalPoint=79 fragileIntervalStable=True flippedScales=[]
SOFT OK: 脆弱区间在 50%–200% 阈值扰动下不变;稳健区间段数在 2–4 之间浮动(阈值越低切得越碎)
SOFT: hardFail=false(B 档软告警不硬失败) elapsed=8 ms
结论分两半,都值得记下来:脆弱区间在五种缩放下全部不变([78, 79] 始终是那一段),但稳健区间的段数在 2 到 4 之间浮动——阈值压到 50‰ 时,原本被算作「稳健」的 -63‰ / -62‰ 两步掉进了过渡类,稳健区间从 2 段碎成 4 段。「哪一格危险」是稳的,「哪里绝对安全」是阈值相关的;软告警只报前一种,把后一种留给设计者自己拿主意。
自检表
| 检查项 | 合格标准 |
|---|---|
| 分类有判据 | 稳健 / 过渡 / 脆弱三条线都是整数千分比,不靠眼看 |
| 告警可读 | 每条告警带 message,直接说出「哪一格、差多少、为什么危险」 |
| 软告警不硬失败 | JSON 首部 hardFail: false,脚本永远 exit 0;阈值扰动只报 SOFT |
| 说法诚实 | 区分「危险位置稳定」与「安全区间阈值相关」,不把后者说成前者 |
参数扫过临界点,胜率一步到底——本节把它量成了脆弱区间。但「胜率突然从 875‰ 掉到 0‰」只是现象,不是原因。下一节换一个角度:把机制里的反馈回路拿出来,看正反馈与负反馈各自怎样塑造这条曲线。
阅读导航:上一节:6.2 相变扫描:游戏被解开的点 · 下一节:7.1 正反馈与负反馈 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。