4.1 涌现:从规则到模式

规则是局部的,模式是全局的。本节把《灰烬牌库》一回合内合法打出的卡牌多重集定义成「回合程序」,解析枚举出合法闭包 35 个,再用状态去重 BFS 算出默认种子可达 34 个、8 种子并集 35 个,得出涌现度 2692‰,并声明它是只做相对比较的代理指标。

4.1 涌现:从规则到模式

站内的平衡治理流程 讲的是委员会、评审与榜单:谁来裁决一个机制要不要改、改动走哪条流程。本节不碰治理,碰的是治理之前的那一步——先得有一个可算的量,说明「这套规则到底跑出了什么」。涌现度量就是那个量:可达模式数与规则数的比。它不判断「该不该改」,只回答「现在有多少种可打的打法」。这份量之所以能算,是因为卷二把「模式」定义成了有限、可枚举、与顺序无关的对象。

规则是局部的,模式是全局的

「涌现」在游戏设计语境里常被当成玄学:规则简单、玩法丰富,于是「涌现」了。这句话没法证伪,因为它没给「丰富」一个定义。本节的立场是把两件事分开:规则是局部的,每条规则只约束一个动作或一次结算;模式是全局的,它是一次完整交互里被规则允许的组合。前者有 13 条(卷一 1.1 的规则表),后者需要算出来。

层对象数量从哪来
局部规则 R1–R9、不变量 I1–I4规格文本,13 条
局部动作字母表handSize + 1 = 6 个符号
全局回合程序(一回合内合法打出的卡牌多重集)本节要算的「模式」

「模式」选回合程序而不是整局轨迹,有两个工程理由:有限(一回合最多 handSize 张牌)、与顺序无关(一回合内先打 A 后打 B 与先打 B 后打 A,付得起的费用之和一样,故规范化为排序元组)。有限 + 无序,模式空间才能被枚举干净。

回合程序:模式的规范化定义

一个回合程序是一个非空的卡牌 id 多重集,写成排序元组,例如 ('guard', 'guard', 'jab', 'strike')。它合法当且仅当同时满足三条:

条件表达式来源
不超手牌容量size <= handSizeR2 / R8
不超能量预算sum(cost) <= energyMaxR1 / R6
不超牌库张数每种卡的出现次数 <= countcards[]

三条条件一列出来,「合法闭包」就与发牌无关了——它是规则的解析闭包:所有满足上面三条的非空多重集。而「可达」是另一个集合:在真实发牌序下,真的能凑到手上的那些回合程序。两者的差,就是「规则允许」与「运气允许」之间的缺口。

S 层:算闭包与可达集

sim/gd_play_04_emergence.py 读真源规格,先解析枚举合法闭包,再对每个种子做状态去重 BFS(用 ashlib.digest 对状态去重,避免展开重复前缀),在每个未结束的状态上枚举手牌的可负担子多重集并求并集。全整数,无浮点,无随机。

#!/usr/bin/env python3
# file: sim/gd_play_04_emergence.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_emergence.py
"""卷二 4.1 涌现:从规则到模式(S 层)。

局部对象是**规则**(13 条,卷一 1.1 的规则表:R1-R9 加 I1-I4),全局对象是**回合程序**
——一回合内合法打出的卡牌多重集(排序元组规范化,与打牌顺序无关)。合法闭包由规则
直接决定(非空、大小 <= handSize、总费用 <= energyMax、每种卡不超牌库张数);可达集由
实际发牌序决定(对每个可达状态枚举手牌的可负担子多重集并求并集)。涌现度 = 1000 ×
可达模式数 // 规则数,是**代理指标**,只用于相对比较。产物 out/play04_emergence.{json,csv,svg}。
"""
from __future__ import annotations

import copy
import itertools
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_04_emergence.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def closure(spec):
    """合法闭包:非空、大小 <= handSize、总费用 <= energyMax、每种卡不超牌库张数。"""
    p, cards, out = spec["params"], spec["cards"], set()

    def rec(i, chosen, size, spend):
        if i == len(cards):
            if size:
                out.add(tuple(sorted(chosen)))
            return
        c = cards[i]
        for k in range(0, min(c["count"], p["handSize"] - size) + 1):
            if spend + k * c["cost"] > p["energyMax"]:
                break
            rec(i + 1, chosen + [c["id"]] * k, size + k, spend + k * c["cost"])

    rec(0, [], 0, 0)
    return out


