1.1 策略的表示:动作序列与决策树

策略是「状态到动作」的映射,在规格里有两种写法:定长的动作序列与按状态分支的决策树。本节用《灰烬牌库》把两种写法并排跑一遍——同一份 play_spec.json,固定序列在 16 个种子上只实现出 1 条序列,贪心决策树实现出 16 条,而自实现步进器的 digest 与公共库逐字一致。

站内的《小游戏开发实战》卷一 6.1 核心循环拆解 用 S 层算的是核心循环的时长占比——一局里打牌、结算、决策各占多少时间,是时间轴上的度量;本节算的是策略空间——玩家能按出的动作序列一共有多少条、这些序列的形状如何,是状态空间上的度量。两者的数据源相同(同一份 config/play_spec.json),口径完全不同:前者问「一局有多长」,后者问「一局有多少种走法」。卷一已经证明了这份规格是可执行、可断言的;卷二从这里开始,把它真的跑起来。

1.1 策略的表示:动作序列与决策树

**策略(strategy)**在博弈论与强化学习里有一个标准定义:从状态到动作的映射 π: S → A。它不规定「现在走到了哪」,只规定「走到这里该按什么」。落到《灰烬牌库》上,一张策略就是一张把当下可观测状态(能量、手牌)换成下一条指令的表。整卷要算的所有东西——策略空间的规模、支配策略、涌现、均衡——都建在这张表上,所以先把这张表写清楚。

同一张表有两种写法,形状完全不同:

表示形状读状态?规模本节的例子
动作序列定长指令列表否,按位回放长度 × 字母表规格里的 strategy 字段(25 个符号)
决策树state → action 的函数是,逐点分支分支数 ^ 决策点数贪心策略(取伤害最高的牌)

两种写法的关系是一句话:动作序列是退化的决策树——它每一步都不分支,所以一个序列就是一棵「每个节点只有一个子节点」的树。反过来,一棵决策树在给定输入下会长出一条动作序列:这就是它在某个种子上实现出来的走法。

表示一:动作序列

规格里的 strategy 字段就是第一种写法。它是一个 25 个符号的定长列表,每个符号要么是「打第 i 张牌」(p0…p4),要么是「结束回合」(e),字母表大小 |A| = 6。它不读状态,从头按到尾:p0 p0 p0 p0 e p0 p0 p0 p0 e p0 p0 p0 p0 e p0 p0 p0 p0 e p0 p0 p0 p0 e。

这条序列的含义是「每回合尽量打第 0 张牌,打不动就结束回合」。它的关键性质是非适应性:无论牌序怎么变、手牌长什么样,它按下的键序列永远一模一样。牌序变了,只有结果会变,序列不会变。

表示二:决策树

第二种写法是决策树。下面这棵树的每个节点问一个问题,答案决定下一步:

决策点问什么分支
根有可负担的牌吗?是 / 否
「是」的内部节点其中哪张伤害最高?按 card.damage 分 4 支
「否」的叶——结束回合

它的叶子是动作,内部节点是可观测状态上的判断。和固定序列不同,它读状态:同一棵树,换一个牌序,它会在某个决策点选另一张牌,于是实现出一条不同的序列。

S 层:自检与两种表示的对照

sim/gd_play_01_representation.py 做三件事:先用一个自实现的忠实步进器回放 baseline 序列,断言 digest 与公共库 ashlib.run_spec 逐字相同(证明步进器与底座同规则);再把两种表示各在 16 个种子上跑一遍,数实现出来的序列条数;最后把决策树真正需要区分的「决策上下文」(能量 + 可负担手牌多重集)数出来。全整数,无浮点,无随机。

#!/usr/bin/env python3
# file: sim/gd_play_01_representation.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_01_representation.py
"""卷二 1.1 策略的表示(S 层):动作序列与决策树是同一个策略的两种写法。

同一份 play_spec.json,策略有两种等价写法:
  ① 动作序列:定长指令列表,不读状态、按位回放——就是规格里 strategy 字段本身。
  ② 决策树:state → action 的函数,按当下可观测状态(能量、手牌)分支——同一策略
     在不同种子上会长出不同序列。

三步可核对:A. 自实现的忠实步进器回放 baseline 序列,digest 必须与 ashlib.run_spec
逐字相同;B. 固定序列 vs 贪心决策树各在 16 个种子上跑,数**实现出来的序列**条数;
C. 把决策树真正需要区分的「决策上下文」(能量 + 可负担手牌多重集)数出来。
全整数、无浮点、无 random。产出 out/play01_representation.{json,csv,svg}。
"""
from __future__ import annotations

