11.1 技巧习得的建模

技巧不是一个数,而是一组可播种的决策熟练度。本节把《灰烬牌库》的出牌纪律、防御时机、免费牌三个维度写成 0..1000 的整数参数,用指数逼近与幂律两种整数递推模拟越练越强,再在固定评测场景与固定种子池上实测胜率,得到一条可复现的习得曲线;B 层换四组种子池与三个速率假设检验达标 trial 数稳不稳。

11.1 技巧习得的建模

《核心循环与手感设计》 是一篇约 690 行的散文,用 Unity csharp 片段讲手感参数体系——输入延迟、缓动、镜头跟随这些不可跑的参数手感;《新手引导设计清单》 给的是引导流程清单:什么时候教什么、怎么提示。两篇都停在「怎么调、教什么」。本节问一个它们都没问的问题:玩家「越练越强」这件事本身,能不能写成一个可播种、可实测的模型? 能。本章把技巧拆成一组 0..1000 的整数熟练度,用自实现的 PRNG 决定「这一次决策走不走老练做法」,于是同一份规格在不同熟练度下会打出不同的胜率——习得曲线就是「熟练度模型 → 实测胜率」这条映射。11.2 把这条曲线和难度曲线对上,11.3 找它的天花板与瓶颈。

技巧不是一个数,是一条曲线

新手和高手打同一局《灰烬牌库》,差别不在「血多血少」,而在每一个决策点上做什么选择。所以技巧不该建模成一个标量「水平」,而该建模成一组决策倾向:每次该出牌时,是「出到没能量为止」还是「打一张就收手」;血量危险时,是「先叠格挡」还是「继续抢伤害」;手里有零费牌时,是「先白嫖三点伤害」还是「无视它」。

候选维度老练做法新手做法影响
thrift 出牌纪律一直出到能量/手牌用尽打完第一张就结束回合每回合输出张数
defense 防御时机血量 ≤ 敌人攻击×4 时叠格挡完全不打格挡能不能活过高压回合
tempo 免费牌优先打零费攻击牌无视零费牌每回合白嫖的伤害

每个维度是一个 0..1000 的整数熟练度。决策时用可播种 PRNG 掷一次 0..999 的整数骰:点数小于熟练度就走老练做法,否则走新手做法。全 0 是纯新手,全 1000 是纯老练。注意「技巧」与「胜率」都是代理指标(proxy):它们只用来做相对比较(这个维度比那个维度重要、第 5 个 trial 比第 1 个 trial 强),不做任何绝对化断言——胜率依赖固定策略模型与固定种子池,换一套口径就换一个数。

把技巧写成可播种的策略模型

模型本体在 sim/gd_play_11_policy.py:它按 ashlib.run_spec 的机制逐条重打一局,只是「打几张、要不要格挡、打不打免费牌」由熟练度向量决定。下面摘出决策核心(完整文件在工作区,可 verify 命令直接跑):

# snippet: sim/gd_play_11_policy.py 的决策核心(完整文件见工作区)
DIMS = ("thrift", "defense", "tempo")
EXPERT = {"thrift": 1000, "defense": 1000, "tempo": 1000}
NOVICE = {"thrift": 0, "defense": 0, "tempo": 0}

def _roll(st: int) -> tuple:
    st = ashlib.rng_next(st)      # Lehmer MINSTD,无浮点
    return st, st % 1000          # 一次 0..999 的整数骰

# 决策(每回合、每张牌前各掷一次)
if guards and php <= atk * 4 and def_roll < acc["defense"]:
    pick = guards[0]                                  # 老练:危险时先格挡
elif frees and tmp_roll < acc["tempo"]:
    pick = frees[0]                                   # 老练:先白嫖零费牌
