3.1 结局分布的熵

结局熵有四个数,混在一起就错。本节把上界 2321 毫比特、单路径 0 毫比特、路径采样 1714 毫比特、状态枚举 1888 毫比特分开报,并讲清「上界不是实测值」——一条路径只到一个结局,它的熵恒为 0。全部整数、毫比特取整,seed=20261016 可复现。

3.1 结局分布的熵

《游戏设计》卷二《策略涌现与体验度量》 量的是机制层的分布与平衡——一局战斗的动力学、资源守恒、动作密度;本章量的是叙事层的结局分布与分支平衡——玩家路径的形状。两者共享「整数化 + 确定性 + proxy 口径」的纪律,但不共享任何字段:那一套的真源是机制数值,这一套的真源是叙事结构与条件。结局分布的第一个量,是熵。

一个数会骗人:熵有四个

「结局分布均不均匀」听起来是一个数,其实是四个,混在一起就错:

  • 熵上界 upper:5 个结局均匀分布时的最大熵,floor(log2(5)*1000) = 2321 毫比特。这是理论上界,不是任何一次实跑的观测值。
  • 单路径熵 single:一条路径只落到 1 个结局,分布退化成单点,熵 = 0 毫比特。这不是坏消息——单条路径本来就只经历一个结局,熵为 0 是定义使然。
  • 路径采样熵 sampled:20000 条均匀随机路径的结局分布算出的熵(路径均匀口径)。
  • 状态枚举熵 bfs:BFS 全量枚举可达状态,按落在每个结局节点的状态数算(状态均匀口径)。

后两个都是「实测值」,却给出不同的数——因为口径不同:一个是「随机走一条路,落到各结局的频率」,另一个是「状态空间里落在各终局节点的状态数占比」。口径必须先声明,否则「熵是多少」这个问题没有唯一答案。

S 层:把四个熵算成毫比特整数

下面这段 S 层脚本 sim/nm_03_1_ending_entropy.py 一次算出四个熵,并顺手用纯 xml.etree 把采样分布画成一张 SVG。它复用公共内核 sim/nm_03_common.py(Lehmer MINSTD 同算法同种子、打包整数 BFS),只读 config/story_spec.json。

#!/usr/bin/env python3
# file: sim/nm_03_1_ending_entropy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_03_1_ending_entropy.py
"""卷三 3.1 结局分布的熵(S 层):四个量分开报,混在一起就错。

  1) 熵上界 upper —— 5 个结局均匀分布 = floor(log2(5)*1000) = 2321 毫比特;
  2) 单路径熵 single —— 一条路径只到 1 个结局,分布退化为单点,熵 = 0 毫比特;
  3) 路径采样熵 sampled —— 20000 条均匀随机路径的结局分布(**路径均匀**口径);
  4) 状态枚举熵 bfs —— BFS 全量枚举可达状态,按落在每个结局节点的**状态数**算
     (**状态均匀**口径,与采样口径不同)。
「上界 ≠ 实测值」,且两个实测口径给出不同的数——这正是口径必须声明的理由。
公共内核见 sim/nm_03_common.py。口径(proxy):只做相对比较,不声称叙事更好。
产出 out/nm_03_1_ending_entropy.json + .csv + .svg。
"""
from __future__ import annotations

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

from nm_03_common import (OUT, Rng, SEED, bfs_packed, entropy_milli_bits,
                          load_spec, walk)

SAMPLE_N = 20000
SVG_NS = "http://www.w3.org/2000/svg"


