6.3 代理指标的口径声明

张力曲线 MAE 是 proxy:它度量的是作者标注与设计目标的偏离,不是玩家实际感受到的情绪。本节把这条边界写成一份可机器检查的声明 config/nm_06_3_proxy_declaration.json,用 lint 钉死字段、口径类型、单位与禁声称清单,再把实测的 0 毫填进报告,落 out/nm_06_3_proxy.json。

《游戏设计》卷一《玩法机制与可执行规格》 那套把每个指标的口径写死在规格里,让「这个数怎么算、能断言什么」可被机器检查;本章接住它的真源做叙事层的曲线度量,并把同一条纪律用在最危险的地方——代理指标。张力曲线 MAE 是本节的主角:它是 proxy,不是玩家情绪。这一节把「它是 proxy、不能声称什么」从一句文档里的话,变成一份可 lint 的产物。

6.3 代理指标的口径声明

本卷通篇在度量叙事体验,但体验类指标有一个不可回避的事实:命令行量不出玩家情绪。真人测试需要真实业务环境,命令行不可复现,本套不做。所以本卷的每一个指标都必须显式声明自己是 proxy(代理指标),只做相对比较,禁绝对化。张力曲线 MAE 尤其容易越界——它叫「情感曲线」的 MAE,一不小心就会被读成「玩家有多被打动」。本节把这条边界钉死。

声明要写清的六件事

一份合格的 proxy 声明,必须回答下面六个问题,缺一个就是可被误读的空档:

字段回答的问题
metric / kind是哪个指标,是不是 proxy
unit / rounding单位与取整口径(毫 / 千分位整数地板除)
inputs读的是哪两段真源(作者标注 / 设计目标)
measures度量的是什么(标注与目标的偏离)
does_not_measure不度量什么(玩家情绪、台词感染力、机制张力)
forbidden_claims禁声称什么(更感人 / 提升 N 倍 / 满意度 X%)

其中 does_not_measure 与 forbidden_claims 是这份声明真正的价值所在:它们不是补充说明,是硬边界。 一个只写「度量情感曲线误差」的声明,会让人以为它能证明体验好坏;加上「不度量玩家实际感受到的情绪强度」,这个误会才被堵住。

三个 proxy 与它们的真值

本卷有三类 proxy,每一个都有一个「真值」是想量而命令行量不到的。把真值摆出来,proxy 的边界就一目了然:

proxy想量的真值(命令行量不到)实际度量的是什么
张力曲线 MAE玩家实际感受的情绪起伏作者标注与设计目标的偏离
分支覆盖率玩家实际探索到的丰富度走过的对话边 / 全部对话边
结局分布熵玩家实际体验到的多样性5 个结局在采样分布上的均匀度

三者的形状不同,纪律却完全一样:只做相对比较。覆盖率比的是「这条策略比那条策略覆盖得多」,熵比的是「这个分布比那个分布更均匀」,MAE 比的是「改之前比改之后偏离得更少」——没有一条能单独证明「体验好」。

这也回答了「为什么非得声明」:proxy 本身没有对错,只有口径。同一个 MAE 值 0,读成「标注与目标此刻一致」是对的,读成「玩家很满意」就是错的——差别不在数字,在口径。声明就是把这个口径固化下来,让读法不能随人改。而第 15 章会把三类 proxy 收成一张总表,专门讲「指标不能声称什么」;本节先把 MAE 这一类做成可检查的样本。

声明怎么被消费

这份声明不是写给人看的文档,它有明确的消费方:

  • lint 脚本读它,检查字段齐不齐、kind 是不是 proxy——不齐即 FAIL;
  • 报告产物(out/nm_06_3_proxy.json)把声明与实测值绑在一起,值变了声明还在;
  • 规格演化时(第 14 章)它给出「这个数可以怎么用」的边界——只允许相对对照,不允许绝对断言。

换句话说,声明把「度量」和「解读」分开了:脚本负责给出数,声明负责给出这个数允许被怎样使用。前者可复现,后者可 lint,两者都不依赖写作者的自觉。

这也是本卷与《游戏设计》卷二在口径上的接续:那套给机制指标定容差,本卷给叙事 proxy 定用途——容差管「差多少算一致」,用途管「这个数能被拿来说什么」。两者一起,才让「整数化 + 确定性 + proxy 口径」这条纪律在三卷之间不落空。

D 层:可 lint 的声明文件

