8.3 关系曲线的形状分类

把每条「路径 × 变量」的曲线按整数规则分类成六种形状:平台型 / 单调上升 / 单调下降 / 先升后降 / 先降后升 / 震荡型。实测 5000 条路径 × 4 变量 = 20000 条曲线里,单调上升占 359‰ 最多、震荡型 181‰ 次之、单调下降只有 74‰。shen 的单调下降为 0 条、yan 的平台型为 0 条,这两处零值各有成因。

互动叙事中的好感度设计 那篇讲的是经验:一段关系该怎么推进、什么时候该有起伏。本章往下沉一层,把「关系推进的形状」变成整数——8.1 画出曲线,8.2 数阈值触达,本节把每条曲线分类成一个形状并统计占比。经验回答「这段关系写得好不好」,本节回答「走出来的曲线能不能被归成有限几种形状、各占多少」。

8.3 关系曲线的形状分类

分类只用整数规则:把一条曲线相邻两项的差取符号,得到一个 +1 / −1 的符号序列(差为 0 的项跳过),再按符号序列的形态归类。规则先匹配先返回,不重叠。派单里列了四类,实测把方向也分开后得到六类——「单调」和「先升后降」各自成一对(升 / 降),加上平台型与震荡型。

形状整数规则直觉
平台型相邻差全为 0该变量沿这条路径从未变化
单调上升down == 0 且 up >= 1只涨不跌
单调下降up == 0 且 down >= 1只跌不涨
先升后降恰一个峰、峰后回落(单转折,+…+-…-)先热后冷
先降后升恰一个谷、谷后回升(单转折,-…-+…+)先冷后热
震荡型转折 >= 2方向反复切换

S 层:把 20000 条曲线分类

脚本采样 5000 条路径,每条路径取 4 个变量的曲线,共 20000 条曲线,逐条分类后统计各形状的计数与占比(千分位);同时给出那条确定性路径的 4 个形状作为对照。

#!/usr/bin/env python3
# file: sim/nm_08_3_shape_classify.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_08_3_shape_classify.py
"""卷三 8.3 关系曲线的形状分类(S 层)。

8.1 画出了曲线,8.2 数了门限触达。本节把每条「路径 × 变量」的曲线**分类**成一个
形状,并统计各形状占比。分类只用**整数规则**(看相邻差的符号序列),先匹配先返回:

  平台型    相邻差全为 0(该变量沿这条路径从未变化);
  单调上升  差只增不减(down == 0 且 up >= 1);
  单调下降  差只减不增(up == 0 且 down >= 1);
  先升后降  恰有一个峰、峰后回落(符号序列 +…+-…-,单转折);
  先降后升  恰有一个谷、谷后回升(符号序列 -…-+…+,单转折);
  震荡型    转折 >= 2(方向反复切换)。

采样 5000 条路径 × 4 个变量 = 20000 条曲线,给出各形状计数与占比(千分位)。形状
占比是结构量的 proxy,只做相对比较。产出 out/nm_08_3_shape_classify.json。
"""
from __future__ import annotations

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")
AFF_LO, AFF_HI = 0, 100
SAMPLE_N, SEED, STEP_CAP = 5000, 20261016, 64
SHAPES = ["平台型", "单调上升", "单调下降", "先升后降", "先降后升", "震荡型"]


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


def clamp(v: int, lo: int = AFF_LO, hi: int = AFF_HI) -> int:
    return lo if v < lo else hi if v > hi else v


def cond_ok(c, s: dict) -> bool:
    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, "<": v < n, "==": v == n, "!=": v != n}[op]
    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 init_state(spec: dict) -> dict:
    aff = {v: clamp(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v))
           for v in sorted(spec["state"]["vars"])}
    return {"node": spec["meta"]["start"], "aff": aff,
            "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
            "items": {i: 0 for i in sorted(spec["state"]["items"])}}


def step(spec: dict, s: dict, c: dict) -> dict:
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        flags[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = clamp(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 {"node": c["goto"], "aff": aff, "flags": flags, "items": items}


def classify(vals: list) -> str:
    """整数规则:只看相邻差的符号序列。"""
    signs = []
    for i in range(1, len(vals)):
        if vals[i] > vals[i - 1]:
            signs.append(1)
        elif vals[i] < vals[i - 1]:
            signs.append(-1)
    up, down = signs.count(1), signs.count(-1)
    if up == 0 and down == 0:
        return "平台型"
    if down == 0:
        return "单调上升"
    if up == 0:
        return "单调下降"
    turns = sum(1 for i in range(1, len(signs)) if signs[i] != signs[i - 1])
    if turns == 1:
        return "先升后降" if signs[0] == 1 else "先降后升"
    return "震荡型"


def sample_curves(spec: dict, n: int, seed: int) -> list:
    by = {x["id"]: x for x in spec["dialogue"]}
    VARS = sorted(spec["state"]["vars"])
    rng = seed
    out = []
    for _ in range(n):
        s = init_state(spec)
        cols = {v: [s["aff"][v]] for v in VARS}
        for _step in range(STEP_CAP):
            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)]
            s = step(spec, s, c)
            for v in VARS:
                cols[v].append(s["aff"][v])
        out.append(cols)
    return out


