站内经济平衡的概念与示意代码 把「平衡」当成一个经济概念来讲:产出与消耗要匹配、通胀要压住,配的是示意性数字,判不了「这套牌局平衡不平衡」。本节换一条更窄也更硬的口径:把平衡拆成四个层次,每层给一个能算出来的整数,再用《灰烬牌库》的策略胜率矩阵逐层判一遍。这是 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 平衡的度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。