本版配套:下载SLG 两卷完整教学快照
。先解压完整依赖,再按包内说明运行;文末的完整文件可单独用于修订。新补参数为教学假设,正文历史快照的固定输出需与当前配置区分。
上一节证明了加法战力在克制环上会判错。这一节问一个更长线的问题:版本一年更新几次,每次加一两个新属性(穿透、暴击、连击、士气……),这个加法公式还站得住吗?
SLG 是持续运营产品,属性膨胀是必然,不是意外。第 8 章会讲跨赛季的数值膨胀怎么控制;本节只关心公式本身——当属性从 2 个涨到 6 个,加法战力公式的排序保真度会发生什么。
属性膨胀从哪来新属性几乎都带着同一个特征:它们是乘区,不是加项。穿透「忽略对方 30% 防御」、暴击「30% 概率打双倍」、士气「全队攻击 ×1.2」——这些效果在战斗里都是乘法,因为它们是独立发生的。但战力面板是加法:战力 = Σ wᵢ·aᵢ。于是每加一个乘区,策划就得在加法公式里补一项、重标定一次权重,而且越补越不准。
加法战力是乘积的一阶近似把队伍强度记成各乘区之积(aᵢ 是百分比加成):
truth = Π (100 + aᵢ) / 100^(k-1)
令 xᵢ = aᵢ/100,则 truth = 100 · Π(1 + xᵢ)。把它泰勒展开:
truth = 100 · (1 + Σxᵢ + Σ_{i = 100 + Σaᵢ + Σ_{i 这行展开解释了全部问题:加法战力 Σaᵢ 只保留了乘积的一阶项,丢掉了所有交叉项。属性越少,交叉项越少,误差越小;属性越多,C(k,2) 个交叉项越多,加法公式漏掉的越多。这就是「属性膨胀」在公式层面的样子。 下面这段是 D 层配置:基础乘区、逐版本新增的乘区、采样规模与种子。随机用自实现 mulberry32,Python 与 TypeScript 两侧同算法同种子。 { "version": 1, "note": "公式可扩展性实验:随版本增加属性/乘区,比较三种战力公式对真值排序的保真度。真值 = 各乘区之积(设计假设)。随机用自实现 mulberry32,种子固定。", "seed": 20261012, "teams": 240, "attrMaxPct": 200, "crossTermDen": 100, "baseAttrs": [ { "id": "atkPct", "name": "攻击区" }, { "id": "defPct", "name": "防御区" } ], "addedAttrs": [ { "id": "hpPct", "name": "兵力区", "version": 2 }, { "id": "penPct", "name": "穿透区", "version": 3 }, { "id": "critPct", "name": "暴击区", "version": 4 }, { "id": "moralePct", "name": "士气区", "version": 5 } ] } 下面这段是 S 层仿真脚本:采样 240 支队伍,对每个 k(2→6)算三种公式与真值的 Spearman 秩相关与 Kendall 反演对数。 # file: sim/n01_ext.py # tier: S # verify: python3 sim/n01_ext.py --config config/ --out out/ """公式可扩展性实验:读 config/n01_ext.json,随版本增加属性/乘区,量三种战力公式的排序保真度。 真值(设计假设):队伍强度 = 各乘区之积,truth = Π (100 + a_i) // 100^(k-1)。 把乘积展开(x_i = a_i/100):truth = 100 + Σa_i + Σ_{i - 加法战力(一阶):power_add = Σ a_i——只保留一阶项。 - 加法战力(二阶):power_add2 = Σ a_i + Σ_{i - 乘法战力(精确):power_mul = truth——与真值同构。 对 N 支采样队伍算三种公式与真值的 Spearman 秩相关与 Kendall 反演对数。 队伍属性用自实现 mulberry32 生成,Python 与 TS 两侧同算法同种子。 纯标准库,图表用 xml.etree 手写 SVG,零依赖。""" from __future__ import annotations import argparse import csv import json import os import xml.etree.ElementTree as ET def load_config(path: str) -> dict: with open(path, encoding="utf-8") as f: return json.load(f) def mulberry32(seed: int): """确定性 PRNG:与 proto/src/n01_ext.ts 逐位一致。""" state = seed & 0xFFFFFFFF def nxt() -> float: nonlocal state state = (state + 0x6D2B79F5) & 0xFFFFFFFF t = ((state ^ (state >> 15)) * (state | 1)) & 0xFFFFFFFF t = (t ^ (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF))) & 0xFFFFFFFF return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0 return nxt def make_teams(cfg: dict, k: int) -> list[list[int]]: rng = mulberry32(cfg["seed"]) mx = cfg["attrMaxPct"] return [[int(rng() * (mx + 1)) for _ in range(k)] for _ in range(cfg["teams"])] def truth_of(team: list[int]) -> int: p = 1 for a in team: p *= (100 + a) return p // (100 ** (len(team) - 1)) def add_of(team: list[int]) -> int: return sum(team) def add2_of(team: list[int], den: int) -> int: cross = sum(team[i] * team[j] for i in range(len(team)) for j in range(i + 1, len(team))) return sum(team) + cross // den def rankdata(vals: list[int]) -> 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 for t in range(i, j + 1): ranks[order[t]] = (i + j) / 2 + 1 i = j + 1 return ranks def spearman_permille(xs: list[int], ys: list[int]) -> 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 inversions(xs: list[int], ys: list[int]) -> int: """Kendall 反演对数:两两比较,xs 次序与 ys 次序相反的对数。""" return sum(1 for i in range(len(xs)) for j in range(i + 1, len(xs)) if (xs[i] - xs[j]) * (ys[i] - ys[j]) < 0) def write_svg(path: str, rows: list[dict]) -> None: width, height, pl, pr, pt, pb = 640, 360, 64, 24, 40, 56 iw, ih = width - pl - pr, height - pt - pb vmax = max(max(r[k] for k in ("addInversions", "add2Inversions", "mulInversions")) for r in rows) 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 = "反演对数随属性数 k 的变化(越小越准)" 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"}) series = (("addInversions", "#d1242f", "一阶加法"), ("add2Inversions", "#e3a008", "二阶加法"), ("mulInversions", "#1f6feb", "乘法")) for key, color, _ in series: pts = [f"{pl + iw * (i / max(len(rows) - 1, 1)):.1f},{pt + ih * (1 - r[key] / vmax):.1f}" for i, r in enumerate(rows)] ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": color, "stroke-width": "2"}) for i, r in enumerate(rows): ET.SubElement(svg, "text", {"x": f"{pl + iw * (i / max(len(rows) - 1, 1)):.1f}", "y": str(pt + ih + 16), "font-size": "11", "text-anchor": "middle", "font-family": "sans-serif"}).text = f"k={r['k']}" for t, (_, color, label) in enumerate(series): ET.SubElement(svg, "text", {"x": str(pl + t * 110), "y": str(pt + ih + 38), "font-size": "12", "fill": color, "font-family": "sans-serif"}).text = label 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_config(os.path.join(args.config, "n01_ext.json")) base, den = len(cfg["baseAttrs"]), cfg["crossTermDen"] rows = [] for added in range(len(cfg["addedAttrs"]) + 1): k = base + added teams = make_teams(cfg, k) truth = [truth_of(t) for t in teams] padd = [add_of(t) for t in teams] p2 = [add2_of(t, den) for t in teams] rows.append({"version": 1 + added, "k": k, "addPermille": spearman_permille(padd, truth), "add2Permille": spearman_permille(p2, truth), "mulPermille": 1000, "addInversions": inversions(padd, truth), "add2Inversions": inversions(p2, truth), "mulInversions": 0}) summary = {"version": 1, "teams": cfg["teams"], "attrMaxPct": cfg["attrMaxPct"], "crossTermDen": den, "rows": rows, "addInversionGrowth": rows[-1]["addInversions"] - rows[0]["addInversions"], "add2InversionGrowth": rows[-1]["add2Inversions"] - rows[0]["add2Inversions"]} os.makedirs(args.out, exist_ok=True) with open(os.path.join(args.out, "n01_ext.json"), "w", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2) with open(os.path.join(args.out, "n01_ext.csv"), "w", encoding="utf-8", newline="") as f: w = csv.writer(f) w.writerow(["version", "k", "addPermille", "add2Permille", "mulPermille", "addInversions", "add2Inversions", "mulInversions"]) for r in rows: w.writerow([r["version"], r["k"], r["addPermille"], r["add2Permille"], r["mulPermille"], r["addInversions"], r["add2Inversions"], r["mulInversions"]]) write_svg(os.path.join(args.out, "n01_ext.svg"), rows) print(json.dumps(summary, ensure_ascii=False, indent=2)) if __name__ == "__main__": main() 本机实跑(240 支队伍,C(240,2)=28680 对;反演对数越小越准): k=2 add 989‰ inv=982 | add2 1000‰ inv=0 | mul 1000‰ inv=0 k=3 add 988‰ inv=1201 | add2 998‰ inv=434 | mul 1000‰ inv=0 k=4 add 989‰ inv=1217 | add2 996‰ inv=667 | mul 1000‰ inv=0 k=5 add 989‰ inv=1219 | add2 995‰ inv=766 | mul 1000‰ inv=0 k=6 add 989‰ inv=1208 | add2 995‰ inv=834 | mul 1000‰ inv=0 addInversionGrowth: 226 add2InversionGrowth: 834 读结果:一阶公式注定漏掉交叉项三行结论直接对应泰勒展开: 一阶加法(Σaᵢ)从 k=2 就有 982 处反演,到 k=6 升到 1208。它从一开始就漏掉了交叉项,属性越多漏得越多。二阶加法(补上 Σaᵢaⱼ/100)在 k=2 时反演为 0——因为 k=2 时真值恰好等于 100 + add2,交叉项只有一项,补上就精确了。k≥3 出现三阶及以上交叉项,反演从 0 涨到 834,但始终远低于一阶公式。乘法公式反演恒为 0:它与真值同构,所以永远是 0。这条是基准线——不是「乘法公式有多好」,而是「结构对了就不用打补丁」。注意反演对数(绝对量)比 Spearman(千分比)敏感得多:Spearman 从 989 到 1000 看着差别不大,但反演对数在 k=6 是 1208 对 0——28680 对里有 4.2% 被一阶公式排反了。所以评估战力公式时,别只看相关系数,要看反演率。 可扩展性的结论:加法战力不是「换个权重就能修」的问题,是结构不匹配。要么把战力改成乘法/分乘区结构(与战斗的乘法本质一致),要么至少补上交叉项(二阶公式),让新属性进来时误差涨得慢一些。 P 层:跨语言与 BigInt乘法真值 Π(100+aᵢ) 在 k=6、属性到 200 时已达 300⁶ ≈ 7.3×10¹⁴,继续加属性就会超 2⁵³(JS 的安全整数上限)。所以原型层必须用 BigInt。下面这段是 P 层原型,与 S 层共用 config/n01_ext.json 和同种子的 mulberry32。 // file: proto/src/n01_ext.ts // tier: P // verify: node --test proto/test/n01_ext.test.ts // bastion 原型层:公式可扩展性。读 config/n01_ext.json,用 BigInt 算乘积真值。 // 与 sim/n01_ext.py 共用配置、同算法同种子的 mulberry32,逐位一致。 import { loadConfig } from "./core.ts"; export interface ExtConfig { seed: number; teams: number; attrMaxPct: number; crossTermDen: number; baseAttrs: { id: string }[]; addedAttrs: { id: string; version: number }[]; } export function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = Math.imul(a ^ (a >>> 15), 1 | a) >>> 0; const t0 = t; t = (t0 + Math.imul(t0 ^ (t0 >>> 7), 61 | t0)) >>> 0; t = (t ^ t0) >>> 0; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export function makeTeams(cfg: ExtConfig, k: number): number[][] { const rnd = mulberry32(cfg.seed); const out: number[][] = []; for (let i = 0; i < cfg.teams; i++) { const row: number[] = []; for (let j = 0; j < k; j++) row.push(Math.floor(rnd() * (cfg.attrMaxPct + 1))); out.push(row); } return out; } // 乘积真值必须用 BigInt:Π(100+a) 在 k 大、属性大时远超 2^53 export function truthOf(team: number[]): bigint { let p = 1n; for (const a of team) p *= BigInt(100 + a); return p / 100n ** BigInt(team.length - 1); } export function addOf(team: number[]): number { return team.reduce((s, a) => s + a, 0); } export function add2Of(team: number[], den: number): number { let cross = 0; for (let i = 0; i < team.length; i++) for (let j = i + 1; j < team.length; j++) cross += team[i] * team[j]; return addOf(team) + Math.floor(cross / den); } export function loadExt(): ExtConfig { return loadConfig } node --test proto/test/n01_ext.test.ts 本机全绿,断言 TS 侧与 Python 侧逐位一致: mulberry32 前 6 个整数: [68, 3, 73, 1, 136, 132] (两侧相同) k=6 首支队伍 truth=1655n add=413 add2=1036 (两侧相同) k=2 首支队伍 add2 + 100 == truth (二阶公式精确) 小结属性膨胀是 SLG 的必然:新属性几乎都是乘区,而战力面板是加法。加法战力是乘积的一阶近似,漏掉全部交叉项;C(k,2) 个交叉项随属性数平方增长。仿真结果:一阶公式反演对数 982→1208(k=2→6),二阶 0→834,乘法恒为 0。二阶公式在 k=2 精确,k≥3 起偏离。评估公式看反演率而非相关系数;乘法真值跨语言要用 BigInt。下一章离开属性层,进入成长曲线:属性怎么随等级长出来。阅读导航:上一节:1.2 战力公式设计与校验 · 下一节:2.1 成长曲线的四种家族 。 完整配套文件:proto/test/n01_ext.test.ts补齐本节命令引用的完整测试入口。测试包含手算样本、边界或重放不变量;实际大样本输出仍需与 Python 独立对照。 // file: proto/test/n01_ext.test.ts // verify: node --test proto/test/n01_ext.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { truthOf, addOf, add2Of, makeTeams, loadExt } from "../src/n01_ext.ts"; test("两属性乘积的手算反例:加法漏掉交叉项", () => { assert.equal(truthOf([10, 20]), 132n); assert.equal(addOf([10, 20]), 30); assert.equal(add2Of([10, 20], 100), 32); }); test("固定种子与维度可复现", () => { const c = loadExt(), a = makeTeams(c, 3); assert.equal(a.length, c.teams); assert.equal(a[0].length, 3); assert.deepEqual(a, makeTeams(c, 3)); }); SLG 数值策划 游戏设计← 上一篇 10.1 定价锚点与档位设计 下一篇 → 1.2 战力公式设计与校验继续阅读 探索更多技术文章浏览归档,发现更多关于系统设计、工具链和工程实践的内容。 全部文章 返回首页「slg」更多文章01 12.3 留存与付费的联合分析 2026-10-12 →02 12.2 流失预警与召回 2026-10-12 →03 附录 C:全书公式、参数与数据集索引 2026-10-12 →