2.1 数字原型的最小内核
《小游戏开发实战》的最小可玩原型 讲的是产品口径的最小——多小算能玩、多小算能验、范围怎么框住;本节讲的是工程口径的最小:一份规格能驱动的最小内核长什么样。原型迭代决策 那篇是「什么时候继续、什么时候改方向」的产品判断,本章第 2.2 节做的是迭代闭环的代价核算——改一个参数、跑一次内核、重跑一次原型,各花几秒几行。上一节把纸面记录抽成了规格字段,本节让这份规格第一次跑起来:一份 config 进,一条 digest 出。
把「最小」从形容词换成量
「最小内核」很容易被说成一个形容词——「尽量少」「只做必要的」。形容词没法验收,所以本节把它换成三个可以数的量:多少行、几个不变量、跑多久。一份规格进、一条摘要出的内核,只要能同时报出这三个数,就说明它是被设计成最小的,而不是碰巧写小了。
「最小」不是「功能最少」,而是在没有多余代码的前提下,规格仍能被完整消费。删掉一个不变量,内核就不再能证明守恒;删掉确定性 PRNG,摘要就不再可复现。最小是「再删一个就漏」的那条边界,不是「删得越多越好」。
最小内核的边界:五个模块,一个入口
内核是 core/src/ashdeck/ 下的五个 TypeScript 模块,手工拓扑序(先声明后使用),打包时用 Node 官方 stripTypeScriptTypes 擦类型:
| 模块 | 职责 | 运行时 |
|---|---|---|
types.ts | 规格与快照的类型形状 | 否(纯类型,打包时擦除) |
rng.ts | 自实现可播种 PRNG(Lehmer MINSTD) | 是 |
cards.ts | 把卡池展开成牌堆 | 是 |
combat.ts | 规格驱动的确定性状态机 + 不变量 | 是 |
digest.ts | 跨语言摘要(键按字母序) | 是 |
入口只有一个:runSpec(spec, strategy?) → snapshot,再由 digestSnap(snapshot) 压成一条字符串。最小内核不做下面这些事——它们都是原型的范围之外:不做渲染(画布是 W 层的事)、不做存档、不做关卡、不做经济、不做联网。任何一项进来,内核都不再是「最小」的。
P 层:把三条判据写成断言
判据必须能被断言,否则它只是注释。P 层测试把「最小」的三条判据、以及「一份 config 进一条 digest 出」本身,全部写成断言:
// file: core/test/gd_proto_02_minimal_kernel.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_02_minimal_kernel.test.ts'
//
// 2.1 最小内核的 P 层:把「最小」的三条判据写成断言——一份 config 进(gd_proto_02_minimal_kernel.json)、
// 一条 digest 出;模块数 5、不变量 4 条、运行时行数 131(types.ts 只声明类型,打包时被擦除,不计);
// 两次跑逐字一致。行数按「非空且非 // 注释」计,与 sim/gd_proto_02_minimal_kernel.py 同一口径。
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import type { Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SPEC: Spec = JSON.parse(
readFileSync(join(ROOT, "config", "gd_proto_02_minimal_kernel.json"), "utf8"),
);
const MODULES = ["types.ts", "rng.ts", "cards.ts", "combat.ts", "digest.ts"];
const TYPE_ONLY = new Set(["types.ts"]);
const MIN_DIGEST =
'{"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":0,' +
'"energy":0,"hand":[],"over":true,"played":6,"playerHp":28,"turn":3,"won":true}';
/** 非空且非 // 注释的行数——与 S 层同一口径。 */
function codeLines(rel: string): number {
return readFileSync(join(ROOT, "core", "src", "ashdeck", rel), "utf8")
.split("\n")
.filter((l) => l.trim() !== "" && !l.trim().startsWith("//")).length;
}
test("一份 config 进,一条 digest 出(最小内核跑得动)", () => {
assert.equal(digestSnap(runSpec(SPEC)), MIN_DIGEST);
});
test("最小内核满足全部 4 条不变量", () => {
const s = runSpec(SPEC);
assert.equal(invariantsHold(SPEC, s), true);
assert.equal(s.cardCount, 6);
assert.equal(s.damageConserved, true);
});
test("「最小」判据一:模块数 5,运行时行数 131(<= 140)", () => {
assert.equal(MODULES.length, 5);
const runtime = MODULES.filter((m) => !TYPE_ONLY.has(m))
.reduce((a, m) => a + codeLines(m), 0);
assert.equal(runtime, 131);
assert.ok(runtime <= 140, "运行时行数应 <= 140");
});
test("「最小」判据二:不变量恰 4 条", () => {
assert.equal(SPEC.invariants.length, 4);
assert.deepEqual(SPEC.invariants, [
"cardCount==deckSize", "damageConserved", "energyInRange", "blockNonNegative",
]);
});
test("两次独立运行逐字一致(确定性)", () => {
assert.equal(digestSnap(runSpec(SPEC)), digestSnap(runSpec(SPEC)));
});
test("若 S 层记录已存在,digest 与行数必须与记录一致", () => {
const p = join(ROOT, "out", "gd_proto_02_minimal_kernel.json");
if (!existsSync(p)) return; // 未跑过 S 层则跳过(存在才比)。
const rec = JSON.parse(readFileSync(p, "utf8"));
assert.equal(rec.digest, MIN_DIGEST);
assert.equal(rec.runtimeCodeLines, 131);
assert.equal(rec.moduleCount, 5);
});
P 实跑输出
✔ 一份 config 进,一条 digest 出(最小内核跑得动)
✔ 最小内核满足全部 4 条不变量
✔ 「最小」判据一:模块数 5,运行时行数 131(<= 140)
✔ 「最小」判据二:不变量恰 4 条
✔ 两次独立运行逐字一致(确定性)
✔ 若 S 层记录已存在,digest 与行数必须与记录一致
ℹ tests 6
ℹ pass 6
ℹ fail 0
六条全绿。第一条断言的是形状:一份 config 进,一条 digest 出,中间没有别的东西。第三条把「最小」变成可失败的断言——如果有人往内核里塞了渲染或存档,运行时行数会越界,测试当场红。第四条同理:不变量少了就不是最小内核,是残缺内核。
S 层:三条判据的实测量
P 层断言的是上界,S 层报的是实测值。同一口径数下来,五个模块、不变量条数、单局 wall time 各是多少:
#!/usr/bin/env python3
# file: sim/gd_proto_02_minimal_kernel.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_02_minimal_kernel.py
"""卷三 2.1 数字原型的最小内核(S 层):给「最小」一个可数的判据。
一份 config 进(config/gd_proto_02_minimal_kernel.json),一条 digest 出。本脚本数三件事,
把「最小」从形容词变成可数的量:
多少行 —— core/src/ashdeck/ 五个模块的「非空且非注释」行;其中 types.ts 只声明类型,
打包时被 stripTypeScriptTypes 擦除,故另记「运行时行数」(不含 types.ts)。
几个不变量 —— 规格 invariants 段条数(与 combat.ts 的 invariantsHold 逐条对应)。
跑多久 —— 单局仿真的 wall time(跑 RUNS 次取整数微秒;计时只在 S 层,不进断言)。
产物 out/gd_proto_02_minimal_kernel.json。全整数、无浮点、无 random。
"""
from __future__ import annotations
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
MODULES = ["types.ts", "rng.ts", "cards.ts", "combat.ts", "digest.ts"]
TYPE_ONLY = {"types.ts"} # 只声明类型,打包时被擦除
RUNS = 1000
def code_lines(path: str) -> tuple[int, int]:
"""返回(总行数, 非空且非注释行数)。空行与 // 注释不计。"""
text = open(path, encoding="utf-8").read()
total = text.count("\n")
code = sum(1 for l in text.split("\n") if l.strip() and not l.strip().startswith("//"))
return total, code
def main() -> int:
spec = ashlib.load_spec(os.path.join(ROOT, "config", "gd_proto_02_minimal_kernel.json"))
seq = list(spec["strategy"])
snap = ashlib.run_spec(spec, seq)
d = ashlib.digest(snap)
mods = []
total_lines = total_code = runtime_lines = 0
for m in MODULES:
tl, cl = code_lines(os.path.join(ROOT, "core", "src", "ashdeck", m))
mods.append({"module": m, "lines": tl, "codeLines": cl, "runtime": m not in TYPE_ONLY})
total_lines += tl
total_code += cl
if m not in TYPE_ONLY:
runtime_lines += cl
t0 = time.perf_counter_ns()
for _ in range(RUNS):
ashlib.run_spec(spec, seq)
t1 = time.perf_counter_ns()
total_ms = (t1 - t0) // 1_000_000
per_us = (t1 - t0) // (RUNS * 1000)
report = {
"id": "gd_proto_02_minimal_kernel",
"config": "config/gd_proto_02_minimal_kernel.json",
"deckSize": ashlib.deck_size(spec),
"digest": d,
"digestChars": len(d),
"modules": mods,
"moduleCount": len(MODULES),
"totalLines": total_lines,
"totalCodeLines": total_code,
"runtimeCodeLines": runtime_lines,
"invariantCount": len(spec["invariants"]),
"invariants": spec["invariants"],
"timing": {"runs": RUNS, "totalMs": total_ms, "perRunUs": per_us},
"criteria": {
"linesLe": 140, "invariantsEq": 4, "deckSizeLe": 8,
"note": "判据只约束「最小」的量级,不约束「好玩」——后者不可度量。",
},
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_02_minimal_kernel.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 2.1 最小内核的三条判据 ===")
for m in mods:
print(f" {m['module']:12s} lines={m['lines']:4d} codeLines={m['codeLines']:4d}"
f" runtime={'Y' if m['runtime'] else 'N'}")
print(f"模块数={len(MODULES)} 总行数={total_lines} 非空非注释行={total_code} 运行时行数={runtime_lines}")
print(f"不变量条数={len(spec['invariants'])} 牌堆={ashlib.deck_size(spec)} 张")
print(f"单局耗时={per_us} us({RUNS} 次共 {total_ms} ms)")
print(f"digest={d}")
print("wrote out/gd_proto_02_minimal_kernel.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
S 实跑输出
=== 2.1 最小内核的三条判据 ===
types.ts lines= 54 codeLines= 44 runtime=N
rng.ts lines= 26 codeLines= 17 runtime=Y
cards.ts lines= 17 codeLines= 11 runtime=Y
combat.ts lines= 98 codeLines= 85 runtime=Y
digest.ts lines= 26 codeLines= 18 runtime=Y
模块数=5 总行数=221 非空非注释行=175 运行时行数=131
不变量条数=4 牌堆=6 张
单局耗时=4 us(1000 次共 4 ms)
digest={"block":0,"cardCount":6,"damageConserved":true,"dealt":20,"enemyHp":0,"energy":0,"hand":[],"over":true,"played":6,"playerHp":28,"turn":3,"won":true}
wrote out/gd_proto_02_minimal_kernel.json
三个数落定:运行时行数 131(总行数 221 里,types.ts 的 44 行是纯类型,打包时被擦除,不计)、不变量 4 条、单局 4 微秒(1000 次共 4 毫秒)。单局耗时是 wall time,会随机器负载抖动,所以它只写进 S 层报告、不进断言——断言的是可复现的量(行数、条数、digest),不是时间。
三条判据的读法
| 判据 | 实测 | 上界 | 越界意味着 |
|---|---|---|---|
| 运行时行数 | 131 | ≤ 140 | 内核里混进了不属于最小内核的东西(渲染、存档、经济) |
| 不变量条数 | 4 | = 4 | 少了不变量,内核证明不了守恒;多了说明规格被塞进实现 |
| 单局耗时 | 4 µs | 不入断言 | 只作参考;时间不可复现,只有行数/条数/digest 可复现 |
判据的意义不在数字本身,而在它可以失败:三条判据都是断言,改动内核就会触发。一个「最小」如果不能被违反,它就不是判据,是口号。
最小内核的验收清单
| # | 检查 | 通过条件 |
|---|---|---|
| M1 | 一份 config 进 | runSpec(SPEC) 不依赖除规格外的任何输入 |
| M2 | 一条 digest 出 | 输出是可逐字比较的字符串,不是对象 |
| M3 | 不变量可断言 | invariantsHold 对最小 config 返回 true |
| M4 | 确定性 | 两次独立运行 digest 逐字相同 |
| M5 | 规模可数 | 运行时行数 ≤ 140、模块数 = 5、不变量 = 4 |
下一节把这台内核推进一个循环:改一个参数、跑一次内核、重跑一次原型,看一轮迭代到底花多少秒、多少行、digest 变几个字段——以及改坏了会怎样。
阅读导航:上一节:1.3 纸面到规格的转换 · 下一节:2.2 数字原型的迭代循环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。