《游戏设计》卷一《玩法机制与可执行规格》 那一套给的是玩法规格的度量口径;本章给的是叙事规格的度量口径——指标集不同(那边是一局的动力学,这边是整张故事图的路径形状),但「整数化 + 确定性 + 只做相对比较」的纪律同源。第 15 章是全卷的总表章:前十四章把指标一条条立起来,这一章把它们收进同一张表,并写清每条指标不能声称什么。
15.1 指标的定义与口径
《雾港夜航》从头到尾只被七个整数描述,可它们散在十四个章节里:2.2 立覆盖率、3.1 立熵、5.2 立张力 MAE、7.2 立密度、9.1 立残差、8.1 立好感度曲线,1.1 立整数不变量。散着放有个隐患——同一件事在不同章节容易被讲成不同口径,读的人凑不齐一张全景。本节把七条指标收进同一张表,每条给全五样东西:定义、量纲、真源字段、实测值、首见小节。
一张定义表要回答的五个问题
| 字段 | 回答的问题 |
|---|---|
definition | 这个数怎么算(分母口径、取整规则) |
unit | 量纲是什么(千分 / 毫比特 / 毫 / 计数) |
truth_source | 读的是哪段真源(规格里的哪个字段) |
observed | 当前实测是多少(脚本实跑填入) |
first_section | 第一次在哪一节立起来(可回溯) |
前四项决定「这个数能被算出来」,第五项决定「这个数能被追溯」。缺任何一项,这张表就退化成一份数字清单——数字对不上时,没人知道该回去改哪一节。
七条指标总表
| # | 指标 | 量纲 | 实测 | 阈值 | 首见 |
|---|---|---|---|---|---|
| 1 | 分支覆盖率 | 千分 | 446 | ≥ 850 | §2.2 |
| 2 | 结局分布熵 | 毫比特 | 1714 | ≥ 1500 | §3.1 |
| 3 | 张力曲线 MAE | 毫 | 0 | ≤ 1000 | §5.2 |
| 4 | 好感度曲线 MAE | 毫 | 5739 | ≤ 12000 | §15.1 |
| 5 | 一致性残差 | 计数 | 8 | = 0 | §9.1 |
| 6 | 节奏密度偏差 | 千分 | 5 | = 0 | §7.2 |
| 7 | 整数不变量残差 | 计数 | 0 | = 0 | §1.1 |
七条里只有第 7 条是恒等判据(必须为 0,无容差),其余六条都是阈值判据(越过阈值才判得动方向)。而七条无一例外都是 proxy——度量的都是规格里的结构量,没有一条碰得到玩家。
好感度曲线 MAE:本节首次给出定义
第 4 条是全卷唯一一条本节才首次给出定义的指标,原因值得说清:真源 config/story_spec.json 里有 metrics.affinity_mae_max = 12 这个阈值,却没有对应的 target_affinity_curve 字段——阈值摆在那里,却没有可比的目标曲线。这是一处规格欠声明。
本节按「路径对采样基线」把它定义死:取确定性路径(每步走第一个可用选项)的四条好感度曲线,与随机策略 2000 条采样的逐步均值曲线逐变量、逐步相减,sum(|确定性 − 均值|) × 1000 // 比较步数,单位毫(1 点 = 1000 毫,与张力 MAE 同口径)。实测 5739 毫,落在 12000 毫阈值内。这不是「关系写得好」的证明,只是「这条具体路径偏离总体基线多远」的读数。
S 层:从真源重算并对账
脚本 sim/nm_15_1_metric_catalog.py 不抄各章产物,而是从 config/story_spec.json 重算六条指标(分支覆盖率、结局分布熵、张力曲线 MAE、好感度曲线 MAE、一致性静态残差、整数不变量残差),只有「节奏密度偏差」读第 7 章产物(信息事件口径属于 7.2,本节不重复实现):
#!/usr/bin/env python3
# file: sim/nm_15_1_metric_catalog.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_15_1_metric_catalog.py
"""卷三 15.1 指标的定义与口径(S 层):全卷代理指标总表。
把散落全卷的七条 proxy 指标收成一张表,每条给出精确定义、量纲、真源字段、实测值
与首见小节。六条从 config/story_spec.json **重算**,再与各章产物逐条对账;只有
「节奏密度偏差」读第 7 章产物(信息事件口径属于 7.2)。好感度曲线 MAE 是全卷唯一
一条本节首次给出定义的指标:真源里只有阈值没有目标曲线字段。全部整数、全部 proxy。
产出 out/nm_15_1_metric_catalog.json。
"""
from __future__ import annotations
import json
import math
import os
import sys
import nm_15_common as c
N_ENTROPY, N_MEAN, N_COVER = 20000, 2000, 200
def main() -> int:
spec = c.load_json("config/story_spec.json")
by = {n["id"]: n for n in spec["dialogue"]}
vars_ = sorted(spec["state"]["vars"])
m, beats = spec["metrics"], spec["beats"]
# [1] 分支覆盖率:确定性路径 vs 采样 200 条(去重边口径,分母 56)
e_dedup = len(c.edges_of(spec))
_, det_nodes, det_trail, det_curves, _, bad_det = c.walk(spec, None)
det_edges = c.det_edges(spec)
cov_det = len(det_edges) * 1000 // e_dedup
rng, seen = c.SEED, set()
for _ in range(N_COVER):
_, nd, _, _, rng, _ = c.walk(spec, rng, 0)
seen |= {(nd[i], nd[i + 1]) for i in range(len(nd) - 1)}
cov_rand = len(seen) * 1000 // e_dedup
# [2] 结局分布熵:采样 20000 条
rng, dist = c.SEED, {}
for _ in range(N_ENTROPY):
end, _, _, _, rng, _ = c.walk(spec, rng, 0)
key = by[end].get("ending", "(未终局)")
dist[key] = dist.get(key, 0) + 1
ent_sampled = c.entropy_milli_bits(list(dist.values()), N_ENTROPY)
# [3] 张力曲线 MAE:路径 11 拍 / 全量 16 拍
visited = {by[nid]["scene"] for nid in det_nodes}
idx = [i for i, b in enumerate(beats) if b["scene"] in visited]
curve = [beats[i]["tension"] for i in idx]
mae_path = sum(abs(beats[i]["tension"] - m["target_tension_curve"][i]) for i in idx) * 1000 // len(idx)
all_t = [b["tension"] for b in beats]
mae_all = sum(abs(a - b) for a, b in zip(all_t, m["target_tension_curve"])) * 1000 // len(all_t)
# [4] 好感度曲线 MAE:确定性曲线 vs 随机策略均值曲线(本节首次定义)
rand_mean, bad_rand = c.mean_curve(spec, N_MEAN, 0, c.SEED)
num = cnt = 0
for v in vars_:
for i in range(min(len(det_curves[v]), len(rand_mean[v]))):
num += abs(det_curves[v][i] - rand_mean[v][i])
cnt += 1
aff_mae = num * 1000 // cnt
# [5] 一致性静态残差
d, k, s_, r = c.static_residual(spec)
residual = len(d) + k + s_ + r
# [6] 节奏密度偏差:读第 7 章产物(信息事件口径属于 7.2)
dens = c.load_json("out/nm_07_2_density.json")
dens_dev = dens["dry_share_permille"] + dens["burst_share_permille"]
# [7] 整数不变量残差:恒 0
inv = bad_det + bad_rand + sum(1 for t in all_t if not 1 <= t <= 5)
inv += sum(1 for n in spec["dialogue"] for ch in (n.get("choices") or [])
for v in ((ch.get("effects") or {}).get("set") or {}).values() if v not in (0, 1))
inv += 0 if len(det_nodes) == len(det_trail) + 1 else 1
rows = [
{"id": 1, "metric": "branch_coverage", "name": "分支覆盖率", "unit": "permille", "unit_cn": "千分",
"observed": cov_det, "threshold": m["coverage_target_permille"], "first_section": "2.2"},
{"id": 2, "metric": "ending_entropy", "name": "结局分布熵", "unit": "milli_bits", "unit_cn": "毫比特",
"observed": ent_sampled, "threshold": m["ending_entropy_min_milli_bits"], "first_section": "3.1"},
{"id": 3, "metric": "tension_curve_mae", "name": "张力曲线 MAE", "unit": "milli", "unit_cn": "毫",
"observed": mae_path, "threshold": m["tension_mae_max"] * 1000, "first_section": "5.2"},
{"id": 4, "metric": "affinity_curve_mae", "name": "好感度曲线 MAE", "unit": "milli", "unit_cn": "毫",
"observed": aff_mae, "threshold": m["affinity_mae_max"] * 1000, "first_section": "15.1"},
{"id": 5, "metric": "consistency_residual", "name": "一致性残差", "unit": "count", "unit_cn": "计数",
"observed": residual, "threshold": 0, "first_section": "9.1"},
{"id": 6, "metric": "density_deviation", "name": "节奏密度偏差", "unit": "permille", "unit_cn": "千分",
"observed": dens_dev, "threshold": 0, "first_section": "7.2"},
{"id": 7, "metric": "invariant_residual", "name": "整数不变量残差", "unit": "count", "unit_cn": "计数",
"observed": inv, "threshold": 0, "first_section": "1.1"},
]
print(f"spec={spec['meta']['title']} 节拍 {len(beats)}(tension 和 {sum(all_t)})"
f" 对话节点 {len(spec['dialogue'])} 去重边 {e_dedup} 结局 {len(spec['endings'])}")
print("全卷代理指标总表(seed=20261016,本机实跑,全部整数,全部 proxy)")
for r_ in rows:
print(f" {r_['id']} {r_['name']} = {r_['observed']} {r_['unit_cn']}"
f"(阈值 {r_['threshold']},首见 §{r_['first_section']})")
print(f" 分支覆盖率对照:确定性路径 {len(det_edges)}/{e_dedup} = {cov_det}‰;"
f"采样 {N_COVER} 条 {len(seen)}/{e_dedup} = {cov_rand}‰")
print(f" 结局分布熵:采样 {N_ENTROPY} 条 {dist}")
print(f" 好感度曲线 MAE:确定性曲线 vs 随机 {N_MEAN} 条均值曲线,{cnt} 个逐步比较,差和 {num} 点")
print(f" 一致性残差:D {len(d)} + K {k} + S {s_} + R {r} = {residual}")
# 对账:本节重算值 vs 各章产物
checks = [
("覆盖率 2.2", cov_det, c.load_json("out/nm_02_2_coverage.json")["sources"][0]["dedup_permille"]),
("熵 3.1", ent_sampled, c.load_json("out/nm_03_1_ending_entropy.json")["entropy_sampled_milli_bits"]),
("张力 MAE 6.2 路径", mae_path, c.load_json("out/nm_06_2_mae.json")["mae_path_milli"]),
("张力 MAE 6.2 全量", mae_all, c.load_json("out/nm_06_2_mae.json")["mae_all_milli"]),
("一致性残差 9.1", residual, c.load_json("out/nm_09_1_residual.json")["static_total"]),
("密度偏差 7.2", dens_dev, dens["dry_share_permille"] + dens["burst_share_permille"]),
]
print("对账(本节重算值 = 各章产物):")
for label, mine, theirs in checks:
print(f" {label} 本节 {mine} 产物 {theirs} {'OK' if mine == theirs else 'MISMATCH'}")
assert mine == theirs, f"{label} 对账不一致:{mine} != {theirs}"
prod81 = c.load_json("out/nm_08_1_affinity_curve.json")
det_ok = all(prod81["deterministic"]["curves"][v]["curve"] == det_curves[v] for v in vars_)
print(f" 好感度曲线 8.1 确定性 4 条曲线逐项一致 {det_ok}")
assert det_ok, "好感度曲线与 8.1 产物不一致"
# 定义表 lint(D 层)
entries = c.load_json("config/nm_15_1_metric_definition_table.json")["metrics"]
need = ["id", "metric", "name", "kind", "unit", "definition", "truth_source", "measures",
"does_not_measure", "allowed_use", "first_section", "threshold_field"]
miss = [(e.get("metric"), f) for e in entries for f in need if f not in e]
bad_kind = [e["metric"] for e in entries if e.get("kind") != "proxy"]
empty = [e["metric"] for e in entries if not e.get("does_not_measure") or not e.get("allowed_use")]
by_id = {e["id"]: e for e in entries}
drift = [r_["name"] for r_ in rows if by_id[r_["id"]]["unit"] != r_["unit"]
or by_id[r_["id"]]["name"] != r_["name"]
or by_id[r_["id"]]["first_section"] != r_["first_section"]]
print(f"定义表 lint 条目 {len(entries)} / 缺字段 {len(miss)} / 非 proxy {len(bad_kind)}"
f" / 空清单 {len(empty)} / 口径漂移 {len(drift)}")
assert len(entries) == len(rows) and not (miss or bad_kind or empty or drift), "定义表 lint 失败"
os.makedirs(c.OUT, exist_ok=True)
with open(os.path.join(c.OUT, "nm_15_1_metric_catalog.json"), "w", encoding="utf-8") as fh:
json.dump({"seed": c.SEED, "rows": rows,
"details": {"coverage_det_edges": len(det_edges), "coverage_edges_total": e_dedup,
"coverage_rand_permille": cov_rand, "entropy_distribution": dist,
"entropy_uniform_upper_milli_bits": int(math.log2(len(spec["endings"])) * 1000),
"tension_mae_full_milli": mae_all, "tension_curve": curve,
"affinity_mae_pairs": cnt, "affinity_mae_abs_sum": num,
"consistency_dksr": {"D": len(d), "K": k, "S": s_, "R": r},
"density_dry_permille": dens["dry_share_permille"],
"density_burst_permille": dens["burst_share_permille"]},
"reconciled": [{"label": a, "value": b} for a, b, _ in checks],
"proxy": True}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_15_1_metric_catalog.json")
print("OK nm_15_1_metric_catalog:7 条 proxy 指标已实测并与各章产物逐条对账")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_15_1_metric_catalog.py:
spec=雾港夜航 节拍 16(tension 和 52) 对话节点 48 去重边 56 结局 5
全卷代理指标总表(seed=20261016,本机实跑,全部整数,全部 proxy)
1 分支覆盖率 = 446 千分(阈值 850,首见 §2.2)
2 结局分布熵 = 1714 毫比特(阈值 1500,首见 §3.1)
3 张力曲线 MAE = 0 毫(阈值 1000,首见 §5.2)
4 好感度曲线 MAE = 5739 毫(阈值 12000,首见 §15.1)
5 一致性残差 = 8 计数(阈值 0,首见 §9.1)
6 节奏密度偏差 = 5 千分(阈值 0,首见 §7.2)
7 整数不变量残差 = 0 计数(阈值 0,首见 §1.1)
分支覆盖率对照:确定性路径 25/56 = 446‰;采样 200 条 56/56 = 1000‰
结局分布熵:采样 20000 条 {'离港': 10074, '沉没': 5507, '缄默': 3223, '灯守': 1018, '真相': 178}
好感度曲线 MAE:确定性曲线 vs 随机 2000 条均值曲线,92 个逐步比较,差和 528 点
一致性残差:D 2 + K 3 + S 1 + R 2 = 8
对账(本节重算值 = 各章产物):
覆盖率 2.2 本节 446 产物 446 OK
熵 3.1 本节 1714 产物 1714 OK
张力 MAE 6.2 路径 本节 0 产物 0 OK
张力 MAE 6.2 全量 本节 0 产物 0 OK
一致性残差 9.1 本节 8 产物 8 OK
密度偏差 7.2 本节 5 产物 5 OK
好感度曲线 8.1 确定性 4 条曲线逐项一致 True
定义表 lint 条目 7 / 缺字段 0 / 非 proxy 0 / 空清单 0 / 口径漂移 0
wrote out/nm_15_1_metric_catalog.json
OK nm_15_1_metric_catalog:7 条 proxy 指标已实测并与各章产物逐条对账
对账表里每一行都是 assert:覆盖率 2.2 本节 446 产物 446 OK、熵 3.1 1714=1714、张力 MAE 6.2 路径 0=0……任何一条对不上,脚本直接 FAIL。这不是「我复述了一遍各章的数字」,是「我独立算了一遍,和它们撞上了」。
D 层:可 lint 的定义表
定义本身是一份 JSON,落 config/nm_15_1_metric_definition_table.json,每条 12 个字段:
{
"section": "15.1",
"tier": "D",
"spec": "config/story_spec.json",
"seed": 20261016,
"note": "全卷 proxy 指标定义总表。每条 kind 恒为 proxy:只做相对比较,不做绝对判断。observed 由 sim/nm_15_1_metric_catalog.py 实跑填入,本表只写定义与口径。",
"metrics": [
{"id": 1, "metric": "branch_coverage", "name": "分支覆盖率", "kind": "proxy", "unit": "permille",
"definition": "走过的去重对话边数 × 1000 // 全部去重边数。去重口径把指向同一目标的并行选项并成一条图边,分母 56;对照的选项口径分母 115。",
"truth_source": "dialogue[].choices[].goto(去重成 (节点, 目标) 对)", "measures": "一条策略走过的图边占全图的千分比",
"does_not_measure": ["玩家实际探索到的丰富度", "文本量或演出量", "体验多样性"],
"allowed_use": ["同一规格内不同策略的相对比较", "采样规模对覆盖的影响", "规格演化前后的回归对照"],
"first_section": "2.2", "threshold_field": "metrics.coverage_target_permille", "threshold_value": 850},
{"id": 2, "metric": "ending_entropy", "name": "结局分布熵", "kind": "proxy", "unit": "milli_bits",
"definition": "采样分布上 5 个结局的 Shannon 熵 × 1000 取整(毫比特):-sum(p log2 p) × 1000。均匀上界 log2(5) = 2321 毫比特。",
"truth_source": "endings[] 与 dialogue 里的结局节点", "measures": "采样分布离均匀分布有多远",
"does_not_measure": ["玩家实际体验到的多样性", "单个结局的叙事质量", "稀有结局是否值得存在"],
"allowed_use": ["同一规格内不同策略 / 采样规模的相对比较", "演化前后分布偏斜的回归对照"],
"first_section": "3.1", "threshold_field": "metrics.ending_entropy_min_milli_bits", "threshold_value": 1500},
{"id": 3, "metric": "tension_curve_mae", "name": "张力曲线 MAE", "kind": "proxy", "unit": "milli",
"definition": "沿路径取经过场景的节拍张力序列,与 metrics.target_tension_curve 同下标逐项相减,sum(|实际 − 目标|) × 1000 // n(毫)。1 点 = 1000 毫。",
"truth_source": "beats[].tension 与 metrics.target_tension_curve", "measures": "标注张力序列与设计目标曲线的平均绝对偏离",
"does_not_measure": ["玩家实际感受到的情绪强度", "台词的感染力", "机制层的张力动力学"],
"allowed_use": ["同一规格内两条曲线的相对偏离", "规格演化前后的回归对照"],
"first_section": "5.2", "threshold_field": "metrics.tension_mae_max", "threshold_value": 1000},
{"id": 4, "metric": "affinity_curve_mae", "name": "好感度曲线 MAE", "kind": "proxy", "unit": "milli",
"definition": "确定性路径(每步取第一个可用选项)的 4 条好感度曲线,与随机策略 N 条采样的逐步均值曲线逐变量逐步相减,sum(|确定性 − 均值|) × 1000 // 比较步数(毫),对齐到较短长度。",
"truth_source": "characters[].affinity.init 与 dialogue[].choices[].effects.add", "measures": "一条具体路径的关系推进曲线偏离总体基线多远",
"does_not_measure": ["玩家是否被这段关系打动", "关系设计是否有说服力", "阈值门是否设计得合理"],
"allowed_use": ["同一规格内两条曲线的相对偏离", "确定性策略与采样基线之间的对照"],
"first_section": "15.1", "threshold_field": "metrics.affinity_mae_max", "threshold_value": 12000,
"caveat": "真源有 affinity_mae_max 阈值,却没有对应的 target_affinity_curve 字段——阈值当前没有可比目标,本节按「路径对采样基线」给出实测值,属规格的一处欠声明。"},
{"id": 5, "metric": "consistency_residual", "name": "一致性残差", "kind": "proxy", "unit": "count",
"definition": "四类静态失配计数之和:D 声明域失配 + K 秘密失配(见证旗标无门提供者)+ S 立场失配(敌对派系两侧都有 add 提供者)+ R 规则失配(禁区无门可进)。",
"truth_source": "characters[].affinity / secret、world.factions[].stance、world.rules[] 对 dialogue[].choices[].when / effects 的对齐",
"measures": "规格里「声明」与「许可」之间的失配条数",
"does_not_measure": ["叙事失调的主观强度", "玩家会不会察觉", "失配是否该修"],
"allowed_use": ["同一规格演化前后的残差回归", "四类失配之间的相对占比"],
"first_section": "9.1", "threshold_field": null, "threshold_value": 4, "threshold_note": "告警阈值 tau = 4,见 9.3;非规格字段"},
{"id": 6, "metric": "density_deviation", "name": "节奏密度偏差", "kind": "proxy", "unit": "permille",
"definition": "滑窗(W = 5 节点)密度越出可读带 [250, 750] 千分的窗口占比 = 偏疏窗口占比 + 偏密窗口占比。窗口密度 = 窗口内信息事件数 × 1000 // (4 × 5)。",
"truth_source": "由路径信息事件算出(新场景 / 新 flag / 新物品 / 有意义选择各算一件),口径见 7.2", "measures": "路径上有多少比例的窗口把信息给得太稀或太密",
"does_not_measure": ["玩家觉得节奏快还是慢", "文本可读性", "节拍张力的高低"],
"allowed_use": ["同一规格内不同策略的相对比较", "密度控制参数扰动前后的对照"],
"first_section": "7.2", "threshold_field": null, "threshold_value": 0, "threshold_note": "可读带 [250, 750] 千分是 7.2 的设计参数;偏差本身只做相对比较"},
{"id": 7, "metric": "invariant_residual", "name": "整数不变量残差", "kind": "proxy", "unit": "count",
"definition": "四类整数不变量越界计数之和:好感度越出 [0,100]、tension 越出 [1,5]、flag 取值非布尔、节点数不等于选择数加一。",
"truth_source": "state.vars / beats[].tension / effects.set / 路径长度", "measures": "整数状态机有没有越界(恒等判据,无容差)",
"does_not_measure": ["叙事质量", "体验好坏"],
"allowed_use": ["全书 S 层每节的硬断言", "跨语言(Python / TypeScript / 浏览器)指标一致性"],
"first_section": "1.1", "threshold_field": null, "threshold_value": 0, "threshold_note": "恒等判据:必须为 0,不设容差"}
]
}
S 层脚本最后会 lint 这张表:条目数必须等于 7、每条 kind 必须是 proxy、does_not_measure 与 allowed_use 不能为空、unit / name / first_section 必须与实算行一致(口径漂移为 0)。定义表不是文档,是产物——它和脚本互相钉死,改一个字段就得改另一个。
判读
第一,这张表是「收口」,不是「汇总」。 汇总只是把数字搬到一起;收口是把它们压到同一套字段下,让「怎么算、量什么、能回溯到哪」三件事一次问清。七个首见小节串起来,就是本卷从第 1 章到第 15 章的度量路线图。
第二,量纲是这张表的骨头。 七条指标落在四个量纲上:千分、毫比特、毫、计数。它们不能相加——这正是 15.2 要展开的局限。一张把千分和计数并排的表,如果没有量纲列,就会诱导人去求「总分」。
第三,也是最重要的一点:实测值全落在阈值内,不代表「叙事好」。 覆盖率 446 低于 850、残差 8 高于 0,两个「没达标」的读数并列摆着,指标也没判「好坏」——它只报出偏离,方向留给人判。这一条边界,15.3 会讲透。
自检表
| 检查项 | 实测 | 合格标准 |
|---|---|---|
| 指标条数 | 7 | 覆盖全卷七条 proxy |
| 重算 vs 产物对账 | 6 / 6 OK | 逐条 assert 通过 |
| 定义表 lint | 缺字段 0 / 口径漂移 0 | 全通过 |
| 好感度曲线对账 | 4 条曲线逐项一致 | 与 8.1 产物一致 |
| 全部 proxy | 7 / 7 | kind 恒为 proxy |
阅读导航:上一节:14.3 演化的告警 · 下一节:15.2 指标的可比性与局限 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。