4.2 阵容加成与组合强度

单兵种克制解决不了「几个兵种怎么搭」。本节把阵容加成写成条件规则:三军齐备、单一兵种、骑兵主力、均衡编队,命中即叠加;再用 TypeScript 原型把「命中加成 → 攻击分/防御分 → 组合强度」算成整数,用 node --test 断言三军齐备拿到 5187 分、枪兵方阵拿到 2878 分。

4.2 阵容加成与组合强度

4.1 给了单兵种之间的克制矩阵,但玩家出阵从来不是「一种兵对一种兵」:真实战斗里你带的是阵容——枪兵几个、骑兵几个、弓兵几个,以及由这个配比触发的组合加成。这一节把「配比 → 加成 → 组合强度」这条链写成配置和原型,用 TypeScript 把它算成确定的整数。它对应本卷可运行性分层里的 P 原型层:不是跑仿真出统计量,而是把一条产品规则复刻成可断言的代码。

4.2.1 阵容加成的三种典型条件

几乎所有 SLG 的阵容加成都能归到几类条件上。第一类是齐备型:三个兵种都带,触发「三军齐备」,奖励多样性。第二类是专精型:某一种兵占到一定数量,触发「单一兵种」或「某兵主力」,奖励把资源堆在一个方向。第三类是均衡型:各兵种数量差距不超过一个阈值,触发「均衡编队」,奖励不偏科。

这三类条件天然互相冲突:你不可能同时「三军齐备」和「单一兵种」。设计上加成本来就是让玩家做取舍——堆单一兵种能吃满专精加成,但会被对应的克制兵种打穿;三军齐备更稳,但每种兵都不到专精线。加成怎么设计,决定了玩家会往哪个方向卷。

还有一点常被忽略:加成是数值诱导,不只是数值奖励。玩家会为了凑条件而改变行为——为了卡「骑兵 ≥ 2」硬塞一队骑兵、为了极差 ≤ 1 把多余的兵拆成两队。这些行为本身可能并不优,但加成把玩家往那边推。所以加成的条件阈值要设计得「凑起来不别扭」,否则玩家会为了吃加成做出违背克制逻辑的操作。

条件判定全部是整数比较:数兵种种类、数某个兵的数量、算最大最小数量差。没有任何浮点,也不需要随机。这一点很重要——它让加成可以跨语言逐位一致地复刻,Python 侧和 TypeScript 侧读同一份配置,命中哪几条加成永远相同。

4.2.2 加成规则的配置

新建 config/n04_lineup.json。每条规则四个要素:id 稳定标识、name 展示名、cond 条件、atkPct / defPct 命中后的整数百分比加成。条件用 kind 区分四种判定:distinct(兵种种类数)、sameMax(某兵最大数量)、unitAtLeast(指定兵种数量)、countSpreadMax(各兵数量极差上限)。

{
  "version": 1,
  "note": "阵容加成规则。条件全部为整数比较;满足条件即叠加(可加性),atkPct/defPct 为整数百分比。攻击分 = 全队 atk 之和再乘 (100+atkPct)/100;防御分 = 全队 (def+hp) 之和再乘 (100+defPct)/100;组合强度 = 攻击分 + 防御分,全程整数地板除。",
  "bonuses": [
    { "id": "tri-army", "name": "三军齐备", "cond": { "kind": "distinct", "min": 3 }, "atkPct": 12, "defPct": 6 },
    { "id": "mono-army", "name": "单一兵种", "cond": { "kind": "sameMax", "min": 3 }, "atkPct": 0, "defPct": 18 },
    { "id": "cavalry-focus", "name": "骑兵主力", "cond": { "kind": "unitAtLeast", "unit": "cavalry", "min": 2 }, "atkPct": 10, "defPct": 0 },
    { "id": "balanced", "name": "均衡编队", "cond": { "kind": "countSpreadMax", "max": 1 }, "atkPct": 6, "defPct": 6 }
  ],
  "testLineups": [
    { "id": "L-tri", "name": "三军齐备", "stacks": [ { "unit": "spearman", "count": 2 }, { "unit": "cavalry", "count": 2 }, { "unit": "archer", "count": 2 } ] },
    { "id": "L-mono", "name": "枪兵方阵", "stacks": [ { "unit": "spearman", "count": 3 } ] },
    { "id": "L-cav", "name": "骑兵主力", "stacks": [ { "unit": "cavalry", "count": 2 }, { "unit": "archer", "count": 1 } ] },
    { "id": "L-single", "name": "单队弓手", "stacks": [ { "unit": "archer", "count": 1 } ] }
  ]
}

