14.3 赛季结算与过渡

把 10000 名玩家按排名分成王者、精英、骨干、活跃、休闲五段,逐段算出结算发放:总发放 850 万玉璧,前 1% 拿走 9%、前 10% 拿走 41%,量出奖励集中度。再用 TS 原型对同一份配置重算一遍保证逐位一致,并算清「结算 1 天 + 准备 3 天」的过渡期占整个 64 天周期的 6%,玩家在这段时间里没有赛季进度产出。

赛季有两个时刻最考验数值:开局和结算。开局是所有人归零,本节处理结算——赛季结束时,那一笔按排名发放的大额奖励怎么分,以及结算到新赛季之间的过渡期,玩家在干什么。

先划边界。第 8 章《赛季数值膨胀控制》处理的是跨赛季的进度继承与压缩——城建清到几级、英雄保不保、荣誉怎么折算,那是「下个赛季的起点」。本节处理的是赛季末这一笔发放本身:按排名分段发多少、发得多集中、发完之后到新赛季之间有多长的空档。卷一 13.3《赛季重置与货币回收》讲的是货币存量在赛季边界怎么回收,本节讲的是排名奖励怎么发放——一个是回收旧存量,一个是发放新奖励,方向相反。

结算是赛季里唯一一次大额发放

赛季内所有活动的奖励都是细水长流:签到每天给一点、竞赛发几档、联盟战分几轮。结算不一样——它是一次性的、按排名分档的、金额最大的发放。 一个赛季的产出里,可能有相当一部分是在结算这一刻涌出去的。

结算发放的设计有三个参数:

  • 分几段:按排名把玩家分成几档(前 1%、前 10%、前 30%……)。
  • 每段发多少:越靠前的段奖励越高,形成冲刺动机。
  • 发的集中度:顶段拿走了总发放的多大比例——这个数决定「头部通吃」还是「雨露均沾」。

排名分段与发放集中度

《王朝纪元》把结算奖励分成五段。设计意图很清楚:顶段要够厚,才能驱动头部玩家为一个名次拼命;但底段不能太薄,否则大多数玩家会觉得「反正拿不到奖、不如不冲」。

这里引入一个能算的指标——发放集中度:

  • 前 1% 发放占比 = 王者组发放 / 总发放。
  • 前 10% 发放占比 = (王者组 + 精英组)发放 / 总发放。

这两个数如果太高,说明奖励被头部通吃,中腰部玩家没有冲刺动机;太低则头部没动力。它和 Gini 系数是一回事,只是用一个更直观的「前 X% 拿走多少」来表述。

把分段写进配置

下面这份是 D 层(静态层)配置。赛季长度、准备天数、结算小时都复用共享的 config/season.json;本文件只补两样——排名分段的奖励表,和过渡期的参数:

{
  "version": 1,
  "note": "第 14 章 14.3 赛季结算与过渡(n14 前缀)。season.json 提供赛季长度、准备天数、结算小时;本文件给出排名分段奖励与过渡期参数。整数与整数百分比,禁浮点。",
  "population": 10000,
  "rankBands": [
    { "id": "champion", "name": "王者组(前 1%)", "sharePct": 1, "rewardJade": 8000, "honor": 12000 },
    { "id": "elite", "name": "精英组(前 10%)", "sharePct": 9, "rewardJade": 3000, "honor": 6000 },
    { "id": "core", "name": "骨干组(前 30%)", "sharePct": 20, "rewardJade": 1200, "honor": 3000 },
    { "id": "active", "name": "活跃组(前 70%)", "sharePct": 40, "rewardJade": 500, "honor": 1000 },
    { "id": "casual", "name": "休闲组(后 30%)", "sharePct": 30, "rewardJade": 200, "honor": 300 }
  ],
  "transition": {
    "note": "过渡期:结算窗口(settlementHours)+ 准备期(prepDays),玩家在此期间无赛季进度产出;prepCarryoverPct 为准备期可保留的赛季末状态比例。",
    "prepCarryoverPct": 100,
    "rewardGrantDelayHours": 24
  }
}

sharePct 是各段人数占比,从顶到底累加正好是 100%(1 + 9 + 20 + 40 + 30)。rewardJade 是每人发放,honor 是同时发放的荣誉值——荣誉进入下一季的继承折算,这条链路第 8 章讲过,本节只负责把荣誉的总量算出来。

