3.2 资源守恒与不变量检验

机制自己得先自洽,才谈得上好玩。本节对同一份规格在 8 组显式种子上跑同一策略,逐局检验四条不变量:牌数守恒、伤害守恒、能量范围、格挡非负,输出误差表且全部为 0;四条判据都是整数,合格标准是误差恰好为 0 而不是约等于。再给一个「出牌忘进弃牌堆」的人为负例,证明检验器真的会报错。

3.2 资源守恒与不变量检验

3.1 把一局展开成了一条轨迹,但「展开」不等于「正确」。机制自己得先自洽,才谈得上好玩。《小游戏开发实战》卷一的核心循环拆解 用 S 层算循环时长占比,那是在问「一局里节奏怎么分配」;本节问的是更靠前的一件事——这套规则在跑的时候,有没有把资源弄丢或凭空造出来。守恒与不变量是机制口径的地基,节奏与体验都得建在它上面。

不变量:跑得再乱也不该破的等式

不变量(invariant)是「无论策略怎么出、种子怎么变,都必须成立」的性质。规格 play_spec.json 的 invariants 字段列了四条:

代号不变量判据(整数)误差字段
①牌数守恒hand + draw + discard == deckSizecardErr
②伤害守恒dealt == params.enemyHp - enemyHpdmgErr
③能量范围0 <= energy <= params.energyMaxenergyErr
④格挡非负block >= 0blockErr

四条都是整数等式或不等式,所以判据不是「约等于」,而是「误差恰好为 0」。把每条不变量写成一个误差量(违规就是非零),就能把「这一局合不合格」压缩成一张误差表:每行一组种子,每列一条不变量的误差。全 0 即全部合格;任何一格非 0,就精确指出是哪条不变量、差了多少。

误差表比布尔值有用得多。invariantsHold 只回一个 true/false,而误差表告诉你「牌数差了 9 张」——这直接指向「出牌时忘了把牌放进弃牌堆」这类具体 bug。

四条不变量各自的语义

四条不是随便凑的,每条都盯着一类会真实发生的错:

不变量防的是什么错破了会长什么样
牌数守恒牌被复制或被吞手牌越打越多 / 越打越少
伤害守恒伤害被多算或漏算打出 6 点却掉了 9 点血
能量范围能量为负或超上限打出付不起的牌 / 一回合放空能量池
格挡非负格挡被扣穿格挡变负数,等于反手给敌人补血

「牌数守恒」是封闭系统的账:12 张牌只能在 hand / draw / discard 三个口袋里挪,总和恒为 12,既不会凭空生也不会凭空灭。「伤害守恒」是因果的账:累计伤害必须等于敌方血量的下降量,多一分少一分都说明结算有漏。「能量范围」与「格挡非负」是边界的账:它们保证状态永远落在合法区间内——这正是「不可达状态」检测的雏形(第 10 章会把它做全)。

多组种子下的守恒检验

种子是显式列举的整数(不用 random 模块),同一份种子表在任何机器上给出同一张误差表。S 层脚本如下。

#!/usr/bin/env python3
# file: sim/gd_mech_03_conservation.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_03_conservation.py
"""3.2 资源守恒与不变量检验(S 层)。

快照给终态,轨迹给步序;两者都要能被**检验**。本脚本对同一份 play_spec.json
在**多组种子**下跑同一策略,逐局检查四条不变量,输出误差表——**全部应为 0**:

  ① 牌数守恒   hand + draw + discard == deckSize(12)      → cardErr
  ② 伤害守恒   dealt == params.enemyHp - enemyHp          → dmgErr
  ③ 能量范围   0 <= energy <= params.energyMax            → energyErr
  ④ 格挡非负   block >= 0                                 → blockErr

误差是**整数**,判据是「恰好为 0」,不是「约等于」。种子是**显式列举的整数**,
不使用 random 模块——同一份种子表在任何机器上给出同一张误差表。

最后给一个**人为破坏守恒的负例**(出牌时忘了把牌放进弃牌堆),证明检验器真的会报错,
而不是恒返回 0。
"""
from __future__ import annotations

