上一节的复合分把带宽与熵各算一半。可这两个量真的同向吗?直觉上「越平衡越多样」,但这是错觉。站内经济平衡的概念与示意代码 谈的是产出与消耗的匹配,平衡就是收支相等;本节要指出,在策略层,「收支相等」式的平衡有一个退化陷阱:让所有打法都赢不了,带宽也是 0。
5.3 平衡与多样性
把平衡与多样性拆成两条轴,只需要一个旋钮。本节拨的是重击卡(heavy)的伤害:从 6 一路加到 24。每拨一次,重算六个打法在八组遭遇上的胜率,得到两个量:
| 轴 | 定义 | 说明 |
|---|---|---|
| 平衡度 | 1000 − band | 带宽越小越平衡,满分 1000‰ |
| 多样性 | 胜率熵(同 5.2 的口径) | 六条打法胜率越均匀越高,满分 1000‰ |
| 复合分 | (1000 − band + entropy) // 2 | 两条轴各占一半 |
两个轴都是代理指标,只做相对比较。要看清的是一条反例:把重击伤害压到最低时,带宽会掉到 0——但熵也掉到 0。band=0 只说明六个打法胜率一样,不说明它们都在玩:一起赢、一起输,都会让带宽变 0。
{ "seeds": [401, "…", 416],
"knob": { "card": "heavy", "field": "damage", "values": [6, 8, 10, 12, 14, 16, 18, 20, 24] },
"tolerances": { "viableTol": 200 } }
S 层:扫一个旋钮,看两条轴
脚本对旋钮的每个取值深拷贝一份规格、改掉 heavy 的伤害,再跑同一套策略 × 遭遇,算出两条轴并落盘 CSV + SVG。全整数,无浮点,无 random。
#!/usr/bin/env python3
# file: sim/gd_play_05_diversity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_diversity.py
"""卷二 5.3 平衡与多样性(S 层):扫一个旋钮,看「平衡」与「多样性」是不是一回事。
旋钮 = 重击卡(heavy)的伤害,取值 6…24。每取一个值跑同一套策略 × 遭遇,算两个轴
(均为千分比整数,禁浮点):平衡度 = 1000 − band;多样性 = 行均值归一化后的
Shannon 熵 ÷ log2(策略数) ×1000;再加 balanceScore = (1000 − band + entropy) // 2。
要看清的是一条反例:**把重击伤害压到最低时 band 会掉到 0——但熵也掉到 0**,
那不是「平衡」,是六个策略一起赢不了。产出 out/play05_diversity.json + .csv + .svg。
两个轴都是**代理指标**,只做相对比较。
"""
import copy, json, math, 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_diversity.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
KEYS = ("enemyHp", "turnLimit", "enemyAttack")
def choose(rule, snap, cost, dmg, blk, ea):
"""确定性策略规则:读当前快照,返回下一步命令(pK 出第 K 张,e 结束回合)。"""
hand, en, turn = snap["hand"], snap["energy"], snap["turn"]
aff = [i for i, cid in enumerate(hand) if cost[cid] <= 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 snap["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 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 row_means(spec, cfg, seeds):
"""每个策略在全部遭遇上的胜率均值(千分比整数)。"""
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"]}
means = {}
for pol in cfg["policies"]:
total = 0
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):
snap = ashlib.run_spec(s, seq)
if snap["over"]:
break
seq.append(choose(pol["rule"], snap, cost, dmg, blk, enc["enemyAttack"]))
wins += 1 if ashlib.run_spec(s, seq)["won"] else 0
total += 1000 * wins // len(seeds)
means[pol["id"]] = total // len(cfg["encounters"])
return means
def axes(means, tol):
"""两个轴 + 可用策略数 + 复合分。"""
band = max(means.values()) - min(means.values())
tot = sum(means.values())
ent = 0
if tot > 0:
h = sum(-(v / tot) * math.log2(v / tot) for v in means.values() if v > 0)
ent = int(round(1000 * h / math.log2(len(means))))
best = max(means.values())
viable = sum(1 for v in means.values() if v > 0 and v >= best - tol)
return {"band": band, "balance": 1000 - band, "diversity": ent,
"viableCount": viable, "balanceScore": (1000 - band + ent) // 2}
def draw(pts, path):
"""平衡度(x)– 多样性(y)平面,退化点画红。"""
x0, y0, x1, y1 = 70, 60, 600, 320
root = ashlib.svg_begin(640, 380)
ashlib.svg_text(root, 22, 28, "《灰烬牌库》平衡–多样性平面(旋钮:重击伤害)", 14)
ashlib.svg_text(root, 22, 48, "x=平衡度(1000−band) y=多样性(胜率熵) 均为代理指标", 11, "#94a3b8")
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1),
"stroke": "#475569"})
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x0), "y2": str(y1),
"stroke": "#475569"})
ashlib.svg_text(root, x0 - 8, y0 + 6, "1000", 10, "#94a3b8", "end")
ashlib.svg_text(root, x0 - 8, y1, "0", 10, "#94a3b8", "end")
ashlib.svg_text(root, x1, y1 + 20, "1000", 10, "#94a3b8", "middle")
ashlib.svg_text(root, (x0 + x1) // 2, y1 + 38, "平衡度 →", 11, "#cbd5e1", "middle")
ashlib.svg_text(root, 26, (y0 + y1) // 2, "多样性 →", 11, "#cbd5e1")
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0 + 6), "x2": str(x1), "y2": str(y0 + 6),
"stroke": "#ef4444", "stroke-dasharray": "4 4"})
ashlib.svg_text(root, x1 - 4, y0 + 20, "退化线:band=0 但没人赢", 10, "#ef4444", "end")
for p in pts:
px = x0 + (x1 - x0) * p["balance"] // 1000
py = y1 - (y1 - y0) * p["diversity"] // 1000
ET.SubElement(root, "circle", {"cx": str(px), "cy": str(py), "r": "5",
"fill": "#38bdf8" if p["diversity"] > 0 else "#ef4444"})
ashlib.svg_text(root, px + 8, py + 4, f"重击{p['knob']}", 10, "#cbd5e1")
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)
knob, tol = cfg["knob"], cfg["tolerances"]["viableTol"]
pts, csv = [], []
for v in knob["values"]:
s = copy.deepcopy(spec)
for c in s["cards"]:
if c["id"] == knob["card"]:
c[knob["field"]] = v
means = row_means(s, cfg, cfg["seeds"])
ax = axes(means, tol)
pts.append({"knob": v, "means": means, **ax})
csv.append([v, ax["band"], ax["balance"], ax["diversity"], ax["viableCount"],
ax["balanceScore"]])
n = ashlib.write_csv(os.path.join(OUT, "play05_diversity.csv"),
["heavyDamage", "band", "balance", "diversity", "viableCount",
"balanceScore"], csv)
size = draw(pts, os.path.join(OUT, "play05_diversity.svg"))
degen = [p["knob"] for p in pts if p["diversity"] == 0]
report = {"id": "gd_play_05_diversity", "knob": knob, "seeds": len(cfg["seeds"]),
"points": pts, "degenerateAt": degen,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play05_diversity.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'重击伤害':>8}{'band':>7}{'平衡度':>8}{'多样性':>8}{'可用':>6}{'复合分':>8}")
for p in pts:
print(f"{p['knob']:>8}{p['band']:>7}{p['balance']:>8}{p['diversity']:>8}"
f"{p['viableCount']:>6}{p['balanceScore']:>8}")
print(f"退化为零的点(band=0 且 熵=0):{degen}")
print(f"csv={n} 行 svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play05_diversity.csv out/play05_diversity.svg out/play05_diversity.json")
ET.parse(os.path.join(OUT, "play05_diversity.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_05_diversity.py:
重击伤害 band 平衡度 多样性 可用 复合分
6 0 1000 0 0 500
8 23 977 374 2 675
10 101 899 763 4 831
12 500 500 916 1 708
14 703 297 914 2 605
16 757 243 914 2 578
18 703 297 961 4 629
20 469 531 985 4 758
24 297 703 994 4 848
退化为零的点(band=0 且 熵=0):[6]
csv=9 行 svg=3126 bytes elapsed=742 ms
wrote out/play05_diversity.csv out/play05_diversity.svg out/play05_diversity.json
判读:最「平衡」的那一行是最差的
| 重击伤害 | 平衡度 | 多样性 | 复合分 | 读法 |
|---|---|---|---|---|
| 6 | 1000 | 0 | 500 | 退化:没人赢,band=0 是假平衡 |
| 8 | 977 | 374 | 675 | 开始有人赢,但打法单一 |
| 10 | 899 | 763 | 831 | 平衡度与多样性都在高位 |
| 12 | 500 | 916 | 708 | 多样性接近满分,平衡度掉一半 |
| 14 | 297 | 914 | 605 | 基准值:不平衡但多样 |
| 16 | 243 | 914 | 578 | 九行里最不平衡 |
| 18 | 297 | 961 | 629 | 多样性最高之一 |
| 20 | 531 | 985 | 758 | 两条轴都高 |
| 24 | 703 | 994 | 848 | 复合分最高 |
看第一行与最后一行。重击伤害 = 6 时平衡度 1000‰——满分,因为它把 band 压到 0。可多样性也是 0:六个打法全都赢不了,胜率矩阵全零,谁跟谁都一样,带宽自然是 0。这不是平衡,是退化;复合分把它判成 (1000 + 0) // 2 = 500‰,是九行里最低的。反过来,重击伤害 = 24 时平衡度只有 703‰,但多样性 994‰,复合分 848‰ 最高。「最平衡」的那一行,恰恰是整体最差的一行。
B 层:退化点的分辨率与稳定性
结论里有两处需要复核:退化点(重击伤害 = 6)是一个孤立的点还是一段区间,以及最优点(24)的带宽稳不稳。B 层把旋钮逐 1 加密、扫容差、换种子数,全部软告警。
#!/usr/bin/env python3
# file: sim/gd_play_05_diversity_robustness.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_diversity_robustness.py
"""卷二 5.3 平衡–多样性结论的稳健性(B 层):**永远 exit 0**,只提示不使构建失败。
读 out/play05_diversity.json(5.3 S 层产物),做两件事:① 旋钮加密——把重击伤害从 6 逐 1
加到 10,看「退化角」是一个点还是一段区间;② 采样扰动——退化点与复合分最高点在
4/8/16 个种子下是否还成立。复用 5.3 S 层的 row_means / axes,不重写一遍。
产出 out/play05_diversity_robustness.json。
"""
import copy, json, os, sys
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_diversity.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
from gd_play_05_diversity import row_means, axes # noqa: E402
def axes_at(spec, cfg, seeds, value):
"""把旋钮拨到 value 再算两轴。"""
s = copy.deepcopy(spec)
for c in s["cards"]:
if c["id"] == cfg["knob"]["card"]:
c[cfg["knob"]["field"]] = value
return axes(row_means(s, cfg, seeds), cfg["tolerances"]["viableTol"])
def main():
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
with open(os.path.join(OUT, "play05_diversity.json"), encoding="utf-8") as f:
rep = json.load(f)
warn = []
fine = [axes_at(spec, cfg, cfg["seeds"], v) for v in (6, 7, 8, 9, 10)]
degen = [6 + i for i, a in enumerate(fine) if a["band"] == 0 and a["diversity"] == 0]
warn.append({"code": "KNOB_RESOLUTION", "severity": "soft", "fineGrid": fine,
"degenerateValues": degen,
"message": f"重击伤害逐 1 加密到 6…10:退化点出现在 {degen},"
f"是{'一个区间' if len(degen) > 1 else '一个孤立点'};"
f"粗扫步长 2 可能把退化区间边界切丢"})
hi = max(rep["points"], key=lambda p: p["balanceScore"])["knob"]
for label, value in (("退化点", min(p["knob"] for p in rep["points"])), ("复合分最高点", hi)):
rows = [{"seeds": c, **{k: axes_at(spec, cfg, cfg["seeds"][:c], value)[k]
for k in ("band", "diversity")}} for c in (4, 8, 16)]
warn.append({"code": "SAMPLING_STABILITY", "severity": "soft", "point": label,
"knob": value, "rows": rows,
"message": f"{label}(重击伤害={value})在 4/8/16 个种子下的 "
f"(band, 多样性) = {[(r['band'], r['diversity']) for r in rows]}"})
warn.append({"code": "PROXY_ONLY", "severity": "soft",
"message": "平衡度与多样性都来自仿真胜率,是代理指标:只做配置之间的相对比较,"
"禁写「多样性提升 N 倍」"})
report = {"id": "gd_play_05_diversity_robustness", "tier": "B", "hardFail": False,
"note": "B 档软告警:只提示旋钮分辨率与采样对平衡–多样性结论的影响",
"fineGrid": fine, "degenerateValues": degen, "warnings": warn}
with open(os.path.join(OUT, "play05_diversity_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_diversity_robustness.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_05_diversity_robustness.py:
SOFT WARN KNOB_RESOLUTION: 重击伤害逐 1 加密到 6…10:退化点出现在 [6],是一个孤立点;粗扫步长 2 可能把退化区间边界切丢
SOFT WARN SAMPLING_STABILITY: 退化点(重击伤害=6)在 4/8/16 个种子下的 (band, 多样性) = [(0, 0), (0, 0), (0, 0)]
SOFT WARN SAMPLING_STABILITY: 复合分最高点(重击伤害=24)在 4/8/16 个种子下的 (band, 多样性) = [(407, 991), (297, 995), (297, 994)]
SOFT WARN PROXY_ONLY: 平衡度与多样性都来自仿真胜率,是代理指标:只做配置之间的相对比较,禁写「多样性提升 N 倍」
warnings=4 hardFail=false → exit 0
wrote out/play05_diversity_robustness.json
加密到逐 1 后,退化点只有 6 这一个值,是孤立点而非区间——粗扫步长 2 恰好没漏掉它,但换一个旋钮就未必。采样扰动显示:退化点的 (band, 多样性) = (0, 0) 在 4/8/16 个种子下完全稳定(它不靠运气),而最优点 24 的带宽会在 407‰ 与 297‰ 之间晃——所以比较不同旋钮取值的带宽时,种子数必须固定。
自检表
| 检查项 | 合格标准 |
|---|---|
| 两条轴分开 | 平衡度与多样性各自可算,不混为一谈 |
| 退化可识别 | band=0 且熵=0 判为退化,不判为平衡 |
| 旋钮入档 | 扫哪个旋钮、取哪些值写进 config |
| 代理指标显式 | 两条轴都是代理指标,只做相对比较 |
平衡与多样性是两条轴,把重击伤害压低能买到「平衡」,代价是把游戏买死。旋钮继续往另一边拨会发生什么?下一章把「参数扫过某个点、系统整体换一种行为」这件事单拎出来讲。
阅读导航:上一节:5.2 平衡的度量 · 下一节:6.1 相变:参数扫过临界点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。