9.3 自走棋的涌现
9.2 把「钱怎么花」变成了一条可算判据,但它只管赢不赢,不管赢得像不像样。自走棋真正的耐玩点不在赢,而在「每次都能拼出不一样的阵容」——这就是涌现。问题在于,「阵容丰富」是个形容词,形容词不可断言。《数值平衡与商业化运营》 量的是付费与通胀,本节量的是组合空间:这套规格到底容得下多少种打法,以及有没有一种打法永远赢。
本节把涌现写成可枚举的整数,而且必须如实报告退化。所有涌现度、胜率都是代理指标(proxy),只做相对比较,禁绝对化:涌现度大只说明阵容池宽,不等于更好玩;它必须与退化检测同读,否则会得到一个「看起来很丰富、其实只有一条路」的假象。
涌现度 = 可达阵容数 / 规则条数
先把「阵容」定义清楚。一个阵容就是一串棋子(id + 星级),满足两条硬约束:棋子数 ≤ 上阵容量(capacity = 6),且每个单位的份数 ≤ poolPerUnit(12)——份数按 9.1 的口径算,1 星 1 份、2 星 3 份、3 星 9 份。所有满足约束的阵容合起来就是可达阵容池。
数量小到可以全枚举,所以这里全是精确值,不是采样估计:本规格枚举出 9390 个可达阵容,未触及 20 万上限。涌现度定义为可达阵容数对规格复杂度的比:
涌现度(‰) = floor(1000 × 可达阵容数 / 规则条数) = floor(1000 × 9390 / 16) = 586875
分母用规则条数(16 条:单位数、商店格数、池容量、刷新成本、合成数、星级倍率、羁绊矩阵、敌方羁绊、利息台阶、利息上限、连胜表、基础收入、失败伤害、升级成本、等级上限、回合数),意思很直白:一条规则能撑起多少种阵容。数值大 = 规则的经济性高(少量规则长出大量组合),数值小 = 规则堆了很多但组合没多。这是相对指标,横向比两个规格才有意义。
退化检测:一个「永远赢」的阵容
光看涌现度会骗人。586875‰ 听着很大,但如果 9390 个阵容里只有一个能赢,这局游戏实际上只有一条路——那就是退化解。所以涌现度必须配一个退化检测量:「每一回合都能赢」的阵容数(winAll)。
winAll大 = 打法多,容错高,健康。winAll == 1= 唯一解,退化解:只有一个阵容能通关。winAll == 0= 无解:没有任何阵容能通关,规格被打穿了。
本规格在基准强度(enemyStep 11)下 winAll = 2963、末回合能赢的 3885 个,未退化。但把敌方强度扫上去,故事就变了——下一节的表会给出那个拐点。
P 层:枚举、涌现度与退化扫描
core/test/gd_proto_09_3_emergence.test.ts 全枚举可达阵容、算涌现度、找唯一最大战力阵容、扫敌方强度找退化点。7 条测试全部对精确值下断言:
// file: core/test/gd_proto_09_3_emergence.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_09_3_emergence.test.ts'
//
// 卷三 9.3 自走棋的涌现(P 层):用「可达阵容数 / 规则条数 × 1000」当**涌现度代理指标**,
// 并把「每一回合都能赢」的阵容数当成退化检测量——它塌缩到 1 就是唯一解(退化解),
// 塌缩到 0 就是无解。数量小到可以全枚举,所以这里全是精确值,不是采样估计。
// 涌现度是**代理指标(proxy)**,只做相对比较,禁绝对化。全整数、无浮点、无 random。
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 { abComps, abEmergence, abBoardPower, abCopies } from "../genre/autobattler.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const load = (name: string) => JSON.parse(readFileSync(join(ROOT, "config", name), "utf8"));
const EM = load("gd_proto_09_emergence.json");
const CAP = EM.emergence.capacity;
const CAP_LIMIT = EM.emergence.cap;
test("枚举规模可控:9390 个可达阵容,未触及 20 万上限", () => {
const { all, capHit } = abComps(EM, CAP, CAP_LIMIT);
assert.equal(all.length, 9390);
assert.equal(capHit, false);
// 每个阵容都同时满足「棋子数 ≤ 上阵容量」与「每个单位的份数 ≤ poolPerUnit」。
for (const comp of all) {
assert.equal(comp.length <= CAP, true);
const per: Record<string, number> = {};
for (const p of comp) per[p.id] = (per[p.id] ?? 0) + abCopies(p.star);
for (const k of Object.keys(per)) assert.equal(per[k] <= EM.rules.poolPerUnit, true);
}
});
test("涌现度 = 可达阵容数 / 规则条数 × 1000(代理指标,禁绝对化)", () => {
const e = abEmergence(EM, CAP, CAP_LIMIT);
assert.equal(e.ruleCount, 16);
assert.equal(e.reachablePermille, 586875); // 1000 × 9390 / 16,整数地板除
assert.equal(Math.floor((1000 * e.comps) / e.ruleCount), 586875);
});
test("末回合能赢的阵容 3885 个,每一回合都能赢的 2963 个(未退化)", () => {
const e = abEmergence(EM, CAP, CAP_LIMIT);
assert.equal(e.winFinal, 3885);
assert.equal(e.winAll, 2963);
assert.equal(e.winningPermille, 413);
assert.equal(e.degenerate, false);
assert.equal(e.finalEnemyPower, 147); // enemyBase 26 + enemyStep 11 × 11
});
test("唯一最大战力阵容:254 战力,只有 1 个阵容达到(末回合敌方 147)", () => {
const e = abEmergence(EM, CAP, CAP_LIMIT);
assert.equal(e.bestPower, 254);
assert.equal(e.bestCount, 1);
assert.deepEqual(e.bestComp, ["blade_grunt:3", "ward_sentinel:3", "arcane_adept:2",
"arcane_adept:3", "blade_champion:2", "blade_champion:3"]);
const copies = [9, 9, 3, 9, 3, 9].reduce((a, b) => a + b, 0);
assert.equal(copies, 42); // ≤ 48:份数约束真的在起作用
});
test("克制关系是涌现的来源:同一个阵容对三种敌方羁绊给出三个不同战力", () => {
const comp = [{ id: "blade_grunt", star: 1 }, { id: "ward_sentinel", star: 1 },
{ id: "arcane_adept", star: 1 }];
const p = EM.rules.enemyTraits.map((t: string) => abBoardPower(EM, comp, t));
assert.deepEqual(p, [40, 43, 41]);
assert.equal(new Set(p).size, 3);
});
test("退化扫描:敌方强度扫过 20 时唯一解,21 起无解(如实报告)", () => {
const sweep: [number, number, number, boolean][] = [];
for (let st = 11; st <= 34; st += 1) {
const sp = JSON.parse(JSON.stringify(EM));
sp.params.enemyStep = st;
const e = abEmergence(sp, CAP, CAP_LIMIT);
sweep.push([st, e.winAll, e.winFinal, e.degenerate]);
}
console.log(" enemyStep 每回合都能赢 末回合能赢 退化");
for (const [st, wa, wf, dg] of sweep) {
console.log(` ${String(st).padStart(9)} ${String(wa).padStart(11)} ${String(wf).padStart(11)} ${dg ? "是" : "否"}`);
}
const at = (s: number) => sweep.find((x) => x[0] === s) as [number, number, number, boolean];
assert.deepEqual(at(11), [11, 2963, 3885, false]);
assert.deepEqual(at(15), [15, 427, 694, false]);
assert.deepEqual(at(19), [19, 10, 15, false]);
assert.deepEqual(at(20), [20, 1, 1, true]); // 退化解:只有一个阵容每回合都赢
assert.deepEqual(at(21), [21, 0, 0, true]); // 无解:没有阵容能赢
assert.deepEqual(at(34), [34, 0, 0, true]);
// 单调性:敌方越强,「每回合都能赢」的阵容只会更少,不会更多。
for (let i = 1; i < sweep.length; i += 1) assert.equal(sweep[i][1] <= sweep[i - 1][1], true);
});
test("涌现度不是「越多越好」:它是阵容池宽度对规格复杂度的比,必须与退化检测同读", () => {
const wide = abEmergence(EM, CAP, CAP_LIMIT);
const narrow = JSON.parse(JSON.stringify(EM));
narrow.params.enemyStep = 20;
const tight = abEmergence(narrow, CAP, CAP_LIMIT);
assert.equal(wide.comps, tight.comps); // 阵容池本身没变
assert.equal(wide.reachablePermille, tight.reachablePermille);
assert.equal(wide.winAll, 2963);
assert.equal(tight.winAll, 1); // 但可赢的那一层塌缩成 1
assert.equal(tight.degenerate, true);
});
# snippet: 跑本节 P 层
node --test 'core/test/gd_proto_09_3_emergence.test.ts'
enemyStep 每回合都能赢 末回合能赢 退化
11 2963 3885 否
12 2061 2853 否
13 1302 1913 否
14 782 1223 否
15 427 694 否
16 232 374 否
17 107 160 否
18 38 53 否
19 10 15 否
20 1 1 是
21 0 0 是
22 0 0 是
23 0 0 是
24 0 0 是
25 0 0 是
26 0 0 是
27 0 0 是
28 0 0 是
29 0 0 是
30 0 0 是
31 0 0 是
32 0 0 是
33 0 0 是
34 0 0 是
✔ 枚举规模可控:9390 个可达阵容,未触及 20 万上限
✔ 涌现度 = 可达阵容数 / 规则条数 × 1000(代理指标,禁绝对化)
✔ 末回合能赢的阵容 3885 个,每一回合都能赢的 2963 个(未退化)
✔ 唯一最大战力阵容:254 战力,只有 1 个阵容达到(末回合敌方 147)
✔ 克制关系是涌现的来源:同一个阵容对三种敌方羁绊给出三个不同战力
✔ 退化扫描:敌方强度扫过 20 时唯一解,21 起无解(如实报告)
✔ 涌现度不是「越多越好」:它是阵容池宽度对规格复杂度的比,必须与退化检测同读
ℹ tests 7
ℹ pass 7
ℹ fail 0
七条全绿。最后一条测试是本节最重要的方法论:把 enemyStep 从 11 调到 20,阵容池一个没变(comps 与 reachablePermille 完全相同),但「能赢的阵容」从 2963 塌到 1。涌现度没动,可玩性却塌了——这正是为什么涌现度不能单独读。
三个读数一起看
涌现度不是单个数字,是一组必须同读的读数:
| 读数 | 含义 | 本规格实测 |
|---|---|---|
| 可达阵容数 | 满足容量与份数约束的阵容总数 | 9390 |
| 规则条数 | 规格里可变的规则字段数(分母) | 16 |
| 涌现度‰ | 每条规则撑起的阵容数 × 1000 | 586875 |
| 末回合能赢 | 只要求第 12 回合赢的阵容数 | 3885 |
| 每回合都能赢 | 全程不输一局的阵容数(退化检测量) | 2963 |
| 可赢占比‰ | 每回合都能赢 / 可达阵容 × 1000 | 413 |
| 最大战力 | 单阵容战力上界 | 254 |
| 末回合敌方战力 | 通关门槛 | 147 |
最右两列揭示了一个不对称:最大战力 254 比通关门槛 147 高出 107,看着余裕很大;但只有 413‰ 的阵容能全程赢。差距来自克制关系——同一个阵容面对 ward / arcane / blade 三种敌方羁绊会打出三个不同战力(实测 40 / 43 / 41),而敌方羁绊逐回合轮换,所以「战力够」不等于「每回合都够」。涌现的来源不是棋子数量,是羁绊矩阵把战力做成了回合相关的。
退化扫描:拐点在 20
把敌方强度从 11 扫到 34,可以看到一条干净的单调下降曲线:
enemyStep11→19:winAll从 2963 一路掉到 10,winFinal从 3885 掉到 15,仍有多个解法。enemyStep= 20:winAll=winFinal= 1,退化解出现——全池只剩一个阵容能每回合都赢。enemyStep≥ 21:winAll= 0,无解——规格被打穿,任何阵容都赢不了。
「唯一最大战力阵容」是 254 战力(6 个棋子共 42 份,未超 48),但它是战力最大,不是唯一能赢;退化点那个唯一解未必是它。这条曲线也是配平衡的旋钮:想让游戏「有挑战但仍有多解」,enemyStep 应停在 19 附近;想过 20,就必须先加机制(新羁绊、新单位),否则只剩一条路。
表里还有一个细节值得注意:winAll 与 winFinal 从 11 到 19 的比值始终接近 3 : 4(2963/3885、427/694、10/15)。这说明「全程不输」比「末回合能赢」严格得多——有一批阵容能赢最后一局,但中途会翻车一两回合。玩家体感上,这批阵容就是「差一点」的那一档,它们的数量决定了游戏的容错感;enemyStep 往上扫时,这一档先于通关阵容消失。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node --test 'core/test/gd_proto_09_3_emergence.test.ts' | 断言结果 | 7 测试全绿,0 失败 |
| 全枚举可达阵容 | 阵容池 | 9390 个,未触及 20 万上限 |
| 涌现度 | 代理指标 | floor(1000 × 9390 / 16) = 586875‰ |
| 退化扫描 11→34 | 扫表 | 20 退化(winAll 1),21 起无解 |
第九章到此收束:9.1 拆字段,9.2 算经济,9.3 数涌现。三个动作合起来是一条完整的品类解剖流水线——先找到可断言的字段,再给决策找出可算判据,最后量组合空间并如实报告退化。下一章换一个完全不同的品类:放置类,它的机制主轴是「离线收益」,与自走棋的「回合内经济」恰好是镜像。
阅读导航:上一节:9.2 自走棋的经济与平衡 · 下一节:10.1 放置的机制解剖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。