1.2 战力公式设计与校验

战力公式是玩家看得见的排序依据,必须与真打一场的结果一致。本篇用确定性回合战斗做真值,把「单位类型 × 兵力」的 12 支队伍两两对撞 66 场,跑出同型 1000‰、跨型 791‰ 的排序一致性,再用最小二乘反推权重、报告残差,暴露加法战力在克制环上的结构性盲区。

上一节把属性算到了 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 属性膨胀与公式的可扩展性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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