9.3 自走棋的涌现

把涌现度写成「可达阵容数 / 规则条数 × 1000」的代理指标:本规格枚举出 9390 个可达阵容、16 条规则,涌现度 586875‰;但如实报告一个退化解——敌方强度扫到 20 时只剩 1 个阵容每回合都能赢,21 起无解,涌现度必须与退化检测同读。

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
涌现度‰每条规则撑起的阵容数 × 1000586875
末回合能赢只要求第 12 回合赢的阵容数3885
每回合都能赢全程不输一局的阵容数(退化检测量)2963
可赢占比‰每回合都能赢 / 可达阵容 × 1000413
最大战力单阵容战力上界254
末回合敌方战力通关门槛147

最右两列揭示了一个不对称:最大战力 254 比通关门槛 147 高出 107,看着余裕很大;但只有 413‰ 的阵容能全程赢。差距来自克制关系——同一个阵容面对 ward / arcane / blade 三种敌方羁绊会打出三个不同战力(实测 40 / 43 / 41),而敌方羁绊逐回合轮换,所以「战力够」不等于「每回合都够」。涌现的来源不是棋子数量,是羁绊矩阵把战力做成了回合相关的。

退化扫描:拐点在 20

把敌方强度从 11 扫到 34,可以看到一条干净的单调下降曲线:

  • enemyStep 11→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 放置的机制解剖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.2 自走棋的经济与平衡
  3. 9.1 自走棋的机制解剖