7.1 掉落表服务
怪物倒下的一瞬间,服务端要做的事只有一件:决定往地上吐什么。听起来是随机数的事,但在 MMO 里它同时是玩法、经济与合规的交汇点——掉率决定了道具的稀缺度,稀缺度决定了拍卖行的价格曲线,而玩家对「我明明该出」的质疑又要求这次随机能被复算。所以掉落系统的第一块地基不是随机数生成器,而是一张能被策划编辑、被程序校验、被审计复算的掉落表。
本节只解决第一步:把掉落表这个数据结构写出来,并让「按权重抽一次」这件事正确、可测。抽取用的随机数从哪来,是下一节 7.2 的主题;本节的所有函数都接受一个 *rand.Rand 参数,不自己造随机源——这正是为了让 7.2 能把「随机源」这一层单独换掉,而不用动抽取逻辑。
下面这段是 L1,go test ./internal/loot/ 可以直接跑:
// file: internal/loot/table.go
// tier: L1
// verify: go test ./internal/loot/
package loot
import (
"errors"
"math/rand/v2"
"sort"
)
// ItemID 是配置表里的物品 ID(不是背包内的实例 ID)。
type ItemID uint32
// Entry 是掉落表里的一行:一个候选物品加它被抽中的权重与数量区间。
type Entry struct {
ItemID ItemID
Weight uint32 // 相对权重,越大越容易被抽中
Min uint32 // 掉落数量下限(含)
Max uint32 // 掉落数量上限(含)
}
// Table 是一张掉落表:一组候选条目,每次抽取命中其中一个。
//
// total 是权重总和,在 Build 时算好,Roll 时不再重复求和。
type Table struct {
ID string
Entries []Entry
total uint32
}
// Build 校验配置并预计算权重总和。
//
// 掉落表是策划直接编辑的数据,最脏的输入都在这里进来,所以宁可启动时
// 报错,也不要在线上抽出一个权重为 0 或数量倒挂的条目。
func (t *Table) Build() error {
if len(t.Entries) == 0 {
return errors.New("loot: 掉落表为空")
}
var sum uint32
for i := range t.Entries {
e := &t.Entries[i]
if e.Weight == 0 {
return errors.New("loot: 条目权重不能为 0")
}
if e.Max < e.Min {
return errors.New("loot: 数量上限小于下限")
}
sum += e.Weight
}
t.total = sum
return nil
}
// Total 返回预计算好的权重总和。
func (t *Table) Total() uint32 { return t.total }
// Pick 按权重抽一个条目。
//
// r 必须是该实体自己的 PRNG(见 7.2),不能是全局 rand——同一次掉落
// 要在任意时刻、任意机器上重放出相同结果。
func (t *Table) Pick(r *rand.Rand) (Entry, bool) {
if t.total == 0 {
return Entry{}, false
}
roll := r.Uint32N(t.total) // [0, total)
var acc uint32
for i := range t.Entries {
acc += t.Entries[i].Weight
if roll < acc {
return t.Entries[i], true
}
}
// roll < total 恒成立,正常不可达;兜底返回最后一项。
return t.Entries[len(t.Entries)-1], true
}
// Count 在该条目的 [Min, Max] 区间内取一个掉落数量。
func (e Entry) Count(r *rand.Rand) uint32 {
if e.Max <= e.Min {
return e.Min
}
return e.Min + r.Uint32N(e.Max-e.Min+1)
}
// Drop 是一次掉落结算后的结果行:某个物品掉了多少个。
type Drop struct {
ItemID ItemID
Count uint32
}
// Roll 抽 n 次,把同一物品的数量合并成一行,并按 ItemID 升序输出。
//
// 合并与排序是为了让「同样一组抽取」永远产出同样顺序的结果,
// 否则 7.3 的审计指纹会随 map 遍历顺序抖动。
func (t *Table) Roll(r *rand.Rand, n int) []Drop {
if n <= 0 {
return nil
}
agg := make(map[ItemID]uint32, len(t.Entries))
order := make([]ItemID, 0, n)
for i := 0; i < n; i++ {
e, ok := t.Pick(r)
if !ok {
break
}
if _, seen := agg[e.ItemID]; !seen {
order = append(order, e.ItemID)
}
agg[e.ItemID] += e.Count(r)
}
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
out := make([]Drop, 0, len(order))
for _, id := range order {
out = append(out, Drop{ItemID: id, Count: agg[id]})
}
return out
}
本章与站内既有内容的分工
站内已有两篇文章从架构层面讨论过掉落与奖励:一篇讲掉落表服务的整体形态,一篇讲奖励编排引擎如何把多种来源统一成一个发奖入口。它们负责「架构方案与取舍」。本章不复述结论,只做一件事:把掉落从权重表一路写到可复算的审计链,并全部真跑。分工如下。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/game-server-loot-table-service-architecture/ | 掉落表服务的分层、缓存与热更方案 | 只写「一张表按权重抽一次」这一步 |
/game-server-reward-orchestration-engine-architecture/ | 奖励编排引擎如何汇总多来源奖励 | 只产出掉落结果行,不接管发放入口 |
掉落表与按权重抽取
一张掉落表就是「一组候选条目」。每条 Entry 有三个字段:物品 ID、权重、以及掉落数量的区间 [Min, Max]。用整数权重而不是直接写概率,有两个好处:一是策划改一个数字不用重算其他行,二是整数加法不会累积浮点误差,60/30/10 抽一万次也不会因为 0.1+0.2 之类的舍入而偏。
Build 把校验和预计算分开做,是因为这两件事的时机不同。校验只在配置加载时做一次,越早报错越好——一张权重为 0 的表在线上跑一整晚都不会被发现,因为那个条目只是永远抽不中;而权重总和的预计算则让每次 Pick 少一次 O(n) 求和。表一旦建好就当成只读,Roll 不会修改任何字段。
Pick 的实现是经典的「前缀和 + 二分定位」的线性版本:先把 [0, total) 里均匀取一个 roll,再从头累加权重,第一个让累加值超过 roll 的条目就是结果。举例:权重 60/30/10,total = 100,若 roll = 75,累加到第二项时 acc = 90 > 75,命中兽皮。每个条目被命中的概率精确等于它的权重占比,且一次抽取只消耗一个随机数。
条目数在真实项目里通常是十几到几十,线性扫描比建前缀和数组再二分更简单也更快。Count 则在 [Min, Max] 上均匀取一个数量:当 Max == Min 时它连随机数都不消耗,这也是 Roll 每次消耗的随机数个数会随结果浮动的原因——这一点在 7.2 讲「确定性」时会再回来。
从「抽 N 次」到一次掉落结算
一次击杀往往不是抽一次,而是抽 N 次(比如普通掉落 2 次、精英 3 次)。Roll 做的就是这件事:抽 N 次,把命中同一物品的数量加在一起,最后按物品 ID 升序输出。升序这一步不是装饰——7.3 会把掉落结果压成一个 64 位指纹写进审计链,如果输出顺序随 map 的遍历顺序抖动,同一次掉落每次算出的指纹都不同,审计就失去意义。所以从第一天起,掉落结果的顺序就必须是规范的。
下面这份测试也是 L1,验证了配置校验、频率收敛、数量区间与聚合排序四件事,go test ./internal/loot/ 可以直接跑:
// file: internal/loot/table_test.go
// tier: L1
// verify: go test ./internal/loot/
package loot
import (
"math/rand/v2"
"testing"
)
func testTable() *Table {
return &Table{
ID: "boss_wolf_common",
Entries: []Entry{
{ItemID: 1001, Weight: 60, Min: 1, Max: 3}, // 铜币
{ItemID: 1002, Weight: 30, Min: 1, Max: 1}, // 兽皮
{ItemID: 1003, Weight: 10, Min: 1, Max: 1}, // 狼牙
},
}
}
func TestBuildRejectsBadConfig(t *testing.T) {
empty := &Table{}
if err := empty.Build(); err == nil {
t.Fatal("空表应当报错")
}
zero := &Table{Entries: []Entry{{ItemID: 1, Weight: 0, Min: 1, Max: 1}}}
if err := zero.Build(); err == nil {
t.Fatal("权重为 0 应当报错")
}
inv := &Table{Entries: []Entry{{ItemID: 1, Weight: 1, Min: 5, Max: 2}}}
if err := inv.Build(); err == nil {
t.Fatal("数量倒挂应当报错")
}
}
func TestPickFrequencyMatchesWeight(t *testing.T) {
tb := testTable()
if err := tb.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
if tb.Total() != 100 {
t.Fatalf("权重总和 = %d, 期望 100", tb.Total())
}
r := rand.New(rand.NewPCG(1, 2))
const n = 200000
hit := map[ItemID]int{}
for i := 0; i < n; i++ {
e, ok := tb.Pick(r)
if !ok {
t.Fatal("Pick 返回空")
}
hit[e.ItemID]++
}
// 60/30/10 的权重,允许 ±2% 的统计抖动。
want := map[ItemID]float64{1001: 0.60, 1002: 0.30, 1003: 0.10}
for id, w := range want {
got := float64(hit[id]) / n
if diff := got - w; diff > 0.02 || diff < -0.02 {
t.Fatalf("物品 %d 频率 = %.3f, 期望 %.2f", id, got, w)
}
}
}
func TestCountWithinRange(t *testing.T) {
r := rand.New(rand.NewPCG(7, 8))
e := Entry{ItemID: 1001, Weight: 1, Min: 1, Max: 3}
seen := map[uint32]bool{}
for i := 0; i < 1000; i++ {
c := e.Count(r)
if c < 1 || c > 3 {
t.Fatalf("数量 %d 越界", c)
}
seen[c] = true
}
if len(seen) != 3 {
t.Fatalf("1..3 三个值应都出现过,实得 %d 个", len(seen))
}
}
func TestRollAggregatesAndSorts(t *testing.T) {
tb := testTable()
if err := tb.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
r := rand.New(rand.NewPCG(3, 4))
drops := tb.Roll(r, 20)
if len(drops) == 0 {
t.Fatal("20 次抽取不该为空")
}
var total uint32
for i, d := range drops {
total += d.Count
if i > 0 && drops[i-1].ItemID > d.ItemID {
t.Fatalf("结果未按 ItemID 升序:%d 在 %d 之后", drops[i-1].ItemID, d.ItemID)
}
}
// 每次抽取数量至少为 1,所以总件数不会少于抽取次数。
if total < 20 {
t.Fatalf("总件数 %d 少于抽取次数 20", total)
}
}
本节小结
掉落表服务的核心只有两件事:一张能校验、能预计算的权重表,和一个规范化的掉落结算。把随机源挡在函数签名之外(Pick(r *rand.Rand)),是本节刻意的设计——它让 7.2 可以干净地替换掉随机源,也让本节的所有测试用固定种子就能复现。实测:go test ./internal/loot/ 全绿,频率校验在 20 万次抽样下稳定落在 ±2% 内,Roll 的输出在任意输入下都按物品 ID 升序。
阅读导航:上一节:目录 · 下一节:7.2 确定性随机与随机种子 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。