4.3 强度评估与平衡迭代
4.2 算出了每个阵容的「组合强度」,但那只是一个账面加权分。它到底能不能代表真实强弱?这一节给两个可测的基准:排序一致性(解析分排出来的顺序,和真打出来的顺序,有多一致)和平衡迭代收敛(把失衡的加成往回调,胜率差距能不能收敛)。两个都落在本卷的 B 基准层——产出 JSON,软告警不硬失败,因为「一致性低」本身就是结论,不是脚本出错。
4.3.1 强度评估的两个问题
产品侧评估阵容强度时,实际在问两件事。第一件是排序问题:「这几个阵容谁强谁弱,能不能排个名」。如果有一个便宜的解析公式能排出和真打一致的顺序,策划就不用每次改数值都跑全量仿真。第二件是收敛问题:「调数值的时候,怎么知道调到位了」。手工调加成是场灾难——改一个兵种,五个阵容的相对强弱全变,靠人眼盯不住。
这一节就针对这两个问题各给一个可跑的基准。排序问题用 Spearman 秩相关系数,把「解析排序 vs 模拟排序」的一致性压成一个数;收敛问题用一个自动平衡器,迭代地削弱当前最强、增强当前最弱,看胜率极差能不能收敛到目标带。
先把六个阵容摆出来。它们都带 6 个兵,用同一套 4.2 的加成规则算解析分。配置放在 config/n04_strength.json:lineups 是六个阵容,iteration 是平衡迭代参数(初始失衡增量、每步调整幅度、最大轮数、目标极差),sweep 是循环赛的抖动参数。
{
"version": 1,
"note": "强度评估与平衡迭代:6 个等兵力阵容,用同一套加成规则算解析分,再用多栈确定性战斗跑循环赛得模拟分;B 基准看两者排序一致性与迭代收敛。iteration 给出可调 atk 增量(只作用在最强阵容上,制造失衡)。",
"lineups": [
{ "id": "mono-spear", "name": "枪兵方阵", "stacks": [ { "unit": "spearman", "count": 6 } ] },
{ "id": "mono-cav", "name": "骑兵方阵", "stacks": [ { "unit": "cavalry", "count": 6 } ] },
{ "id": "mono-arch", "name": "弓兵方阵", "stacks": [ { "unit": "archer", "count": 6 } ] },
{ "id": "tri-army", "name": "三军齐备", "stacks": [ { "unit": "spearman", "count": 2 }, { "unit": "cavalry", "count": 2 }, { "unit": "archer", "count": 2 } ] },
{ "id": "cav-heavy", "name": "骑兵主力", "stacks": [ { "unit": "cavalry", "count": 4 }, { "unit": "archer", "count": 2 } ] },
{ "id": "arch-heavy", "name": "弓兵主力", "stacks": [ { "unit": "archer", "count": 4 }, { "unit": "spearman", "count": 2 } ] }
],
"iteration": { "initialDelta": { "tri-army": 15 }, "step": 4, "maxIter": 16, "spreadTargetPct": 15 },
"sweep": { "countJitter": 2, "samples": 60, "seed": 20261012 }
}
initialDelta 只给 tri-army 加了 15 点 atk 增量,故意制造一个「被过度强化」的阵容;平衡器的任务就是把它调回来。step 是每次调整的幅度(4 点),spreadTargetPct 是收敛目标(胜率极差 ≤ 15%)。六个阵容里三个是纯兵种方阵、三个是混编,正好覆盖单兵与混编两大类。
4.3.2 多栈循环赛与排序一致性
下面这段是 S 层仿真脚本。它比 4.1 多两件事:一是战斗从「单兵对单兵」升级成「多栈对多栈」——每个阵容是一个栈列表,每回合各栈按当前存活数输出伤害,集火打血池最大的敌方栈;二是加了一层自动平衡器,迭代削弱最强、增强最弱。B 基准的两个数(排序一致性、迭代收敛)都在这里算出来。
# file: sim/n04_strength.py
# tier: S
# verify: python3 sim/n04_strength.py --config config/
"""强度评估与平衡迭代(S + B 基准)。解析分用加成规则;模拟分用多栈确定性战斗跑循环赛;B 基准看解析/模拟排序一致性与迭代收敛。产出 JSON + CSV + SVG。"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from bastion_core import load_config # noqa: E402
from svg_line import render as render_line # noqa: E402
OUT = os.path.join(HERE, "..", "out")
def mulberry32(seed):
a = seed & 0xFFFFFFFF
def nxt():
nonlocal a
a = (a + 0x6D2B79F5) & 0xFFFFFFFF
t = (a ^ (a >> 15)) & 0xFFFFFFFF
t = (t * ((1 | a) & 0xFFFFFFFF)) & 0xFFFFFFFF
o = t
x = ((o ^ (o >> 7)) & 0xFFFFFFFF)
x = (x * ((61 | o) & 0xFFFFFFFF)) & 0xFFFFFFFF
t = (((o + x) & 0xFFFFFFFF) ^ o) & 0xFFFFFFFF
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
return nxt
def rand_int(rng, lo, hi):
return lo + int(rng() * (hi - lo + 1))
def cond_ok(c, counts):
present = [v for v in counts.values() if v > 0]
if c["kind"] == "distinct":
return len(present) >= c["min"]
if c["kind"] == "sameMax":
return bool(present) and max(present) >= c["min"]
if c["kind"] == "unitAtLeast":
return counts.get(c["unit"], 0) >= c["min"]
return bool(present) and (max(present) - min(present)) <= c.get("max", 0)
def lineup_bonus(stacks, bonuses):
counts = {}
for s in stacks:
counts[s["unit"]] = counts.get(s["unit"], 0) + s["count"]
atk = dfn = 0
for b in bonuses:
if cond_ok(b["cond"], counts):
atk += b["atkPct"]
dfn += b["defPct"]
return atk, dfn
def combo_strength(stacks, bonuses, unit_of):
ap, dp = lineup_bonus(stacks, bonuses)
atk_sum = sum(s["count"] * unit_of[s["unit"]]["atk"] for s in stacks)
def_sum = sum(s["count"] * (unit_of[s["unit"]]["def"] + unit_of[s["unit"]]["hp"]) for s in stacks)
return atk_sum * (100 + ap) // 100 + def_sum * (100 + dp) // 100
def dmg(atk, dfn, pct, rules):
return max(rules["minDamage"], (atk * pct - dfn * rules["defenseFactorPct"]) // 100)
def alive(hp, h):
return 0 if hp <= 0 else (hp + h - 1) // h
def make_side(stacks, unit_of, ap, dp):
return [{"unit": s["unit"], "hp": unit_of[s["unit"]]["hp"], "hpPool": s["count"] * unit_of[s["unit"]]["hp"],
"atk": unit_of[s["unit"]]["atk"] * (100 + ap) // 100,
"dfn": unit_of[s["unit"]]["def"] * (100 + dp) // 100} for s in stacks]
def focus(side):
"""集火目标 = 血池最大的存活栈,平手取下标最小。"""
return max(range(len(side)), key=lambda i: (side[i]["hpPool"], -i))
def resolve(a, d, m, rules):
a, d = [dict(s) for s in a], [dict(s) for s in d]
r = 0
while any(s["hpPool"] > 0 for s in a) and any(s["hpPool"] > 0 for s in d) and r < rules["maxRounds"]:
r += 1
ta, td = focus(a), focus(d)
ad = sum(alive(s["hpPool"], s["hp"]) * dmg(s["atk"], d[td]["dfn"], m[s["unit"]][d[td]["unit"]], rules) for s in a if s["hpPool"] > 0)
dd = sum(alive(s["hpPool"], s["hp"]) * dmg(s["atk"], a[ta]["dfn"], m[s["unit"]][a[ta]["unit"]], rules) for s in d if s["hpPool"] > 0)
d[td]["hpPool"] -= ad
a[ta]["hpPool"] -= dd
a_alive, d_alive = any(s["hpPool"] > 0 for s in a), any(s["hpPool"] > 0 for s in d)
return "d" if d_alive and not a_alive else ("a" if a_alive and not d_alive else "draw")
def jitter(stacks, rng, j):
return [{"unit": s["unit"], "count": max(1, s["count"] + rand_int(rng, -j, j))} for s in stacks]
def score_lineups(lineups, bonuses, unit_of, m, rules, deltas, sw):
rng, n, wins = mulberry32(sw["seed"]), len(lineups), {l["id"]: 0 for l in lineups}
for i in range(n):
for j in range(n):
if i == j:
continue
a, b = lineups[i]["id"], lineups[j]["id"]
for _ in range(sw["samples"]):
sa = make_side(jitter(lineups[i]["stacks"], rng, sw["countJitter"]), unit_of, bonuses[a][0] + deltas.get(a, 0), bonuses[a][1])
sb = make_side(jitter(lineups[j]["stacks"], rng, sw["countJitter"]), unit_of, bonuses[b][0] + deltas.get(b, 0), bonuses[b][1])
wins[a] += 1 if resolve(sa, sb, m, rules) == "a" else 0
return {l["id"]: wins[l["id"]] * 100 // (sw["samples"] * (n - 1)) for l in lineups}
def spearman_pct(ids, ra, rb):
"""Spearman ρ 用整数表达为百分比:ρ = 1 - 6Σd²/(n(n²-1))。"""
n = len(ids)
d2 = sum((ra[u] - rb[u]) ** 2 for u in ids)
denom = n * (n * n - 1)
return (denom - 6 * d2) * 100 // denom
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
args = ap.parse_args()
cfg_dir = args.config if os.path.isabs(args.config) else os.path.join(HERE, "..", args.config)
st = json.load(open(os.path.join(cfg_dir, "n04_strength.json"), encoding="utf-8"))
lp = load_config("n04_lineup")
cc = json.load(open(os.path.join(cfg_dir, "n04_counter.json"), encoding="utf-8"))
unit_of = {u["id"]: u for u in load_config("units")["units"]}
rules, m = load_config("battle")["rules"], cc["matrix"]
lineups, ids = st["lineups"], [l["id"] for l in st["lineups"]]
bonuses = {l["id"]: lineup_bonus(l["stacks"], lp["bonuses"]) for l in lineups}
analytic = {l["id"]: combo_strength(l["stacks"], lp["bonuses"], unit_of) for l in lineups}
base = score_lineups(lineups, bonuses, unit_of, m, rules, {}, st["sweep"])
ana_rank = sorted(ids, key=lambda u: (-analytic[u], u))
sim_rank = sorted(ids, key=lambda u: (-base[u], u))
rho = spearman_pct(ids, {u: i for i, u in enumerate(ana_rank)}, {u: i for i, u in enumerate(sim_rank)})
it, cur = st["iteration"], dict(st["iteration"]["initialDelta"])
hcap = score_lineups(lineups, bonuses, unit_of, m, rules, cur, st["sweep"])
iterations, converged = [], False
for k in range(it["maxIter"]):
sc = score_lineups(lineups, bonuses, unit_of, m, rules, cur, st["sweep"])
top = max(ids, key=lambda u: (sc[u], u))
bot = min(ids, key=lambda u: (sc[u], u))
spread = sc[top] - sc[bot]
iterations.append({"iter": k + 1, "spreadPct": spread, "topId": top, "topScorePct": sc[top], "botId": bot, "botScorePct": sc[bot]})
if spread <= it["spreadTargetPct"]:
converged = True
break
cur[top], cur[bot] = cur.get(top, 0) - it["step"], cur.get(bot, 0) + it["step"]
report = {"lineups": [{"id": l["id"], "name": l["name"], "atkPct": bonuses[l["id"]][0], "defPct": bonuses[l["id"]][1],
"comboStrength": analytic[l["id"]], "baseScorePct": base[l["id"]], "handicapScorePct": hcap[l["id"]]} for l in lineups],
"analyticRank": ana_rank, "simRank": sim_rank, "rankCorrelationPct": rho,
"rankAgreement": "high" if rho >= 50 else "low", "topMatch": ana_rank[0] == sim_rank[0],
"initialSpreadPct": max(hcap.values()) - min(hcap.values()), "iterations": iterations,
"maxIter": it["maxIter"], "converged": converged, "finalSpreadPct": iterations[-1]["spreadPct"]}
# B 基准是软告警:迭代不收敛才算硬失败;排序一致性低只提示「解析分不足以评估强弱」
report["ok"] = converged
os.makedirs(OUT, exist_ok=True)
json.dump(report, open(os.path.join(OUT, "n04_strength.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2)
with open(os.path.join(OUT, "n04_strength.csv"), "w", encoding="utf-8", newline="") as f:
wr = csv.writer(f)
wr.writerow(["iter", "spreadPct", "topId", "topScorePct", "botId", "botScorePct"])
for r in iterations:
wr.writerow([r["iter"], r["spreadPct"], r["topId"], r["topScorePct"], r["botId"], r["botScorePct"]])
render_line([r["spreadPct"] for r in iterations], os.path.join(OUT, "n04_strength.svg"), "平衡迭代:胜率极差随迭代收敛")
print(json.dumps({k: report[k] for k in ("rankCorrelationPct", "topMatch", "initialSpreadPct", "finalSpreadPct", "converged", "ok")}, ensure_ascii=False))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
几处关键设计。score_lineups 是循环赛:n 个阵容两两对打,每个有序对跑 samples 次抖动战斗,胜率按 样本 × (n−1) 归一。spearman_pct 把秩相关压成整数百分比——ρ = 1 − 6Σd²/(n(n²−1)),n=6 时分母是 210,全程整数运算,不做浮点开方。平衡迭代的核心只有四行:算出当前分数、取最强和最弱、算极差、把最强的增量减 step、最弱的加 step。
4.3.3 实跑结果:排序一致性只有 -9%
本机运行脚本,stdout 一行 JSON:
{"rankCorrelationPct": -9, "topMatch": false, "initialSpreadPct": 49, "finalSpreadPct": 15, "converged": true, "ok": true}
先看排序一致性。六个阵容的解析分与模拟分对照:
| 阵容 | 组合强度(解析) | 模拟胜率分 | 解析名次 | 模拟名次 |
|---|---|---|---|---|
| 枪兵方阵 | 5756 | 32% | 1 | 6 |
| 骑兵方阵 | 5603 | 49% | 2 | 3 |
| 三军齐备 | 5187 | 74% | 3 | 1 |
| 骑兵主力 | 5048 | 50% | 4 | 2 |
| 弓兵主力 | 4725 | 37% | 5 | 5 |
| 弓兵方阵 | 4586 | 40% | 6 | 4 |
解析分最高的枪兵方阵,模拟分最低(32%),名次从第 1 掉到第 6;解析分只排第 3 的三军齐备,模拟分最高(74%)。Spearman 排序一致性 rho = -9%,topMatch 为假。这个结果不是脚本坏了,恰恰是本节最想展示的结论:加权求和式的组合强度,和真实胜率基本不相关。
原因不难找。枪兵方阵的 atk + def + hp 总和最大(枪兵 hp 400 最高),但它被弓兵克制——纯枪兵阵容在循环赛里被所有带弓兵的阵容按着打。组合强度里完全没有克制矩阵这一项,所以它看不出「枪兵会被弓兵打穿」。这说明 4.2 的账面分只能用于展示和匹配初筛,绝不能拿来做平衡决策。要做平衡决策,必须回到模拟。
4.3.4 平衡迭代:极差从 49% 收敛到 15%
再看收敛基准。initialDelta 给三军齐备灌了 15 点 atk,制造了一个失衡局面。平衡器每轮削弱当前最强、增强当前最弱,胜率极差的变化如下(来自 out/n04_strength.csv):
| 轮次 | 胜率极差 | 当前最强 | 当前最弱 |
|---|---|---|---|
| 1 | 49% | 三军齐备 (80%) | 枪兵方阵 (31%) |
| 2 | 41% | 三军齐备 (78%) | 弓兵主力 (37%) |
| 3 | 37% | 三军齐备 (76%) | 弓兵主力 (39%) |
| 5 | 30% | 三军齐备 (70%) | 枪兵方阵 (40%) |
| 7 | 24% | 三军齐备 (65%) | 弓兵主力 (41%) |
| 9 | 15% | 三军齐备 (58%) | 弓兵主力 (43%) |
极差单调下降:49 → 41 → 37 → 35 → 30 → 28 → 24 → 20 → 15,第 9 轮落到目标 15% 以内,converged 为 true。三军齐备的胜率分从 80% 一路被压到 58%,最弱阵容从 31% 抬到 43%。收敛曲线也写成了 SVG(out/n04_strength.svg)。
值得注意的是,即便收敛了,极差仍有 15%——这不是平衡器没调好,而是六个阵容里存在结构性差异:三军齐备同时吃攻击与均衡加成,天然就是更强的骨架,光靠 atk 增量很难把它和纯兵种方阵压平。真实运营里这也是常态:平衡迭代的目标不是「所有阵容胜率相等」(那等于抹掉克制与加成,游戏就没得玩了),而是「把差距压到一个玩家感知不到不公平的区间」。15% 是不是这个区间,要由 7 章的匹配与分段、以及真实对战数据来回答,不是本章能单独定的。
4.3.5 小结
本章从 4.1 的单兵种克制矩阵,走到 4.2 的阵容加成与组合强度,再到 4.3 的强度评估与平衡迭代,形成一条闭环:配置化 → 可断言 → 可仿真 → 可迭代。核心结论有三条:克制矩阵要逐对调参,单一倍率会让三角不均;阵容加成要可加不要相乘,阈值别设成悬崖;账面战力分不能当胜率用,排序一致性基准会把这件事量化。
原型层把本章的规则用 TypeScript 复刻了一遍(proto/src/n04_strength.ts),四个阵容的循环赛胜率、排序一致性 -9%、迭代序列 [49, 41, 37, 35, 30, 28, 24, 20, 15] 与 Python 侧逐位一致——这正是 bastion 工具链「一份配置、两处消费」要保证的性质。下一节进入第 5 章,从「确定性战斗」转向「概率掉落」。
阅读导航:上一节:4.2 阵容加成与组合强度 · 下一节:5.1 掉落表与概率分布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。