9.1 自走棋的机制解剖
《数值平衡与商业化运营》 那套管的是 SLG 的赛季膨胀、属性战力与商业化节奏,通胀曲线是围绕长线付费设计的;本章是自走棋经济,问的是完全不同的一件事:金币的利息与刷新能不能写成一条可算的判据,阵容的涌现度能不能量出来。前八章已经解剖过卡牌构筑、塔防、三消、平台跳跃四个品类,本节是第五个变体,也是唯一一个「经济本身就是玩法主轴」的品类。
自走棋的规则看着杂,拆开其实只有六个字段。本节新建 core/genre/autobattler.ts,只 import 共享底座的 rng.ts 与 digest.ts,规格骨架(id / naming / state / rules / params / invariant)与 config/play_spec.json 同构,只覆盖 rules / params 两段。本章出现的所有胜率、战力、涌现度都是代理指标(proxy),只做相对比较,禁绝对化——它们说明「在这套规格与种子池下 A 比 B 高」,不等于「A 绝对更好」。
机制不是名词,是字段
「商店刷新」「利息」这些词在策划案里到处都是,但词本身不可断言。可断言的形态是字段:一个字段 = 一段确定性的代码 + 一条可检验的性质。自走棋恰好六个:
| 机制字段 | 内核位置 | 语义 | 可断言的性质 | 本节实测 |
|---|---|---|---|---|
| 商店刷新 | drawShop() | 按剩余池量加权抽 shopSize 格 | 池量守恒;同 seed 同序 | 刷新 3 次 |
| 购买 | apply() 的 buy 分支 | 扣金币、格子清空、棋子入待命 | 购买数 +1;金币 -= 价格 | 购买 19 次 |
| 合成升星 | mergeAll() | 同 id 同星满 3 个合一个高一星 | 份数守恒(1 / 3 / 9 份) | 合成 5 次 |
| 上阵 | deploy() | 回合末顶上待命最强,直到占满容量 | 上阵数 ≤ 容量 | 上阵 6 / 6 位 |
| 利息 | 回合末结算 | min(gold // 10, cap) 入账 | 利息只依赖回合末金币 | 利息 3 金 |
| 连胜连败 | streak 变量 | 胜负串表 `streakGold[ | streak | ]` |
这张表就是「机制解剖」的全部产出:六个字段,六行可断言的性质。后面两节都在这张表上做文章——9.2 量利息与刷新的边际收益,9.3 数阵容的涌现度。
合成会消灭棋子,所以守恒要按「份数」计
最容易被写错的一格是守恒。直觉上「池量守恒」应该是「池 + 商店 + 待命 + 上阵 = 单位数 × 池容量」,但合成会把 3 个 1 星变成 1 个 2 星,棋子个数少了 2 个。第一次跑就撞上了:poolTotal 是 46 而不是 48,守恒断言直接红。
正确的口径是份数:1 星占 1 份、2 星占 3 份、3 星占 9 份(3^(star-1))。合成只是把 3 份打包成 1 个棋子,份数不变。改成按份数计之后,三种跑法(空策略、截断策略、完整策略)的份数恒为 4 个单位 × 12 份 = 48,断言全绿。这条教训和卷一「机制数值整数化」是同一件事:守恒量要选在不变的那个量纲上。
W 层:把六个字段画出来
web/build_proto09.mjs 复用 web/pack.mjs 的 bundle(),把品类内核与两个共享底座模块按手工拓扑序擦类型后内联进单文件 out/proto09.html,再把六个字段画到 canvas 上:商店 5 格(空格显示「空」)、待命、上阵、上阵战力对敌方战力的双条、金币条、血量与连胜连败。页面暴露 globalThis.__ashdeck,供 Playwright 注入策略并取回 digest。
// file: web/build_proto09.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto09.mjs
//
// 卷三 9.1 自走棋的机制解剖(W 层):把品类内核 core/genre/autobattler.ts 与共享底座
// rng.ts / digest.ts 按手工拓扑序擦类型后,连同 config/gd_proto_09_ab.json 一起内联进
// **一个** HTML(out/proto09.html)。页面把六个机制字段画出来——商店 5 格、待命、上阵、
// 金币、血量、连胜连败——并暴露 globalThis.__ashdeck:
// run(ops?) -> 机制摘要(与 P 层测试冻结的那条逐字相同);policy(name) -> 策略模式摘要;
// snapshot() -> 最近快照;proxy() -> 「每回合都能赢」的阵容数(代理指标,禁绝对化);
// canvasDrawn。擦类型复用 web/pack.mjs 的 bundle(),本章不重写任何擦除逻辑。
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { abRun, abRunPolicy, abDigest, abEmergence, abPiecePower } from "../core/genre/autobattler.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck
// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
"core/src/ashdeck/rng.ts",
"core/src/ashdeck/digest.ts",
"core/genre/autobattler.ts",
];
const spec = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_09_ab.json"), "utf8"));
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto09.html");
const script = core + `
const SPEC = ` + inlineConfig(spec) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
const COLOR = { blade: "#f472b6", ward: "#38bdf8", arcane: "#a3e635" };
let snap = null;
// 代理指标:本规格下「每一回合都能赢」的可达阵容数。它只做相对比较,禁绝对化:
// 数值大只说明阵容池宽,不等于更好玩;它必须与退化检测同读(<= 1 即退化解)。
function proxy() { return abEmergence(SPEC, 6, 200000).winAll; }
function chip(x, y, w, h, label, trait, on) {
ctx.fillStyle = on ? (COLOR[trait] || "#94a3b8") : "#334155";
ctx.fillRect(x, y, w, h);
ctx.fillStyle = on ? "#0f172a" : "#64748b";
ctx.font = "10px system-ui, sans-serif";
ctx.fillText(label.slice(0, 9), x + 3, y + h - 4);
}
function bar(x, y, w, h, num, den, color) {
ctx.fillStyle = "#334155";
ctx.fillRect(x, y, w, h);
ctx.fillStyle = color;
ctx.fillRect(x, y, Math.max(0, Math.floor((w * num) / Math.max(1, den))), h);
}
// 六个机制字段的画法:商店 5 格、待命、上阵、金币条、血量条、连胜连败。
function render(s) {
const W = cv.width, H = cv.height, r = SPEC.rules;
ctx.fillStyle = "#111827";
ctx.fillRect(0, 0, W, H);
ctx.font = "12px system-ui, sans-serif";
const cw = Math.floor((W - 32 - 4 * 4) / 5);
ctx.fillStyle = "#94a3b8";
ctx.fillText("商店(刷新 1 次 = " + r.refreshCost + " 金,池量守恒 " + s.poolTotal + "/48)", 16, 20);
for (let i = 0; i < r.shopSize; i += 1) {
const id = s.shop[i] || "";
const u = r.units.find((x) => x.id === id);
chip(16 + i * (cw + 4), 28, cw, 26, id === "" ? "空" : id.slice(0, 8) + " " + u.cost + "金",
u ? u.trait : "", id !== "");
}
ctx.fillStyle = "#94a3b8";
ctx.fillText("待命 " + s.bench.length + " 个 · 上阵 " + s.board.length + " / " +
(SPEC.params.boardBase + SPEC.params.boardPerLevel * (s.level - 1)) + " 位(等级 " + s.level + ")", 16, 82);
const bw = Math.floor((W - 32 - 9 * 4) / 10);
for (let i = 0; i < s.bench.length && i < 10; i += 1) {
const p = s.bench[i].split(":");
const u = r.units.find((x) => x.id === p[0]);
chip(16 + i * (bw + 4), 90, bw, 26, p[0].slice(0, 6) + " " + p[1] + "星", u.trait, false);
}
for (let i = 0; i < s.board.length && i < 10; i += 1) {
const p = s.board[i].split(":");
const u = r.units.find((x) => x.id === p[0]);
chip(16 + i * (bw + 4), 124, bw, 26, p[0].slice(0, 6) + " " + p[1] + "星", u.trait, true);
}
ctx.fillStyle = "#e2e8f0";
ctx.fillText("上阵战力 " + s.boardPower + " vs 敌方 " + s.enemyPower +
"(羁绊 " + s.enemyTrait + ")", 16, 178);
bar(16, 188, W - 32, 14, s.boardPower, Math.max(s.boardPower, s.enemyPower), "#34d399");
bar(16, 206, W - 32, 14, s.enemyPower, Math.max(s.boardPower, s.enemyPower), "#f87171");
ctx.fillStyle = "#94a3b8";
ctx.fillText("金币 " + s.gold + "(利息 " + s.interest + " + 连胜 " + s.streakBonus + ")", 16, 242);
bar(16, 252, W - 32, 14, s.gold, 60, "#fbbf24");
ctx.fillText("血量 " + s.hp + " / 40 连胜连败 " + s.streak + " 合成 " + s.merges +
" 次 购买 " + s.buys + " 次 刷新 " + s.refreshes + " 次", 16, 292);
ctx.fillText("回合 " + s.round + " / " + SPEC.params.rounds + " " +
(s.won ? "通关" : "失败") + " (第 12 回合我方最弱棋子战力 " +
(s.board.length ? Math.min.apply(null, s.board.map((b) => {
const p = b.split(":"); return abPiecePower(SPEC, { id: p[0], star: Number(p[1]) }, s.enemyTrait);
})) : 0) + ")", 16, 316);
document.getElementById("out").textContent = abDigest(s);
document.getElementById("proxy").textContent = String(proxy());
}
globalThis.__ashdeck = {
spec: SPEC,
run(ops) {
const sp = JSON.parse(JSON.stringify(SPEC));
snap = abRun(sp, Array.isArray(ops) ? ops : undefined);
render(snap);
return abDigest(snap);
},
policy(name) {
const parts = String(name).split("/");
const sp = JSON.parse(JSON.stringify(SPEC));
snap = abRunPolicy(sp, parts[0], parts[1]);
render(snap);
return abDigest(snap);
},
snapshot() { return snap; },
proxy,
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
globalThis.__ashdeck.canvasDrawn = true;
}));
`;
const body = `<h1>《灰烬牌库》· 自走棋变体(第 9 章)</h1>
<div class="sub">同一份规格骨架,只覆盖 rules / params 两段:商店、购买、合成、上阵、利息、连胜连败</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">机制摘要 digest(与 P 层测试冻结的那条逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">代理指标 · 「每回合都能赢」的阵容数(只做相对比较,禁绝对化)</div><div class="big" id="proxy">—</div></div>`;
const html = page({
title: "《灰烬牌库》· 第 9.1 节自走棋的机制解剖",
subtitle: "品类内核 + 共享底座:读同一份骨架,产出同一条摘要",
body,
script,
});
const bytes = writePage(OUT, html);
const nodeDigest = abDigest(abRun(spec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "gd_proto_09_build.json"),
JSON.stringify({
spec: spec.id, modules: MODULES.length, kernelChars: core.length,
page: "out/proto09.html", pageBytes: bytes,
winAll: abEmergence(spec, 6, 200000).winAll, nodeDigest,
}, null, 2) + "\n");
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + " " + bytes + " B");
console.log("NODE_DIGEST=" + nodeDigest);
构建与真玩分两步跑——先构建、再断言,只跑断言等于在验上一版页面:
# snippet: 构建 + 无头真玩
NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto09.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto09.html --shot out/shots/proto09.png
modules=3 kernelChars=13185
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto09.html 22998 B
NODE_DIGEST={"block":3,"cardCount":48,"damageConserved":true,"dealt":149,"enemyHp":147,"energy":47,"hand":["arcane_adept:2","ward_sentinel:2","blade_grunt:2","blade_champion:2","blade_champion:1","blade_champion:1"],"over":true,"played":19,"playerHp":28,"turn":12,"won":true}
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(100440 B)
PAGE_DIGEST={"block":3,"cardCount":48,"damageConserved":true,"dealt":149,"enemyHp":147,"energy":47,"hand":["arcane_adept:2","ward_sentinel:2","blade_grunt:2","blade_champion:2","blade_champion:1","blade_champion:1"],"over":true,"played":19,"playerHp":28,"turn":12,"won":true}
OK W 层通过
三条通用断言全绿。画布上能直接读出机制字段表里那几行:商店 5 格里 2 格是「空」(池里这一档被买空了)、待命 3 个、上阵 6 / 6 位、上阵战力 149 对敌方 147、金币 47、血量 28 / 40、合成 5 次。canvasDrawn 被推迟到两次 requestAnimationFrame 之后才置真,靠这一手绕开冷启动首个 page 读 canvas 恒为空白的老坑;PAGE_DIGEST 与构建期 Node 侧算出的 NODE_DIGEST 一字不差——页面与内核本来就是同一段源码的两个宿主。
P 层:逐字段断言
W 层把字段画出来,P 层把字段断言掉。核心手法是变异测试:改一格规则,最终摘要必须变;改不动,那一格就是文档装饰而不是机制。16 条测试全部对新建的品类内核下断言,期望值全部来自本机实跑:
// snippet: core/test/gd_proto_09_1_anatomy.test.ts 的字段变异与对账(节选)
const BASE_DIGEST =
'{"block":3,"cardCount":48,"damageConserved":true,"dealt":149,"enemyHp":147,"energy":47,' +
'"hand":["arcane_adept:2","ward_sentinel:2","blade_grunt:2","blade_champion:2",' +
'"blade_champion:1","blade_champion:1"],"over":true,"played":19,"playerHp":28,"turn":12,' +
'"won":true}';
const mutate = (fn: (s: any) => void): boolean => {
const s = JSON.parse(JSON.stringify(BASE));
fn(s);
return abDigest(abRun(s)) !== BASE_DIGEST;
};
test("商店刷新是机制:shopSize 5 → 3、poolPerUnit 12 → 6 都改变结果", () => {
assert.equal(mutate((s) => { s.rules.shopSize = 3; }), true);
assert.equal(mutate((s) => { s.rules.poolPerUnit = 6; }), true);
});
test("合成升星是机制:mergeCount 3 → 2、升星倍率都改变结果", () => {
assert.equal(mutate((s) => { s.rules.mergeCount = 2; }), true);
assert.equal(mutate((s) => { s.rules.starPermille[1] = 2500; }), true);
});
test("利息是机制:interestStep 与 interestCap 都改变结果", () => {
assert.equal(mutate((s) => { s.rules.interestStep = 5; }), true);
assert.equal(mutate((s) => { s.rules.interestCap = 0; }), true);
});
test("份数口径:1 星 1 份、2 星 3 份、3 星 9 份,合成不消灭份数", () => {
assert.deepEqual([1, 2, 3].map(abCopies), [1, 3, 9]);
const s = abRun(BASE);
assert.equal(s.merges, 5); // 合成发生 5 次,份数仍是 48
assert.equal(s.poolTotal, 48);
});
test("buysPerRound 是策略旋钮不是机制字段:op 模式下改它摘要不变", () => {
assert.equal(mutate((s) => { s.rules.buysPerRound = 1; }), false);
});
shop-refresh drawShop(): rngNext 加权抽 shopSize 个 ok
buy buy idx -> gold -= cost, shop[idx]="" ok
merge 3 个同 id 同星 -> 1 个高一星 ok
deploy 回合末顶上待命最强,直到占满容量 ok
interest min(gold // 10, cap) 逐回合入账 ok
streak 连胜连败表 streakGold[|streak|] ok
✔ 基线摘要冻结:与实跑逐字一致
✔ 确定性:同规格同种子两次运行摘要相同
✔ 池量守恒:空策略、截断策略、完整策略三种跑法都守恒(份数 48)
✔ 份数口径:1 星 1 份、2 星 3 份、3 星 9 份,合成不消灭份数
✔ 商店刷新是机制:shopSize 5 → 3、poolPerUnit 12 → 6 都改变结果
✔ 购买是机制:刷新成本与单位价格都改变结果
✔ 合成升星是机制:mergeCount 3 → 2、升星倍率都改变结果
✔ 上阵是机制:容量 boardBase 3 → 2、maxLevel 4 → 2 都改变结果
✔ 利息是机制:interestStep 与 interestCap 都改变结果
✔ 连胜连败是机制:把 streakGold 清零会改变结果
✔ 克制关系是机制:改 traitMatrix 一格或轮换敌方羁绊都改变结果
✔ 单位战力公式:基础战力 = (攻击 + 生命) × 星级千分比,两级整数地板除
✔ buysPerRound 是策略旋钮不是机制字段:op 模式下改它摘要不变
✔ 品类状态投影回共享的 12 键摘要契约
✔ 规格骨架与 play_spec.json 同构:六段一个不缺
✔ 六字段对账:机制字段表逐行给出可断言证据
ℹ tests 16
ℹ pass 16
ℹ fail 0
十六条第全绿。有一条特意写成反例:buysPerRound(每回合最多买几次)改掉之后摘要不变——因为它是策略模式下的旋钮,op 模式根本不读它。把「策略旋钮」和「机制字段」分开,是这张字段表能自洽的前提;不分开,字段表就会混进一堆调参项,看起来什么都可断言,其实什么都没断言。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node web/build_proto09.mjs | out/proto09.html | 3 模块内联,内核 13185 字符,页面 22998 B |
node web/verify_mech.mjs --page out/proto09.html --shot out/shots/proto09.png | out/shots/proto09.png | 3 断言全绿,截图 100440 B,canvas 230400 px |
node --test 'core/test/gd_proto_09_1_anatomy.test.ts' | 断言结果 | 16 测试全绿,0 失败 |
六个字段已经拆开并各自可断言。但字段拆开只说明「机制是什么」,不说明「钱该往哪儿花」——下一节把金币当成三个花钱方向(存利息 / 刷新 / 升级)来量,给出一条可算判据和一个平衡破坏点。
阅读导航:上一节:8.3 平台跳跃的心流带宽 · 下一节:9.2 自走棋的经济与平衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。