15.3 失败的诚实报告
站内 《小游戏开发实战》12.2 可玩性度量测试 会给出指标的置信区间,告诉你「这个数有多可信」;本节的诚实报告回答的是另一个问题——「这个通过有多可信」。15.2 判了 6/6 通过,但通过不等于无话可说:13.3 从第一天起就如实记下了三处保留,一直没被抹掉。这一节把它们逐条摊开、分类、给出处置。诚实是本节唯一的落点,它有两条硬约束:不许把没通过的写成通过的,也不许把设计使然写成缺陷——三处保留里没有一处是「变体坏了」,也没有一处是「可以假装不存在」。
三处保留的逐条分类
分类只有三种,缺一不可,因为它们的处置完全不同:
| 保留项 | 现象 | 分类 | 是缺陷吗 | 处置 |
|---|---|---|---|---|
platformer_seed_axis_inert | 平台跳跃种子轴 16 个输入只出 1 个结果桶 | by_design 设计使然 | 否 | 保留 |
tower_defense_low_seed_resolution | 塔防种子轴 16 个输入只有 10 个结果桶 | real_shortfall 真实区分度不足 | 否 | 待改进 |
naive_winrate_criterion_refuted | 「胜率不得为 0‰ 或 1000‰」六条里五条不过 | criterion_error 判据本身错 | 否 | 改判据 |
三行都是「缺陷=否」,但它们为什么不是缺陷,理由完全不同——把三者混为一谈是最常见的偷懒:要么把①说成「待修」,要么把③说成「变体不够好玩」。
① 平台跳跃的种子轴惰性 → 设计使然
平台跳跃的 pfRun 根本不调用 rngNext——它的机制里没有随机,不确定来源是玩家逐跳按下的帧(timing 轴),不是种子。所以「16 个种子只出 1 个结果桶」是它的机制的直接推论:种子对它本来就无效。这不是缺陷,是确定性品类的正确行为——如果为了让它在种子轴上「有区分度」而硬塞一个随机数进去,反而是把一条正确的设计改坏。处置是保留:seedAxis 那一列在报告里只是个统一的诊断位,不参与判据,原样落盘、原样贴出,专门用来说明「它的不确定来源不在这里」。
② 塔防种子轴只有 10 桶 → 真实区分度不足
塔防的种子轴是有效的(它调 rngNext),但只有 10 个桶。原因很具体:这 16 个种子里 dealt 恒为 772、enemyHp 恒为 0,唯一在变的是 played(开火次数,10 个不同值)——因为种子只喂给每击的伤害抖动(jitterPermille = 250,即 ±25%),而伤害经整数地板除与 Math.min(dmg, tgt.hp) 截顶之后,剩下的差别只够让某些波少开一两枪。处置是待改进:它不是 bug(判据⑤依然通过),但它是六个变体里分辨率最低的一个,改法明确——加大伤害抖动幅度,或让更多字段(比如漏怪数)参与种子驱动。它和①的区别在于:① 的种子轴本该惰性,② 的种子轴本该更敏感却不够,一个是设计,一个是差距。
③ 「胜率不得为 0‰ 或 1000‰」→ 判据本身错
最直觉的可玩性判据是「胜率不能是 0‰ 也不能是 1000‰」。实测下来六条里五条不过:本体、塔防、自走棋都是 1000‰(第 5、6、9 章把胜利条件调成「策略正确就赢」),三消与放置都是 0‰(第 7、10 章的胜利条件故意设得够不着),只有平台跳跃的 888‰ 落在区间内。这不是变体坏了,是判据错了——错在把「某个数值落在区间里」当成了可玩性,而六个配置是按各自章节的设计意图调过的,硬把它们调进同一个胜率区间,等于抹掉那六章的全部结论。正确形态是 13.3 已经用的那条:在该品类自己声明的扰动轴上结果桶数 ≥ 2。处置是改判据,不是改变体。
S 层:把分类读回来并自检
分类本身也是机器产物(P 层写 out/gd_proto_15_3_classification.json),所以同样要有一个独立读它的人。S 层只读 JSON、只写 out/**,把三条分类打印出来,并做一次诚实性自检——三条都不许出现在 failed 列表里,缺陷数必须为 0:
# snippet: 读 out/gd_proto_15_3_classification.json,打印分类并自检缺陷数
/opt/homebrew/bin/python3 sim/gd_proto_15_3_failure_report.py
#!/usr/bin/env python3
# file: sim/gd_proto_15_3_failure_report.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_15_3_failure_report.py
"""卷三 15.3 失败的诚实报告(S 层):把 13.3 如实记下的三处保留读回来、**分类**、给出处置,
落成一份人读 CSV 与一张分类色带 SVG。
分类只有三种:
· by_design(设计使然,不是缺陷)—— 平台跳跃的种子轴惰性。
· real_shortfall(真实区分度不足,可改进项,不是 bug)—— 塔防种子轴只有 10 桶。
· criterion_error(判据本身错)—— 「胜率不得为 0‰ 或 1000‰」被实测证伪。
诚实是本节最重要的落点:**不许把没通过的写成通过的,也不许把设计使然写成缺陷**。本脚本只读
JSON、只写 out/**;它同时校验三条**都不在** failed 列表里(缺陷数 = 0)。全整数、无浮点。
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ashlib import svg_begin, svg_text, svg_bar, svg_save, write_csv # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
GATE = os.path.join(OUT, "gd_proto_13_gate.json")
CLASSIFY = os.path.join(OUT, "gd_proto_15_3_classification.json")
SVG = os.path.join(OUT, "gd_proto_15_3_classification.svg")
CSV = os.path.join(OUT, "gd_proto_15_3_classification.csv")
CLASS_LABEL = {"by_design": "设计使然", "real_shortfall": "真实区分度不足", "criterion_error": "判据本身错"}
CLASS_COLOR = {"by_design": "#38bdf8", "real_shortfall": "#f59e0b", "criterion_error": "#a78bfa"}
def main() -> int:
with open(GATE, encoding="utf-8") as f:
gate = json.load(f)
with open(CLASSIFY, encoding="utf-8") as f:
rep = json.load(f)
items = rep["items"]
failed = gate["failed"]
# 诚实性自检:三条都不在 failed 列表里,缺陷数必须为 0。
bad = [i["genre"] for i in items if i["genre"] in failed]
defect = sum(1 for i in items if i["isDefect"])
w, h = 860, 90 + len(items) * 46
root = svg_begin(w, h)
svg_text(root, 24, 34, "第 15.3 节 失败的诚实报告 · 三处保留的分类与处置", 16)
svg_text(root, 24, 56, "不许把没通过的写成通过的,也不许把设计使然写成缺陷;缺陷数必须为 0", 11, "#94a3b8")
for i, it in enumerate(items):
y = 84 + i * 46
svg_text(root, 24, y + 20, it["key"], 12)
svg_text(root, 24, y + 36, it["disposition"], 10, "#64748b")
svg_bar(root, 470, y + 6, 180, 20, 1, 1, CLASS_COLOR[it["classification"]],
CLASS_LABEL[it["classification"]])
svg_text(root, 668, y + 21, "缺陷=" + ("是" if it["isDefect"] else "否"), 12,
"#ef4444" if it["isDefect"] else "#22c55e")
svg_text(root, 24, h - 14, "三处保留 · 缺陷数=%d · failed=%s" % (defect, failed or "[]"), 13,
"#22c55e" if defect == 0 and not bad else "#ef4444")
size = svg_save(root, SVG)
rows = [[it["key"], it["genre"], it["classification"], it["isDefect"], it["disposition"]]
for it in items]
write_csv(CSV, ["key", "genre", "classification", "is_defect", "disposition"], rows)
print("source=" + rep["source"] + " items=%d" % len(items))
for it in items:
print(" %-34s %-16s 缺陷=%s 处置:%s"
% (it["key"], CLASS_LABEL[it["classification"]], "是" if it["isDefect"] else "否", it["disposition"]))
print("缺陷数=%d failed=%s noneInFailed=%s" % (defect, failed, rep["noneInFailed"]))
print("直觉判据不过:" + ",".join(items[2]["evidence"]["naiveFailGenres"]) +
"(%d/6)" % items[2]["evidence"]["naiveFailCount"])
print("诚实边界:" + rep["honestBoundary"])
print("svg=%d B csv=%d rows" % (size, len(rows)))
if bad or defect != 0:
print("FAIL 分类不自洽")
return 1
print("OK 三处保留分类自洽,缺陷数 0(都不在 failed 列表里)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
source=out/gd_proto_13_gate.json items=3
platformer_seed_axis_inert 设计使然 缺陷=否 处置:保留(确定性品类本就不该有种子轴区分度)
tower_defense_low_seed_resolution 真实区分度不足 缺陷=否 处置:待改进(可加大伤害抖动幅度或让更多字段参与种子驱动)
naive_winrate_criterion_refuted 判据本身错 缺陷=否 处置:改判据(改用「声明式扰动轴上结果桶数 ≥ 2」)
缺陷数=0 failed=[] noneInFailed=True
直觉判据不过:deckbuilder,tower-defense,match3,autobattler,idle(5/6)
诚实边界:闸门只覆盖机制侧(确定性/不变量/终态可达/契约同构/扰动区分度);玩家主观体验、美术表现、性能、网络、商业化需真实业务环境,命令行不可复现(X 档)。
svg=2972 B csv=3 rows
OK 三处保留分类自洽,缺陷数 0(都不在 failed 列表里)
「6/6 通过」这句话有一道必须写明的边界:闸门只覆盖机制侧的五条判据——确定性、不变量、终态可达、契约同构、扰动区分度。它不覆盖玩家主观体验、美术表现、性能、网络、商业化——这些需要真实业务环境,命令行不可复现,属于全书分档里的 X 档。换句话说,allPass: true 只保证「这套规格在机制上自洽、可复现、非退化」,它不保证「好玩」;把机制侧的自洽当成「游戏好玩」的证明,是比判据③更隐蔽的一次越界。
W 层:把验收落成一个可点的页面
机制侧的自洽还有一个跨运行时的检验:把六个内核与 15.3 的分类一起内联进一个 HTML(out/proto15.html),在无头浏览器里逐个跑,断言浏览器里的 digest 与构建期 Node 侧逐字相同。构建脚本是 web/build_proto15.mjs:
# snippet: 构建验收看板 out/proto15.html,并在无头浏览器里断言
NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto15.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node out/gd_proto_15_wcheck.mjs
// file: web/build_proto15.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto15.mjs
//
// 卷三 15 品类变体的验收(W 层):把六个内核(本体 combat.ts + 五个 core/genre/*.ts)与共享底座
// rng.ts / digest.ts 按手工拓扑序擦类型后,连同六份规格、13.3 的闸门基线与 15.3 的保留分类一起
// 内联进一个 HTML(out/proto15.html)。页面是**验收看板**:点一个品类跑对应内核,画出该变体在
// 6×5 判据矩阵里的那一行,并显示三处保留分类。暴露 globalThis.__ashdeck:
// run(genre?)/genres()/matrix()/classification()/canvasDrawn。擦类型复用 web/pack.mjs 的 bundle()。
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";
import { tdRun, tdDigest } from "../core/genre/towerdefense.ts";
import { m3Run, m3Digest } from "../core/genre/match3.ts";
import { pfRun, pfDigest } from "../core/genre/platformer.ts";
import { abRun, abDigest } from "../core/genre/autobattler.ts";
import { idleRun, idleDigest } from "../core/genre/idle.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck
// 手工拓扑序:先声明后使用;去掉跨文件 import 后同一作用域内不得重名。
const MODULES = ["core/src/ashdeck/types.ts", "core/src/ashdeck/rng.ts", "core/src/ashdeck/cards.ts",
"core/src/ashdeck/combat.ts", "core/src/ashdeck/digest.ts", "core/genre/towerdefense.ts",
"core/genre/match3.ts", "core/genre/platformer.ts", "core/genre/autobattler.ts", "core/genre/idle.ts"];
/** bundle() 把 10 个模块拼进同一模块作用域(严格模式),禁止重复声明。四个品类内核各带一个逐字
* 相同的顶层 idiv,只把**逐字相同**的折叠成一份;同名不同内容直接抛错,绝不静默取后者。 */
function dedupeFunctions(src) {
const lines = src.split("\n");
const out = [];
const seen = new Map();
let i = 0;
while (i < lines.length) {
const m = /^function ([A-Za-z_$][\w$]*)\(/.exec(lines[i]);
if (m === null) { out.push(lines[i]); i += 1; continue; }
let depth = 0, opened = false, j = i;
const block = [];
for (; j < lines.length; j += 1) {
block.push(lines[j]);
for (const ch of lines[j]) {
if (ch === "{") { depth += 1; opened = true; } else if (ch === "}") depth -= 1;
}
if (opened && depth === 0) break;
}
const text = block.join("\n");
const prev = seen.get(m[1]);
if (prev === undefined) { seen.set(m[1], text); out.push(text); }
else if (prev !== text) throw new Error("同名函数内容不同,拒绝折叠: " + m[1]);
i = j + 1;
}
return out.join("\n");
}
const registry = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_13_variants.json"), "utf8"));
const base = JSON.parse(readFileSync(join(ROOT, "out/gd_proto_13_gate.json"), "utf8"));
const classify = JSON.parse(readFileSync(join(ROOT, "out/gd_proto_15_3_classification.json"), "utf8"));
const specs = {};
for (const v of registry.variants) specs[v.genre] = JSON.parse(readFileSync(join(ROOT, v.spec), "utf8"));
const core = dedupeFunctions(bundle(ROOT, MODULES));
const OUT = join(ROOT, "out", "proto15.html");
const script = core + `
const SPECS = ` + inlineConfig(specs) + `;
const GENRES = ` + inlineConfig(registry.variants.map((v) => ({ genre: v.genre, label: v.label, axis: v.axis }))) + `;
const CRIT = ` + inlineConfig(base.criteria) + `;
const BASEV = ` + inlineConfig(base.variants.map((v) => ({ genre: v.genre, criteria: v.criteria }))) + `;
const CLASSIFY = ` + inlineConfig(classify.items.map((i) => ({ key: i.key, classification: i.classification, isDefect: i.isDefect }))) + `;
const K = { deckbuilder: [runSpec, digestSnap], "tower-defense": [tdRun, tdDigest], match3: [m3Run, m3Digest],
platformer: [pfRun, pfDigest], autobattler: [abRun, abDigest], idle: [idleRun, idleDigest] };
const cv = document.getElementById("cv"), ctx = cv.getContext("2d");
const label = (g) => (GENRES.find((x) => x.genre === g) || {}).label || g;
let snap = null, current = "deckbuilder";
// 六行一图:每行一个变体,五格是该变体的五条判据(通过绿 / 不通过红);当前选中行高亮。
function render(genre) {
ctx.fillStyle = "#111827"; ctx.fillRect(0, 0, cv.width, cv.height);
ctx.fillStyle = "#94a3b8"; ctx.font = "13px system-ui, sans-serif";
ctx.fillText("6 变体 × 5 判据 通过矩阵(重跑内核重新判出,与 13.3 权威输入逐格一致)", 16, 22);
for (let i = 0; i < BASEV.length; i += 1) {
const row = BASEV[i], y = 46 + i * 46, on = row.genre === genre;
ctx.fillStyle = on ? "#e2e8f0" : "#64748b";
ctx.font = (on ? "bold " : "") + "13px system-ui, sans-serif";
ctx.fillText(row.genre, 16, y + 22);
for (let j = 0; j < CRIT.length; j += 1) {
const ok = row.criteria[CRIT[j]];
ctx.fillStyle = ok ? "#22c55e" : "#ef4444"; ctx.fillRect(210 + j * 84, y + 4, 76, 26);
ctx.fillStyle = "#0f172a"; ctx.fillText(ok ? "通过" : "不通过", 216 + j * 84, y + 22);
}
}
ctx.fillStyle = "#e2e8f0"; ctx.font = "12px system-ui, sans-serif";
ctx.fillText("当前变体 " + genre + " · 扰动轴 " + (GENRES.find((g) => g.genre === genre) || {}).axis +
" · 三处保留均为「非缺陷」", 16, cv.height - 10);
document.getElementById("out").textContent = K[genre][1](snap);
document.getElementById("cur").textContent = genre + " · " + label(genre);
}
globalThis.__ashdeck = {
spec: SPECS,
run(genre) {
current = typeof genre === "string" && K[genre] ? genre : "deckbuilder";
snap = K[current][0](JSON.parse(JSON.stringify(SPECS[current])));
render(current);
return K[current][1](snap);
},
snapshot() { return snap; },
genres() { return GENRES.map((g) => g.genre); },
matrix() { return BASEV; },
classification() { return CLASSIFY; },
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真。
requestAnimationFrame(() => requestAnimationFrame(() => {
globalThis.__ashdeck.canvasDrawn = true;
}));
`;
const buttons = registry.variants.map((v) => `<button data-g="${v.genre}">${v.label}</button>`).join("\n");
const body = `<h1>《灰烬牌库》· 品类变体的验收(第 15 章)</h1>
<div class="sub">一套回归 · 6/6 过闸门 · 三处保留如实分类,无一处是缺陷</div>
<canvas id="cv" width="700" height="330"></canvas>
<div class="row">${buttons}</div>
<div class="card"><div class="k">当前变体</div><div class="big" id="cur">—</div></div>
<div class="card"><div class="k">摘要 digest(与 P 层测试冻结的那条逐字相同)</div><div id="out" class="k">—</div></div>
<script>document.querySelectorAll("button[data-g]").forEach(function (b) {
b.addEventListener("click", function () { globalThis.__ashdeck.run(b.getAttribute("data-g")); });
});</script>`;
const bytes = writePage(OUT, page({ title: "《灰烬牌库》· 第 15 章 品类变体的验收",
subtitle: "六内核 · 一套回归 · 6×5 闸门矩阵 · 三处保留", body, script }));
const K = { deckbuilder: [runSpec, digestSnap], "tower-defense": [tdRun, tdDigest], match3: [m3Run, m3Digest],
platformer: [pfRun, pfDigest], autobattler: [abRun, abDigest], idle: [idleRun, idleDigest] };
const nodeDigests = {};
for (const g of Object.keys(K)) nodeDigests[g] = K[g][1](K[g][0](specs[g]));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "gd_proto_15_build.json"),
JSON.stringify({ spec: registry.id, modules: MODULES.length, kernelChars: core.length,
page: "out/proto15.html", pageBytes: bytes, genres: registry.variants.map((v) => v.genre), nodeDigests,
matrixPass: base.variants.every((v) => v.pass),
classification: classify.items.map((i) => ({ key: i.key, isDefect: i.isDefect })) }, null, 2) + "\n");
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + " " + bytes + " B");
for (const g of Object.keys(nodeDigests)) console.log("NODE_DIGEST[" + g + "]=" + nodeDigests[g]);
modules=10 kernelChars=44801
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto15.html 63512 B
ok 页面暴露的六个品类名与注册表逐个相同
ok 浏览器 deckbuilder 的 digest == 构建期 Node 侧 digest
(tower-defense / match3 / platformer / autobattler / idle 五行同格式,此处省略)
ok 同一品类两遍逐字一致(确定性)
ok 页面 6×5 矩阵 6 行 / 30 格通过 == 13.3 权威输入
ok 三处保留的分类都标为「非缺陷」(isDefect=false)
OK W 层加断言通过(6/6 品类 · 30/30 格 · 3/3 保留)
shot=out/shots/proto15.png
这里踩的坑是冷启动与重复声明。一是 Playwright 新起的第一个 page 读 canvas 像素恒为 0,必须先开一个预热页画一个 8×8,而且 canvasDrawn 必须等到两次 requestAnimationFrame 之后再置真。二是四个品类内核各带一个逐字相同的顶层 function idiv,擦类型后拼进同一模块作用域会触发严格模式下的重复声明 SyntaxError——整页白屏、canvasDrawn 永远为假、验证器 15 秒超时;所以必须先过一遍 dedupeFunctions():只折叠逐字相同的,同名不同内容直接抛错,绝不静默取后者。
15.3 把「6/6 通过」这句话补完:三处保留没有一处是缺陷,但它们分别是设计使然(保留)、真实区分度不足(待改进)、判据本身错(改判据)——三种处置,缺一不可。诚实的报告比漂亮的报告有用——一个被如实记下、分了类的保留项,是下一版可以动手改的输入;一个被粉饰成「全绿」的缺口,只会在某天变成线上事故。
阅读导航:上一节:15.2 六分之六通过闸门 · 下一节:16.1 三卷装配检查表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。