4.3 退化解与退化模式

退化 = 可达模式数不少、但实际打法全挤在一条主模式上。退化指数 = 1000 × 主模式出现次数 // 非空回合程序总数。本节对 64 个优先序策略算胜率与退化指数,按胜线/退化线分成退化解、健康解、退化模式、弱解四象限:退化解 22 个,最退化的 heavy>strike>guard 胜率 1000‰、退化 631‰;B 层证明退化线一动名单就变。

4.3 退化解与退化模式

上一节把「有多少种打法」变成了一个可算的数。但 平衡治理流程 的榜单真正要抓的不是数量,而是一种特定的坏:牌堆里明明有几十种合法组合,实战里所有人却只反复打同一条线。这条线就是退化解——它常常还很强(胜率拉满),所以光看胜率根本抓不住它。本节给「退化」一个可算的定义,并说明它比涌现度更容易被阈值左右。

退化:模式很多,但都在一条上

涌现度(4.2)量的是可达模式有多少种。退化量的是这些模式被用得多不均匀。两者不矛盾:一个游戏可以「可达 35 种模式」同时「实战 63% 的回合都在打同一种」。把每个策略在一局里打出的非空回合程序收集起来,数出出现次数最多的那一种(主模式)占总回合数的比例,就是退化指数:退化指数 = 1000 × 主模式出现次数 // 非空回合程序总数。

它和涌现度一样是代理指标,同样依赖口径——但比涌现度更脆,因为它还依赖一条人定的线(多高算退化)。本节把这条线的脆弱性也跑出来。

S 层:64 个优先序策略

sim/gd_play_04_degenerate.py 构造 64 个优先序策略:4 种卡(jab/strike/guard/heavy)按优先序排列(长度 1–4 的全排列共 4+12+24+24=64 个),每回合打第一张付得起的牌,都不行就结束回合。对每个策略跨 8 个种子聚合胜率与退化指数,再按胜线 500‰、退化线 500‰ 分成四象限。

#!/usr/bin/env python3
# file: sim/gd_play_04_degenerate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_degenerate.py
"""卷二 4.3 退化解与退化模式(S 层):把「退化」写成主模式占比。

策略族:64 个优先序策略——4 种卡按优先序排列(长度 1..4 的全排列 = 4+12+24+24),
每回合按优先序打第一张付得起的牌,都不行就结束回合。对每个策略跨种子聚合:
    winPermille     胜率千分比
    dominantShare‰  退化指数 = 1000 × 主模式出现次数 // 非空回合程序总数
    distinct        并集里出现过的不同回合程序数
四象限:胜 + 高退化 = **退化解**;胜 + 低退化 = 健康解;负 + 高退化 = **退化模式**;
负 + 低退化 = 弱解。产物 out/play04_degenerate.{json,csv,svg}。全整数、无浮点。
"""
from __future__ import annotations

import copy
import itertools
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_play_04_degenerate.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

IDS = ["jab", "strike", "guard", "heavy"]
FILL = {"退化解": "#ef4444", "健康解": "#22c55e", "退化模式": "#f59e0b", "弱解": "#475569"}


def run_priority(spec, priority, cost, cap=400):
    """按优先序贪心:每回合打第一张付得起的牌,否则结束回合。返回命令序列。"""
    seq = []
    for _ in range(cap):
        s = ashlib.run_spec(spec, seq)
        if s["over"]:
            break
        pick = None
        for cid in priority:
            for i, c in enumerate(s["hand"]):
                if c == cid and cost[cid] <= s["energy"]:
                    pick = i
                    break
            if pick is not None:
                break
        seq.append("p" + str(pick) if pick is not None else "e")
    return seq


def turn_programs(spec, seq):
    """只取**非空完整回合**的回合程序(排序元组),跳过结尾未结束的残回合。"""
    out, cur = [], []
    for k in range(len(seq)):
        if seq[k] == "e":
            if cur:
                out.append(tuple(sorted(cur)))
            cur = []
        else:
            s = ashlib.run_spec(spec, seq[:k])
            i = int(seq[k][1:])
            if 0 <= i < len(s["hand"]):
                cur.append(s["hand"][i])
    return out