def write_svg(names, shares, path):
    w, h, pad = 520, 240, 40
    root = ET.Element("svg", {"xmlns": SVG_NS, "width": str(w), "height": str(h),
                              "viewBox": f"0 0 {w} {h}"})
    ET.SubElement(root, "rect", {"x": "0", "y": "0", "width": str(w), "height": str(h),
                                 "fill": "#0f172a"})
    ET.SubElement(root, "text", {"x": str(pad), "y": "26", "fill": "#e2e8f0",
                                 "font-size": "14"}).text = "结局分布(千分比,20000 条采样)"
    bw = (w - 2 * pad) // len(names)
    for i, (nm, sh) in enumerate(zip(names, shares)):
        bh = (h - pad - 40) * sh // 1000
        x = pad + i * bw + 8
        ET.SubElement(root, "rect", {"x": str(x), "y": str(h - pad - bh), "width": str(bw - 16),
                                     "height": str(bh), "fill": "#38bdf8"})
        ET.SubElement(root, "text", {"x": str(x), "y": str(h - pad + 16), "fill": "#cbd5e1",
                                     "font-size": "12"}).text = nm
        ET.SubElement(root, "text", {"x": str(x), "y": str(h - pad - bh - 6), "fill": "#e2e8f0",
                                     "font-size": "12"}).text = str(sh)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    ET.fromstring(open(path, encoding="utf-8").read())  # 回读校验合法性


