3.1 从规格到轨迹
一份规格跑一局,ashlib.run_spec 只交回一个快照——终态的那十几个字段。要看清机制在时间里怎么走,得把每一步都留下来,那就是轨迹。《小游戏开发实战》卷一的核心循环拆解
也用 S 层算循环,但它算的是循环时长占比(一次循环占整局多久),是产品口径的节奏账;本节算的是状态序列本身——每一步的能量、双方血量、格挡与累计伤害,是机制口径的轨迹账。守恒与不变量检验见 3.2,输出的可读化见 3.3。
快照是一个点,轨迹是一条线
run_spec 返回的字典是快照:turn / energy / playerHp / enemyHp / block / dealt / played / over / won / hand / cardCount / damageConserved。它是整局结束那一刻的一个点。
轨迹是这条点走过的线:把同一套规则逐步执行,每执行一步就记一行状态。轨迹就是状态序列。二者不是两次运行,是同一局的两个视图——所以轨迹的末态必须与快照逐位一致,一旦不一致,就说明重放规则与 run_spec 已经分叉了。
| 维度 | 快照 snapshot | 轨迹 trajectory |
|---|---|---|
| 是什么 | 一个终态 | 一条状态序列 |
| 粒度 | 整局一个 | 每步一行 |
| 能回答 | 赢没赢、打了几点、剩多少血 | 第几步掉光能量、哪一步开始掉血 |
| 长度 | 恒为 1 | 等于实际执行的步数 |
| 用途 | 跨语言 digest 比对 | 可视化、节奏分析、确定性回放 |
这个「点与线」的区分不是修辞。快照适合做断言(第 4 章的不变量、第 15 章的跨语言摘要都吃快照),轨迹适合做观察(第 3.3 节的血线图、第 7 章的回路增益、第 8 章的奖励节奏,都要逐步数据)。同一份规格同时供给两者,是 S 层的第一个产出。
逐步重放:把 run_spec 拆成「一步」
轨迹由逐步重放得到。规则与 ashlib.run_spec 逐条对应——出牌扣能量、加格挡、结算伤害,结束回合敌人攻击(先扣格挡再扣血)、抽牌、能量重置——区别只有一个:每执行一步之后把状态记一行。被跳过的非法动作(下标越界)与欠费动作(能量不足)continue 掉,不产生轨迹行。
#!/usr/bin/env python3
# file: sim/gd_mech_03_trajectory.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_03_trajectory.py
"""3.1 从规格到轨迹(S 层)。
`ashlib.run_spec` 只返回**快照**(终态)。本脚本把同一套规则拆成「一步」逐步执行,
每执行一步就记一行状态,得到**轨迹**:一条按步序排列的状态序列。
规则与 ashlib.run_spec 逐条对应(复用 ashlib 的洗牌与常数,只把每步状态留下来):
出牌扣能量、加格挡、结算伤害;结束回合敌人攻击(先扣格挡再扣血)、抽牌、能量重置。
轨迹长度 = **实际执行的步数**——被跳过的非法动作(下标越界)与欠费动作(能量不足)
**不计入**,与快照里的 played 不是同一个量。
导出 out/mech03_trajectory.csv(step/turn/energy/playerHp/enemyHp/block/dealt/action),
打印首尾几行。整条轨迹的最后一个状态必须与 run_spec 的快照逐位一致。
"""
from __future__ import annotations
import os
import sys
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
HEADER = ["step", "turn", "energy", "playerHp", "enemyHp", "block", "dealt", "action"]
def replay(spec: dict) -> list:
"""逐步重放 strategy,返回轨迹(每步一行状态)。跳过非法/欠费动作,不计行。"""
p = spec["params"]
deck: list = []
for c in spec["cards"]:
deck.extend([c] * c["count"])
deck = ashlib.shuffle_deck(deck, spec["seed"])
hand = deck[: p["handSize"]]
draw = deck[p["handSize"]:]
discard: list = []
turn = 1
energy = p["energyMax"]
player_hp = p["playerHp"]
enemy_hp = p["enemyHp"]
block = 0
dealt = 0
over = False
rows: list = []
for cmd in spec["strategy"]:
if over:
break
if turn > p["turnLimit"]:
over = True
break
t = turn # 本步发生在第几回合(end_turn 会把 turn 加一,故先记下)
if cmd.startswith("p"):
idx = int(cmd[1:])
if idx < 0 or idx >= len(hand):
continue # 非法动作:不计步
c = hand[idx]
if c["cost"] > energy:
continue # 欠费动作:不计步
hand.pop(idx)
discard.append(c)
energy -= c["cost"]
block += c["block"]
d = min(c["damage"], enemy_hp)
enemy_hp -= d
dealt += d
action = "play:" + c["id"]
if enemy_hp <= 0:
over = True
elif cmd == "e":
absorbed = min(block, p["enemyAttack"])
block -= absorbed
player_hp = max(0, player_hp - (p["enemyAttack"] - absorbed))
action = "end_turn"
if player_hp <= 0:
over = True
rows.append(mk(len(rows) + 1, t, energy, player_hp, enemy_hp, block, dealt, action))
break
turn += 1
block = 0
energy = p["energyMax"]
while len(hand) < p["handSize"] and draw:
hand.append(draw.pop(0))
else:
continue # 未知指令:不计步
rows.append(mk(len(rows) + 1, t, energy, player_hp, enemy_hp, block, dealt, action))
return rows
def mk(step, turn, energy, player_hp, enemy_hp, block, dealt, action):
return [step, turn, energy, player_hp, enemy_hp, block, dealt, action]
def main() -> int:
spec = ashlib.load_spec()
rows = replay(spec)
snap = ashlib.run_spec(spec)
out = os.path.join(ROOT, "out", "mech03_trajectory.csv")
n = ashlib.write_csv(out, HEADER, rows)
# 轨迹末态必须与快照逐位一致:轨迹不是另跑一遍,是同一条时间线。
last = rows[-1]
same = (
last[2] == snap["energy"]
and last[3] == snap["playerHp"]
and last[4] == snap["enemyHp"]
and last[5] == snap["block"]
and last[6] == snap["dealt"]
)
print(f"deckSize={ashlib.deck_size(spec)} strategyLen={len(spec['strategy'])}")
print(f"trajectoryLen={n} playedInSnapshot={snap['played']} skipped={len(spec['strategy']) - n}")
print("step turn energy playerHp enemyHp block dealt action")
for r in rows[:4]:
print(" ".join(str(x) for x in r))
print("...")
for r in rows[-4:]:
print(" ".join(str(x) for x in r))
print(f"snapshot turn={snap['turn']} enemyHp={snap['enemyHp']} won={snap['won']}")
print(f"trajectoryLast==snapshot: {same}")
print(f"wrote out/mech03_trajectory.csv rows={n}")
return 0 if same else 1
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_03_trajectory.py:
deckSize=12 strategyLen=25
trajectoryLen=12 playedInSnapshot=9 skipped=13
step turn energy playerHp enemyHp block dealt action
1 1 3 60 47 0 3 play:jab
2 1 1 60 33 0 17 play:heavy
3 1 0 60 27 0 23 play:strike
4 1 3 54 27 0 23 end_turn
...
9 3 2 54 15 0 35 play:strike
10 3 1 54 9 0 41 play:strike
11 3 3 48 9 0 41 end_turn
12 4 1 48 0 50 play:heavy
snapshot turn=4 enemyHp=0 won=True
trajectoryLast==snapshot: True
wrote out/mech03_trajectory.csv rows=12
读出来:第 1 回合用 jab(0 费)起手,接着 heavy(2 费)与 strike(1 费)把 3 点能量打光,敌方从 50 掉到 27;回合结束敌人反击 6 点,格挡为 0 所以玩家从 60 掉到 54。第 2、3 回合继续压血,第 4 回合用一张 heavy 收尾,dealt 累到 50,敌方归零。轨迹末行与快照逐位一致(trajectoryLast==snapshot: True),说明重放没有跑偏。
落盘的 out/mech03_trajectory.csv 全 12 行(中段省略):
step,turn,energy,playerHp,enemyHp,block,dealt,action
1,1,3,60,47,0,3,play:jab
2,1,1,60,33,0,17,play:heavy
3,1,0,60,27,0,23,play:strike
4,1,3,54,27,0,23,end_turn
...
12,4,1,48,0,50,play:heavy
这份 CSV 是下游的直接输入:enemyHp 列就是 3.3 血线图的纵坐标,energy 列就是第 7 章回路增益要看的逐步余量。轨迹一旦落成规整的表,任何「按步」的分析都不必重跑仿真。
轨迹长度为什么不是 played
看 trajectoryLen=12 和 playedInSnapshot=9:两个数字不一样,而且都对。
| 量 | 值 | 数的是什么 |
|---|---|---|
strategyLen | 25 | 规格里写了几条指令 |
trajectoryLen | 12 | 实际执行了几步(出牌 + 结束回合) |
playedInSnapshot | 9 | 其中出牌的步数 |
skipped | 13 | 没有成为一步的指令数 |
基线策略给了 25 条指令,只有 12 条变成了步:9 次出牌、3 次结束回合。剩下的 13 条没执行——有的是能量不足被 continue 掉(例如第 1 回合第 4 个 p0,此时能量已是 0),有的是胜负已分之后不再执行。这正是「轨迹长度 = 实际执行的步数」的含义:被跳过的动作不留痕。如果拿 strategyLen 当轨迹长度,或者拿 played 当轨迹长度,都会得到一条与真实时间线对不上的序列。
什么时候要轨迹,什么时候只要快照
- 要轨迹:可视化(3.3 的血线图直接由轨迹生成)、节奏与回路分析(第 7、8 章要逐步数据)、确定性回放(第 9 章按步重演)、调试「第几步开始不对劲」。
- 只要快照:不变量断言(第 4 章只判终态是否守恒)、跨语言 digest 比对(第 15 章比的是终态摘要)、胜负与耗时的批量统计。
- 两者都要:守恒检验(3.2)既看快照的四条不变量,也用轨迹确认每一步都没越界。
自检表
| 检查项 | 合格标准 |
|---|---|
| 重放规则一致 | 轨迹末态与 run_spec 快照逐位相等 |
| 跳过不计步 | 非法 / 欠费动作不产生轨迹行 |
| 纯整数 | 全程无浮点,能量与血量都是整数 |
| 确定性 | 同 seed 同策略,两次重放轨迹逐行相同 |
| 列齐全 | step/turn/energy/playerHp/enemyHp/block/dealt/action 八列 |
轨迹把「一局」展开成了一条可以逐步检视的时间线。下一条问题就来了:这条线上每一步的状态,有没有越界。
阅读导航:上一节:2.3 规格的版本与迁移 · 下一节:3.2 资源守恒与不变量检验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。