《MMO 服务器开发》7.1 掉落表服务

掉落是玩法系统的第一个随机点:怪物死了掉什么、掉几个。本节从零写 internal/loot 包,用权重表描述候选物品、在 [Min,Max] 区间取数量,把多次抽取聚合成一次可复算的掉落结算,并附一份能直接 go test 跑过的 L1 测试。

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 确定性随机与随机种子 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标