13.3 机制的退场

删一个机制之前得先证明它真的没人在用。本节把「退场」写成两级探针加消费层绑定的可判定判据:全域无反应才是死参数,近探针无反应而全域有反应是休眠,同一参数在机制底座里判死、在远征层里却仍然判为活。附六步退场清单、不变量恒真检查与删完后的回归验法。

13.3 机制的退场

赛季结束要删东西,DLC 迭代要换东西——退场在运营口径里常被当成一张商业化排期表 上的动作,但机制层的退场只有一条硬门槛:你得先证明这个机制真的没人在用。猜是不行的。12.3 组合爆炸的控制 那条组合判定告诉我们「没观察到」不等于「不存在」;退场判据同理。

第 1 章记过一个真实案例:rewardGold 与 punishHp 从规格第一天就在 params 里,但机制底座 run_spec 从头到尾没读过它们——它们是孤儿参数,一直到第 8 章做远征层时才第一次被消费。这个案例是本节的全部动机:「规格里有」和「有人在用」是两件事,而退场的前提是分清这两件事。

为什么单点扰动测不出退场

最直觉的判据是「扰动一个参数,digest 不变就说明它没用」。这条判据会误杀:turnLimit = 20,而一局战斗平均 3 到 5 回合就结束了——把 turnLimit 从 20 改成 21,digest 一动不动,但它不是死参数,它只是休眠:一旦某个赛季把敌人血量拉高到要打 25 回合,它立刻复活并判定这局失败。删掉它,那个赛季就会静默地永不结束。

所以判据必须是两级的:

探针做法测什么
近探针p → p ± step(step = 1)当前工作点上动一格有没有反应
全域探针p 扫过 config 声明的整条定义域存不存在任何取值让它有反应
近探针全域探针判定处置
有反应有反应active 在役不能动
无反应有反应dormant 休眠不能删,只能标记「等触发条件」
无反应无反应dead 死参数退场候选

只有全域无反应才叫死参数。这个区别不是洁癖:休眠参数是设计意图的载体(turnLimit 表达的正是「一局不该拖过 20 回合」这条规则),死参数才是纯粹的负债。

第二条判据:绑定消费层

同一份规格,不同层的代码读不同字段。机制底座(ashlib.run_spec,单场战斗)与远征层(8.1 发 rewardGold 金币、8.2 按 punishHp 扣血)消费的字段完全不重叠:

参数机制底座 run_spec远征层(8.1 / 8.2)
playerHp enemyHp enemyAttack energyMax handSize turnLimit直接读通过 run_spec 间接读
rewardGold punishHp从不读直接读

所以「死参数清单」必须带层标签。在底座里判 rewardGold 为死参数、然后把它从规格删掉,会直接打断第 8 章的远征奖励。退场判据不绑定消费层,就会把仍在用的参数判成死参数。

