7.3 循环的稳定性

渐近稳定只由增益决定:G<1000‰ 收敛、=1000‰ 临界、>1000‰ 发散,与延迟无关。但游戏只有有限回合——本机按 play_spec 的 turnLimit=20 扫 (G, d) 平面:有限时域稳定域从 d=0 的 G≤950‰ 收缩到 d=4 的 G≤850‰,B 层再把设计点放进 ±10% 增益扰动网格,失稳只发软告警。

7.3 循环的稳定性

6.3 的稳健性是别的参数(enemyHp)动一格、胜率动多少;本节问的是回路自己稳不稳。《小游戏开发实战》卷一 6.2 与 独立游戏平衡与经济设计 都不给这条判据——前者量时长,后者讲概念;本节把它写成 (增益, 延迟) 平面上的稳定域。

两张判据:渐近的与有限时域的

7.1 给的判据是渐近的:只要 G < 1000‰,跑够多回合总会收敛;G > 1000‰ 总会发散。7.2 又证明了延迟不改变这个判据。那稳定性还有什么可算的?答案是游戏不给无限回合——play_spec.params.turnLimit = 20,20 回合内没稳下来,和发散在体感上没有区别。于是有两张表:

判据只看什么结论
渐近稳定增益 G 与 1000‰G < 1000 收敛、= 1000 临界、> 1000 发散(与 d 无关)
有限时域稳定增益 G 与 延迟 d20 回合内偏离衰减到一半,才叫稳

第二张表才是设计者要的:它不是「会不会散」,而是「这局之内来不来得及」。

S 层:扫 (G, d) 平面

#!/usr/bin/env python3
# file: sim/gd_mech_07_stability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_07_stability.py
"""卷一 7.3 循环的稳定性(S 层):在 (增益 G, 延迟 d) 平面上扫稳定域。

渐近判据还是那一条:G < 1000 收敛 / G = 1000 临界 / G > 1000 发散——**延迟不改变它**。
但游戏只有有限回合:play_spec 的 params.turnLimit = 20。于是真正要问的是
「**在 20 回合内**,偏离能不能衰减到 e0 的四分之一」。

本脚本把两张表都跑出来:渐近判据(与 d 无关)与有限时域判据(与 d 强相关)。
产出 out/mech07_stability.csv、out/mech07_stability.svg、out/mech07_stability.json。
不用浮点、不用 random 模块。
"""
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_07_feedback.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
from gd_mech_07_feedback import run_loop, trunc_div, fixed_band, band_dist  # noqa: E402

CSV_PATH = os.path.join(OUT, "mech07_stability.csv")
SVG_PATH = os.path.join(OUT, "mech07_stability.svg")
JSON_PATH = os.path.join(OUT, "mech07_stability.json")


def settle(xs, band, e0, num, den):
    """偏离衰减到 e0*num/den 之后再没反弹的回合数。0 表示一开始就在带内。"""
    thr = e0 * num // den
    last = -1
    for t, x in enumerate(xs):
        if band_dist(x, band) > thr:
            last = t
    return last + 1


def asymptotic(gain):
    return "unstable" if gain > 1000 else ("critical" if gain == 1000 else "stable")


def cell(gain, delay, c, x0, turns, num, den):
    xs = run_loop(gain, c, turns, x0, delay=delay)
    band = fixed_band(gain, c)
    e0 = band_dist(x0, band)
    st = settle(xs, band, e0, num, den) if band else None
    kind = asymptotic(gain)
    horizon = "pass" if (kind == "stable" and st is not None and st <= turns) else (
        "na" if kind != "stable" else "miss")
    return {"gain": gain, "delay": delay, "band": [band[0], band[-1]] if band else None,
            "e0": e0, "settle": st, "asymptotic": kind, "horizon": horizon, "xEnd": xs[-1]}