声明本身是一份 JSON,落 config/nm_06_3_proxy_declaration.json:

{
  "metric": "tension_curve_mae",
  "kind": "proxy",
  "unit": "permille",
  "rounding": "floor_integer",
  "inputs": {
    "actual": "beats[].tension",
    "target": "metrics.target_tension_curve"
  },
  "measures": "作者标注的节拍张力序列与设计目标曲线的平均绝对偏离",
  "does_not_measure": [
    "玩家实际感受到的情绪强度",
    "台词的感染力",
    "机制层的张力动力学"
  ],
  "allowed_use": [
    "同一规格内两条曲线的相对偏离",
    "规格演化前后的回归对照"
  ],
  "forbidden_claims": [
    "更感人",
    "提升 N 倍",
    "玩家满意度 X%",
    "情绪强度绝对达标"
  ],
  "threshold_milli": 1000
}

这份声明是数据,不是散文——它可以被脚本读取、被 lint、被拒绝。allowed_use 里只列了两种用途,都是相对的:同一规格内两条曲线比、演化前后对照。没有任何一条允许「这段体验绝对达标」这类断言。

S 层:lint 声明并把实测值填进报告

脚本读声明、读规格,逐条 lint 声明的字段,再把当前实测的 MAE 填进报告,落 out/nm_06_3_proxy.json:

#!/usr/bin/env python3
# file: sim/nm_06_3_proxy_metric_declaration.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_06_3_proxy_metric_declaration.py
"""卷三 6.3 代理指标的口径声明(S 层)。

张力曲线 MAE 是 proxy:它度量的是「作者标注 vs 设计目标」的偏离,**不是**玩家
实际感受到的情绪。本脚本读 config/nm_06_3_proxy_declaration.json,逐条 lint 这份
声明的字段是否齐全,再把当前规格下实测的 MAE 填进报告,落 out/nm_06_3_proxy.json。

声明的 lint 是硬断言:缺字段、kind 不是 proxy、单位不是千分位、禁声称清单为空,
任一不满足即 FAIL。这样「这个数是 proxy、不能声称什么」不是文档里的一句话,而是
一份可被机器检查的产物。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
DECL = os.path.join(HERE, "../config/nm_06_3_proxy_declaration.json")

REQUIRED = ["metric", "kind", "unit", "rounding", "inputs", "measures",
            "does_not_measure", "allowed_use", "forbidden_claims", "threshold_milli"]


def main() -> int:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)
    with open(DECL, encoding="utf-8") as fh:
        decl = json.load(fh)

    beats = spec["beats"]
    target = spec["metrics"]["target_tension_curve"]
    actual = [b["tension"] for b in beats]
    mae = sum(abs(a - t) for a, t in zip(actual, target)) * 1000 // len(actual)

    missing = [k for k in REQUIRED if k not in decl]
    print(f"声明文件       config/{os.path.basename(DECL)}")
    print(f"指标           {decl['metric']}")
    print(f"口径类型       {decl['kind']}  (必须为 proxy)")
    print(f"单位           {decl['unit']}  (千分位整数地板除)")
    print(f"输入真源       {decl['inputs']['actual']}  /  {decl['inputs']['target']}")
    print(f"度量的是       {decl['measures']}")
    print(f"不度量         {len(decl['does_not_measure'])} 项:{';'.join(decl['does_not_measure'])}")
    print(f"允许的用途     {len(decl['allowed_use'])} 项:{';'.join(decl['allowed_use'])}")
    print(f"禁声称         {len(decl['forbidden_claims'])} 项:{';'.join(decl['forbidden_claims'])}")
    print(f"当前实测 MAE   {mae} 毫  (全 16 节拍,阈值 {decl['threshold_milli']} 毫)")
    print(f"lint 字段      {len(REQUIRED) - len(missing)} / {len(REQUIRED)} 齐" +
          (f",缺 {missing}" if missing else ""))

    assert not missing, f"声明缺字段 {missing}"
    assert decl["kind"] == "proxy", "口径类型必须是 proxy"
    assert decl["unit"] == "permille", "单位必须是千分位"
    assert decl["forbidden_claims"], "禁声称清单不能为空"
    assert decl["does_not_measure"], "不度量清单不能为空"
    assert mae <= decl["threshold_milli"], "MAE 超阈值"

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_06_3_proxy.json"), "w", encoding="utf-8") as fh:
        json.dump({"declaration": decl, "observed_mae_milli": mae,
                   "lint_ok": not missing, "fields": len(REQUIRED)},
                  fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_06_3_proxy.json")
    print("OK nm_06_3_proxy_metric_declaration:口径声明字段齐全,MAE 为 proxy 且已声明边界")
    return 0


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

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

声明文件       config/nm_06_3_proxy_declaration.json
指标           tension_curve_mae
口径类型       proxy  (必须为 proxy)
单位           permille  (千分位整数地板除)
输入真源       beats[].tension  /  metrics.target_tension_curve
度量的是       作者标注的节拍张力序列与设计目标曲线的平均绝对偏离
不度量         3 项:玩家实际感受到的情绪强度;台词的感染力;机制层的张力动力学
允许的用途     2 项:同一规格内两条曲线的相对偏离;规格演化前后的回归对照
禁声称         4 项:更感人;提升 N 倍;玩家满意度 X%;情绪强度绝对达标
当前实测 MAE   0 毫  (全 16 节拍,阈值 1000 毫)
lint 字段      10 / 10 齐
wrote out/nm_06_3_proxy.json
OK nm_06_3_proxy_metric_declaration:口径声明字段齐全,MAE 为 proxy 且已声明边界

判读:声明是产物,不是修辞

第一,字段齐全是被 lint 钉住的。 脚本检查 10 个必填字段,10 / 10 齐。缺任何一个、kind 不是 proxy、单位不是千分位、禁声称清单为空,assert 直接让脚本 FAIL——声明不是「写了就算」,是「写全才算」。

第二,边界是双向的。 声明同时写了 measures(度量标注与目标的偏离)与 does_not_measure(不度量玩家情绪、台词感染力、机制张力)。前者告诉读者这个数能说什么,后者告诉读者它不能说什么——只写前者,就给了误读的空间。

第三,禁声称清单是可执行的。 forbidden_claims 里四条——更感人、提升 N 倍、玩家满意度 X%、情绪强度绝对达标——正是本卷口径里点名禁止的表述。把它写成数据,意味着任何引用了这个 MAE 的结论,都可以对着这份清单逐条自查。

顺带说一句:这份声明是章的产物,不是章的附录。它和 out/nm_06_3_proxy.json 一起被落盘,意味着「这个数是 proxy、不能声称什么」有可核对的落地物——换个人来读这一章,拿到的边界与作者写下的边界是同一份,不依赖任何口头约定。

检查项实测合格标准
必填字段10 / 10全齐
口径类型proxy必须是 proxy
单位permille千分位整数地板除
禁声称清单4 项非空
实测 MAE0 毫≤ 阈值 1000 毫

三个常见误读

围绕这个 MAE,最常见的三个误读都能被声明直接挡下:

  • 误读一:MAE 为 0 说明「这段叙事很成功」。 声明里 measures 只写「标注与目标的偏离」——0 只说明标注和目标一致,与玩家感受无关。
  • 误读二:MAE 涨了说明「叙事变差了」。 它只说明标注与目标分开了;分开可能是目标过时,也可能是标注更新,方向要人判,指标只负责报警。
  • 误读三:可以拿 MAE 跨作品比。 声明里 allowed_use 只列了「同一规格内」与「演化前后」——跨规格比没有共同真源,不在允许用途之内。

三条误读的共同点:都把相对量当成了绝对量。声明的作用,就是让这类越界在使用之前就被字段挡住,而不是等结论已经写进汇报再去补救。

最后把这一章收口:6.1 把路径构造成曲线,6.2 把曲线与目标比出 MAE,6.3 把 MAE 的边界写成声明。三步连起来,就是「把体验还原成可度量」这句话的正身——曲线怎么长出来、跟目标差多少、这个差不能声称什么,全都有可跑的产物兜底。本章不评价《雾港夜航》写得好不好,只保证:它跑出来的每一条路径,其张力形状都能被算成一个整数,且这个整数不会冒充玩家情绪。

自检表

检查项合格标准
声明是数据字段可被脚本读取与 lint
边界双向同时声明 measures 与 does_not_measure
禁声称清单非空,且为数据而非注释
单位一致与 6.2 的 MAE 同口径(毫)
实测值由脚本实跑填入,非手写
误读覆盖常见越界都能被字段挡下
消费方明确lint / 报告 / 演化三处可读

阅读导航:上一节:6.2 目标曲线与 MAE · 下一节:7.1 叙事节奏的度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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