站内独立游戏平衡与经济设计 给的修复手段是调数值:把强的调弱、把弱的调强,靠经验和手感收敛。本节把「怎么修」变成一个带约束的搜索问题——它和那篇的目标一样(让选择重新有意义),但每一步都可复算、可比较代价。修复的判据不是「感觉平衡了」,而是「严格支配对归零且最优策略仍然赢」。
2.3 支配策略的修复
2.1 找出支配对、2.2 给了检测算法,但「一条策略被另一条处处压住」本身不是终点:如果被压住的策略本该是个有意义的选择,支配就是设计缺陷,得修。修复的目标要写死两条,缺一不可:
| 约束 | 判据 | 为什么 |
|---|---|---|
| 消除支配 | strictPairs == 0 | 没有任何策略被别的策略处处压住 |
| 保住可玩 | 最优策略胜局数 ≥ minWins(8) | 只消支配不保可玩就是「治死」 |
第二条是本节最容易被忽略、也最要紧的一条。把敌人血量一路拉高,确实能让所有策略都不支配彼此——但那是因为谁都赢不了,分数全塌到同一水平。这种「修复」把游戏治死了。所以修复不是单纯求 strictPairs==0 的最小改动,而是求同时满足两条约束的最小改动。
候选旋钮取 7 个:params 的 enemyHp / energyMax / enemyAttack / turnLimit,以及三张牌的字段 jab.damage / guard.block / strike.cost。每个旋钮扫一遍取值网格,代价记为 |value − baseline|,不做二分——因为支配计数不是参数的单调函数(enemyHp 65 剩 1 对、70 反而回到 2 对)。
S 层:最小改动的修复搜索
脚本对每个候选的每个取值重算一次支配矩阵,挑出满足两条约束、代价最小的修复。全整数,无浮点,无随机。
#!/usr/bin/env python3
# file: sim/gd_play_02_repair.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_repair.py
"""卷二 2.3 支配策略的修复(S 层):找一个最小的设计参数改动,消掉全部严格支配对。
修复目标(写进 config):strictPairs==0 且 最优策略胜局数 >= minWins。
只消支配不保可玩是「治死」——把敌人血量拉到全员必败也能让支配消失。
候选旋钮 = params 四项 + 三张牌的字段。对每个候选扫一遍取值网格:
cost = |value − baseline|(改动步长);可行解按 cost 升序、并列时按最优胜局数降序取。
支配计数**不是**参数的单调函数,故不做二分,老老实实扫完整网格。
产出 out/play02_repair.csv + out/play02_repair.svg + out/play02_repair.json。
全整数,无浮点,无 random。
"""
from __future__ import annotations
import copy
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_02_dominance.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
from gd_play_02_dominance import payoff_matrix # noqa: E402
def variant(spec, cand, value):
s = copy.deepcopy(spec)
if "param" in cand:
s["params"][cand["param"]] = value
else:
for c in s["cards"]:
if c["id"] == cand["card"]:
c[cand["field"]] = value
return s
def evaluate(spec, cfg, seeds):
"""返回 (严格支配对数, 最优策略胜局数, 存活策略数)。"""
_, rows, win_rows = payoff_matrix(spec, cfg, seeds)
n = len(rows)
pairs = [(i, j) for i in range(n) for j in range(n)
if i != j and all(x > y for x, y in zip(rows[i], rows[j]))]
dominated = {j for _, j in pairs}
return len(pairs), max(sum(w) for w in win_rows), n - len(dominated)
def baseline_value(spec, cand):
return spec["params"][cand["param"]] if "param" in cand else \
next(c[cand["field"]] for c in spec["cards"] if c["id"] == cand["card"])
def label(cand):
return cand["param"] if "param" in cand else f"{cand['card']}.{cand['field']}"
def chart(rows, knob, path):
root = ashlib.svg_begin(720, 300)
ashlib.svg_text(root, 20, 26, f"修复扫描:{knob} 取值 → 严格支配对数", 14)
ashlib.svg_text(root, 20, 46, "绿 = 严格支配对 0;灰 = 仍有支配(越高越坏)", 11, "#94a3b8")
mx = max(r["strictPairs"] for r in rows) or 1
bw = 620 // max(1, len(rows))
for i, r in enumerate(rows):
h = r["strictPairs"] * 180 // mx
ET.SubElement(root, "rect", {
"x": str(30 + i * bw), "y": str(250 - h), "width": str(max(bw - 8, 4)),
"height": str(max(h, 1)), "fill": "#22c55e" if r["strictPairs"] == 0 else "#64748b"})
ashlib.svg_text(root, 30 + i * bw + bw // 2, 266, str(r["value"]), 10, "#cbd5e1", "middle")
ashlib.svg_text(root, 30 + i * bw + bw // 2, 248 - h, str(r["strictPairs"]), 10, "#e2e8f0", "middle")
return ashlib.svg_save(root, path)
def main() -> int:
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, min_wins = cfg["seeds"], cfg["repair"]["minWins"]
cands = [dict(c, kind="param") for c in cfg["repair"]["paramCandidates"]] + \
[dict(c, kind="card") for c in cfg["repair"]["cardCandidates"]]
csv, feasible, per_cand = [], [], {}
for cand in cands:
base, rows = baseline_value(spec, cand), []
for v in cand["values"]:
p, w, surv = evaluate(variant(spec, cand, v), cfg, seeds)
cost = abs(v - base)
rows.append({"value": v, "cost": cost, "strictPairs": p, "bestWins": w,
"survivors": surv, "ok": p == 0 and w >= min_wins})
csv.append([label(cand), base, v, cost, p, w, surv, str(p == 0 and w >= min_wins).lower()])
per_cand[label(cand)] = rows
feasible += [{"label": label(cand), "baseline": base, **r} for r in rows if r["ok"]]
feasible.sort(key=lambda r: (r["cost"], -r["bestWins"]))
ashlib.write_csv(os.path.join(OUT, "play02_repair.csv"),
["knob", "baseline", "value", "cost", "strictPairs", "bestWins", "survivors", "ok"], csv)
report = {"id": "gd_play_02_repair", "seeds": len(seeds), "minWins": min_wins,
"objective": cfg["repair"]["objective"], "feasible": feasible,
"chosen": feasible[0] if feasible else None, "scans": per_cand,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play02_repair.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'旋钮':<14}{'基线':>5}{'取值':>6}{'cost':>5}{'严格对':>7}{'最优胜':>7}{'存活':>5} 可行")
for cand in cands:
for r in per_cand[label(cand)]:
print(f"{label(cand):<14}{baseline_value(spec, cand):>5}{r['value']:>6}{r['cost']:>5}"
f"{r['strictPairs']:>7}{r['bestWins']:>7}{r['survivors']:>5} {'✓' if r['ok'] else ''}")
print(f"\n可行修复 {len(feasible)} 个(strictPairs=0 且 最优胜局≥{min_wins}):")
for f in feasible:
print(f" {f['label']} {f['baseline']}→{f['value']} cost={f['cost']} 最优胜={f['bestWins']}")
ch = feasible[0]
size = chart(per_cand[ch["label"]], ch["label"], os.path.join(OUT, "play02_repair.svg"))
print(f"选定修复:{ch['label']} {ch['baseline']}→{ch['value']}(cost={ch['cost']},"
f"严格对 {ch['strictPairs']},最优胜 {ch['bestWins']})")
print(f"svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play02_repair.csv out/play02_repair.svg out/play02_repair.json")
ET.parse(os.path.join(OUT, "play02_repair.svg"))
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_02_repair.py:
旋钮 基线 取值 cost 严格对 最优胜 存活 可行
enemyHp 50 50 0 5 16 3
enemyHp 50 60 10 4 16 3
enemyHp 50 65 15 1 16 4
enemyHp 50 70 20 2 16 3
enemyHp 50 79 29 0 0 5
enemyHp 50 90 40 0 0 5
energyMax 3 2 1 0 15 5 ✓
energyMax 3 3 0 5 16 3
energyMax 3 4 1 7 16 3
enemyAttack 6 6 0 5 16 3
enemyAttack 6 8 2 5 16 3
turnLimit 20 4 16 4 16 3
turnLimit 20 20 0 5 16 3
jab.damage 3 3 0 5 16 3
jab.damage 3 7 4 4 16 4
guard.block 5 0 5 3 16 4
guard.block 5 5 0 5 16 3
strike.cost 1 1 0 5 16 3
strike.cost 1 2 1 0 10 5 ✓
可行修复 2 个(strictPairs=0 且 最优胜局≥8):
energyMax 3→2 cost=1 最优胜=15
strike.cost 1→2 cost=1 最优胜=10
选定修复:energyMax 3→2(cost=1,严格对 0,最优胜 15)
svg=1460 bytes elapsed=46 ms
wrote out/play02_repair.csv out/play02_repair.svg out/play02_repair.json
判读:三种修复,两种是陷阱
| 类别 | 例子 | 严格对 | 最优胜 | 判读 |
|---|---|---|---|---|
| 治死 | enemyHp 50→79 | 0 | 0 | 支配没了,但全员必败——游戏被治死,不可行 |
| 无效 | enemyAttack 6→8、guard.block 5→0 | 5 / 3 | 16 | 改了但支配没消(甚至更多),没修到点子上 |
| 可行 | energyMax 3→2 | 0 | 15 | 支配归零,最优策略仍赢 15/16——选定 |
enemyHp 那一列还有一处细节值得停一下:65 时只剩 1 对、70 时又回到 2 对。支配计数不是单调的,所以「二分找最小 enemyHp」是错的——65 会被当成答案,但它还有 1 对没消。正确做法是扫完整网格再挑,脚本正是这么做的。
可行解有两个(energyMax 3→2 与 strike.cost 1→2,代价都是 1),并列时按最优胜局数降序取,于是选定 energyMax 3→2:它把一回合的能量上限从 3 降到 2,重牌(费用 2)仍打得动,但「一回合连打两张 1 费牌」的余量没了,策略之间的差距被压平,支配消失而胜局仍高。
B 层:修复在留出集上的稳健性
修复是在训练种子上选出来的,必须拿到没见过的种子上复核,否则可能只是过拟合了那 16 个环境。脚本把选定修复与「治死」方案一并搬到留出集 testSeeds。
#!/usr/bin/env python3
# file: sim/gd_play_02_repair_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_repair_robust.py
"""卷二 2.3 修复的稳健性(B 层):训练种子上选出的修复,能否在留出种子上成立。
在 config 的 seeds(训练)上取 2.3 S 层选定的修复旋钮与取值,搬到 testSeeds(留出)复核:
严格支配对是否仍为 0、最优策略胜局数是否仍 ≥ minWins。
同时把被否决的「治死」方案(enemyHp→79)一并搬到留出集,验证它确实把胜局打成 0。
判据:留出集上 strictPairs==0 且 bestWins>=minWins → 修复稳健;否则软告警。
永远 exit 0:B 档软告警不硬失败。产出 out/play02_repair_robust.json。
"""
from __future__ import annotations
import copy
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")
CONFIG = os.path.join(ROOT, "config/gd_play_02_dominance.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
from gd_play_02_dominance import payoff_matrix # noqa: E402
CHOSEN = {"knob": "energyMax", "value": 2} # 2.3 S 层选定的修复
REJECTED = {"knob": "enemyHp", "value": 79} # 被否决的「治死」方案
def apply_knob(spec, knob, value):
s = copy.deepcopy(spec)
if "." in knob:
card, field = knob.split(".")
for c in s["cards"]:
if c["id"] == card:
c[field] = value
else:
s["params"][knob] = value
return s
def assess(spec, cfg, seeds):
_, rows, win_rows = payoff_matrix(spec, cfg, seeds)
n = len(rows)
pairs = [(i, j) for i in range(n) for j in range(n)
if i != j and all(x > y for x, y in zip(rows[i], rows[j]))]
return len(pairs), max(sum(w) for w in win_rows)
def main() -> int:
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
train, test, min_wins = cfg["seeds"], cfg["testSeeds"], cfg["repair"]["minWins"]
warnings, checks = [], []
for label, kn, seeds in [("baseline", None, train), ("baseline", None, test),
("chosen", CHOSEN, train), ("chosen", CHOSEN, test),
("rejected", REJECTED, test)]:
sp = spec if kn is None else apply_knob(spec, kn["knob"], kn["value"])
pairs, best = assess(sp, cfg, seeds)
checks.append({"set": "train" if seeds is train else "test", "variant": label,
"strictPairs": pairs, "bestWins": best,
"ok": pairs == 0 and best >= min_wins})
rob = next(c for c in checks if c["variant"] == "chosen" and c["set"] == "test")
if not rob["ok"]:
warnings.append({"code": "REPAIR_NOT_ROBUST", "severity": "soft",
"message": f"修复在留出集上 strictPairs={rob['strictPairs']} "
f"bestWins={rob['bestWins']},未达到目标"})
rej = next(c for c in checks if c["variant"] == "rejected")
if rej["bestWins"] > 0:
warnings.append({"code": "REJECT_EXPECTATION", "severity": "soft",
"message": f"被否决方案在留出集上仍胜 {rej['bestWins']} 局,"
f"「治死」判断需复核"})
report = {"id": "gd_play_02_repair_robust", "tier": "B", "hardFail": False,
"note": "B 档软告警:修复须在留出种子上仍消除支配且保住可玩",
"minWins": min_wins, "chosen": CHOSEN, "rejected": REJECTED,
"checks": checks, "warnings": warnings,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play02_repair_robust.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'集合':<7}{'方案':<10}{'严格对':>7}{'最优胜':>7} 达标")
for c in checks:
print(f"{c['set']:<7}{c['variant']:<10}{c['strictPairs']:>7}{c['bestWins']:>7} {'✓' if c['ok'] else ''}")
for w in warnings:
print(f"SOFT WARN {w['code']}: {w['message']}")
print(f"repairRobust={rob['ok']} 被否决方案留出集胜局={rej['bestWins']} "
f"warnings={len(warnings)} hardFail=false elapsed={report['elapsedMs']} ms")
print("wrote out/play02_repair_robust.json")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_02_repair_robust.py:
集合 方案 严格对 最优胜 达标
train baseline 5 16
test baseline 3 16
train chosen 0 15 ✓
test chosen 0 15 ✓
test rejected 0 0
repairRobust=True 被否决方案留出集胜局=0 warnings=0 hardFail=false elapsed=10 ms
wrote out/play02_repair_robust.json
repairRobust=True:选定修复在留出集上 strictPairs=0、最优胜局 15,与训练集一致——修复泛化了,不是过拟合。对比行更有说服力:baseline 在训练集有 5 对、留出集只有 3 对(说明基线本身就带采样依赖,2.1 已论及),而修复在两处都归零;被否决的 enemyHp→79 在留出集上最优胜局仍是 0,「治死」判断在留出集上复现。B 层按纪律软告警不硬失败:warnings=0 表示没有风险要报,一旦修复不泛化就会报 REPAIR_NOT_ROBUST,但不会让构建失败。至此 2.1–2.3「定义 → 检测 → 修复」闭合;下一章换角度:不问「有没有支配」,而问「双方都最优会走到哪」。
自检表
| 检查项 | 合格标准 |
|---|---|
| 目标双约束 | 同时要求 strictPairs==0 与最优胜局 ≥ minWins |
| 软告警不硬失败 | B 层永远 exit 0,不泛化只报警不阻断 |
阅读导航:上一节:2.2 检测算法与复杂度 · 下一节:3.1 minimax 与极小极大 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。