13.1 机制增量与兼容
《数值平衡与商业化运营》 那套处理的是 SLG 的赛季膨胀与商业化数值:第几赛季发多少资源、付费曲线怎么抬。本节只取这条链路上最前面的一环——机制层演化。赛季、DLC、资料片在这里不是运营排期,而是一组叠到规格上的改动,要回答的是:旧规格、旧回放还算不算数。上一节 12.3 组合爆炸的控制 管住了组合空间的枚举预算,本节管的是组合空间自己在长大时怎么办。
2.3 规格的版本与迁移
已经处理过 spec_version 与迁移函数,但那是字段层:新键补默认值、旧规格仍被新校验器接受。字段层管不到的是机制层——当新增的东西真的接进结算(一张新牌、一条新规则、一个被读取的参数),旧回放的行为就可能变。判据必须从「字段齐不齐」升级为「旧回放重放出来的 digest 变没变」。
三条可判定的判据
三条判据全是整数比较,没有一条需要形容词:
| 判据 | 问题 | 为真意味着 |
|---|---|---|
| ① digest 一致 | 旧回放在新规格下重放的 digest 是否与旧规格逐字相同 | 这次增量对这条回放零影响 |
| ② 不变量保持 | 四条命名不变量是否全真 | 增量没有把机制改坏 |
| ③ 旧回放仍胜 | 旧回放的 won 是否仍为 true | 旧打法没有被废掉 |
三条的组合给出三档判定:①②③全真 = compatible(兼容);①假而②③真 = migratable(可迁移,旧基线要重算);②或③假 = breaking(破坏,旧回放作废)。分档的关键是「兼容」的定义被收紧了——它不再指「能读」,而是指可观测输出没变。
本节拿七条候选增量做实验,覆盖三类改动:只加字段、只改数值、追加卡牌。
| 增量 | 类型 | 改动 |
|---|---|---|
param_crit0 | 参数 | 新增 params.critRate = 0(2.3 迁移情形的机制层复现) |
param_energy4 | 参数 | energyMax 3→4:每回合多打一张牌 |
param_attack8 | 参数 | enemyAttack 6→8:每回合多咬两口 |
param_enemyHp70 | 参数 | enemyHp 50→70:旧回放未必打得完 |
param_turnLimit2 | 参数 | turnLimit 20→2:上限先到,回放被截断 |
card_add_pierce | 卡牌 | 加一张新牌 pierce:卡池 12→13,洗牌序整体改变 |
card_add_ember | 卡牌 | 加两张新牌 ember:卡池 12→14 |
S 档:把三条判据跑成一张判定表
脚本读 config/gd_mech_13_increment.json 的增量清单,对每条增量叠到规格上,用规格里那条录下来的旧回放(spec.strategy,25 条命令)重放一次,逐条给判定。另有一条接线判据:把新参数换个取值再跑一遍,digest 若不变,说明这个参数根本没被 run_spec 读过。
#!/usr/bin/env python3
# file: sim/gd_mech_13_increment.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_13_increment.py
"""卷一 13.1 机制增量与兼容(S 层):旧回放在新规格下 digest 是否仍一致。
2.3 管的是**字段层**迁移(spec_version 抬号 + 补默认值);本节管的是**机制层**增量:
在规格上真的加一条规则或一张牌之后,那份**已经录下来的旧回放**还算不算数。
三条可判定的判据(全整数,逐位可比):
① digest 一致 旧回放在新规格下重放,digest 是否与旧规格逐字相同;
② 不变量保持 cardCount==deckSize / damageConserved / energyInRange / blockNonNegative 全真;
③ 旧回放仍胜 旧回放的 won 是否仍为 true。
判据①②③全真 → compatible;①假而②③真 → migratable;②或③假 → breaking。
另有一条**接线判据**:把新参数取两个不同值各跑一次,digest 若不变则该参数没被消费
(死参数),此时的「兼容」是**假兼容**——它证明的是「什么都没加」,不是「加得干净」。
产出 out/mech13_increment.csv + out/mech13_increment.svg(三判据状态格)+ .json。
"""
import copy
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_mech_13_increment.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
CRITERIA = ["digestSame", "invariantsHold", "stillWins"]
def apply_increment(spec, inc):
"""把一条候选增量叠到规格上:params 覆盖 + 追加卡牌。返回新对象,不动传入者。"""
s = copy.deepcopy(spec)
s["params"].update(inc.get("set", {}))
for c in inc.get("addCards", []):
s["cards"].append(dict(c))
return s
def invariants(spec, snap):
"""四条命名不变量的判据,与规格 invariants 字段一一对应。"""
return {
"cardCount==deckSize": snap["cardCount"] == ashlib.deck_size(spec),
"damageConserved": bool(snap["damageConserved"]),
"energyInRange": 0 <= snap["energy"] <= spec["params"]["energyMax"],
"blockNonNegative": snap["block"] >= 0,
}
def classify(c):
if not c["invariantsHold"] or not c["stillWins"]:
return "breaking"
return "compatible" if c["digestSame"] else "migratable"
def grid(rows, path):
"""三判据状态格:绿 = 判据真,红 = 判据假;末列写判定结论。"""
cw, ch, x0, y0 = 104, 34, 230, 62
W, H = x0 + cw * 4 + 40, y0 + ch * len(rows) + 46
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 28, "机制增量的兼容判定:三条判据逐条过", 15)
for j, k in enumerate(CRITERIA + ["verdict"]):
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12, k, 11, "#94a3b8", "middle")
for i, r in enumerate(rows):
y = y0 + i * ch
ashlib.svg_text(root, x0 - 10, y + ch // 2 + 4, r["name"], 11, "#cbd5e1", "end")
for j, k in enumerate(CRITERIA):
ok = r[k]
ET.SubElement(root, "rect", {"x": str(x0 + j * cw + 2), "y": str(y + 2),
"width": str(cw - 4), "height": str(ch - 4),
"fill": "#22c55e" if ok else "#ef4444",
"data-ok": "1" if ok else "0"})
fill = {"compatible": "#22c55e", "migratable": "#f59e0b", "breaking": "#ef4444"}[r["verdict"]]
ET.SubElement(root, "rect", {"x": str(x0 + 3 * cw + 2), "y": str(y + 2),
"width": str(cw - 4), "height": str(ch - 4), "fill": fill})
ashlib.svg_text(root, x0 + 3 * cw + cw // 2, y + ch // 2 + 5, r["verdict"], 11, "#0f172a", "middle")
ashlib.svg_text(root, 16, H - 14, "绿 = 兼容 / 琥珀 = 可迁移(要重算基线) / 红 = 破坏(旧回放作废)",
12, "#64748b")
return ashlib.svg_save(root, path)
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
replay = spec["strategy"]
base_snap = ashlib.run_spec(spec, replay)
base_digest = ashlib.digest(base_snap)
base_deck = ashlib.deck_size(spec)
rows, csv = [], []
for inc in cfg["increments"]:
new = apply_increment(spec, inc)
snap = ashlib.run_spec(new, replay)
inv = invariants(new, snap)
d = ashlib.digest(snap)
# 接线判据:同一增量换个取值再跑一次,digest 不变即该参数未被消费。
consumed = None
if "probe" in inc:
probe = apply_increment(spec, {"set": inc["probe"]})
consumed = ashlib.digest(ashlib.run_spec(probe, replay)) != d
r = {
"name": inc["name"], "kind": inc["kind"], "note": inc["note"],
"deckSize": ashlib.deck_size(new), "deckDelta": ashlib.deck_size(new) - base_deck,
"digestSame": d == base_digest, "invariantsHold": all(inv.values()),
"stillWins": bool(snap["won"]), "consumed": consumed,
"brokenInvariants": [k for k, v in inv.items() if not v],
"deltaDealt": snap["dealt"] - base_snap["dealt"],
"deltaPlayed": snap["played"] - base_snap["played"],
"deltaTurn": snap["turn"] - base_snap["turn"],
"deltaPlayerHp": snap["playerHp"] - base_snap["playerHp"],
}
r["verdict"] = classify(r)
rows.append(r)
csv.append([r["name"], r["kind"], r["deckSize"], r["deckDelta"],
int(r["digestSame"]), int(r["invariantsHold"]), int(r["stillWins"]),
"-" if r["consumed"] is None else int(r["consumed"]),
r["deltaDealt"], r["deltaPlayed"], r["deltaTurn"], r["deltaPlayerHp"],
r["verdict"]])
ashlib.write_csv(os.path.join(OUT, "mech13_increment.csv"),
["increment", "kind", "deckSize", "deckDelta", "digestSame",
"invariantsHold", "stillWins", "consumed", "dDealt", "dPlayed",
"dTurn", "dPlayerHp", "verdict"], csv)
size = grid(rows, os.path.join(OUT, "mech13_increment.svg"))
counts = {v: sum(1 for r in rows if r["verdict"] == v)
for v in ("compatible", "migratable", "breaking")}
checks = len(rows) * len(invariants(spec, base_snap))
violations = sum(len(r["brokenInvariants"]) for r in rows)
report = {"id": "gd_mech_13_increment", "baseDeckSize": base_deck,
"baseDigest": base_digest, "replayLength": len(replay),
"increments": len(rows), "counts": counts, "rows": rows,
"invariantChecks": checks, "invariantViolations": violations,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "mech13_increment.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"旧规格:卡池 {base_deck} 张,回放 {len(replay)} 条命令")
print(f"旧 digest {base_digest}")
print(f"{'increment':<20}{'kind':<7}{'deck':>5}{'digest':>7}{'invar':>6}{'won':>5}"
f"{'consumed':>9}{'dTurn':>6}{'dHp':>5} verdict")
for r in rows:
cons = "-" if r["consumed"] is None else str(r["consumed"])
print(f"{r['name']:<20}{r['kind']:<7}{r['deckSize']:>5}{int(r['digestSame']):>7}"
f"{int(r['invariantsHold']):>6}{int(r['stillWins']):>5}{cons:>9}"
f"{r['deltaTurn']:>6}{r['deltaPlayerHp']:>5} {r['verdict']}")
print(f"counts={counts} 不变量检查 {checks} 次,为假 {violations} 次 svg={size} bytes")
print("wrote out/mech13_increment.csv out/mech13_increment.svg out/mech13_increment.json")
ET.parse(os.path.join(OUT, "mech13_increment.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_13_increment.py:
旧规格:卡池 12 张,回放 25 条命令
旧 digest {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
increment kind deck digest invar won consumed dTurn dHp verdict
param_crit0 param 12 1 1 1 False 0 0 compatible
param_energy4 param 12 0 1 1 True -1 10 migratable
param_attack8 param 12 0 1 1 True 0 -4 migratable
param_enemyHp70 param 12 0 1 1 True 1 -6 migratable
param_turnLimit2 param 12 0 1 0 True -1 6 breaking
card_add_pierce card 13 0 1 1 - 0 0 migratable
card_add_ember card 14 0 1 1 - 0 4 migratable
counts={'compatible': 1, 'migratable': 5, 'breaking': 1} 不变量检查 28 次,为假 0 次 svg=4982 bytes
wrote out/mech13_increment.csv out/mech13_increment.svg out/mech13_increment.json
七条里只有一条判为兼容,五条可迁移,一条破坏。
P 档:同一列判定在两处消费下复算
同一份增量清单、同一条旧回放,P 层用 runSpec + digestSnap 复算一遍,判定列必须逐条相同——判据全是整数,digest 因此跨语言逐字可比。这是「判据不是某一侧的巧合」的直接证据:
// snippet: core/test/gd_mech_13_increment.test.ts 的判定核心(节选)
// S 层实跑出的判定,逐条钉死;P 层必须复算出同一列。
const S_VERDICTS: Record<string, string> = {
param_crit0: "compatible",
param_energy4: "migratable",
param_attack8: "migratable",
param_enemyHp70: "migratable",
param_turnLimit2: "breaking",
card_add_pierce: "migratable",
card_add_ember: "migratable",
};
function deckSize(spec: Spec): number {
return spec.cards.reduce((a, c) => a + c.count, 0);
}
function applyIncrement(spec: Spec, inc: Record<string, unknown>): Spec {
const s = JSON.parse(JSON.stringify(spec)) as Spec;
const set = inc.set as Record<string, number> | undefined;
if (set) Object.assign(s.params as unknown as Record<string, number>, set);
const add = inc.addCards as Card[] | undefined;
if (add) for (const c of add) s.cards.push(c);
return s;
}
function invariantsHold(spec: Spec, s: Snapshot): boolean {
return (
s.cardCount === deckSize(spec) &&
s.damageConserved === true &&
s.energy >= 0 &&
s.energy <= spec.params.energyMax &&
s.block >= 0
);
}
function verdictOf(spec: Spec, inc: Record<string, unknown>, base: string): string {
const s = runSpec(applyIncrement(spec, inc));
if (!invariantsHold(applyIncrement(spec, inc), s) || !s.won) return "breaking";
return digestSnap(s) === base ? "compatible" : "migratable";
}
本机跑 node --test 'core/test/gd_mech_13_increment.test.ts',六个用例全绿:
✔ 旧回放基线 digest 与 S 层逐字相同 (0.707167ms)
✔ 七条增量的 verdict 与 S 层逐条一致 (0.723792ms)
✔ 假兼容:critRate 取 0 与 1000 的 digest 相同,说明它根本没被消费 (0.107792ms)
✔ 真增量:energyMax 3→4 改变 digest、仍胜,故为可迁移 (0.106667ms)
✔ 破坏:turnLimit 20→2 让旧回放不再胜,判据③为假 (0.07775ms)
✔ 加牌只改 cardCount:pierce 增量的四个观测量与基线逐位相同 (0.08675ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
读表:一条假兼容、一条破坏、五条可迁移
param_crit0 是唯一的 compatible,而它的 consumed=False 让这个「兼容」立刻露馅:critRate 取 0 与取 1000,digest 完全一样——因为 run_spec 根本不读这个字段。digest 不变有两种成因,判据必须把它们分开:
| 现象 | 成因 | 判定 |
|---|---|---|
| digest 不变,参数被消费 | 新参数在本局落在「不触发」的取值上 | 真兼容:增量接上了线,只是这条回放没走到 |
| digest 不变,参数未被消费 | 字段加了,规则里没有任何一行读它 | 假兼容:什么都没加,兼容是自明的 |
2.3 靠「默认值落在不触发档」来保语义等价,前提正是第一种。第二种在规格里长得一模一样,只有接线判据能把它挑出来——它也是 13.3 判「退场」的那把尺子。
param_turnLimit2 是唯一的 breaking:把回合上限从 20 砍到 2,旧回放跑到第 3 条命令就撞上限,won=false,判据③为假。注意它的 dTurn=-1、dHp=+6 看着像「变好了」——这两个差是截断出来的假象:回放提前收摊,turn 自然少、playerHp 自然多。这正是为什么判据③(仍胜)不能省:只看整数差会把「被打断」读成「更优」。
五条 migratable 各有各的读法。param_energy4 让 playerHp +10、turn −1,是实打实的机制增益;param_attack8 让 playerHp −4,是实打实的代价;param_enemyHp70 让 turn +1、playerHp −6,旧回放还赢得下来但更吃力。两条加牌增量最值得注意:card_add_pierce 的 dTurn 与 dHp 都是 0,digest 却变了——因为 cardCount 从 12 变成 13,而 cardCount 是 digest 的一个字段。卡池一变,所有回放的 digest 都失效,哪怕这一局的读数分毫未动。
判据的边界
| 边界 | 说明 |
|---|---|
| 判据② 在本规格下是恒真的 | 七条增量 × 四条不变量 = 28 次检查,为假 0 次。四条不变量都是构造性恒真的(run_spec 的写法保证了它们),所以判据② 是一条回归护栏,不是分辨器——它的价值在下一次有人改 run_spec 时 |
| 只测了一条回放 | 「旧回放」= 规格里录下的那 25 条命令。增量可能对别的回放破坏而对它无影响;要覆盖更多,得先把回放集建起来 |
| 三档判定不含「部分兼容」 | migratable 是一个大口袋:energyMax +1 与加两张牌都落在里面。要细分,得看差值的量级与 12.1 的耦合矩阵 |
| 增量之间会互相作用 | 本节一条一条加。同时加两条时该看 12.2 的交互项,而不是把两个判定并起来 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 判据可判定 | 三条判据都是整数比较,能给 true / false |
| 兼容有定义 | 「兼容」= digest 逐字相同,不是「能读」 |
| 假兼容可识别 | 有接线判据,能区分「中性」与「没接线」 |
| 三档齐全 | 兼容 / 可迁移 / 破坏各至少一例,判定不是二值 |
| 跨语言一致 | 同一列判定在 S 与 P 两侧复算,逐条相同 |
增量进得来、判得清。但真实运营里改动很少一次只加一条——下一节把整组改动打包成赛季与 DLC,并在整条难度阶梯上量它。
阅读导航:上一节:12.3 组合爆炸的控制 · 下一节:13.2 赛季与 DLC 的机制设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。