15.1 联盟生态与社交压力

联盟是 SLG 的社交骨架:一群人的贡献高度不平均,少数核心扛着大多数人。这一节把贡献分布与贡献配额建成可跑模型,扫描 0 到 150 的配额,看联盟在留存率与保留产出率之间怎么权衡——配额 40 只丢 6% 的人,却保住 97.3% 的产出。

前 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 之间。低于这个区间白嫖泛滥,高于这个区间开始伤及活跃成员。

小结

  1. 联盟贡献高度集中:核心约 8%、活跃约 64%、白嫖约 28%,基尼约 0.42。
  2. 社交压力有两副面孔:过滤效应筛低贡献者,倦怠效应伤高贡献者,方向相反。
  3. 配额 40 是最划算的一档:只丢 6% 的人,保住 97.3% 的产出——因为被筛掉的本来就不出力。
  4. 配额越过基准贡献带就开始割肉:Q=100→150 时留存掉 13.1 个百分点,产出掉 5.2 个。
  5. 基尼下降是结果不是目标:平均化可能只是把联盟做小。

配额是「管人」的旋钮。下一节换到「打仗」——一群人怎么被配到一起、战场怎么打、怎么保证强弱差距不会让 GVG 变成单方面碾压。

阅读导航:上一节:目录 · 下一节:15.2 GVG 匹配与战场设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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