7.3 节奏冲突的检测

节奏冲突是密度与张力的错配:张力峰值处信息却极稀。本节用两条判据在 500 条路径上检出 4310 次节拍命中——峰值节拍处平均密度 373 千分、谷值处 502 千分、基线 406 千分;绝对错配仅 3 次,方向错配 2810 次(651 千分),全程整数千分并显式声明为 proxy。

独立游戏的叙事设计与分支对话 那篇用散文谈「高潮处要给够信息、铺垫处要留白」这类经验,靠的是修改稿子的手感,代码不可跑;本节把「信息给没给对位置」变成一条可检出的判据:把密度序列与张力序列并排,找出两者不同向的位置并计数。7.1 量了节奏,7.2 控住了密度,本节查的是密度与张力的错配。

7.3 节奏冲突的检测

密度和张力是两条独立的曲线:张力来自作者标注的 beats[].tension,密度来自路径上的信息事件。它们本可以各自都「正常」,却互相错位——最典型的是张力冲到峰值的那一步,信息反而极稀,玩家正被吊起情绪却什么都没拿到;反过来,张力还在谷底,信息却密集地塞过来,玩家还没进入状态就被灌了一堆设定。这种错位就叫节奏冲突。本节给它两条可判定的判据,并在采样路径上检出、计数。

两条判据:绝对错配与方向错配

冲突必须用写死的规则判定,不能凭感觉。本节定义两条:

规则判据说明
A 绝对错配张力 ≥ 4 且密度 < 250;或张力 ≤ 2 且密度 > 750峰处极疏 / 谷处极密,撞到 7.2 的可读区间边界
B 方向错配张力 ≥ 4 且密度 < 本路均值;或张力 ≤ 2 且密度 > 本路均值峰处比本路平均还稀 / 谷处比本路平均还密

两条规则的关系是绝对 vs 相对。A 规则要求峰处的密度低到越过可读区间下界,是强判据;B 规则只要求峰处的密度低于这条路径自己的平均值,是弱判据。把两条都算出来,才能分清「结构性错位」与「只是撞了下边界」——这正是 6.3 那条口径的延续:同一个数,口径不同,读法就不同。

张力与密度的锚点

要把两条曲线并排,先得让它们对齐到同一组锚点。本节取节拍作为锚点:路径按首次进入场景的顺序命中一串节拍,每个节拍带着它的 tension,而它所在节点处的窗口密度就是这一拍的密度。于是每条路径得到一串 (张力, 密度) 对,冲突就在这些对上判。张力阈值取峰值 ≥ 4、谷值 ≤ 2,与 5.1 的节拍张力口径一致;密度可读区间沿用 7.2 的 [250, 750]。

S 层:在 500 条路径上检出并计数冲突

脚本 sim/nm_07_3_rhythm_conflict_detection.py 采样 500 条路径,对每条路径构建节拍锚点上的 (张力, 密度) 对,按两条规则计数冲突,并统计峰值 / 谷值节拍处的平均密度与全路径基线。

#!/usr/bin/env python3
# file: sim/nm_07_3_rhythm_conflict_detection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_07_3_rhythm_conflict_detection.py
"""卷三 7.3 节奏冲突的检测(S 层):密度与张力曲线的错配,检出并计数。

7.1 量了节奏的快慢,7.2 把密度维持在可读区间;这一节查**两者与张力的错配**。
可判定口径(写死在代码里):

  张力 = 命中节拍的 beats[].tension(1~5 整数);
  局部密度 = 该节拍**首次进入的场景**所在节点起、W=5 节点的滑窗密度(千分),
             事件口径同 7.2(新场景 + 新 flag + 新物品 + 有意义选择)。
  规则 A(绝对错配):张力峰值 tension>=4 而密度 < DENS_LO(峰处极疏);
                      张力谷   tension<=2 而密度 > DENS_HI(谷处极密)。
  规则 B(方向错配,相对本路径基线):峰值处密度 < 本路均值;谷处密度 > 本路均值。

用 Lehmer MINSTD(rng = rng * 48271 % 2147483647)采样 500 条路径。
冲突计数是 **proxy**:它只说明「结构上张力的峰谷与信息疏密不同向」,不评价叙事好坏,
也不预测玩家是否感到「该紧不紧」。产出 out/nm_07_3_conflict.csv + .json。
"""
from __future__ import annotations

