15.2 GVG 匹配与战场设计

GVG 是「一群人对一群人」。这一节把联盟总战力配对与三据点战场结算都建成可跑模型:按窗口 5000 配对时战力差 2482、碾压率 0%,窗口放宽到 20000 时碾压率飙到 34.8%;再用 30 个种子的 B 基准证明匹配均衡度与战场时长偏差都稳定。

7.3 量的是玩家对玩家的匹配:按匹配分把两个人配到一起,看实力差多大。这一节换主体——联盟对联盟:一群几十人、总战力从几万到几十万不等,怎么把两个联盟配到一起、战场又该怎么打,才能既公平又不拖沓。

GVG 有两个可调旋钮,本节各量一个:

  • 配对窗口(bandWidth):两个联盟总战力的允许差。窗口窄 → 打得公平但排得慢;窗口宽 → 排得快但容易一边倒。
  • 战场结构:几个据点、每个据点多少权重、地形偏向谁。它决定了「均势」和「碾压」分别长什么样。

匹配:按联盟总战力配对

联盟匹配和玩家匹配有个本质差别:玩家匹配看的是一个数(匹配分),联盟匹配看的是一群数(总战力)。总战力的方差比个人大得多——一个 50 人的联盟,核心玩家的战力能顶十个白嫖。所以「按总战力配对」本身就比玩家匹配粗糙。

沿用 7.3 的思路,用配对窗口做约束:只在总战力差不超过 bandWidth 的联盟之间配对。窗口越宽,配上的联盟战力差越大。

战场:三据点得分赛

战场怎么结算?这里用一个确定性、整数的模型:战场有 3 个据点,中路权重最大(40),两翼各 30;两翼对 A、B 各有地形偏置(+12 / -12),中路中立。每回合,双方在每个据点上比较有效战力,高者拿下该据点、按权重得分;累计得分先达 100 的一方胜,打满 30 回合则比分高者胜。

关键在地形偏置是相对的:effA = Pa*(100+bias)/100、effB = Pb*(100-bias)/100。两翼各偏 12%,所以战力比要超过约 1.27 倍,强的一方才吃得住对方的地形据点——这就是「碾压」和「均势」的分界线。

S 层:配对窗口扫描与战场结算

下面这段是 S 层脚本,生成 400 个联盟,对每个配对窗口各抽 4000 局,跑战场结算,统计战力差、均衡度、战场平均时长与碾压率(时长 ≤ 12 回合)。

# file: sim/n15_gvg.py
# tier: S
# verify: python3 sim/n15_gvg.py --config config/ --out out/
"""GVG 匹配与战场结算(15.2):按联盟总战力配对,跑确定性据点得分赛。

战场:3 据点,各有权重 weightPct 与对 A 的地形偏置 biasPct;每回合比较有效战力
effA=Pa*(100+bias)/100、effB=Pb*(100-bias)/100,高者得分 roundScore*weight/100;
累计得分先达 captureScore 者胜,打满 maxRounds 则比分高者胜、平局记 draw。
扫描配对窗口 bandWidth:窗口越宽,战力差越大,战场越短、碾压越多。
产出 out/n15_gvg.json + .csv + n15_gvg_cases.json(跨语言对照)+ .svg。
"""
import argparse
import bisect
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 svg_line import polyline_points  # noqa: E402

SVG_NS = "http://www.w3.org/2000/svg"


def mulberry32(seed):
    state = seed & 0xFFFFFFFF

    def nxt():
        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 settle_gvg(pa, pb, bf):
    """确定性战场结算:整数运算,与 proto/src/n15_gvg.ts settleGvg 逐位一致。"""
    rs, cap, maxr = bf["roundScore"], bf["captureScore"], bf["maxRounds"]
    sa = sb = rounds = 0
    while rounds < maxr and sa < cap and sb < cap:
        rounds += 1
        for k in range(bf["points"]):
            bias = bf["terrainBiasPct"][k]
            ea = pa * (100 + bias) // 100
            eb = pb * (100 - bias) // 100
            s = rs * bf["pointWeightsPct"][k] // 100
            if ea > eb:
                sa += s
            elif eb > ea:
                sb += s
    return {"winner": "a" if sa > sb else ("b" if sb > sa else "draw"),
            "rounds": rounds, "scoreA": sa, "scoreB": sb}


def gen_alliances(g, rng, m):
    c, sp = g["powerCenter"], g["powerSpread"]
    return [c + int(round((rng() + rng() + rng() + rng() - 2.0) * sp)) for _ in range(m)]


