《游戏设计》卷二《策略涌现与体验度量》 建模的是玩家动因与类型,用动机解释真人为什么选这条路;本章的「模拟玩家」只是路径选择策略(随机 / 贪心 / 覆盖导向),用途是采样,不建模动因、不做玩家分型。这一节把 11.1 定义的三种策略放到同一份规格上各跑 5000 条,量它们产出的分布——同一份图、同一个种子,只换「怎么选」这一条规则,分布就变了,这正是「策略」这个旋钮的作用。
11.2 策略对路径分布的影响
11.1 只看了覆盖率一个侧面。但采样产出的是一整个分布:走到哪些结局、各多少条,路径多长,边被访问得多不均匀。覆盖率相同的两种策略,分布可能完全不同。本节把三种策略各跑 5000 条路径,量四个量:结局分布、结局分布熵(毫比特)、边访问偏斜 gini(千分)、路径长度区间。
S 层:三种策略各 5000 条的分布
脚本 sim/nm_11_2_strategy_distribution.py 复用 11.1 的走图内核与三种 pick() 分支,对每种策略跑 5000 条路径,落 out/nm_11_2_strategy_distribution.json。覆盖率分母仍是去重对话边(同 2.1 / 11.1 口径),熵按 Shannon ×1000 取整(毫比特),gini 按千分位整数地板除。
#!/usr/bin/env python3
# file: sim/nm_11_2_strategy_distribution.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_11_2_strategy_distribution.py
"""卷三 11.2 策略对路径分布的影响(S 层)。
同一份规格、同一个起点、同一个 PRNG,**只换「怎么选」这一条规则**,三种策略各跑
5000 条路径,量它们产出的分布:结局分布、结局分布熵(毫比特)、边访问偏斜 gini(千分)、
路径长度区间与不同序列数。这正说明不能拿某一种策略的分布去断言「玩家会怎么走」。
公共走法复用 11.1 的模块。全部整数、无浮点;覆盖率分母是去重对话边(同 2.1 / 11.1)。
产出 out/nm_11_2_strategy_distribution.json。所有指标都是 proxy,只做相对比较。
"""
from __future__ import annotations
import json
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import nm_11_1_sim_player_strategy as base # noqa: E402 复用三种策略的走法
SEED = 20261016
N = 5000
STEP_CAP = 200
def gini_permille(counts: list) -> int:
xs = sorted(counts)
n, s = len(xs), sum(xs)
if n == 0 or s == 0:
return 0
weighted = sum((i + 1) * x for i, x in enumerate(xs))
return (2 * weighted * 1000) // (n * s) - (1000 * (n + 1)) // n
def run(spec: dict, by: dict, strategy: str, n: int) -> dict:
E = len({(x["id"], c["goto"]) for x in spec["dialogue"] for c in (x.get("choices") or [])})
dist = {e["name"]: 0 for e in spec["endings"]}
rng, seen, ecount, trails, lengths = SEED, set(), {}, set(), []
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
for _ in range(n):
node, st, trail = spec["meta"]["start"], base.init_state(spec), []
for _step in range(STEP_CAP):
if by[node].get("ending"):
dist[by[node]["ending"]] += 1
break
opts = [c for c in (by[node].get("choices") or [])
if base.eval_cond(c.get("when"), st)]
if not opts:
break
c = base.pick(strategy, node, opts, rnd, seen)
ecount[(node, c["goto"])] = ecount.get((node, c["goto"]), 0) + 1
seen.add((node, c["goto"]))
trail.append(c["id"])
st = base.apply_effects(c.get("effects") or {}, st)
node = c["goto"]
trails.add(tuple(trail))
lengths.append(len(trail))
ent = 0.0
for v in dist.values():
if v > 0:
p = v / n
ent -= p * math.log2(p)
order = [e["name"] for e in spec["endings"]]
return {"strategy": strategy, "n": n,
"distribution": {k: dist[k] for k in order},
"coverage_permille": len(ecount) * 1000 // E,
"ending_entropy_milli_bits": int(ent * 1000),
"distinct_trails": len(trails),
"edge_gini_permille": gini_permille(list(ecount.values())),
"len_min": min(lengths), "len_max": max(lengths),
"len_mean": sum(lengths) // len(lengths)}
def main() -> int:
spec = base.load_spec()
by = {n["id"]: n for n in spec["dialogue"]}
E = len({(n["id"], c["goto"]) for n in spec["dialogue"] for c in (n.get("choices") or [])})
rows = [run(spec, by, s, N) for s in ("random", "greedy", "cover")]
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_11_2_strategy_distribution.json"), "w",
encoding="utf-8") as fh:
json.dump({"n": N, "seed": SEED, "edges": E, "rows": rows},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
names = [e["name"] for e in spec["endings"]]
print(f"每条策略 {N} 条路径(seed={SEED}) 去重对话边 {E} 目标覆盖率 {spec['metrics']['coverage_target_permille']}‰")
print(f" {'策略':<8}{'覆盖‰':>7}{'熵毫比特':>10}{'gini‰':>7}{'序列':>6}{'长-短':>6}{'长-长':>6}{'长-均':>6} 结局分布")
for r in rows:
d = " ".join(f"{k} {r['distribution'][k]}" for k in names)
print(f" {r['strategy']:<8}{r['coverage_permille']:>7}{r['ending_entropy_milli_bits']:>10}"
f"{r['edge_gini_permille']:>7}{r['distinct_trails']:>6}"
f"{r['len_min']:>6}{r['len_max']:>6}{r['len_mean']:>6} {d}")
for r in rows:
assert r["ending_entropy_milli_bits"] >= 0, "熵须非负"
assert 0 <= r["coverage_permille"] <= 1000, "覆盖率须落在 [0,1000]‰"
assert 0 <= r["edge_gini_permille"] <= 1000, "gini 须落在 [0,1000]‰"
assert sum(r["distribution"].values()) == N, "结局计数之和须等于路径数"
print("wrote out/nm_11_2_strategy_distribution.json")
print("OK nm_11_2_strategy_distribution:三种策略各 5000 条的分布已量出,均为 proxy")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_11_2_strategy_distribution.py:
每条策略 5000 条路径(seed=20261016) 去重对话边 56 目标覆盖率 850‰
策略 覆盖‰ 熵毫比特 gini‰ 序列 长-短 长-长 长-均 结局分布
random 1000 1730 225 5000 22 31 25 灯守 270 离港 2502 真相 49 缄默 815 沉没 1364
greedy 964 1716 196 1440 22 28 24 灯守 407 离港 2483 真相 0 缄默 744 沉没 1366
cover 1000 1732 225 4999 22 31 25 灯守 270 离港 2499 真相 50 缄默 818 沉没 1363
wrote out/nm_11_2_strategy_distribution.json
OK nm_11_2_strategy_distribution:三种策略各 5000 条的分布已量出,均为 proxy
判读:贪心把「真相」这个结局整条关掉了
第一,最刺眼的是那一列 0。 贪心跑 5000 条,一次都没走到「真相」(真相 0 条);随机 49 条、覆盖导向 50 条。原因不在随机性,在结构:真相 由一条窄路径门控,而贪心每一步都挑好感度增量最大的选项,那条窄路径上的选项增量低,永远排不到它前面。一个只最大化单个观测量的策略,会把某些结局整条关掉——这不是「没采样到」,是「采不到」。
第二,熵几乎不动,但分布已经不一样了。 三种策略熵都在 1716–1732 毫比特(阈值 1500),可「灯守」从随机的 270 跳到贪心的 407、「真相」从 49 掉到 0。熵只概括均匀度,不区分是哪个结局被挤掉——这正是 15.3「指标不能声称什么」的样本。
第三,路径长度分布也跟着变。 随机与覆盖导向的长度区间是 [22, 31]、均值 25;贪心是 [22, 28]、均值 24——贪心的路径更短、更集中,因为它反复走进同一条走廊(11.1 已看到它覆盖率不随预算增长)。长度分布变窄,往往意味着某个枢纽塌了或被绕开了。
第四,随机与覆盖导向的分布几乎一样,但走法完全不同。 两者结局分布、长度区间、gini(都是 225)都接近,可一个靠等概率撞、一个主动填缺口。在这张 56 条边的小图上,只要不刻意偏置,分布就趋同;偏置(贪心)才是让分布变形的旋钮。但这绝不等于「随机分布 = 真人分布」——换一种策略分布就换一副样子。
B 层:把三种策略按混合比掺着走
真实采样很少只用一种策略。下面这段 B 层脚本 sim/nm_11_2_mix_sweep.py 把「策略混合比」当作可扰动参数,扫 7 档(纯 R/G/C、两两各半、三种均分),每档 3000 条路径。B 层只产出软告警、一律 exit 0——覆盖率低于目标、结局熵低于阈值、有结局没被采样到,都只打印 WARN,不返回非零。
#!/usr/bin/env python3
# file: sim/nm_11_2_mix_sweep.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_11_2_mix_sweep.py
"""卷三 11.2 策略混合比的相变扫描(B 层):参数扰动 → 分布的相变,**软告警不硬失败**。
11.2 S 层只跑三种纯策略,真实采样往往混着用。本节把「策略混合比」当作可扰动参数,
扫 7 档(纯 R/G/C、两两各半、三种均分),每档 3000 条路径,报覆盖率(千分)、结局熵
(毫比特)、未采样结局数、不同序列数。看的是相变:贪心占比一高,稀有结局先消失。
公共走法复用 11.1;策略抽样走**独立** PRNG 流,故纯策略档复现 S 层同一条走边序列。
口径(proxy):全部是结构量,只做相对比较。**B 层只产出软告警,一律 exit 0**。
产出 out/nm_11_2_mix_sweep.json。
"""
from __future__ import annotations
import json
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import nm_11_1_sim_player_strategy as base # noqa: E402 复用三种策略的走法
import nm_11_2_strategy_distribution as dist_mod # noqa: E402 复用 gini 口径
gini_permille = dist_mod.gini_permille
SEED = 20261016
N_PER = 3000
STEP_CAP = 200
# (标签, 随机权重, 贪心权重, 覆盖权重),权重为千分位
MIXES = [("R", 1000, 0, 0), ("G", 0, 1000, 0), ("C", 0, 0, 1000),
("RG", 500, 500, 0), ("RC", 500, 0, 500), ("GC", 0, 500, 500),
("RGC", 334, 333, 333)]
def run_mix(spec: dict, by: dict, weights: tuple, n: int) -> dict:
wr, wg, wc = weights
E = len({(x["id"], c["goto"]) for x in spec["dialogue"] for c in (x.get("choices") or [])})
dist = {e["name"]: 0 for e in spec["endings"]}
rng, seen, ecount, trails = SEED, set(), {}, set()
rng_strat = SEED + 1 # 策略抽样走独立流
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
def rnd_strat(mod: int) -> int:
nonlocal rng_strat
rng_strat = (rng_strat * 48271) % 2147483647
return rng_strat % mod
for _ in range(n):
t = rnd_strat(1000) # 每条路径按混合比抽一个策略(可复现)
strat = "random" if t < wr else "greedy" if t < wr + wg else "cover"
node, st, trail = spec["meta"]["start"], base.init_state(spec), []
for _step in range(STEP_CAP):
if by[node].get("ending"):
dist[by[node]["ending"]] += 1
break
opts = [c for c in (by[node].get("choices") or [])
if base.eval_cond(c.get("when"), st)]
if not opts:
break
c = base.pick(strat, node, opts, rnd, seen)
ecount[(node, c["goto"])] = ecount.get((node, c["goto"]), 0) + 1
seen.add((node, c["goto"]))
trail.append(c["id"])
st = base.apply_effects(c.get("effects") or {}, st)
node = c["goto"]
trails.add(tuple(trail))
ent = 0.0
for v in dist.values():
if v > 0:
p = v / n
ent -= p * math.log2(p)
return {"weights": {"random": wr, "greedy": wg, "cover": wc},
"coverage_permille": len(ecount) * 1000 // E,
"ending_entropy_milli_bits": int(ent * 1000),
"edge_gini_permille": gini_permille(list(ecount.values())),
"unsampled_endings": sum(1 for v in dist.values() if v == 0),
"distinct_trails": len(trails)}
def main() -> int:
spec = base.load_spec()
by = {n["id"]: n for n in spec["dialogue"]}
target = spec["metrics"]["coverage_target_permille"]
thr = spec["metrics"]["ending_entropy_min_milli_bits"]
rows = []
for label, wr, wg, wc in MIXES:
r = run_mix(spec, by, (wr, wg, wc), N_PER)
r["mix"] = label
rows.append(r)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_11_2_mix_sweep.json"), "w", encoding="utf-8") as fh:
json.dump({"n_per": N_PER, "seed": SEED, "target_coverage_permille": target,
"entropy_threshold_milli_bits": thr, "rows": rows},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"每档 {N_PER} 条路径(seed={SEED}) 混合比 随机/贪心/覆盖(千分)")
print(f" {'档':<5}{'R/G/C':>14}{'覆盖‰':>8}{'熵毫比特':>10}{'gini‰':>7}{'未采样结局':>12}{'序列':>7}")
for r in rows:
w = r["weights"]
print(f" {r['mix']:<5}{w['random']:>4}/{w['greedy']:>3}/{w['cover']:>4}"
f"{r['coverage_permille']:>8}{r['ending_entropy_milli_bits']:>10}"
f"{r['edge_gini_permille']:>7}{r['unsampled_endings']:>12}{r['distinct_trails']:>7}")
warns = 0
for r in rows:
if r["coverage_permille"] < target:
print(f"WARN mix={r['mix']} 覆盖 {r['coverage_permille']} 千分低于目标 {target}")
warns += 1
if r["ending_entropy_milli_bits"] < thr:
print(f"WARN mix={r['mix']} 结局熵 {r['ending_entropy_milli_bits']} 毫比特低于阈值 {thr}")
warns += 1
if r["unsampled_endings"] > 0:
print(f"WARN mix={r['mix']} 有 {r['unsampled_endings']} 个结局未被采样到")
warns += 1
print(f"wrote out/nm_11_2_mix_sweep.json rows={len(rows)}")
print(f"OK nm_11_2_mix_sweep:{len(rows)} 档混合比已扫描,告警 {warns} 条已报告(B 层不硬失败)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_11_2_mix_sweep.py(exit 0):
每档 3000 条路径(seed=20261016) 混合比 随机/贪心/覆盖(千分)
档 R/G/C 覆盖‰ 熵毫比特 gini‰ 未采样结局 序列
R 1000/ 0/ 0 1000 1732 223 0 3000
G 0/1000/ 0 964 1717 199 1 1142
C 0/ 0/1000 1000 1736 224 0 3000
RG 500/500/ 0 1000 1726 224 0 2271
RC 500/ 0/ 500 1000 1735 223 0 3000
GC 0/500/ 500 1000 1713 224 0 2261
RGC 334/333/ 333 1000 1716 224 0 2600
WARN mix=G 有 1 个结局未被采样到
wrote out/nm_11_2_mix_sweep.json rows=7
OK nm_11_2_mix_sweep:7 档混合比已扫描,告警 1 条已报告(B 层不硬失败)
判读 B:只有纯贪心丢结局,掺一点别的就回来
相变点很干净:7 档里只有纯贪心(G,1000/0/0)丢了一个结局——上一节那个「真相」。掺进一点随机或覆盖导向(RG、GC、RGC),真相立刻回来。这不是「贪心不好」,而是纯贪心把一条窄路径整条关掉;只要还有别的策略在采样,那条路就会被走到。告警 1 条已报告,脚本仍 exit 0——B 层口径:只产软告警,不硬失败。
覆盖率的相变也在同一档:纯贪心 964‰,其余全部 1000‰——964‰ 已高于目标 850‰,所以没有触发覆盖率告警,最先报警的是「丢了一个结局」这类计数,而非覆盖率。混合比还说明一件事:RGC(三种均分)各项都不差,但它只是一份混了三种走法的采样器,混合比是人为选的,同样不能被读成「真人会这样走」。
本节所有数字都是代理指标(proxy):结局分布、熵、gini、长度都是结构量,只度量「这张图被走成什么形状」,不声称哪条分布更接近真人、哪种策略「更好」。分布相等不等于体验相等,熵相等不等于分布相等。
自检表
| 检查项 | 合格标准 |
|---|---|
| 同图同种子 | 三种策略只换「怎么选」,规格与 PRNG 相同 |
| 整数不变量 | 覆盖率 / gini ∈ [0,1000]‰,熵 ≥ 0,计数之和 = 5000 |
| B 层软告警 | 只打印 WARN,脚本 exit 0,告警条数已报告 |
分布已经量出来了,而且看到一件要紧的事:一个策略能整条关掉一个结局。那么这套「模拟玩家」到底算什么?下一节把这条边界写成一份可 lint 的分工表。
阅读导航:上一节:11.1 模拟玩家的策略 · 下一节:11.3 与玩家动因模型的分工 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。