def affordable(hand, energy, cost):
    """手牌的可负担子多重集(非空),排序元组规范化。"""
    out = set()
    for r in range(1, len(hand) + 1):
        for idx in itertools.combinations(range(len(hand)), r):
            picked = [hand[i] for i in idx]
            if sum(cost[c] for c in picked) <= energy:
                out.add(tuple(sorted(picked)))
    return out


def reachable(spec, max_steps, max_nodes):
    """可达回合程序:BFS 展开状态(digest 去重),收集每个未结束状态的可负担子多重集。"""
    cost = {c["id"]: c["cost"] for c in spec["cards"]}
    seen = {ashlib.digest(ashlib.run_spec(spec, []))}
    frontier, progs, nodes, trunc = [[]], set(), 1, False
    for _ in range(max_steps):
        nxt = []
        for seq in frontier:
            s = ashlib.run_spec(spec, seq)
            if not s["over"]:
                progs |= affordable(s["hand"], s["energy"], cost)
            acts = ["p" + str(i) for i, cid in enumerate(s["hand"]) if cost[cid] <= s["energy"]] + ["e"]
            for a in acts:
                g = ashlib.digest(ashlib.run_spec(spec, seq + [a]))
                if g in seen:
                    continue
                if nodes >= max_nodes:
                    trunc = True
                    break
                seen.add(g)
                nodes += 1
                nxt.append(seq + [a])
            if trunc:
                break
        if trunc or not nxt:
            break
        frontier = nxt
    return progs, nodes, trunc


