1.1 一次通关的轨迹

一次通关可以被拆成三种表示:玩家按下的选择序列、引擎走出的节点序列、以及每一步之后的完整状态轨迹。本节用《雾港夜航》的首选项确定性策略走完一条 25 步路径,逐步骤打印好感度、置位 flag、持有物品与 FNV-1a 状态摘要,并给出节点数等于选择数加一的整数不变量。

站内那篇独立游戏的叙事设计与分支对话 (约 1076 行)是散文式的叙事经验:分支怎么写、好感度怎么调、选择怎么做,配的是清单与示意图,代码不可跑。本章把同一批问题变成可算的数——「一次通关」不再是读者脑补的一段经历,而是一条可以被编码、被存储、被逐位比对的路径。这是本卷的第一个基本对象,后面所有度量(覆盖率、结局分布、张力曲线)都挂在它这根轴上。

卷一把故事还原成结构,卷二把结构还原成可执行的引擎;这一卷要做的是把执行出来的体验还原成可度量的数。度量的第一步不是选指标,而是先想清楚一件事:「玩家走了一趟」这件事,到底由哪些数据构成。

一次通关由三段数据构成

「玩家通关了一次」听起来像一段经历,但在引擎里它只是三串数据。把这三串分开,是本卷全部度量能成立的前提。

表示是什么长度谁产生
选择序列 choices玩家按下的一串按钮(choice_id)25 步玩家
节点序列 nodes引擎走出来的一串位置(node_id)26 个引擎
状态轨迹 states每一步之后的完整状态快照26 个引擎

三者的关系是刚性的:节点数 = 选择数 + 1。每按一次按钮,就从前一个节点跳到后一个节点,起点那个节点没有对应的按钮,所以节点总是比选择多一个。这不是约定,是可断言的整数不变量——脚本会在末尾直接打印它是否成立。

为什么三种表示缺一不可

三种表示不是同一件事的三种写法,它们各自回答一个不同的问题,也各自喂给一组不同的指标。

  • 选择序列回答「玩家做了什么」。它是唯一的外部输入——只要给它一串 choice_id,引擎的推进就被完全决定。分支覆盖率(走过的对话边 / 全部对话边)算的就是它。
  • 节点序列回答「故事走到了哪」。它比选择序列短一截(少一个起点之外的重复),是场景覆盖、节拍覆盖、结局判定直接吃的表示。
  • 状态轨迹回答「一路上变成了什么样」。好感度曲线、flag 置位序列、物品持有变化,全部只存在于它里面——节点序列看不见这些。

换句话说,只看节点序列会丢掉状态,只看选择序列会丢掉位置。要让「一次通关」可度量,三者都得留,而且要在同一趟推进里对齐。

选择序列与节点序列

选择序列是玩家视角:c_pier_1a、c_tavern_2a…… 一串选项 id。节点序列是引擎视角:d_pier_1、d_pier_2…… 一串对话节点 id。

两者不是一一对应的。整份规格里 48 个对话节点、115 条选项,但去重后的唯一跳转只有 56 条——多条不同的选项会汇到同一个后继节点(这是卷二 4.1 实测过的 115 → 56 汇聚)。所以「按了哪些按钮」和「走过了哪些位置」是两种不同的表示:前者记录玩家做了什么,后者记录故事走到了哪。本卷两个都要,因为不同的指标吃不同的表示——覆盖率算的是边(选择),场景与节拍覆盖算的是节点。

状态轨迹:每一步之后发生了什么

状态是一个纯数据快照,只有五个字段。它不含对象引用、不含随机数生成器,因此可以被完整序列化、逐字段比对、算摘要。

字段含义初值
node当前停在哪个对话节点spec.meta.start(d_pier_1)
aff四个好感度变量(整数 0–100)各角色 affinity.init
flags18 个布尔 flag全 0
items9 件线索物品的持有量全 0
path走过的节点序列[起点]

