5.1 平衡的定义与层次

平衡不是一个数,是四个层次:卡层看有没有被支配的卡,策略层看胜率带宽,反制层看有没有互相克制,参数层看判定会不会随采样翻转。本节把四层各写成一个可算的整数,用《灰烬牌库》六条打法乘八组遭遇跑出胜率矩阵,结果卡层说平衡、策略层说不平衡、反制层说无反制——同一个机制,四层各说各话。

站内经济平衡的概念与示意代码 把「平衡」当成一个经济概念来讲:产出与消耗要匹配、通胀要压住,配的是示意性数字,判不了「这套牌局平衡不平衡」。本节换一条更窄也更硬的口径:把平衡拆成四个层次,每层给一个能算出来的整数,再用《灰烬牌库》的策略胜率矩阵逐层判一遍。这是 MDA 里 D 层(动态)的性质——它回答的不是「这样玩爽不爽」,而是「换个打法,胜率会不会塌」。

5.1 平衡的定义与层次

玩家嘴里的「平衡」其实是四件不同的事混在一起说的。有人说「这张卡太强了」(卡与卡),有人说「无脑莽就赢」(打法与打法),有人说「没有克制关系」(结构),还有人说「上次不是这么算的」(结论稳不稳)。要判定,就得先把这四件事分开,各配一个能算的量。

层次算的量平衡的含义
L1 卡层被支配卡数 + 卡效率差没有一张卡被另一张全面压住
L2 策略层胜率带宽 band最强与最弱打法的胜率差距不大
L3 反制层互有胜负对数 / 反制循环数存在能互相克制的打法(石头剪子布)
L4 参数层平移后带宽是否跨线判定不随采样抖动

这里的量全部来自仿真胜率,是代理指标:它只说明「在这套策略族与遭遇集上,谁比谁赢得多」,不等于玩家感受,也不允许写成「平衡度提升 N 倍」。策略族是六条确定性打法规则(最大伤害优先、格挡优先、最小费用优先、偶数回合蓄能、固定打第一张、固定打最后一张),遭遇集是八组整数参数;改口径只改配置,不动规则。

{ "seeds": [401, "…", 416], "seedShift": 100,
  "policies": [ {"id": "greedy", "rule": "max_damage"}, "…", {"id": "last", "rule": "last_slot"} ],
  "encounters": [ {"id": "e0", "enemyHp": 50, "turnLimit": 3, "enemyAttack": 6}, "…" ],
  "thresholds": { "bandBalanced": 200, "viableTol": 200 } }

S 层:把四个层次算出来

脚本建 6×8 的胜率矩阵(行是打法,列是遭遇),再逐层读数:卡层做「更便宜且更强」的两两检查,策略层取行均值的极差,反制层数互有胜负对与非传递三元组,参数层把种子整体平移 100 再算一遍带宽。全整数,无浮点,无 random。

#!/usr/bin/env python3
# file: sim/gd_play_05_layers.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_layers.py
"""卷二 5.1 平衡的定义与层次(S 层):把「平衡」拆成四层,各层给一个可算的量。

L1 卡层(被支配卡数 + 卡效率差)· L2 策略层(胜率带宽 band)· L3 反制层(互有胜负对数
mutual + 反制循环数 cycles)· L4 参数层(种子整体平移后 L2 判定是否翻转)。策略集是六条
确定性打法规则,遭遇集是八组整数参数;只换这两组,不改 play_spec.json 的规则。产出
out/play05_layers.json + out/play05_layers.svg。胜率是**代理指标**,只做相对比较。
"""
import copy, json, os, sys, 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_05_layers.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
KEYS = ("enemyHp", "turnLimit", "enemyAttack")


def choose(rule, s, cost, dmg, blk, ea):
    """确定性策略规则:读当前快照,返回下一步命令(pK 出第 K 张,e 结束回合)。"""
    hand, en = s["hand"], s["energy"]
    aff = [i for i, c in enumerate(hand) if cost[c] <= en]
    if not aff:
        return "e"
    if rule == "max_damage":
        i = max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))
    elif rule == "block_first":
        g = [i for i in aff if blk[hand[i]] > 0]
        i = g[0] if g and s["block"] < ea else max(aff, key=lambda i: dmg[hand[i]])
    elif rule == "min_cost":
        i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
    elif rule == "even_turn_big":
        big = [i for i in aff if cost[hand[i]] >= 2]
        if s["turn"] % 2 == 0:
            return f"p{big[0]}" if big else "e"
        i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
    elif rule == "first_slot":
        i = aff[0]
    else:
        i = aff[-1]
    return f"p{i}"


