13.2 赛季与 DLC 的机制设计

赛季与 DLC 的差别不在体量,而在叠加层动什么:赛季只覆盖 params(可逆),DLC 追加卡牌实体(不可逆)。本节把五个叠加层压在整条难度阶梯上跑,给出整数差与千分比,并检查相变带有没有被移动、不变量有没有被破坏、哪个叠加层是纯增益膨胀。

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-emberDLC卡池 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→4turn +1、playerHp −4:两个旋钮抢同一瓶颈,多出来的能量被手牌卡住(与 12.2 的反协同同源)
season-frost敌人攻击 6→5playerHp +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 机制的退场 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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