《游戏设计》卷二《策略涌现与体验度量》 做的是玩家动因与类型建模——动机、人格、玩家分型,用来解释与预测真人为什么那样玩;本章的「模拟玩家」只做路径选择策略(随机 / 贪心 / 覆盖导向),用途是采样,不建模动因、不做玩家分型。这一节先把三种策略写成可跑的函数,§11.3 再用一整节把这条分工写死。
11.1 模拟玩家的策略
第 2 章已经说明「一条路径覆盖不够,必须采样」。但采样有一个第 2 章没有展开的自由度:每一条路径,每一步怎么选? 均匀随机是一种选法,总是选好感度最高的选项是另一种,优先走没走过的边又是第三种。把「怎么选」抽出来,就是一个可替换的策略——本章给采样装上这个旋钮。
「模拟玩家」是一台采样器,不是一个玩家模型
先立边界。本章通篇用「模拟玩家」这个词,但它指的是一台按固定规则走图的采样器:它只读 config/story_spec.json 里的条件与效果,每一步挑一个当时可用的选项。它对「玩家为什么这样选」一无所知——没有动机、没有人格、没有分型。它唯一的用途是把路径铺开,供第 2 章的覆盖率与第 3 章的分布去度量。 把「模拟玩家」读成「玩家模型」,是本章最需要防的误读,§11.3 会用一份可 lint 的分工表把它钉死。
三种策略的定义如下。它们共享同一个走图内核(条件求值、效果应用、状态推进),只在挑哪一个选项这一步分岔:
| 策略 | 每一步怎么选 | 优化的是什么 | 用途 |
|---|---|---|---|
| 随机 random | 用可播种 PRNG 在当时可用的选项里等概率选 | 无(无偏) | 得到不偏的路径分布 |
| 贪心 greedy | 总是挑好感度增量最大的可用选项 | 好感度增量这一个观测量 | 模拟「只认一条线」的走法 |
| 覆盖导向 cover | 优先走尚未覆盖的对话边 | 边覆盖率 | 用最少路径数把边铺开 |
三种策略都不建模动因:随机没有偏好,贪心只认一个可算的观测量(好感度增量,即选项效果里 add 之和),覆盖导向只认「这条边走过没有」。它们对「玩家会不会觉得这样有趣」不置一词——那是玩家动因模型的活,不是采样器的活。
S 层:三种策略在同一份规格下对照
下面这段 S 层脚本 sim/nm_11_1_sim_player_strategy.py 把三种策略写成 pick() 里的三个分支,各走 10 与 200 条路径,报四个结构量:分支覆盖率(千分)、到达的结局数、不同序列数、路径长度区间。再加一条确定性下界(每步选第一个可用选项)做对照。PRNG 是自实现的 Lehmer MINSTD(rng = rng * 48271 % 2147483647),同种子同序列;覆盖率分母是去重对话边(与 2.1 同口径)。
#!/usr/bin/env python3
# file: sim/nm_11_1_sim_player_strategy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_11_1_sim_player_strategy.py
"""卷三 11.1 模拟玩家的策略(S 层)。
给采样装上「策略」这个旋钮。所谓「模拟玩家」不是玩家模型,只是一台**采样器**——
它每一步只按一条固定规则挑一个可用选项,对动因一无所知。本节定义三种策略:
随机(random) 用可播种 PRNG(Lehmer MINSTD)在**当时可用**的选项里等概率选。
贪心(greedy) 总是挑**好感度增量最大**的可用选项(效果里 `add` 之和最大);
增量相同时用 PRNG 打破平局。这是「总是最大化某个观测量」的样本。
覆盖导向(cover) 优先走**尚未覆盖**的对话边;全已覆盖时退回 PRNG。目标是铺边。
三种策略各跑 N=200 条路径,报四个结构量:分支覆盖率(千分)、到达的结局数、不同的
选项序列数、路径长度区间。再加一条确定性下界(每步选第一个可用选项)做对照。
全部整数、无浮点;只读 config/story_spec.json;覆盖率分母是**去重对话边**(与 2.1 同
口径)。产出 out/nm_11_1_sim_player_strategy.csv。所有指标都是 proxy,只做相对比较。
"""
from __future__ import annotations
import csv
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
SEED = 20261016
NS = [10, 200]
STEP_CAP = 200
AFF_LO, AFF_HI = 0, 100
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def eval_cond(c, st: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = st["aff"].get(c[1], 0), c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "item":
return st["items"].get(c[1], 0) == c[2]
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
return not eval_cond(c[1], st) # not
def apply_effects(eff: dict, st: dict) -> dict:
aff, flags, items = dict(st["aff"]), dict(st["flags"]), dict(st["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = max(AFF_LO, min(AFF_HI, aff.get(k, 0) + v))
for k, v in (eff.get("grant") or {}).items():
items[k] = v
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"aff": aff, "flags": flags, "items": items}
def init_state(spec: dict) -> dict:
aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])}
return {"aff": aff, "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
def aff_gain(c: dict) -> int:
"""贪心观测:这个选项带来的好感度增量之和(可能为 0)。"""
return sum((c.get("effects") or {}).get("add", {}).values())
def pick(strategy: str, node: str, opts: list, rnd, seen: set) -> dict:
"""按策略挑一个选项。rnd(mod) 是自实现 PRNG 取模。"""
if strategy == "greedy":
g = max(aff_gain(c) for c in opts)
cand = [c for c in opts if aff_gain(c) == g]
return cand[rnd(len(cand))]
if strategy == "cover":
fresh = [c for c in opts if (node, c["goto"]) not in seen]
cand = fresh if fresh else opts
return cand[rnd(len(cand))]
return opts[rnd(len(opts))] # random
def sample(spec: dict, by: dict, strategy: str, n: int) -> dict:
rng = SEED
seen: set = set()
es, ends, trails, lengths = set(), 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"], init_state(spec), []
for _step in range(STEP_CAP):
if by[node].get("ending"):
ends.add(by[node]["ending"])
break
opts = [c for c in (by[node].get("choices") or []) if eval_cond(c.get("when"), st)]
if not opts:
break
c = pick(strategy, node, opts, rnd, seen)
seen.add((node, c["goto"]))
es.add((node, c["goto"]))
trail.append(c["id"])
st = apply_effects(c.get("effects") or {}, st)
node = c["goto"]
trails.add(tuple(trail))
lengths.append(len(trail))
return {"edges": es, "endings": ends, "trails": len(trails),
"lo": min(lengths), "hi": max(lengths), "mean": sum(lengths) // len(lengths)}
def first_path(spec: dict, by: dict) -> dict:
"""确定性下界:每步选第一个可用选项。"""
node, st, es, ln = spec["meta"]["start"], init_state(spec), set(), 0
for _ in range(STEP_CAP):
if by[node].get("ending"):
break
opts = [c for c in (by[node].get("choices") or []) if eval_cond(c.get("when"), st)]
if not opts:
break
c = opts[0]
es.add((node, c["goto"]))
st = apply_effects(c.get("effects") or {}, st)
node = c["goto"]
ln += 1
return {"edges": es, "endings": {by[node]["ending"]} if by[node].get("ending") else set(),
"trails": 1, "lo": ln, "hi": ln, "mean": ln}
def main() -> int:
spec = 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 [])})
m = spec["metrics"]
rows = []
det = first_path(spec, by)
rows.append({"strategy": "deterministic", "n": 1, "edges": len(det["edges"]),
"permille": len(det["edges"]) * 1000 // E, "endings": len(det["endings"]),
"trails": 1, "len_lo": det["lo"], "len_hi": det["hi"], "len_mean": det["mean"]})
for strat in ("random", "greedy", "cover"):
for n in NS:
r = sample(spec, by, strat, n)
rows.append({"strategy": strat, "n": n, "edges": len(r["edges"]),
"permille": len(r["edges"]) * 1000 // E, "endings": len(r["endings"]),
"trails": r["trails"], "len_lo": r["lo"], "len_hi": r["hi"], "len_mean": r["mean"]})
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_11_1_sim_player_strategy.csv"), "w",
encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["strategy", "n", "edges", "permille",
"endings", "trails", "len_lo", "len_hi", "len_mean"])
w.writeheader()
w.writerows(rows)
print(f"图:{len(spec['dialogue'])} 节点 去重对话边 {E} 目标覆盖率 {m['coverage_target_permille']}‰ 预算档 {NS}")
print(f" {'策略':<14}{'N':>5}{'边':>5}{'‰':>6}{'结局':>5}{'序列':>6}{'长-短':>7}{'长-长':>7}{'长-均':>7}")
for r in rows:
print(f" {r['strategy']:<14}{r['n']:>5}{r['edges']:>5}{r['permille']:>6}"
f"{r['endings']:>5}{r['trails']:>6}{r['len_lo']:>7}{r['len_hi']:>7}{r['len_mean']:>7}")
assert all(0 <= r["permille"] <= 1000 for r in rows), "覆盖率须落在 [0,1000]‰"
assert all(0 < r["len_lo"] <= r["len_hi"] for r in rows), "路径长度区间须非空且有序"
print("wrote out/nm_11_1_sim_player_strategy.csv")
print(f"OK nm_11_1_sim_player_strategy:三种策略在预算档 {NS} 已对照,指标均为 proxy")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_11_1_sim_player_strategy.py:
图:48 节点 去重对话边 56 目标覆盖率 850‰ 预算档 [10, 200]
策略 N 边 ‰ 结局 序列 长-短 长-长 长-均
deterministic 1 25 446 1 1 25 25 25
random 10 54 964 4 10 22 28 25
random 200 56 1000 5 200 22 31 25
greedy 10 54 964 4 10 22 28 25
greedy 200 54 964 4 179 22 28 24
cover 10 55 982 4 10 22 31 26
cover 200 56 1000 5 200 22 31 25
wrote out/nm_11_1_sim_player_strategy.csv
OK nm_11_1_sim_player_strategy:三种策略在预算档 [10, 200] 已对照,指标均为 proxy
判读:三种策略是三种采样器,不是一个模型的三个参数
第一,确定性下界是 446‰,与目录篇一致。 每步选第一个可用选项,一条路径走 25 条边、1 个结局——无论重复多少次都不会变。这是「必须采样」的基线。
第二,覆盖导向铺边最快。 在 N=10 的小预算下,覆盖导向已经到 982‰(55/56),随机与贪心都只有 964‰(54/56)。原因很直接:它每一步优先走没走过的边,等于主动去填缺口;随机靠撞,贪心根本不看覆盖。到了 N=200,随机与覆盖导向都到 1000‰,因为这张图只有 56 条去重边、路径本身就有 25 步左右,随机也撞得满。
第三,贪心收敛到一条走廊,覆盖率不随预算增长。 贪心 N=10 与 N=200 都是 964‰(54/56),不同序列数从 10 涨到 179,但覆盖的边不再增加,路径长度上限还卡在 28(随机与覆盖导向能到 31)。这是「总是最大化一个观测量」的代价:它反复走进同一条好感度最高的走廊,走廊外的边永远碰不到。
第四,三种策略都不是玩家模型。 覆盖导向的走法在真人里几乎不存在(没人会为了「走没走过的边」而选对话);贪心也只是「只认一个数」的极端。它们存在的意义是采样工具:覆盖导向用来铺边,随机用来拿无偏分布,贪心用来做「一条线走到底」的对照。任何一条都不能被读成「玩家会怎么走」。
本节所有指标都是代理指标(proxy):覆盖率、结局数、路径长度都是结构量,只度量「这张图被走过多少、走到哪里」,不声称走出来的叙事体验更好或更完整。它们只做相对比较(三种策略互相对照),不做绝对判断。
自检表
| 检查项 | 合格标准 |
|---|---|
| 策略是采样器 | 三种策略都不建模动因、不做分型 |
| 口径一致 | 覆盖率同分母(去重对话边 56),千分比整数地板除 |
| 随机可复现 | 自实现 Lehmer MINSTD,同种子同序列,禁内置 random |
| 覆盖导向真铺边 | N=10 时覆盖率高于随机与贪心(982‰ vs 964‰) |
| 贪心真收敛 | 覆盖不随预算增长,长度上限低于另两种 |
| proxy 声明 | 结构量只做相对比较,不声称体验更好 |
三种策略各能覆盖多少边已经清楚了,但覆盖率只是分布的一个侧面。同一条规格、同一个种子,只换「怎么选」这一条规则,结局分布与路径长度分布会怎样变——这是下一节要量的事。
阅读导航:上一节:10.3 路径热力图 · 下一节:11.2 策略对路径分布的影响 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。