3.1 给了单次伤害,3.2 给了随机分布。但玩家打的不是「一次伤害」,而是一场战斗——两支部队从满血打到一方崩溃。这一节把前两节的东西装进一个回合循环,并且回答一个工程问题:同一场仗,能不能回放出一模一样的结果。
边界先划清楚。本节复用 bastion 已经实现的 resolveBattle(proto/src/core.ts 与 sim/bastion_core.py 逐位一致),不另起一套结算。兵种克制矩阵——枪克骑、骑克弓、弓克枪这张三角的系统化展开——留到第 4 章;本节里 resolveBattle 内部已经会应用克制倍率,但本节只把它当成「公式里的一个乘数」,不展开成矩阵。站内那篇伤害公式与平衡工具
讲的是单机动作游戏里「一次命中怎么算」,那是即时制口径;本节是 SLG 的回合制口径——伤害按整堆部队结算,胜负由回合数决定。
回合结算:三条规则
resolveBattle 的循环很短,规则只有三条,但每条都影响结果:
- 每回合双方同时出手。不是「A 先打完 B 才动」,而是双方都基于回合开始时的存活数计算伤害,然后同时扣除。这保证了结算顺序不影响结果——先算 A 还是先算 B 都一样。
- 伤害按存活单位数放大。一支 300 人的部队,每回合总伤害 = 存活数 × 单位伤害。部队被打残后,存活数下降,总伤害随之下降——这是「雪崩效应」的来源。
- 血量池先见底的一方输。胜负判据是
hpPool <= 0,而不是「谁先归零」。血量池可以是负的(最后一击溢出),胜负只看谁先跌破 0。
「存活数」本身也值得一说:它用整数上取整 ceil(hpPool / hp) 算,而不是浮点除法——这样最后一丝血对应「还有一个单位活着」,跨语言不会因为浮点舍入差一个人。
一次交锋的完整日志
拿枪兵 300 打骑兵 300 为例(spearman 克制 cavalry,倍率 150%)。枪兵单点伤害 (180×150 − 150×50)/100 = 195,骑兵单点伤害 (260×100 − 220×50)/100 = 150。回合推进如下:
| 回合 | 枪兵存活 | 骑兵存活 | 枪兵伤害 | 骑兵伤害 | 枪兵血量 | 骑兵血量 |
|---|---|---|---|---|---|---|
| 1 | 300 | 300 | 58500 | 45000 | 75000 | 49500 |
| 2 | 188 | 138 | 36660 | 20700 | 54300 | 12840 |
| 3 | 136 | 36 | 26520 | 5400 | 48900 | −13680 |
第 1 回合双方都是 300 人满编,伤害最高;此后每回合存活数都在掉,伤害逐回合衰减;第 3 回合骑兵血量跌破 0,判定枪兵胜,残存 123 人(ceil(48900/400))。整场 3 回合收敛。下面这段是 P 层原型,用 core.ts 的 resolveBattle 跑结算,并把日志规范成一个可哈希的字符串。
// file: proto/src/n03_round.ts
// tier: P
// verify: node --test proto/test/n03.test.ts
// 第 3 章确定性原型:自实现确定性 PRNG(mulberry32)+ 回合结算回放。
// 结算复用 core.ts 的 resolveBattle / damagePerUnit / battleReport;兵种克制矩阵见第 4 章。
import { resolveBattle, battleReport } from "./core.ts";
import type { BattleResult } from "./core.ts";
// 与 sim/n03_random.py 同算法同种子,随机序列逐位一致;不吃运行时随机数实现。
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6D2B79F5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t = (t ^ (t + Math.imul(t ^ (t >>> 7), t | 61))) >>> 0;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function firstRolls(seed: number, n: number): string[] {
const r = mulberry32(seed);
return Array.from({ length: n }, () => r().toFixed(12));
}
// FNV-1a 32 位:给回放日志算一个可断言、可跨语言对齐的指纹。
export function fnv1a(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
// 回放日志的规范串:每回合一行,字段用冒号分隔,回合之间用竖线。
export function canonicalLog(r: BattleResult): string {
return r.log.map((l) => `${l.round}:${l.aHp}:${l.dHp}:${l.aDmg}:${l.dDmg}`).join("|");
}
export function battleReplay(aUnit: string, aCount: number, dUnit: string, dCount: number, terrainPct = 0) {
const r = resolveBattle(aUnit, aCount, dUnit, dCount, terrainPct);
const log = canonicalLog(r);
const hash = fnv1a(log);
return {
winner: r.winner,
rounds: r.rounds,
aSurvivors: r.aSurvivors,
dSurvivors: r.dSurvivors,
aHp: r.aHp,
dHp: r.dHp,
logHash: hash,
logHex: hash.toString(16).padStart(8, "0"),
log,
};
}
// 门禁基准战斗:与 sim/bastion_core.py 的 battle_report() 同输入同输出。
export function gateBattle() {
const b = battleReport().battle;
return { winner: b.winner, rounds: b.rounds, aSurvivors: b.aSurvivors, aHp: b.aHp, dHp: b.dHp };
}
canonicalLog 产出的规范串是 1:75000:49500:58500:45000|2:54300:12840:36660:20700|3:48900:-13680:26520:5400——每回合「回合号:枪兵血量:骑兵血量:枪兵伤害:骑兵伤害」,回合之间用竖线连。这个字符串就是回放的唯一真源。
回放可复现:给日志算指纹
「确定性」光靠嘴说没用,得能断言。做法是给规范日志算一个 FNV-1a 32 位哈希:日志里任何一个字节变了,哈希就变。上例的哈希是 1910851607,十六进制 0x71e54817。下面这段是 P 层测试,断言多回合收敛、哈希稳定、地形改变结果、以及门禁基准战斗。
// file: proto/test/n03.test.ts
// tier: P
// verify: node --test proto/test/n03.test.ts
// 第 3 章断言:确定性回合结算的多回合收敛、回放哈希稳定、与 Python 侧逐位一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import { battleReplay, fnv1a, gateBattle, firstRolls } from "../src/n03_round.ts";
test("3.2 确定性 PRNG:前 5 个随机数与 Python 逐位一致", () => {
assert.deepEqual(firstRolls(20261012, 5), [
"0.341123865684", "0.017803263851", "0.364578908775", "0.008977568476", "0.681191388052",
]);
});
test("3.3 多回合结算:枪兵 300 对骑兵 300,3 回合,胜者 a", () => {
const r = battleReplay("spearman", 300, "cavalry", 300, 0);
assert.equal(r.winner, "a");
assert.equal(r.rounds, 3);
assert.equal(r.aSurvivors, 123);
assert.equal(r.dSurvivors, 0);
assert.equal(r.aHp, 48900);
assert.equal(r.dHp, 0);
});
test("3.3 回放哈希稳定:同输入两次运行同哈希", () => {
const r1 = battleReplay("spearman", 300, "cavalry", 300, 0);
const r2 = battleReplay("spearman", 300, "cavalry", 300, 0);
assert.equal(r1.logHash, r2.logHash);
assert.equal(r1.logHash, 1910851607);
assert.equal(r1.logHex, "71e54817");
});
test("3.3 地形加成改变结算:terrain=15 时提前到 2 回合结束", () => {
const r = battleReplay("spearman", 300, "cavalry", 300, 15);
assert.equal(r.winner, "a");
assert.equal(r.rounds, 2);
assert.equal(r.aSurvivors, 145);
assert.equal(r.logHash, 1742504974);
});
test("3.3 与 Python 侧逐位一致:core 门禁基准 battle(骑兵对弓兵)", () => {
const b = gateBattle();
assert.equal(b.winner, "a");
assert.equal(b.rounds, 1);
assert.equal(b.aSurvivors, 171);
assert.equal(b.aHp, 61500);
assert.equal(b.dHp, 0);
});
test("3.3 fnv1a 基础向量:空串 = 0x811c9dc5", () => {
assert.equal(fnv1a(""), 0x811c9dc5);
assert.equal(fnv1a(""), 2166136261);
});
用 node --test proto/test/n03.test.ts 跑它(注意:node --test <目录> 不发现 .ts,必须显式给文件或 glob),6 条全过:
✔ 3.2 确定性 PRNG:前 5 个随机数与 Python 逐位一致
✔ 3.3 多回合结算:枪兵 300 对骑兵 300,3 回合,胜者 a
✔ 3.3 回放哈希稳定:同输入两次运行同哈希
✔ 3.3 地形加成改变结算:terrain=15 时提前到 2 回合结束
✔ 3.3 与 Python 侧逐位一致:core 门禁基准 battle(骑兵对弓兵)
✔ 3.3 fnv1a 基础向量:空串 = 0x811c9dc5
ℹ tests 6 ℹ pass 6 ℹ fail 0
注意第 4 条测试:地形加成 15% 作用在减法之后,枪兵单点伤害从 195 抬到 195×115/100 = 224(整除),于是战斗提前一个回合在 2 回合结束,残存 145 人。同一套规则、只改一个地形参数,日志哈希就从 0x71e54817 变成 0x67dc840e——这正是「日志变了哈希就变」的意义。
与 Python 逐位一致
哈希能不能跨语言对齐,才是「一份配置、两处消费」的真正考验。下面这段是 S 层对照脚本,用 Python 侧的 resolve_battle 重算同一批战斗,产出同样格式的规范日志与 FNV-1a 指纹。
# file: sim/n03_round_cross.py
# tier: S
# verify: python3 sim/n03_round_cross.py
"""确定性回合结算的 Python 侧对照:复用 bastion_core.resolve_battle,产出与 proto 侧逐位一致的日志与指纹。
产出:out/n03_round_cross.json
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import bastion_core as core # noqa: E402
OUT = os.path.join(HERE, "..", "out")
def fnv1a(s: str) -> int:
h = 0x811c9dc5
for ch in s.encode("ascii"):
h = ((h ^ ch) * 0x01000193) & 0xFFFFFFFF
return h
def canonical_log(r: dict) -> str:
return "|".join(f"{l['round']}:{l['aHp']}:{l['dHp']}:{l['aDmg']}:{l['dDmg']}" for l in r["log"])
def replay(a_unit, a_count, d_unit, d_count, terrain_pct=0) -> dict:
r = core.resolve_battle(a_unit, a_count, d_unit, d_count, terrain_pct)
log = canonical_log(r)
h = fnv1a(log)
return {
"winner": r["winner"], "rounds": r["rounds"],
"aSurvivors": r["aSurvivors"], "dSurvivors": r["dSurvivors"],
"aHp": r["aHp"], "dHp": r["dHp"],
"logHash": h, "logHex": format(h, "08x"), "log": log,
}
def main() -> int:
report = {
"spearman300_vs_cavalry300": replay("spearman", 300, "cavalry", 300, 0),
"spearman300_vs_cavalry300_terrain15": replay("spearman", 300, "cavalry", 300, 15),
"gate_cavalry300_vs_archer300": replay("cavalry", 300, "archer", 300, 0),
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "n03_round_cross.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑出来的哈希与 TypeScript 侧逐位相同——枪兵对骑兵是 0x71e54817,地形 15% 那场是 0x67dc840e,门禁基准(骑兵对弓兵)是 0x6395cd39:
{"spearman300_vs_cavalry300": {"winner": "a", "rounds": 3, "aSurvivors": 123, "aHp": 48900,
"logHash": 1910851607, "logHex": "71e54817", ...},
"spearman300_vs_cavalry300_terrain15": {"rounds": 2, "aSurvivors": 145, "logHex": "67dc840e", ...},
"gate_cavalry300_vs_archer300": {"rounds": 1, "aSurvivors": 171, "logHex": "6395cd39", ...}}
哈希一致,意味着同一场战斗,在 Python 与 TypeScript 里逐回合、逐血量、逐伤害都一样。这就是确定性战斗结算的全部价值:战斗结果不依赖语言、不依赖机器、不依赖运行时——给定配置和种子,它就是一个纯函数。
三条可以写进回归的不变量
把这一节收成三条可断言的不变量,任何一条被打破都说明结算被改坏了:
- 收敛性:战斗必须在
maxRounds(本卷 60)内结束,否则winner会是draw。上例 3 回合收敛,远低于上限。 - 对称性:把攻守双方对调,胜者应该对调、残存数应该一致。枪兵 300 打骑兵 300 枪兵胜,反过来骑兵 300 打枪兵 300 就是骑兵胜——数值上完全镜像。
- 可复现性:同一份输入跑两次,
logHash必须相同;跨语言跑,logHash也必须相同。上例已经验证。
至此,第 3 章的「一次交锋怎么算」就闭环了:3.1 给确定伤害,3.2 给随机分布,3.3 给回合结算与回放。但这里始终只处理了两个兵种。真实的 SLG 战场上同时有枪、骑、弓,谁克谁、混编阵容怎么加成、强度怎么评估——这是第 4 章要系统展开的兵种与阵容数值。
阅读导航:上一节:3.2 命中、暴击与随机性 · 下一节:4.1 兵种三角与克制矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。