11.2 战斗的策略空间与平衡

用固定下标族 slot0…slot4 在 16 个种子上跑出得分矩阵,算严格/弱支配对、支配策略缺失度(千分比)与结果分布的 Shannon 熵(×1000):没有支配策略(缺失度 1000‰),但 slot4 被其余四条策略严格支配——策略空间既没被解死、也不是全有效,是一个有真实弱选项的可玩空间。

11.2 战斗的策略空间与平衡

站内 《输入、帧缓冲与打击感》 讲的是战斗手感怎么实现;本节不碰表现层,只把 11.1 那三组字段当成一个可枚举的策略空间:给定同一场战斗,玩家有多少种可区分打法,其中有没有一条把别人全压死。这正是 《独立游戏平衡与经济设计》 想回答但只能用散文回答的问题——本节把它变成一次枚举与一组整数读数。

策略族取固定下标族:每回合依次打「当前手牌第 k 张」四次,再结束回合,共六回合——k 从 0 到 4 得到五条策略 slot0…slot4。环境 Ω 是 16 个种子:同一策略在不同洗牌下得到不同对局。这样就把「打法」这一模糊概念压成五个可执行序列。

得分与两个代理指标

每条策略在一个种子上得到一个整数得分 score = 1000×won + playerHp(胜局优先、存活血量为次级)。在 16 个种子上就得到一行 16 个得分,五行组成得分矩阵。基于矩阵定义两个量:

  • 支配策略缺失度 = 1000 − 1000×(严格支配策略数 / 策略数)(千分比)。缺失度 1000‰ 表示没有任何一条策略在所有环境上都不劣于别人。
  • 结果分布的 Shannon 熵 ×1000:把每条策略的结果按「胜/负 + 平均存活血量分桶」归桶,对桶计数求熵。

这两个量都是代理指标(proxy):缺失度高只说明「没被解死」,熵高只说明「结果分散」,都不等于「好玩」,只用于相对比较,禁绝对化。

支配的定义

支配有两档。严格支配 s ≻ t:对所有环境 ω 都有 score(s, ω) > score(t, ω);弱支配 s ⪰ t:处处 ≥ 且至少一处 >。支配策略(dominant)是严格支配所有其它策略的那条;被支配策略(dominated)是被某条严格支配的那条。二者不是一回事:一条策略可以没人支配它,但它自己也不支配所有人。

S 层:枚举策略空间并求两个指标

脚本对五条策略 × 16 个种子跑满矩阵,算出严格/弱支配对、被支配策略、支配缺失度、结果熵,并把矩阵写成 CSV 与一张 SVG 热力图。

