上一节的验收报告是在 Python 侧跑出来的,其中 P 层只有一条检查:战斗回放指纹与 Python 侧逐位一致。那一条之所以放行,是因为它证明的正是整部设计案最要紧的性质——同一份配置,两个语言算出同一个数。这一节把这条链路展开:写一段配置驱动的原型回放,把结算、战斗、赛季推进串成一条确定性转录,用 node --test 钉成断言,再在原型层复核几条跨章不变量,最后收束整部设计案的交付。
回放不是演示,是可断言性的证明。一份设计案能交付的标志,不是它写得漂亮,而是别人拿到你的 config/ 能重新跑出同一串数。
配置驱动的回放:一串步骤,一条转录
回放的输入是一份 JSON:一串步骤,每步要么结算资源(settle)、要么打一场战斗(battle)、要么推进若干赛季(season)。回放器把每步的规范输出串成一条转录,任何一步的数字变了,转录哈希就变。这份配置是 D 层数据,回放器不写死任何数字。
{
"version": 1,
"note": "配置驱动的确定性回放脚本:一串步骤,每步要么结算资源(settle)、要么打一场战斗(battle)、要么推进若干赛季(season)。回放把每步的规范输出串成一条转录,任何一步的数字变了转录哈希就变。整条链路复用 core.ts 与 n08_season.ts,不新起结算逻辑。",
"seed": 20261012,
"steps": [
{ "kind": "settle", "levels": { "lumberyard": 5, "quarry": 5, "ironworks": 3, "farm": 6 }, "hours": 24 },
{ "kind": "battle", "a": "spearman", "aCount": 300, "d": "cavalry", "dCount": 300, "terrainPct": 0 },
{ "kind": "battle", "a": "cavalry", "aCount": 300, "d": "archer", "dCount": 300, "terrainPct": 0 },
{ "kind": "season", "scheme": "season_default", "seasons": 3 },
{ "kind": "season", "scheme": "keep_all", "seasons": 3 }
]
}
五步,正好各取一种能力:结算复用第 2 章的产出模型,两场战斗复用第 3 章的确定性解算(枪兵克骑兵、骑兵克弓兵),两次赛季推进复用第 8 章的压缩方案(默认档 vs 无重置)。
一个回放器,复用整条链路
回放器不新起任何结算逻辑,它把前面各章已经验证过的模块拼起来:core.ts 的 settle 与 resolveBattle、n08_season.ts 的 simulate 与 summarize、n03_round.ts 的 fnv1a。这正是本卷一贯的分工——一份配置,两个消费者。
// file: proto/src/n16_replay.ts
// tier: P
// verify: node --test proto/test/n16.test.ts
// 第 16 章原型回放:读 config/n16_replay.json 的一串步骤,逐条执行并串成确定性转录。
// 结算复用 core.ts(settle / resolveBattle),赛季推进复用 n08_season.ts(simulate / summarize),
// 指纹复用 n03_round.ts 的 fnv1a——整条链路是「配置驱动、可回放、可断言」的装配样例。
// 直接运行会打印转录:node proto/src/n16_replay.ts
import { loadConfig, settle, resolveBattle } from "./core.ts";
import { fnv1a } from "./n03_round.ts";
import { loadCompress, loadSeasonCfg, simulate, summarize } from "./n08_season.ts";
export interface ReplayStep {
kind: "settle" | "battle" | "season";
levels?: Record<string, number>; hours?: number;
a?: string; aCount?: number; d?: string; dCount?: number; terrainPct?: number;
scheme?: string; seasons?: number;
}
export interface ReplayConfig { version: number; seed: number; steps: ReplayStep[] }
export function loadReplay(): ReplayConfig {
return loadConfig<ReplayConfig>("n16_replay");
}
// 每步产出一行规范串;同一份配置每次得到完全相同的行。
export function stepLine(step: ReplayStep): string {
if (step.kind === "settle") {
const stock = settle(step.levels!, step.hours!);
const body = Object.keys(stock).sort().map((r) => `${r}=${stock[r]}`).join(",");
return `settle:${step.hours}h:${body}`;
}
if (step.kind === "battle") {
const r = resolveBattle(step.a!, step.aCount!, step.d!, step.dCount!, step.terrainPct ?? 0);
const log = r.log.map((l) => `${l.round}:${l.aHp}:${l.dHp}:${l.aDmg}:${l.dDmg}`).join("|");
return `battle:${step.a}x${step.aCount}>${step.d}x${step.dCount}:${r.winner}:${r.rounds}:${fnv1a(log)}`;
}
const cfg = loadCompress();
const sc = loadSeasonCfg();
const scheme = cfg.schemes.find((s) => s.id === step.scheme)!;
const rows = simulate({ ...cfg, seasons: step.seasons! }, sc, scheme);
const s = summarize(cfg, scheme, rows);
return `season:${step.scheme}:${s.finalIndexStart}:${s.convergeBp}:${s.healthy ? "H" : "N"}`;
}
export function replay(cfg: ReplayConfig = loadReplay()) {
const lines = cfg.steps.map(stepLine);
const transcript = lines.join("||");
return { version: cfg.version, steps: cfg.steps.length, lines, transcript, hash: fnv1a(transcript) };
}
export function replayHex(cfg?: ReplayConfig): string {
return replay(cfg).hash.toString(16).padStart(8, "0");
}
if (import.meta.main) {
const r = replay();
console.log(`replay 步骤 ${r.steps} · 转录哈希 0x${replayHex()}`);
for (const l of r.lines) console.log(` ${l}`);
}
本机实跑(node proto/src/n16_replay.ts):
replay 步骤 5 · 转录哈希 0x23f9ff06
settle:24h:grain=4776,iron=648,stone=2640,wood=3168
battle:spearmanx300>cavalryx300:a:3:1910851607
battle:cavalryx300>archerx300:a:1:1670761785
season:season_default:887621:208:N
season:keep_all:2224636:1382:N
两场战斗的指纹 1910851607 与 1670761785,正是第 3 章 3.3 确定性战斗解算
里 Python 侧算出的同两个数;赛季指数 887621 与 2224636,正是第 8 章 8.2 重置、继承与压缩
里默认档与无重置档的终局值。回放把跨了十几章的数字,重新在一个进程里串起来验了一遍。
把回放钉成断言:node –test
回放跑一次不算证明,跑两次同哈希才算。测试直接断言:同配置两次运行转录与哈希都相同、哈希固定为 0x23f9ff06、每步的规范行与各章产物对齐。
// file: proto/test/n16.test.ts
// tier: P
// verify: node --test proto/test/n16.test.ts
// 第 16 章原型回放断言:配置驱动的确定性回放,两次运行同哈希;每步的规范行与各章产物对齐
// (战斗指纹来自 n03_round_cross.json,赛季指数来自 n08_compress.json)。
import { test } from "node:test";
import assert from "node:assert/strict";
import { replay, replayHex, stepLine, loadReplay } from "../src/n16_replay.ts";
test("16.3 回放确定性:同配置两次运行同转录、同哈希", () => {
const a = replay();
const b = replay();
assert.equal(a.transcript, b.transcript);
assert.equal(a.hash, b.hash);
assert.equal(a.steps, 5);
});
test("16.3 转录哈希固定:0x23f9ff06(603586310)", () => {
assert.equal(replayHex(), "23f9ff06");
assert.equal(replay().hash, 603586310);
});
test("16.3 settle 步骤:24 小时结算产出与 bastion_core 一致", () => {
const line = stepLine({ kind: "settle", levels: { lumberyard: 5, quarry: 5, ironworks: 3, farm: 6 }, hours: 24 });
assert.equal(line, "settle:24h:grain=4776,iron=648,stone=2640,wood=3168");
});
test("16.3 battle 步骤:枪兵 300 胜骑兵 300,指纹与 Python 侧一致", () => {
const line = stepLine({ kind: "battle", a: "spearman", aCount: 300, d: "cavalry", dCount: 300, terrainPct: 0 });
assert.equal(line, "battle:spearmanx300>cavalryx300:a:3:1910851607");
});
test("16.3 season 步骤:默认档 3 赛季后的综合进度指数", () => {
const line = stepLine({ kind: "season", scheme: "season_default", seasons: 3 });
assert.equal(line, "season:season_default:887621:208:N");
const cfg = loadReplay();
assert.equal(cfg.steps.filter((s) => s.kind === "battle").length, 2);
});
验收抽检:在原型层复核跨章不变量
回放测试证明了「S 与 P 同数」,还差一步:在原型层把上一节的验收报告复核一遍。这份测试读 Python 侧产出的 n16_acceptance.json,断言 hard 失败为 0,并用原型层的 resolveBattle 复算战斗指纹,再抽检几条各章产物。
// file: proto/test/n16_acceptance.test.ts
// tier: P
// verify: node --test proto/test/n16_acceptance.test.ts
// P 层验收抽检:读 Python 侧产出的验收报告与各章产物,在原型层复核几条跨章不变量,
// 并用原型层的 resolveBattle 复算战斗回放指纹,断言与 S 层逐位一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { resolveBattle } from "../src/core.ts";
import { fnv1a } from "../src/n03_round.ts";
const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "out");
const out = (n: string) => JSON.parse(readFileSync(join(OUT, `${n}.json`), "utf-8"));
test("16.2 验收报告:所有 hard 检查通过", () => {
assert.equal(out("n16_acceptance").totals.hardFail, 0);
});
test("16.2 原型层复算:战斗回放指纹与 Python 侧逐位一致", () => {
const r = resolveBattle("spearman", 300, "cavalry", 300, 0);
const log = r.log.map((l) => `${l.round}:${l.aHp}:${l.dHp}:${l.aDmg}:${l.dDmg}`).join("|");
assert.equal(fnv1a(log), 1910851607);
});
test("16.2 产物复核:战力同型 1000‰、卡池概率归一、克制矩阵成立", () => {
assert.equal(out("n01_power").concordancePermille.sameUnit, 1000);
assert.equal(out("n06_gacha").allSumOk, true);
assert.equal(out("n04_counter_matrix").ok, true);
});
本机实跑(node --test proto/test/n16.test.ts proto/test/n16_acceptance.test.ts):
✔ 16.3 回放确定性:同配置两次运行同转录、同哈希
✔ 16.3 转录哈希固定:0x23f9ff06(603586310)
✔ 16.3 settle 步骤:24 小时结算产出与 bastion_core 一致
✔ 16.3 battle 步骤:枪兵 300 胜骑兵 300,指纹与 Python 侧一致
✔ 16.3 season 步骤:默认档 3 赛季后的综合进度指数
✔ 16.2 验收报告:所有 hard 检查通过
✔ 16.2 原型层复算:战斗回放指纹与 Python 侧逐位一致
✔ 16.2 产物复核:战力同型 1000‰、卡池概率归一、克制矩阵成立
ℹ tests 8 · pass 8 · fail 0
8 个测试全过。到这里,「同一份配置,两个语言算出同一个数」不再是口号,而是 8 条断言。
设计案交付:一份能重新跑出来的东西
回到本卷开头的承诺——bastion 是一套可跑、可调参、可断言的工程,不是一份纸面规格。第 16 章把这句话收尾成三个可核对的交付件:
| 交付件 | 文件 | 说明 |
|---|---|---|
| 装配索引 | out/n16_manifest.json | 两卷 30 章、45 个产物的可追溯清单 |
| 验收报告 | out/n16_acceptance.json | 39 项不变量检查,S 24 / B 14 / P 1 |
| 余量基准 | out/n16_acceptance_bench.json | 阈值检查余量,标出唯一贴线项 |
| 原型回放 | proto/src/n16_replay.ts | 5 步转录,哈希 0x23f9ff06 |
| 回放断言 | proto/test/n16*.test.ts | 8 条测试,全过 |
一份能交付的 SLG 设计案长这样:改一个数字,config/ 变,S 与 P 两侧的数一起变,验收报告重新告诉你过不过、稳不稳。它不依赖任何人的记忆,也不依赖某个人的电脑——谁拿到这份 config/,谁就能重新跑出同一串数。
至于设计案里那些「数字之外」的东西——品类判断、玩家心理、活动创意——那是卷一《产品架构与经济系统》和本卷前 15 章正文负责的部分,它们同样有配置、有产物、有基准。这套工程的价值不在于替你做决策,而在于让你敢做决策:任何一次改动,都能在交付前被算一遍、被验一遍。
小结
- 原型回放是配置驱动的:一串步骤(结算 / 战斗 / 赛季)串成一条确定性转录,任何数字变了哈希就变。
- 回放器复用整条链路(
core.ts/n08_season.ts/n03_round.ts),不新起结算逻辑;本机实跑转录哈希0x23f9ff06。 node --test把回放钉成断言:8 个测试全过,涵盖回放确定性、哈希固定、每步与各章产物对齐、验收报告 hard 失败为 0。- 设计案交付 = 装配索引 + 验收报告 + 余量基准 + 原型回放 + 回放断言;核心性质是「同一份配置,两个语言算出同一个数」。
- 至此两卷收束。回目录 可按章查阅;本卷全部结论都可在这套工程里重新算出来。
阅读导航:上一节:16.2 全链路仿真验收 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。