四类条件刻意各留一条:tri-army 看 distinct >= 3,mono-army 看 sameMax >= 3,cavalry-focus 看 cavalry >= 2,balanced 看极差 <= 1。testLineups 是四条测试阵容,直接写在配置里——原型读配置、测试读原型,不需要在测试代码里硬编码兵种数量。

注意 mono-army 的攻击加成是 0、防御加成是 18:单一兵种奖励的是「扛」,不是「打」。这是一个常见的反直觉设计——如果单一兵种既加攻又加防,玩家就没有理由混编,克制关系会被架空。

4.2.3 组合强度怎么算

下面这段是 P 层原型,读 config/n04_lineup.json 与 units.json,先判定每条加成是否命中并累加百分比,再算攻击分、防御分与组合强度。它 import 公共模块 core.ts 的 loadConfig 与 UnitDef——Node 26 原生跑 TS 要求写 .ts 后缀。

// file: proto/src/n04_lineup.ts
// tier: P
// verify: node --test proto/test/n04.test.ts
// 阵容加成与组合强度:读 config/n04_lineup.json 的条件规则逐条判定命中,再按整数百分比算攻击分/防御分/组合强度。
// 条件全部整数比较;攻击分 = 全队 atk 之和 ×(100+atkPct)/100,防御分 = 全队 (def+hp) 之和 ×(100+defPct)/100,地板除。

import { loadConfig, type UnitDef } from "./core.ts";

export interface BonusCond { kind: string; min?: number; max?: number; unit?: string }
export interface BonusRule { id: string; name: string; cond: BonusCond; atkPct: number; defPct: number }
export interface StackDef { unit: string; count: number }
export interface LineupDef { id: string; name: string; stacks: StackDef[] }
export interface LineupConfig { bonuses: BonusRule[]; testLineups: LineupDef[] }

export function applyBonus(rule: BonusRule, counts: Record<string, number>, stacks: StackDef[]): boolean {
  const present = stacks.filter((s) => s.count > 0).map((s) => s.count);
  switch (rule.cond.kind) {
    case "distinct":
      return Object.keys(counts).length >= (rule.cond.min ?? 1);
    case "sameMax":
      return present.length > 0 && Math.max(...present) >= (rule.cond.min ?? 1);
    case "unitAtLeast":
      return (counts[rule.cond.unit ?? ""] ?? 0) >= (rule.cond.min ?? 1);
    case "countSpreadMax": {
      if (present.length === 0) return false;
      return Math.max(...present) - Math.min(...present) <= (rule.cond.max ?? 0);
    }
    default:
      return false;
  }
}

export interface LineupScore {
  id: string; name: string; applied: string[]; atkPct: number; defPct: number;
  atkSum: number; defSum: number; attackScore: number; defenseScore: number; comboStrength: number;
}

export function comboStrength(lineup: LineupDef, cfg: LineupConfig, unitOf: Record<string, UnitDef>): LineupScore {
  const counts: Record<string, number> = {};
  for (const s of lineup.stacks) counts[s.unit] = (counts[s.unit] ?? 0) + s.count;
  const applied: string[] = [];
  let atkPct = 0, defPct = 0;
  for (const b of cfg.bonuses) {
    if (applyBonus(b, counts, lineup.stacks)) { applied.push(b.id); atkPct += b.atkPct; defPct += b.defPct; }
  }
  let atkSum = 0, defSum = 0;
  for (const s of lineup.stacks) {
    const u = unitOf[s.unit];
    atkSum += s.count * u.atk;
    defSum += s.count * (u.def + u.hp);
  }
  const attackScore = Math.floor((atkSum * (100 + atkPct)) / 100);
  const defenseScore = Math.floor((defSum * (100 + defPct)) / 100);
  return { id: lineup.id, name: lineup.name, applied, atkPct, defPct, atkSum, defSum, attackScore, defenseScore,
           comboStrength: attackScore + defenseScore };
}

