掉落表(drop table)是 SLG 里最容易被忽视、又最容易被玩家投诉的一张表。它决定了打一个野怪、开一个宝箱、清一个资源点到底掉什么、掉多少。玩家嘴上说的是「这游戏概率有问题」,真正的问题往往在这张表的权重与抽条次数怎么配。
站内那篇经济系统运营 checklist 讲的是「资源、货币和付费点如何不把项目带崩」这类产业清单——它给你的是要检查什么。这一节补上它没给的那一半:一张掉落表怎么从权重变成概率,又怎么用可复现的仿真验证它真的按你写的概率在掉。这是《王朝纪元》里「精英营地」掉落表的口径。
掉落表就是一张权重表
一张掉落表由三样东西构成:
- 条目(entry):可能掉的东西。每个条目有
id、稀有度rarity、权重weight、数量区间amountMin/amountMax。 - 抽条次数(rollsPerKill):一次结算抽几次。抽 3 次意味着一次击杀最多掉 3 样。
- 稀有度(rarity):common / rare / epic / legendary。稀有度是给玩家看的分层,真正的概率由权重决定。
《王朝纪元》的精英营地掉落表用了千分权重(权重之和 = 1000),这样每一项的权重数字本身就是它的千分概率,不用再心算:
| 条目 | 稀有度 | 权重 | 概率 |
|---|---|---|---|
| wood | common | 500 | 50.0% |
| iron | common | 300 | 30.0% |
| speedup | rare | 150 | 15.0% |
| blueprint | epic | 45 | 4.5% |
| heroShard | legendary | 5 | 0.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/、同一个种子,两门语言抽出同一条序列——掉落表的「唯一真源」这才算真的成立。
小结
这一节把掉落表从「一张写死的表」变成了「一个可验证的分布」:
- 千分权重让权重数字直接等于千分概率,整数权重是跨语言一致的前提。
- 蒙特卡洛对照证明代码真的按表在抽:传说项实测 0.4933% 对理论 0.5%,偏差在二项分布 ±1σ 内。
- 收敛轨迹说明概率是「样本趋近」而非「表里写死」。
- P 层原型证明 Python 与 TypeScript 逐位一致,
config/作为唯一真源成立。
表里只说了「掉什么」,没说「运气差到什么程度该怎么办」。下一节处理后半句:伪随机与保底。
阅读导航:上一节:目录 · 下一节:5.2 伪随机与保底机制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。