def sample_band(sp_, si, width, rng):
    n = len(sp_)
    r = int(rng() * n)
    j = min(max(bisect.bisect_left(sp_, sp_[r] + (rng() * 2.0 - 1.0) * width), 0), n - 1)
    return si[r], si[j + 1 if j == r and j + 1 < n else (j - 1 if j == r else j)]


def measure(powers, cfg, width, rng, matches):
    g, bf, n = cfg["gvg"], cfg["gvg"]["battlefield"], len(powers)
    sp_, si = sorted(powers), sorted(range(n), key=lambda i: powers[i])
    gaps, rounds, blow = [], [], 0
    for _ in range(matches):
        a, b = sample_band(sp_, si, width, rng)
        gaps.append(abs(powers[a] - powers[b]))
        r = settle_gvg(powers[a], powers[b], bf)
        rounds.append(r["rounds"])
        blow += r["rounds"] <= bf["shortBattleRounds"]
    ag = sum(gaps) / matches
    return {"band": width, "matches": matches, "avgPowerGap": round(ag, 1),
            "balanceIndexPct": round(100.0 - 100.0 * ag / g["powerCenter"], 1),
            "avgRounds": round(sum(rounds) / matches, 2), "blowoutPct": round(100.0 * blow / matches, 1)}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    ap.add_argument("--seed", type=int, default=None)
    args = ap.parse_args()
    cfg = json.load(open(os.path.join(args.config, "n15_alliance.json"), encoding="utf-8"))
    g, bf = cfg["gvg"], cfg["gvg"]["battlefield"]
    seed = args.seed if args.seed is not None else cfg["seed"]
    powers = gen_alliances(g, mulberry32(seed), g["alliances"])
    rows = [measure(powers, cfg, w, mulberry32(seed + 101 + i), g["matchesPerBand"])
            for i, w in enumerate(g["matchBandWidths"])]
    curve = [dict({"ratioPct": r}, **settle_gvg(g["powerCenter"] * r // 100, g["powerCenter"], bf))
             for r in range(100, 141, 5)]
    cases = [dict({"powerA": pa, "powerB": pb}, **settle_gvg(pa, pb, bf))
             for pa, pb in [(50000, 50000), (52000, 48000), (60000, 40000), (40000, 60000), (55000, 50000)]]
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n15_gvg.json"), "w", encoding="utf-8") as f:
        json.dump({"version": 1, "seed": seed, "alliances": g["alliances"], "rows": rows, "curve": curve},
                  f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n15_gvg_cases.json"), "w", encoding="utf-8") as f:
        json.dump({"version": 1, "cases": cases}, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n15_gvg.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["band", "matches", "avgPowerGap", "balanceIndexPct", "avgRounds", "blowoutPct"])
        for r in rows:
            w.writerow([r["band"], r["matches"], r["avgPowerGap"], r["balanceIndexPct"], r["avgRounds"], r["blowoutPct"]])
    ys = [r["avgRounds"] for r in rows]
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": "0 0 640 360", "width": "640", "height": "360"})
    ET.SubElement(svg, "polyline", {"points": polyline_points(ys, min(ys), max(ys)),
                                    "fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
    ET.SubElement(svg, "text", {"x": "56", "y": "16", "font-size": "13", "fill": "#111"}).text = "战场平均时长随配对窗口变化"
    ET.ElementTree(svg).write(os.path.join(args.out, "n15_gvg.svg"), encoding="utf-8", xml_declaration=True)
    for r in rows:
        print(f"band{r['band']:>6}  战力差 {r['avgPowerGap']:>7}  均衡度 {r['balanceIndexPct']:>5}%  "
              f"平均时长 {r['avgRounds']:>5} 回合  碾压率 {r['blowoutPct']:>5}%")
    for c in curve:
        print(f"A/B={c['ratioPct']:>3}%  时长 {c['rounds']:>3} 回合  比分 {c['scoreA']}:{c['scoreB']}  胜者 {c['winner']}")


if __name__ == "__main__":
    main()

本机实跑输出(同机相对量级,绝对数值不可移植):

band  5000  战力差  2482.1  均衡度  95.0%  平均时长  15.0 回合  碾压率   0.0%
band 10000  战力差  4932.7  均衡度  90.1%  平均时长 14.93 回合  碾压率   1.4%
band 20000  战力差  9244.4  均衡度  81.5%  平均时长 13.26 回合  碾压率  34.8%
band 40000  战力差 15355.8  均衡度  69.3%  平均时长 11.89 回合  碾压率  62.2%
band 80000  战力差 18876.0  均衡度  62.2%  平均时长 11.31 回合  碾压率  73.8%
A/B=100%  时长  30 回合  比分 90:90  胜者 draw
A/B=105%  时长  15 回合  比分 105:45  胜者 a
A/B=130%  时长  10 回合  比分 100:0  胜者 a

三个读数:

  1. 窗口 5000 时几乎不可能碾压。 战力差 2482、均衡度 95.0%、碾压率 0.0%——窗口卡到总战力的 10% 以内,战场稳定在 15 回合,强弱分明但打不满。代价是「能配上的联盟太少」,排队时间长。
  2. 窗口 20000 是拐点。 碾压率从 1.4% 跳到 34.8%——三分之一的 GVG 在 12 回合内就被打崩。战力差 9244 已经接近「地形偏置 12%」那条分界线,强的一方开始吃得住对方的地形据点。
  3. 窗口再放宽就逼近「不匹配」。 40000/80000 时碾压率 62.2%/73.8%、平均时长掉到 11.9/11.3 回合。一旦窗口宽过联盟战力分布的特征尺度,按战力配对就名存实亡。

战场时长被据点结构量化:均势打 30 回合(90:90 平局),一方略强打 15 回合(105:45),一方碾压打 10 回合(100:0)。时长不是连续的,而是被「谁拿下几个据点」切成几档——这是据点得分赛和自由战斗最大的差别,也意味着设计者可以用据点权重直接控制一局 GVG 的时长。

P 层:同一份配置的第二个消费者

战场结算不该只有 Python 一个实现。下面这段是 P 层原型,TypeScript 读同一份 config/n15_alliance.json,复刻同一套整数运算,node --test 断言它与 Python 逐条一致——这就是「一份配置、两处消费」。

// file: proto/src/n15_gvg.ts
// tier: P
// verify: node --test proto/test/n15.test.ts
// GVG 战场结算原型:读同一份 config/n15_alliance.json,与 sim/n15_gvg.py 逐位一致。
// 逐位复刻 mulberry32;结算全用整数地板除,保证两侧同一份配置算出同一个战场。

import { loadConfig } from "./core.ts";

export interface Battlefield {
  points: number; pointWeightsPct: number[]; terrainBiasPct: number[];
  roundScore: number; captureScore: number; maxRounds: number; shortBattleRounds: number;
}
export interface GvgConfig { seed: number; gvg: { alliances: number; powerCenter: number; battlefield: Battlefield } }
export interface GvgResult { winner: "a" | "b" | "draw"; rounds: number; scoreA: number; scoreB: number }

export function loadGvg(): GvgConfig {
  return loadConfig<GvgConfig>("n15_alliance");
}

export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = (a ^ (a >>> 15)) >>> 0;
    t = Math.imul(t, a | 1) >>> 0;
    const u = (t ^ (t >>> 7)) >>> 0;
    t = ((t + Math.imul(u, t | 61)) >>> 0) ^ t;
    return (t ^ (t >>> 14)) >>> 0;
  };
}

/** 确定性战场结算:3 据点得分赛,整数运算,与 Python settle_gvg 逐位一致。 */
export function settleGvg(powerA: number, powerB: number, bf: Battlefield): GvgResult {
  let sa = 0, sb = 0, rounds = 0;
  while (rounds < bf.maxRounds && sa < bf.captureScore && sb < bf.captureScore) {
    rounds++;
    for (let k = 0; k < bf.points; k++) {
      const bias = bf.terrainBiasPct[k];
      const effA = Math.floor((powerA * (100 + bias)) / 100);
      const effB = Math.floor((powerB * (100 - bias)) / 100);
      const s = Math.floor((bf.roundScore * bf.pointWeightsPct[k]) / 100);
      if (effA > effB) sa += s;
      else if (effB > effA) sb += s;
    }
  }
  return { winner: sa > sb ? "a" : sb > sa ? "b" : "draw", rounds, scoreA: sa, scoreB: sb };
}

node --test proto/test/n15.test.ts 4 项全过:PRNG 逐位一致、配置读同一份、五个战力比逐条对齐 Python、重复调用结果确定。改 config 里一个地形偏置,两边结果同时变——这就是把数值变成可回归测试的东西。

B 基准:结论稳不稳

上面的数字都只是一个种子的结果。换 30 个种子各生成一批联盟再配对,看均衡度和战场时长会不会被单个种子带偏。

# file: sim/n15_gvg_bench.py
# tier: B
# verify: python3 sim/n15_gvg_bench.py --config config/ --out out/
"""GVG 匹配均衡度与战场时长偏差基准(B 层,软告警不硬失败)。

15.2 的均衡度(95.0%)、平均时长(15.0 回合)、碾压率(34.8%)都只是**一个种子**的结果。
换 30 个种子各生成一批联盟并配对,看匹配均衡度与战场时长是否稳定。
偏差超阈值只告警——线上还有活跃人数波动、联盟临时并会、战场地形轮换。产出 out/n15_gvg_bench.json。
"""
import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n15_gvg import gen_alliances, measure, mulberry32  # noqa: E402


def mean_std(vals):
    n = len(vals)
    m = sum(vals) / n
    return m, (sum((v - m) ** 2 for v in vals) / n) ** 0.5


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = json.load(open(os.path.join(args.config, "n15_alliance.json"), encoding="utf-8"))
    g = cfg["gvg"]
    trials, matches = g["trials"], g["matchesPerBand"]
    bands = [g["matchBandWidths"][0], g["matchBandWidths"][2]]  # 5000 与 20000
    acc = {b: {"gap": [], "rounds": [], "blow": []} for b in bands}
    for t in range(trials):
        seed = cfg["seed"] + t * 7919
        powers = gen_alliances(g, mulberry32(seed), g["alliances"])
        for bi, b in enumerate(bands):
            m = measure(powers, cfg, b, mulberry32(seed + 101 + bi), matches)
            for k, mk in (("gap", "avgPowerGap"), ("rounds", "avgRounds"), ("blow", "blowoutPct")):
                acc[b][k].append(m[mk])
    warn_r, warn_b = 0.3, 2.0  # 时长用绝对回合,碾压率用绝对百分点
    out = {"version": 1, "trials": trials, "matchesPerBand": matches, "bands": {}}
    stable = True
    for b in bands:
        gm, gs = mean_std(acc[b]["gap"])
        rm, rs = mean_std(acc[b]["rounds"])
        bm, bs = mean_std(acc[b]["blow"])
        ok = rs <= warn_r and bs <= warn_b
        stable = stable and ok
        out["bands"][str(b)] = {"gapMean": round(gm, 1), "gapStd": round(gs, 1), "roundsMean": round(rm, 2),
                                "roundsStd": round(rs, 3), "blowMeanPct": round(bm, 2),
                                "blowStdPct": round(bs, 3), "stable": ok}
    out.update(warnRounds=warn_r, warnBlowPct=warn_b, stable=stable)
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n15_gvg_bench.json"), "w", encoding="utf-8") as f:
        json.dump(out, f, ensure_ascii=False, indent=2)
    print(f"{trials} 个种子 · {g['alliances']} 个联盟 · 每档 {matches} 局")
    for b in bands:
        d = out["bands"][str(b)]
        print(f"band{b:>6}  战力差 {d['gapMean']}±{d['gapStd']}  |  平均时长 {d['roundsMean']}±{d['roundsStd']}  |  "
              f"碾压率 {d['blowMeanPct']}%±{d['blowStdPct']}%")
    print("OK 匹配均衡度与战场时长结论稳定" if stable else "WARN 战场指标抖动:检查配对或 PRNG")


if __name__ == "__main__":
    main()

本机实跑输出:

30 个种子 · 400 个联盟 · 每档 4000 局
band  5000  战力差 2498.5±27.0  |  平均时长 15.0±0.003  |  碾压率 0.0%±0.018%
band 20000  战力差 9470.1±187.9  |  平均时长 13.19±0.072  |  碾压率 36.21%±1.474%
OK 匹配均衡度与战场时长结论稳定

窗口 5000 的战场时长标准差只有 0.003 回合——几乎恒定在 15 回合;窗口 20000 的碾压率标准差 1.474 个百分点。两个窗口的结论在 30 个种子里都只抖了很小的幅度,「窄窗口不碾压、宽窗口碾压」这个判断是稳的,不是某个种子的偶然。

三条设计结论

  1. 配对窗口是「公平—速度」的旋钮。 5000 公平但排得慢,20000 是碾压率的拐点,再宽就退化。合理区间在总战力的 10%~20%。
  2. 战场时长被据点结构量化。 均势 30 回合、均势偏强 15 回合、碾压 10 回合——用据点权重能直接控制一局 GVG 的时长上限。
  3. 地形偏置是「碾压」的分界线。 偏 12% 时,战力比超过约 1.27 倍的一方就能吃掉对方的地形据点——这就是碾压率随窗口跳升的机制来源。

阅读导航:上一节:15.1 联盟生态与社交压力 · 下一节:15.3 联盟健康度指标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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