7.1 留下了一个分数。但玩家不会说「我 1293 分」,他会说「我是铂金」。分数是给系统看的,段位是给玩家看的。 段位把一条连续的分数轴切成几段有名字的区间,每一段都是一个可感知的身份和一个可追逐的目标。
这一节回答三个问题:段位怎么切、每段多少人、以及——最容易被忽略的——段位到底把实力分得准不准。
段位是「分数」的翻译层
段位至少承担三件事:
- 可感知:
1293记不住,铂金一眼就懂。名字给了一个玩家向别人介绍自己的方式。 - 可追逐:目标要够近才动人。「上钻石」比「涨 144 分」更有画面感。
- 可分配:运营要按段位发奖励、做活动、算赛季结算——段位是分层运营的抓手。
正因为段位是玩家-facing 的,它的分布就成了设计目标:青铜太多显得游戏没高手,钻石太多显得段位不值钱。这一节的核心就是把「段位分布」变成一个可核对的东西。
用百分位切段,而不是固定分数线
切段有两条路:
- 固定分数线:青铜
0–1000、白银1000–1200…… 问题是分数会漂。赛季膨胀、新玩法加产出、玩家人数变化,都会让分数整体上移,于是「白银」里的人越来越多,段位失去区分度。第 8 章会专门讲这个膨胀。 - 百分位切段:按「分数在全体里的排名」切,比如前 10% 是钻石、10%–30% 是铂金。这样每段的人数占比天然固定,分数怎么漂都不影响段位的稀缺性。
本机取百分位方案,配置里给的是各段位的下界百分位与目标占比:
| 段位 | 下界百分位 cutPct | 目标占比 targetPct |
|---|---|---|
| 青铜 | 0 | 20% |
| 白银 | 20 | 25% |
| 黄金 | 45 | 25% |
| 铂金 | 70 | 20% |
| 钻石 | 90 | 10% |
S 层:切段与分布核对
下面这段是 S 层脚本,读同一份 config/,用 7.1 的同一批玩家(同种子 → 同一批评分),按百分位切段,核对实际占比、段位边界与段内实力分布。
# file: sim/n07_tiers.py
# tier: S
# verify: python3 sim/n07_tiers.py --config config/ --out out/
"""分段与段位设计(7.2):把收敛后的匹配分按百分位切成段位,检验分布与实力分离。
读 7.1 的同一批玩家(同 config 同种子 → 同一批评分),按 config 的 cutPct 切段位:
· 段位边界的匹配分是多少(可写进配置表)
· 各段位实际占比 vs 目标占比
· 各段位真实实力的 p10/p90(段位内实力重叠)
· 用「评分分段」与「真实实力分段」的错位率,衡量评分把段位分得多准
产出:out/n07_tiers.json + out/n07_tiers.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n07_elo import load, run_population # noqa: E402
def percentile_cut(sorted_vals, cut_pct):
"""nearest-rank 下界:cutPct 百分位对应的取值(与 TS 侧逐位一致)。"""
n = len(sorted_vals)
idx = (cut_pct * n) // 100
return sorted_vals[min(n - 1, idx)]
def tier_boundaries(sorted_ratings, tiers):
return [{"id": t["id"], "name": t["name"], "cutPct": t["cutPct"],
"minRating": percentile_cut(sorted_ratings, t["cutPct"])} for t in tiers]
def assign_tier(rating, bounds):
"""取 minRating <= rating 的最大段位;低于最低段位则归最低段。"""
cur = bounds[0]["id"]
for b in bounds:
if rating >= b["minRating"]:
cur = b["id"]
return cur
def pctl(sorted_vals, p):
return sorted_vals[min(len(sorted_vals) - 1, (p * len(sorted_vals)) // 100)]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(os.path.join(args.config, "n07_match.json"))
tiers = cfg["tiers"]["list"]
res = run_population(cfg, cfg["seed"])
ratings, skills = res["elo"], res["skills"]
n = len(ratings)
sorted_r = sorted(ratings)
sorted_s = sorted(skills)
bounds = tier_boundaries(sorted_r, tiers)
skill_bounds = tier_boundaries(sorted_s, tiers)
# 各段位实际人数与占比
counts = {t["id"]: 0 for t in tiers}
for r in ratings:
counts[assign_tier(r, bounds)] += 1
target = {t["id"]: t["targetPct"] for t in tiers}
# 段位内真实实力 p10/p90
by_tier_skill = {t["id"]: [] for t in tiers}
for r, s in zip(ratings, skills):
by_tier_skill[assign_tier(r, bounds)].append(s)
tier_rows = []
for t in tiers:
tid = t["id"]
s = sorted(by_tier_skill[tid])
tier_rows.append({
"id": tid, "name": t["name"], "minRating": next(b["minRating"] for b in bounds if b["id"] == tid),
"count": counts[tid], "actualPct": round(100.0 * counts[tid] / n, 2),
"targetPct": target[tid],
"skillP10": pctl(s, 10), "skillP50": pctl(s, 50), "skillP90": pctl(s, 90),
})
# 错位率:评分分段 vs 真实实力分段
mismatch = sum(1 for r, s in zip(ratings, skills)
if assign_tier(r, bounds) != assign_tier(s, skill_bounds))
report = {
"version": 1, "seed": cfg["seed"], "players": n,
"boundaries": bounds, "tierRows": tier_rows,
"mismatchPct": round(100.0 * mismatch / n, 2),
"topGap": bounds[-1]["minRating"] - bounds[0]["minRating"],
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "n07_tiers.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "n07_tiers.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["tier", "name", "minRating", "count", "actualPct", "targetPct", "skillP10", "skillP50", "skillP90"])
for r in tier_rows:
w.writerow([r["id"], r["name"], r["minRating"], r["count"], r["actualPct"], r["targetPct"],
r["skillP10"], r["skillP50"], r["skillP90"]])
for r in tier_rows:
print(f"{r['name']:>4} 分界 {r['minRating']:>4} 占比 {r['actualPct']:>5}% (目标 {r['targetPct']}%) "
f"实力 p10/p50/p90 {r['skillP10']}/{r['skillP50']}/{r['skillP90']}")
print(f"评分分段 vs 实力分段错位率 {report['mismatchPct']}% 顶底段位分界差 {report['topGap']} 分")
# TS 测试向量:固定小数组的分段边界(供 proto/test/n07.test.ts 对照)
demo = [1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
db = [b["minRating"] for b in tier_boundaries(sorted(demo), tiers)]
print(f"测试向量 demo10 边界 {db}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出(同机相对量级,绝对数值不可移植):
青铜 分界 666 占比 20.0% (目标 20%) 实力 p10/p50/p90 840/967/1068
白银 分界 1045 占比 24.85% (目标 25%) 实力 p10/p50/p90 1034/1123/1202
黄金 分界 1179 占比 25.15% (目标 25%) 实力 p10/p50/p90 1151/1232/1311
铂金 分界 1293 占比 20.0% (目标 20%) 实力 p10/p50/p90 1270/1347/1435
钻石 分界 1437 占比 10.0% (目标 10%) 实力 p10/p50/p90 1400/1487/1571
评分分段 vs 实力分段错位率 29.65% 顶底段位分界差 771 分
测试向量 demo10 边界 [1000, 1200, 1400, 1700, 1900]
段位边界与实力重叠
先看分布:实测占比 20.0 / 24.85 / 25.15 / 20.0 / 10.0,与目标 20 / 25 / 25 / 20 / 10 基本重合。百分位切段天然贴合目标分布——因为切的就是排名,人数是切出来的,不是凑出来的。
再看边界:青铜 666、白银 1045、黄金 1179、铂金 1293、钻石 1437。注意相邻段位的边界间隔并不均匀——白银到黄金只差 134 分,而青铜到白银差 379 分。这不是设计失误,是玩家分布不均:中低分段人稀、分数跨度大,中高分段人密、一分压一分。用百分位切,段位边界会自动反映这种密度。
最后看段内实力重叠:每段真实实力的 p10 到 p90 铺开在 160 到 230 分之间(青铜最宽,228 分;黄金 160 分)。也就是说,同为「黄金」的两个玩家,真实实力从 p10 到 p90 就铺开了 160 分。这是段位作为「粗分类」的固有代价——五段只能把玩家分成五堆,堆内必然有差距。
P 层:跨语言一致的分段引擎
段位边界是配置,客户端和服务端都要能算出来,且必须算出一样的结果。下面这段是 P 层原型,用整数地板除复刻百分位切段,与 Python 侧逐位一致。
// file: proto/src/n07_match.ts
// tier: P
// verify: node --test proto/test/n07.test.ts
// 段位分配原型:与 sim/n07_tiers.py 消费同一份 config/n07_match.json,
// 逐位复刻 mulberry32,并把「百分位切段」用整数地板除实现,保证两侧边界一致。
import { loadConfig } from "./core.ts";
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;
const im = Math.imul(u, t | 61) >>> 0;
t = ((t + im) >>> 0) ^ t;
return (t ^ (t >>> 14)) >>> 0;
};
}
export interface TierDef { id: string; name: string; cutPct: number; targetPct: number }
export interface MatchConfig { seed: number; tiers: { list: TierDef[] } }
export interface TierBound { id: string; name: string; cutPct: number; minRating: number }
export function percentileCut(sortedVals: number[], cutPct: number): number {
const n = sortedVals.length;
const idx = Math.min(n - 1, Math.floor((cutPct * n) / 100));
return sortedVals[idx];
}
export function tierBoundaries(sortedRatings: number[], tiers: TierDef[]): TierBound[] {
return tiers.map((t) => ({
id: t.id, name: t.name, cutPct: t.cutPct,
minRating: percentileCut(sortedRatings, t.cutPct),
}));
}
export function assignTier(rating: number, bounds: TierBound[]): string {
let cur = bounds[0].id;
for (const b of bounds) if (rating >= b.minRating) cur = b.id;
return cur;
}
export function loadMatch(): MatchConfig {
return loadConfig<MatchConfig>("n07_match");
}
node --test proto/test/n07.test.ts 的断言覆盖三件事:mulberry32 与 Python 逐位一致(前 8 个 uint32)、固定小数组 demo10 的边界与 Python 相同([1000, 1200, 1400, 1700, 1900])、边界值归入该段(1199 归青铜、1200 归白银)。本机实跑:
ℹ tests 4
ℹ pass 4
ℹ fail 0
错位率:评分分段 vs 实力分段
最后一列数字是这一节的落点:评分分段 vs 实力分段错位率 29.65%。
它的含义是:如果系统能直接看到玩家的真实实力并按实力切段,那么近三成玩家会被分到和现在不同的段位。这个数字来自 7.1 的残差——评分本身有约 55 分的噪声,玩家越靠近段位边界,越容易被噪声「推过界」。
这不是说段位设计失败,而是给三条工程建议:
- 段位边界要留缓冲带:不要用固定分数线卡死,允许「降级保护」「保级赛」这类缓冲,减少边界抖动带来的挫败。
- 定级赛要足够长:新手
RD高,前几局分数乱跳,太短的定级赛会把玩家扔进错误的段位。 - 段位不是匹配的输入:真正配对时用连续分数(7.1 的评分),段位只做展示与运营分层——用粗分类去配对,会把 200 分差距的玩家硬凑到一起。
小结
- 百分位切段让每段人数占比天然贴合目标(实测 20 / 24.85 / 25.15 / 20 / 10)。
- 段位边界反映玩家密度:白银到黄金只差 134 分,青铜到白银差 379 分。
- 段内实力重叠 160–230 分(p10–p90),五段只是粗分类。
- 评分分段与实力分段错位率 29.65%——评分噪声让近三成玩家站在「错」的段位上,所以边界要留缓冲、定级赛要够长、配对要用连续分数。
有了分数(7.1)和段位(7.2),还剩最后一个问题:把玩家按这个分数配到一起,对局到底公不公平? 下一节用仿真直接量。
阅读导航:上一节:7.1 匹配分与 ELO / Glicko · 下一节:7.3 匹配公平性仿真 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。