3.1 从规格到轨迹

快照只留终态,轨迹留每一步的状态序列。本节把 run_spec 的规则拆成「一步」,逐步重放策略并记录每步的能量、双方血量、格挡与累计伤害,导出 out/mech03_trajectory.csv。基线策略实际执行 12 步,未执行的 13 个指令不计入轨迹长度,且轨迹末态与快照逐位一致。

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:两个数字不一样,而且都对。

量值数的是什么
strategyLen25规格里写了几条指令
trajectoryLen12实际执行了几步(出牌 + 结束回合)
playedInSnapshot9其中出牌的步数
skipped13没有成为一步的指令数

基线策略给了 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 资源守恒与不变量检验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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