5.1 掉落表与概率分布

掉落表是一张权重表:千分权重让每一项的数字直接就是千分概率。用确定性 PRNG 跑 60000 次抽条对照理论分布,传说项实测 0.4933% 对理论 0.5%,并用 TypeScript 原型验证 Python 与 TS 两侧逐位一致,权重表即唯一真源。

掉落表(drop table)是 SLG 里最容易被忽视、又最容易被玩家投诉的一张表。它决定了打一个野怪、开一个宝箱、清一个资源点到底掉什么、掉多少。玩家嘴上说的是「这游戏概率有问题」,真正的问题往往在这张表的权重与抽条次数怎么配。

站内那篇经济系统运营 checklist 讲的是「资源、货币和付费点如何不把项目带崩」这类产业清单——它给你的是要检查什么。这一节补上它没给的那一半:一张掉落表怎么从权重变成概率,又怎么用可复现的仿真验证它真的按你写的概率在掉。这是《王朝纪元》里「精英营地」掉落表的口径。

掉落表就是一张权重表

一张掉落表由三样东西构成:

  • 条目(entry):可能掉的东西。每个条目有 id、稀有度 rarity、权重 weight、数量区间 amountMin/amountMax。
  • 抽条次数(rollsPerKill):一次结算抽几次。抽 3 次意味着一次击杀最多掉 3 样。
  • 稀有度(rarity):common / rare / epic / legendary。稀有度是给玩家看的分层,真正的概率由权重决定。

《王朝纪元》的精英营地掉落表用了千分权重(权重之和 = 1000),这样每一项的权重数字本身就是它的千分概率,不用再心算:

条目稀有度权重概率
woodcommon50050.0%
ironcommon30030.0%
speeduprare15015.0%
blueprintepic454.5%
heroShardlegendary50.5%

权重求和是 1000,所以某一项的概率就是 weight / 1000。按稀有度合计:common 80%、rare 15%、epic 4.5%、legendary 0.5%。这就是这张表声明的概率分布——后面所有验证都拿它当基准。

用千分权重而不是百分比小数,不只是好看。整数权重让 config/ 里没有浮点,Python 和 TypeScript 两侧才能逐位一致(见本卷「一份配置两处消费」的约定)。

配置:唯一真源

下面这段是 D 层静态配置,是掉落表的唯一真源。权重、数量区间、抽条次数、保底参数都在这里。

{
  "version": 1,
  "note": "掉落表与保底参数(卷二第 5 章):权重与概率一律整数,随机用确定性 PRNG mulberry32(Python/TS 同算法同种子)。weight 为千分权重,basePct/stepPct 为整数百分比。",
  "seed": 20261012,
  "dropTable": {
    "id": "elite-camp",
    "name": "精英营地掉落表",
    "rollsPerKill": 3,
    "entries": [
      { "id": "wood", "rarity": "common", "weight": 500, "amountMin": 200, "amountMax": 400 },
      { "id": "iron", "rarity": "common", "weight": 300, "amountMin": 80, "amountMax": 160 },
      { "id": "speedup", "rarity": "rare", "weight": 150, "amountMin": 1, "amountMax": 3 },
      { "id": "blueprint", "rarity": "epic", "weight": 45, "amountMin": 1, "amountMax": 1 },
      { "id": "heroShard", "rarity": "legendary", "weight": 5, "amountMin": 1, "amountMax": 1 }
    ]
  },
  "pity": {
    "note": "保底:连续未出货 n 抽出货概率 = basePct(n < softPityStart);n >= softPityStart 后按 softPityStepPct 线性递增;n >= hardPity 必出(100%)。",
    "targetRarity": "legendary",
    "basePct": 3,
    "softPityStart": 40,
    "softPityStepPct": 6,
    "hardPity": 60
  },
  "experienceBand": {
    "note": "体验区间目标:出货抽数的分位数上界(超过即判「非预期」)。",
    "p50Max": 25,
    "p90Max": 55,
    "p99Max": 60
  },
  "sim": {
    "kills": 20000,
    "players": 20000,
    "trials": 30
  }
}

蒙特卡洛对照:权重说的是不是真的

表写好了,接下来是它最容易出错的地方:代码到底有没有按这张表抽。落到运行时,常见 bug 是累计权重边界写错(< 写成 <=)、随机数区间没对齐,导致某一项实际概率悄悄偏掉。要抓这种错,只能真跑一遍再对账。

下面这段是 S 层仿真脚本,读同一份 config/ 跑 20000 次击杀、60000 次抽条,统计各条目与各稀有度的实际占比,并与权重推出的理论占比对照。

