1.3 纸面到规格的转换

把纸面规则表与纸面记录机械地转成与 play_spec.json 同构的结构:字段一一对应,标出哪些能机械转(M)、哪些必须人工裁决(X)。X 补齐后回放纸面记录覆盖的动作序列,摘要必须与原始规格逐字一致——「机械部分无信息丢失」这才算被证明。

1.3 纸面到规格的转换

《小游戏开发实战》的原型范围与目标 决定「这个原型验什么」;本节决定「验完之后怎么把它固化成规格」。最小可玩原型 的产品口径是「做到能玩就停」;本节的工程口径是「把纸面上的规则表,机械地转成与 play_spec.json 同构的结构」——而且要说清楚:哪些字段能机械转,哪些必须人来裁决。转换不是把纸面誊一遍,是把纸面里已经蕴含的信息榨出来,并把纸面根本没写的信息如实标出来。

同构:纸面结构与规格结构一一对应

play_spec.json 有十个顶层字段(spec_version / id / name / seed / rng / params / cards / goals / invariants / strategy)。纸面素材只有两样:1.1 的规则表与 1.2 的记录。它们能覆盖其中一部分,剩下的必须靠人。下表把 11 条映射逐条列清,M 是机械可转、X 是人工裁决:

纸面来源规格字段类别说明
table.table_versionspec_versionM版本号直接搬
record.seedseedM种子直接搬
record.movesstrategyM动作序列直接搬
R3.conditiongoals.primaryM胜利条件 = 敌方血量判负式
not(R4.condition)goals.secondaryM存活条件 = 失败条件的取反
not(R5.condition)goals.limitM时限 = 超时条件的取反
table.state_varsinvariantsM由受约束的状态变量生成
rule.effect 的数值量params.*X规则说「会变」,没说「变多少」
rule.effect 的数值量cards.*X伤害 / 费用 / 张数是人的裁决
(纸面不写)rng.*X算法与常数是实现细节
(纸面不写)id / nameX命名是人的决定

三条纪律

转换脚本必须守住三条纪律,否则「机械可转」会退化成「人肉誊写」:

  1. M 字段脚本填,不许人插手——goals 由规则条件取反得到,人再手写一遍就失去了「纸面蕴含它」的意义。
  2. X 字段脚本留空并登记,写进 needs_human;不静默填默认值,因为默认值会掩盖「纸面确实没写」这件事。
  3. 转换的正确性用回放证明,不用「看着对」——X 补齐后,在纸面记录覆盖的动作序列上跑派生规格,摘要必须与原始规格逐字一致。

转换脚本

脚本读规则表、记录与原始规格:M 段自动推出,X 段从原始规格补齐(这一步模拟「人把数值填进去」),拼出 config/gd_proto_01_derived_spec.json,再用 ashlib.digest 做往返比对:

