11.1 模拟玩家的策略

给采样装上「策略」这个旋钮:随机(可播种 PRNG 选可用选项)、贪心(总是最大化好感度增量)、覆盖导向(优先走尚未覆盖的边)三种模拟玩家策略,在同一份规格下各走 10 与 200 条路径,对照覆盖率、结局数与路径长度区间。全部指标都是 proxy。

《游戏设计》卷二《策略涌现与体验度量》 做的是玩家动因与类型建模——动机、人格、玩家分型,用来解释与预测真人为什么那样玩;本章的「模拟玩家」只做路径选择策略(随机 / 贪心 / 覆盖导向),用途是采样,不建模动因、不做玩家分型。这一节先把三种策略写成可跑的函数,§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 策略对路径分布的影响 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长