11.3 天花板与瓶颈

练满之后还拦得住玩家吗?本节把天花板定义为熟练度全 1000 时的胜率、地板定义为全 0 时的胜率,用留一法算出每个技巧维度的边际增益,在难度网格上找出瓶颈归属、被证伪的维度、以及会变号的维度;B 层换四组种子池看瓶颈标签稳不稳、冠亚军差距会不会被摆动淹没。全程只用代理指标做相对比较,不绝对化。

11.3 天花板与瓶颈

《核心循环与手感设计》 用 Unity csharp 片段谈的是手感参数怎么调,《新手引导设计清单》 谈的是新手怎么教——两篇都从「玩家还不会」这一端出发。本节反过来问另一端:玩家练满之后呢? 11.1 给出了「熟练度 → 胜率」的映射,11.2 发现难度轴到 enemyAttack=17 就拉不住了。本节把这件事做成可计算的:天花板 = 熟练度全 1000 时的胜率,地板 = 全 0 时的胜率,再用留一法给技巧维度排名,找出真正的瓶颈。天花板、地板、边际增益都是代理指标(proxy),只在固定评测场景与固定种子池下做相对比较,不绝对化。

瓶颈的定义要能算,不能靠感觉。 直觉会说「血少的时候防御最重要」,但那是把玩家的当前状态当成了技巧。这里的定义是:某个维度的边际增益 = 天花板 − 只把该维度降回新手(留一法)后的胜率。增益越大,说明「缺了这个维度」损失越大,它就是这个场景下的瓶颈。这个定义有一个直接的好处:它可以为零、甚至为负——为零说明该维度在这个机制里根本没有可观测的作用,为负说明老练做法反而更差。两种都是可证伪的结论,比「防御很重要」这类无法检验的说法强得多。

S 层:留一法排名与变号维度

sim/gd_play_11_bottleneck.py 先在探针格(ehp=76, atk=14)上算三个维度的留一增益,再在一个 3 × 6 的难度网格上逐格重算,统计每格的瓶颈归属、零增益维度、以及符号会翻转的维度。signSets 里 [0,1] 表示该维度的增益在「零」与「正」之间跳,[-1,0,1] 表示「负、零、正」三种都出现过。

#!/usr/bin/env python3
# file: sim/gd_play_11_bottleneck.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_bottleneck.py
"""卷二 11.3 天花板与瓶颈(S 层):用留一法给候选技巧维度排名,并找出**符号翻转**。

天花板 = 熟练度全 1000 时的胜率;地板 = 全 0 时的胜率。某个维度的**边际增益** = 天花板 −
只把该维度降回新手(留一法)。在难度网格上逐格算三个候选维度的边际增益,得到每格的瓶颈
归属,并观察哪些维度的增益会**变号**。产出 out/play11_bottleneck.json + .svg。胜率是
代理指标(proxy),只做相对比较。全程整数、无浮点、无 random。
"""
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_play_11_bottleneck.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_11_policy as P  # noqa: E402

COLORS = {"thrift": "#38bdf8", "defense": "#f59e0b", "tempo": "#a78bfa"}


def cell(spec, seeds, ehp, atk):
    s = copy.deepcopy(spec)
    s["params"].update({"enemyHp": ehp, "enemyAttack": atk})
    ceil = P.win_permille(s, seeds, P.EXPERT)
    drops = {}
    for d in P.DIMS:
        a = dict(P.EXPERT)
        a[d] = 0
        drops[d] = ceil - P.win_permille(s, seeds, a)
    order = sorted(P.DIMS, key=lambda d: -drops[d])
    return {"enemyHp": ehp, "enemyAttack": atk, "ceiling": ceil,
            "floor": P.win_permille(s, seeds, P.NOVICE), "drops": drops,
            "bottleneck": order[0], "order": order, "margin": drops[order[0]] - drops[order[1]]}


