前 14 章讲的都是一个玩家:他的属性、成长、抽卡、付费、留存。从这一章起换主体——一群人。联盟是 SLG 的社交骨架:地图上的每一块地、每一座要塞、每一次 GVG,背后都是一个由几十个人组成的组织。联盟的问题从来不是「数值够不够」,而是「人愿不愿意继续待下去」。
联盟设计里有一个绕不开的词:社交压力。有人白嫖、有人硬撑,联盟管理者夹在中间——管松了养懒汉,管紧了逼走人。这一节把这件事建成可跑的模型,量一量配额到底该定多高。
先说分工。竞技平衡委员会 讲的是平衡治理流程——数据、玩家反馈、版本节奏怎么组织;本章给的是联盟与 GVG 的玩法与数值建模:贡献怎么分布、配额怎么影响留存、战场怎么结算。两者是「治理」和「机制」的关系,不重复。
站内 /game/ 下已有约 45 篇与公会战相关的文章,但绝大多数是服务端架构(报名锁、实例回收、跨服事件注册)。本章不碰服务端——只谈玩法与数值:一个联盟的人为什么留下、为什么走,以及怎么用配额和战场规则去调。
联盟里其实有三类人
任何一个真实联盟,贡献都不是均匀的。按赛季贡献分,大致分三档:
| 档位 | 占比(本例) | 贡献特征 | 行为 |
|---|---|---|---|
| 核心 | 约 8% | 数倍于基准 | 扛战线、带节奏、出钱 |
| 活跃 | 约 64% | 基准到两倍之间 | 日常打卡、参与 GVG |
| 白嫖 | 约 28% | 接近 0 | 挂名、蹭奖励、几乎不出力 |
贡献高度集中是常态:少数核心贡献了大部分产出。衡量集中度用基尼系数(0 = 完全平均,1 = 极度集中)。本例的成员贡献基尼约 0.42——已经比很多真实联盟「健康」了,但仍意味着四分之一的产出压在几个人身上。
这就引出了社交压力:核心看着白嫖的人不走,会觉得「凭什么我一个人扛」;管理者看着产出不够,会想「是不是该踢人」。压力有两副面孔:
- 过滤效应(对低贡献者):配额把长期不出力的人筛掉,联盟「瘦身」。
- 倦怠效应(对高贡献者):配额本身是一种压力,定得越高,连达标的人也会累、会走。
这两副面孔方向相反,所以配额不是一个「越高越好」的旋钮——这就是本节要量的东西。
D 层:一份联盟与 GVG 配置
下面这段是 D 层配置,bastion 的联盟生态、GVG 战场、健康度三组参数都在这一个文件里。注意所有比率都是整数百分比(pressureRatePct: 60 表示每缺口 100% 基准贡献、流失概率 60%),随机种子固定,Python 与 TypeScript 读同一份。
{
"version": 1,
"note": "第 15 章「联盟与 GVG 运营」参数(n15 前缀,独立于卷一 ch*)。贡献/战力一律整数,比率整数百分比;随机用自实现确定性 PRNG mulberry32(Python/TS 同算法同种子),禁止浮点系数进 config。",
"seed": 20261013,
"alliance": {
"note": "15.1 联盟生态:成员赛季贡献分布 + 贡献配额(社交压力)。贡献为整数(赛季单位),配额为绝对值;低于配额的成员按缺口成比例流失(过滤),高于配额的成员也承受配额压力(倦怠)。",
"members": 50,
"baseContribution": 100,
"carryPct": 8,
"carryMultiplierPct": 500,
"activePct": 72,
"lurkerMaxPct": 40,
"quotaScan": [0, 20, 40, 60, 80, 100, 120, 150],
"pressureRatePct": 60,
"burnoutRatePct": 5,
"maxChurnPct": 90,
"freeRiderPct": 50,
"trials": 30
},
"gvg": {
"note": "15.2 GVG:联盟总战力为整数;战场为 3 据点得分赛,每回合按地形偏置判定据点归属,累计得分达 captureScore 即胜,未分胜负则打满 maxRounds。",
"alliances": 400,
"powerCenter": 50000,
"powerSpread": 15000,
"matchBandWidths": [5000, 10000, 20000, 40000, 80000],
"matchesPerBand": 4000,
"battlefield": {
"points": 3,
"pointWeightsPct": [30, 40, 30],
"terrainBiasPct": [12, 0, -12],
"roundScore": 10,
"captureScore": 100,
"maxRounds": 30,
"shortBattleRounds": 12
},
"trials": 30
},
"health": {
"note": "15.3 联盟健康度:赛季 60 天,健康 vs 衰退两剧本逐日观测;衰退剧本自 declineStartDay 起参与概率按 engageDeclineRatePct、在线概率按 presenceDeclineRatePct 逐日下滑(参与先于在线瓦解)。",
"seasonDays": 60,
"members": 50,
"baseContribution": 100,
"activeProbPct": 85,
"declineStartDay": 20,
"engageDeclineRatePct": 2,
"presenceDeclineRatePct": 1,
"presentFloorPct": 45,
"engageFloorPct": 25,
"missChurnDays": 5,
"contributionSmoothDays": 7,
"trials": 20,
"thresholds": {
"activeRatePct": 60,
"giniMaxPct": 70,
"gvgParticipatePct": 50,
"contributionPct": 85,
"churnMaxPct": 10
}
}
}
S 层:配额扫描
下面这段是 S 层仿真脚本。它对固定的一批成员(每档按比例抽)逐个配额 Q 施加两种压力:低于 Q 的按缺口流失、高于 Q 的按 Q 流失;每次试验换一批成员、换一套流失随机数,30 次试验取平均以压噪声。
# file: sim/n15_alliance.py
# tier: S
# verify: python3 sim/n15_alliance.py --config config/ --out out/
"""联盟生态与社交压力(15.1):贡献配额 Q 的「产出—留存」权衡。
对固定成员集逐配额 Q 施加两种压力:
· 过滤效应:c_i < Q 的成员按缺口成比例流失;
· 压力效应:c_i >= Q 的成员也按 Q 成比例流失(倦怠)。
产出 out/n15_alliance.json + .csv + .svg。随机用自实现 mulberry32。
"""
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 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 gen_members(a, rng, n):
base = a["baseContribution"]
out = []
for _ in range(n):
r = int(rng() * 100)
if r < a["carryPct"]:
out.append(base * a["carryMultiplierPct"] // 100)
elif r < a["activePct"]:
out.append(base + int(rng() * base))
else:
out.append(int(rng() * base * a["lurkerMaxPct"] // 100))
return out
def gini(xs):
xs = sorted(xs)
n = len(xs)
total = sum(xs)
if not n or not total:
return 0.0
cum = sum(i * v for i, v in enumerate(xs, 1))
return 2.0 * cum / (n * total) - (n + 1.0) / n
def simulate(a, rng, q, contribs):
base = a["baseContribution"]
cap = a["maxChurnPct"] / 100.0
total = sum(contribs)
kept = []
for c in contribs:
if c < q:
p = min(cap, (q - c) * a["pressureRatePct"] / (base * 100.0))
else:
p = min(cap, q * a["burnoutRatePct"] / (base * 100.0))
if rng() >= p:
kept.append(c)
fr = base * a["freeRiderPct"] // 100
return {
"retentionPct": 100.0 * len(kept) / len(contribs),
"retainedOutputPct": 100.0 * sum(kept) / total,
"freeRiderPct": 100.0 * sum(1 for c in kept if c < fr) / len(kept) if kept else 0.0,
"gini": gini(kept),
}
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"))
a = cfg["alliance"]
seed = args.seed if args.seed is not None else cfg["seed"]
n, trials = a["members"], a["trials"]
keys = ("retentionPct", "retainedOutputPct", "freeRiderPct", "gini")
rows = []
for i, q in enumerate(a["quotaScan"]):
acc = dict.fromkeys(keys, 0.0)
for t in range(trials):
contribs = gen_members(a, mulberry32(seed + 1009 * t + i), n)
m = simulate(a, mulberry32(seed + 7919 * t + 101 + i), q, contribs)
for k in keys:
acc[k] += m[k]
row = {"quota": q}
for k in keys:
row[k] = round(acc[k] / trials, 3 if k == "gini" else 1)
row["outputPerRetainedPct"] = round(row["retainedOutputPct"] / row["retentionPct"] * 100.0, 1)
rows.append(row)
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "n15_alliance.json"), "w", encoding="utf-8") as f:
json.dump({"version": 1, "seed": seed, "members": n, "trials": trials, "rows": rows}, f,
ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "n15_alliance.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["quota", "retentionPct", "retainedOutputPct", "freeRiderPct", "gini", "outputPerRetainedPct"])
for r in rows:
w.writerow([r["quota"], r["retentionPct"], r["retainedOutputPct"], r["freeRiderPct"],
r["gini"], r["outputPerRetainedPct"]])
ys = [r["retainedOutputPct"] for r in rows]
lo, hi = min(ys), max(ys)
svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": "0 0 640 360", "width": "640", "height": "360"})
ET.SubElement(svg, "rect", {"width": "640", "height": "360", "fill": "#ffffff"})
ET.SubElement(svg, "polyline", {"points": polyline_points(ys, lo, hi),
"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_alliance.svg"), encoding="utf-8", xml_declaration=True)
for r in rows:
print(f"配额 {r['quota']:>4} 留存率 {r['retentionPct']:>5}% 保留产出率 {r['retainedOutputPct']:>5}% "
f"白嫖率 {r['freeRiderPct']:>5}% 基尼 {r['gini']:.3f} 人均产出指数 {r['outputPerRetainedPct']:>5}")
if __name__ == "__main__":
main()
本机实跑输出(30 次试验平均;同机相对量级,绝对数值不可移植):
配额 0 留存率 100.0% 保留产出率 100.0% 白嫖率 27.7% 基尼 0.424 人均产出指数 100.0
配额 20 留存率 98.3% 保留产出率 98.9% 白嫖率 26.8% 基尼 0.416 人均产出指数 100.6
配额 40 留存率 94.0% 保留产出率 97.3% 白嫖率 24.4% 基尼 0.399 人均产出指数 103.5
配额 60 留存率 91.0% 保留产出率 96.6% 白嫖率 23.3% 基尼 0.382 人均产出指数 106.2
配额 80 留存率 86.1% 保留产出率 94.8% 白嫖率 22.1% 基尼 0.385 人均产出指数 110.1
配额 100 留存率 83.3% 保留产出率 92.9% 白嫖率 16.9% 基尼 0.353 人均产出指数 111.5
配额 120 留存率 80.7% 保留产出率 92.6% 白嫖率 15.1% 基尼 0.325 人均产出指数 114.7
配额 150 留存率 70.2% 保留产出率 87.7% 白嫖率 7.7% 基尼 0.277 人均产出指数 124.9
读配额曲线
四个读数,每个都能落到一个具体的联盟管理决策上。
一、低配额区间是「丢小保大」。 从 Q=0 到 Q=40,留存率只掉 6 个百分点(100%→94.0%),保留产出率却只掉 2.7 个(100%→97.3%)。也就是说,踢掉最不活跃的那 6% 的人,只损失了 2.7% 的产出——因为他们本来就没贡献多少。这是配额最「划算」的一段:人少了,产出几乎没少,人均产出指数从 100.0 升到 103.5。
二、白嫖率下降得比想象中慢。 即使配额拉到 40,白嫖率仍有 24.4%(Q=0 时 27.7%)。原因是过滤是概率性的——低贡献者不是必踢,只是「按缺口成比例」地更容易流失。想彻底清掉白嫖,得把配额压到接近基准(Q=100 时白嫖率才降到 16.9%)。
三、越过基准贡献带就开始割肉。 Q 从 100 升到 150,留存率掉了 13.1 个百分点(83.3%→70.2%),保留产出率掉了 5.2 个(92.9%→87.7%)。这一段被筛掉的不再是白嫖,而是刚过基准的活跃成员——他们贡献不小,走一个少一块。这就是倦怠效应开始压过过滤效应的位置。
四、基尼随配额单调下降。 从 0.424 降到 0.277——配额确实让联盟更「平均」了。但要注意:平均不等于健康。把白嫖和低贡献的活跃成员一起筛掉,剩下的人贡献更接近,基尼自然下降,可联盟的总盘子也小了。基尼是结果,不是目标。
把这些合起来,配额的合理区间是**「刚过白嫖带、远低于活跃带」**——本例大约在 20 到 60 之间。低于这个区间白嫖泛滥,高于这个区间开始伤及活跃成员。
小结
- 联盟贡献高度集中:核心约 8%、活跃约 64%、白嫖约 28%,基尼约 0.42。
- 社交压力有两副面孔:过滤效应筛低贡献者,倦怠效应伤高贡献者,方向相反。
- 配额 40 是最划算的一档:只丢 6% 的人,保住 97.3% 的产出——因为被筛掉的本来就不出力。
- 配额越过基准贡献带就开始割肉:Q=100→150 时留存掉 13.1 个百分点,产出掉 5.2 个。
- 基尼下降是结果不是目标:平均化可能只是把联盟做小。
配额是「管人」的旋钮。下一节换到「打仗」——一群人怎么被配到一起、战场怎么打、怎么保证强弱差距不会让 GVG 变成单方面碾压。
阅读导航:上一节:目录 · 下一节:15.2 GVG 匹配与战场设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。