2.1 数字原型的最小内核

数字原型的最小内核不是「写得少」,而是「可数的少」:一份 config 进、一条 digest 出,5 个模块、4 条不变量、131 行运行时逻辑、单局 4 微秒。本节把「最小」换成三条可数判据——多少行、几个不变量、跑多久,并用 P 层断言与 S 层实测把它钉死。

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 数字原型的迭代循环 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