def main() -> int:
    spec = load_spec()
    VARS = sorted(spec["state"]["vars"])
    curves = sample_curves(spec, SAMPLE_N, SEED)
    total = SAMPLE_N * len(VARS)

    # 确定性路径的 4 条曲线形状
    by = {x["id"]: x for x in spec["dialogue"]}
    s = init_state(spec)
    det_cols = {v: [s["aff"][v]] for v in VARS}
    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
        s = step(spec, s, opts[0])
        for v in VARS:
            det_cols[v].append(s["aff"][v])
    print("确定性路径(总是选第一个可用选项)的曲线形状:")
    for v in VARS:
        print(f"  {v:<5} {classify(det_cols[v])}")

    # 全量形状统计
    overall = {sh: 0 for sh in SHAPES}
    per_var = {v: {sh: 0 for sh in SHAPES} for v in VARS}
    for cols in curves:
        for v in VARS:
            sh = classify(cols[v])
            overall[sh] += 1
            per_var[v][sh] += 1
    print(f"\n形状占比({SAMPLE_N} 条路径 × {len(VARS)} 变量 = {total} 条曲线,千分位):")
    print(f"  {'形状':<8}{'计数':>8}{'占比‰':>8}")
    for sh in SHAPES:
        print(f"  {sh:<8}{overall[sh]:>8}{overall[sh] * 1000 // total:>8}")
    print(f"  合计    {sum(overall.values()):>8}{sum(overall.values()) * 1000 // total:>8}")

    print("\n按变量看形状分布(计数):")
    print("  " + f"{'变量':<6}" + "".join(f"{sh:>10}" for sh in SHAPES))
    for v in VARS:
        print(f"  {v:<6}" + "".join(f"{per_var[v][sh]:>10}" for sh in SHAPES))

    assert sum(overall.values()) == total, "形状计数与曲线总数不符"
    assert sum(1 for sh in SHAPES if overall[sh] > 0) >= 1, "无任何形状被分类"

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_08_3_shape_classify.json"), "w", encoding="utf-8") as fh:
        json.dump({"sample_n": SAMPLE_N, "seed": SEED, "vars": VARS, "curves": total,
                   "shapes": SHAPES, "overall": overall,
                   "overall_permille": {sh: overall[sh] * 1000 // total for sh in SHAPES},
                   "per_var": per_var,
                   "deterministic": {v: classify(det_cols[v]) for v in VARS}},
                  fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("wrote out/nm_08_3_shape_classify.json")
    print("OK nm_08_3_shape_classify:曲线形状已按整数规则分类并统计占比")
    return 0


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

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

确定性路径(总是选第一个可用选项)的曲线形状:
  hui   单调上升
  mu    单调上升
  shen  平台型
  yan   单调上升

形状占比(5000 条路径 × 4 变量 = 20000 条曲线,千分位):
  形状            计数     占比‰
  平台型         2137     106
  单调上升        7197     359
  单调下降        1486      74
  先升后降        2613     130
  先降后升        2931     146
  震荡型         3636     181
  合计       20000    1000

按变量看形状分布(计数):
  变量           平台型      单调上升      单调下降      先升后降      先降后升       震荡型
  hui          315      1653       474      1213       413       932
  mu           919      1730       712       875       378       386
  shen         903      2367         0        14       930       786
  yan            0      1447       300       511      1210      1532
wrote out/nm_08_3_shape_classify.json
OK nm_08_3_shape_classify:曲线形状已按整数规则分类并统计占比

判读:六种形状与两处零值

整体分布:20000 条曲线里 单调上升占 359‰ 最多——多数「路径 × 变量」的关系是只涨不跌的,这与规格的设计一致:大多数 add 都是正增量,负增量只出现在少数几个「得罪人」的选项上。震荡型 181‰ 次之,说明有近五分之一的曲线方向反复切换;先降后升 146‰ 与先升后降 130‰ 各占一成多;平台型 106‰(该变量在这条路上从头到尾没动);单调下降只有 74‰ 最少。四类方向性形状(升 / 降 / 先升后降 / 先降后升)合起来占 709‰,平台与震荡合计 287‰。

两处零值各有成因。第一,shen 的单调下降是 0 条:shen 的 add 里唯一的负增量是 -5(d_db_1/c_db_1b),而引用 shen 的其它节点几乎全是正增量(+5 到 +20),所以「只跌不涨」的组合在采样里一条都没出现。第二,yan 的平台型是 0 条:yan 起点 20,几乎每条路径都会在很早的节点(d_wp_1)上给它一个 +5,于是 yan 在每条路上都至少动一次,从没有「全程不动」的曲线。零值不是缺陷,是规格结构的直接读数——它反过来证明了分类规则能区分出变量的引用模式。

按变量看,mu 的平台型最多(919 条):mu 只在商会 / 港口那几条支线上被引用,绕开它们的路径上 mu 一动不动。yan 的震荡型最多(1532 条):主角的好感度在最多节点上被引用,既有正也有负,方向最容易反复。这两条观察合起来说明一件事:一个变量被引用的节点越多、正负增量越混杂,它的曲线形状就越分散——形状分布本身成了「这个变量在规格里被用得多不多、用得多干净」的 proxy。

形状计数占比‰读法
单调上升7197359多数 add 为正,关系整体上行
震荡型3636181方向反复切换
先降后升2931146先冷后热
先升后降2613130先热后冷
平台型2137106该变量在整条路上未动
单调下降148674只跌不涨,最少见

自检表

检查项合格标准
规则整数化只看相邻差的符号序列,不出现浮点 / 阈值
互斥完备六类先匹配先返回,计数合计 = 曲线总数 20000
零值有解释shen 单调下降 0、yan 平台型 0,各给出成因
双向对照既给整体占比,也给按变量的分布
口径形状占比是 proxy,只做相对比较

曲线画了(8.1)、门触达数了(8.2)、形状也分类了(8.3)——第 8 章把「关系推进的形状」变成了一组可算的整数。下一章换一个维度:把叙事一致性做成一个可回归的残差,看设定与行为之间有没有对不上的地方。

阅读导航:上一节:8.2 阈值触达的分布 · 下一节:9.1 一致性残差的度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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