export function lineupReport() {
  const cfg = loadConfig<LineupConfig>("n04_lineup");
  const unitOf: Record<string, UnitDef> = {};
  for (const u of loadConfig<{ units: UnitDef[] }>("units").units) unitOf[u.id] = u;
  const lineups = cfg.testLineups.map((l) => comboStrength(l, cfg, unitOf));
  const best = lineups.reduce((a, b) => (b.comboStrength > a.comboStrength ? b : a));
  return { count: lineups.length, lineups, maxStrengthId: best.id, maxStrength: best.comboStrength };
}

组合强度的定义值得说明:攻击分 = 全队 atk 之和 × (100 + atkPct) / 100,防御分 = 全队 (def + hp) 之和 × (100 + defPct) / 100,两者相加。为什么防御分用 def + hp 而不是只用 def?因为 SLG 里「扛得住」由减伤和血量共同决定:def 高减伤多,hp 高能多吃几轮。把两者加起来是一种粗粒度但稳定的近似,真正的强弱还要靠 4.3 的模拟。所有乘法都走 Math.floor(a * (100+p) / 100),与 Python 的 // 语义一致。

还要注意,这个公式把攻击分和防御分直接相加,隐含了一个假设:1 点攻击分和 1 点防御分对胜负的贡献相同。这个假设当然不成立——真实战斗里攻击和防御是乘性耦合的(第 3 章的伤害公式就是 atk × 克制 − def × 系数)。但我们故意先用这个最粗糙的模型,因为 4.3 会拿它去和模拟对照,用数据告诉读者这个假设到底错多少。先立一个会错的靶子,比直接上一个调好的复杂模型更有教学价值。

4.2.4 断言与实跑

加成的价值在于「命中哪几条」是可断言的。下面这个测试文件覆盖本章两节:4.2 的三条阵容断言,加上 4.3 的强度扫描与平衡迭代断言(依赖下一节的 n04_strength.ts)。

// file: proto/test/n04.test.ts
// tier: P
// verify: node --test proto/test/n04.test.ts
// 第 4 章断言:阵容加成命中、组合强度、以及 4.3 的强度扫描与 Python 侧逐位一致。

import { test } from "node:test";
import assert from "node:assert/strict";
import { lineupReport } from "../src/n04_lineup.ts";
import { strengthReport } from "../src/n04_strength.ts";

test("4.2 三军齐备:命中 tri-army / cavalry-focus / balanced,组合强度 5187", () => {
  const l = lineupReport().lineups.find((x) => x.id === "L-tri")!;
  assert.deepEqual(l.applied, ["tri-army", "cavalry-focus", "balanced"]);
  assert.equal(l.atkPct, 28);
  assert.equal(l.defPct, 12);
  assert.equal(l.attackScore, 1715);
  assert.equal(l.defenseScore, 3472);
  assert.equal(l.comboStrength, 5187);
});

test("4.2 枪兵方阵:单一兵种 + 均衡编队,无攻击加成", () => {
  const l = lineupReport().lineups.find((x) => x.id === "L-mono")!;
  assert.deepEqual(l.applied, ["mono-army", "balanced"]);
  assert.equal(l.atkPct, 6);
  assert.equal(l.defPct, 24);
  assert.equal(l.comboStrength, 2878);
});

test("4.2 最强阵容由加成后的组合强度决定", () => {
  const r = lineupReport();
  assert.equal(r.count, 4);
  assert.equal(r.maxStrengthId, "L-tri");
  assert.equal(r.maxStrength, 5187);
});

test("4.3 强度扫描:解析分与模拟分排序一致性低(rho=-9),topMatch 为假", () => {
  const r = strengthReport();
  assert.equal(r.lineups.length, 6);
  assert.equal(r.rankCorrelationPct, -9);
  assert.equal(r.topMatch, false);
  // 解析分最高的是 mono-spear,但模拟分最低——加权战力分排不出强弱
  const spear = r.lineups.find((l) => l.id === "mono-spear")!;
  assert.equal(spear.comboStrength, 5756);
  assert.equal(spear.baseScorePct, 32);
});

test("4.3 平衡迭代:极差 49% → 15%,第 9 轮收敛", () => {
  const r = strengthReport();
  assert.equal(r.initialSpreadPct, 49);
  assert.equal(r.iterations.length, 9);
  assert.equal(r.finalSpreadPct, 15);
  assert.equal(r.converged, true);
  assert.deepEqual(r.iterations.map((x) => x.spreadPct), [49, 41, 37, 35, 30, 28, 24, 20, 15]);
});