import os
import sys

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)

SEEDS = [20261014, 1, 7, 42, 100, 999, 123456, 2147483646]
HEADER = ["seed", "cardErr", "dmgErr", "energyErr", "blockErr", "turn", "won"]


def check(spec: dict, snap: dict) -> dict:
    """四条不变量的误差(整数,合格即全 0)。"""
    deck_n = ashlib.deck_size(spec)
    p = spec["params"]
    return {
        "cardErr": snap["cardCount"] - deck_n,
        "dmgErr": snap["dealt"] - (p["enemyHp"] - snap["enemyHp"]),
        "energyErr": max(0, -snap["energy"]) + max(0, snap["energy"] - p["energyMax"]),
        "blockErr": max(0, -snap["block"]),
    }


def run_broken(spec: dict) -> dict:
    """负例:出牌时**忘了** discard.append(c),牌数立刻不再守恒。其余规则照旧。"""
    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 = []
    energy, player_hp, enemy_hp, block, dealt = p["energyMax"], p["playerHp"], p["enemyHp"], 0, 0
    for cmd in spec["strategy"]:
        if enemy_hp <= 0:
            break
        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)
            # BUG 就在这里:漏了 discard.append(c)
            energy -= c["cost"]
            block += c["block"]
            d = min(c["damage"], enemy_hp)
            enemy_hp -= d
            dealt += d
        else:
            absorbed = min(block, p["enemyAttack"])
            block -= absorbed
            player_hp = max(0, player_hp - (p["enemyAttack"] - absorbed))
            block = 0
            energy = p["energyMax"]
            while len(hand) < p["handSize"] and draw:
                hand.append(draw.pop(0))
    return {
        "cardCount": len(hand) + len(draw) + len(discard),
        "dealt": dealt, "enemyHp": enemy_hp, "energy": energy, "block": block,
    }


def main() -> int:
    spec = ashlib.load_spec()
    rows: list = []
    total = 0
    for seed in SEEDS:
        s = dict(spec)
        s["seed"] = seed
        snap = ashlib.run_spec(s)
        err = check(s, snap)
        total += sum(err.values())
        rows.append([seed, err["cardErr"], err["dmgErr"], err["energyErr"],
                     err["blockErr"], snap["turn"], str(snap["won"]).lower()])

    out = os.path.join(ROOT, "out", "mech03_conservation.csv")
    n = ashlib.write_csv(out, HEADER, rows)

    print(f"seeds={len(SEEDS)} deckSize={ashlib.deck_size(spec)} energyMax={spec['params']['energyMax']}")
    print(" ".join(HEADER))
    for r in rows:
        print(" ".join(str(x) for x in r))
    print(f"totalError={total} allZero={total == 0}")

    broken = run_broken(spec)
    berr = check(spec, broken)
    print(f"broken: cardCount={broken['cardCount']} cardErr={berr['cardErr']} "
          f"dmgErr={berr['dmgErr']} energyErr={berr['energyErr']} blockErr={berr['blockErr']}")
    print(f"detected={any(v != 0 for v in berr.values())}")
    print(f"wrote out/mech03_conservation.csv rows={n}")
    return 0 if total == 0 and any(v != 0 for v in berr.values()) else 1


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/gd_mech_03_conservation.py:

seeds=8 deckSize=12 energyMax=3
seed cardErr dmgErr energyErr blockErr turn won
20261014 0 0 0 0 4 true
1 0 0 0 0 4 true
7 0 0 0 0 4 true
42 0 0 0 0 4 true
100 0 0 0 0 3 true
999 0 0 0 0 4 true
123456 0 0 0 0 3 true
2147483646 0 0 0 0 4 true
totalError=0 allZero=True
broken: cardCount=3 cardErr=-9 dmgErr=0 energyErr=0 blockErr=0
detected=True
wrote out/mech03_conservation.csv rows=8