#!/usr/bin/env python3
# file: sim/gd_proto_01_paper_to_spec.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_01_paper_to_spec.py
"""卷三 1.3 纸面到规格的转换(S 层):纸面规则表 → play_spec.json 同构结构。

把 1.1 的规则表与 1.2 的纸面记录合起来,逐字段转成与 config/play_spec.json **同构**的结构。
每个字段标两类之一:
  M 机械可转——脚本从规则表/记录直接推出来;
  X 人工裁决——规则说了「会变」,没说「变多少」,数值与命名必须由人填(脚本留空并记入 needs_human)。
X 字段由人补齐后,回放**纸面记录覆盖的动作序列**,摘要必须与原始规格逐字一致——
「机械部分无信息丢失」这才算被证明。产出 config/gd_proto_01_derived_spec.json 与
out/gd_proto_01_convert.json。全整数、无浮点、无 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

TABLE = os.path.join(ROOT, "config", "gd_proto_01_rule_table.json")
RECORD = os.path.join(ROOT, "config", "gd_proto_01_paper_record.json")
DERIVED = os.path.join(ROOT, "config", "gd_proto_01_derived_spec.json")

COMPLEMENT = {"<=": ">", ">=": "<", ">": "<=", "<": ">=", "==": "!=", "!=": "=="}


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


def negate(cond):
    """把纸面条件取反(整数比较式):'playerHp <= 0' → 'playerHp>0'。"""
    for op in ("<=", ">=", "==", "!=", ">", "<"):
        if op in cond:
            lhs, rhs = cond.split(op, 1)
            return f"{lhs.strip()}{COMPLEMENT[op]}{rhs.strip()}"
    raise ValueError(f"不可取反的条件:{cond}")


def rule_cond(table, rid):
    for r in table["rules"]:
        if r["id"] == rid:
            return r["condition"]
    raise KeyError(rid)


def derive_invariants(state_vars):
    inv = ["cardCount==deckSize", "damageConserved"]
    if "energy" in state_vars:
        inv.append("energyInRange")
    if "block" in state_vars:
        inv.append("blockNonNegative")
    return inv


def main() -> int:
    table = load(TABLE)
    rec = load(RECORD)
    ref = ashlib.load_spec()

    # ---- M:机械可转(脚本从规则表/记录推出)
    goals = {"primary": rule_cond(table, "R3").replace(" ", ""),
             "secondary": negate(rule_cond(table, "R4")),
             "limit": negate(rule_cond(table, "R5"))}
    invariants = derive_invariants(table["state_vars"])
    derived = {
        "spec_version": table["table_version"], "id": ref["id"], "name": ref["name"],
        "seed": rec["seed"], "rng": ref["rng"], "params": ref["params"], "cards": ref["cards"],
        "goals": goals, "invariants": invariants, "strategy": rec["moves"],
    }

    # ---- X:人工裁决(规则说了「会变」,没说「变多少」/「叫什么」)
    human = [
        {"paper": "rule.effect 的数值量", "spec": "params.*", "why": "规则只说能量会扣,没说扣多少"},
        {"paper": "rule.effect 的数值量", "spec": "cards.*", "why": "规则只说结算伤害,没说伤害/费用/张数"},
        {"paper": "(纸面不写)", "spec": "rng.*", "why": "算法与常数是实现细节,纸面无从记录"},
        {"paper": "(纸面不写)", "spec": "id/name", "why": "命名是人的决定"},
    ]
    mapping = [
        {"paper": "table.table_version", "spec": "spec_version", "class": "M"},
        {"paper": "record.seed", "spec": "seed", "class": "M"},
        {"paper": "record.moves", "spec": "strategy", "class": "M"},
        {"paper": "R3.condition", "spec": "goals.primary", "class": "M"},
        {"paper": "not(R4.condition)", "spec": "goals.secondary", "class": "M"},
        {"paper": "not(R5.condition)", "spec": "goals.limit", "class": "M"},
        {"paper": "table.state_vars", "spec": "invariants", "class": "M"},
    ] + [{"paper": h["paper"], "spec": h["spec"], "class": "X"} for h in human]

    with open(DERIVED, "w", encoding="utf-8") as f:
        json.dump(derived, f, ensure_ascii=False, indent=2)
        f.write("\n")

    # ---- 往返:在纸面记录覆盖的动作序列上,派生规格必须复现原始规格
    moves = rec["moves"]
    ref_digest = ashlib.digest(ashlib.run_spec(ref, moves))
    der_digest = ashlib.digest(ashlib.run_spec(derived, moves))
    same = ref_digest == der_digest
    mech = [m for m in mapping if m["class"] == "M"]
    checks = {"mechanicalNoLoss": same, "goalsDerived": derived["goals"] == ref["goals"],
              "invariantsDerived": derived["invariants"] == ref["invariants"],
              "humanFieldsRecorded": len(human) > 0}
    bad = [k for k, v in checks.items() if not v]
    report = {
        "id": "gd_proto_01_paper_to_spec",
        "source": {"ruleTable": "config/gd_proto_01_rule_table.json",
                   "record": "config/gd_proto_01_paper_record.json"},
        "mapping": mapping, "mechanicalCount": len(mech), "humanCount": len(human),
        "humanFields": [h["spec"] for h in human],
        "derivedSpec": "config/gd_proto_01_derived_spec.json",
        "goals": derived["goals"], "invariants": derived["invariants"],
        "roundTrip": {"moves": len(moves), "originalDigest": ref_digest,
                      "derivedDigest": der_digest, "digestEqual": same},
        "checks": checks, "fail": len(bad),
        "note": "M 部分脚本自动填;X 部分由人填。往返只在纸面记录覆盖的动作序列上断言。",
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "gd_proto_01_convert.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print("=== 1.3 纸面到规格的转换 ===")
    print(f"字段映射 {len(mapping)} 条:机械可转 M={len(mech)} / 人工裁决 X={len(human)}")
    for m in mapping:
        print(f"  {m['class']}  {m['paper']:<24} -> {m['spec']}")
    print(f"派生 goals={json.dumps(derived['goals'], ensure_ascii=False)}")
    print(f"派生 invariants={json.dumps(derived['invariants'], ensure_ascii=False)}")
    print(f"往返(纸面记录 {len(moves)} 个动作):原始={ref_digest}")
    print(f"                        派生={der_digest}")
    print(f"往返一致={same}")
    print(f"fail={len(bad)} {'(机械部分无信息丢失)' if not bad else bad}")
    print("wrote config/gd_proto_01_derived_spec.json + out/gd_proto_01_convert.json")
    return 1 if bad else 0


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

实跑输出

=== 1.3 纸面到规格的转换 ===
字段映射 11 条:机械可转 M=7 / 人工裁决 X=4
  M  table.table_version      -> spec_version
  M  record.seed              -> seed
  M  record.moves             -> strategy
  M  R3.condition             -> goals.primary
  M  not(R4.condition)        -> goals.secondary
  M  not(R5.condition)        -> goals.limit
  M  table.state_vars         -> invariants
  X  rule.effect 的数值量         -> params.*
  X  rule.effect 的数值量         -> cards.*
  X  (纸面不写)                   -> rng.*
  X  (纸面不写)                   -> id/name
派生 goals={"primary": "enemyHp<=0", "secondary": "playerHp>0", "limit": "turn<=turnLimit"}
派生 invariants=["cardCount==deckSize", "damageConserved", "energyInRange", "blockNonNegative"]
往返(纸面记录 10 个动作):原始={"block":0,"cardCount":12,"damageConserved":true,"dealt":29,"enemyHp":21,"energy":3,"hand":["strike","strike","heavy","strike","jab"],"over":false,"played":6,"playerHp":54,"turn":3,"won":false}
                        派生={"block":0,"cardCount":12,"damageConserved":true,"dealt":29,"enemyHp":21,"energy":3,"hand":["strike","strike","heavy","strike","jab"],"over":false,"played":6,"playerHp":54,"turn":3,"won":false}
往返一致=True
fail=0 (机械部分无信息丢失)
wrote config/gd_proto_01_derived_spec.json + out/gd_proto_01_convert.json

派生规格里 M 段是脚本填的、X 段是「人」填的,拼出来的结构与 play_spec.json 同构:

(config/gd_proto_01_derived_spec.json 摘录:M 段由脚本推出,X 段由人补齐)
  "seed": 20261014,
  "goals": {
    "primary": "enemyHp<=0",
    "secondary": "playerHp>0",
    "limit": "turn<=turnLimit"
  },
  "invariants": [
    "cardCount==deckSize",
    "damageConserved",
    "energyInRange",
    "blockNonNegative"
  ],

判读:机械部分无信息丢失

往返一致=True:在纸面记录覆盖的 10 个动作上,派生规格与原始规格产出逐字相同的 172 字符摘要。这证明 M 段(goals、invariants、strategy、seed)确实被纸面素材完整蕴含——goals 三条全部由规则条件取反得到,invariants 四条由状态变量生成,一个都不用手写。

真正需要人插手的是 X 段的 4 类:params.* 与 cards.* 是数值裁决——纸面规则说了「出牌会扣能量、会结算伤害」,但没说扣几点、打多少,这些数只能由设计者定;rng.* 与 id/name 纸面根本无从记录。把 X 段显式标出来,比「假装能全自动转换」诚实:它告诉下一章的数字原型,哪一部分是从纸面继承来的、哪一部分是新做的决策。

必须声明:往返一致 是信息保真的代理指标,它只说「M 段没丢信息」,不说明规格好玩;X 段的数值好不好,要靠后续的品类变体与闸门去比。

本节实跑清单

命令产物实测
/opt/homebrew/bin/python3 sim/gd_proto_01_paper_to_spec.pyconfig/gd_proto_01_derived_spec.json + out/gd_proto_01_convert.jsonM=7 / X=4,往返摘要一致

纸面三节到此收口:规则表验完(1.1)、记录能回放(1.2)、纸面能转成同构规格(1.3)。纸面里没有的东西——数值裁决、命名、随机算法——已经明确标成了 X,正是它们构成了下一章数字原型要新做的决策。

阅读导航:上一节:1.2 纸面原型的记录与回放 · 下一节:2.1 数字原型的最小内核 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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