import csv
import itertools
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
LO, HI = 0, 100
SEED = 20261016
N_PATHS = 500
W = 5
DENS_LO = 250
DENS_HI = 750
PEAK = 4         # 张力峰值阈值
TROUGH = 2       # 张力谷阈值


def cond_ok(c, s):
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    if k == "and":
        return cond_ok(c[1], s) and cond_ok(c[2], s)
    if k == "or":
        return cond_ok(c[1], s) or cond_ok(c[2], s)
    return not cond_ok(c[1], s)


def apply_eff(eff, s):
    aff, fl, it = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        fl[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = LO if aff.get(k, 0) + v < LO else HI if aff.get(k, 0) + v > HI else aff.get(k, 0) + v
    for k, v in (eff.get("grant") or {}).items():
        it[k] = v
    for k in (eff.get("revoke") or {}):
        it[k] = 0
    return aff, fl, it


def advance_key(c, s):
    a, f, i = apply_eff(c.get("effects") or {}, s)
    return (c["goto"], tuple(sorted(a.items())), tuple(sorted(f.items())), tuple(sorted(i.items())))


def main() -> int:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)
    nodes = spec["dialogue"]
    by = {n["id"]: n for n in nodes}
    start = spec["meta"]["start"]
    vars_ = sorted(spec["state"]["vars"])
    flags = sorted(spec["state"]["flags"])
    items = sorted(spec["state"]["items"])
    scene_of_node = {n["id"]: n["scene"] for n in nodes}
    beat_by_scene = {}
    for b in spec["beats"]:
        beat_by_scene.setdefault(b["scene"], []).append(b)

    def init_state():
        aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
               for v in vars_}
        return {"node": start, "aff": aff,
                "flags": {f: 0 for f in flags}, "items": {i: 0 for i in items}}

    rng = SEED
    rows = []
    peak_dens, trough_dens, base_dens = [], [], []
    for p in range(N_PATHS):
        s = init_state()
        seen_scenes = {scene_of_node[start]}
        path = [start]
        events = []
        while True:
            cur = by[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
            rng = (rng * 48271) % 2147483647
            c = opts[rng % len(opts)]
            k = advance_key(c, s)
            fake = any(o is not c and advance_key(o, s) == k for o in opts)
            aff, fl, it = apply_eff(c.get("effects") or {}, s)
            sc = scene_of_node[c["goto"]]
            ev = 0
            if sc not in seen_scenes:
                seen_scenes.add(sc)
                ev += 1
            if any(fl[f] == 1 and s["flags"].get(f, 0) == 0 for f in flags):
                ev += 1
            if any(it[i] == 1 and s["items"].get(i, 0) == 0 for i in items):
                ev += 1
            if not fake:
                ev += 1
            events.append(ev)
            s = {"node": c["goto"], "aff": aff, "flags": fl, "items": it}
            path.append(s["node"])

        # 每个节点位置上的滑窗密度(起点窗口从 0 开始)
        wins = [sum(events[i:i + W]) * 1000 // (4 * W) for i in range(max(1, len(events) - W + 1))]
        base = sum(wins) // len(wins) if wins else 0
        base_dens.append(base)

        # 节拍命中:按首次进入场景的顺序,取该节拍的张力与该处的窗口密度
        first_pos, seen = {}, set()
        for i, nid in enumerate(path):
            sc = scene_of_node[nid]
            if sc not in seen:
                seen.add(sc)
                first_pos[sc] = i
        # 第 i 个节点对应的事件下标是 i-1(起点无事件);窗口密度取该处
        conf_a, conf_b, pairs = 0, 0, []
        for sc, i in first_pos.items():
            if sc not in beat_by_scene:
                continue
            t = beat_by_scene[sc][0]["tension"]
            wi = max(0, i - 1)
            d = wins[wi] if wi < len(wins) else wins[-1]
            pairs.append((t, d))
            if t >= PEAK:
                peak_dens.append(d)
                if d < DENS_LO:
                    conf_a += 1
                if d < base:
                    conf_b += 1
            elif t <= TROUGH:
                trough_dens.append(d)
                if d > DENS_HI:
                    conf_a += 1
                if d > base:
                    conf_b += 1
        rows.append({
            "path": p, "nodes": len(path), "beats": len(pairs),
            "base_density": base, "conflict_a": conf_a, "conflict_b": conf_b,
            "peak_beats": sum(1 for t, _ in pairs if t >= PEAK),
            "trough_beats": sum(1 for t, _ in pairs if t <= TROUGH),
        })

    n = len(rows)
    tot_beats = sum(r["beats"] for r in rows)
    tot_a = sum(r["conflict_a"] for r in rows)
    tot_b = sum(r["conflict_b"] for r in rows)
    tot_peaks = sum(r["peak_beats"] for r in rows)
    tot_troughs = sum(r["trough_beats"] for r in rows)

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_07_3_conflict.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0]))
        w.writeheader()
        w.writerows(rows)

    summary = {
        "paths": n, "seed": SEED, "window": W,
        "readable_band_permille": [DENS_LO, DENS_HI],
        "peak_threshold": PEAK, "trough_threshold": TROUGH,
        "beat_hits_total": tot_beats,
        "peak_beats": tot_peaks, "trough_beats": tot_troughs,
        "conflict_a_total": tot_a,
        "conflict_b_total": tot_b,
        "conflict_a_per_mille": tot_a * 1000 // tot_beats,
        "conflict_b_per_mille": tot_b * 1000 // tot_beats,
        "paths_with_conflict_a": sum(1 for r in rows if r["conflict_a"]),
        "paths_with_conflict_b": sum(1 for r in rows if r["conflict_b"]),
        "max_conflict_a_per_path": max(r["conflict_a"] for r in rows),
        "max_conflict_b_per_path": max(r["conflict_b"] for r in rows),
        "peak_beat_share_permille": tot_peaks * 1000 // tot_beats,
        "trough_beat_share_permille": tot_troughs * 1000 // tot_beats,
        "peak_density_mean": sum(peak_dens) // len(peak_dens) if peak_dens else 0,
        "trough_density_mean": sum(trough_dens) // len(trough_dens) if trough_dens else 0,
        "base_density_mean": sum(base_dens) // len(base_dens) if base_dens else 0,
    }
    with open(os.path.join(OUT, "nm_07_3_conflict.json"), "w", encoding="utf-8") as fh:
        json.dump(summary, fh, ensure_ascii=False, indent=1)
        fh.write("\n")

    print(f"采样 {n} 条路径(seed={SEED}),节拍命中 {tot_beats} 次")
    print(f"张力阈值:峰值 >={PEAK}  谷值 <={TROUGH};密度可读区间 [{DENS_LO}, {DENS_HI}] 千分")
    print(f"峰值节拍 {tot_peaks} 次 = {summary['peak_beat_share_permille']} 千分"
          f"  谷值节拍 {tot_troughs} 次 = {summary['trough_beat_share_permille']} 千分")
    print(f"规则 A 绝对错配(峰处极疏 / 谷处极密):{tot_a} 次 = {summary['conflict_a_per_mille']} 千分")
    print(f"规则 B 方向错配(峰处低于本路均值 / 谷处高于本路均值):{tot_b} 次 = "
          f"{summary['conflict_b_per_mille']} 千分")
    print(f"含 A 冲突的路径 {summary['paths_with_conflict_a']} / {n}"
          f"  含 B 冲突的路径 {summary['paths_with_conflict_b']} / {n}")
    print(f"单条路径最多 A 冲突 {summary['max_conflict_a_per_path']}"
          f"  最多 B 冲突 {summary['max_conflict_b_per_path']}")
    print(f"平均密度:峰值节拍处 {summary['peak_density_mean']} 千分"
          f"  谷值节拍处 {summary['trough_density_mean']} 千分"
          f"  全路径基线 {summary['base_density_mean']} 千分")
    print("wrote out/nm_07_3_conflict.csv / .json")

    if tot_beats == 0:
        print("FAIL 采样未命中任何节拍")
        return 1
    print("OK nm_07_3_rhythm_conflict_detection:密度-张力错配已检出并计数(软告警)")
    return 0


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

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