读出来:8 组种子(含规格自带的 20261014,以及 1、7、42、100、999、123456、2147483646)下,四条不变量的误差全部为 0,totalError=0。种子不同只改变洗牌顺序与胜负回合(3 或 4 回合),不改变守恒——这正是「不变量」的定义:它对随机性免疫。种子里特意放了 1 与 2147483646 这类极端值(后者逼近 PRNG 模数 2147483647),用来试探取模边界,结果依旧守恒。

落盘的 out/mech03_conservation.csv 前几行:

seed,cardErr,dmgErr,energyErr,blockErr,turn,won
20261014,0,0,0,0,4,true
1,0,0,0,0,4,true
7,0,0,0,0,4,true

这份 CSV 每一列都是一条不变量的误差,可以直接被后续脚本读走:第 6 章的相变扫描会按参数格重跑这张表,第 15 章的三处消费联调会把它与 TS 侧的判定并排比对。误差表不是终点,它是下游的输入。

为什么是 8 组种子

种子数不是越多越好。守恒检验是判据,不是统计:一组种子破了就是破了,多几组只是多几次确认。这 8 组覆盖了三类——规格自带种子(20261014)、小整数(1、7、42)、中等整数(100、999、123456),以及逼近模数的极大值(2147483646)。三类都在,就能同时试探洗牌分布与取模边界。真正需要大样本的是占比类指标(第 6 章的相变扫描、卷二的策略空间熵),那才要采样;守恒检验用 8 组足够。

负例:把守恒打破,检验器必须报错

一个「恒返回 0」的检验器毫无价值。为证明 check 真的在算,脚本里放了一个人为破坏守恒的重放 run_broken:它与正常重放逐条相同,只在出牌时漏掉 discard.append(c)——牌从手牌弹出后凭空消失,既不在手牌也不在抽牌堆也不在弃牌堆。

实跑结果 broken: cardCount=3 cardErr=-9:整局出了 9 张牌,cardCount 从 12 掉到 3,误差 −9,精确等于「丢失的牌数」。dmgErr/energyErr/blockErr 仍为 0——因为破坏只发生在牌数这条线上,另外三条不变量照旧成立。误差表不但指出哪条不变量破了,还指出破了多少,这正是它比布尔值强的地方。

同一套不变量在 P 层再验一遍

这四条不变量不只属于 S 层。第 4 章会在 TypeScript 内核里写一个 invariantsHold(spec, snap),对同一份规格的同一局再做一次四条检查——两个语言、一套判据。S 层这里算的是误差量(差多少),P 层那边判的是布尔(破没破),粒度不同,结论必须一致:S 层全 0,P 层就必须全 true。

不变量破了怎么办

  • 先看是哪条:误差表已经指出列,四条各管一类错。
  • 再看差多少:差 1 与差 9 指向不同的 bug,误差量本身就是线索。
  • 改规则不改判据:上面负例的正确修法是补回 discard.append(c),不是放宽 cardErr 的判据——把判据改松只会让 bug 藏得更深。
  • 规格改了才动判据:牌池从 12 张扩到 15 张时 deckSize 随 play_spec.json 一起变,判据仍以「误差恰好为 0」为准。

自检表

检查项合格标准
判据是整数四条误差都是整数,判「恰好为 0」
种子显式种子是列举的整数,不用 random
覆盖边界含 1 与 2147483646 等极端种子
检验器有效负例必须被报出非零误差
失败即非零任何一格非 0,脚本以非 0 退出码结束

四条不变量在 8 组种子下全部守住了,机制的地基就算稳了。接下来只剩一件事:把这些数字讲给人看。

阅读导航:上一节:3.1 从规格到轨迹 · 下一节:3.3 仿真输出的可读化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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