《游戏设计》卷二《策略涌现与体验度量》 做的是玩家动因与类型建模——动机、人格、玩家分型,用来解释与预测真人为什么那样玩;本节把本章与之的分工钉成一份可被机器检查的表:本章的「模拟玩家」只做路径选择策略(随机 / 贪心 / 覆盖导向),用途是采样,不建模动因、不做玩家分型。
11.3 与玩家动因模型的分工
第 11.1 与 11.2 通篇叫「模拟玩家」,这个词有被误读的风险:一旦读者把它当成玩家模型,就会顺理成章地问「随机策略的分布是不是就是真人分布」「贪心策略的走法是不是一种玩家类型」。本节用一份分工表把这条边界写死,并配一个 lint 脚本——让「本章不建模动因、不做玩家分型」不是一句口号,而是一份可被机器检查的产物。
分工的边界:一边是采样器,一边是动因模型
边界可以一句话概括:本章这一侧只回答「路径怎么被走出来」,另一侧才回答「玩家为什么这样走」。 两侧的对象、用途、输入都不同:
| 维度 | 本章(模拟玩家与路径策略) | 《游戏设计》卷二(策略涌现与体验度量) |
|---|---|---|
| 对象 | 路径选择策略 | 玩家动因与类型建模 |
| 关心 | 走法(随机 / 贪心 / 覆盖导向) | 动机、人格、玩家分型 |
| 用途 | 采样:把路径铺开供度量 | 解释与预测玩家行为 |
| 建模动因 | 否 | 是 |
| 玩家分型 | 否 | 是 |
| 输入 | config/story_spec.json | 玩家行为数据 |
边界一旦划清,几条话就被禁止:不能说「玩家会这样走」、不能说「这是玩家的真实偏好」、不能「按动因给玩家分型」、不能「预测真人行为」。允许的用法只有两条:同一规格内不同策略的相对对照,以及覆盖采样时选择走法。这不是措辞洁癖——把采样分布当成行为分布,会直接导致拿「采样器选出来的路径」去给玩家下结论。
反过来看,这也解释了为什么 11.2 要并排跑三种策略、而不是只跑一种:如果只跑一种,读者很容易把它当成「玩家会这样走」;三种并排,才能看出分布是被「怎么选」这条规则塑造的,而不是被玩家塑造的。分工表里那一串「否」字,正是从这种并排对照里长出来的。
还有一层:分工表把「本章不做什么」写进了产物,而不只是写进前言。前言里一句「本章不建模动因」读者可以跳过,但 forbidden_claims 的四条禁声称是结构化的、可被脚本读到的——它随 out/nm_11_3_division.json 一起落盘,任何人复查本章时都能看到这份声明。
为什么必须写死这条边界
把边界写死,不是为了划地盘,而是因为混用「采样分布」与「行为分布」会直接算错。三件具体的事:
- 指标不可比:本章量的是「路径被走成什么形状」(覆盖率、结局分布、gini、长度),动因模型量的是「玩家为什么这样选」(动机权重、人格维度、类型归属)。两者纵轴不同、对象不同,画在一张图上比高低等于拿体温计比海拔仪。
- 输入不共享:本章只读
config/story_spec.json(条件与效果),动因模型要的是真人行为数据。没有一样是共用的,所以不存在「两边各算一半再拼起来」的做法。 - 口径不可搬:本章的「同种子可复现」是采样器的性质,动因模型的「可预测」是统计推断的性质。前者的可复现不等于后者的可预测——把采样器复现出来的路径当成「玩家一定会走的路」,是把确定性错当成规律。
一句话:两者共享的是「整数化 + proxy 口径」的工程纪律,不共享对象,也不共享结论。 这正是本节要用一份可 lint 的表把边界固定下来、而不是靠一段散文的原因。
D 层:把分工写成一份可 lint 的表
下面这份 D 层声明 config/nm_11_3_division_table.json 把上面的边界落成结构化数据:本章对象、对照对象、四条边界条目(每条附「否:……」的理由)、四条禁声称、两条允许用法。
{
"title": "路径选择策略 vs 玩家动因模型:分工表",
"this_side": {
"name": "本章:模拟玩家与路径策略",
"object": "路径选择策略",
"strategies": ["random", "greedy", "cover"],
"purpose": "覆盖采样",
"models_motivation": false,
"does_player_typing": false,
"inputs": ["config/story_spec.json"],
"outputs": [
"out/nm_11_1_sim_player_strategy.csv",
"out/nm_11_2_strategy_distribution.json",
"out/nm_11_2_mix_sweep.json"
]
},
"other_side": {
"name": "《游戏设计》卷二《策略涌现与体验度量》",
"path": "/posts/books/策略涌现与体验度量/",
"object": "玩家动因与类型建模",
"measures": ["动机", "人格", "玩家分型"],
"purpose": "解释与预测玩家行为"
},
"boundary": [
{"claim": "模拟玩家就是玩家模型", "verdict": "否:它只是一台采样器,每一步只按一条固定规则挑一个可用选项"},
{"claim": "策略产出的分布就是真人行为分布", "verdict": "否:换一种策略,分布就换一副样子(11.2 实测三种策略分布不同)"},
{"claim": "策略能反推动机", "verdict": "否:策略对动因一无所知,它只读规格里的条件与效果"},
{"claim": "策略能用于玩家分型", "verdict": "否:本章不做分型,只做路径覆盖采样"}
],
"forbidden_claims": [
"玩家会这样走",
"这是玩家的真实偏好",
"按动因给玩家分型",
"预测真人行为"
],
"allowed_use": [
"同一规格内不同策略的相对对照",
"覆盖采样时选择走法"
]
}
这张表里最容易被忽略的是第三条边界「策略能反推动机」——答案同样是「否」。11.1 的贪心策略只认「好感度增量」这一个可算的量,它读不出玩家是出于好奇、成就还是社交而选那条路;把「贪心总是挑增量最大的」读成「玩家都想最大化好感度」,是本章最典型的越界。
S 层:lint 这张表,并和真跑产物交叉核对
光有声明还不够,得有人检查它。S 层脚本 sim/nm_11_3_motivation_division.py 做两件事:一是 lint 这张表(六个必需字段齐不齐,models_motivation / does_player_typing 必须为 false,策略集合必须恰为三种),二是交叉核对——声明里写的三种策略,必须与 11.1、11.2 真跑产出的策略集合一致。任一条不满足即 FAIL。
#!/usr/bin/env python3
# file: sim/nm_11_3_motivation_division.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_11_3_motivation_division.py
"""卷三 11.3 与玩家动因模型的分工(S 层)。
本章通篇叫「模拟玩家」,但必须把这句话的边界钉死:**它们是采样器,不是玩家模型。**
本脚本读 config/nm_11_3_division_table.json,逐条 lint 这张分工表,并把它与真实的
采样产物(out/nm_11_1_sim_player_strategy.csv、out/nm_11_2_strategy_distribution.json)
**交叉核对**——声明里写的三种策略,必须与脚本真跑出来的策略集合一致。
lint 是硬断言:`models_motivation` / `does_player_typing` 必须为 false,策略集合必须
恰为 {random, greedy, cover},禁声称清单与边界清单不能为空。任一条不满足即 FAIL。
这样「本章不建模动因、不做玩家分型」就不是一句口号,而是一份可被机器检查的产物。
产出 out/nm_11_3_division.json。
"""
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")
DECL = os.path.join(ROOT, "config/nm_11_3_division_table.json")
STRATEGIES = {"random", "greedy", "cover"}
REQUIRED = ["title", "this_side", "other_side", "boundary", "forbidden_claims", "allowed_use"]
def main() -> int:
with open(DECL, encoding="utf-8") as fh:
decl = json.load(fh)
missing = [k for k in REQUIRED if k not in decl]
side = decl["this_side"]
# 交叉核对:声明里的策略集合,必须与 11.1 真跑产出的策略集合一致
csv_path = os.path.join(OUT, "nm_11_1_sim_player_strategy.csv")
observed = set()
if os.path.exists(csv_path):
with open(csv_path, encoding="utf-8", newline="") as fh:
for row in csv.DictReader(fh):
observed.add(row["strategy"])
observed &= STRATEGIES # 去掉 deterministic 下界,只留三种模拟玩家策略
# 交叉核对:11.2 分布产物必须对同样的三种策略给出结果
dist_path = os.path.join(OUT, "nm_11_2_strategy_distribution.json")
dist_strategies = set()
if os.path.exists(dist_path):
with open(dist_path, encoding="utf-8") as fh:
dist_strategies = {r["strategy"] for r in json.load(fh)["rows"]}
print(f"分工表 config/{os.path.basename(DECL)}")
print(f"本章对象 {side['object']}(策略 {', '.join(side['strategies'])})")
print(f"本章用途 {side['purpose']}")
print(f"建模动因 {side['models_motivation']} (必须为 false)")
print(f"玩家分型 {side['does_player_typing']} (必须为 false)")
print(f"对照对象 {decl['other_side']['name']}:{decl['other_side']['object']}")
print(f"边界条目 {len(decl['boundary'])} 条;禁声称 {len(decl['forbidden_claims'])} 条")
print(f"交叉核对 11.1 脚本实跑策略集合 {sorted(observed)}")
print(f"交叉核对 11.2 分布产物策略集合 {sorted(dist_strategies)}")
print(f"lint 字段 {len(REQUIRED) - len(missing)} / {len(REQUIRED)} 齐" +
(f",缺 {missing}" if missing else ""))
assert not missing, f"分工表缺字段 {missing}"
assert side["models_motivation"] is False, "本章不得建模动因"
assert side["does_player_typing"] is False, "本章不得做玩家分型"
assert set(side["strategies"]) == STRATEGIES, "策略集合必须是三种路径选择策略"
assert decl["forbidden_claims"], "禁声称清单不能为空"
assert decl["boundary"], "边界清单不能为空"
assert observed == STRATEGIES, f"11.1 实跑策略 {observed} 与声明不一致"
assert dist_strategies == STRATEGIES, f"11.2 实跑策略 {dist_strategies} 与声明不一致"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_11_3_division.json"), "w", encoding="utf-8") as fh:
json.dump({"declaration": decl, "observed_strategies": sorted(observed),
"dist_strategies": sorted(dist_strategies), "lint_ok": not missing,
"fields": len(REQUIRED)}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_11_3_division.json")
print("OK nm_11_3_motivation_division:分工表字段齐全,策略仅做路径选择、不建模动因")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_11_3_motivation_division.py:
分工表 config/nm_11_3_division_table.json
本章对象 路径选择策略(策略 random, greedy, cover)
本章用途 覆盖采样
建模动因 False (必须为 false)
玩家分型 False (必须为 false)
对照对象 《游戏设计》卷二《策略涌现与体验度量》:玩家动因与类型建模
边界条目 4 条;禁声称 4 条
交叉核对 11.1 脚本实跑策略集合 ['cover', 'greedy', 'random']
交叉核对 11.2 分布产物策略集合 ['cover', 'greedy', 'random']
lint 字段 6 / 6 齐
wrote out/nm_11_3_division.json
OK nm_11_3_motivation_division:分工表字段齐全,策略仅做路径选择、不建模动因
判读:lint 过的边界,才是可信的边界
第一,交叉核对是本节的关键。 只 lint 声明本身,只能证明「表没写错」;只有把声明与 11.1 的 CSV、11.2 的 JSON 对齐,才能证明「表说的和脚本做的是同一件事」。脚本实跑策略集合与 11.2 分布产物策略集合都是 ['cover', 'greedy', 'random'],与声明完全一致——这条断言一旦不成立,整章的自洽性就塌了。
第二,两个布尔字段被钉死为 false。 models_motivation 与 does_player_typing 是本章最容易被越界的地方,脚本对它们做硬断言。11.2 已经用实测证明「换一种策略,分布就换一副样子」,这正是否定「策略分布 = 真人分布」最直接的证据。
第三,分工表不是本章的发明,是本章的护栏。 它把「模拟玩家」这个词的含义限制在采样器上,让读者不会顺着「模拟」二字滑向「模型」。第 12 章会把「假设」写成可证伪的命题,同样靠这条护栏——采样能证伪的是「某条路径存在 / 某类路径可达」,而不是「玩家会怎么选」。
第四,边界表本身就是「不能声称什么」的清单。 forbidden_claims 四条(玩家会这样走 / 这是玩家的真实偏好 / 按动因给玩家分型 / 预测真人行为)把本章的输出能说什么、不能说什么写死了。它和 6.3「proxy 指标声明」是同一套纪律:先声明指标不能声称什么,再谈它量了什么。
第五,这条边界是可回归的。 lint 不是一次性的人工检查——脚本把「策略集合必须等于三种」「两个布尔必须为 false」写成断言,规格或代码一改就重跑。如果哪天有人往采样器里加了「按动机加权选边」的逻辑,策略集合或布尔字段会立刻变化,断言失败,提醒「这一侧开始越界了」。边界不是写一次就完,是被断言守住的。
本节所有字段与断言都是结构性声明,不涉及任何真人数据。分工表里的「否:……」是边界声明,不是对《游戏设计》卷二工作的评价——那一侧做动因建模,正是本章刻意不做的部分。所有采样指标仍是 proxy,只做相对比较。
自检表
本节自检表只有四行,但每一行都是可执行的——全部由 sim/nm_11_3_motivation_division.py 的 assert 落实,报告里那行「lint 字段 6 / 6 齐」就是它的实测值。
| 检查项 | 合格标准 |
|---|---|
| lint 六字段 | 六个必需字段齐全,缺一即 FAIL |
| 布尔钉死 | models_motivation / does_player_typing 必须为 false |
| 策略集合一致 | 声明 = 11.1 CSV = 11.2 JSON = {random, greedy, cover} |
| 边界非空 | boundary 与 forbidden_claims 清单都不能为空 |
分工写清楚了:本章只做路径选择策略,不做动因、不做分型。三节合起来,本章只做了一件事——把路径铺开,供度量去量:11.1 的三种策略是三种采样规则,11.2 的分布差异是规则差异,11.3 的分工表则说明这些差异与玩家无关。下一章换一个角度——把「假设」写成可证伪的命题,看采样与度量究竟能验证什么、又不能验证什么。
阅读导航:上一节:11.2 策略对路径分布的影响 · 下一节:12.1 目标与假设 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。