# file: sim/n05_drops.py
# tier: S
# verify: python3 sim/n05_drops.py --config config/ --out out/
"""掉落表与概率分布(5.1):读 config/n05_drops.json 跑蒙特卡洛,对照理论分布。

按权重表逐次抽条(每次击杀抽 rollsPerKill 次),用确定性 PRNG mulberry32 保证可复现,
统计各条目 / 各稀有度的实际占比,与权重推出的理论占比对照,并记录传说占比的收敛轨迹。

产出:out/n05_drops.json + out/n05_drops.csv + out/n05_drops.svg
"""

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 svg_line import render  # noqa: E402


def mulberry32(seed):
    """确定性 PRNG:同一种子在任何机器上产生同一序列(与 proto/src/n05_drops.ts 逐位一致)。"""
    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 load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def roll(entries, total_weight, rng):
    """按累计权重抽一条:pick 落在哪个区间就返回哪个条目。"""
    pick = int(rng() * total_weight)
    acc = 0
    for e in entries:
        acc += e["weight"]
        if pick < acc:
            return e["id"]
    return entries[-1]["id"]


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, "n05_drops.json"))
    table = cfg["dropTable"]
    entries = table["entries"]
    total_weight = sum(e["weight"] for e in entries)
    kills = cfg["sim"]["kills"]
    rolls_per_kill = table["rollsPerKill"]
    rng = mulberry32(cfg["seed"])

    counts = {e["id"]: 0 for e in entries}
    total_rolls = kills * rolls_per_kill
    legend_running = []  # 每 1000 次击杀记录一次传说占比
    for k in range(kills):
        for _ in range(rolls_per_kill):
            counts[roll(entries, total_weight, rng)] += 1
        if (k + 1) % 1000 == 0:
            legend_running.append(counts["heroShard"] * 100.0 / ((k + 1) * rolls_per_kill))

    per_entry = []
    for e in entries:
        per_entry.append({
            "id": e["id"], "rarity": e["rarity"], "weight": e["weight"],
            "theoreticalPct": round(e["weight"] * 100.0 / total_weight, 4),
            "count": counts[e["id"]],
            "observedPct": round(counts[e["id"]] * 100.0 / total_rolls, 4),
        })
    rarity_totals = {}
    for e in entries:
        rarity_totals[e["rarity"]] = rarity_totals.get(e["rarity"], 0) + counts[e["id"]]
    report = {
        "version": 1, "seed": cfg["seed"], "kills": kills, "rollsPerKill": rolls_per_kill,
        "totalRolls": total_rolls, "perEntry": per_entry, "rarityTotals": rarity_totals,
    }
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n05_drops.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n05_drops.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["kills", "legendarySharePct"])
        for i, v in enumerate(legend_running):
            w.writerow([(i + 1) * 1000, round(v, 4)])
    leg = per_entry[-1]
    render(legend_running, os.path.join(args.out, "n05_drops.svg"),
           f"传说占比收敛:理论 {leg['theoreticalPct']}% / 实测 {leg['observedPct']}%")
    for e in per_entry:
        print(f"{e['id']:<10} {e['rarity']:<10} 理论 {e['theoreticalPct']:>7.4f}%  实测 {e['observedPct']:>7.4f}%  次数 {e['count']}")
    print(f"总抽条 {total_rolls}  传说偏差 {abs(leg['observedPct'] - leg['theoreticalPct']):.4f}pp")
    return 0


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

本机实跑输出(同机相对量级,绝对数值不可移植):

wood       common     理论 50.0000%  实测 49.7917%  次数 29875
iron       common     理论 30.0000%  实测 30.0967%  次数 18058
speedup    rare       理论 15.0000%  实测 15.2717%  次数 9163
blueprint  epic       理论  4.5000%  实测  4.3467%  次数 2608
heroShard  legendary  理论  0.5000%  实测  0.4933%  次数 296
总抽条 60000  传说偏差 0.0067pp

每一项的实测占比都贴着理论值。 最稀有的传说项理论 0.5%,实测 0.4933%,绝对偏差只有 0.0067 个百分点(60000 抽里出了 296 次,期望是 300 次)。这个量级的偏差是抽样噪声,不是 bug——二项分布的标准差 sqrt(n·p·(1-p)) 在这里约 17.3,所以实测落在 300±17 区间里都算对。

out/n05_drops.csv 每 1000 次击杀记一次传说占比,out/n05_drops.svg 把它画成一条折线:早期只有 3000 次抽条时噪声大(0.40% 到 0.52% 之间跳),随样本累积逐渐收敛到 0.5% 附近。「概率分布」不是表里写死的那个数,而是样本量足够大之后实测占比会逼近的那个值。

跨语言一致:P 层原型

同一张掉落表,sim/ 用 Python 消费,proto/ 用 TypeScript 消费。如果两边抽出来的序列不一样,「唯一真源」就是假的。下面这段是 P 层原型,用与 Python 同一算法、同一种子的 mulberry32 抽条。