import json
import os
import sys
import xml.etree.ElementTree as ET

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
SEEDS = [201, 202, 203, 204, 205, 206, 207, 208,
         209, 210, 211, 212, 213, 214, 215, 216]   # 与后续各节同一批种子


def alphabet(spec: dict) -> list[str]:
    """动作字母表:handSize 个出牌符号 + 1 个结束回合符号。"""
    return ["p" + str(i) for i in range(spec["params"]["handSize"])] + ["e"]


def new_env(spec: dict, seed: int) -> dict:
    """开一局:与 ashlib.run_spec 的初始化逐条对应。"""
    p = spec["params"]
    deck = [c for c in spec["cards"] for _ in range(c["count"])]
    deck = ashlib.shuffle_deck(deck, seed)
    return {"p": p, "hand": deck[: p["handSize"]], "draw": deck[p["handSize"]:],
            "discard": [], "turn": 1, "energy": p["energyMax"], "playerHp": p["playerHp"],
            "enemyHp": p["enemyHp"], "block": 0, "dealt": 0, "played": 0,
            "over": False, "won": False}


def step(env: dict, cmd: str) -> None:
    """施加一条指令:与 ashlib.run_spec 的循环体逐条对应(含非法指令静默丢弃)。"""
    p = env["p"]
    if env["over"]:
        return
    if env["turn"] > p["turnLimit"]:
        env["over"], env["won"] = True, False
        return
    if cmd.startswith("p"):
        i = int(cmd[1:])
        if not (0 <= i < len(env["hand"])):
            return
        c = env["hand"][i]
        if c["cost"] > env["energy"]:
            return
        env["hand"].pop(i)
        env["discard"].append(c)
        env["energy"] -= c["cost"]
        env["block"] += c["block"]
        d = min(c["damage"], env["enemyHp"])
        env["enemyHp"] -= d
        env["dealt"] += d
        env["played"] += 1
        if env["enemyHp"] <= 0:
            env["over"], env["won"] = True, True
    elif cmd == "e":
        ab = min(env["block"], p["enemyAttack"])
        env["block"] -= ab
        env["playerHp"] = max(0, env["playerHp"] - (p["enemyAttack"] - ab))
        if env["playerHp"] <= 0:
            env["over"], env["won"] = True, False
            return
        env["turn"] += 1
        env["block"] = 0
        env["energy"] = p["energyMax"]
        while len(env["hand"]) < p["handSize"] and env["draw"]:
            env["hand"].append(env["draw"].pop(0))


def snap(env: dict) -> dict:
    """取快照:键与 ashlib.run_spec 的返回值一致。"""
    p = env["p"]
    return {"turn": env["turn"], "energy": env["energy"], "playerHp": env["playerHp"],
            "enemyHp": env["enemyHp"], "block": env["block"], "dealt": env["dealt"],
            "played": env["played"], "over": env["over"], "won": env["won"],
            "hand": [c["id"] for c in env["hand"]],
            "cardCount": len(env["hand"]) + len(env["draw"]) + len(env["discard"]),
            "damageConserved": env["dealt"] == p["enemyHp"] - env["enemyHp"]}


def greedy(env: dict):
    """表示②:贪心决策树。可负担就取伤害最高的那张(并列取下标最小),否则结束回合。"""
    if env["over"]:
        return None
    afford = [i for i, c in enumerate(env["hand"]) if c["cost"] <= env["energy"]]
    if not afford:
        return "e"
    return "p" + str(max(afford, key=lambda i: (env["hand"][i]["damage"], -i)))


def drive(spec: dict, policy, seed: int, cap: int) -> tuple:
    """用决策树驱动一局:返回(实现出的序列, 决策上下文列表, env)。"""
    env, seq, ctxs = new_env(spec, seed), [], []
    for _ in range(cap):
        if env["over"]:
            break
        ctxs.append((env["energy"], tuple(sorted(c["id"] for c in env["hand"]
                                                 if c["cost"] <= env["energy"]))))
        cmd = policy(env)
        if cmd is None:
            break
        seq.append(cmd)
        step(env, cmd)
    return seq, ctxs, env


def replay(spec: dict, seq: list, seed: int) -> dict:
    """表示①:按位回放一个定长动作序列。"""
    env = new_env(spec, seed)
    for cmd in seq:
        step(env, cmd)
    return env