采样 500 条路径(seed=20261016),节拍命中 4310 次
张力阈值:峰值 >=4  谷值 <=2;密度可读区间 [250, 750] 千分
峰值节拍 1928 次 = 447 千分  谷值节拍 1500 次 = 348 千分
规则 A 绝对错配(峰处极疏 / 谷处极密):3 次 = 0 千分
规则 B 方向错配(峰处低于本路均值 / 谷处高于本路均值):2810 次 = 651 千分
含 A 冲突的路径 3 / 500  含 B 冲突的路径 500 / 500
单条路径最多 A 冲突 1  最多 B 冲突 8
平均密度:峰值节拍处 373 千分  谷值节拍处 502 千分  全路径基线 406 千分
wrote out/nm_07_3_conflict.csv / .json
OK nm_07_3_rhythm_conflict_detection:密度-张力错配已检出并计数(软告警)

判读:绝对错配几乎没有,方向错配却是系统性的

第一,绝对错配只有 3 次(0 千分)。 4310 次节拍命中里,只有 3 次张力峰值处的密度跌破了可读区间下界 250——而且这 3 次分散在 3 条不同路径上,单条最多 1 次。这说明这张图没有「峰处极疏」的结构性缺陷:张力冲到 4 或 5 的地方,信息量仍稳在可读区间内。规则 A 的价值正在于此——它是强判据,触发了就是真问题;实测几乎不触发,就是一个阴性结论。