// file: proto/src/n05_drops.ts
// tier: P
// verify: node --test proto/test/n05.test.ts
// 第 5 章掉落表原型:读 config/n05_drops.json 按千分权重抽条,与 sim/n05_drops.py 逐位一致。

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const HERE = dirname(fileURLToPath(import.meta.url));
export const CONFIG_DIR = join(HERE, "..", "..", "config");

export interface DropEntry { id: string; rarity: string; weight: number; amountMin: number; amountMax: number }
export interface DropTable { id: string; name: string; rollsPerKill: number; entries: DropEntry[] }
export interface DropsConfig { seed: number; dropTable: DropTable; sim: { kills: number; players: number; trials: number } }

export function loadJson<T>(name: string): T {
  return JSON.parse(readFileSync(join(CONFIG_DIR, `${name}.json`), "utf-8")) as T;
}

/** 确定性 PRNG:与 Python 侧 mulberry32 逐位一致(32 位无符号掩码 + imul)。 */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return function (): number {
    a = (a + 0x6D2B79F5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a) >>> 0;
    const t0 = t;
    t = (t0 + Math.imul(t0 ^ (t0 >>> 7), 61 | t0)) >>> 0;
    t = (t ^ t0) >>> 0;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function totalWeight(t: DropTable): number {
  return t.entries.reduce((s, e) => s + e.weight, 0);
}

/** 按累计权重抽一条:pick 落在哪个区间就返回哪个条目。 */
export function rollDrop(t: DropTable, tw: number, rnd: () => number): string {
  const pick = Math.floor(rnd() * tw);
  let acc = 0;
  for (const e of t.entries) {
    acc += e.weight;
    if (pick < acc) return e.id;
  }
  return t.entries[t.entries.length - 1].id;
}

export function simulateDrops(cfg: DropsConfig): Record<string, number> {
  const t = cfg.dropTable;
  const tw = totalWeight(t);
  const rnd = mulberry32(cfg.seed);
  const counts: Record<string, number> = {};
  for (const e of t.entries) counts[e.id] = 0;
  for (let k = 0; k < cfg.sim.kills; k++) {
    for (let r = 0; r < t.rollsPerKill; r++) counts[rollDrop(t, tw, rnd)]++;
  }
  return counts;
}
// file: proto/test/n05.test.ts
// tier: P
// verify: node --test proto/test/n05.test.ts
// 第 5 章掉落表原型测试:PRNG 与 Python 逐位一致、权重合计、抽条计数对齐 sim/n05_drops.py。

import { test } from "node:test";
import assert from "node:assert/strict";
import {
  loadJson, mulberry32, totalWeight, simulateDrops, type DropsConfig,
} from "../src/n05_drops.ts";

const cfg = loadJson<DropsConfig>("n05_drops");

test("mulberry32 前三个数固定(与 Python 侧逐位一致)", () => {
  const r = mulberry32(20261012);
  const got = [r(), r(), r()].map((x) => x.toFixed(10));
  assert.deepEqual(got, ["0.3411238657", "0.0178032639", "0.3645789088"]);
});

test("掉落表权重合计 1000(千分权重)", () => {
  assert.equal(totalWeight(cfg.dropTable), 1000);
});

test("60000 次抽条的条目计数与 Python 逐位一致", () => {
  // 由 sim/n05_drops.py 实测:wood 29875 / iron 18058 / speedup 9163 / blueprint 2608 / heroShard 296
  assert.deepEqual(simulateDrops(cfg), {
    wood: 29875, iron: 18058, speedup: 9163, blueprint: 2608, heroShard: 296,
  });
});
✔ mulberry32 前三个数固定(与 Python 侧逐位一致) (2.218083ms)
✔ 掉落表权重合计 1000(千分权重) (0.439542ms)
✔ 60000 次抽条的条目计数与 Python 逐位一致 (17.843708ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0

三条断言全过。最关键的是第三条:TypeScript 侧 60000 次抽条得到 wood 29875 / iron 18058 / speedup 9163 / blueprint 2608 / heroShard 296,与 Python 侧逐位相同。同一份 config/、同一个种子,两门语言抽出同一条序列——掉落表的「唯一真源」这才算真的成立。

小结

这一节把掉落表从「一张写死的表」变成了「一个可验证的分布」:

  1. 千分权重让权重数字直接等于千分概率,整数权重是跨语言一致的前提。
  2. 蒙特卡洛对照证明代码真的按表在抽:传说项实测 0.4933% 对理论 0.5%,偏差在二项分布 ±1σ 内。
  3. 收敛轨迹说明概率是「样本趋近」而非「表里写死」。
  4. P 层原型证明 Python 与 TypeScript 逐位一致,config/ 作为唯一真源成立。

表里只说了「掉什么」,没说「运气差到什么程度该怎么办」。下一节处理后半句:伪随机与保底。

阅读导航:上一节:目录 · 下一节:5.2 伪随机与保底机制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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