#!/usr/bin/env python3
# file: sim/gd_mech_13_retire.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_13_retire.py
"""卷一 13.3 机制的退场(S 层):把「这个机制还能不能删」变成可判定的探针。

退场的第一条判据是「对 digest 无影响」——但单步扰动测不出它,因为还有一类参数是
**休眠**:当前工作点上不被触及(`turnLimit=20` 在一局只打 3 回合时永远不响)。
故用**两级探针**:
  近探针  p → p ± step(step = 1),测「动一格有没有反应」;
  全域探针 p 扫过 config 声明的整条定义域,测「存不存在任何取值让它有反应」。
判定:全域无反应 → **死参数(退场候选)**;全域有反应而近探针无 → **休眠**;近探针有 → **在役**。

第二条判据是**消费层**:同一份规格,机制底座(`ashlib.run_spec`,单场战斗)与远征层
(8.1 发 `rewardGold` 金币、8.2 按 `punishHp` 扣血)读的是不同的字段。`rewardGold` 与
`punishHp` 在底座里全域无反应(第 1 章记的「规格里有、实现里没读」的孤儿),在远征层
却是活的——**退场判据必须绑定消费层,否则会把仍在用的参数判成死参数**。

第三条判据是**不变量恒真**:逐条统计四条命名不变量在全部探针下为假的次数,恒为 0 的
那条是构造性恒真的——它挡不住任何东西,只能当回归护栏,不能当机制描述。
产出 out/mech13_retire.csv + out/mech13_retire.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_retire.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def core_digests(spec, seeds, strategy, param, value):
    """机制底座在给定工作点上的逐种子 digest 列表。"""
    out = []
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        if param is not None:
            s["params"][param] = value
        out.append(ashlib.digest(ashlib.run_spec(s, strategy)))
    return out


def invariant_false_count(spec, seeds, strategy, param, value):
    """四条命名不变量在该探针下为假的次数(逐种子 × 逐条)。"""
    bad = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        if param is not None:
            s["params"][param] = value
        snap = ashlib.run_spec(s, strategy)
        flags = [snap["cardCount"] == ashlib.deck_size(s),
                 bool(snap["damageConserved"]),
                 0 <= snap["energy"] <= s["params"]["energyMax"],
                 snap["block"] >= 0]
        bad += sum(1 for f in flags if not f)
    return bad


def run_layer_signature(spec, seeds, strategy, param, value, layer):
    """远征层消费签名:只取 8.1 / 8.2 真正读过的两个字段。

    8.1:每个胜场发 params.rewardGold 金币;8.2:每负一场扣 params.punishHp 血。
    一次远征跑 `encounters` 场,敌血按 `difficultyStep` 爬升——故既有胜场也有负场,
    两个字段都能被真正兑现。这是远征层的最小复刻:它消费的字段与机制底座完全不同,
    两层的死参数清单因此不同。
    """
    gold_piece, punish = spec["params"]["rewardGold"], spec["params"]["punishHp"]
    if param == "rewardGold":
        gold_piece = value
    if param == "punishHp":
        punish = value
    rewards = losses = exhausted = 0
    for sd in seeds:
        run_hp = spec["params"]["playerHp"]
        for k in range(layer["encounters"]):
            if run_hp <= 0:
                exhausted += 1  # 8.2 的终止条件:远征血量耗尽即收摊
                break
            s = copy.deepcopy(spec)
            s["seed"] = sd
            s["params"]["enemyHp"] = spec["params"]["enemyHp"] + k * layer["difficultyStep"]
            if param is not None:
                s["params"][param] = value
            snap = ashlib.run_spec(s, strategy)
            if snap["won"]:
                rewards += 1
            else:
                losses += 1
                run_hp -= punish
    return f"goldTotal={rewards * gold_piece};runHp={run_hp};exhausted={exhausted}"


def grid(rows, path):
    """两级探针状态格:近探针 / 全域探针 / 判定 三列。"""
    cw, ch, x0, y0 = 112, 34, 210, 62
    W, H = x0 + cw * 3 + 40, y0 + ch * len(rows) + 46
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 16, 28, "机制退场探针:近探针有反应吗 / 全域探针有反应吗", 15)
    for j, k in enumerate(["near±1", "domain-wide", "verdict"]):
        ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12, k, 11, "#94a3b8", "middle")
    color = {"active": "#22c55e", "dormant": "#f59e0b", "dead": "#ef4444"}
    for i, r in enumerate(rows):
        y = y0 + i * ch
        ashlib.svg_text(root, x0 - 10, y + ch // 2 + 4, r["param"], 12, "#cbd5e1", "end")
        for j, ok in enumerate([r["nearChanged"], r["wideChanged"]]):
            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"})
        ET.SubElement(root, "rect", {"x": str(x0 + 2 * cw + 2), "y": str(y + 2),
                                     "width": str(cw - 4), "height": str(ch - 4),
                                     "fill": color[r["verdict"]]})
        ashlib.svg_text(root, x0 + 2 * 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)
    seeds, step = cfg["seeds"], cfg["step"]
    strategy = cfg["probePattern"] * cfg["probeTurns"]
    core_wp = cfg["coreWorkingPoint"]

    base_spec = copy.deepcopy(spec)
    base_spec["params"][core_wp["param"]] = core_wp["value"]
    base = core_digests(base_spec, seeds, strategy, None, 0)
    inv_false = invariant_false_count(base_spec, seeds, strategy, None, 0)

    rows, csv = [], []
    for p in spec["params"]:
        near = any(core_digests(base_spec, seeds, strategy, p, spec["params"][p] + d) != base
                   for d in (step, -step))
        wide, wide_hit = False, None
        for v in cfg["domains"][p]:
            if core_digests(base_spec, seeds, strategy, p, v) != base:
                wide, wide_hit = True, v
                break
        for v in cfg["domains"][p]:
            inv_false += invariant_false_count(base_spec, seeds, strategy, p, v)
        verdict = "active" if near else ("dormant" if wide else "dead")
        rows.append({"param": p, "nearChanged": near, "wideChanged": wide,
                     "wideHitValue": wide_hit, "verdict": verdict,
                     "domain": cfg["domains"][p]})
        csv.append([p, json.dumps(cfg["domains"][p]), int(near), int(wide), verdict])

    # 消费层对照:同样的两级探针,换到远征层(8.1 / 8.2 的消费口径)重跑一遍。
    run_cfg = cfg["runLayer"]
    layer = []
    for p in ("rewardGold", "punishHp"):
        b = run_layer_signature(spec, seeds, strategy, p, spec["params"][p], run_cfg)
        near = any(run_layer_signature(spec, seeds, strategy, p, spec["params"][p] + d, run_cfg) != b
                   for d in (step, -step))
        wide = any(run_layer_signature(spec, seeds, strategy, p, v, run_cfg) != b
                   for v in cfg["domains"][p])
        v = "active" if near else ("dormant" if wide else "dead")
        layer.append({"param": p, "layer": "expedition", "nearChanged": near, "wideChanged": wide,
                      "verdict": v, "baseSignature": b,
                      "coreVerdict": next(r["verdict"] for r in rows if r["param"] == p)})
        csv.append([p + "@expedition", json.dumps(cfg["domains"][p]), int(near), int(wide), v])

    counts = {v: sum(1 for r in rows if r["verdict"] == v) for v in ("active", "dormant", "dead")}
    dead = [r["param"] for r in rows if r["verdict"] == "dead"]
    probes = len(rows) * (2 + sum(len(cfg["domains"][p]) for p in spec["params"]))
    ashlib.write_csv(os.path.join(OUT, "mech13_retire.csv"),
                     ["param", "domain", "nearChanged", "wideChanged", "verdict"], csv)
    size = grid(rows, os.path.join(OUT, "mech13_retire.svg"))
    report = {"id": "gd_mech_13_retire", "seeds": len(seeds), "step": step,
              "coreWorkingPoint": core_wp, "runLayer": run_cfg,
              "probes": probes, "counts": counts, "deadParams": dead,
              "invariantFalseCount": inv_false, "rows": rows, "layerComparison": layer,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "mech13_retire.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"seeds={len(seeds)}  探针={probes} 次  工作点 {core_wp['param']}={core_wp['value']}")
    print(f"{'param':<14}{'near±1':>8}{'wide':>7}{'wide命中':>10}  verdict")
    for r in rows:
        hit = "-" if r["wideHitValue"] is None else str(r["wideHitValue"])
        print(f"{r['param']:<14}{str(r['nearChanged']):>8}{str(r['wideChanged']):>7}{hit:>10}  {r['verdict']}")
    print(f"counts={counts}  死参数={dead}")
    print(f"四条命名不变量在全部探针下为假 {inv_false} 次")
    for r in layer:
        print(f"消费层对照 {r['param']}@expedition: near={r['nearChanged']} wide={r['wideChanged']}"
              f"  verdict={r['verdict']}  签名={r['baseSignature']}")
    print(f"svg={size} bytes")
    print("wrote out/mech13_retire.csv out/mech13_retire.svg out/mech13_retire.json")
    ET.parse(os.path.join(OUT, "mech13_retire.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_13_retire.py:

seeds=16  探针=280 次  工作点 enemyHp=50
param           near±1   wide    wide命中  verdict
playerHp          True   True         1  active
enemyHp           True   True         1  active
enemyAttack       True   True         0  active
energyMax         True   True         0  active
handSize          True   True         1  active
turnLimit        False   True         1  dormant
rewardGold       False  False         -  dead
punishHp         False  False         -  dead
counts={'active': 5, 'dormant': 1, 'dead': 2}  死参数=['rewardGold', 'punishHp']
四条命名不变量在全部探针下为假 0 次
消费层对照 rewardGold@expedition: near=True wide=True  verdict=active  签名=goldTotal=1536;runHp=28;exhausted=0
消费层对照 punishHp@expedition: near=True wide=True  verdict=active  签名=goldTotal=1536;runHp=28;exhausted=0
svg=4721 bytes
wrote out/mech13_retire.csv out/mech13_retire.svg out/mech13_retire.json

读表:三个判定,三种处置

turnLimit 是唯一一个近探针红、全域探针绿的参数:动一格没反应,但把定义域扫到 [1, 2, 5, 20, 40],wide命中 = 1 —— 把上限压到 1 回合,digest 立刻变。它休眠的原因是当前工作点(enemyHp = 50)上战斗平均 3 到 5 回合收尾,离 20 还很远。处置:不删。它承载的是「一局不该拖过 20 回合」这条规则;等某个赛季把敌血拉到 90 档,它就会开始响(13.2 的相变带正好说明 90 档是打不赢的档,回合数会被拉到 9 到 13)。

rewardGold 与 punishHp 是唯一两个两级探针全红的参数,全域命中是 -,即扫完 [0, 1, 12, 100] 与 [0, 1, 4, 100] 都没能让底座 digest 动一下。判定 dead,退场候选。

但下面两行消费层对照立刻把话说清楚了:同样的两级探针,换到远征层(16 场遭遇、敌血每场 +4),两个参数都变成 near=True wide=True verdict=active,签名 goldTotal=1536;runHp=28;exhausted=0。它们在底座是死的,在远征层是活的。 这不是矛盾,是判据必须带层标签的直接后果——第 1 章的「孤儿参数」不是「没用的参数」,只是「当时的消费层还没写出来」。

第三条判据:不变量恒真

四条命名不变量在全部探针下为假 0 次。280 次探针里,cardCount==deckSize、damageConserved、energyInRange、blockNonNegative 这四条一次都没被违反——因为它们是按构造恒真的:cardCount 就是从 deck_size() 数出来的,damageConserved 是战斗循环自己维护的账。一条永远为真的不变量挡不住任何东西,它只能当回归护栏(下次有人改 run_spec 时它会响),不能当机制描述。把它当成「机制还活着」的证据,就是拿恒真式当观测。

退场清单

步骤动作不合格的样子
1列出该参数的全部消费层(底座 / 远征层 / 未来层)只列一层,然后把别的层的活参数判死
2在每个消费层上跑近探针只跑近探针,把休眠参数判死
3近探针无反应者跑全域探针定义域只取「当前工作点附近」,扫不出休眠
4全域无反应 → 标 dead;有反应 → 标 dormant 并记下触发条件把 dormant 直接删掉
5删 dead 前再扫一遍不变量:它是否被某条恒真不变量隐式引用删完发现某条不变量变成恒假
6删完后重跑全部消费层的基线 digest,确认只有该参数相关的 digest 变没做这一步,删出静默回归

自检表

检查项合格标准
探针是两级的近探针与全域探针都跑了,且区分 dormant 与 dead
消费层带标签死参数清单标明是哪一层的,不写「全站死参数」
恒真不变量被点破明说「0 次违例 = 按构造恒真 = 护栏而非描述」
退场可回退删之前有基线 digest,删之后能验「只有相关 digest 变」
休眠有触发条件dormant 参数写明「什么情况下会复活」

机制能加(13.1)、能打包(13.2)、能退(本节)。加、改、删都做完了,那这套机制最终给玩家的是什么体验——第 14 章把「机制」翻译成「体验」。

阅读导航:上一节:13.2 赛季与 DLC 的机制设计 · 下一节:14.1 机制到体验的映射 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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