6.3 稳健性与告警

把临界点附近的胜率逐格分类成稳健步与脆弱步,输出软告警清单:本节给出可跑的 S 层告警脚本、临界点邻域的胜率表、out/mech06_warnings.json 的告警条目,以及一个 B 层基准——把分类阈值本身缩放 50%~200%,看脆弱区间会不会翻盘。

6.3 稳健性与告警

《小游戏开发实战》卷一 7.2 难度曲线与心流 那篇的 B 层停在「敏感性」这一步:给出指标随参数的变化,再由设计者自己判断哪一段危险。本节把「判断」也做成可跑的产物:逐格分类 + 软告警清单。这里的分工是——6.1 给出斜率、6.2 给出临界点,6.3 回答设计者真正要问的那句「我把参数放在这里,安全吗」。

稳健 / 脆弱:把逐格步差分类

判据只有两条线,都是千分比整数,写在只读配置里:

类别判据含义
稳健步相邻网格点之间 |Δ胜率| ≤ 100‰参数动一格,胜率几乎不动
过渡步100‰ < |Δ胜率| < 200‰有变化但不到危险
脆弱步|Δ胜率| ≥ 200‰参数动一格,难度显著改变

稳健区间取连续稳健步的极大区间,脆弱区间取含脆弱步的极大区间。分类结果在临界点附近长这样(网格在临界点邻域加密到步长 1):

enemyHp胜率平均剩余 HPΔ胜率判定
74937‰440稳健
75937‰440稳健
76875‰43-62稳健
77875‰430稳健
78875‰430稳健
790‰39-875脆弱
800‰390稳健

表里只有一个脆弱步: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 正反馈与负反馈 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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