else:
    pick = max(attacks, key=lambda c: c["damage"] * 1000 // max(1, c["cost"]))
if thrift_roll >= acc["thrift"]:                      # 纪律不够:打完一张就收手
    break

关键在最后一行:thrift 的骰子每回合掷一次,掷不中就提前结束回合,于是剩余能量被浪费掉。这不是「打错牌」,而是「少打牌」——一个完全由整数骰控制的、可复现的行为差异。

S 层:两种习得模型 + 实测胜率

接下来把「越练越强」写成整数递推。两种模型都只用整数、无浮点:指数逼近 q_{t+1} = q_t + (qMax − q_t) × rate ÷ 1000 前慢后快、逐步逼近上限;幂律 q_{t+1} = q_t + gain ÷ t 前快后慢、逼近得很慢(这正是练习曲线的经典形状)。每个 trial 的熟练度 q_t 三候选维度取同一个值,喂给策略模型,在固定评测场景(enemyHp=76、enemyAttack=14)与固定种子池上实测胜率千分比。

#!/usr/bin/env python3
# file: sim/gd_play_11_acquisition.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_acquisition.py
"""卷二 11.1 技巧习得的建模(S 层):把「越练越强」写成整数曲线并**实测**它的效果。

两种习得模型都是整数递推、无浮点:指数逼近 q_{t+1}=q_t+(qMax−q_t)×rate÷1000;幂律
q_{t+1}=q_t+gain÷t。每个 trial 的熟练度 q_t 喂给 gd_play_11_policy 的策略模型,在固定
评测场景(enemyHp=76, enemyAttack=14)与固定种子池上实测胜率千分比,得到习得曲线,再求
两条曲线跨过 500‰ 的 trial 序号。产出 out/play11_acquisition.json + .svg。胜率与熟练度
都是代理指标(proxy),只做相对比较。全程整数、无浮点、无 random。
"""
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_acquisition.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_11_policy as P  # noqa: E402


def curve_exponential(q0, qmax, rate, trials):
    qs = [q0]
    for _ in range(trials - 1):
        qs.append(min(qmax, qs[-1] + (qmax - qs[-1]) * rate // 1000))
    return qs


def curve_power(q0, qmax, gain, trials):
    qs = [q0]
    for t in range(1, trials):
        qs.append(min(qmax, qs[-1] + gain // t))
    return qs


def first_cross(win, thr):
    for i, w in enumerate(win):
        if w >= thr:
            return i + 1
    return None


def draw(qs, w_exp, w_pow, path):
    w, h, x0, y0, cw, ph = 760, 340, 96, 48, 52, 200
    root = ashlib.svg_begin(w, h)
    ashlib.svg_text(root, 16, 26, "习得曲线:熟练度模型 → 实测胜率(固定评测场景)", 15)
    for i in range(0, 1001, 250):
        y = y0 + ph - i * ph // 1000
        ashlib.svg_text(root, 14, y + 4, f"{i}", 11)
        ET.SubElement(root, "line", {"x1": str(x0 - 6), "y1": str(y), "x2": str(x0 + cw * len(qs)),
                                     "y2": str(y), "stroke": "#1e293b", "stroke-width": "1"})
    for i, q in enumerate(qs):
        ashlib.svg_text(root, x0 + i * cw, y0 + ph + 16, f"t{i + 1}", 10, anchor="middle")
        ET.SubElement(root, "rect", {"x": str(x0 + i * cw - 5), "y": str(y0 + ph - q * ph // 1000),
                                     "width": "10", "height": "6", "fill": "#f59e0b"})
    for series, color, sw in ((w_exp, "#38bdf8", "3"), (w_pow, "#a78bfa", "2")):
        pts = [(x0 + i * cw, y0 + ph - wr * ph // 1000) for i, wr in enumerate(series)]
        for a, b in zip(pts, pts[1:]):
            ET.SubElement(root, "line", {"x1": str(a[0]), "y1": str(a[1]), "x2": str(b[0]),
                                         "y2": str(b[1]), "stroke": color, "stroke-width": sw})
    ashlib.svg_text(root, x0, y0 + ph + 34, "蓝=指数逼近 紫=幂律 黄块=熟练度模型值", 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)
    spec["params"].update(cfg["scenario"])
    seeds, L = cfg["seedPool"], cfg["learning"]
    thr = L["thresholdPermille"]
    qs_exp = curve_exponential(L["startQ"], L["qMax"], L["rateExponential"], L["trials"])
    qs_pow = curve_power(L["startQ"], L["qMax"], L["gainPower"], L["trials"])
    w_exp = [P.win_permille(spec, seeds, P.uniform(q)) for q in qs_exp]
    w_pow = [P.win_permille(spec, seeds, P.uniform(q)) for q in qs_pow]
    cx, cp = first_cross(w_exp, thr), first_cross(w_pow, thr)
    elapsed = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_11_acquisition", "scenario": cfg["scenario"], "seeds": len(seeds),
              "thresholdPermille": thr, "qExponential": qs_exp, "winExponential": w_exp,
              "qPower": qs_pow, "winPower": w_pow, "crossExponential": cx, "crossPower": cp,
              "elapsedMs": elapsed}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play11_acquisition.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    size = draw(qs_exp, w_exp, w_pow, os.path.join(OUT, "play11_acquisition.svg"))

    print(f"scenario enemyHp={cfg['scenario']['enemyHp']} enemyAttack={cfg['scenario']['enemyAttack']}"
          f"  seeds={len(seeds)}  trials={L['trials']}")
    print(f"{'t':>3} {'q_exp':>6} {'win_exp':>8} {'q_pow':>6} {'win_pow':>8}")
    for i in range(L["trials"]):
        print(f"{i + 1:>3} {qs_exp[i]:>6} {w_exp[i]:>7}‰ {qs_pow[i]:>6} {w_pow[i]:>7}‰")
    print(f"crossExponential={cx}  crossPower={cp}  threshold={thr}‰")
    print(f"elapsed={elapsed} ms  svg={size} bytes")
    print("wrote out/play11_acquisition.json out/play11_acquisition.svg")
    ET.parse(os.path.join(OUT, "play11_acquisition.svg"))
    return 0


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

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

scenario enemyHp=76 enemyAttack=14  seeds=64  trials=12
  t  q_exp  win_exp  q_pow  win_pow
  1    100       0‰    100       0‰
  2    370      62‰    300      62‰
  3    559     265‰    400      78‰
  4    691     453‰    466     109‰
  5    783     578‰    516     187‰
  6    848     718‰    556     250‰
  7    893     843‰    589     296‰
  8    925     921‰    617     343‰
  9    947     921‰    642     390‰
 10    962     953‰    664     421‰
 11    973     968‰    684     453‰
 12    981     984‰    702     468‰
crossExponential=5  crossPower=None  threshold=500‰
elapsed=42 ms  svg=5529 bytes
wrote out/play11_acquisition.json out/play11_acquisition.svg

指数逼近那条曲线是标准的 S 形:trial 12 胜率还在 062‰,trial 5 跨过 500‰,trial 12 到 984‰。幂律那条则一路趴在底部——12 个 trial 后熟练度才 702,胜率 468‰,从没跨过 500‰ 阈值(crossPower=None)。这不是幂律「更差」,而是它描述的是另一种成长形状:前几个 trial 涨得快、之后几乎不动。两条曲线在 trial 1~2 重合、trial 3 之后分开,正是「前快后慢」与「前慢后快」的分水岭。同一份机制、同一组种子,只换习得曲线的形状假设,就能得到「练得会」与「练不会」两种完全相反的结论——所以形状参数必须当成显式假设写在配置里,不能藏在代码里。

B 层:达标 trial 数稳不稳

上一步的结论是「第 5 个 trial 学会」。这个数可信吗?有两个完全不同的怀疑方向,B 层分开回答:口径问题(换一组种子池,这个序号会不会挪)与模型假设问题(换一个速率 rate,这个序号会挪多少)。脚本对四组种子池(基准池 + 三个整体偏移池)与三个速率候选各扫一遍,漂移超阈值只打印软告警、进程始终 exit 0。

#!/usr/bin/env python3
# file: sim/gd_play_11_acquisition_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_11_acquisition_robust.py
"""卷二 11.1 技巧习得建模(B 层):达标 trial 数对**种子池**与**速率假设**的稳健性。

两个问题分开问:(1)换四组不同的种子池,同一个指数逼近模型跨过 500‰ 的 trial 序号
会不会挪?(2)把速率 rate 从 150 换到 600,这个序号会挪多少?前者是口径问题,后者是
**模型假设**问题。任一项漂移超阈值只**打印软告警**,进程始终 exit 0。产出
out/play11_acquisition_robust.json。全整数、无浮点、无 random。
"""
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_acquisition.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_11_policy as P  # noqa: E402


def curve_exponential(q0, qmax, rate, trials):
    qs = [q0]
    for _ in range(trials - 1):
        qs.append(min(qmax, qs[-1] + (qmax - qs[-1]) * rate // 1000))
    return qs


def cross(spec, seeds, qs, thr):
    for i, q in enumerate(qs):
        if P.win_permille(spec, seeds, P.uniform(q)) >= thr:
            return i + 1
    return None


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    spec["params"].update(cfg["scenario"])
    L, rob = cfg["learning"], cfg["robustness"]
    thr = L["thresholdPermille"]
    pool, trials = cfg["seedPool"], L["trials"]
    pools = [("base", pool)] + [(f"+{o}", [s + o for s in pool]) for o in rob["altPoolOffsets"]]

    qs = curve_exponential(L["startQ"], L["qMax"], L["rateExponential"], trials)
    pool_rows = [{"pool": lab, "cross": cross(spec, sd, qs, thr)} for lab, sd in pools]
    xs = [r["cross"] for r in pool_rows if r["cross"] is not None]
    drift = (max(xs) - min(xs)) if xs else None

    rate_rows = []
    for rate in rob["rateCandidates"]:
        qr = curve_exponential(L["startQ"], L["qMax"], rate, trials)
        rate_rows.append({"rate": rate, "cross": cross(spec, pool, qr, thr),
                          "qEnd": qr[-1]})

    warnings = []
    if drift is not None and drift > rob["trialsToThresholdDriftWarn"]:
        warnings.append(f"达标 trial 数随种子池漂移 {drift}(阈值 {rob['trialsToThresholdDriftWarn']}):"
                        f"「第几个 trial 学会」是口径相关的读数")
    known = [r["cross"] for r in rate_rows if r["cross"] is not None]
    if known and (max(known) - min(known)) >= 3:
        warnings.append(f"达标 trial 数随速率假设在 {min(known)}~{max(known)} 之间移动:"
                        f"习得曲线的**形状参数是假设**,不是实测")
    report = {"id": "gd_play_11_acquisition_robust", "thresholdPermille": thr,
              "poolRows": pool_rows, "crossDrift": drift, "rateRows": rate_rows,
              "warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play11_acquisition_robust.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"阈值 {thr}‰  指数逼近模型 rate={L['rateExponential']}")
    for r in pool_rows:
        print(f"pool={r['pool']:<6} 达标 trial={r['cross']}")
    print(f"crossDrift={drift}")
    for r in rate_rows:
        print(f"rate={r['rate']:>4}  达标 trial={r['cross']}  末值q={r['qEnd']}")
    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_acquisition_robust.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
阈值 500‰  指数逼近模型 rate=300
pool=base   达标 trial=5
pool=+100   达标 trial=5
pool=+200   达标 trial=5
pool=+400   达标 trial=5
crossDrift=0
rate= 150  达标 trial=9  末值q=847
rate= 300  达标 trial=5  末值q=981
rate= 600  达标 trial=3  末值q=999
warnings=1
  [WARN] 达标 trial 数随速率假设在 3~9 之间移动:习得曲线的**形状参数是假设**,不是实测
elapsed=49 ms  exit=0(软告警不硬失败)
wrote out/play11_acquisition_robust.json

两个读数指向完全相反的结论,合起来才是完整的:「第 5 个 trial 学会」跨种子池成立(crossDrift=0,四组池子都是 5),但它对速率假设极不稳(rate 从 150 换到 600,达标 trial 数在 3~9 之间移动)。所以「第几个 trial 学会」这句话只有在给定习得模型时才有意义;而「达标 trial 数随速率单调前移」这条排序结论,才是跨假设成立的。能跨口径引用的量与不能跨口径引用的量必须分开说——这与 6.1 在相变点上得到的教训是同一件事。

自检表

检查项合格标准
技巧可播种三维熟练度全为 0..1000 整数,决策骰来自 Lehmer PRNG
习得模型可复现指数逼近与幂律都是整数递推,无浮点、无 random
曲线真跑12 个 trial 各在 64 个固定种子上实测,非纸面推演
阈值有定义达标 = 胜率首次 ≥ 500‰ 的那个 trial 序号
代理指标声明技巧与胜率写明是 proxy,只做相对比较,不绝对化
稳健性分开说区分「跨种子池稳定」与「依赖形状假设」两类结论

有了「熟练度 → 胜率」这条映射,下一个问题是难度该排在哪:如果玩家的技巧沿指数逼近往上走,难度曲线要跟着走到哪一格,才能让胜率始终落在能玩的区间里?下一节把这条反推做出来。

阅读导航:上一节:10.3 带宽与难度设计 · 下一节:11.2 习得曲线与难度曲线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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