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 机制到体验的映射 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。