def draw(cells, gains, delays, turns, path):
    """稳定域热力图:绿 = 20 回合内稳定,红 = 未达,灰 = 渐近发散。"""
    W, H = 660, 320
    cw, ch = 52, 44
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 70, 24, f"有限时域稳定域:{turns} 回合内偏离衰减到 1/4", 15)
    for j, d in enumerate(delays):
        ashlib.svg_text(root, 60, 70 + j * ch + 28, f"d={d}", 12, "#94a3b8", "end")
    for i, g in enumerate(gains):
        ashlib.svg_text(root, 78 + i * cw + cw // 2, 56, g, 11, "#94a3b8", "middle")
    for cl in cells:
        i, j = gains.index(cl["gain"]), delays.index(cl["delay"])
        fill = {"pass": "#22c55e", "miss": "#ef4444", "na": "#475569"}[cl["horizon"]]
        ET.SubElement(root, "rect", {"x": str(78 + i * cw), "y": str(70 + j * ch),
                                     "width": str(cw - 6), "height": str(ch - 6),
                                     "fill": fill, "data-settle": str(cl["settle"])})
        if cl["settle"] is not None:
            ashlib.svg_text(root, 78 + i * cw + (cw - 6) // 2, 70 + j * ch + 26,
                            cl["settle"], 11, "#0f172a", "middle")
    ashlib.svg_text(root, 78, 300, "横轴 = 回路增益 G(千分比);纵轴 = 反馈延迟 d(回合);格内数字 = 稳定所需回合", 11)
    return ashlib.svg_save(root, path)


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    c = spec["params"]["rewardGold"]
    st = cfg["stability"]
    gains, delays = st["gains"], st["delays"]
    turns = spec["params"]["turnLimit"]  # 20:有限时域锚定规格,不是随手取的
    num, den = st["shrinkNum"], st["shrinkDen"]
    cells = [cell(g, d, c, cfg["x0"], turns, num, den) for d in delays for g in gains]
    ashlib.write_csv(CSV_PATH, ["gain", "delay", "bandLo", "bandHi", "e0", "settle",
                                "asymptotic", "horizon", "xEnd"],
                     [[cl["gain"], cl["delay"], cl["band"][0] if cl["band"] else "",
                       cl["band"][1] if cl["band"] else "", cl["e0"], cl["settle"],
                       cl["asymptotic"], cl["horizon"], cl["xEnd"]] for cl in cells])
    size = draw(cells, gains, delays, turns, SVG_PATH)
    with open(JSON_PATH, "w", encoding="utf-8") as f:
        json.dump({"id": "gd_mech_07_stability", "tier": "S", "injection": c, "x0": cfg["x0"],
                   "horizonTurns": turns, "shrink": [num, den], "cells": cells},
                  f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    for d in delays:
        row = [cl for cl in cells if cl["delay"] == d]
        print(f"d={d}  " + "  ".join(f"G={cl['gain']}:{cl['horizon']}"
                                     f"({cl['settle'] if cl['settle'] is not None else '-'})" for cl in row))
    passed = [cl for cl in cells if cl["horizon"] == "pass"]
    print(f"horizon={turns} turns (play_spec.params.turnLimit)  shrink={num}/{den}  "
          f"pass={len(passed)}/{len(cells)}  injection c={c}  x0={cfg['x0']}")
    for d in delays:
        gs = sorted(cl["gain"] for cl in cells if cl["delay"] == d and cl["horizon"] == "pass")
        print(f"d={d}  有限时域稳定域 G <= {max(gs) if gs else 'none'}")
    print(f"svg={size} bytes  elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
    print("wrote out/mech07_stability.csv out/mech07_stability.svg out/mech07_stability.json")
    ET.parse(SVG_PATH)
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_07_stability.py(pass(n) = n 回合稳定):

d=0  G=700:pass(2)  G=800:pass(3)  G=850:pass(4)  G=900:pass(5)  G=930:pass(8)  G=950:pass(12)  G=970:miss(21)  G=990:miss(21)  G=1000:na(-)  G=1050:na(-)
d=1  G=700:pass(3)  G=800:pass(5)  G=850:pass(7)  G=900:pass(9)  G=930:pass(15)  G=950:miss(21)  G=970:miss(21)  G=990:miss(21)  G=1000:na(-)  G=1050:na(-)
d=2  G=700:pass(4)  G=800:pass(7)  G=850:pass(10)  G=900:pass(13)  G=930:miss(21)  G=950:miss(21)  G=970:miss(21)  G=990:miss(21)  G=1000:na(-)  G=1050:na(-)
d=4  G=700:pass(6)  G=800:pass(11)  G=850:pass(16)  G=900:miss(21)  G=930:miss(21)  G=950:miss(21)  G=970:miss(21)  G=990:miss(21)  G=1000:na(-)  G=1050:na(-)
horizon=20 turns (play_spec.params.turnLimit)  shrink=1/2  pass=18/40  injection c=12  x0=100
d=0  有限时域稳定域 G <= 950
d=1  有限时域稳定域 G <= 930
d=2  有限时域稳定域 G <= 900
d=4  有限时域稳定域 G <= 850
svg=9401 bytes  elapsed=14 ms
wrote out/mech07_stability.csv out/mech07_stability.svg out/mech07_stability.json

四行数字把两件事分得很干净:渐近判据与延迟无关(每一行的 G=1000 / G=1050 都标 na,G>1000 一律发散,不管 d 是几),有限时域判据与延迟强相关(稳定边界从 d=0 的 G≤950‰ 收缩到 d=1 的 ≤930‰、d=2 的 ≤900‰、d=4 的 ≤850‰)。延迟没有让任何一条回路从「收敛」变成「发散」,它只是把收敛推到了 20 回合之外。

d有限时域稳定域读法
0G ≤ 950‰无延迟时还能吃下 950,离临界只剩 50‰
1G ≤ 930‰多一回合延迟,边界就掉 20‰
2G ≤ 900‰边界回到 7.1 那个收敛档
4G ≤ 850‰四回合延迟把余量吃掉一半

B 层:把设计点放进扰动网格

既然稳定域是拿一个设计点定的,就该问「标定错了 ±5% 还稳吗」。取 G=930‰、d=1 作设计点,把增益缩放 90%~110% 重跑:

#!/usr/bin/env python3
# file: sim/gd_mech_07_gain_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_mech_07_gain_bench.py
"""卷一 7.3 回路稳健性基准(B 层):把**设计点**放进扰动网格。

7.3 的稳定域是拿一个设计点(G=930‰、d=1)定的。真正该问的是:增益标定错了 ±5%/±10%、
或者延迟因为一次网络/动画改动多了一回合,这个设计点还稳吗?

复用 sim/gd_mech_07_stability.py 的 cell / draw 判据,不重写扫描逻辑。
**永远 exit 0**:B 档软告警不硬失败——失稳只打印 SOFT,不抛异常、不返回非零。
产出 out/mech07_gain_warnings.json。
"""
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")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_mech_07_stability as stab  # noqa: E402

JSON_PATH = os.path.join(OUT, "mech07_gain_warnings.json")
BASE_GAIN, BASE_DELAY = 930, 1


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(stab.CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    c = spec["params"]["rewardGold"]
    turns = spec["params"]["turnLimit"]
    num, den = cfg["stability"]["shrinkNum"], cfg["stability"]["shrinkDen"]
    scales = cfg["perturb"]["gainScales"]
    delays = cfg["perturb"]["delays"]
    cells = []
    for sc in scales:
        gain = BASE_GAIN * sc // 100
        for d in delays:
            cl = stab.cell(gain, d, c, cfg["x0"], turns, num, den)
            cl["scale"] = sc
            cells.append(cl)
    base = next(cl for cl in cells if cl["scale"] == 100 and cl["delay"] == BASE_DELAY)
    lost = [cl for cl in cells if cl["horizon"] != "pass"]
    by_delay = {}
    for d in delays:
        row = [cl for cl in cells if cl["delay"] == d]
        by_delay[d] = {"pass": sum(1 for cl in row if cl["horizon"] == "pass"), "total": len(row)}
    warnings = [{
        "code": "DESIGN_POINT_LOST", "severity": "soft",
        "gain": cl["gain"], "delay": cl["delay"], "scale": cl["scale"],
        "horizon": cl["horizon"],
        "message": f"增益扰动到 {cl['scale']}%(G={cl['gain']}‰)、延迟 {cl['delay']} 回合时,"
                   f"设计点在 {turns} 回合内不再稳定({cl['horizon']})",
    } for cl in lost]
    base_lost = base["horizon"] != "pass"
    margin = turns - base["settle"] if base["settle"] is not None else 0
    warnings.append({
        "code": "BASELINE_MARGIN", "severity": "soft",
        "settle": base["settle"], "horizon": turns, "margin": margin,
        "message": f"基准设计点 G={BASE_GAIN}‰ d={BASE_DELAY} 需 {base['settle']} 回合稳定,"
                   f"距 {turns} 回合的上限只剩 {margin} 回合余量",
    })
    payload = {
        "id": "gd_mech_07_gain_warnings", "tier": "B", "hardFail": False,
        "note": "B 档软告警:只提示风险,不使任何构建失败",
        "baseGain": BASE_GAIN, "baseDelay": BASE_DELAY, "horizonTurns": turns,
        "baseStable": not base_lost, "stableCells": len(cells) - len(lost), "totalCells": len(cells),
        "byDelay": {str(k): v for k, v in by_delay.items()},
        "warnings": warnings,
    }
    with open(JSON_PATH, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    for sc in scales:
        row = [cl for cl in cells if cl["scale"] == sc]
        npass = sum(1 for cl in row if cl["horizon"] == "pass")
        print(f"scale={sc:>3}%  G={row[0]['gain']:>4}‰  pass={npass}/{len(row)}  "
              f"settle={[cl['settle'] if cl['settle'] is not None else '-' for cl in row]}")
    for w in warnings:
        print(f"SOFT WARN {w['code']}: {w['message']}")
    print(f"baseStable={not base_lost}  stableCells={len(cells) - len(lost)}/{len(cells)}  "
          f"byDelay={by_delay}  hardFail=false")
    print(f"SOFT: hardFail=false(B 档软告警不硬失败)  elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
    print("wrote out/mech07_gain_warnings.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_07_gain_bench.py(告警段截前四条):

scale= 90%  G= 837‰  pass=4/4  settle=[4, 7, 10, 16]
scale= 95%  G= 883‰  pass=4/4  settle=[0, 0, 0, 0]
scale=100%  G= 930‰  pass=2/4  settle=[8, 15, 21, 21]
scale=105%  G= 976‰  pass=0/4  settle=[21, 21, 21, 21]
scale=110%  G=1023‰  pass=0/4  settle=['-', '-', '-', '-']
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 100%(G=930‰)、延迟 4 回合时,设计点在 20 回合内不再稳定(miss)
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 105%(G=976‰)、延迟 0 回合时,设计点在 20 回合内不再稳定(miss)
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 105%(G=976‰)、延迟 1 回合时,设计点在 20 回合内不再稳定(miss)
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 105%(G=976‰)、延迟 2 回合时,设计点在 20 回合内不再稳定(miss)
baseStable=True  stableCells=10/20  byDelay={0: {'pass': 3, 'total': 5}, 1: {'pass': 3, 'total': 5}, 2: {'pass': 2, 'total': 5}, 4: {'pass': 2, 'total': 5}}  hardFail=false
SOFT: hardFail=false(B 档软告警不硬失败)  elapsed=5 ms
wrote out/mech07_gain_warnings.json

结果分三档,都值得记下来:增益往下偏是安全的(90% 时四档延迟全部稳定);往上偏一格就危险(105% 时四档全灭,因为 G=976‰ 离 1000‰ 只剩 24‰);110% 直接跨过临界(G=1023‰ 标 na,渐近发散,再怎么跑也不会稳)。而基准点本身只剩 5 回合余量——BASELINE_MARGIN 那条软告警说的就是这件事。

三条纪律在这节落定:判据分两层(渐近的看 G,有限时域的看 G 与 d);延迟侵蚀的是余量而不是稳定域;告警只报风险、不阻断构建(hardFail: false,脚本永远 exit 0)。

检查项合格标准
判据分层渐近稳定与有限时域稳定分开写,不混成一句
延迟归因正确说「延迟推后收敛」,不说「延迟导致发散」
稳定域有边界每个 d 给出 G 的上界,不是只给一张热力图
扰动不硬失败失稳只写 severity: soft,脚本 exit 0

第 7 章把回路从「滚雪球」这个词变成了一条递推、一个增益、一个延迟和一张稳定域。但回路只说了「领先会不会滚大」,没说玩家什么时候拿到奖励、多久给一次——那是下一章的事:奖励节奏谱。

阅读导航:上一节:7.2 反馈延迟与回路增益 · 下一节:8.1 奖励节奏谱 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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