状态轨迹就是把这条路径上每一个中间状态都留下来,而不是只留终局。它比「节点序列」更重:节点序列只记位置,状态轨迹还记着「走到这个位置时,好感度是多少、哪些 flag 亮了、手里有几件线索」。后面第 5 章的张力曲线、第 8 章的好感度曲线,吃的都是状态轨迹。

S 层:把一条路径摊开

sim/nm_01_1_trajectory.py 用「总是选第一个可用选项」的确定性策略走完一条路径,逐步骤打印状态轨迹:每步给出进入前的节点、所选选项、四个好感度、置位 flag 数、持有物品数,以及该步之后的 FNV-1a 状态摘要。推进与摘要复用卷一 sim/na_02_digest.py(只读 import,不改动它)。

#!/usr/bin/env python3
# file: sim/nm_01_1_trajectory.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_01_1_trajectory.py
"""卷三 1.1 一次通关的轨迹(S 层):把一次通关拆成三种表示。

  选择序列   choices = [choice_id, ...]   玩家按下的一串按钮
  节点序列   nodes   = [node_id, ...]      引擎走出来的一串位置(比 choices 多 1)
  状态轨迹   states  = [state, ...]        每一步之后的完整状态快照

用「总是选第一个可用选项」的确定性策略走完一条路径(与 ne_00 / nm_00 同策略),
逐步打印状态轨迹与步后摘要,落 out/nm_01_1_trajectory.json。
推进与摘要复用卷一 sim/na_02_digest.py(只读 import,不改动它)。
全部整数,不出现浮点;digest 只对状态做,不对文本做。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from na_02_digest import cond_ok, init_state, node_by_id, apply_choice, digest  # noqa: E402

ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
JSON_PATH = os.path.join(OUT, "nm_01_1_trajectory.json")
AFF_LO, AFF_HI = 0, 100
VARS = ["yan", "hui", "shen", "mu"]


def load_spec() -> dict:
    with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def walk(spec: dict) -> list[dict]:
    """确定性策略:每步选第一个条件成立的选项。返回逐步骤记录。"""
    s = init_state(spec)
    rows = [{"step": 0, "choice": "(起点)", "node": s["node"], "state": s}]
    for i in range(64):
        cur = node_by_id(spec, s["node"])
        if cur.get("ending"):
            break
        opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
        if not opts:
            break
        s = apply_choice(spec, s, opts[0]["id"])
        rows.append({"step": i + 1, "choice": opts[0]["id"], "node": s["node"], "state": s})
    return rows


def main() -> int:
    spec = load_spec()
    rows = walk(spec)
    final = rows[-1]["state"]
    choices = [r["choice"] for r in rows[1:]]
    nodes = [r["node"] for r in rows]

    scenes = {node_by_id(spec, nid)["scene"] for nid in nodes}
    beats = [b for b in spec["beats"] if b["scene"] in scenes]

    print(f"spec={spec['meta']['title']}  起点 {spec['meta']['start']}  策略=首选项")
    print(f"选择序列({len(choices)} 步):{' '.join(choices)}")
    print(f"节点序列({len(nodes)} 个):{' '.join(nodes)}")
    print("轨迹  进入节点       事件(选项)         yan hui shen mu  flag item  步后摘要")
    for r in rows:
        st = r["state"]
        aff = " ".join(f"{st['aff'][v]:>3}" for v in VARS)
        nfl = sum(1 for v in st["flags"].values() if v)
        nit = sum(1 for v in st["items"].values() if v)
        print(f"{r['step']:>4}  {r['node']:<14} {r['choice']:<18} {aff}  {nfl:>4} {nit:>4}  {digest(st):>10}")

    print(f"三条长度    选择 {len(choices)} 步 / 节点 {len(nodes)} 个 / 状态 {len(rows)} 个")
    print(f"节点 = 选择 + 1:{len(nodes) == len(choices) + 1}")
    print(f"终局        节点 {final['node']}  结局 {node_by_id(spec, final['node']).get('ending')}")
    print(f"覆盖场景    {len(scenes)} / {len(spec['scene_graph']['nodes'])}")
    print(f"覆盖节拍    {len(beats)} / {len(spec['beats'])}")

    bad = sum(1 for r in rows for v in r["state"]["aff"].values() if not (AFF_LO <= v <= AFF_HI))
    print(f"好感度越界  {bad}")
    if bad or len(nodes) != len(choices) + 1:
        print("FAIL 轨迹不变量")
        return 1

    os.makedirs(OUT, exist_ok=True)
    with open(JSON_PATH, "w", encoding="utf-8") as fh:
        json.dump({
            "start": spec["meta"]["start"], "choices": choices, "nodes": nodes,
            "steps": [{"step": r["step"], "choice": r["choice"], "node": r["node"],
                       "digest": digest(r["state"])} for r in rows],
            "final_node": final["node"],
            "final_ending": node_by_id(spec, final["node"]).get("ending"),
            "final_digest": digest(final),
            "scenes_covered": len(scenes), "scenes_total": len(spec["scene_graph"]["nodes"]),
            "beats_covered": len(beats), "beats_total": len(spec["beats"]),
        }, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print(f"wrote out/nm_01_1_trajectory.json  final_digest={digest(final)}")
    print(f"OK nm_01_1_trajectory:一次通关的三种表示已展开,终局摘要 {digest(final)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/nm_01_1_trajectory.py:

spec=雾港夜航  起点 d_pier_1  策略=首选项
选择序列(25 步):c_pier_1a c_pier_2a c_tavern_1a c_tavern_2a c_tavern_3a c_hub_a1a c_ch_1a c_ch_2a c_ch_3a c_hub_a2a c_lb_1a c_lb_2a c_lb_3a c_tc_1a c_tc_2a c_tc_3a c_ho_1a c_ho_2a c_ho_3a c_hub_b2a c_lt_1b c_lt_2a c_lt_3a c_dq_1c c_dq_2a
节点序列(26 个):d_pier_1 d_pier_2 d_tavern_1 d_tavern_2 d_tavern_3 d_hub_a1 d_ch_1 d_ch_2 d_ch_3 d_hub_a2 d_lb_1 d_lb_2 d_lb_3 d_tc_1 d_tc_2 d_tc_3 d_ho_1 d_ho_2 d_ho_3 d_hub_b2 d_lt_1 d_lt_2 d_lt_3 d_dq_1 d_dq_2 e_lit
轨迹  进入节点       事件(选项)         yan hui shen mu  flag item  步后摘要
   0  d_pier_1       (起点)                20  50  50  50     0    0  4127714464
   1  d_pier_2       c_pier_1a           20  50  50  50     2    0  3085224634
   2  d_tavern_1     c_pier_2a           20  50  50  50     3    0  3641975660
   3  d_tavern_2     c_tavern_1a         20  55  50  50     4    0  2314516345
   4  d_tavern_3     c_tavern_2a         20  60  50  50     4    0   649771119
   5  d_hub_a1       c_tavern_3a         20  60  50  50     4    1  3389763485
   6  d_ch_1         c_hub_a1a           20  60  50  50     4    1  3040035912
   7  d_ch_2         c_ch_1a             20  60  50  50     5    1  3406712909
   8  d_ch_3         c_ch_2a             20  60  50  55     5    1   760222539
   9  d_hub_a2       c_ch_3a             20  60  50  55     5    2   537685670
  10  d_lb_1         c_hub_a2a           20  60  50  55     6    2   168581929
  11  d_lb_2         c_lb_1a             20  60  50  55     6    2   253695422
  12  d_lb_3         c_lb_2a             25  60  50  55     6    2   907750077
  13  d_tc_1         c_lb_3a             25  60  50  55     6    2  3255935952
  14  d_tc_2         c_tc_1a             25  60  50  55     7    2  3751148729
  15  d_tc_3         c_tc_2a             35  60  50  55     7    2  2928125429
  16  d_ho_1         c_tc_3a             35  60  50  55     8    3  1130405027
  17  d_ho_2         c_ho_1a             35  60  50  60     8    3  4150811831
  18  d_ho_3         c_ho_2a             35  60  50  65     8    3  2318798367
  19  d_hub_b2       c_ho_3a             35  60  50  65     8    4  3800715304
  20  d_lt_1         c_hub_b2a           35  60  50  65     8    4   241919427
  21  d_lt_2         c_lt_1b             45  60  50  65     8    4  1087722381
  22  d_lt_3         c_lt_2a             55  60  50  65     8    4  2427991916
  23  d_dq_1         c_lt_3a             65  60  50  65     8    4  3535535470
  24  d_dq_2         c_dq_1c             65  60  50  65     8    4   748181400
  25  e_lit          c_dq_2a             65  60  50  65     8    4  4162796975
三条长度    选择 25 步 / 节点 26 个 / 状态 26 个
节点 = 选择 + 1:True
终局        节点 e_lit  结局 灯守
覆盖场景    9 / 14
覆盖节拍    11 / 16
好感度越界  0
wrote out/nm_01_1_trajectory.json  final_digest=4162796975
OK nm_01_1_trajectory:一次通关的三种表示已展开,终局摘要 4162796975

判读:三段数据各说明了什么

选择 25 步、节点 26 个、状态 26 个,节点数 = 选择数 + 1 成立。 这条不变量是三条表示之间唯一的硬约束:只要它不成立,就说明「路径」和「按钮」在某一步对不上了,后面任何基于它们的度量都不必看。脚本把它当成退出码的一部分。

终局摘要 4162796975 与卷二 1.1 逐位相同。 这不是巧合——同一个策略、同一份规格、同一个 FNV-1a 算法,终局状态就该是同一个 32 位整数。这条路径本身也就是目录篇里那条「覆盖 9 / 14 场景、11 / 16 节拍」的路径:它只走过了 9 个场景、11 个节拍,只到达 1 个结局。单条路径的形状,从这一节开始就是可数的。

好感度是离散跳跃,不是均匀累加。 起点 yan=20,直到第 12 步(d_lb_2)才升到 25,第 21 步跳到 45、第 23 步到 65;而 hui 在第 3、4 步就连升两次到 60,此后不再动。哪一步踩到带 add 的选项,哪一步才变——这解释了为什么第 8 章要按「曲线形状」而不是「平均值」来分类。

每一步的摘要都不同。 26 行里没有两行摘要相等,说明摘要对状态变化是敏感的。这是下一节「路径的编码」能成立的前提:如果摘要对变化不敏感,它就没法用来判两条路径是否相同。

这里的每一个数都是 proxy 口径下的结构量——覆盖率、节拍覆盖、摘要,只用来做相对比较,不声称「这条路径体验更好」。本卷通篇如此。

自检表

检查项合格标准本节实测
三种表示齐全选择序列 / 节点序列 / 状态轨迹各有定义三张表
长度不变量节点数 = 选择数 + 126 = 25 + 1
状态是纯数据五字段可序列化、可摘要json.dump 成功
摘要只覆盖状态由节点、好感度、flag、物品、路径算出不含文本
整数不变量好感度全程 ∈ [0,100]越界 0
说法诚实覆盖数只对这条路径声明,不外推9 / 14、11 / 16

三种表示立起来之后,下一个问题是:怎么把一条路径压成一个可存储、可比较的编码——规范串怎么排、能不能压成整数元组、摘要到底对什么做。下一节把这三种编码写出来,并证明它们在同一条路径上跨语言逐位一致。

阅读导航:上一节:目录 · 下一节:1.2 路径的表示与编码 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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