def matrix(spec, cfg, seeds):
    """W[policy][encounter] = 胜场千分比(整数地板除)。"""
    cost = {c["id"]: c["cost"] for c in spec["cards"]}
    dmg = {c["id"]: c["damage"] for c in spec["cards"]}
    blk = {c["id"]: c["block"] for c in spec["cards"]}
    W = {}
    for pol in cfg["policies"]:
        row = []
        for enc in cfg["encounters"]:
            wins = 0
            for sd in seeds:
                s = copy.deepcopy(spec)
                s["seed"] = sd
                s["params"].update({k: enc[k] for k in KEYS})
                seq = []
                for _ in range(300):
                    sn = ashlib.run_spec(s, seq)
                    if sn["over"]:
                        break
                    seq.append(choose(pol["rule"], sn, cost, dmg, blk, enc["enemyAttack"]))
                wins += 1 if ashlib.run_spec(s, seq)["won"] else 0
            row.append(1000 * wins // len(seeds))
        W[pol["id"]] = row
    return W


def counter_stats(W, ids):
    """互有胜负对数 mutual + 非传递三元组数 cycles(反制循环)。"""
    wins = lambda a, b: sum(1 for k in range(len(W[a])) if W[a][k] > W[b][k])
    mutual = sum(1 for i in range(len(ids)) for j in range(i + 1, len(ids))
                 if wins(ids[i], ids[j]) and wins(ids[j], ids[i]))
    cycles = set()
    for a in ids:
        for b in ids:
            for c in ids:
                if len({a, b, c}) == 3 and wins(a, b) > wins(b, a) and wins(b, c) > wins(c, b) \
                   and wins(c, a) > wins(a, c):
                    cycles.add(tuple(sorted((a, b, c))))
    return mutual, len(cycles)


def draw(layers, path):
    root = ashlib.svg_begin(640, 66 + 40 * len(layers))
    ashlib.svg_text(root, 20, 30, "《灰烬牌库》平衡的四个层次", 15)
    ashlib.svg_text(root, 20, 50, "胜率与带宽均为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
    for i, (name, value, verdict, ok) in enumerate(layers):
        y, col = 66 + i * 40, "#22c55e" if ok else "#ef4444"
        ET.SubElement(root, "rect", {"x": "20", "y": str(y), "width": "600", "height": "30",
                                     "fill": "#1e293b", "rx": "6"})
        ET.SubElement(root, "rect", {"x": "20", "y": str(y), "width": "6", "height": "30",
                                     "fill": col, "rx": "3"})
        ashlib.svg_text(root, 40, y + 20, name, 13)
        ashlib.svg_text(root, 250, y + 20, value, 12, "#cbd5e1")
        ashlib.svg_text(root, 600, y + 20, verdict, 12, col, "end")
    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)
    ids, seeds, thr = [p["id"] for p in cfg["policies"]], cfg["seeds"], cfg["thresholds"]
    W = matrix(spec, cfg, seeds)
    means = {k: sum(v) // len(v) for k, v in W.items()}
    band = max(means.values()) - min(means.values())
    dom = [(a["id"], b["id"]) for b in spec["cards"] for a in spec["cards"] if a is not b
           and a["cost"] <= b["cost"] and a["damage"] >= b["damage"] and a["block"] >= b["block"]
           and (a["cost"] < b["cost"] or a["damage"] > b["damage"] or a["block"] > b["block"])]
    eff = [c["damage"] * 100 // c["cost"] for c in spec["cards"] if c["cost"] > 0 and c["damage"] > 0]
    mutual, cycles = counter_stats(W, ids)
    m2 = {k: sum(v) // len(v) for k, v in matrix(spec, cfg, [s + cfg["seedShift"] for s in seeds]).items()}
    band2 = max(m2.values()) - min(m2.values())
    bal, bal2 = band <= thr["bandBalanced"], band2 <= thr["bandBalanced"]
    layers = [("L1 卡层:被支配卡", f"{len(dom)} 张,效率差 {max(eff) - min(eff)}‰", "平衡" if not dom else "不平衡", not dom),
              ("L2 策略层:胜率带宽", f"{band}‰(阈值 {thr['bandBalanced']}‰)", "平衡" if bal else "不平衡", bal),
              ("L3 反制层:反制循环", f"互有胜负 {mutual} 对,循环 {cycles} 个", "有反制" if cycles else "无反制", cycles >= 1),
              ("L4 参数层:采样稳健", f"平移后带宽 {band2}‰", "稳健" if bal == bal2 else "翻转", bal == bal2)]
    size = draw(layers, os.path.join(OUT, "play05_layers.svg"))
    report = {"id": "gd_play_05_layers", "seeds": len(seeds), "policies": ids, "thresholds": thr,
              "matrix": W, "rowMeans": means, "band": band, "bandShifted": band2,
              "dominatedCards": dom, "cardEffSpread": max(eff) - min(eff), "mutualPairs": mutual,
              "cycles": cycles, "layers": [{"layer": a, "value": b, "verdict": c, "ok": d}
                                           for a, b, c, d in layers],
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play05_layers.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print("策略\\遭遇  " + " ".join(f"{e['id']:>5}" for e in cfg["encounters"]) + "    均值")
    for p in ids:
        print(f"{p:9}" + " ".join(f"{v:5d}" for v in W[p]) + f"  {means[p]:5d}")
    print(f"band={band}‰  bandShifted={band2}‰  mutual={mutual}  cycles={cycles}")
    for a, b, c, d in layers:
        print(f"  {a:22} {b:26} {c}")
    print(f"svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play05_layers.svg out/play05_layers.json")
    ET.parse(os.path.join(OUT, "play05_layers.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_05_layers.py:

策略\遭遇     e0    e1    e2    e3    e4    e5    e6    e7    均值
greedy    1000   687  1000  1000   375   375   687     0    640
tank       875   125  1000  1000  1000  1000   750   625    796
thrifty    500     0     0   687     0     0   625     0    226
mixed      125     0     0   625     0     0     0     0     93
first      875     0   562  1000   562   562   500   250    538
last       750   250   562   937   312   312   812   187    515
band=703‰  bandShifted=773‰  mutual=6  cycles=0
  L1 卡层:被支配卡             0 张,效率差 100‰               平衡
  L2 策略层:胜率带宽            703‰(阈值 200‰)              不平衡
  L3 反制层:反制循环            互有胜负 6 对,循环 0 个            无反制
  L4 参数层:采样稳健            平移后带宽 773‰                 稳健
svg=2742 bytes  elapsed=170 ms
wrote out/play05_layers.svg out/play05_layers.json

判读:四层各说各话

同一个机制,四层给出的判决并不一致:

层次实跑值判定读法
L1 卡层被支配卡 0 张,效率差 100‰平衡没有一张卡被另一张全面压住
L2 策略层带宽 703‰(线在 200‰)不平衡tank 796‰ 对 mixed 93‰,差得太远
L3 反制层互有胜负 6 对,循环 0 个无反制互有胜负但可传递,没有石头剪子布
L4 参数层平移后带宽 773‰稳健换一组种子,L2 仍判不平衡

为什么会打架?因为「平衡」在这四层说的是四件不同的事:L1 说的是卡与卡的关系,L2 说的是打法与打法的关系,L3 说的是克制结构,L4 说的是结论稳不稳。一套机制完全可能「卡很平衡、打法很不平衡」——四张卡没有强弱之分(strike 的伤害/费用是 6、heavy 是 7,效率差 100‰),可六种出牌顺序的胜率天差地别。把结论绑到某一层上,就会漏掉另外三层。

B 层:层次判定的阈值与采样依赖

L2 的判定绑在一条阈值线上(带宽 ≤ 200‰ 算平衡),L3 的判定绑在遭遇集上。换个阈值、换个种子,结论可能翻。B 层把这些依赖显式摆出来,按纪律软告警不硬失败。

#!/usr/bin/env python3
# file: sim/gd_play_05_layer_robustness.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_layer_robustness.py
"""卷二 5.1 层次判定的稳健性(B 层):**永远 exit 0**,只提示不使构建失败。

读 out/play05_layers.json(5.1 S 层产物),只做三件事:① 把「带宽 ≤ 多少算平衡」这条线
从 100 扫到 900‰,找 L2 判定的翻转点;② 看种子平移后的带宽是否跨过这条线;③ 反制循环为 0
时提醒「有反制」依赖遭遇集选法。每条含 severity=soft 与 hardFail=false。
产出 out/play05_layers_robustness.json。
"""
import json
import os
import sys

OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "out")


def main():
    with open(os.path.join(OUT, "play05_layers.json"), encoding="utf-8") as f:
        rep = json.load(f)
    band, band2, thr = rep["band"], rep["bandShifted"], rep["thresholds"]["bandBalanced"]
    warn = []
    flip = next((t for t in range(100, 901, 50) if band <= t), None)
    if flip is not None:
        warn.append({"code": "THRESHOLD_FLIP", "severity": "soft", "band": band, "flipAt": flip,
                     "configured": thr,
                     "message": f"带宽 {band}‰ 只要「平衡线」放到 {flip}‰ 及以上,L2 就从"
                                f"「不平衡」翻成「平衡」——层判定绑定在阈值上,当前用的是 {thr}‰"})
    if (band <= thr) != (band2 <= thr):
        warn.append({"code": "SAMPLING_FLIP", "severity": "soft", "band": band, "bandShifted": band2,
                     "message": f"种子平移把带宽从 {band}‰ 推到 {band2}‰,跨过 {thr}‰ 这条线,"
                                f"L2 判定随采样改变"})
    else:
        warn.append({"code": "SAMPLING_STABLE", "severity": "soft", "band": band, "bandShifted": band2,
                     "message": f"种子平移后带宽 {band}‰→{band2}‰,未跨线:L2 判定对本次采样稳健"})
    if rep["cycles"] == 0:
        warn.append({"code": "NO_COUNTERPLAY", "severity": "soft", "mutualPairs": rep["mutualPairs"],
                     "message": f"反制循环为 0(互有胜负 {rep['mutualPairs']} 对但可传递):"
                                f"L3「有反制」在本次遭遇集上不成立,换一组遭遇可能又成立"})
    warn.append({"code": "PROXY_ONLY", "severity": "soft",
                 "message": "四个层次的量全部来自仿真胜率,是代理指标:只保证机制层结论,"
                            "不等于玩家感受,不得写成「平衡度提升 N 倍」"})
    report = {"id": "gd_play_05_layer_robustness", "tier": "B", "hardFail": False,
              "note": "B 档软告警:只提示层次判定的阈值依赖与采样依赖,不使任何构建失败",
              "band": band, "bandShifted": band2,
              "thresholds": {"configured": thr, "flipAt": flip}, "warnings": warn}
    with open(os.path.join(OUT, "play05_layers_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/play05_layers_robustness.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_05_layer_robustness.py:

SOFT WARN THRESHOLD_FLIP: 带宽 703‰ 只要「平衡线」放到 750‰ 及以上,L2 就从「不平衡」翻成「平衡」——层判定绑定在阈值上,当前用的是 200‰
SOFT WARN SAMPLING_STABLE: 种子平移后带宽 703‰→773‰,未跨线:L2 判定对本次采样稳健
SOFT WARN NO_COUNTERPLAY: 反制循环为 0(互有胜负 6 对但可传递):L3「有反制」在本次遭遇集上不成立,换一组遭遇可能又成立
SOFT WARN PROXY_ONLY: 四个层次的量全部来自仿真胜率,是代理指标:只保证机制层结论,不等于玩家感受,不得写成「平衡度提升 N 倍」
warnings=4  hardFail=false  →  exit 0
wrote out/play05_layers_robustness.json

阈值一放到 750‰,L2 立刻改口叫「平衡」——所以「平衡 / 不平衡」不是机制的固有属性,而是「机制 + 阈值 + 遭遇集」三者共同的结论。这正是把结论落成数字的意义:阈值和遭遇集都在配置里,改一个数就能重算,而不是靠嘴争。NO_COUNTERPLAY 则提醒 L3 的「无反制」也只对本次遭遇集成立。

自检表

检查项合格标准
层次可算四层各有一个整数输出,不是形容词
输入入档策略族、遭遇集、阈值全写进 config
代理指标显式胜率与带宽声明为代理指标,只做相对比较
软告警不硬失败B 层永远 exit 0,只列依赖不阻断

四个层次说完了「平衡是什么」,下一节把它们压成一组可比的整数,好让两套配置能放在一起比。

阅读导航:上一节:4.3 退化解与退化模式 · 下一节:5.2 平衡的度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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