def classify(win_pm, share_pm, win_line, share_line):
    win, deg = win_pm >= win_line, share_pm >= share_line
    if win and deg:
        return "退化解"
    if win:
        return "健康解"
    return "退化模式" if deg else "弱解"


def main():
    t0 = time.perf_counter_ns()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    seeds, win_line, share_line = cfg["seeds"], cfg["winLinePermille"], cfg["shareLinePermille"]
    spec = ashlib.load_spec()
    cost = {c["id"]: c["cost"] for c in spec["cards"]}

    rows, csv = [], []
    for prio in [p for r in range(1, 5) for p in itertools.permutations(IDS, r)]:
        wins = turns = dominant = 0
        distinct = set()
        for sd in seeds:
            s = copy.deepcopy(spec)
            s["seed"] = sd
            seq = run_priority(s, prio, cost)
            snap = ashlib.run_spec(s, seq)
            wins += 1 if snap["won"] else 0
            progs = turn_programs(s, seq)
            if not progs:
                continue
            counts = {}
            for p in progs:
                counts[p] = counts.get(p, 0) + 1
            turns += len(progs)
            dominant += max(counts.values())
            distinct |= set(progs)
        win_pm = 1000 * wins // len(seeds)
        share_pm = 1000 * dominant // turns if turns else 0
        row = {"priority": ">".join(prio), "r": len(prio), "winPermille": win_pm,
               "dominantSharePermille": share_pm, "distinct": len(distinct),
               "kind": classify(win_pm, share_pm, win_line, share_line)}
        rows.append(row)
        csv.append([row["priority"], row["r"], win_pm, share_pm, len(distinct), row["kind"]])

    rows.sort(key=lambda r: (-r["winPermille"], -r["dominantSharePermille"], r["priority"]))
    n = ashlib.write_csv(os.path.join(OUT, "play04_degenerate.csv"),
                         ["priority", "r", "winPermille", "dominantSharePermille", "distinct", "kind"], csv)
    counts = {k: sum(1 for r in rows if r["kind"] == k) for k in FILL}
    deg = [r for r in rows if r["kind"] == "退化解"]

    root = ashlib.svg_begin(720, 340)
    ashlib.svg_text(root, 20, 28, "64 个优先序策略:胜率 vs 退化指数", 15)
    ashlib.svg_text(root, 20, 48, "红=退化解  绿=健康解  橙=退化模式  灰=弱解", 11, "#94a3b8")
    for i, r in enumerate(rows):
        x, y = 24 + (i % 8) * 86, 70 + (i // 8) * 30
        ET.SubElement(root, "rect", {"x": str(x), "y": str(y), "width": "80", "height": "24",
                                     "fill": FILL[r["kind"]], "rx": "3"})
        ashlib.svg_text(root, x + 40, y + 16,
                        f"{r['winPermille'] // 10}%/{r['dominantSharePermille'] // 10}%", 10, "#0f172a", "middle")
    size = ashlib.svg_save(root, os.path.join(OUT, "play04_degenerate.svg"))
    report = {"id": "gd_play_04_degenerate", "strategies": len(rows), "seeds": len(seeds),
              "winLinePermille": win_line, "shareLinePermille": share_line, "counts": counts,
              "worstDegenerateSolution": deg[0] if deg else None, "rows": rows,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play04_degenerate.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(rows)} 个  种子 {len(seeds)}  胜线={win_line}‰  退化线={share_line}‰")
    print(f"{'优先序':<28}{'r':>2}{'胜率‰':>7}{'退化‰':>7}{'模式数':>7}  类别")
    for r in rows[:12]:
        print(f"{r['priority']:<28}{r['r']:>2}{r['winPermille']:>7}{r['dominantSharePermille']:>7}"
              f"{r['distinct']:>7}  {r['kind']}")
    print(f"象限计数: {counts}")
    print(f"最退化的解: {deg[0]['priority']}  胜率={deg[0]['winPermille']}‰  "
          f"退化={deg[0]['dominantSharePermille']}‰  模式={deg[0]['distinct']}")
    print(f"csv={n} 行  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play04_degenerate.json out/play04_degenerate.csv out/play04_degenerate.svg")
    ET.parse(os.path.join(OUT, "play04_degenerate.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_04_degenerate.py(前 6 行,完整 64 行见 out/play04_degenerate.csv):

策略族 64 个  种子 8  胜线=500‰  退化线=500‰
优先序                          r    胜率‰    退化‰    模式数  类别
heavy>strike>guard           3   1000    631      4  退化解
heavy>jab>strike             3   1000    625      5  退化解
heavy>jab>strike>guard       4   1000    625      5  退化解
heavy>strike>guard>jab       4   1000    625      5  退化解
heavy>strike>jab             3   1000    625      5  退化解
heavy>strike>jab>guard       4   1000    625      5  退化解
象限计数: {'退化解': 22, '健康解': 16, '退化模式': 10, '弱解': 16}
最退化的解: heavy>strike>guard  胜率=1000‰  退化=631‰  模式=4
csv=64 行  svg=13187 bytes  elapsed=138 ms
wrote out/play04_degenerate.json out/play04_degenerate.csv out/play04_degenerate.svg

判读:胜率满分的解,恰恰是最退化的

四象限计数是 退化解 22 / 健康解 16 / 退化模式 10 / 弱解 16。注意胜率 1000‰ 的策略大多落在「退化解」,其中最极端的 heavy>strike>guard 只打出了 4 种不同回合程序、63% 的回合都在重复主模式——它赢,但它让这局游戏只剩一条路。

象限胜率退化指数含义数量
退化解高高强但单调,最该警惕22
健康解高低强且多样,理想目标16
退化模式低高又弱又单调,设计失败10
弱解低低又弱但多样,尚可救16

这张表正是「涌现度会骗人」的证据:heavy>strike>guard 所在的牌池可达 35 种模式(4.2),可它实战只走 4 种。光报涌现度,会把这个最该修的解法说成「模式丰富」。退化指数补的就是这一刀。

B 层:退化线一动,名单就变

退化指数依赖一条人定的退化线。sim/gd_play_04_degenerate_bench.py 把这条线从 300‰ 扫到 700‰,看「被判为退化解的策略集合」会不会变。B 档软告警不硬失败,脚本永远 exit 0。

#!/usr/bin/env python3
# file: sim/gd_play_04_degenerate_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_degenerate_bench.py
"""卷二 4.3 退化检测基准(B 层):把「退化线」本身当成可扰动参数。

4.3 的「退化解」判定依赖一条人定的线(主模式占比 ≥ 500‰)。该问的是:**线换一个,
被判退化的策略集合会不会翻盘**——若翻盘,说明「哪些策略退化」是阈值相关的,
告警清单须人工复核。复用 sim/gd_play_04_degenerate.py 的 run_priority / turn_programs /
classify。**永远 exit 0**:B 档软告警不硬失败——翻盘只打印 SOFT,不抛异常。
"""
from __future__ import annotations

import copy
import itertools
import json
import os
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_04_degenerate as dg  # noqa: E402


def main() -> int:
    t0 = time.perf_counter_ns()
    with open(dg.CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    seeds, win_line = cfg["seeds"], cfg["winLinePermille"]
    spec = ashlib.load_spec()
    cost = {c["id"]: c["cost"] for c in spec["cards"]}

    # 先把每个策略的 (胜率, 退化指数) 算一次,阈值扫描只是重新分类,不重跑仿真
    stats = []
    for prio in [p for r in range(1, 5) for p in itertools.permutations(dg.IDS, r)]:
        wins = turns = dominant = 0
        for sd in seeds:
            s = copy.deepcopy(spec)
            s["seed"] = sd
            seq = dg.run_priority(s, prio, cost)
            snap = ashlib.run_spec(s, seq)
            wins += 1 if snap["won"] else 0
            progs = dg.turn_programs(s, seq)
            if not progs:
                continue
            counts = {}
            for p in progs:
                counts[p] = counts.get(p, 0) + 1
            turns += len(progs)
            dominant += max(counts.values())
        stats.append({"priority": ">".join(prio), "win": 1000 * wins // len(seeds),
                      "share": 1000 * dominant // turns if turns else 0})

    rows = []
    for line in cfg["perturb"]["shareLinePermille"]:
        kinds = [dg.classify(s["win"], s["share"], win_line, line) for s in stats]
        counts = {k: kinds.count(k) for k in ("退化解", "健康解", "退化模式", "弱解")}
        deg = sorted(s["priority"] for s, k in zip(stats, kinds) if k == "退化解")
        rows.append({"shareLine": line, "counts": counts, "degenerateSolutions": deg})

    base = next(r for r in rows if r["shareLine"] == cfg["shareLinePermille"])
    flipped = [r["shareLine"] for r in rows if r["degenerateSolutions"] != base["degenerateSolutions"]]
    for r in rows:
        flag = "  <-- 基准" if r["shareLine"] == cfg["shareLinePermille"] else ""
        print(f"退化线={r['shareLine']}‰  {r['counts']}  退化解数={len(r['degenerateSolutions'])}{flag}")
    print(f"基准退化解集合稳定={not flipped}  flippedLines={flipped}")
    if flipped:
        print(f"SOFT WARN THRESHOLD_FLIP: 退化线换到 {flipped} 时被判退化的策略集合改变,"
              f"告警清单须人工复核")
    else:
        print("SOFT OK: 退化线在 300‰–700‰ 之间变动时,被判退化的策略集合不变")
    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_play_04_degenerate_bench.py:

退化线=300‰  {'退化解': 38, '健康解': 0, '退化模式': 26, '弱解': 0}  退化解数=38
退化线=400‰  {'退化解': 36, '健康解': 2, '退化模式': 20, '弱解': 6}  退化解数=36
退化线=500‰  {'退化解': 22, '健康解': 16, '退化模式': 10, '弱解': 16}  退化解数=22  <-- 基准
退化线=600‰  {'退化解': 8, '健康解': 30, '退化模式': 7, '弱解': 19}  退化解数=8
退化线=700‰  {'退化解': 0, '健康解': 38, '退化模式': 6, '弱解': 20}  退化解数=0
基准退化解集合稳定=False  flippedLines=[300, 400, 600, 700]
SOFT WARN THRESHOLD_FLIP: 退化线换到 [300, 400, 600, 700] 时被判退化的策略集合改变,告警清单须人工复核
SOFT: hardFail=false(B 档软告警不硬失败)  elapsed=121 ms

结论很直接:退化名单对阈值极度敏感。线从 500‰ 挪到 400‰,退化解就从 22 个涨到 36 个;挪到 700‰,一个都不剩。这跟 4.2 的排序稳定形成鲜明对比——涌现度的名次换粒度不变,退化的名单换阈值就崩。所以退化检测的正确用法是:只拿它排优先级、提示人工复核,绝不拿它当自动判罚的依据。

自检表

检查项合格标准
退化有定义退化指数 = 主模式次数 // 非空回合程序总数,写清分母
与涌现度划界明确「可达模式多」不等于「实战模式多」,给出反例
阈值敏感显式B 层证明换线名单就变,结论是「排优先级而非自动判罚」
软告警不硬失败两个 B 块都 return 0,翻盘只打印 SOFT,不抛异常

本章三节合起来给了一套可算的口径:规则 → 可达模式(4.1)→ 度量它(4.2)→ 抓退化解(4.3)。下一章换到「平衡」本身,先把它拆成定义与层次。

阅读导航:上一节:4.2 涌现的度量 · 下一节:5.1 平衡的定义与层次 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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