上一节把属性算到了 power = 3049,但那个公式是定的——权重 3/2/1/5 是随手写的。这一节要回答一个更硬的问题:这个战力数字,和真打一场的结果对得上吗?
战力公式在 SLG 里身兼两职。对玩家,它是排序依据——战力高的应该更能打,不然「战力」这个数字就失去意义;对策划,它是平衡抓手——两个单位战力接近,就该势均力敌。这两条都指向同一个校验标准:战力给出的排序,必须和仿真打出来的排序一致。
本节只做战力公式的设计与校验,不碰成长曲线(第 2 章)与伤害公式(第 3 章)。兵种克制的完整矩阵留到第 4 章,这里只用到它作为「战力公式的盲区」。
战力公式的三种写法
战力公式的本质是把一组属性压成一个标量,方便排序。常见三种:
| 写法 | 形式 | 优点 | 代价 |
|---|---|---|---|
| 加权和 | Σ wᵢ·aᵢ | 可解释、可公示、玩家能自己算 | 加法是「一阶近似」,属性间无法交互 |
| 乘积 | Π aᵢ^wᵢ | 天然表达「输出 × 生存」 | 数值增长快,公示难,跨语言要用大整数 |
| 幂函数 | a₁^w₁ · a₂^w₂ | 兼顾量纲 | 权重标定困难,难向玩家解释 |
本节先用加权和——它最可解释,也是大多数 SLG 面板实际用的写法。但加法有一个先天问题:真实战斗的强度更接近乘积。把两支队伍的对撞拆开看,A 每回合造成 n_A × dmgA 伤害,B 的池子是 n_B × hpB;A 要赢,需要 n_A·dmgA · n_A·hpA > n_B·dmgB · n_B·hpB——强度 ∝ 数量² × 输出 × 生存,是个乘法结构。加法公式只是它的低阶近似,所以必须校验,而且大概率会在某些对局上判错。
下面这段是 D 层配置:战力权重、对局网格与拟合设置。真值来自确定性回合战斗,不引入任何随机。
{
"version": 1,
"note": "战力公式与校验网格。power(army) = (wAtk*atk + wDef*def + wHp*hp) * count // den。真值来自确定性回合战斗(sim/bastion_core.resolve_battle),不引入随机。",
"powerFormula": { "wAtk": 3, "wDef": 2, "wHp": 1, "den": 1 },
"unitIds": ["spearman", "cavalry", "archer"],
"counts": [50, 100, 150, 200],
"terrainPct": 0,
"fit": { "note": "B 基准:用最小二乘从回合制真值反推权重,报告残差", "features": ["atk", "def", "hp"], "warnR2BelowPermille": 900 }
}
网格是 3 种单位 × 4 档兵力 = 12 支队伍,两两对撞共 66 场。下面这段是 S 层仿真脚本,跑这套对局,算排序一致性,并输出每支队伍的循环赛得分供 B 基准拟合。
# file: sim/n01_power.py
# tier: S
# verify: python3 sim/n01_power.py --config config/ --out out/
"""战力公式校验:读 config/n01_power.json,把「战力排序」与「真打一场的排序」做对照。
战力公式:power(army) = (wAtk*atk + wDef*def + wHp*hp) * count // den
真值:用 sim/bastion_core.resolve_battle 做确定性回合战斗(无随机),
(单位类型 × 兵力) 的每支队伍两两对撞,胜者即真值。
产出:排序一致性(同型 / 跨型分开算)与循环赛得分(胜 - 负),供 B 基准做最小二乘拟合。
纯标准库,图表用 xml.etree 手写 SVG,零依赖。"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from bastion_core import load_config as load_bastion, resolve_battle # noqa: E402
def load(path: str, name: str) -> dict:
with open(os.path.join(path, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def build_armies(cfg: dict, units_cfg: dict) -> list[dict]:
unit_of = {u["id"]: u for u in units_cfg["units"]}
w = cfg["powerFormula"]
armies = []
for uid in cfg["unitIds"]:
for n in cfg["counts"]:
u = unit_of[uid]
power = (w["wAtk"] * u["atk"] + w["wDef"] * u["def"] + w["wHp"] * u["hp"]) * n // w["den"]
armies.append({"id": f"{uid}-{n}", "unitId": uid, "count": n,
"atk": u["atk"], "def": u["def"], "hp": u["hp"], "power": power})
return armies
def run_round_robin(armies: list[dict], terrain_pct: int) -> list[dict]:
rows, score = [], {a["id"]: 0 for a in armies}
for i in range(len(armies)):
for j in range(i + 1, len(armies)):
a, b = armies[i], armies[j]
res = resolve_battle(a["unitId"], a["count"], b["unitId"], b["count"], terrain_pct)
actual = {"d": "b"}.get(res["winner"], res["winner"]) # 引擎用 a/d,统一成 a/b
pred = "a" if a["power"] > b["power"] else ("b" if a["power"] < b["power"] else "draw")
if actual == "a":
score[a["id"]] += 1
score[b["id"]] -= 1
elif actual == "b":
score[b["id"]] += 1
score[a["id"]] -= 1
rows.append({"a": a["id"], "b": b["id"], "sameUnit": a["unitId"] == b["unitId"],
"aPower": a["power"], "bPower": b["power"],
"predicted": pred, "actual": actual, "match": pred == actual})
for a in armies:
a["score"] = score[a["id"]]
return rows
def write_svg(path: str, armies: list[dict]) -> None:
"""散点图:横轴战力、纵轴循环赛得分。"""
width, height, pl, pr, pt, pb = 640, 400, 64, 24, 40, 48
iw, ih = width - pl - pr, height - pt - pb
xs = [a["power"] for a in armies]
ys = [a["score"] for a in armies]
xlo, xhi, ylo, yhi = min(xs), max(xs), min(ys), max(ys)
xspan, yspan = (xhi - xlo) or 1, (yhi - ylo) or 1
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
"height": str(height), "viewBox": f"0 0 {width} {height}"})
ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": str(pl), "y": "24", "font-size": "15",
"font-family": "sans-serif"}).text = "战力 vs 循环赛得分"
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(pl + iw), "y2": str(pt + ih), "stroke": "#888"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(pt + ih), "stroke": "#888"})
for a in armies:
x = pl + iw * (a["power"] - xlo) / xspan
y = pt + ih * (1 - (a["score"] - ylo) / yspan)
ET.SubElement(svg, "circle", {"cx": f"{x:.1f}", "cy": f"{y:.1f}", "r": "4", "fill": "#1f6feb"})
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config, "n01_power")
armies = build_armies(cfg, load_bastion("units"))
rows = run_round_robin(armies, cfg["terrainPct"])
def rate(m: int, n: int) -> int:
return m * 1000 // n if n else 0
same_n = sum(1 for r in rows if r["sameUnit"])
cross_n = len(rows) - same_n
same_m = sum(1 for r in rows if r["sameUnit"] and r["match"])
cross_m = sum(1 for r in rows if not r["sameUnit"] and r["match"])
summary = {"version": 1, "armies": armies, "battles": len(rows),
"concordancePermille": {"sameUnit": rate(same_m, same_n), "crossUnit": rate(cross_m, cross_n),
"overall": rate(same_m + cross_m, len(rows))},
"counts": {"sameUnit": same_n, "crossUnit": cross_n}}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "n01_power.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "n01_power.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["a", "b", "sameUnit", "aPower", "bPower", "predicted", "actual", "match"])
for r in rows:
w.writerow([r["a"], r["b"], r["sameUnit"], r["aPower"], r["bPower"],
r["predicted"], r["actual"], r["match"]])
write_svg(os.path.join(args.out, "n01_power.svg"), armies)
print(json.dumps({"battles": summary["battles"], "concordancePermille": summary["concordancePermille"],
"counts": summary["counts"]}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
本机实跑(节选):
battles: 66
concordancePermille: {sameUnit: 1000, crossUnit: 791, overall: 848}
counts: {sameUnit: 18, crossUnit: 48}
读结果:10 处错判全在克制环上
同型对局 18 场,排序一致性 1000‰(全对)——兵力多的必胜,战力算得没错。跨型对局 48 场,一致性只有 791‰——48 场里有 10 场判反了。把 out/n01_power.csv 里 match=false 的行捞出来看,10 场全部落在克制环上:
| 对局 | 战力预测 | 实际 | 原因 |
|---|---|---|---|
| 枪兵 vs 骑兵(同兵力) | 骑兵赢 | 枪兵赢 | 枪兵克制骑兵 |
| 枪兵 vs 弓兵(同兵力) | 枪兵赢 | 弓兵赢 | 弓兵克制枪兵 |
| 骑兵 vs 弓兵(兵力差一档) | 弓兵赢 | 骑兵赢 | 骑兵克制弓兵 |
这不是权重没调好——是加法战力公式里根本没有「克制」这一项。战力只看到 atk/def/hp,看不到「枪兵打骑兵 ×150%」。所以跨型对局的 209‰ 误差是结构性的,调权重调不掉。要修它,只能在战力里显式加一个克制修正项(第 4 章的克制矩阵),或者接受「战力只用于同型比较」。
B 基准:拟合残差与权重的可解释性
排序一致性是「逐场对错」的硬指标,但它不告诉你误差有多大。下面这段是 B 层基准:用最小二乘从真值反推权重,报告残差——软告警不硬失败。
# file: sim/n01_power_bench.py
# tier: B
# verify: python3 sim/n01_power_bench.py --config config/ --out out/
"""B 基准:把「设计战力」与「回合制真值」做两类对照,报告残差。
真值 = out/n01_power.json 里每支队伍的循环赛得分(胜 - 负)。
1) 排序对照:设计战力与真值的 Spearman 秩相关(千分比)。
2) 权重反推:最小二乘拟合 score ≈ b0 + Σ w_i * (attr_i * count),报告 R² 与 RMSE。
软告警不硬失败——R² 过低、或拟合出非正权重(不可解释)只打 WARN,退出码恒为 0。"""
from __future__ import annotations
import argparse
import json
import os
def load(path: str, name: str) -> dict:
with open(os.path.join(path, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def solve_linear(a: list[list[float]], b: list[float]) -> list[float]:
"""高斯消元解 n 元线性方程组(手写,不依赖 scipy)。"""
n = len(a)
m = [row[:] + [b[i]] for i, row in enumerate(a)]
for col in range(n):
piv = max(range(col, n), key=lambda r: abs(m[r][col]))
m[col], m[piv] = m[piv], m[col]
pv = m[col][col]
for r in range(n):
if r != col and m[r][col]:
f = m[r][col] / pv
for c in range(col, n + 1):
m[r][c] -= f * m[col][c]
return [m[i][n] / m[i][i] for i in range(n)]
def least_squares(x_rows: list[list[float]], y: list[float]) -> list[float]:
"""正规方程 (XᵀX)β = Xᵀy,带截距项。"""
x = [[1.0] + r for r in x_rows]
k = len(x[0])
xtx = [[sum(x[i][a] * x[i][b] for i in range(len(x))) for b in range(k)] for a in range(k)]
xty = [sum(x[i][a] * y[i] for i in range(len(x))) for a in range(k)]
return solve_linear(xtx, xty)
def rankdata(vals: list[float]) -> list[float]:
"""平均秩(并列取均值)。"""
order = sorted(range(len(vals)), key=lambda i: vals[i])
ranks = [0.0] * len(vals)
i = 0
while i < len(order):
j = i
while j + 1 < len(order) and vals[order[j + 1]] == vals[order[i]]:
j += 1
avg = (i + j) / 2 + 1
for t in range(i, j + 1):
ranks[order[t]] = avg
i = j + 1
return ranks
def spearman(xs: list[float], ys: list[float]) -> int:
rx, ry = rankdata(xs), rankdata(ys)
n = len(xs)
mx, my = sum(rx) / n, sum(ry) / n
num = sum((rx[i] - mx) * (ry[i] - my) for i in range(n))
dx = sum((rx[i] - mx) ** 2 for i in range(n)) ** 0.5
dy = sum((ry[i] - my) ** 2 for i in range(n)) ** 0.5
return int(round(num / (dx * dy) * 1000)) if dx and dy else 0
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
cfg = load(args.config, "n01_power")
sim = load(args.out, "n01_power")
feats = cfg["fit"]["features"]
x_rows = [[a[f] * a["count"] for f in feats] for a in sim["armies"]]
y = [a["score"] for a in sim["armies"]]
beta = least_squares(x_rows, y)
pred = [beta[0] + sum(beta[1 + k] * x_rows[i][k] for k in range(len(feats))) for i in range(len(y))]
ymean = sum(y) / len(y)
ss_tot = sum((v - ymean) ** 2 for v in y)
ss_res = sum((y[i] - pred[i]) ** 2 for i in range(len(y)))
r2_permille = int(round((1 - ss_res / ss_tot) * 1000)) if ss_tot else 0
weights = {f: round(beta[1 + k], 6) for k, f in enumerate(feats)}
non_positive = [f for f, w in weights.items() if w <= 0]
warn = r2_permille < cfg["fit"]["warnR2BelowPermille"] or bool(non_positive)
rep = {"version": 1, "features": feats, "weights": weights, "intercept": round(beta[0], 6),
"r2Permille": r2_permille, "rmse": round((ss_res / len(y)) ** 0.5, 3),
"spearmanPermille": spearman([a["power"] for a in sim["armies"]], y),
"nonPositiveWeights": non_positive, "warnThresholdPermille": cfg["fit"]["warnR2BelowPermille"],
"anyWarn": warn}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "n01_power_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(f"{'WARN' if warn else 'OK'} r2={r2_permille}permille rmse={rep['rmse']} "
f"spearman={rep['spearmanPermille']}permille nonPositive={non_positive}")
print(json.dumps(rep, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑:
WARN r2=990permille rmse=0.661 spearman=989permille nonPositive=['hp']
weights: {atk: 0.000791, def: 0.001236, hp: -0.000747} intercept: -14.333333
这里有三件事值得停下来看:
- Spearman 989‰:设计战力与真值的秩相关极高,说明排序大方向对。这与上一节「跨型 209‰ 判错」不矛盾——判错的都是相邻档位(如 144000 vs 184500 这种),整体趋势仍是「战力越高越强」。
- R² 990‰、RMSE 0.661:线性拟合能解释绝大部分方差,残差不到 1 分(得分范围 −9 到 9)。看起来很好。
- 但拟合出的
hp权重是负的(−0.000747):这不可解释——兵力越高战力越低?显然是错的。原因是三个属性高度共线:每支队伍的atk·count、def·count、hp·count都正比于兵力,模型分不清是哪个属性在起作用,于是把hp的贡献甩给了atk和def,hp就变成了负的补偿项。
这就是 B 基准的价值:它没有因为 R² 高就放行,而是对权重的符号做了可解释性检查,发现异常后打 WARN。所以脚本把 nonPositiveWeights 单列出来——战力公式的权重应该来自设计约束(这个属性值多少战力是策划定的),而不是对数据回归硬凑。回归可以校验,不能反推。
小结
- 战力公式是玩家可见的排序依据,校验标准是「战力排序 vs 真打排序」的一致性。
- 用 12 支队伍 66 场确定性对局验证:同型 1000‰、跨型 791‰、整体 848‰。10 处错判全部落在克制环上,是加法公式的结构性盲区,调权重解决不了。
- B 基准(最小二乘)给出 Spearman 989‰、R² 990‰,但同时暴露
hp权重为负——属性共线时回归权重不可解释。权重靠设计,回归只用于校验。 - 下一节把问题推向未来:版本迭代不断加属性时,这个加法公式还站得住吗?
阅读导航:上一节:1.1 属性分类与派生属性 · 下一节:1.3 属性膨胀与公式的可扩展性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。