16.3 原型回放与设计案交付

验收在 Python 侧跑完了,交付前还差最后一步:亲手证明同一份配置在 S 与 P 两侧算出同一串数。本篇给出一段配置驱动的原型回放,把结算、战斗、赛季推进串成一条确定性转录,用 node --test 钉成断言,再在原型层复核跨章不变量,最后收束整部设计案的交付。

上一节的验收报告是在 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.json39 项不变量检查,S 24 / B 14 / P 1
余量基准out/n16_acceptance_bench.json阈值检查余量,标出唯一贴线项
原型回放proto/src/n16_replay.ts5 步转录,哈希 0x23f9ff06
回放断言proto/test/n16*.test.ts8 条测试,全过

一份能交付的 SLG 设计案长这样:改一个数字,config/ 变,S 与 P 两侧的数一起变,验收报告重新告诉你过不过、稳不稳。它不依赖任何人的记忆,也不依赖某个人的电脑——谁拿到这份 config/,谁就能重新跑出同一串数。

至于设计案里那些「数字之外」的东西——品类判断、玩家心理、活动创意——那是卷一《产品架构与经济系统》和本卷前 15 章正文负责的部分,它们同样有配置、有产物、有基准。这套工程的价值不在于替你做决策,而在于让你敢做决策:任何一次改动,都能在交付前被算一遍、被验一遍。

小结

  • 原型回放是配置驱动的:一串步骤(结算 / 战斗 / 赛季)串成一条确定性转录,任何数字变了哈希就变。
  • 回放器复用整条链路(core.ts / n08_season.ts / n03_round.ts),不新起结算逻辑;本机实跑转录哈希 0x23f9ff06。
  • node --test 把回放钉成断言:8 个测试全过,涵盖回放确定性、哈希固定、每步与各章产物对齐、验收报告 hard 失败为 0。
  • 设计案交付 = 装配索引 + 验收报告 + 余量基准 + 原型回放 + 回放断言;核心性质是「同一份配置,两个语言算出同一个数」。
  • 至此两卷收束。回目录 可按章查阅;本卷全部结论都可在这套工程里重新算出来。

阅读导航:上一节:16.2 全链路仿真验收 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引