7.2 分段与段位设计

匹配分是给系统看的,段位是给玩家看的。用百分位把 2000 名玩家切成青铜到钻石五段,实测占比 20/24.85/25.15/20/10 贴合目标,段位边界落在 666/1045/1179/1293/1437 分,并量化「评分分段」与「真实实力分段」29.65% 的错位率。

7.1 留下了一个分数。但玩家不会说「我 1293 分」,他会说「我是铂金」。分数是给系统看的,段位是给玩家看的。 段位把一条连续的分数轴切成几段有名字的区间,每一段都是一个可感知的身份和一个可追逐的目标。

这一节回答三个问题:段位怎么切、每段多少人、以及——最容易被忽略的——段位到底把实力分得准不准。

段位是「分数」的翻译层

段位至少承担三件事:

  1. 可感知:1293 记不住,铂金 一眼就懂。名字给了一个玩家向别人介绍自己的方式。
  2. 可追逐:目标要够近才动人。「上钻石」比「涨 144 分」更有画面感。
  3. 可分配:运营要按段位发奖励、做活动、算赛季结算——段位是分层运营的抓手。

正因为段位是玩家-facing 的,它的分布就成了设计目标:青铜太多显得游戏没高手,钻石太多显得段位不值钱。这一节的核心就是把「段位分布」变成一个可核对的东西。

用百分位切段,而不是固定分数线

切段有两条路:

  • 固定分数线:青铜 0–1000、白银 1000–1200…… 问题是分数会漂。赛季膨胀、新玩法加产出、玩家人数变化,都会让分数整体上移,于是「白银」里的人越来越多,段位失去区分度。第 8 章会专门讲这个膨胀。
  • 百分位切段:按「分数在全体里的排名」切,比如前 10% 是钻石、10%–30% 是铂金。这样每段的人数占比天然固定,分数怎么漂都不影响段位的稀缺性。

本机取百分位方案,配置里给的是各段位的下界百分位与目标占比:

段位下界百分位 cutPct目标占比 targetPct
青铜020%
白银2025%
黄金4525%
铂金7020%
钻石9010%

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 分差距的玩家硬凑到一起。

小结

  1. 百分位切段让每段人数占比天然贴合目标(实测 20 / 24.85 / 25.15 / 20 / 10)。
  2. 段位边界反映玩家密度:白银到黄金只差 134 分,青铜到白银差 379 分。
  3. 段内实力重叠 160–230 分(p10–p90),五段只是粗分类。
  4. 评分分段与实力分段错位率 29.65%——评分噪声让近三成玩家站在「错」的段位上,所以边界要留缓冲、定级赛要够长、配对要用连续分数。

有了分数(7.1)和段位(7.2),还剩最后一个问题:把玩家按这个分数配到一起,对局到底公不公平? 下一节用仿真直接量。

阅读导航:上一节:7.1 匹配分与 ELO / Glicko · 下一节:7.3 匹配公平性仿真 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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