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
三个读数:
- 窗口 5000 时几乎不可能碾压。 战力差 2482、均衡度 95.0%、碾压率 0.0%——窗口卡到总战力的 10% 以内,战场稳定在 15 回合,强弱分明但打不满。代价是「能配上的联盟太少」,排队时间长。
- 窗口 20000 是拐点。 碾压率从 1.4% 跳到 34.8%——三分之一的 GVG 在 12 回合内就被打崩。战力差 9244 已经接近「地形偏置 12%」那条分界线,强的一方开始吃得住对方的地形据点。
- 窗口再放宽就逼近「不匹配」。 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 个种子里都只抖了很小的幅度,「窄窗口不碾压、宽窗口碾压」这个判断是稳的,不是某个种子的偶然。
三条设计结论
- 配对窗口是「公平—速度」的旋钮。 5000 公平但排得慢,20000 是碾压率的拐点,再宽就退化。合理区间在总战力的 10%~20%。
- 战场时长被据点结构量化。 均势 30 回合、均势偏强 15 回合、碾压 10 回合——用据点权重能直接控制一局 GVG 的时长上限。
- 地形偏置是「碾压」的分界线。 偏 12% 时,战力比超过约 1.27 倍的一方就能吃掉对方的地形据点——这就是碾压率随窗口跳升的机制来源。
阅读导航:上一节:15.1 联盟生态与社交压力 · 下一节:15.3 联盟健康度指标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。