1.2 纸面原型的记录与回放

纸面推演只有结论就是无法复现的轶事。本节把一次两回合推演记成「动作序列 + 每步全量整数状态」的 JSON,再用引擎回放它、逐字段比对,误差必须是 0;每步还校验牌数、伤害、能量、格挡四条守恒。负对照把第 7 步抄错 1 点,回放必须精确定位到那一步。

1.2 纸面原型的记录与回放

《小游戏开发实战》的原型范围与目标 管的是验什么、验到什么程度;本节管的是怎么把验的过程记下来。最小可玩原型 的产品口径关心「原型能不能玩」;本节的纸面口径关心「这次推演能不能被另一个人原样重放」。纸面原型如果只留结论(「推了两回合,敌人剩 21 血」),它就是一段无法复现的轶事;如果记下了每一步的状态,它就能被回放、被比对、被证伪——而能被证伪,正是它区别于「写文档」的地方。

记录什么:动作序列 + 每步全量状态

纸面推演记录只有两样东西:你做了什么(动作序列)和做完之后世界是什么样(每步的全量整数状态)。前者是输入,后者是人工抄写的结果;回放就是把输入重新喂给引擎,看它吐出的状态和抄下来的对不对得上。记录格式固定五字段,缺任何一个都会让回放失去意义:

字段含义约束
record_version记录格式版本整数;升版时旧记录仍可读
seed洗牌种子整数;同种子在任何语言下同序
moves动作序列字符串数组,按发生顺序
state_fields记录哪些状态字段必须是状态变量集合的子集
states每步之后的全量状态长度必须等于 moves 的长度

一次两回合的纸面推演记录(D 档)

下面是《灰烬牌库》一次真实纸面推演的记录:种子 20261014,玩家打两回合,共 10 个动作。注意第 4 与第 9 个动作是无效动作——能量已经打光,R1 的条件不成立,状态原地不动,所以 states[4] 与 states[3] 逐字相同。纸面记录不删掉无效动作,因为「玩家试了一下但没打出去」也是真实发生的事:

{
  "record_version": 1,
  "game": "ashdeck",
  "seed": 20261014,
  "note": "纸面推演记录:人工抄写,两回合共 10 个动作。states[i] 是第 i 个动作执行后的全量整数状态。",
  "moves": ["p0", "p0", "p0", "p0", "e", "p0", "p0", "p0", "p0", "e"],
  "state_fields": ["turn", "energy", "playerHp", "enemyHp", "block", "played", "hand"],
  "states": [
    {"turn": 1, "energy": 3, "playerHp": 60, "enemyHp": 47, "block": 0, "played": 1, "hand": ["heavy", "strike", "guard", "guard"]},
    {"turn": 1, "energy": 1, "playerHp": 60, "enemyHp": 33, "block": 0, "played": 2, "hand": ["strike", "guard", "guard"]},
    {"turn": 1, "energy": 0, "playerHp": 60, "enemyHp": 27, "block": 0, "played": 3, "hand": ["guard", "guard"]},
    {"turn": 1, "energy": 0, "playerHp": 60, "enemyHp": 27, "block": 0, "played": 3, "hand": ["guard", "guard"]},
    {"turn": 2, "energy": 3, "playerHp": 54, "enemyHp": 27, "block": 0, "played": 3, "hand": ["guard", "guard", "strike", "strike", "strike"]},
    {"turn": 2, "energy": 2, "playerHp": 54, "enemyHp": 27, "block": 5, "played": 4, "hand": ["guard", "strike", "strike", "strike"]},
    {"turn": 2, "energy": 1, "playerHp": 54, "enemyHp": 27, "block": 10, "played": 5, "hand": ["strike", "strike", "strike"]},
    {"turn": 2, "energy": 0, "playerHp": 54, "enemyHp": 21, "block": 10, "played": 6, "hand": ["strike", "strike"]},
    {"turn": 2, "energy": 0, "playerHp": 54, "enemyHp": 21, "block": 10, "played": 6, "hand": ["strike", "strike"]},
    {"turn": 3, "energy": 3, "playerHp": 54, "enemyHp": 21, "block": 0, "played": 6, "hand": ["strike", "strike", "heavy", "strike", "jab"]}
  ]
}

回放:逐字段比对 + 每步守恒

回放脚本做两件事。第一,逐字段比对:把 moves 的前 k 个动作喂给引擎,得到第 k 步的快照,与 states[k-1] 的每个字段比,误差必须是 0(整数状态,不设容差)。第二,每步守恒:牌数恒等于牌库大小、伤害守恒、能量落在 [0, energyMax]、格挡非负。再加一次负对照——把第 7 步的 playerHp 抄错 1 点,回放必须精确报出是第 7 步:

