《游戏设计》卷一《玩法机制与可执行规格》 那套给指标定的是整数化 + 声明容差:任何派生量都按声明的容差断言,绝不比浮点。本章接住它的真源做叙事层的曲线度量——本节把 6.1 构造出的张力曲线与设计目标逐项比,得到的 MAE 也走同一条整数容差口径,单位是毫(千分位)。这一节要回答的核心问题是:这个 MAE 现在恒为 0,是指标没用,还是规格此刻自洽?
6.2 目标曲线与 MAE
真源里除了 beats[].tension(作者标注),还有一段 metrics.target_tension_curve(设计目标)——它是 16 个整数 1–5,长度与节拍数一一对应。MAE(平均绝对误差)量的是这两条曲线逐项偏离的平均值,公式是 MAE(毫) = sum(|实际曲线[i] − 目标曲线[i]|) × 1000 // n。
三个细节写死口径:分子先求和再乘 1000,最后一步才做整数地板除——这样单位是千分之一,1000 毫 恰好等于「平均偏离 1 个张力档」;// 是整数地板除,不出现浮点;阈值来自真源 metrics.tension_mae_max = 1,换算成毫就是 1000 毫。注意 1000 毫 这个换算不是随便定的:sum × 1000 // n 的物理含义就是「平均偏离 × 1000」,所以「平均偏离 1 档」正好等于 1000 毫,阈值 tension_mae_max = 1 与它一一对应。
两个口径:路径口径与全量口径
MAE 要算两次,因为「比哪些节拍」有两种合理口径:
| 口径 | 比什么 | 长度 | 回答的问题 |
|---|---|---|---|
| 路径口径 | 只比路径命中过的节拍 | 11 | 这一条路的曲线离目标多远 |
| 全量口径 | 比全部 16 个节拍 | 16 | 整份规格的标注与目标差多少 |
路径口径是「这条路径的体验形状」,全量口径是「规格级的一致度」。两个都算,才能区分「路走得少」与「标注本身偏了」——单看路径口径,路走得越短、命中的节拍越少,MAE 越容易被少数几项左右。
S 层:逐项比 + 反事实探针
脚本先按两个口径各算一次 MAE,再做一次反事实探针:把目标曲线的第 1 项挪动 1,看 MAE 会不会抬起来。探针是本节的关键——它回答「这个 0 是不是指标失效」。
#!/usr/bin/env python3
# file: sim/nm_06_2_target_curve_mae.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_06_2_target_curve_mae.py
"""卷三 6.2 目标曲线与 MAE(S 层)。
把 6.1 构造出的「实际曲线」与真源里的设计目标 metrics.target_tension_curve
逐项比,按千分位整数地板除算 MAE(毫)。
两个口径都算:
- 路径口径:只比路径命中过的节拍(长度 = 覆盖节拍数);
- 全量口径:比全部 16 个节拍(规格级的整体一致度)。
真源里 beats[].tension 与 target_tension_curve 当前逐项相同,所以两个口径的
MAE 都是 0——本脚本用「反事实探针」把这一点坐实:只把目标曲线的某一项挪动 1,
MAE 立刻抬起来,证明 MAE 恒 0 是「标注与目标此刻一致」的自洽证据,而不是指标
失效。全程整数,不出现浮点。
"""
from __future__ import annotations
import json
import os
import sys
AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
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 node(spec, nid):
return next(n for n in spec["dialogue"] if n["id"] == nid)
def init_state(spec):
aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
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"])},
"path": [spec["meta"]["start"]]}
def step(spec, s, c):
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] = min(AFF_HI, max(AFF_LO, 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,
"path": s["path"] + [c["goto"]]}
def walk(spec):
s = init_state(spec)
for _ in range(64):
cur = node(spec, 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])
return s
def mae_milli(actual, target):
"""MAE,千分位整数地板除:sum(|a-t|) × 1000 // n。"""
n = len(actual)
return sum(abs(a - t) for a, t in zip(actual, target)) * 1000 // n if n else 0
def main() -> int:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
s = walk(spec)
visited = {node(spec, nid)["scene"] for nid in s["path"]}
beats = spec["beats"]
target = spec["metrics"]["target_tension_curve"]
thresh = spec["metrics"]["tension_mae_max"]
# 路径口径:只比路径命中过的节拍
idx_path = [i for i, b in enumerate(beats) if b["scene"] in visited]
act_path = [beats[i]["tension"] for i in idx_path]
tgt_path = [target[i] for i in idx_path]
# 全量口径:比全部 16 个节拍
act_all = [b["tension"] for b in beats]
tgt_all = list(target)
mae_path = mae_milli(act_path, tgt_path)
mae_all = mae_milli(act_all, tgt_all)
diffs = [abs(a - t) for a, t in zip(act_path, tgt_path)]
print(f"路径口径 命中节拍 {len(idx_path)} / {len(beats)}")
print(f"实际曲线 {' '.join(str(v) for v in act_path)}")
print(f"目标曲线 {' '.join(str(v) for v in tgt_path)}")
print(f"逐项绝对差 {' '.join(str(v) for v in diffs)}")
print(f"路径 MAE {mae_path} 毫 ({sum(diffs)} × 1000 // {len(act_path)})")
print(f"全量 MAE {mae_all} 毫 ({sum(abs(a-t) for a,t in zip(act_all,tgt_all))} × 1000 // {len(act_all)})")
print(f"阈值 {thresh * 1000} 毫 (metrics.tension_mae_max = {thresh})")
# 反事实探针:把目标曲线第 1 个节拍的目标挪动 1,MAE 必须抬起
probe = list(tgt_all)
probe[0] = probe[0] + 1
mae_probe = mae_milli(act_all, probe)
print(f"反事实探针 目标曲线第 1 项 {tgt_all[0]} → {probe[0]}")
print(f"探针 MAE {mae_probe} 毫 (1 × 1000 // {len(act_all)},非 0 证明指标有分辨力)")
assert mae_path <= thresh * 1000, "路径 MAE 超阈值"
assert mae_all <= thresh * 1000, "全量 MAE 超阈值"
assert mae_probe > 0, "反事实探针未抬起,指标疑似失效"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_06_2_mae.json"), "w", encoding="utf-8") as fh:
json.dump({"path_beats": len(idx_path), "actual": act_path, "target": tgt_path,
"abs_diff": diffs, "mae_path_milli": mae_path, "mae_all_milli": mae_all,
"threshold_milli": thresh * 1000, "probe_mae_milli": mae_probe},
fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_06_2_mae.json")
print("OK nm_06_2_target_curve_mae:两条曲线逐项相同 → MAE 恒 0,且探针证明指标非失效")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_06_2_target_curve_mae.py:
路径口径 命中节拍 11 / 16
实际曲线 1 2 3 2 3 4 4 3 4 4 2
目标曲线 1 2 3 2 3 4 4 3 4 4 2
逐项绝对差 0 0 0 0 0 0 0 0 0 0 0
路径 MAE 0 毫 (0 × 1000 // 11)
全量 MAE 0 毫 (0 × 1000 // 16)
阈值 1000 毫 (metrics.tension_mae_max = 1)
反事实探针 目标曲线第 1 项 1 → 2
探针 MAE 62 毫 (1 × 1000 // 16,非 0 证明指标有分辨力)
wrote out/nm_06_2_mae.json
OK nm_06_2_target_curve_mae:两条曲线逐项相同 → MAE 恒 0,且探针证明指标非失效
判读:恒 0 是自洽证据,不是指标失效
第一,逐项绝对差全是 0。 路径命中过的 11 个节拍上,实际曲线与目标曲线完全吻合,逐项差 0 0 0 0 0 0 0 0 0 0 0。这不是巧合,是因为真源里 beats[].tension 与 target_tension_curve 逐项相同——作者标注的张力,与设计目标此刻就是一份东西。
第二,两个口径的 MAE 都是 0,且都过阈值。 路径口径 0 毫、全量口径 0 毫,阈值 1000 毫。0 ≤ 1000,通过。 脚本用 assert 把这条钉住。
第三,反事实探针证明指标有分辨力。 只把目标曲线第 1 项从 1 改成 2,探针 MAE 立刻变成 62 毫(1 × 1000 // 16)。如果 MAE 真的退化成恒 0,探针就不可能抬起——它抬起了,说明这个 0 是真实的 0,而不是公式算不动的 0。
| 观察 | 数字 | 读法 |
|---|---|---|
| 路径 MAE | 0 毫 | 命中 11 个节拍上标注与目标完全吻合 |
| 全量 MAE | 0 毫 | 全 16 个节拍上标注与目标完全吻合 |
| 阈值 | 1000 毫 | tension_mae_max = 1,即平均偏离 1 档 |
| 探针 MAE | 62 毫 | 挪动 1 项即抬起,指标非失效 |
所以「当前 MAE 恒 0」的读法只有一种:这份规格的标注与目标此刻是一致的。MAE 真正起作用是在规格演化时(第 14 章)——改了某个节拍的 tension 而没同步 target_tension_curve,它就会从 0 抬起来,成为回归信号。一个恒为 0 的指标不是没用,而是「此刻没有偏离」这一事实的度量。
一个交叉核对:本节算出的路径口径 MAE = 0,与目录篇 nm_00_toc_metric.py 打印的「张力 MAE 0 毫」逐位一致——两条脚本各自独立实现,走同一条确定性路径、读同一段真源,得到同一个 0。这不是巧合,而是「同一条路径的曲线唯一」的直接结果:曲线由路径唯一决定(6.1),目标由真源唯一决定,两个唯一量相减,结果自然唯一。
还要提醒一点:MAE 与覆盖率是两个正交的量。同一条路径可能覆盖率很低(本卷目录篇实测 446‰)而 MAE 为 0——覆盖低说明「这条路没走全」,MAE 为 0 说明「走过的部分没偏」。两者不能互相替代:覆盖看的是广度,MAE 看的是已覆盖部分的贴合度。第 2 章管前者,本章管后者。
自检表
| 检查项 | 合格标准 |
|---|---|
| 单位 | 毫(千分位),sum × 1000 // n |
| 无浮点 | 只用 // 整数地板除 |
| 阈值来源 | metrics.tension_mae_max,换算 × 1000 |
| 双口径 | 路径口径 + 全量口径都算 |
| 非退化 | 反事实探针 MAE > 0(脚本 assert) |
MAE 算出来了,但「这个数说明了什么、不能说明什么」还没写死。下一节把这条边界做成一份可机器检查的口径声明——它明确这个 MAE 是 proxy、只做相对比较、并列出禁声称清单。
阅读导航:上一节:6.1 情感曲线的构造 · 下一节:6.3 代理指标的口径声明 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。