def main() -> int:
    spec = load_spec()
    order = [e["name"] for e in spec["endings"]]
    n_ends = len(order)
    thr = spec["metrics"]["ending_entropy_min_milli_bits"]

    # 1) 熵上界:n 个结局均匀分布时的最大熵
    upper = int(math.log2(n_ends) * 1000)

    # 2) 单路径熵:确定性首选项策略只落到 1 个结局 → 分布退化为单点 → 熵 0
    single_end, _ = walk(spec)
    single = entropy_milli_bits([1], 1)

    # 3) 路径采样熵:20000 条均匀随机路径
    rng = Rng(SEED)
    dist = {nm: 0 for nm in order}
    for _ in range(SAMPLE_N):
        name, _ = walk(spec, rng)
        if name:
            dist[name] += 1
    counts = [dist[nm] for nm in order]
    sampled = entropy_milli_bits(counts, SAMPLE_N)
    shares = [c * 1000 // SAMPLE_N for c in counts]

    # 4) 状态枚举熵:BFS 全量枚举(状态均匀口径)
    by_node = {n["id"]: n.get("ending") for n in spec["dialogue"]}
    bfs = {nm: 0 for nm in order}

    def on_state(node, aff, fl, it):
        name = by_node.get(node)
        if name:
            bfs[name] += 1

    states, budget_hit = bfs_packed(spec, on_state)
    bfs_counts = [bfs[nm] for nm in order]
    bfs_total = sum(bfs_counts)
    bfs_ent = entropy_milli_bits(bfs_counts, bfs_total)

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_03_1_ending_entropy.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["ending", "sample_count", "sample_share_permille", "bfs_state_count"])
        for nm, c, sh, bc in zip(order, counts, shares, bfs_counts):
            w.writerow([nm, c, sh, bc])
    report = {
        "endings": n_ends,
        "entropy_upper_milli_bits": upper,
        "entropy_single_path_milli_bits": single,
        "single_path_ending": single_end,
        "sample_n": SAMPLE_N, "seed": SEED,
        "sampled_distribution": dist,
        "entropy_sampled_milli_bits": sampled,
        "entropy_normalized_permille": sampled * 1000 // upper,
        "bfs_distribution": bfs,
        "bfs_states": states, "bfs_budget_hit": budget_hit, "bfs_terminal_states": bfs_total,
        "entropy_bfs_milli_bits": bfs_ent,
        "entropy_min_milli_bits": thr,
    }
    with open(os.path.join(OUT, "nm_03_1_ending_entropy.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    write_svg(order, shares, os.path.join(OUT, "nm_03_1_ending_entropy.svg"))

    print(f"结局数        {n_ends}")
    print(f"熵上界        {upper} 毫比特({n_ends} 个结局均匀分布,log2({n_ends})*1000 取整)")
    print(f"单路径熵      {single} 毫比特(确定性首选项策略落到「{single_end}」,只到 1 个结局)")
    print(f"路径采样熵    {sampled} 毫比特({SAMPLE_N} 条均匀随机采样,阈值 {thr} 毫比特)")
    print(f"状态枚举熵    {bfs_ent} 毫比特(BFS 全量枚举 {states} 个可达状态,{bfs_total} 个终局状态)")
    print(f"归一化        {sampled * 1000 // upper} 千分(路径采样熵 / 上界)")
    print(f"采样分布      " + " · ".join(f"{nm} {c}" for nm, c in zip(order, counts)))
    print(f"枚举分布      " + " · ".join(f"{nm} {c}" for nm, c in zip(order, bfs_counts)))
    print(f"分布占比      " + " · ".join(f"{nm} {sh}‰" for nm, sh in zip(order, shares)))
    for nm, c in zip(order, counts):
        if c == 0:
            print(f"WARN 结局「{nm}」在 {SAMPLE_N} 条采样中一次都没被走到")
    if sampled < thr:
        print(f"WARN 路径采样熵 {sampled} 毫比特低于阈值 {thr} 毫比特(软告警,不硬失败)")
    if budget_hit:
        print(f"WARN BFS 状态数 {states} 超预算(软告警,不硬失败)")
    print(f"wrote out/nm_03_1_ending_entropy.json / .csv / .svg")
    print(f"OK nm_03_1_ending_entropy:上界 {upper} / 单路径 {single} / 采样 {sampled} / 枚举 {bfs_ent} 毫比特")
    return 0


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

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

结局数        5
熵上界        2321 毫比特(5 个结局均匀分布,log2(5)*1000 取整)
单路径熵      0 毫比特(确定性首选项策略落到「灯守」,只到 1 个结局)
路径采样熵    1714 毫比特(20000 条均匀随机采样,阈值 1500 毫比特)
状态枚举熵    1888 毫比特(BFS 全量枚举 9895423 个可达状态,4211116 个终局状态)
归一化        738 千分(路径采样熵 / 上界)
采样分布      灯守 1018 · 离港 10074 · 真相 178 · 缄默 3223 · 沉没 5507
枚举分布      灯守 979816 · 离港 1740474 · 真相 27672 · 缄默 401944 · 沉没 1061210
分布占比      灯守 50‰ · 离港 503‰ · 真相 8‰ · 缄默 161‰ · 沉没 275‰
wrote out/nm_03_1_ending_entropy.json / .csv / .svg
OK nm_03_1_ending_entropy:上界 2321 / 单路径 0 / 采样 1714 / 枚举 1888 毫比特

判读:上界不是实测值

第一,2321 是上界,不是「这次跑出来的熵」。 它只取决于「有 5 个结局」这个事实——把 5 个结局摆均匀,熵就顶到 log2(5) 毫比特。任何一次实跑的采样熵都 ≤ 它。把上界当成实测值,是这套度量里最常见的错。

第二,单路径熵恒为 0,而且必须为 0。 一条路径只经历一个结局(这里是「灯守」),它的结局分布是单点分布,熵的定义就是 0。如果有人报告「单路径熵 = 1714」,那是把采样分布的熵错安到了单条路径头上。单路径熵唯一的用途是作下界:任何采样的熵都 ≥ 0,而 0 只属于「只看一条路」。

第三,两个实测口径给出不同的数:采样 1714 vs 枚举 1888。 采样口径(1714)是「随机走 20000 条路,落到各结局的频率算熵」;枚举口径(1888)是「状态空间里 4211116 个终局状态按落点分类算熵」。后者更接近结构上的分布,前者更接近随机玩法下的分布。它们都合法,但不能互换——报告时必须写清用的是哪一个。

第四,归一化 738 千分把采样熵放回上界的尺度。 1714*1000//2321 = 738,意思是「随机玩法的结局分布,覆盖了均匀分布 73.8% 的信息量」。这个数比裸熵好读,但同样只是相对量——它说的是这张图的形状,不是这段故事写得好不好。

第五,8‰ 的「真相」是分布里的长尾。 20000 条采样只有 178 条走到真相(8‰),而枚举口径下真相也最少(27672 / 4211116 ≈ 6.6‰)。两个口径在「哪个结局最稀有」上一致,这比熵的具体数值更有信息量——熵会随口径变,但「真相最稀」这个结论稳。

结局熵是代理指标:它度量「随机玩法下结局分布有多均匀」,不声称熵越高故事越好,也不声称某个具体毫比特数对应某种体验。它只做相对比较——比较不同规格、不同采样规模、不同口径下的分布形状。第 15 章会专门讲「指标不能声称什么」。

上界、下界与阈值:1500 毫比特是怎么来的

上界由结局个数定死:n 个结局均匀分布时熵最大,floor(log2(5)*1000) = 2321 毫比特。下界由路径条数定死:只看一条路径时,它的结局分布退化成单点分布,熵 = 0。所以熵的取值区间是 [0, 2321],两个端点都不是「跑出来的」,而是从定义推出来的。

真源里 metrics.ending_entropy_min_milli_bits = 1500 是一个采样熵下限:它要求「随机玩法下的结局分布至少覆盖均匀分布 64.6% 的信息量」(1500 / 2321 ≈ 646‰)。低于它,说明随机玩几乎只落到少数几个结局——分布太塌。但**「熵过线」不等于「每个结局都被采到」**,下面的规模扫描会把这个区别摆出来。

# snippet: 结局熵随采样规模 N 的收敛(sim/nm_03_1_entropy_scale.py 的核心循环)
from nm_03_common import Rng, SEED, entropy_milli_bits, walk
UPPER = 2321
for n in (10, 100, 1000, 5000, 20000):
    rng = Rng(SEED)                        # 每个 N 从同一种子重开 → 嵌套采样
    dist = {nm: 0 for nm in order}
    for _ in range(n):
        name, _ = walk(spec, rng)
        if name:
            dist[name] += 1
    ent = entropy_milli_bits([dist[nm] for nm in order], n)
    print(n, ent, ent * 1000 // UPPER, sum(1 for v in dist.values() if v == 0))

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

     N      熵毫比特      归一化‰       未采样结局   分布
    10      1721       741           1   灯守1 离港4 真相0 缄默1 沉没4
   100      1567       675           1   灯守6 离港53 真相0 缄默9 沉没32
  1000      1713       738           0   灯守56 离港508 真相9 缄默149 沉没278
  5000      1730       745           0   灯守270 离港2502 真相49 缄默815 沉没1364
 20000      1714       738           0   灯守1018 离港10074 真相178 缄默3223 沉没5507
OK nm_03_1_entropy_scale:5 档采样规模已扫描(seed=20261016)

判读:小样本的熵不可信。 N=10 时熵 1721、N=100 时反而降到 1567、N=1000 又回到 1713——同一个图、同一种子,熵在 1567 到 1730 之间抖。更关键的是 N=100 那一行:真相被采样 0 次(未采样结局 1 个),可熵 1567 仍然高于阈值 1500。「熵过线」和「结局都被采到」是两回事——前者是分布形状的粗指标,后者要靠第 2.3 节的缺口名单去点。N ≥ 1000 后熵稳定在 1714 附近,这正是本节主脚本取 SAMPLE_N = 20000 的理由:规模不够时,熵这个数本身没有意义。

自检表

检查项合格标准
四个熵分开报上界 / 单路径 / 采样 / 枚举,缺一不可
上界与实测值分清2321 是上界,1714 与 1888 是实测,标注口径
单路径熵为 0一条路径只到 1 个结局,熵定义即 0
整数不变量熵 ≥ 0 毫比特、占比 ∈ [0,1000]‰、SVG 可回读

四个熵把「结局分布」的静态形状量清楚了。但分布均不均匀是一回事,每条分支被走得偏不偏是另一回事——下一节把「分支的平衡与偏斜」变成一个可比的偏斜度。

阅读导航:上一节:2.3 覆盖的缺口与告警 · 下一节:3.2 分支的平衡与偏斜 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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