#!/usr/bin/env python3
# file: sim/gd_proto_01_record_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_01_record_replay.py
"""卷三 1.2 纸面原型的记录与回放(S 层):把一次纸面推演回放并逐条校验守恒。

纸面推演记录 config/gd_proto_01_paper_record.json 记的是「动作序列 + 每步之后的全量整数状态」。
回放 = 用引擎按同一条动作序列重跑,逐字段比对人工抄写的状态;误差必须是 **0**(全整数,无浮点)。
同时每步校验四条守恒:牌数守恒、伤害守恒、能量在界内、格挡非负。

再做一次**负对照**:把记录里第 7 步的 playerHp 抄错 1 点,回放必须精确报出这一步——
回放检查能定位抄写错误,才算真的在比对。

产出 out/gd_proto_01_replay.json、out/gd_proto_01_trajectory.csv、out/gd_proto_01_replay.svg。
全整数、无浮点、无 random。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

RECORD = os.path.join(ROOT, "config", "gd_proto_01_paper_record.json")


def load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def compare(rec, spec, mutate=None):
    """按动作序列逐步回放,返回 (错配列表, 每步快照列表)。"""
    fields = rec["state_fields"]
    moves = rec["moves"]
    states = json.loads(json.dumps(rec["states"]))
    if mutate is not None:
        step, key, delta = mutate
        states[step - 1][key] += delta
    mismatches, snaps = [], []
    for k in range(1, len(moves) + 1):
        snap = ashlib.run_spec(spec, moves[:k])
        snaps.append(snap)
        for f in fields:
            if snap[f] != states[k - 1][f]:
                mismatches.append({"step": k, "move": moves[k - 1], "field": f,
                                   "engine": snap[f], "record": states[k - 1][f]})
    return mismatches, snaps


def conservation(spec, snaps):
    ds = ashlib.deck_size(spec)
    p = spec["params"]
    rows = []
    for i, s in enumerate(snaps, 1):
        rows.append({
            "step": i,
            "cardCountEqDeck": s["cardCount"] == ds,
            "damageConserved": s["damageConserved"] is True,
            "energyInRange": 0 <= s["energy"] <= p["energyMax"],
            "blockNonNegative": s["block"] >= 0,
        })
    ok = all(all(v for k, v in r.items() if k != "step") for r in rows)
    return rows, ok


def draw_svg(snaps, path):
    spec = ashlib.load_spec()
    root = ashlib.svg_begin(660, 220)
    ashlib.svg_text(root, 16, 24, "纸面推演回放:每步之后的敌方 HP(整数)")
    w, gap, x0, base = 52, 10, 24, 40
    den = spec["params"]["enemyHp"]
    for i, s in enumerate(snaps):
        x = x0 + i * (w + gap)
        ashlib.svg_bar(root, x, 60, w, 120, s["enemyHp"], den, "#38bdf8")
        ashlib.svg_text(root, x + w // 2, 198, str(i + 1), 12, "#94a3b8", "middle")
        ashlib.svg_text(root, x + w // 2, 54, str(s["enemyHp"]), 12, "#e2e8f0", "middle")
    return ashlib.svg_save(root, path)


def main() -> int:
    spec = ashlib.load_spec()
    rec = load(RECORD)
    mismatches, snaps = compare(rec, spec)
    cons_rows, cons_ok = conservation(spec, snaps)

    # 负对照:第 7 步 playerHp 抄错 1 点
    neg_mm, _ = compare(rec, spec, mutate=(7, "playerHp", -1))
    neg = {"step": 7, "field": "playerHp", "delta": -1,
           "detectedMismatches": len(neg_mm), "detected": len(neg_mm) == 1,
           "locatedStep": neg_mm[0]["step"] if neg_mm else None}

    checks = {"replayExact": not mismatches, "conservationHolds": cons_ok,
              "negativeControlLocates": neg["detected"]}
    bad = [k for k, v in checks.items() if not v]

    csv_rows = [[i, rec["moves"][i - 1], s["turn"], s["energy"], s["playerHp"],
                 s["enemyHp"], s["block"], s["played"]] for i, s in enumerate(snaps, 1)]
    n_csv = ashlib.write_csv(os.path.join(OUT, "gd_proto_01_trajectory.csv"),
                             ["step", "move", "turn", "energy", "playerHp",
                              "enemyHp", "block", "played"], csv_rows)
    svg_bytes = draw_svg(snaps, os.path.join(OUT, "gd_proto_01_replay.svg"))

    report = {
        "id": "gd_proto_01_record_replay", "record": "config/gd_proto_01_paper_record.json",
        "seed": rec["seed"], "moves": len(rec["moves"]), "states": len(rec["states"]),
        "stateFields": rec["state_fields"], "replayMismatch": len(mismatches),
        "mismatches": mismatches, "conservation": {"stepsChecked": len(cons_rows),
                                                   "allHold": cons_ok, "rows": cons_rows},
        "negativeControl": neg, "checks": checks, "fail": len(bad),
        "artifacts": {"csv": "out/gd_proto_01_trajectory.csv", "csvRows": n_csv,
                      "svg": "out/gd_proto_01_replay.svg", "svgBytes": svg_bytes},
        "note": "回放误差必须是 0:全整数状态,逐位比对,不设容差。",
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "gd_proto_01_replay.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print("=== 1.2 纸面推演回放 ===")
    print(f"动作 {len(rec['moves'])} 个 / 记录状态 {len(rec['states'])} 个 / 比对字段 {len(rec['state_fields'])} 个")
    print(f"回放错配={len(mismatches)}(误差 0 才通过)")
    print("每步守恒(牌数/伤害/能量/格挡):")
    for r in cons_rows:
        flags = "".join("1" if v else "0" for k, v in sorted(r.items()) if k != "step")
        print(f"  step {r['step']:>2}  {flags}")
    print(f"守恒全成立={cons_ok}")
    print(f"负对照:第 {neg['step']} 步 playerHp 抄错 {neg['delta']} → "
          f"检出错配 {neg['detectedMismatches']} 条,定位 step={neg['locatedStep']}")
    print(f"产物:out/gd_proto_01_trajectory.csv({n_csv} 行)、"
          f"out/gd_proto_01_replay.svg({svg_bytes} B)")
    print(f"fail={len(bad)} {'(回放可复现且守恒)' if not bad else bad}")
    print("wrote out/gd_proto_01_replay.json")
    return 1 if bad else 0


if __name__ == "__main__":
    raise SystemExit(main())

实跑输出

=== 1.2 纸面推演回放 ===
动作 10 个 / 记录状态 10 个 / 比对字段 7 个
回放错配=0(误差 0 才通过)
每步守恒(牌数/伤害/能量/格挡):
  step  1  1111
  step  2  1111
  step  3  1111
  step  4  1111
  step  5  1111
  step  6  1111
  step  7  1111
  step  8  1111
  step  9  1111
  step 10  1111
守恒全成立=True
负对照:第 7 步 playerHp 抄错 -1 → 检出错配 1 条,定位 step=7
产物:out/gd_proto_01_trajectory.csv(10 行)、out/gd_proto_01_replay.svg(4224 B)
fail=0 (回放可复现且守恒)
wrote out/gd_proto_01_replay.json

判读:误差 0 与「能定位错」

回放错配=0 说明人工抄写的 7 个字段 × 10 步全部与引擎一致——这次纸面推演是可复现的:换个人拿着同一条 moves 重推,会得到同一串数。十步的守恒位全是 1111,说明推演过程没有出现「凭空多一张牌」或「伤害对不上」的破绽。

负对照是本节的关键:把第 7 步的 playerHp 从 54 抄成 53,回放报出恰好 1 条错配、且定位在 step=7。这说明检查不是「跑完就绿」,它真的在逐格比对。第 4、9 步是无效动作(能量为 0,R1 条件不成立),状态与上一步完全相同——记录里保留它们,正是为了让「玩家尝试但失败」也被回放出来。

必须声明:回放错配=0 是可复现性的代理指标,它只说「这套规则自洽且被正确抄写」,不说明这套规则好玩。自洽能证明,好玩不能。

本节实跑清单

命令产物实测
/opt/homebrew/bin/python3 sim/gd_proto_01_record_replay.pyout/gd_proto_01_replay.json + out/gd_proto_01_trajectory.csv + out/gd_proto_01_replay.svg10 步误差 0,守恒全成立,负对照定位 step=7

记录能被回放,只证明「这次推演自洽」。下一步要问的是:从这张规则表和这份记录里,能不能机械地抽出与 play_spec.json 同构的规格?哪些字段抽不出来、必须人来裁决?

阅读导航:上一节:1.1 不写代码先验规则 · 下一节:1.3 纸面到规格的转换 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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