13.1 机制增量与兼容

机制层增量要判的不是字段齐不齐,而是旧回放还算不算数。本节用三条可判定判据——digest 是否逐字一致、四条不变量是否保持、旧回放是否仍胜——把七条候选增量判成兼容 / 可迁移 / 破坏三档,再用接线判据揭穿「假兼容」:字段加了却没被消费,digest 不变只证明什么都没加。

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 的机制设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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