站内的《小游戏开发实战》卷一 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
判读:序列不读状态,树读状态
三行读数是本节的核心对照:
| 表示 | 实现出的序列条数 | 实现出的结果条数 | 说明 |
|---|---|---|---|
| 动作序列 | 1 | 16 | 序列恒定,牌序变了只有结果变 |
| 决策树 | 16 | 15 | 16 个种子长出 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 熵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。