def main():
    t0 = time.perf_counter_ns()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    rules, seeds, ms, mn = cfg["rules"], cfg["seeds"], cfg["maxSteps"], cfg["maxNodes"]
    spec = ashlib.load_spec()
    legal = closure(spec)
    union, rows = set(), []
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        progs, nodes, _ = reachable(s, ms, mn)
        union |= progs
        rows.append([sd, len(progs), len(legal), nodes, 1000 * len(progs) // rules])
    base, _bnodes, btrunc = reachable(spec, ms, mn)
    emergence = 1000 * len(union) // rules
    reach_ratio = 1000 * len(union) // len(legal)
    missing = sorted(legal - base, key=lambda x: (len(x), x))
    n = ashlib.write_csv(os.path.join(OUT, "play04_emergence.csv"),
                         ["seed", "reachable", "closure", "states", "emergencePermille"], rows)
    counts = {"规则数 R": rules, "合法闭包 L": len(legal), "可达(默认种子)": len(base),
              "可达(多种子并集)": len(union)}
    root = ashlib.svg_begin(680, 250)
    ashlib.svg_text(root, 20, 28, "从规则到模式:规则数 vs 回合程序数", 15)
    top = max(counts.values())
    for i, (k, v) in enumerate(counts.items()):
        ashlib.svg_text(root, 20, 70 + i * 40, k, 12)
        ashlib.svg_bar(root, 150, 52 + i * 40, 440, 22, v, top, "#38bdf8", str(v))
    size = ashlib.svg_save(root, os.path.join(OUT, "play04_emergence.svg"))
    report = {"id": "gd_play_04_emergence", "rules": rules, "closure": len(legal),
              "reachableUnion": len(union), "reachableDefault": len(base), "defaultSeed": spec["seed"],
              "emergencePermille": emergence, "reachPermille": reach_ratio,
              "missingInDefault": [list(m) for m in missing], "perSeed": rows,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play04_emergence.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"rules R={rules}  合法闭包 L={len(legal)}")
    print(f"{'seed':>6}{'reachable':>10}{'states':>8}{'emergence‰':>12}")
    for r in rows:
        print(f"{r[0]:>6}{r[1]:>10}{r[3]:>8}{r[4]:>12}")
    print(f"默认种子 {spec['seed']}: 可达 {len(base)} 截断={btrunc}")
    print(f"多种子并集 {len(union)} = 闭包 {len(legal)} 的 {reach_ratio}‰;涌现度 {emergence}‰")
    print(f"默认种子缺的合法模式 {len(missing)}: {missing}")
    print(f"csv={n} 行  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play04_emergence.json out/play04_emergence.csv out/play04_emergence.svg")
    ET.parse(os.path.join(OUT, "play04_emergence.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_04_emergence.py(elapsed 是墙钟采样,每次运行都不同;逐种子表这里只截前 4 行,完整 8 行见 out/play04_emergence.csv):

rules R=13  合法闭包 L=35
  seed reachable  states  emergence‰
   201        35   17524        2692
   202        32    9085        2461
   203        35   21383        2692
   204        32   10631        2461
默认种子 20261014: 可达 34 截断=False
多种子并集 35 = 闭包 35 的 1000‰;涌现度 2692‰
默认种子缺的合法模式 1: [('guard', 'guard', 'jab', 'jab', 'strike')]
csv=8 行  svg=1971 bytes  elapsed=5205 ms
wrote out/play04_emergence.json out/play04_emergence.csv out/play04_emergence.svg

判读:13 条规则长出 35 个模式

三个数是本节的核心:规则 13、合法闭包 35、可达并集 35。涌现度 = 1000 × 35 // 13 = 2692‰,也就是每个局部规则平均对应约 2.7 个全局模式。

读数值说明
可达 A(默认种子)34差 1 个:('guard','guard','jab','jab','strike') 需要一张特定手牌
可达 A(8 种子并集)35多换几个种子,闭包就被填满
涌现度2692‰1000 × 35 // 13,代理指标

两条判读值得记下。第一,闭包几乎全是可达的:8 个种子的并集恰好等于闭包,说明这个内核的规则没有「写得出、够不着」的死模式;缺的那一个(guard,guard,jab,jab,strike)只在默认种子下够不着,换种子就能凑到。第二,单种子读数会抖:逐种子的可达数是 35/32/35/32/35/35/30/32,对应涌现度 2307‰–2692‰,跨度 385‰。所以「涌现度」必须声明种子集才有意义——它不是一个机制常数,而是「规则 × 采样」的联合读数。

B 层:换参数,涌现度稳不稳

既然涌现度依赖机制参数,就该问「换个参数会不会翻盘」。sim/gd_play_04_emergence_bench.py 把 energyMax 与 handSize 各扫三档(3×3 网格),复用 S 层的 closure / reachable 重算。B 档软告警不硬失败,脚本永远 exit 0。

#!/usr/bin/env python3
# file: sim/gd_play_04_emergence_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_emergence_bench.py
"""卷二 4.1 涌现基准(B 层):把涌现度的两个旋钮(energyMax / handSize)扫一遍。

涌现度依赖两个机制参数——每回合能量预算 energyMax 与手牌容量 handSize。该问的是:
**换一组参数,涌现度的排序会不会翻盘**。复用 sim/gd_play_04_emergence.py 的 closure /
reachable,不重写枚举逻辑。**永远 exit 0**:B 档软告警不硬失败——翻盘只打印 SOFT。
"""
from __future__ import annotations

import copy
import json
import os
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
import gd_play_04_emergence as em  # noqa: E402

BENCH_SEEDS = [201, 202, 203, 204]


def sweep(spec, energy, hand, ms, mn):
    """换 energyMax / handSize,重算合法闭包与多种子可达并集。"""
    s = copy.deepcopy(spec)
    s["params"]["energyMax"], s["params"]["handSize"] = energy, hand
    legal = em.closure(s)
    union = set()
    for sd in BENCH_SEEDS:
        t = copy.deepcopy(s)
        t["seed"] = sd
        union |= em.reachable(t, ms, mn)[0]
    return len(legal), len(union)


def main():
    t0 = time.perf_counter_ns()
    with open(em.CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    rules, ms, mn = cfg["rules"], cfg["maxSteps"], cfg["maxNodes"]
    spec = ashlib.load_spec()
    be, bh = spec["params"]["energyMax"], spec["params"]["handSize"]

    rows = []
    for energy in cfg["perturb"]["energyMax"]:
        for hand in cfg["perturb"]["handSize"]:
            legal, union = sweep(spec, energy, hand, ms, mn)
            rows.append({"energyMax": energy, "handSize": hand, "closure": legal, "reachable": union,
                         "emergencePermille": 1000 * union // rules, "base": energy == be and hand == bh})
    base = next(r for r in rows if r["base"])
    best = max(rows, key=lambda r: r["emergencePermille"])
    worst = min(rows, key=lambda r: r["emergencePermille"])
    nonmono = []
    for hand in cfg["perturb"]["handSize"]:
        vals = [r["emergencePermille"] for r in sorted((r for r in rows if r["handSize"] == hand),
                                                       key=lambda r: r["energyMax"])]
        if vals != sorted(vals):
            nonmono.append({"handSize": hand, "values": vals})

    for r in rows:
        print(f"energyMax={r['energyMax']} handSize={r['handSize']}  closure={r['closure']:>3}  "
              f"reachable={r['reachable']:>3}  emergence={r['emergencePermille']:>5}‰"
              f"{'  <-- 基准' if r['base'] else ''}")
    print(f"base={base['emergencePermille']}‰  best={best['emergencePermille']}‰(e={best['energyMax']},"
          f"h={best['handSize']})  worst={worst['emergencePermille']}‰(e={worst['energyMax']},"
          f"h={worst['handSize']})  spread={best['emergencePermille'] - worst['emergencePermille']}‰")
    print("SOFT OK: 每个 handSize 列上涌现度随 energyMax 单调不减" if not nonmono
          else f"SOFT WARN ENERGY_NONMONO: 涌现度对 energyMax 非单调({nonmono})")
    print(f"SOFT WARN PARAM_DRIFT: 涌现度最高不是基准({best['energyMax']}/{best['handSize']}),"
          f"是否调参需人工权衡" if not best["base"] else "SOFT OK: 基准即涌现度最高组合")
    print(f"SOFT: hardFail=false(B 档软告警不硬失败)  elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_04_emergence_bench.py:

energyMax=2 handSize=4  closure= 20  reachable= 20  emergence= 1538‰
energyMax=2 handSize=5  closure= 20  reachable= 20  emergence= 1538‰
energyMax=2 handSize=6  closure= 20  reachable= 20  emergence= 1538‰
energyMax=3 handSize=4  closure= 32  reachable= 32  emergence= 2461‰
energyMax=3 handSize=5  closure= 35  reachable= 35  emergence= 2692‰  <-- 基准
energyMax=3 handSize=6  closure= 35  reachable= 35  emergence= 2692‰
energyMax=4 handSize=4  closure= 44  reachable= 44  emergence= 3384‰
energyMax=4 handSize=5  closure= 53  reachable= 53  emergence= 4076‰
energyMax=4 handSize=6  closure= 56  reachable= 56  emergence= 4307‰
base=2692‰  best=4307‰(e=4,h=6)  worst=1538‰(e=2,h=4)  spread=2769‰
SOFT OK: 每个 handSize 列上涌现度随 energyMax 单调不减
SOFT WARN PARAM_DRIFT: 涌现度最高不是基准(4/6),是否调参需人工权衡
SOFT: hardFail=false(B 档软告警不硬失败)  elapsed=30020 ms

网格里两件事值得分开读。涌现度随能量单调不减(每列 1538→2461→3384 之类),没有非单调段——SOFT OK 那行说的就是这个。但手牌容量在 energyMax=3 时已经饱和:handSize 从 5 涨到 6,闭包与可达都不动(都是 35),因为费用预算 ≤ 3 时最多只装得下 5 张牌。所以「加手牌容量」这个旋钮在这条工作点上是死旋钮——拧它不产生新打法。

自检表

检查项合格标准
模式有定义「回合程序」写成多重集 + 三条合法性条件,不靠语义描述
闭包可解析合法闭包从规则直接枚举,与发牌无关;可达集用状态去重 BFS,同种子逐位相同
代理指标显式正文声明涌现度是代理指标、依赖种子集,只做相对比较
软告警不硬失败B 层脚本 hardFail=false,翻盘只打印 SOFT,永远 exit 0

闭包与可达立起来之后,下一节把这套口径变成一个正式指标:定义粒度、分母与种子集三个声明,看同一个内核在不同口径下能给出多大的读数差。

阅读导航:上一节:3.3 最优解的代价 · 下一节:4.2 涌现的度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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