3.3 设定的暴露节奏

设定不是一次倒完的:lore[].reveal_flag 把「揭示时机」写成状态,某条设定何时被玩家知道,取决于它的 flag 在哪一步被置 1。本节沿 20000 条采样路径统计 6 条 lore 的首次揭示位置分布,并用纯 xml.etree 出一张暴露节奏 SVG——网格给每条 lore 落在哪一夜,折线给平均已揭示条数。

3.3 设定的暴露节奏

游戏叙事设计概览 用散文提醒过「设定不要一次倒完」,那是经验之谈;本节把「什么时候倒」写成一个可统计的量:每条设定何时被玩家第一次知道,落在哪一夜,多少比例的路径根本不会知道。3.1 说了 lore 要靠 reveal_flag 挂到状态上,3.2 查了设定之间不打架,本节算的是同一批设定在时间轴上的分布。

揭示时机是一个状态,不是一个时间点

一个常见的写法是给设定标个「第几章揭示」。在这份规格里没有这种字段,取而代之的是 lore[].reveal_flag——每条 lore 挂一个 flag,这条设定何时被知道,等价于那个 flag 何时被置 1。这个转换带来三个好处:时机由玩家的选择决定而不是作者排期;同一份 reveal_flag 可以被多个选项设置,于是同一设定在不同路径上落在不同位置;「始终没揭示」也变成一个可数的结果(flag 没被置 1),而不是一个盲区。

沿路径统计首次揭示位置

用自实现的可播种 PRNG(Lehmer MINSTD,state = state * 48271 % 2147483647)采样 20000 条路径,每条路径从 meta.start 走到结局,记录每条 lore 首次揭示时的步号与所在幕,最后聚合成「第一夜 / 第二夜 / 第三夜 / 未揭示」四列的千分比。全程不用 random 模块,也不做浮点运算——分布是整数计数,曲线是整数平均。

#!/usr/bin/env python3
# file: sim/na_03_exposure_rhythm.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_03_exposure_rhythm.py
"""3.3 设定的暴露节奏(S 层):沿采样路径统计每条 lore 的首次揭示位置,并出一张 SVG。

设定不是一次倒完的。`lore[].reveal_flag` 把「揭示时机」写成**状态**:
某条 lore 何时被玩家知道,取决于它的 `reveal_flag` 在哪一步、哪一个场景被置 1。
本脚本用自实现的可播种 PRNG(Lehmer MINSTD)采样 20000 条路径,对每条 lore 记录
**首次揭示**的步号与幕号,聚合成一张「首次揭示位置分布」表,并用纯 `xml.etree`
画成 SVG(无 matplotlib)。

产物:
  out/na03_exposure.json  —— 每条 lore 的首次揭示分布(千分比)与平均步号
  out/exposure_rhythm.svg —— 6 行(lore)× 4 列(第一夜/第二夜/第三夜/未揭示)的
                             分布网格 + 一条「平均已揭示条数」的累积曲线

随机数一律自实现、可播种;断言只对**整数**做(计数与千分比),不对文本做。
"""
from __future__ import annotations

import json
import os
import sys
import xml.etree.ElementTree as ET

SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)
SAMPLE_N = 20000
SEED = 20261016
MAX_STEP = 24
BUCKETS = [1, 2, 3, 0]  # 幕 1/2/3,0 表示未揭示
BUCKET_LABEL = {1: "第一夜", 2: "第二夜", 3: "第三夜", 0: "未揭示"}


