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