站内独立游戏平衡与经济设计
(约 1400 行)是一篇散文式平衡经验:讲数值怎么调、经济怎么稳,代码是 Unity csharp 片段、跑不起来,也没有「哪条策略更好」的可判定判据。本节不重复那些经验,而是把「一条策略比另一条更好」写成一条能逐环境核对的不等式——严格支配与弱支配。这是 MDA 里 D 层(动态)的第一个可判定性质:它回答的不是「这样玩爽不爽」,而是「这样玩是不是永远不亏」。
2.1 支配关系:严格与弱
策略空间里,玩家真正会选的往往只有几条。支配策略就是那种「无论环境怎么变都不比别的差」的策略——一旦存在,理性的玩家会收敛到它,其余策略就成了摆设。本节先把「不亏」精确定义成两种关系,再让仿真把它们算出来。
| 关系 | 记号 | 条件(∀ω 遍历环境集合 Ω) |
|---|---|---|
| 严格支配 | s ≻ t | 对每个环境 ω,score(s,ω) > score(t,ω) |
| 弱支配 | s ⪰ t | 对每个 ω 有 score(s,ω) ≥ score(t,ω),且至少一个 ω 取严格大于 |
| 支配策略 | — | s 严格(或弱)支配所有其它策略 |
| 被支配策略 | — | 被某条策略严格(或弱)支配 |
两种关系的差别只在「能不能打平」:严格支配要求处处更优,弱支配允许有平局但不能全平。判定它们的唯一输入是一张得分矩阵——行是策略,列是环境。这里的 score 是代理指标(1000×won + playerHp,胜局优先、存活血量作次级),只用来做相对比较:说「s 支配 t」意思是逐格不等式成立,不是「s 的体验好 N 倍」。
策略族与环境。策略取固定下标族:每回合依次打「当前手牌第 k 张」4 次,再结束回合,共 5 回合——k 从 0 到 4 得到 5 条策略 slot0…slot4。它是卷一「策略即动作序列」的一个最简实例,可逐字重放。环境是种子:同一策略在不同洗牌下抽出不同手牌,得到不同的对局。策略与环境都写进只读配置,改口径只改配置。
{ "seeds": [20261001, "…", 20261016], "testSeeds": [20261101, "…", 20261116],
"turns": 5, "slotsPerTurn": 4, "scoreScale": 1000,
"strategies": [ {"name": "slot0", "handIndex": 0}, "…", {"name": "slot4", "handIndex": 4} ] }
S 层:把支配关系算出来
脚本建 5×16 的得分矩阵,再逐有序对做「处处更优」与「处处不差且有一处更优」两种检查。全整数,无浮点,无随机。
#!/usr/bin/env python3
# file: sim/gd_play_02_dominance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_dominance.py
"""卷二 2.1 支配关系:严格与弱(S 层)。
策略族 = 固定下标策略(每回合依次打「手牌第 k 张」4 次,再结束回合,共 5 回合)。
环境 Ω = 种子集合(同一策略在不同洗牌下得到不同对局)。得分是整数代理指标:
score(s, ω) = 1000 * won(s, ω) + playerHp(s, ω) # 胜局优先,存活血量为次级
严格支配:s ≻ t ⇔ ∀ω, score(s,ω) > score(t,ω)
弱支配: s ⪰ t ⇔ ∀ω, score(s,ω) ≥ score(t,ω) 且 ∃ω, score(s,ω) > score(t,ω)
支配策略 = 严格(或弱)支配所有其它策略的策略;被支配策略 = 被某策略支配的策略。
score 是**代理指标**:只用于相对比较,不能写成「体验提升 N 倍」。
产出 out/play02_matrix.csv + out/play02_dominance.svg + out/play02_dominance.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
def strategy_seq(k: int, turns: int, slots: int) -> list:
"""固定下标策略:每回合打第 k 张牌 slots 次,再结束回合。"""
out = []
for _ in range(turns):
out += ["p" + str(k)] * slots + ["e"]
return out
def score_of(snap: dict, scale: int) -> int:
return scale * (1 if snap["won"] else 0) + snap["playerHp"]
def payoff_matrix(spec: dict, cfg: dict, seeds: list) -> tuple:
"""行 = 策略,列 = 环境(种子)。返回 (names, rows, winRows)。"""
names, rows, win_rows = [], [], []
for st in cfg["strategies"]:
seq = strategy_seq(st["handIndex"], cfg["turns"], cfg["slotsPerTurn"])
row, wins = [], []
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
snap = ashlib.run_spec(s, seq)
row.append(score_of(snap, cfg["scoreScale"]))
wins.append(1 if snap["won"] else 0)
names.append(st["name"])
rows.append(row)
win_rows.append(wins)
return names, rows, win_rows
def strict_dom(a: list, b: list) -> bool:
return all(x > y for x, y in zip(a, b))
def weak_dom(a: list, b: list) -> bool:
return all(x >= y for x, y in zip(a, b)) and any(x > y for x, y in zip(a, b))
def relations(rows: list) -> tuple:
n = len(rows)
st = [(i, j) for i in range(n) for j in range(n) if i != j and strict_dom(rows[i], rows[j])]
wk = [(i, j) for i in range(n) for j in range(n) if i != j and weak_dom(rows[i], rows[j])]
strict_dominant = [i for i in range(n) if all(strict_dom(rows[i], rows[j]) for j in range(n) if j != i)]
weak_dominant = [i for i in range(n) if all(weak_dom(rows[i], rows[j]) for j in range(n) if j != i)]
dominated = sorted({j for _, j in st})
return st, wk, strict_dominant, weak_dominant, dominated
def heatmap(names, rows, path):
"""得分矩阵热力图:列 = 种子,行 = 策略,色深 = 得分高低(相对 min/max)。"""
lo = min(min(r) for r in rows)
hi = max(max(r) for r in rows)
cw, ch, x0, y0 = 40, 34, 96, 66
W, H = x0 + cw * len(rows[0]) + 40, y0 + ch * len(names) + 48
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 26, "得分矩阵:策略(行)× 种子(列)", 15)
ashlib.svg_text(root, 16, 46, "色深 = 1000×won + playerHp(越亮越高);代理指标,仅用于相对比较", 11, "#94a3b8")
for i, name in enumerate(names):
y = y0 + i * ch
ashlib.svg_text(root, 16, y + 22, name, 12)
for j, v in enumerate(rows[i]):
t = 0 if hi == lo else (v - lo) * 255 // (hi - lo)
shade = 40 + t * 180 // 255
ET.SubElement(root, "rect", {
"x": str(x0 + j * cw + 2), "y": str(y + 3), "width": str(cw - 4),
"height": str(ch - 6), "fill": f"#{shade:02x}9e{(255 - t):02x}", "rx": "3",
})
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 = cfg["seeds"]
names, rows, win_rows = payoff_matrix(spec, cfg, seeds)
st, wk, sdom, wdom, dominated = relations(rows)
csv = [[names[i]] + list(rows[i]) for i in range(len(names))]
n = ashlib.write_csv(os.path.join(OUT, "play02_matrix.csv"),
["strategy"] + [str(s) for s in seeds], csv)
size = heatmap(names, rows, os.path.join(OUT, "play02_dominance.svg"))
report = {"id": "gd_play_02_dominance", "seeds": len(seeds), "strategies": names,
"scoreScale": cfg["scoreScale"], "strictPairs": st, "weakPairs": wk,
"strictlyDominant": [names[i] for i in sdom],
"weaklyDominant": [names[i] for i in wdom],
"dominated": [names[i] for i in dominated],
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play02_dominance.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}{'胜局':>5} 得分(16 个种子)")
for i, name in enumerate(names):
print(f"{name:<8}{sum(win_rows[i]):>5} {rows[i]}")
print(f"\n严格支配对 {len(st)}: " + " ".join(f"{names[i]}≻{names[j]}" for i, j in st))
print(f"弱支配对 {len(wk)}: " + " ".join(f"{names[i]}⪰{names[j]}" for i, j in wk))
print(f"严格支配策略={[names[i] for i in sdom]} 弱支配策略={[names[i] for i in wdom]}")
print(f"被支配策略={[names[i] for i in dominated]} 未被支配={[names[i] for i in range(len(names)) if i not in dominated]}")
print(f"csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play02_matrix.csv out/play02_dominance.svg out/play02_dominance.json")
ET.parse(os.path.join(OUT, "play02_dominance.svg"))
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_02_dominance.py:
策略 胜局 得分(16 个种子)
slot0 16 [1047, 1048, 1053, 1053, 1053, 1047, 1053, 1048, 1052, 1053, 1052, 1052, 1053, 1048, 1053, 1053]
slot1 16 [1053, 1048, 1047, 1048, 1053, 1052, 1048, 1048, 1048, 1053, 1047, 1048, 1053, 1048, 1052, 1048]
slot2 16 [1048, 1048, 1048, 1042, 1047, 1052, 1048, 1048, 1053, 1053, 1047, 1053, 1048, 1048, 1048, 1048]
slot3 15 [1042, 1042, 1042, 1042, 1046, 36, 1047, 1042, 1041, 1042, 1047, 1042, 1042, 1042, 1047, 1047]
slot4 0 [30, 40, 30, 30, 35, 35, 35, 30, 35, 30, 30, 30, 30, 35, 35, 35]
严格支配对 5: slot0≻slot3 slot0≻slot4 slot1≻slot4 slot2≻slot4 slot3≻slot4
弱支配对 7: slot0⪰slot3 slot0⪰slot4 slot1⪰slot3 slot1⪰slot4 slot2⪰slot3 slot2⪰slot4 slot3⪰slot4
严格支配策略=[] 弱支配策略=[]
被支配策略=['slot3', 'slot4'] 未被支配=['slot0', 'slot1', 'slot2']
csv=5 rows svg=6910 bytes elapsed=6 ms
wrote out/play02_matrix.csv out/play02_dominance.svg out/play02_dominance.json
判读:三种策略三种命运
slot0、slot1、slot2 两两互不支配(谁在某几个种子上更高、在另几个更低),slot3 与 slot4 则被压住:
| 策略 | 胜局 | 严格被支配 | 读法 |
|---|---|---|---|
| slot0 / slot1 / slot2 | 16 / 16 / 16 | 否 | 都在「未被支配」集合里,谁也吃不掉谁 |
| slot3 | 15 | 是(被 slot0) | 只有一个种子上掉到 36 分(第 6 个种子那一局败了) |
| slot4 | 0 | 是(被全部 4 条) | 全程只打最后一张牌,回合数拖到 6,一局都赢不了 |
两个结论要分开说。其一,不存在支配策略:没有任何一条策略支配所有其它策略——slot0 与 slot1 互有胜负,所以策略空间没有被「解死」。其二,slot4 被彻底支配:它在每个种子上的得分都低于另外四条,理性玩家永远不会选它。这就是「被支配策略」的用处——它可以从策略空间里删掉而不影响最优解,2.2 会把它做成算法。
B 层:支配对种子集合的稳健性
支配是「对所有环境成立」的性质,所以它天然依赖环境集合的规模。这里有一条单调性:若 s 在 Ω 上严格支配 t,则在任何子集 Ω’ ⊆ Ω 上也严格支配 t(约束更少)。反过来不成立——小集合上冒出来的支配对,放大集合后可能消失,那是采样假象。脚本把 16 个种子切成 6 个子集逐个复核。
#!/usr/bin/env python3
# file: sim/gd_play_02_dominance_stability.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_dominance_stability.py
"""卷二 2.1 支配关系的稳健性(B 层):严格支配对种子集合的单调性。
单调性事实:Ω' ⊆ Ω 时,s 在 Ω 上严格支配 t ⟹ s 在 Ω' 上也严格支配 t(约束更少)。
反过来不成立:小集合上出现的严格支配对,放大集合后可能消失——那是**采样假象**。
本脚本复用 2.1 S 层的 payoff_matrix / strict_dom,把 16 个种子切成 6 个子集逐个复核。
永远 exit 0:B 档软告警不硬失败。产出 out/play02_stability.json。
"""
from __future__ import annotations
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)
from gd_play_02_dominance import payoff_matrix, strict_dom # noqa: E402
def strict_pairs(rows):
n = len(rows)
return {(i, j) for i in range(n) for j in range(n) if i != j and strict_dom(rows[i], rows[j])}
def main() -> int:
t0 = time.perf_counter_ns()
spec = __import__("ashlib").load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds = cfg["seeds"]
names = [s["name"] for s in cfg["strategies"]]
full = strict_pairs(payoff_matrix(spec, cfg, seeds)[1])
subsets = {"first8": seeds[:8], "last8": seeds[8:], "even8": seeds[0::2],
"odd8": seeds[1::2], "first4": seeds[:4], "last4": seeds[-4:]}
rows_out, warnings = [], []
for label, sub in subsets.items():
sp = strict_pairs(payoff_matrix(spec, cfg, sub)[1])
missing, artifacts = full - sp, sp - full
for i, j in sorted(artifacts):
warnings.append({"code": "SAMPLING_ARTIFACT", "severity": "soft", "subset": label,
"pair": [names[i], names[j]],
"message": f"子集 {label}({len(sub)} 种子)里 {names[i]}≻{names[j]},"
f"放大到 {len(seeds)} 种子后消失:小样本假象"})
rows_out.append({"subset": label, "seeds": len(sub), "strictCount": len(sp),
"missing": [[names[i], names[j]] for i, j in sorted(missing)],
"artifacts": [[names[i], names[j]] for i, j in sorted(artifacts)]})
report = {"id": "gd_play_02_dominance_stability", "tier": "B", "hardFail": False,
"note": "B 档软告警:严格支配对随种子集合单调增;子集独有对是采样假象",
"fullSeeds": len(seeds), "fullStrictCount": len(full),
"fullPairs": [[names[i], names[j]] for i, j in sorted(full)],
"subsets": rows_out, "warnings": warnings,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play02_stability.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(seeds)} 种子:严格支配对 {len(full)} "
+ " ".join(f"{names[i]}≻{names[j]}" for i, j in sorted(full)))
print(f"{'子集':<8}{'种子':>5}{'严格对':>7} 假象(子集有全集无)")
for r in rows_out:
arts = " ".join(f"{a}≻{b}" for a, b in r["artifacts"]) or "-"
print(f"{r['subset']:<8}{r['seeds']:>5}{r['strictCount']:>7} {arts}")
for w in warnings:
print(f"SOFT WARN {w['code']}: {w['message']}")
print(f"monotoneOK={all(not r['missing'] for r in rows_out)} "
f"artifacts={len(warnings)} hardFail=false elapsed={report['elapsedMs']} ms")
print("wrote out/play02_stability.json")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_02_dominance_stability.py:
全集 16 种子:严格支配对 5 slot0≻slot3 slot0≻slot4 slot1≻slot4 slot2≻slot4 slot3≻slot4
子集 种子 严格对 假象(子集有全集无)
first8 8 6 slot1≻slot3
last8 8 5 -
even8 8 5 -
odd8 8 6 slot1≻slot3
first4 4 6 slot1≻slot3
last4 4 7 slot1≻slot3 slot2≻slot3
SOFT WARN SAMPLING_ARTIFACT: 子集 first8(8 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 odd8(8 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 first4(4 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 last4(4 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 last4(4 种子)里 slot2≻slot3,放大到 16 种子后消失:小样本假象
monotoneOK=True artifacts=5 hardFail=false elapsed=7 ms
wrote out/play02_stability.json
monotoneOK=True 说明 5 条全集支配对在每个子集里都还在(单调性成立);5 条 SAMPLING_ARTIFACT 则说明只用 4 个种子就会多判出 2 条假的支配对。B 层按纪律是软告警不硬失败:它提示「这个支配结论对样本量敏感」,而不是让构建失败。教训直接可用:支配检测的环境集合宁多勿少,否则会把采样噪声当成设计缺陷。
自检表
| 检查项 | 合格标准 |
|---|---|
| 定义可判定 | 严格 / 弱支配写成逐环境不等式,不是形容词 |
| 环境集合入档 | 种子写进 config,结论与 Ω 绑定 |
| 代理指标显式 | score 声明为代理指标,只做相对比较、禁绝对化 |
| 软告警不硬失败 | B 层永远 exit 0,只列风险不阻断 |
支配关系立起来之后,下一节把它做成算法:怎样在可接受的时间内,从一张大得多的得分矩阵里把被支配策略全部找出来,以及这个代价的复杂度上界。
阅读导航:上一节:1.3 策略空间的规模控制 · 下一节:2.2 检测算法与复杂度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。