4.1 涌现:从规则到模式
站内的平衡治理流程 讲的是委员会、评审与榜单:谁来裁决一个机制要不要改、改动走哪条流程。本节不碰治理,碰的是治理之前的那一步——先得有一个可算的量,说明「这套规则到底跑出了什么」。涌现度量就是那个量:可达模式数与规则数的比。它不判断「该不该改」,只回答「现在有多少种可打的打法」。这份量之所以能算,是因为卷二把「模式」定义成了有限、可枚举、与顺序无关的对象。
规则是局部的,模式是全局的
「涌现」在游戏设计语境里常被当成玄学:规则简单、玩法丰富,于是「涌现」了。这句话没法证伪,因为它没给「丰富」一个定义。本节的立场是把两件事分开:规则是局部的,每条规则只约束一个动作或一次结算;模式是全局的,它是一次完整交互里被规则允许的组合。前者有 13 条(卷一 1.1 的规则表),后者需要算出来。
| 层 | 对象 | 数量从哪来 |
|---|---|---|
| 局部 | 规则 R1–R9、不变量 I1–I4 | 规格文本,13 条 |
| 局部 | 动作字母表 | handSize + 1 = 6 个符号 |
| 全局 | 回合程序(一回合内合法打出的卡牌多重集) | 本节要算的「模式」 |
「模式」选回合程序而不是整局轨迹,有两个工程理由:有限(一回合最多 handSize 张牌)、与顺序无关(一回合内先打 A 后打 B 与先打 B 后打 A,付得起的费用之和一样,故规范化为排序元组)。有限 + 无序,模式空间才能被枚举干净。
回合程序:模式的规范化定义
一个回合程序是一个非空的卡牌 id 多重集,写成排序元组,例如 ('guard', 'guard', 'jab', 'strike')。它合法当且仅当同时满足三条:
| 条件 | 表达式 | 来源 |
|---|---|---|
| 不超手牌容量 | size <= handSize | R2 / R8 |
| 不超能量预算 | sum(cost) <= energyMax | R1 / R6 |
| 不超牌库张数 | 每种卡的出现次数 <= count | cards[] |
三条条件一列出来,「合法闭包」就与发牌无关了——它是规则的解析闭包:所有满足上面三条的非空多重集。而「可达」是另一个集合:在真实发牌序下,真的能凑到手上的那些回合程序。两者的差,就是「规则允许」与「运气允许」之间的缺口。
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 涌现的度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。