第二,方向错配 2810 次(651 千分),500 条路径无一幸免。 这是本节真正的发现。峰值节拍处的平均密度是 373 千分,谷值节拍处是 502 千分,而全路径基线是 406 千分——峰处比谷处还稀 129 千分。方向是系统性反向的:张力越高、信息反而越疏。原因在结构:张力峰值集中在第二、三夜(drowned_bell 的 5、breakwater 的 5、fog_gate 的 5),而那时新场景、新 flag 大多已在第一夜解锁完毕,可新增的信息本就少;谷值节拍反而落在开场(pier 的 1、tavern 的 2),那里全是「新」的东西。于是张力曲线与密度曲线天然反相。

第三,两条规则给出的是相反的量级,而这正是要点。 同一个「节奏冲突」,用绝对判据只有 3 次,用相对判据有 2810 次。差别不在现象,在口径——绝对判据问「有没有撞破可读边界」,相对判据问「峰谷有没有同向」。6.3 定的纪律在这里兑现:报一个冲突数时必须带上它是哪条口径算的,否则 3 与 2810 会让人得出完全相反的结论。

检查项实测合格标准
节拍命中4310 次与采样规模对账
峰值 / 谷值节拍1928 / 1500 次447 / 348 千分
规则 A 绝对错配3 次 = 0 千分软告警,不硬失败
规则 B 方向错配2810 次 = 651 千分同上
峰 / 谷 / 基线密度373 / 502 / 406 千分峰低于谷 = 反相
口径声明两条判据分列不混用绝对与相对

本章到此收口:7.1 把节奏量成一个整数比值,7.2 把密度序列压在可读区间,7.3 把密度与张力的错配检出并计数。三步连起来,回答的是同一个问题——这条路径推进得快不快、密不密、密与紧对不对得上。全部走整数千分位,全部显式声明是 proxy,只做相对比较,不声称任何绝对体验。下一章把镜头从「节奏」转到「关系」,看好感度如何沿路径长成一条曲线。

阅读导航:上一节:7.2 叙事密度的控制 · 下一节:8.1 好感度随路径的变化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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