3.2 资源守恒与不变量检验
3.1 把一局展开成了一条轨迹,但「展开」不等于「正确」。机制自己得先自洽,才谈得上好玩。《小游戏开发实战》卷一的核心循环拆解 用 S 层算循环时长占比,那是在问「一局里节奏怎么分配」;本节问的是更靠前的一件事——这套规则在跑的时候,有没有把资源弄丢或凭空造出来。守恒与不变量是机制口径的地基,节奏与体验都得建在它上面。
不变量:跑得再乱也不该破的等式
不变量(invariant)是「无论策略怎么出、种子怎么变,都必须成立」的性质。规格 play_spec.json 的 invariants 字段列了四条:
| 代号 | 不变量 | 判据(整数) | 误差字段 |
|---|---|---|---|
| ① | 牌数守恒 | hand + draw + discard == deckSize | cardErr |
| ② | 伤害守恒 | dealt == params.enemyHp - enemyHp | dmgErr |
| ③ | 能量范围 | 0 <= energy <= params.energyMax | energyErr |
| ④ | 格挡非负 | block >= 0 | blockErr |
四条都是整数等式或不等式,所以判据不是「约等于」,而是「误差恰好为 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 仿真输出的可读化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。