def draw(rows, cols, grid, path):
    x0, y0, cw, ch = 108, 62, 128, 34
    root = ashlib.svg_begin(900, 360)
    ashlib.svg_text(root, 16, 28, "边际增益(留一法):每格三根 = thrift / defense / tempo", 15)
    for j, atk in enumerate(cols):
        ashlib.svg_text(root, x0 + j * cw + 34, y0 - 8, f"atk={atk}", 11, anchor="middle")
    for i, ehp in enumerate(rows):
        ashlib.svg_text(root, 20, y0 + i * ch + 20, f"ehp={ehp}", 11)
        for j, atk in enumerate(cols):
            g = grid[(ehp, atk)]
            for k, d in enumerate(P.DIMS):
                v = g["drops"][d]
                x, y = x0 + j * cw + k * 38, y0 + i * ch
                bw = min(34, abs(v) * 34 // 1000)
                ET.SubElement(root, "rect", {"x": str(x), "y": str(y), "width": str(bw),
                                             "height": "24", "fill": COLORS[d] if v >= 0 else "#ef4444"})
                ashlib.svg_text(root, x + bw + 3, y + 17, str(v), 9)
    ashlib.svg_text(root, x0, y0 + len(rows) * ch + 24,
                    "蓝=thrift 橙=defense 紫=tempo(零宽)红=增益为负", 11)
    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["seedPool"]
    grid, cells = {}, []
    for ehp in cfg["grid"]["enemyHp"]:
        for atk in cfg["grid"]["enemyAttack"]:
            grid[(ehp, atk)] = cell(spec, seeds, ehp, atk)
            cells.append(grid[(ehp, atk)])
    probe = cell(spec, seeds, cfg["ceilingProbe"]["enemyHp"], cfg["ceilingProbe"]["enemyAttack"])
    sign = {d: sorted({(c["drops"][d] > 0) - (c["drops"][d] < 0) for c in cells}) for d in P.DIMS}
    zero_dims = [d for d in P.DIMS if all(c["drops"][d] == 0 for c in cells)]
    flippers = [d for d in P.DIMS if len(sign[d]) > 1]
    near = [f"ehp={c['enemyHp']},atk={c['enemyAttack']}" for c in cells
            if c["margin"] < cfg["robustness"]["marginWarnPermille"]]
    elapsed = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_11_bottleneck", "ceilingProbe": probe, "cells": cells,
              "signSets": sign, "zeroDimensions": zero_dims, "signFlippingDimensions": flippers,
              "nearTies": near, "cellsCount": len(cells), "elapsedMs": elapsed}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play11_bottleneck.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    size = draw(cfg["grid"]["enemyHp"], cfg["grid"]["enemyAttack"], grid,
                os.path.join(OUT, "play11_bottleneck.svg"))

    print(f"ceilingProbe ehp={probe['enemyHp']} atk={probe['enemyAttack']}: "
          f"floor={probe['floor']}‰ ceiling={probe['ceiling']}‰")
    for d in P.DIMS:
        print(f"  drop {d:>8} = {probe['drops'][d]:>5}  ")
    print(f"  bottleneck={probe['bottleneck']}  margin={probe['margin']}‰")
    print(f"{'ehp':>4}{'atk':>5}{'floor':>7}{'ceil':>6}  drops(thrift,defense,tempo)  bottleneck  margin")
    for c in cells:
        dr = c["drops"]
        print(f"{c['enemyHp']:>4}{c['enemyAttack']:>5}{c['floor']:>7}{c['ceiling']:>6}  "
              f"{dr['thrift']:>6},{dr['defense']:>6},{dr['tempo']:>5}  {c['bottleneck']:>10}  {c['margin']:>5}")
    print(f"zeroDimensions={zero_dims}")
    print(f"signFlippingDimensions={flippers}  signSets={sign}")
    print(f"nearTies={near}")
    print(f"elapsed={elapsed} ms  svg={size} bytes")
    print("wrote out/play11_bottleneck.json out/play11_bottleneck.svg")
    ET.parse(os.path.join(OUT, "play11_bottleneck.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_11_bottleneck.py(耗时随机器浮动,绝对数值不可移植):

ceilingProbe ehp=76 atk=14: floor=0‰ ceiling=1000‰
  drop   thrift =  1000  
  drop  defense =     0  
  drop    tempo =     0  
  bottleneck=thrift  margin=1000‰
 ehp  atk  floor  ceil  drops(thrift,defense,tempo)  bottleneck  margin
  56    8   1000  1000       0,     0,    0      thrift      0
  56   12      0  1000    1000,     0,    0      thrift   1000
  56   16      0  1000    1000,     0,    0      thrift   1000
  56   18      0  1000    1000,     0,    0      thrift   1000
  56   20      0  1000    1000,     0,    0      thrift   1000
  56   24      0   234     234,  -734,    0      thrift    234
  68    8   1000  1000    1000,     0,    0      thrift   1000
  68   12      0  1000    1000,     0,    0      thrift   1000
  68   16      0  1000    1000,     0,    0      thrift   1000
  68   18      0   984     984,   -16,    0      thrift    984
  68   20      0   984     984,   813,    0      thrift    171
  68   24      0     0       0,     0,    0      thrift      0
  76    8      0  1000    1000,     0,    0      thrift   1000
  76   12      0  1000    1000,     0,    0      thrift   1000
  76   16      0  1000    1000,     0,    0      thrift   1000
  76   18      0     0       0,  -984,    0      thrift      0
  76   20      0     0       0,  -171,    0      thrift      0
  76   24      0     0       0,     0,    0      thrift      0
zeroDimensions=['tempo']
signFlippingDimensions=['thrift', 'defense']  signSets={'thrift': [0, 1], 'defense': [-1, 0, 1], 'tempo': [0]}
nearTies=['ehp=56,atk=8', 'ehp=68,atk=24', 'ehp=76,atk=18', 'ehp=76,atk=20', 'ehp=76,atk=24']
elapsed=113 ms  svg=11508 bytes
wrote out/play11_bottleneck.json out/play11_bottleneck.svg

三个结论,一个比一个不客气。第一,tempo 被证伪:zeroDimensions=['tempo'],18 个格子里它的边际增益恒为 0。这不是「免费牌不重要」,而是「在这个机制里,优先打零费牌不产生任何可观测差异」——因为零费攻击牌本来就会被 thrift 的「出到用尽」规则打出去,tempo 只是在换个顺序。一个维度被证伪是有价值的:它说明这个候选不该进模型。第二,defense 的增益会变号:signSets['defense'] = [-1, 0, 1] 三种符号都出现过。低攻时格挡有正增益(ehp=68, atk=20 处 +813),但在 ehp=76, atk=18 处是 −984——老练的格挡反而把胜率打掉,因为它挤占了输出。所以「防御是瓶颈」这句话必须带上难度条件,脱离场景说就是错的。第三,天花板和地板差得极大:探针格 floor=0‰, ceiling=1000‰,margin=1000‰——练满与不练之间是 0 到 1000 的全幅差距。

B 层:瓶颈排名稳不稳

bottleneck=thrift 在 18 个格子里出现 18 次,看着像铁律。但它依赖两件事:种子池(换一组池子,排名会不会翻)和冠亚军差距(第一名和第二名差多少,差距小于摆动时排名就无意义)。sim/gd_play_11_bottleneck_robust.py 换四组种子池重跑整个网格,逐格统计瓶颈标签与边际差的摆动幅度。漂移超阈值只打印软告警、进程始终 exit 0。

#!/usr/bin/env python3
# file: sim/gd_play_11_bottleneck_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_bottleneck_robust.py
"""卷二 11.3 天花板与瓶颈(B 层):瓶颈归属与边际增益对**种子池**的稳健性。

把留一法在整张难度网格上重跑四组不同的种子池,逐格比较两件事:**瓶颈归属**(哪个维度
增益最大)会不会换人,以及**冠亚军之间的边际差**在四组池子里摆动多大。归属换人或摆动
超阈值只**打印软告警**,进程始终 exit 0。产出 out/play11_bottleneck_robust.json。
全整数、无浮点、无 random。
"""
import copy
import json
import os
import sys
import time

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_play_11_bottleneck.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_11_policy as P  # noqa: E402


def cell(spec, seeds, ehp, atk):
    s = copy.deepcopy(spec)
    s["params"].update({"enemyHp": ehp, "enemyAttack": atk})
    ceil = P.win_permille(s, seeds, P.EXPERT)
    drops = {}
    for d in P.DIMS:
        a = dict(P.EXPERT)
        a[d] = 0
        drops[d] = ceil - P.win_permille(s, seeds, a)
    order = sorted(P.DIMS, key=lambda d: -drops[d])
    return {"bottleneck": order[0], "margin": drops[order[0]] - drops[order[1]],
            "drops": drops, "ceiling": ceil}


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    pool = cfg["seedPool"]
    pools = [("base", pool)] + [(f"+{o}", [s + o for s in pool]) for o in cfg["robustness"]["altPoolOffsets"]]
    rows, cols = cfg["grid"]["enemyHp"], cfg["grid"]["enemyAttack"]

    grids = {}
    for lab, seeds in pools:
        grids[lab] = {(e, a): cell(spec, seeds, e, a) for e in rows for a in cols}

    cells_out, flips = [], 0
    for e in rows:
        for a in cols:
            labels = [grids[lab][(e, a)]["bottleneck"] for lab, _ in pools]
            margins = [grids[lab][(e, a)]["margin"] for lab, _ in pools]
            thrift = [grids[lab][(e, a)]["drops"]["thrift"] for lab, _ in pools]
            label_flip = len(set(labels)) > 1
            flips += 1 if label_flip else 0
            cells_out.append({"enemyHp": e, "enemyAttack": a, "labels": labels,
                              "labelFlip": label_flip, "marginMin": min(margins),
                              "marginMax": max(margins), "marginSpread": max(margins) - min(margins),
                              "thriftSpread": max(thrift) - min(thrift)})
    margin_spread = max(c["marginSpread"] for c in cells_out)
    thrift_spread = max(c["thriftSpread"] for c in cells_out)
    warnings = []
    if flips:
        warnings.append(f"{flips} 个格子的瓶颈归属随种子池换人:这些格子上的排名不可辨识")
    if margin_spread > cfg["robustness"]["marginWarnPermille"]:
        warnings.append(f"冠亚军边际差跨种子池最大摆动 {margin_spread}‰"
                        f"(阈值 {cfg['robustness']['marginWarnPermille']}‰):"
                        f"排名只在摆动小于差距时才可信")
    report = {"id": "gd_play_11_bottleneck_robust", "pools": [lab for lab, _ in pools],
              "cells": cells_out, "labelFlips": flips, "marginSpreadMax": margin_spread,
              "thriftSpreadMax": thrift_spread, "warnings": warnings,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play11_bottleneck_robust.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    for c in cells_out:
        print(f"ehp={c['enemyHp']:>3} atk={c['enemyAttack']:>3} labels={c['labels']} "
              f"flip={c['labelFlip']} margin {c['marginMin']}~{c['marginMax']} "
              f"thriftSpread={c['thriftSpread']}")
    print(f"labelFlips={flips}  marginSpreadMax={margin_spread}‰  thriftSpreadMax={thrift_spread}‰")
    print(f"warnings={len(warnings)}")
    for wmsg in warnings:
        print(f"  [WARN] {wmsg}")
    print(f"elapsed={report['elapsedMs']} ms  exit=0(软告警不硬失败)")
    print("wrote out/play11_bottleneck_robust.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
ehp= 56 atk=  8 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 0~32 thriftSpread=32
ehp= 56 atk= 12 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 56 atk= 16 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 56 atk= 18 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 56 atk= 20 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 56 atk= 24 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 187~234 thriftSpread=47
ehp= 68 atk=  8 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 68 atk= 12 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 68 atk= 16 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 68 atk= 18 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 953~984 thriftSpread=31
ehp= 68 atk= 20 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 171~265 thriftSpread=31
ehp= 68 atk= 24 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 0~0 thriftSpread=0
ehp= 76 atk=  8 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 76 atk= 12 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 76 atk= 16 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 1000~1000 thriftSpread=0
ehp= 76 atk= 18 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 0~0 thriftSpread=0
ehp= 76 atk= 20 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 0~0 thriftSpread=0
ehp= 76 atk= 24 labels=['thrift', 'thrift', 'thrift', 'thrift'] flip=False margin 0~0 thriftSpread=0
labelFlips=0  marginSpreadMax=94‰  thriftSpreadMax=47‰
warnings=1
  [WARN] 冠亚军边际差跨种子池最大摆动 94‰(阈值 64‰):排名只在摆动小于差距时才可信
elapsed=341 ms  exit=0(软告警不硬失败)
wrote out/play11_bottleneck_robust.json

两个读数要分开用。瓶颈标签稳:labelFlips=0,四组种子池里 18 个格子的第一名都是 thrift,thrift 自身的留一增益跨池最多摆动 47‰——这条排序结论可以放心引用。冠亚军差距不稳:marginSpreadMax=94‰ 已经超过 64‰ 阈值,说明在那些 margin 本来就小的格子里(nearTies 列出的 5 个),「第一名比第二名强多少」这个数比它自己的噪声还小,此时说「thrift 是瓶颈」是碰巧对,换个池子换个说法。能引用的是「谁是第一」,不能引用的是「第一比第二强多少」——这正是 11.2 那句「只有排序能跨口径引用」在瓶颈问题上的重演。

自检表

检查项合格标准
天花板/地板有定义分别取熟练度全 1000 与全 0 的实测胜率,非推测
瓶颈可证伪留一增益可为零或负;tempo 恒零被明确列为「被证伪维度」
变号被记录defense 的 signSets 含 -1/0/1,结论必须带难度条件
代理指标声明天花板、地板、边际增益写明是 proxy,只做相对比较
排序与差距分开分别报告「标签是否翻转」与「边际差摆动幅度」两个读数
无浮点无随机全部统计为整数比较,种子来自配置,无 random

三节连起来是一条完整的链:11.1 把「越练越强」写成可播种的曲线,11.2 用这条曲线反推难度排布,11.3 找到能力的天花板与真瓶颈——并且证明这三步里能被引用的永远只有相对排序,绝对值一律是口径相关的读数。

阅读导航:上一节:11.2 习得曲线与难度曲线 · 下一节:12.1 体验指标的选取 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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