def load_spec() -> dict:
    here = os.path.dirname(os.path.abspath(__file__))
    with open(os.path.join(here, "../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)
    if k == "not":
        return not eval_cond(c[1], st)
    raise ValueError(f"unknown cond op: {k}")


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] = min(100, max(0, 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 sample(spec: dict) -> tuple[dict, list[int]]:
    by_id = {n["id"]: n for n in spec["dialogue"]}
    scene_act = {n["id"]: n["act"] for n in spec["scene_graph"]["nodes"]}
    node_act = {n["id"]: scene_act[n["scene"]] for n in spec["dialogue"]}
    lore = spec["world"]["lore"]
    flags0 = {f: 0 for f in sorted(spec["state"]["flags"])}
    aff0 = {c["id"]: c["affinity"]["init"] for c in spec["characters"]}
    init = {"aff": aff0, "flags": flags0, "items": {i: 0 for i in sorted(spec["state"]["items"])}}

    rng = SEED

    def rnd(mod: int) -> int:
        nonlocal rng
        rng = (rng * 48271) % 2147483647
        return rng % mod

    # first_act[lore_id][bucket] = 计数;cum[s] = 该步已揭示条数之和
    first_act = {L["id"]: {b: 0 for b in BUCKETS} for L in lore}
    cum = [0] * (MAX_STEP + 1)
    steps_seen = [0] * (MAX_STEP + 1)
    for _ in range(SAMPLE_N):
        node, st, step = spec["meta"]["start"], init, 0
        revealed: dict[str, int] = {}
        cum[0] += 0
        steps_seen[0] += 1
        for _step in range(MAX_STEP):
            cur = by_id[node]
            if cur.get("ending"):
                break
            opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), st)]
            if not opts:
                break
            c = opts[rnd(len(opts))]
            st = apply_effects(c.get("effects") or {}, st)
            node = c["goto"]
            step += 1
            for L in lore:
                if L["id"] not in revealed and st["flags"].get(L["reveal_flag"], 0) == 1:
                    revealed[L["id"]] = node_act[node]
            cum[step] += len(revealed)
            steps_seen[step] += 1
        for s in range(step + 1, MAX_STEP + 1):  # 已结束的路径保持终值
            cum[s] += len(revealed)
            steps_seen[s] += 1
        for L in lore:
            first_act[L["id"]][revealed.get(L["id"], 0)] += 1
    curve = [cum[s] // max(1, steps_seen[s]) for s in range(MAX_STEP + 1)]
    return first_act, curve


def el(tag: str, **attrs) -> ET.Element:
    return ET.Element(f"{{{SVG_NS}}}{tag}", {k: str(v) for k, v in attrs.items()})


def txt(parent, x, y, s, size=12, anchor="start", fill="#334155"):
    t = el("text", x=x, y=y, **{"font-size": size, "font-family": "ui-sans-serif, system-ui",
                                "text-anchor": anchor, "fill": fill})
    t.text = s
    parent.append(t)


def heat(p: int) -> str:
    t = min(1.0, p / 600.0)
    r, g, b = 239 - int(210 * t), 246 - int(168 * t), 255 - int(39 * t)
    return f"#{r:02x}{g:02x}{b:02x}"


def build_svg(lore: list[dict], dist: dict, curve: list[int], path: str) -> int:
    W, H = 820, 470
    L, T = 150, 46
    cw, ch = 120, 40
    svg = el("svg", width=W, height=H, viewBox=f"0 0 {W} {H}")
    svg.append(el("rect", x=0, y=0, width=W, height=H, fill="#ffffff"))
    txt(svg, 24, 24, "《雾港夜航》设定暴露节奏:每条 lore 的首次揭示位置(采样 20000 条路径)", 13, "start", "#0f172a")

    for j, b in enumerate(BUCKETS):
        txt(svg, L + j * cw + cw // 2, T - 12, BUCKET_LABEL[b], 12, "middle", "#0f172a")
    for i, Lr in enumerate(lore):
        y = T + i * ch
        txt(svg, L - 12, y + ch // 2 + 4, Lr["id"], 12, "end", "#334155")
        for j, b in enumerate(BUCKETS):
            p = dist[Lr["id"]][b]
            x = L + j * cw
            svg.append(el("rect", x=x + 2, y=y + 2, width=cw - 4, height=ch - 4, rx=4,
                          fill=heat(p), stroke="#e2e8f0", **{"stroke-width": 1}))
            txt(svg, x + cw // 2, y + ch // 2 + 4, f"{p}", 11, "middle",
                "#ffffff" if p > 330 else "#334155")

    # 累积曲线:平均已揭示条数
    base_y, curve_h, curve_w = 330, 90, 620
    txt(svg, 24, base_y - 12, "平均已揭示条数(沿步号)", 12, "start", "#0f172a")
    n = len(curve)
    pts = []
    for s, v in enumerate(curve):
        px = 150 + (curve_w * s) // max(1, n - 1)
        py = base_y + curve_h - (curve_h * v) // max(1, max(curve))
        pts.append(f"{px},{py}")
    svg.append(el("line", x1=150, y1=base_y + curve_h, x2=150 + curve_w, y2=base_y + curve_h,
                  stroke="#94a3b8", **{"stroke-width": 1.2}))
    svg.append(el("polyline", points=" ".join(pts), fill="none", stroke="#0ea5e9",
                  **{"stroke-width": 2.5, "stroke-linejoin": "round"}))
    txt(svg, 150, base_y + curve_h + 18, "步 0", 10, "middle", "#94a3b8")
    txt(svg, 150 + curve_w, base_y + curve_h + 18, f"步 {n - 1}", 10, "middle", "#94a3b8")
    txt(svg, 150 + curve_w + 8, base_y + 14, f"max {max(curve)}", 10, "start", "#0284c7")

    tree = ET.ElementTree(svg)
    ET.indent(tree, space="  ")
    tree.write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def readback(path: str) -> dict:
    """按**局部名**精确计数:带 xmlns 的 SVG 里 `iter("rect")` 一个都取不到,
    但改用 `endswith` 又会让 `polyline` 被算进 `line`——所以先剥命名空间再比对。"""
    root = ET.parse(path).getroot()
    counts: dict[str, int] = {}
    for e in root.iter():
        if not isinstance(e.tag, str):
            continue
        local = e.tag.rsplit("}", 1)[-1]
        if local in ("rect", "polyline", "text", "line"):
            counts[local] = counts.get(local, 0) + 1
    return counts


def main() -> int:
    spec = load_spec()
    lore = spec["world"]["lore"]
    first_act, curve = sample(spec)

    dist = {L["id"]: {b: (first_act[L["id"]][b] * 1000) // SAMPLE_N for b in BUCKETS} for L in lore}
    print(f"采样 {SAMPLE_N} 条路径,seed={SEED},PRNG=Lehmer MINSTD")
    print(f"{'lore':<12}{'reveal_flag':<22}{'第一夜':>8}{'第二夜':>8}{'第三夜':>8}{'未揭示':>8}")
    for L in lore:
        d = dist[L["id"]]
        print(f"{L['id']:<12}{L['reveal_flag']:<22}{d[1]:>8}{d[2]:>8}{d[3]:>8}{d[0]:>8}")

    never = sum(dist[L["id"]][0] for L in lore)
    early = sum(dist[L["id"]][1] for L in lore)
    print(f"千分比合计:第一夜 {early}  未揭示 {never}(每条 lore 一列,6 条合计上限 6000)")
    print(f"平均已揭示条数曲线(步 0..{MAX_STEP}):{curve}")
    print(f"首末:步 0 = {curve[0]} 条,步 {MAX_STEP} = {curve[-1]} 条")

    here = os.path.dirname(os.path.abspath(__file__))
    out = os.path.join(here, "../out")
    os.makedirs(out, exist_ok=True)
    p = os.path.join(out, "exposure_rhythm.svg")
    size = build_svg(lore, dist, curve, p)
    counts = readback(p)
    print(f"wrote out/exposure_rhythm.svg {size} bytes  {counts}")
    assert counts.get("rect", 0) == len(lore) * len(BUCKETS) + 1, "矩形数应为 lore×桶 + 背景"
    assert counts.get("polyline", 0) == 1, "应恰有一条累积曲线"
    assert len(curve) == MAX_STEP + 1 and curve[0] == 0, "累积曲线应自 0 起步"

    with open(os.path.join(out, "na03_exposure.json"), "w", encoding="utf-8") as fh:
        json.dump({"sample_n": SAMPLE_N, "seed": SEED, "dist_permille": dist, "curve": curve},
                  fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    for L in lore:
        assert 0 <= dist[L["id"]][1] + dist[L["id"]][2] + dist[L["id"]][3] <= 1000, "分布越界"
    print("OK na_03_exposure_rhythm:6 条 lore 的首次揭示分布已聚合,SVG 回读通过")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/na_03_exposure_rhythm.py:

采样 20000 条路径,seed=20261016,PRNG=Lehmer MINSTD
lore        reveal_flag                第一夜     第二夜     第三夜     未揭示
lore_tide   knows_tide_rule            500     135       0     363
lore_bell   knows_bell_legend          499     377       0     123
lore_yan    knows_yan_missing          501       0       0     498
lore_light  saw_lighthouse_light         0    1000       0       0
lore_ledger found_yan_journal            0     252       0     747
lore_fog    cave_explored                0     247       0     752
千分比合计:第一夜 1500  未揭示 2483(每条 lore 一列,6 条合计上限 6000)
平均已揭示条数曲线(步 0..24):[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
首末:步 0 = 0 条,步 24 = 3 条
wrote out/exposure_rhythm.svg 8154 bytes  {'rect': 25, 'text': 39, 'line': 1, 'polyline': 1}
OK na_03_exposure_rhythm:6 条 lore 的首次揭示分布已聚合,SVG 回读通过

读出来:三夜各揭一半,平均只揭三条

第三夜整列是 0。六条 lore 没有一条的首次揭示发生在第三夜——这不是「第三夜没有设定」,而是「所有设定在第二夜结束前都已经有机会被知道」。第三夜的任务是收束(点灯、鸣钟、结账),不是铺新信息。

第一夜揭三条、第二夜揭三条,分工清楚:knows_tide_rule(潮汐规律)、knows_bell_legend(沉钟传说)、knows_yan_missing(砚失踪)在抵港当晚就可能听到,属于环境类设定;saw_lighthouse_light、found_yan_journal、cave_explored 要等到第二夜,属于调查类设定——它们不是「听说」,而是「去过」。

lore_light 是 1000‰。第一夜结束时那个必经的二选一(去灯塔基座或先去鱼市街)两个分支都会设置 saw_lighthouse_light,所以这条设定对所有玩家都成立,落在第二夜是路径结构决定的,不是随机结果。这是保底设定:它可以被用作后续选项的前置,因为不存在「没拿到它」的玩家。

平均只揭三条。累积曲线从步 0 的 0 条爬到步 14 的 3 条,此后平在 3——采样路径平均只让玩家知道 6 条设定里的 3 条。lore_ledger(砚的日志)与 lore_fog(十七个名字)各有约 75% 的路径不会揭示,它们藏在 tide_cave 这条支线后面。暴露是分岔的:不同玩家看到的是不同的半张设定表,这正是「不是一次倒完」在数据上的样子。

SVG:网格 + 累积曲线

产物 out/exposure_rhythm.svg 由纯 xml.etree 生成(本机无 matplotlib)。上半部是 6 行 × 4 列的热力网格,每格填该 lore 落在该桶的千分比;下半部是累积曲线。回读时有个坑要绕两次:带 xmlns 的 SVG 里 root.iter("rect") 匹配不到任何元素,真实标签是 {http://www.w3.org/2000/svg}rect;但改用 tag.endswith("rect") 这类后缀匹配,又会把 polyline 同时算进 line。脚本的做法是先剥掉命名空间再按局部名精确比对,所以输出里 line: 1 与 polyline: 1 各归各。rect: 25 正好等于 6 × 4 + 1(背景),两条断言都通过。

自检表

检查项合格标准本案例
随机可播种自实现 MINSTD,不用 random 模块seed 20261016
整数口径分布是千分比计数,曲线是整数平均无浮点输出
覆盖三夜首次揭示按幕分桶,且单列「未揭示」第三夜 0‰ 也是一个结论
保底可辨1000‰ 的设定能被指认出来lore_light
产物可回读ET.parse 通过,元素计数用 tag 后缀rect 25 / polyline 1
不越权归因分布是采样结果,不说「玩家一定会」结论限定在采样口径内

三节下来,world 段的每一类字段都被读成了可判定的量:矩阵可判对称与失衡、解锁可查硬软、规则待读法表、lore 可算分布。下一章离开「世界」,进入「人」——角色的目标、恐惧与秘密。

阅读导航:上一节:3.2 设定的一致性:冲突检测 · 下一节:4.1 角色的字段:目标、恐惧、秘密 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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