本机 node --test proto/test/n04.test.ts 的实跑输出:

✔ 4.2 三军齐备:命中 tri-army / cavalry-focus / balanced,组合强度 5187 (1.89ms)
✔ 4.2 枪兵方阵:单一兵种 + 均衡编队,无攻击加成 (0.70ms)
✔ 4.2 最强阵容由加成后的组合强度决定 (0.50ms)
✔ 4.3 强度扫描:解析分与模拟分排序一致性低(rho=-9),topMatch 为假 (27.8ms)
✔ 4.3 平衡迭代:极差 49% → 15%,第 9 轮收敛 (16.7ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

四条测试阵容的组合强度实测如下:

阵容命中加成atkPctdefPct攻击分防御分组合强度
三军齐备tri-army / cavalry-focus / balanced2812171534725187
枪兵方阵mono-army / balanced62457223062878
骑兵主力cavalry-focus / balanced16687015262396
单队弓手balanced66243445688

三军齐备拿到 5187 分居首,符合直觉——它同时吃到攻击加成(28%)与均衡奖励。但注意它的防御分反而最低:12% 的 defPct 远低于枪兵方阵的 24%。这说明「组合强度」这个单一标量本身是分不出「攻型/守型」的,它只能排总量。

4.2.5 加成的可加性与阈值悬崖

我们规定加成命中即相加,而不是相乘。这个选择不是随口定的。三军齐备同时命中三条,若按相加,攻击加成是 12 + 10 + 6 = 28;若按相乘,是 1.12 × 1.10 × 1.06 ≈ 1.306,即 30.6%(纯算术)。单看这一个阵容,加法比乘法低 2.6 个百分点,差别不大;但乘法会随命中条数指数放大——命中五条加成时,乘法能轻易冲到 60% 以上,阵容强度失控。SLG 里加成条数多、更新频繁,可加性更可控,也让数值策划能直接口算总加成。代价是:可加性下「多命中一条 6%」的价值恒定,不像乘法那样「越堆越值钱」,诱导力度弱一些。

条件阈值还会制造悬崖效应。同样一套规则,把 L-cav(2 骑兵 + 1 弓兵)改成 4 骑兵 + 2 弓兵,命中情况完全变了:

阵容distinctsameMaxcavalry极差命中atkPctdefPct
2 骑兵 + 1 弓兵2221cavalry-focus / balanced166
4 骑兵 + 2 弓兵2442mono-army / cavalry-focus1018

骑兵从 2 加到 4,balanced 因为极差超过 1 掉了,mono-army 因为最大数量到了 3 又上来了。结果攻击加成从 16% 掉到 10%、防御加成从 6% 升到 18%——玩家的直觉是「多带骑兵应该更强」,但账面攻击反而降了。这不是 bug,而是阈值设计的副作用。要避免这种反直觉,阈值之间要么留够缓冲(如极差上限放到 2),要么把条件写成单调的(数量越多加成越高),而不是「卡在一个区间里」。

4.2.6 组合强度的边界

到这一步,我们有了一个能算、能断言的组合强度。但要清醒:它只是一个加权求和。它把克制矩阵(4.1)完全忽略掉了——枪兵方阵对骑兵方阵的组合强度更高,不代表枪兵方阵真的打得赢。加权分能回答「这套阵容的账面资源堆了多少」,回答不了「这套阵容上场打谁强」。

这个边界正是 4.3 要量化的东西。下一节会把六个阵容放进真实的循环赛里,用 4.1 的克制矩阵跑模拟,然后做一件反直觉的对照:把解析分排序和模拟分排序放在一起比,看它们到底有多一致。如果一致性很低,那就说明「组合强度」这类账面分不能用来评估强弱——这恰恰是本节这套原型最该被验证的假设。

需要提前说明的是,这并不意味着组合强度没用。它在两个地方仍然是刚需:一是展示,玩家需要看到一个「战力数字」来理解自己变强了没有,哪怕这个数字和真实胜率不完全挂钩;二是匹配,很多 SLG 用战力分做初筛,因为算得快、不可被玩家轻易操纵。真正的问题不是「要不要战力分」,而是「别把战力分当成胜率用」。4.3 的排序一致性基准,就是把这句话从经验判断变成可测量的数。

阅读导航:上一节:4.1 兵种三角与克制矩阵 · 下一节:4.3 强度评估与平衡迭代 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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