#!/usr/bin/env python3
# file: sim/gd_proto_11_2_strategy_space.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_11_2_strategy_space.py
"""卷三 11.2 战斗的策略空间与平衡(S 层)。

策略族取**固定下标族**:每回合依次打「当前手牌第 k 张」playsPerTurn 次,再结束回合,共 turns
回合——k 从 0 到 4 得到 5 条策略 slot0…slot4。环境 Ω = 16 个种子(同一策略在不同洗牌下得到不同
对局)。得分是整数代理指标 score = 1000×won + playerHp(胜局优先、存活血量为次级)。

  · 支配策略缺失度 = 1000 − 1000×(严格支配策略数 / 策略数)(千分比)
  · 结果分布的 Shannon 熵 ×1000(按 胜/负 + 平均存活血量分桶)
两个量都是**代理指标(proxy)**:只做相对比较,禁绝对化。

产出 out/gd_proto_11_space.csv + out/gd_proto_11_space.svg + out/gd_proto_11_space.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_proto_11_combat.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def log2_milli(n: int) -> int:
    """floor(log2(n) × 1000),纯整数,供熵计算(禁浮点)。"""
    if n < 1:
        return 0
    one, x, k = 1 << 20, n << 20, 0
    while x >= one << 1:
        x >>= 1
        k += 1
    frac = 0
    for _ in range(10):
        x = (x * x) >> 20
        frac <<= 1
        if x >= one << 1:
            frac += 1
            x >>= 1
    return k * 1000 + (frac * 1000) // 1024


def entropy_milli(counts: list) -> int:
    """离散分布的 Shannon 熵 ×1000(比特),整数地板除。"""
    total = sum(counts)
    if total <= 1:
        return 0
    s = sum(c * log2_milli(c) for c in counts if c > 0)
    return (total * log2_milli(total) - s) // total


def slot_seq(k: int, turns: int, plays: int) -> list:
    return [c for _ in range(turns) for c in ["p" + str(k)] * plays + ["e"]]


def payoff_matrix(spec: dict, ss: dict, seeds: list) -> tuple:
    names, rows, win_rows = [], [], []
    for k in ss["indices"]:
        seq = slot_seq(k, ss["turns"], ss["playsPerTurn"])
        row, wins = [], []
        for sd in seeds:
            s = copy.deepcopy(spec)
            s["seed"] = sd
            snap = ashlib.run_spec(s, seq)
            row.append(ss["scoreScale"] * (1 if snap["won"] else 0) + snap["playerHp"])
            wins.append(1 if snap["won"] else 0)
        names.append("slot" + str(k))
        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 heatmap(names, rows, path):
    lo, hi = min(min(r) for r in rows), max(max(r) for r in rows)
    cw, ch, x0, y0 = 40, 34, 96, 66
    root = ashlib.svg_begin(x0 + cw * len(rows[0]) + 40, y0 + ch * len(names) + 48)
    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)
            ET.SubElement(root, "rect", {"x": str(x0 + j * cw + 2), "y": str(y + 3),
                "width": str(cw - 4), "height": str(ch - 6),
                "fill": f"#{40 + t * 180 // 255: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, ss = cfg["seeds"], cfg["strategySpace"]
    names, rows, win_rows = payoff_matrix(spec, ss, seeds)
    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])]
    sdom = [i for i in range(n) if all(strict_dom(rows[i], rows[j]) for j in range(n) if j != i)]
    dominated = sorted({j for _, j in st})
    keys = []
    for row in rows:
        wins = sum(1 for v in row if v >= ss["scoreScale"])
        avg_hp = sum(v % ss["scoreScale"] for v in row) // len(row)
        keys.append(("W" if wins * 2 >= len(row) else "L") + ":" + str(avg_hp // ss["hpBucket"]))
    counts = {}
    for kk in keys:
        counts[kk] = counts.get(kk, 0) + 1
    dom_permille = 1000 * len(sdom) // n

    ncsv = ashlib.write_csv(os.path.join(OUT, "gd_proto_11_space.csv"),
                            ["strategy"] + [str(s) for s in seeds],
                            [[names[i]] + list(rows[i]) for i in range(n)])
    size = heatmap(names, rows, os.path.join(OUT, "gd_proto_11_space.svg"))
    report = {"id": "gd_proto_11_space", "seeds": len(seeds), "strategies": names,
              "strictPairs": [[names[i], names[j]] for i, j in st],
              "weakPairs": [[names[i], names[j]] for i, j in wk],
              "strictlyDominant": [names[i] for i in sdom],
              "dominated": [names[i] for i in dominated],
              "dominantPermille": dom_permille, "dominantMissingPermille": 1000 - dom_permille,
              "spaceEntropyMilli": log2_milli(n),
              "outcomeEntropyMilli": entropy_milli(list(counts.values())),
              "outcomeKeys": {k: counts[k] for k in sorted(counts)}, "distinctOutcomes": len(counts),
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "gd_proto_11_space.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}  得分({len(seeds)} 个种子)")
    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 dominated]}")
    print(f"支配策略缺失度={1000 - dom_permille}‰(支配占比 {dom_permille}‰)")
    print(f"结果桶={report['outcomeKeys']}")
    print(f"空间熵={report['spaceEntropyMilli']}×1000  结果熵={report['outcomeEntropyMilli']}×1000"
          f"  不同结果={report['distinctOutcomes']}")
    print(f"csv={ncsv} rows  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/gd_proto_11_space.csv out/gd_proto_11_space.svg out/gd_proto_11_space.json")
    ET.parse(os.path.join(OUT, "gd_proto_11_space.svg"))
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑输出(/opt/homebrew/bin/python3 sim/gd_proto_11_2_strategy_space.py):

策略         胜局  得分(16 个种子)
slot0      16  [1048, 1052, 1046, 1047, 1047, 1052, 1048, 1048, 1048, 1042, 1052, 1052, 1052, 1052, 1042, 1052]
slot1      16  [1053, 1048, 1047, 1042, 1048, 1053, 1048, 1053, 1047, 1048, 1054, 1052, 1058, 1054, 1048, 1054]
slot2      16  [1052, 1047, 1047, 1047, 1053, 1052, 1048, 1048, 1048, 1048, 1052, 1052, 1052, 1052, 1047, 1046]
slot3      15  [1046, 1047, 1047, 1047, 1047, 1046, 1046, 1048, 1048, 1041, 1046, 1047, 1041, 30, 1046, 1046]
slot4       1  [29, 29, 29, 29, 29, 34, 29, 1030, 24, 29, 34, 29, 29, 29, 34, 34]

严格支配对 4: slot0≻slot4 slot1≻slot4 slot2≻slot4 slot3≻slot4
弱支配对   5: slot0⪰slot4 slot1⪰slot4 slot2⪰slot3 slot2⪰slot4 slot3⪰slot4
严格支配策略=[]  被支配策略=['slot4']
支配策略缺失度=1000‰(支配占比 0‰)
结果桶={'L:3': 1, 'W:4': 3, 'W:5': 1}
空间熵=2321×1000  结果熵=1370×1000  不同结果=3
csv=5 rows  svg=6892 bytes  elapsed=62 ms
wrote out/gd_proto_11_space.csv out/gd_proto_11_space.svg out/gd_proto_11_space.json

读数:空间没被解死,但有真实弱选项

读数值含义
严格支配对4slot0/1/2/3 各自严格支配 slot4
弱支配对5多出一对 slot2 ⪰ slot3(处处不劣、至少一处更好)
支配策略无(缺失度 1000‰)没有任何一条压死全部对手
被支配策略slot4唯一被严格支配的策略,胜局仅 1/16
结果熵1370×1000(3 桶)结果分三桶,空间未被压成单点
空间熵2321×1000log2(5),策略数决定的上界

这张表要一起读:没有支配策略(好——空间没被解死)但存在被支配策略(也要命——slot4 是条几乎必输的打法)。一个健康的小策略空间应该两者兼有:解不死,但确实有更差的选项。

P 层:把支配关系写成断言

P 层用同一份矩阵复算支配对,把「4 条严格对、5 条弱对、被支配恰是 slot4、缺失度 1000‰」钉成断言。

// snippet: 11.2 P 层节选(core/test/gd_proto_11_2_dominance.test.ts)
const strict = (a: number[], b: number[]): boolean => a.every((x, i) => x > b[i]);
const weak = (a: number[], b: number[]): boolean =>
  a.every((x, i) => x >= b[i]) && a.some((x, i) => x > b[i]);

test("严格支配对 4 条、弱支配对 5 条,被支配策略恰是 slot4", () => {
  const st: [number, number][] = [];
  const wk: [number, number][] = [];
  for (let i = 0; i < N; i += 1) for (let j = 0; j < N; j += 1) {
    if (i === j) continue;
    if (strict(ROWS[i], ROWS[j])) st.push([i, j]);
    if (weak(ROWS[i], ROWS[j])) wk.push([i, j]);
  }
  assert.deepEqual(st, [[0, 4], [1, 4], [2, 4], [3, 4]]);
  const dominated = [...new Set(st.map((p) => p[1]))].sort((a, b) => a - b);
  assert.deepEqual(dominated.map((i) => NAMES[i]), ["slot4"]);
});

test("没有支配策略:支配策略缺失度 = 1000‰", () => {
  const sdom = ROWS.map((_, i) => i)
    .filter((i) => ROWS.every((_, j) => j === i || strict(ROWS[i], ROWS[j])));
  assert.deepEqual(sdom, []);
  assert.equal(1000 - Math.floor((1000 * sdom.length) / N), 1000);
});

实跑输出(节选):

✔ 严格支配对 4 条、弱支配对 5 条,被支配策略恰是 slot4 (1.421334ms)
✔ 没有支配策略:支配策略缺失度 = 1000‰ (0.162792ms)
✔ 结果分布熵 ×1000:3 个结果桶,熵 1370;空间熵 2321 (0.218625ms)
✔ 被支配策略 slot4 是真实低分:胜局 1,且每格都被 slot1 压住 (0.122792ms)
✔ 得分矩阵是确定性的:两次独立计算逐字相同 (0.886125ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1613.30825

熵与支配度都是代理指标,随种子池与得分口径变化。要横向比不同策略族时,必须用同一套种子、同一套得分公式,否则两个数不可比。

阅读导航:上一节:11.1 战斗的机制解剖:伤害、时序、资源 · 下一节:11.3 战斗的相变与支配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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