S 层:分桶算发放

下面这段是 S 层仿真脚本。它读上面的配置与共享的 season.json,按各段人数占比把玩家分桶,逐段算出结算发放(玉璧 + 荣誉)、发放集中度,以及「结算 + 准备」过渡期在一个完整周期里的占比:

# file: sim/n14_settlement.py
# tier: S
# verify: python3 sim/n14_settlement.py --config config/ --out out/
"""14.3 赛季结算与过渡:按排名分段把人口分桶,算出各段结算发放(玉璧 + 荣誉)、
发放集中度,以及「结算 + 准备」过渡期在一个完整周期里的占比(过渡期无赛季进度产出)。
产出 out/n14_settlement.json + out/n14_settlement.csv + out/n14_settlement.svg。整数运算,禁浮点。"""

from __future__ import annotations

import argparse
import csv
import json
import os

from svg_line import render


def load(cfg_dir: str, name: str) -> dict:
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as fh:
        return json.load(fh)


def ceil_div(a: int, b: int) -> int:
    return (a + b - 1) // b


def main() -> int:
    here = os.path.dirname(os.path.abspath(__file__))
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default=os.path.join(here, "..", "config"))
    ap.add_argument("--out", default=os.path.join(here, "..", "out"))
    args = ap.parse_args()
    os.makedirs(args.out, exist_ok=True)

    cfg = load(args.config, "n14_settlement")
    season = load(args.config, "season")
    pop = cfg["population"]
    days = season["seasonLengthDays"]
    prep = season["prepDays"]
    settle_days = ceil_div(season["settlementHours"], 24)

    rows, cursor = [], 0
    for b in cfg["rankBands"]:
        cnt = pop * b["sharePct"] // 100
        rows.append({"id": b["id"], "name": b["name"], "sharePct": b["sharePct"],
                     "count": cnt, "rewardJade": b["rewardJade"], "honor": b["honor"],
                     "payoutJade": cnt * b["rewardJade"], "payoutHonor": cnt * b["honor"]})
        cursor += cnt
    # 百分比地板除可能留下尾数,补进最后一个段,保证分桶总数等于人口
    rows[-1]["count"] += pop - cursor
    rows[-1]["payoutJade"] = rows[-1]["count"] * rows[-1]["rewardJade"]
    rows[-1]["payoutHonor"] = rows[-1]["count"] * rows[-1]["honor"]

    total_jade = sum(r["payoutJade"] for r in rows)
    total_honor = sum(r["payoutHonor"] for r in rows)
    top1 = rows[0]["payoutJade"] * 100 // total_jade
    top10 = (rows[0]["payoutJade"] + rows[1]["payoutJade"]) * 100 // total_jade

    cycle = days + prep + settle_days
    idle = prep + settle_days
    summary = {
        "population": pop, "seasonLengthDays": days, "prepDays": prep,
        "settlementDays": settle_days, "totalCycleDays": cycle,
        "idleDays": idle, "idleCyclePct": idle * 100 // cycle,
        "totalPayoutJade": total_jade, "totalPayoutHonor": total_honor,
        "top1PayoutSharePct": top1, "top10PayoutSharePct": top10,
        "rewardGrantDelayHours": cfg["transition"]["rewardGrantDelayHours"],
    }

    with open(os.path.join(args.out, "n14_settlement.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["id", "sharePct", "count", "rewardJade", "payoutJade", "payoutHonor"])
        for r in rows:
            w.writerow([r["id"], r["sharePct"], r["count"], r["rewardJade"], r["payoutJade"], r["payoutHonor"]])

    render([r["payoutJade"] for r in rows], os.path.join(args.out, "n14_settlement.svg"),
           "season settlement payout by rank band")

    report = {"version": 1, "bands": rows, "summary": summary}
    with open(os.path.join(args.out, "n14_settlement.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
    print(json.dumps(summary, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑(--config config/ --out out/)得到:

{"population": 10000, "seasonLengthDays": 60, "prepDays": 3, "settlementDays": 1, "totalCycleDays": 64, "idleDays": 4, "idleCyclePct": 6, "totalPayoutJade": 8500000, "totalPayoutHonor": 17500000, "top1PayoutSharePct": 9, "top10PayoutSharePct": 41, "rewardGrantDelayHours": 24}

结果:前 1% 拿走 9%、前 10% 拿走 41%

把五段摊开(out/n14_settlement.csv,人口 10,000):

分段人数占比人数每人奖励发放合计荣誉合计
王者组(前 1%)1%1008,000800,0001,200,000
精英组(前 10%)9%9003,0002,700,0005,400,000
骨干组(前 30%)20%2,0001,2002,400,0006,000,000
活跃组(前 70%)40%4,0005002,000,0004,000,000
休闲组(后 30%)30%3,000200600,000900,000
合计100%10,000—8,500,00017,500,000

读这张表要看两个数:

  • 前 1% 拿走 9%。王者组只有 100 人,却拿走了 850 万总发放里的 80 万,占比 9%。这是头部倾斜但不极端——如果奖励曲线更陡(比如顶段发 20,000),前 1% 占比会迅速冲到 20% 以上,中腰部会觉得冲刺无望。
  • 前 10% 拿走 41%。前 10%(1,000 人)拿走近四成发放,剩下 90% 的人分六成。这个比例决定了「腰部玩家冲不冲」——41% 意味着前十名之后仍有大量奖励可分,腰部有动力;如果这个数冲到 70% 以上,后 90% 就成了陪跑。

发放合计 850 万玉璧、荣誉 1,750 万,是这一季所有活动奖励之外、额外一次性涌出的量。把它和 14.2 的赛季产出预算放在一起看:8,500,000 玉璧等价 ≈ 8.5 亿资源,对比单人赛季产出 150 万资源,是按全服人口汇总的量级——它不改变单个玩家的产出预算,但它决定了「赛季末大家手里会突然多出多少东西」,是下一季开局的通胀来源,所以第 8 章才要用重置和压缩把它压回去。

P 层:同一份配置,TypeScript 侧再算一遍

赛季结算这种规则,后台要跑发奖、运营后台要做发放预测,两侧算错一个数就会发错账。下面这段是 P 层原型,读同一份 config/n14_settlement.json 与 config/season.json,把分桶与发放重算一遍,与 Python 侧逐位一致:

// file: proto/src/n14_season.ts
// tier: P
// verify: node --test proto/test/n14.test.ts
// 与 sim/n14_settlement.py 同构:读同一份 config/n14_settlement.json + config/season.json,
// 按排名分段分桶、算结算发放与过渡期占比,全程整数(Math.floor 地板除),与 Python 逐位一致。

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

export interface RankBand { id: string; name: string; sharePct: number; rewardJade: number; honor: number }
export interface SettlementConfig {
  population: number; rankBands: RankBand[];
  transition: { prepCarryoverPct: number; rewardGrantDelayHours: number };
}
export interface SeasonCfg { seasonLengthDays: number; prepDays: number; settlementHours: number }

export function loadSettlement(): SettlementConfig {
  return loadConfig<SettlementConfig>("n14_settlement");
}
export function loadSeasonCfg(): SeasonCfg {
  return loadConfig<SeasonCfg>("season");
}

export interface BandRow {
  id: string; name: string; sharePct: number; count: number;
  rewardJade: number; honor: number; payoutJade: number; payoutHonor: number;
}
export interface SettlementResult {
  bands: BandRow[]; totalPayoutJade: number; totalPayoutHonor: number;
  top1PayoutSharePct: number; top10PayoutSharePct: number;
  totalCycleDays: number; idleDays: number; idleCyclePct: number;
}

export function settle(cfg: SettlementConfig, sc: SeasonCfg): SettlementResult {
  const pop = cfg.population;
  const settleDays = Math.floor((sc.settlementHours + 23) / 24);
  const bands: BandRow[] = [];
  let cursor = 0;
  for (const b of cfg.rankBands) {
    const count = Math.floor((pop * b.sharePct) / 100);
    bands.push({
      id: b.id, name: b.name, sharePct: b.sharePct, count,
      rewardJade: b.rewardJade, honor: b.honor,
      payoutJade: count * b.rewardJade, payoutHonor: count * b.honor,
    });
    cursor += count;
  }
  const last = bands[bands.length - 1];
  last.count += pop - cursor;
  last.payoutJade = last.count * last.rewardJade;
  last.payoutHonor = last.count * last.honor;

  const totalJade = bands.reduce((a, r) => a + r.payoutJade, 0);
  const totalHonor = bands.reduce((a, r) => a + r.payoutHonor, 0);
  const top1 = Math.floor((bands[0].payoutJade * 100) / totalJade);
  const top10 = Math.floor(((bands[0].payoutJade + bands[1].payoutJade) * 100) / totalJade);
  const cycle = sc.seasonLengthDays + sc.prepDays + settleDays;
  const idle = sc.prepDays + settleDays;
  return {
    bands, totalPayoutJade: totalJade, totalPayoutHonor: totalHonor,
    top1PayoutSharePct: top1, top10PayoutSharePct: top10,
    totalCycleDays: cycle, idleDays: idle, idleCyclePct: Math.floor((idle * 100) / cycle),
  };
}

export function settlementReport(): SettlementResult {
  return settle(loadSettlement(), loadSeasonCfg());
}

断言写进 proto/test/n14.test.ts,跑 node --test proto/test/n14.test.ts:

✔ 14.3 分桶总数等于人口 (0.457667ms)
✔ 14.3 结算发放总额与 Python 一致 (0.063208ms)
✔ 14.3 发放集中度:前 1% / 前 10% 占比 (0.059917ms)
✔ 14.3 过渡期占比:结算 1 天 + 准备 3 天 (0.535209ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

4 条全过。TS 侧算出的 8500000 / 17500000 / 9 / 41 / 64 / 6 与 Python 侧逐位一致——这就是「一份配置、两处消费」:排名分段改一个 rewardJade,两侧发放表同步变,不会一边按 8,000 发、一边还按旧值算。

过渡期:结算 1 天 + 准备 3 天 = 6%

赛季不是「结束就立刻开下一季」。共享的 config/season.json 给了两个参数:settlementHours = 24(结算窗口)与 prepDays = 3(准备期)。结算 24 小时 = 1 天,于是:

  • 完整周期 = 60(赛季)+ 3(准备)+ 1(结算)= 64 天。
  • 过渡期(准备 + 结算)= 4 天,占整个周期的 6%(idleCyclePct = 6)。

这 6% 不是小事。在过渡期里,玩家没有赛季进度产出——地图重开、排行结算、奖励发放,玩家只能看着,不能推进。设计上有三个要盯的点:

  1. 奖励到账延迟 24 小时(rewardGrantDelayHours)。结算窗口本身是 1 天,玩家冲完最后一名次,要等 24 小时才知道自己拿了什么。延迟太长,冲刺的爽感会凉。
  2. 准备期 3 天是「缓冲」不是「空档」。 好的准备期会给玩家做这几件事:预览下季规则、继承上季荣誉、领结算奖励、规划开荒。如果准备期什么都不给玩家做,那 3 天就是纯流失窗口。
  3. 6% 的占比要控制。 如果赛季只有 30 天,同样的 4 天过渡就占 12%——赛季越短,过渡期的相对成本越高。赛季长度的下限,部分就由「过渡期占比不能太高」决定。

设计要点

把这一节压成三条:

  1. 结算发放要同时看「总额」和「集中度」。 总额 850 万玉璧、荣誉 1,750 万,决定赛季末涌出多少;前 1% 拿 9%、前 10% 拿 41%,决定冲刺有没有吸引力。两个数一起调,才能既让头部有动力、又不让腰部陪跑。
  2. 分段奖励表要两侧一致。 后台发奖与运营预测各读一份配置,用 P 层原型把 TS 与 Python 对齐,避免「后台按 8,000 发、后台预测按旧值算」这种对不上账的事故。
  3. 过渡期是「隐形流失窗口」。 结算 1 天 + 准备 3 天 = 周期 6%,这段时间玩家无进度产出。缩短延迟、给准备期安排可做的事,是把这段窗口从「空转」变成「期待」的关键。

本章到此结束:14.1 定结构、14.2 算预算、14.3 收尾发放。三节合起来,一个赛季从开场到结算的运营骨架就搭完了。下一章离开数值,进入联盟与 GVG——赛季里最重的那个活动(联盟战)背后的社交结构与战场设计。

阅读导航:上一节:14.2 活动模板与奖励预算 · 下一节:15.1 联盟生态与社交压力 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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