def tree_svg(path: str) -> int:
    """决策树示意图:根问「有可负担的牌吗」,两个分支落到两个叶。"""
    root = ashlib.svg_begin(560, 210)
    ashlib.svg_text(root, 20, 30, "1.1 决策树:每个决策点问一次,分支由可观测状态决定", 14)
    for x, y, w, label in [(200, 60, 160, "有可负担的牌?"), (60, 130, 150, "是 → 取伤害最高的"),
                           (350, 130, 150, "否 → 结束回合")]:
        ET.SubElement(root, "rect", {"x": str(x), "y": str(y), "width": str(w),
                                     "height": "34", "fill": "#1e293b",
                                     "stroke": "#38bdf8", "rx": "6"})
        ashlib.svg_text(root, x + w // 2, y + 22, label, 12, "#e2e8f0", "middle")
    for x2, lab, lx in [(135, "是", 150), (425, "否", 410)]:
        ET.SubElement(root, "line", {"x1": "280", "y1": "94", "x2": str(x2), "y2": "130",
                                     "stroke": "#64748b"})
        ashlib.svg_text(root, lx, 120, lab, 11, "#94a3b8")
    ashlib.svg_text(root, 20, 190, "树形:1 根 + 1 内部节点 + 5 叶(按 damage 分 4 支 + 结束回合)",
                    11, "#94a3b8")
    return ashlib.svg_save(root, path)


def main() -> int:
    spec = ashlib.load_spec()
    alpha = alphabet(spec)
    base_seq = list(spec["strategy"])
    cap = spec["params"]["turnLimit"] * (spec["params"]["handSize"] + 1)

    # A. 自检:步进器回放 baseline 序列,digest 必须与公共库一致。
    mine = ashlib.digest(snap(replay(spec, base_seq, spec["seed"])))
    theirs = ashlib.digest(ashlib.run_spec(spec))
    selfcheck = mine == theirs

    # B/C. 两种表示在 16 个种子上各跑一遍。
    rows, seq_digests, pol_seqs, pol_digests, pol_ctxs = [], set(), set(), set(), set()
    for sd in SEEDS:
        s1 = snap(replay(spec, base_seq, sd))
        d1 = ashlib.digest(s1)
        seq_digests.add(d1)
        pseq, ctxs, e2 = drive(spec, greedy, sd, cap)
        s2 = snap(e2)
        pol_seqs.add(tuple(pseq))
        pol_digests.add(ashlib.digest(s2))
        pol_ctxs.update(ctxs)
        rows.append([sd, "sequence", len(base_seq), s1["won"], d1])
        rows.append([sd, "policy", len(pseq), s2["won"], ashlib.digest(s2)])

    n = ashlib.write_csv(os.path.join(OUT, "play01_representation.csv"),
                         ["seed", "repr", "steps", "won", "digest"], rows)
    b = tree_svg(os.path.join(OUT, "play01_representation.svg"))
    report = {"id": "gd_play_01_representation", "alphabet": alpha, "alphabetSize": len(alpha),
              "baselineLength": len(base_seq), "selfcheckDigestMatch": selfcheck,
              "selfcheckDigest": mine, "seeds": len(SEEDS),
              "sequence": {"distinctRealized": 1, "distinctOutcomes": len(seq_digests)},
              "policy": {"distinctRealized": len(pol_seqs), "distinctOutcomes": len(pol_digests),
                         "decisionContexts": len(pol_ctxs)}}
    with open(os.path.join(OUT, "play01_representation.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"alphabet |A|={len(alpha)} : {','.join(alpha)}")
    print(f"baseline sequence len={len(base_seq)} : {' '.join(base_seq)}")
    print(f"selfcheck digest match={selfcheck}  digest={mine}")
    print(f"repr=sequence seeds={len(SEEDS)} distinctRealized=1 "
          f"distinctOutcomes={len(seq_digests)}")
    print(f"repr=policy   seeds={len(SEEDS)} distinctRealized={len(pol_seqs)} "
          f"distinctOutcomes={len(pol_digests)} decisionContexts={len(pol_ctxs)}")
    print("per-seed realized sequences (repr=policy):")
    for sd in SEEDS:
        pseq, _, _ = drive(spec, greedy, sd, cap)
        print(f"  seed={sd} len={len(pseq)} {' '.join(pseq)}")
    print(f"wrote out/play01_representation.csv rows={n}")
    print(f"wrote out/play01_representation.svg bytes={b}")
    ET.parse(os.path.join(OUT, "play01_representation.svg"))
    return 0 if selfcheck else 1


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_01_representation.py:

alphabet |A|=6 : p0,p1,p2,p3,p4,e
baseline sequence len=25 : p0 p0 p0 p0 e p0 p0 p0 p0 e p0 p0 p0 p0 e p0 p0 p0 p0 e p0 p0 p0 p0 e
selfcheck digest match=True  digest={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
repr=sequence seeds=16 distinctRealized=1 distinctOutcomes=16
repr=policy   seeds=16 distinctRealized=16 distinctOutcomes=15 decisionContexts=37
per-seed realized sequences (repr=policy):
  seed=201 len=10 p4 p1 p0 e p0 p1 p1 p0 e p2
  seed=202 len=10 p1 p0 p2 e p0 p0 p0 p1 e p1
  seed=203 len=9 p0 p0 p1 e p4 p1 p2 e p2
  seed=204 len=8 p0 p1 p2 e p1 p1 e p3
  seed=205 len=10 p0 p0 e p1 p2 p2 e p2 p2 p2
  seed=206 len=9 p3 p1 p0 e p1 p0 p2 e p3
  seed=207 len=10 p3 p0 e p2 p3 p2 p0 e p1 p2
  seed=208 len=8 p0 p0 e p2 p1 p2 e p1
  seed=209 len=9 p1 p0 e p4 p1 p2 e p1 p1
  seed=210 len=8 p2 p2 e p2 p2 p2 e p3
  seed=211 len=8 p2 p0 p2 e p2 p1 e p2
  seed=212 len=9 p0 p0 e p1 p0 p1 e p2 p2
  seed=213 len=9 p0 p0 e p2 p0 p1 p1 e p2
  seed=214 len=8 p3 p0 e p2 p0 p1 e p2
  seed=215 len=8 p0 p0 e p1 p1 p2 e p4
  seed=216 len=10 p1 p2 p1 e p3 p3 p2 p0 e p1
wrote out/play01_representation.csv rows=32
wrote out/play01_representation.svg bytes=1641

判读:序列不读状态,树读状态

三行读数是本节的核心对照:

表示实现出的序列条数实现出的结果条数说明
动作序列116序列恒定,牌序变了只有结果变
决策树161516 个种子长出 16 条不同序列

固定序列在 16 个种子上只实现出 1 条序列,却有 16 个不同结果。这正是「非适应性」的可测形态:它按下的键从不改变,改变的是牌序,而牌序只影响结算、不影响指令。第一行 distinctRealized=1 与 distinctOutcomes=16 的落差,就是「不读状态」的代价——它对每个种子都用同一套走法,好种子赢、坏种子输。

决策树在 16 个种子上长出 16 条序列,几乎没有两条相同。这说明它确实在读状态:手牌不同,它在同一个决策点选出的牌就不同。第二行的 distinctRealized=16 就是「读状态」的收益——它能随牌序调整走法。

最后一行 decisionContexts=37 是这棵树的规模下界:它一共遇到了 37 个互不相同的「能量 + 可负担手牌多重集」组合。任何忠实实现这棵树的代码,至少要能区分这 37 种上下文——这是 1.3 要压的规模的第一个具体数字。

selfcheck digest match=True 则是整节的可信度地基。为了让决策树能逐步读取状态,脚本自己实现了一个步进器 step();如果它和公共库 ashlib.run_spec 的规则有一处不同,后面所有对照都会失真。自检的做法是:用步进器回放规格自带的 baseline 序列,把快照过 ashlib.digest,与 ashlib.run_spec 的结果逐字比较——两个 digest 完全相同,才继续往下算。这与卷一第 15 章「跨语言摘要」用的是同一套 digest 口径。

两种表示的取舍

维度动作序列决策树
读状态否是
同一输入下的输出恒定随状态变
规模长度 × 字母表分支数 ^ 决策点数
适合场景回归测试、确定性回放描述玩家行为、算策略空间
与规格的关系strategy 字段本体需要额外写一个 policy() 函数

两种表示不是竞争关系,而是两个刻度:动作序列是「把一局录下来」,决策树是「把一局的走法规则写下来」。后面几节要枚举的是动作序列(因为它是有限、可数的),而要分析玩家会怎么走,用的是决策树。

检查项合格标准
字母表从规格长出`
步进器同规则回放 baseline 的 digest 与 ashlib.run_spec 逐字相同
两种表示都真跑序列与决策树各在 16 个种子上跑过,非纸面推演
上下文数有定义决策上下文写成(能量, 可负担手牌多重集)二元组

下一节把「动作序列」这一侧做成可数的对象:它一共有多大、枚举出来长什么形状、用 Shannon 熵怎么刻画。

阅读导航:上一节:目录 · 下一节:1.2 策略空间枚举与 Shannon 熵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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