赛季有两个时刻最考验数值:开局和结算。开局是所有人归零,本节处理结算——赛季结束时,那一笔按排名发放的大额奖励怎么分,以及结算到新赛季之间的过渡期,玩家在干什么。
先划边界。第 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% | 100 | 8,000 | 800,000 | 1,200,000 |
| 精英组(前 10%) | 9% | 900 | 3,000 | 2,700,000 | 5,400,000 |
| 骨干组(前 30%) | 20% | 2,000 | 1,200 | 2,400,000 | 6,000,000 |
| 活跃组(前 70%) | 40% | 4,000 | 500 | 2,000,000 | 4,000,000 |
| 休闲组(后 30%) | 30% | 3,000 | 200 | 600,000 | 900,000 |
| 合计 | 100% | 10,000 | — | 8,500,000 | 17,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% 不是小事。在过渡期里,玩家没有赛季进度产出——地图重开、排行结算、奖励发放,玩家只能看着,不能推进。设计上有三个要盯的点:
- 奖励到账延迟 24 小时(
rewardGrantDelayHours)。结算窗口本身是 1 天,玩家冲完最后一名次,要等 24 小时才知道自己拿了什么。延迟太长,冲刺的爽感会凉。 - 准备期 3 天是「缓冲」不是「空档」。 好的准备期会给玩家做这几件事:预览下季规则、继承上季荣誉、领结算奖励、规划开荒。如果准备期什么都不给玩家做,那 3 天就是纯流失窗口。
- 6% 的占比要控制。 如果赛季只有 30 天,同样的 4 天过渡就占 12%——赛季越短,过渡期的相对成本越高。赛季长度的下限,部分就由「过渡期占比不能太高」决定。
设计要点
把这一节压成三条:
- 结算发放要同时看「总额」和「集中度」。 总额 850 万玉璧、荣誉 1,750 万,决定赛季末涌出多少;前 1% 拿 9%、前 10% 拿 41%,决定冲刺有没有吸引力。两个数一起调,才能既让头部有动力、又不让腰部陪跑。
- 分段奖励表要两侧一致。 后台发奖与运营预测各读一份配置,用 P 层原型把 TS 与 Python 对齐,避免「后台按 8,000 发、后台预测按旧值算」这种对不上账的事故。
- 过渡期是「隐形流失窗口」。 结算 1 天 + 准备 3 天 = 周期 6%,这段时间玩家无进度产出。缩短延迟、给准备期安排可做的事,是把这段窗口从「空转」变成「期待」的关键。
本章到此结束:14.1 定结构、14.2 算预算、14.3 收尾发放。三节合起来,一个赛季从开场到结算的运营骨架就搭完了。下一章离开数值,进入联盟与 GVG——赛季里最重的那个活动(联盟战)背后的社交结构与战场设计。
阅读导航:上一节:14.2 活动模板与奖励预算 · 下一节:15.1 联盟生态与社交压力 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。