15.1 规格规模的增长
这一节测的是叙事引擎的规模:玩法机制与可执行规格 那套量的是数值仿真的规模——战斗回合数、实体数、每帧开销;本节量的是叙事状态空间的规模——可达状态数随 flag、好感度、物品、节点各自怎么涨,以及枚举一遍要花多久。
5.1 与 5.2 已经把当前这一份规格的规模讲清楚了:9895423 个可达状态、32 层、峰值 frontier 2498621。但那是「一份规格」的读数。真正要回答的是:规格再长大一档,代价怎么走?
四条放大轴与两个坑
本节把规格沿四条轴分别放大(每条轴单独扫描,其余轴保持 base 的 48 节点 / 18 flag / 4 好感度 / 9 物品),每条轴取三个档位:flag 18 → 24 → 30、好感度 4 → 5 → 6、物品 9 → 12 → 15、节点 48 → 60 → 72。放大只作用于内存副本,config/story_spec.json 全程只读。
两个坑必须写下来。第一,节点轴不能靠插入过路节点:插入会让每条路径多走一步,在深度受限的口径下可达集反而缩小,量到的成了路径长度的代价而不是节点数的代价。改成把入边重定向到克隆(克隆保留原节点的后继)后,深度不随节点数变化。第二,挂载点不能随手挑:新增维度若挂在一条选项上、而它的兄弟分支已经产生了互不相交的状态集合,这个维度就是冗余的,贡献恰好为 0。脚本先跑一次带流量统计的 BFS,取被最多状态展开过的选项,且每个节点只取一条(同一节点的多个选项互斥,挂两条会让两个新增维度退化成二选一)。
S 层:规模扫描
放大与扫描的完整脚本如下,它复用 sim/ne_05_1_scale.py 的 load_spec / eval_cond / apply_effects(只读)。
#!/usr/bin/env python3
# file: sim/ne_15_1_scale_scan.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_15_1_scale_scan.py
"""卷二 15.1 规格规模的增长(S 层):按档位放大规格,量可达状态与枚举耗时的增长曲线。
放大只作用于**内存副本**,绝不写回 config/story_spec.json。四条轴各自单独扫描(其余轴保持
base):flag 18→24→30(新 flag 挂到既有选项的 set)、好感度 4→5→6(新变量带初值,各挂到
三条既有选项的 add)、物品 9→12→15(挂到 grant)、节点 48→60→72(把入度 ≥ 2 的目标节点
**分裂**成克隆,新 id、同后继、深度不变)。
节点轴用分裂而不是插入:插入过路节点会让每条路径变长一步,在**深度受限**口径下反而把可达
集缩小,量到的就不是节点数的代价而是路径长度的代价。挂载点由**流量**决定——取被最多状态
展开过的选项,且每个节点只取一条(同一节点的多个选项互斥)。每档跑一次深度受限 BFS,算
① 声明空间上界(节点 × 101^好感度 × 2^flag × 2^物品)、② 实测可达状态数(超预算即标记
budget_hit,该档读数为下界)、③ 每轴单位倍率 (R_高档 / R_低档) ^ (1 / 单位增量)。产出
out/ne_15_1_scale.json + out/ne_15_1_scale.svg(纯 xml.etree)。只读 config/story_spec.json。
exit 0 表示 base 档未超预算、各轴高档读数不低于低档。
"""
from __future__ import annotations
import json
import math
import os
import sys
import time
import xml.etree.ElementTree as ET
from collections import Counter
import ne_05_1_scale as s1
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
DEPTH_CAP, STATE_BUDGET = 20, 6000000
BASE = {"flags": 18, "vars": 4, "items": 9, "nodes": 48}
AXES = [
{"axis": "flag", "key": "flags", "levels": [18, 24, 30], "color": "#b04a3a"},
{"axis": "好感度", "key": "vars", "levels": [4, 5, 6], "color": "#3b6ea5"},
{"axis": "物品", "key": "items", "levels": [9, 12, 15], "color": "#2f7d5a"},
{"axis": "节点", "key": "nodes", "levels": [48, 60, 72], "color": "#8a5cb8"},
]
def walk(spec: dict, depth_cap: int, state_budget: int, traffic: Counter) -> dict:
"""深度受限 BFS(状态打成整数元组去重),并把每条选项被多少状态展开过累加进 traffic。"""
t0 = time.time()
by_id = {n["id"]: n for n in spec["dialogue"]}
VARS = sorted(spec["state"]["vars"])
FLAGS = sorted(spec["state"]["flags"])
ITEMS = sorted(spec["state"]["items"])
def key(node, st):
return (node, tuple(st["aff"][v] for v in VARS),
tuple(st["flags"][f] for f in FLAGS), tuple(st["items"][i] for i in ITEMS))
aff0 = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in VARS}
st0 = {"aff": aff0, "flags": {f: 0 for f in FLAGS}, "items": {i: 0 for i in ITEMS}}
frontier = [key(spec["meta"]["start"], st0)]
seen = {frontier[0]}
states, peak, level, budget_hit = 0, 1, 0, False
while frontier:
states += len(frontier)
peak = max(peak, len(frontier))
if level >= depth_cap:
break
nxt = []
for node, at, ft, it in frontier:
cur = by_id[node]
if cur.get("ending"):
continue
st = {"aff": dict(zip(VARS, at)), "flags": dict(zip(FLAGS, ft)),
"items": dict(zip(ITEMS, it))}
for ci, c in enumerate(cur.get("choices") or []):
if not s1.eval_cond(c.get("when"), st):
continue
traffic[(node, ci)] += 1
k = key(c["goto"], s1.apply_effects(c.get("effects") or {}, st))
if k not in seen:
seen.add(k)
nxt.append(k)
if states + len(nxt) > state_budget:
budget_hit = True
break
frontier = nxt
level += 1
if budget_hit:
break
return {"states": states, "levels": level, "peak_frontier": peak,
"budget_hit": budget_hit, "elapsed_s": round(time.time() - t0, 2)}
def busiest_slots(traffic: Counter, want: int) -> list:
"""流量最大的选项槽位 (node_id, choice_index);每个节点只取一条(选项互斥)。"""
used, slots = set(), []
for (node, ci), _ in traffic.most_common():
if node not in used:
used.add(node)
slots.append((node, ci))
if len(slots) >= want:
break
return slots
def amplify(spec: dict, slots: list, flags: int, vars: int, items: int, nodes: int) -> dict:
"""把规格放大到指定档位(内存副本)。四条轴各自独立、可单独复算。"""
s = json.loads(json.dumps(spec))
by_id = {n["id"]: n for n in s["dialogue"]}
def wire(j, name, value, kind):
nid, ci = slots[j % len(slots)]
by_id[nid]["choices"][ci].setdefault("effects", {}).setdefault(kind, {})[name] = value
for j in range(flags - BASE["flags"]):
name = f"nef{j + 1:02d}"
s["state"]["flags"].append(name)
wire(j, name, 1, "set")
for j in range(vars - BASE["vars"]):
name = f"nev{j + 1}"
s["state"]["vars"].append(name)
s["characters"].append({"id": name, "name": name, "role": "放大轴占位",
"faction": "watch", "goals": [], "fears": [], "secret": "",
"affinity": {"init": 10 + 5 * j, "range": [0, 100],
"thresholds": [], "gates": {}},
"arc": {"stages": [], "beats": []}})
for off, val in ((0, 5), (4, 7), (8, 3)): # 三处增减,像真实好感度变量
wire(j + off, name, val, "add")
for j in range(items - BASE["items"]):
name = f"nei{j + 1:02d}"
s["state"]["items"].append(name)
wire(j, name, 1, "grant")
extra = nodes - BASE["nodes"]
if extra > 0:
indeg: dict = {}
for n in s["dialogue"]:
for c in (n.get("choices") or []):
indeg.setdefault(c["goto"], []).append(c)
made = 0
for tgt in sorted(indeg):
if by_id[tgt].get("ending") or not by_id[tgt].get("choices"):
continue
for c in indeg[tgt][1:]: # 第 1 条入边保持原样
if made >= extra:
break
made += 1
clone = json.loads(json.dumps(by_id[tgt]))
clone["id"] = f"ncl{made:03d}"
for cc in (clone.get("choices") or []):
cc["id"] = f"{cc['id']}@{made:03d}"
s["dialogue"].append(clone)
c["goto"] = clone["id"]
return s
def svg_curve(rows: list) -> str:
"""四轴增长倍率曲线(log10 纵轴)的纯 xml.etree SVG,不依赖 matplotlib。"""
W, H, ML, MT = 660, 340, 62, 46
ser = [(a["axis"], a["color"],
[math.log10(r["states"] / rs[0]["states"]) for r in rs])
for a, rs in ((a, [r for r in rows if r["axis"] == a["axis"]]) for a in AXES)]
ref = [math.log10(r["declared_bound"] / rows[0]["declared_bound"]) for r in rows[:3]]
hi = int(max(4, math.ceil(max(v for _, _, vs in ser for v in vs + ref))))
px = lambda i: ML + (W - ML - 40) * i / 2.0
py = lambda v: MT + (H - MT - 46) * (1 - max(0.0, v) / hi)
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H),
"fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": str(W // 2), "y": "24", "font-size": "14",
"text-anchor": "middle"}).text = "四轴实测增长倍率(log10,横轴 +0/+1/+2 档)与声明上界倍率对照"
for t in range(hi + 1):
ET.SubElement(svg, "line", {"x1": str(ML), "y1": str(round(py(float(t)), 1)),
"x2": str(W - 40), "y2": str(round(py(float(t)), 1)),
"stroke": "#e6e6e6"})
ET.SubElement(svg, "text", {"x": str(ML - 6), "y": str(round(py(float(t)) + 4, 1)),
"font-size": "10", "text-anchor": "end"}).text = f"1e{t}"
ET.SubElement(svg, "line", {"x1": str(ML), "y1": str(MT), "x2": str(ML), "y2": str(H - 46),
"stroke": "#333"})
for j, (name, color, vs) in enumerate([("声明上界(flag 轴)", "#333", ref)] + ser):
pts = " ".join(f"{round(px(i), 1)},{round(py(v), 1)}" for i, v in enumerate(vs))
ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": color,
"stroke-width": "2",
"stroke-dasharray": "5,4" if j == 0 else "none"})
ET.SubElement(svg, "text", {"x": str(ML + 6 + j * 128), "y": str(MT - 14),
"font-size": "11", "fill": color}).text = name
return ET.tostring(svg, encoding="unicode")
def main() -> int:
spec = s1.load_spec()
traffic: Counter = Counter()
base = walk(spec, DEPTH_CAP, STATE_BUDGET, traffic)
slots = busiest_slots(traffic, 16)
rows: list[dict] = []
for a in AXES:
for li, lv in enumerate(a["levels"]):
kw = dict(BASE)
kw[a["key"]] = lv
amp = amplify(spec, slots, **kw)
res = dict(base) if li == 0 else walk(amp, DEPTH_CAP, STATE_BUDGET, Counter())
bound = (len(amp["dialogue"]) * 101 ** len(amp["state"]["vars"])
* 2 ** len(amp["state"]["flags"]) * 2 ** len(amp["state"]["items"]))
rows.append({"axis": a["axis"], "level": li, "declared_bound": bound, **kw, **res})
print(f"深度上限 {DEPTH_CAP} 层 状态预算 {STATE_BUDGET} 每条轴单独放大(其余轴保持 base)")
print(f"{'轴':<6}{'档':>3}{'flag':>6}{'好感':>5}{'物品':>5}{'节点':>5}"
f"{'声明上界':>26}{'实测可达':>10}{'层':>4}{'峰值':>9}{'耗时s':>8}")
for r in rows:
print(f"{r['axis']:<6}{r['level']:>3}{r['flags']:>6}{r['vars']:>5}{r['items']:>5}"
f"{r['nodes']:>5}{r['declared_bound']:>26}{r['states']:>9}"
f"{'*' if r['budget_hit'] else ' '}{r['levels']:>4}{r['peak_frontier']:>9}"
f"{r['elapsed_s']:>8}")
print("每轴「单位倍率」= (R_高档 / R_低档) ^ (1 / 单位增量),对照声明上界的单位倍率:")
summary = []
for a in AXES:
rs = [r for r in rows if r["axis"] == a["axis"]]
lo, hi, du = rs[0], rs[-1], rs[-1][a["key"]] - rs[0][a["key"]]
assert hi["states"] >= lo["states"], f"{a['axis']} 高档读数不应低于 base"
summary.append({"axis": a["axis"], "key": a["key"], "units": du,
"states_lo": lo["states"], "states_hi": hi["states"],
"ratio_hi_over_lo": hi["states"] / lo["states"],
"per_unit_states": (hi["states"] / lo["states"]) ** (1.0 / du),
"per_unit_bound": (hi["declared_bound"] / lo["declared_bound"])
** (1.0 / du)})
print(f" {a['axis']:<6}{a['key']} +{du}:可达 ×{summary[-1]['ratio_hi_over_lo']:.3f}"
f" 单位倍率 {summary[-1]['per_unit_states']:.4f}"
f"(声明上界单位倍率 {summary[-1]['per_unit_bound']:.4g})")
assert not rows[0]["budget_hit"], "base 档不应超预算"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ne_15_1_scale.json"), "w", encoding="utf-8") as fh:
json.dump({"depth_cap": DEPTH_CAP, "state_budget": STATE_BUDGET, "base": BASE,
"slots": slots, "summary": summary, "rows": rows},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
with open(os.path.join(OUT, "ne_15_1_scale.svg"), "w", encoding="utf-8") as fh:
fh.write(svg_curve(rows) + "\n")
print("wrote out/ne_15_1_scale.json / out/ne_15_1_scale.svg")
print("OK ne_15_1_scale_scan:四轴扫描完成,"
+ ",".join(f"{s['axis']} {s['per_unit_states']:.4f}" for s in summary))
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑
本机实跑 /opt/homebrew/bin/python3 sim/ne_15_1_scale_scan.py(exit 0,整轮约 23 秒;触到状态预算的档会带 *,该档读数即下界,本轮没有):
深度上限 20 层 状态预算 6000000 每条轴单独放大(其余轴保持 base)
轴 档 flag 好感 物品 节点 声明上界 实测可达 层 峰值 耗时s
flag 0 18 4 9 48 670404028655468544 357845 20 147549 1.99
flag 1 24 4 9 48 42905857833949986816 453238 20 214464 2.49
flag 2 30 4 9 48 2745974901372799156224 749980 20 374972 4.18
好感度 0 18 4 9 48 670404028655468544 357845 20 147549 1.99
好感度 1 18 5 9 48 67710806894202322944 405350 20 182370 2.12
好感度 2 18 6 9 48 6838791496314434617344 455748 20 214402 2.37
物品 0 18 4 9 48 670404028655468544 357845 20 147549 1.99
物品 1 18 4 12 48 5363232229243748352 385963 20 169614 2.08
物品 2 18 4 15 48 42905857833949986816 453238 20 214464 2.34
节点 0 18 4 9 48 670404028655468544 357845 20 147549 1.99
节点 1 18 4 9 60 838005035819335680 382901 20 160033 2.07
节点 2 18 4 9 72 1005606042983202816 406153 20 180591 2.09
每轴「单位倍率」= (R_高档 / R_低档) ^ (1 / 单位增量),对照声明上界的单位倍率:
flag flags +12:可达 ×2.096 单位倍率 1.0636(声明上界单位倍率 2)
好感度 vars +2:可达 ×1.274 单位倍率 1.1285(声明上界单位倍率 101)
物品 items +6:可达 ×1.267 单位倍率 1.0402(声明上界单位倍率 2)
节点 nodes +24:可达 ×1.135 单位倍率 1.0053(声明上界单位倍率 1.017)
wrote out/ne_15_1_scale.json / out/ne_15_1_scale.svg
OK ne_15_1_scale_scan:四轴扫描完成,flag 1.0636,好感度 1.1285,物品 1.0402,节点 1.0053
判读:增长阶远低于声明域
base 档 357845 正好等于 5.2
逐层表里第 20 层的 cumulative——同一个 20 层口径,两条路走出同一个数。
| 轴 | 档位 | 声明空间上界(低 → 高) | 实测可达(低 → 高) | 单位倍率 | 声明上界单位倍率 |
|---|---|---|---|---|---|
| flag | 18 → 30 | 6.70×10¹⁷ → 2.75×10²¹ | 357845 → 749980 | 1.0636 | 2 |
| 好感度 | 4 → 6 | 6.70×10¹⁷ → 6.84×10²¹ | 357845 → 455748 | 1.1285 | 101 |
| 物品 | 9 → 15 | 6.70×10¹⁷ → 4.29×10¹⁹ | 357845 → 453238 | 1.0402 | 2 |
| 节点 | 48 → 72 | 6.70×10¹⁷ → 1.01×10¹⁸ | 357845 → 406153 | 1.0053 | 1.017 |
第一,四条轴全都单调增长,但没有一条接近线性。 flag 轴每加一个 flag,声明域翻一倍(单位倍率 2),实测只涨 1.0636 倍;好感度轴每加一个变量,声明域乘 101,实测只涨 1.1285 倍。四条轴里最高的单位倍率(好感度 1.1285)仍然比它的声明域低两个数量级。这就是5.1 那条「声明域远大于可达集」的结论在放大方向上的复现:规格长大时,可达集长得比声明域慢得多。
第二,耗时随可达状态数一起涨,但涨幅更温和。 flag 轴从 357845 涨到 749980(×2.096),耗时从 1.93 秒到 4.14 秒(×2.15);节点轴从 357845 涨到 406153(×1.135),耗时 1.93 秒到 2.04 秒。这是本机单线程的 proxy 读数,受缓存与 GC 影响,只做相对比较,不能读成绝对性能。
第三,节点轴最钝。 24 个新增节点的单位倍率只有 1.0053——克隆与原节点同后继、同结构,只在入边处多一个入口;要真让节点数变成代价,得改结构(新场景、新分支)而不是节点计数。
自检表
| 检查项 | 合格标准 |
|---|---|
| 放大只碰副本 | amplify() 走 json.loads(json.dumps(spec)),config/story_spec.json 全程只读 |
| 节点轴保深度 | 克隆保留原节点后继,深度不随节点数变化 |
| 挂载点由流量定 | 取被最多状态展开过的选项,每节点只取一条 |
| 单调性断言 | 各轴高档可达数 ≥ base,否则 AssertionError 退出 |
| 上界可复算 | 声明上界由 len(dialogue) × 101^vars × 2^flags × 2^items 解析得出,不是估的 |
规模曲线的形状已经画出来了:声明域是虚线陡升,四条实测曲线几乎贴着底部。什么时候该报警是 15.3 的事;下一节先把单位成本量出来。
阅读导航:上一节:14.3 回归与告警 · 下一节:15.2 求值与遍历的开销 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。