13.2 赛季与 DLC 的机制设计
《数值平衡与商业化运营》 那套把赛季当成数值与商业化的排期表:第几赛季开什么档、膨胀多少、付费点放在哪。本节只接手它最上游的一环——赛季与 DLC 在机制层到底改了什么,以及改完之后哪些量跟着动。商业化数值不进本节,判定只对机制说话。上一节 13.1 机制增量与兼容 一条一条地加增量、判旧回放;本节把整组改动打包成叠加层,并在整条难度阶梯上量它。
赛季与 DLC 的机制层差别
运营口径里,赛季与 DLC 的差别在体量与价格;机制层里,差别只有一条——叠加层动的是既有键,还是引入新实体:
| 项 | 赛季 | DLC |
|---|---|---|
| 动什么 | 覆盖 params 里已有的键 | 向 cards 追加基线里不存在的牌 |
| 撤销 | un-merge(把被覆盖的键还原成基线值)即可 | un-merge 还原不了卡池,只能删实体 |
| 引用它的存档 | 不受影响(键还在,只是取值回去) | 删不掉——玩家的收藏与卡组引用了它 |
| 洗牌序 | 不变 | 整体改变(卡池大小变了) |
| 旧 digest | 只在被改动的参数真被消费时失效 | 无条件失效(cardCount 对所有回放都变) |
最后两行是机制层最要紧的差别:赛季的破坏面可控,DLC 的破坏面无条件。这不是好坏判断,是代价结构——所以判据不能问「哪个更强」,只能问「各自的破坏面有多大」。
本节把五个叠加层压在同一份规格上,每一层都在四个难度档上跑:
| 叠加层 | 类型 | 改动 |
|---|---|---|
base | 基线 | 无叠加 |
season-drought | 赛季 | enemyAttack 6→8 |
season-tide | 赛季 | energyMax 3→4 与 handSize 5→4 同时动(一对抢同一瓶颈的旋钮) |
season-frost | 赛季 | enemyAttack 6→5 |
dlc-ember | DLC | 卡池 12→14,追加两张 ember |
为什么必须扫整条难度阶梯
只在基线工作点(enemyHp = 50)上比,读到的是一条常数:所有叠加层在那一档都是 1000‰ 胜率,任何差异都被天花板压平。所以把 enemyHp 拉成阶梯 [50, 70, 90, 110],让读数穿过相变点——阶梯上既有全胜档,也有全负档,叠加层的作用才有地方显出来。
#!/usr/bin/env python3
# file: sim/gd_mech_13_season.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_13_season.py
"""卷一 13.2 赛季与 DLC 的机制设计(S 层):叠加层的机制层对照。
赛季与 DLC 的区别不在「体量」,而在**叠加层动什么**:
赛季 = 只覆盖 params(可逆:撤销叠加即回到基线 digest);
DLC = 追加卡牌(不可逆:卡池变了,洗牌序整体改变,撤销不回去)。
两者都必须在**整条难度阶梯**上测,因为只在基线工作点上比会读到一条常数。
三条判据(全整数,禁浮点):
① 机制差 每个叠加层在每个阶梯档上对基线取整数差与千分比;
② 不变量 规格 invariants 里那四条命名不变量,在全部 (叠加层 × 阶梯 × 种子) 上必须恒真;
③ 可逆性 叠加后再撤销,digest 是否回到基线——赛季应可逆,DLC 应不可逆(这是设计,不是缺陷)。
另附一条膨胀判据:若某叠加层在所有阶梯档上都不劣于基线且至少一项严格更优,判为**纯增益(膨胀)**。
产出 out/mech13_season.csv + out/mech13_season.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_season.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def apply_overlay(spec, ov):
"""把叠加层压到规格上:params 覆盖 + 追加卡牌。返回新对象,不动传入者。"""
s = copy.deepcopy(spec)
s["params"].update(ov.get("set", {}))
for c in ov.get("addCards", []):
s["cards"].append(dict(c))
return s
def invariant_flags(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 measure(spec, seeds, strategy, ov, enemy_hp):
"""一个 (叠加层, 阶梯档) 格:跑全部种子,给出整数均值、逐种子 digest 与不变量违例。"""
wins = turn = php = 0
bad, digests = [], []
for sd in seeds:
s = apply_overlay(spec, ov)
s["seed"] = sd
s["params"]["enemyHp"] = enemy_hp
snap = ashlib.run_spec(s, strategy)
wins += 1 if snap["won"] else 0
turn += snap["turn"]
php += snap["playerHp"]
digests.append(ashlib.digest(snap))
for k, ok in invariant_flags(s, snap).items():
if not ok:
bad.append((k, sd, enemy_hp))
n = len(seeds)
return {"winPermille": 1000 * wins // n, "avgTurn": turn // n, "avgPlayerHp": php // n,
"violations": bad, "digests": digests}
def unmerge_reversible(spec, ov, seeds, strategy):
"""只做 un-merge(把被覆盖的键还原成基线值)能否回到基线 digest。
这是赛季与 DLC 的分水岭:赛季只覆盖**既有键**,un-merge 就干净了;
DLC 追加了基线里不存在的卡牌**实体**,un-merge 还原不了卡池,
cardCount 仍然差 2,digest 回不去——要回得去只能删实体,而引用了它的存档删不掉。
"""
merged = apply_overlay(spec, ov)
unmerged = copy.deepcopy(merged)
for k in ov.get("set", {}):
unmerged["params"][k] = spec["params"][k]
for sd in seeds:
a, b = copy.deepcopy(unmerged), copy.deepcopy(spec)
a["seed"] = b["seed"] = sd
if ashlib.digest(ashlib.run_spec(a, strategy)) != ashlib.digest(ashlib.run_spec(b, strategy)):
return False
return True
def chart(cells, overlays, ladder, path):
"""胜率阶梯分组条:每档一组,组内每个叠加层一根条。"""
cw, ch, x0, y0 = 46, 200, 92, 56
gw = cw * len(overlays) + 18
W, H = x0 + gw * len(ladder) + 40, y0 + ch + 76
colors = {"base": "#475569", "season": "#38bdf8", "dlc": "#f59e0b"}
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 28, "赛季 / DLC 叠加层的胜率阶梯(每组一档敌血,组内五层)", 15)
for j, ehp in enumerate(ladder):
ashlib.svg_text(root, x0 + j * gw + gw // 2, H - 44, f"enemyHp={ehp}", 12, "#94a3b8", "middle")
for i, ov in enumerate(overlays):
v = cells[(ov["name"], ehp)]["winPermille"]
h = v * ch // 1000
x = x0 + j * gw + i * cw
ET.SubElement(root, "rect", {"x": str(x), "y": str(y0 + ch - h), "width": str(cw - 8),
"height": str(max(h, 1)), "fill": colors[ov["kind"]],
"data-win": str(v)})
for i, ov in enumerate(overlays):
ashlib.svg_text(root, x0 + i * cw + (cw - 8) // 2, H - 22, ov["name"].replace("season-", "S:"),
10, colors[ov["kind"]], "middle")
ashlib.svg_text(root, 16, H - 6, "灰 = 基线 / 蓝 = 赛季 / 琥珀 = DLC;条高 = 胜率千分比",
11, "#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 = cfg["seeds"]
ladder = cfg["difficultyLadder"]
overlays = cfg["overlays"]
strategy = cfg["probePattern"] * cfg["probeTurns"]
cells = {}
for ov in overlays:
for ehp in ladder:
cells[(ov["name"], ehp)] = measure(spec, seeds, strategy, ov, ehp)
rows, csv = [], []
creep = []
for ov in overlays[1:]:
deltas = []
for ehp in ladder:
b, c = cells[("base", ehp)], cells[(ov["name"], ehp)]
d = {"win": c["winPermille"] - b["winPermille"],
"turn": c["avgTurn"] - b["avgTurn"],
"hp": c["avgPlayerHp"] - b["avgPlayerHp"]}
pm = 1000 * d["hp"] // b["avgPlayerHp"] if b["avgPlayerHp"] else 0
changed = sum(1 for i in range(len(seeds)) if c["digests"][i] != b["digests"][i])
rows.append({"overlay": ov["name"], "kind": ov["kind"], "enemyHp": ehp,
"baseWin": b["winPermille"], "winPermille": c["winPermille"],
"deltaWin": d["win"], "deltaTurn": d["turn"],
"deltaPlayerHp": d["hp"], "hpPermille": pm, "changedDigests": changed})
deltas.append(d)
csv.append([ov["name"], ov["kind"], ehp, b["winPermille"], c["winPermille"],
d["win"], b["avgTurn"], c["avgTurn"], d["turn"],
b["avgPlayerHp"], c["avgPlayerHp"], d["hp"], pm, changed])
# 膨胀判据只看胜率与剩余血,**故意不看回合**:在打不赢的档上,回合数量的是
# 「撑了多久」而不是「多高效」,它的好坏方向在相变点两侧会翻转,不能进同一张判据。
weak = all(d["win"] >= 0 and d["hp"] >= 0 for d in deltas)
strict = any(d["win"] > 0 or d["hp"] > 0 for d in deltas)
if weak and strict:
creep.append(ov["name"])
viol = [{"overlay": k[0], "enemyHp": k[1], "bad": v["violations"]}
for k, v in cells.items() if v["violations"]]
checks = len(cells) * len(seeds) * 4
rev = {ov["name"]: unmerge_reversible(spec, ov, seeds, strategy) for ov in overlays}
# 相变带:每个叠加层最后一个全胜档与第一个全负档,用来判断赛季有没有移动可解性边界。
phase = {ov["name"]: [max([e for e in ladder if cells[(ov["name"], e)]["winPermille"] == 1000],
default=None),
min([e for e in ladder if cells[(ov["name"], e)]["winPermille"] == 0],
default=None)] for ov in overlays}
ashlib.write_csv(os.path.join(OUT, "mech13_season.csv"),
["overlay", "kind", "enemyHp", "baseWin", "win", "dWin", "baseTurn", "turn",
"dTurn", "baseHp", "hp", "dHp", "dHpPermille", "changedDigests"], csv)
size = chart(cells, overlays, ladder, os.path.join(OUT, "mech13_season.svg"))
report = {"id": "gd_mech_13_season", "seeds": len(seeds), "ladder": ladder,
"overlays": [ov["name"] for ov in overlays], "cells": len(rows),
"runs": len(cells) * len(seeds), "invariantChecks": checks,
"invariantViolations": sum(len(v["violations"]) for v in cells.values()),
"reversible": rev, "powerCreep": creep,
"creepBasis": "只取胜率与剩余血:打不赢的档上回合数量的是存活时长,方向会翻转",
"phaseBand": phase, "rows": rows,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "mech13_season.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)} 阶梯={ladder} 叠加层={len(overlays)} 运行={report['runs']}")
for ov in overlays:
print(f"{ov['name']:<16}{ov['kind']:<8}" + " ".join(
f"ehp{ehp}:win={cells[(ov['name'], ehp)]['winPermille']:>4}‰ "
f"turn={cells[(ov['name'], ehp)]['avgTurn']:>2} "
f"hp={cells[(ov['name'], ehp)]['avgPlayerHp']:>3}" for ehp in ladder))
print(f"{'overlay':<16}{'ehp':>5}{'dWin‰':>8}{'dTurn':>7}{'dHp':>6}{'dHp‰':>7}{'digest变':>9}")
for r in rows:
print(f"{r['overlay']:<16}{r['enemyHp']:>5}{r['deltaWin']:>8}{r['deltaTurn']:>7}"
f"{r['deltaPlayerHp']:>6}{r['hpPermille']:>7}{r['changedDigests']:>6}/16")
print(f"不变量检查 {checks} 次,违例 {report['invariantViolations']} 次")
print(f"un-merge 可逆性 {rev}")
print(f"纯增益(膨胀)叠加层 = {creep if creep else '无'}")
print(f"相变带 [最后全胜档, 首个全负档] = {phase}")
print(f"svg={size} bytes")
print("wrote out/mech13_season.csv out/mech13_season.svg out/mech13_season.json")
ET.parse(os.path.join(OUT, "mech13_season.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_13_season.py:
seeds=16 阶梯=[50, 70, 90, 110] 叠加层=5 运行=320
base base ehp50:win=1000‰ turn= 3 hp= 50 ehp70:win=1000‰ turn= 5 hp= 42 ehp90:win= 0‰ turn=11 hp= 0 ehp110:win= 0‰ turn=11 hp= 0
season-drought season ehp50:win=1000‰ turn= 3 hp= 45 ehp70:win=1000‰ turn= 5 hp= 34 ehp90:win= 0‰ turn= 9 hp= 0 ehp110:win= 0‰ turn= 9 hp= 0
season-tide season ehp50:win=1000‰ turn= 4 hp= 46 ehp70:win=1000‰ turn= 6 hp= 38 ehp90:win= 0‰ turn=11 hp= 0 ehp110:win= 0‰ turn=11 hp= 0
season-frost season ehp50:win=1000‰ turn= 3 hp= 52 ehp70:win=1000‰ turn= 5 hp= 46 ehp90:win= 0‰ turn=13 hp= 0 ehp110:win= 0‰ turn=13 hp= 0
dlc-ember dlc ehp50:win=1000‰ turn= 3 hp= 49 ehp70:win=1000‰ turn= 5 hp= 42 ehp90:win= 0‰ turn=11 hp= 0 ehp110:win= 0‰ turn=11 hp= 0
overlay ehp dWin‰ dTurn dHp dHp‰ digest变
season-drought 50 0 0 -5 -100 16/16
season-drought 70 0 0 -8 -191 16/16
season-drought 90 0 -2 0 0 16/16
season-drought 110 0 -2 0 0 16/16
season-tide 50 0 1 -4 -80 16/16
season-tide 70 0 1 -4 -96 15/16
season-tide 90 0 0 0 0 16/16
season-tide 110 0 0 0 0 16/16
season-frost 50 0 0 2 40 16/16
season-frost 70 0 0 4 95 16/16
season-frost 90 0 2 0 0 16/16
season-frost 110 0 2 0 0 16/16
dlc-ember 50 0 0 -1 -20 16/16
dlc-ember 70 0 0 0 0 16/16
dlc-ember 90 0 0 0 0 16/16
dlc-ember 110 0 0 0 0 16/16
不变量检查 1280 次,违例 0 次
un-merge 可逆性 {'base': True, 'season-drought': True, 'season-tide': True, 'season-frost': True, 'dlc-ember': False}
纯增益(膨胀)叠加层 = ['season-frost']
相变带 [最后全胜档, 首个全负档] = {'base': [70, 90], 'season-drought': [70, 90], 'season-tide': [70, 90], 'season-frost': [70, 90], 'dlc-ember': [70, 90]}
svg=3304 bytes
wrote out/mech13_season.csv out/mech13_season.svg out/mech13_season.json
读表:相变带没动,质量动了
最醒目的一行是最后一行:五个叠加层的相变带全都是 [70, 90]。赛季把敌人调凶、把能量调高、把卡池撑大,可解性边界一格都没挪——enemyHp=70 仍然全胜,enemyHp=90 仍然全负。机制层改的是边界之内的质量(剩余血量、消耗回合),不是边界的位置。
这条结论把 13.2 和 6.2 的相变扫描接上了:想移动相变点,靠调参没用,得改规则结构(比如给 guard 加一条「格挡溢出转为伤害」)。运营口径里「这个赛季变强了」说的其实是质量,不是边界。
| 叠加层 | 机制层动作 | 实测效果 |
|---|---|---|
season-drought | 敌人攻击 6→8 | 全胜档上 playerHp −5 / −8(−100‰ / −191‰),是纯代价 |
season-tide | 能量 3→4 且手牌 5→4 | turn +1、playerHp −4:两个旋钮抢同一瓶颈,多出来的能量被手牌卡住(与 12.2 的反协同同源) |
season-frost | 敌人攻击 6→5 | playerHp +2 / +4(+40‰ / +95‰),纯增益 |
dlc-ember | 卡池 12→14 | 全胜档 playerHp −1(−20‰),其余档三个均值与基线逐位相同 |
season-frost 是膨胀检测器唯一响的一次:它在所有阶梯档上 dWin ≥ 0 且 dHp ≥ 0,并在全胜档上严格更优。判为纯增益不代表它「错了」——一个只有好处的赛季改动在运营上完全合法,但必须被看见:它没有对应的代价,长期叠下去就是单调膨胀。
膨胀判据故意不看 turn。看 season-frost 在 90 档上 dTurn = +2:那不是「变差了」,是「撑得更久」——在打不赢的档上,回合数量的是存活时长,它的好坏方向在相变点两侧会翻转。把方向会翻转的量塞进同一张判据,符号就会骗人(12.2 已经因为同样的原因吃过一次亏)。
dlc-ember 那一行是本节最值得记住的:三个均值与基线逐位相同,可它的 digest变 是 16/16——每个种子的 digest 都变了。原因是 cardCount 进了 digest:卡池从 12 变成 14,所有回放的状态摘要都跟着变,哪怕这一局的胜负、回合、血量分毫未动。聚合量相等不等于机制无影响,这正是 13.1 那条「digest 相同 ≠ 机制中性」的镜像。
un-merge 可逆性 一行给出了赛季与 DLC 的分水岭:四个赛季层全部 True,DLC 是 False。注意判据不是「能不能撤销」——删掉追加的牌当然也能撤销——而是「只做 un-merge 能不能撤销」。赛季只覆盖既有键,un-merge 干净;DLC 引入了基线里不存在的实体,un-merge 还原不了卡池。这条判据必须落在规格结构上,动态回放测不出来。
判据的边界
| 边界 | 说明 |
|---|---|
| 阶梯只有四档 | [50, 70, 90, 110],相变带只能定位到 [70, 90] 这个区间。要收窄,得在 70–90 之间二分(6.2 的做法),本节只报区间 |
| 探针策略固定 | 一条「每回合依次出 p0..p3 再结束回合」的稳定策略。换策略所有差值都会变,层间不可比 |
| 不变量是构造性恒真的 | 1280 次检查违例 0 次。这条判据在本规格下不会响,它的价值在下一次有人改 run_spec 时(13.3 会把它单列成一条退场判据) |
| 只看一个工作点族 | 阶梯扫的是 enemyHp。season-tide 那对旋钮的作用在别的轴(比如 energyMax)上未必成立 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 叠加层分类清楚 | 明说赛季动既有键、DLC 引新实体,并给出可判定的差别 |
| 对照是全阶梯的 | 不在单一工作点上比,明说为什么(会读到常数) |
| 差值可读 | 整数差 + 千分比,两种都给 |
| 不变量被检查 | 每条命名不变量在全部 (层 × 档 × 种子) 上过一遍,报违例数 |
| 膨胀有判据 | 纯增益的判定写成整数不等式,并说明为什么排除某个观测列 |
赛季能加、DLC 能扩,那减呢?一个机制什么时候该退场、退了之后怎么确认真的没人再用它——下一节给一把可判定的尺子。
阅读导航:上一节:13.1 机制增量与兼容 · 下一节:13.3 机制的退场 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。