《MMO 服务器开发》7.2 确定性随机与随机种子

本节定稿全卷的随机口径:用 math/rand/v2 的 PCG,为每个实体单独构造 PRNG,种子 = hash(玩家ID, 掉落批次序号),禁止全局 rand。把为什么全局随机源会让结果不可复算讲透,并给出可测的种子推导与重放实现,这是后续所有随机玩法的地基。

7.2 确定性随机与随机种子

7.1 把随机源挡在了函数签名之外:Pick(r *rand.Rand) 只管抽取,不关心 r 从哪来。这一节就是来填这个空——全卷所有玩法系统的随机,都用同一套口径:用 math/rand/v2 的 PCG,为每个实体单独构造 PRNG,种子 = hash(玩家ID, 掉落批次序号),绝不用全局 rand。这不是风格偏好,而是几条硬需求逼出来的唯一解。

第一条需求是可复算。玩家申诉「我打了一晚上 BOSS 一个橙装都没出」,客服要能在后台把那次击杀的掉落原样重放一遍,证明服务端没算错。如果随机数来自一个全服共享、还在被其他玩家消耗的生成器,那么「重放」在物理上就不可能——你复现不出当时生成器的状态。

第二条需求是不受他人影响。玩家 A 的掉落不该因为玩家 B 在同一秒也杀了一只怪而改变。全局 PRNG 让每个玩家的结果都依赖「在我之前有多少人抽过、各抽了几次」,这在人多时是随机扰动,在只有一个人测试时又会变成「每次都一样」的假象。

第三条需求是抗回档与抗重放攻击。只要种子只由 (玩家ID, 批次) 决定,那么无论服务端重启、回档、还是把掉落计算挪到另一台机器,同一个玩家同一次掉落的结果永远相同;攻击者也无法通过「多打几次让随机数走到想要的区间」来操控结果,因为他控制不了种子的输入。

把这三条合起来,结论就很清楚:随机源不能是「服务端里的一个全局对象」,而必须是「某个玩家某次掉落的纯函数」——输入定了,输出就定了。本节的实现,就是把这个纯函数写出来。由此得到三条必须同时成立的性质:

  • 可复算:给定种子,掉落结果必须逐行可重放;
  • 无干扰:一个玩家的抽取不能影响另一个玩家的结果;
  • 可移植:换进程、换机器、回档重跑,同种子必须同结果。

种子 = hash(玩家ID, 掉落批次序号)

种子的两个输入都必须是确定且可追溯的。玩家 ID 天然满足;「掉落批次序号」是该玩家第几次掉落结算,从 1 开始、只增不减,由玩家自己的存档维护。刻意不用时间戳:时间戳在不同进程、不同机器上无法保证一致,一次回档就会让同一次掉落算出不同种子。

DeriveSeed 把这两个 64 位数拼成 16 字节喂给 FNV-1a,得到一个 64 位摘要,再各跑一轮 splitmix64 得到 PCG 需要的两个种子。这里多做一轮混淆是必要的:PCG 对种子质量敏感,如果把摘要 sum 和 sum+1 直接当两个种子喂进去,两条序列会高度相关,抽出来的结果就不像独立随机的了。

批次序号怎么维护,是这套口径唯一的实现负担。它必须随玩家存档一起落库:玩家每完成一次掉落结算,序号加一并写回;服务端重启、玩家跨进程迁移时都带着它走。如果这个序号丢了或回退了,那么「重放」就会用到错误的种子——所以它和金币、经验一样,是必须持久化的玩家状态,而不是一个可以随手重算的临时值。用「玩家ID + 批次」而不是「玩家ID + 怪物ID」也很关键:同一个玩家在同一个怪物上可以反复击杀,只有批次序号能保证每次掉落互不相同。

选 PCG 而不是 math/rand/v2 的 ChaCha8,是因为掉落不需要密码学强度。掉落面临的「攻击」只是玩家反复击杀试图碰运气,而不是要预测或伪造服务端的随机流;PCG 的状态小(128 位)、速度快、跨平台结果完全一致,正好够用。反过来,如果某天随机数要用在开宝箱的概率公示或防作弊判罚上,那才需要换更强的生成器——但那是另一层的事,本节的口径先把「确定性」立住。

每实体独立 PRNG 的实现

NewEntityRNG 是唯一的构造入口,它保证「同 (playerID, batch) 必然同结果」这条不变量。整个包里没有任何地方引用全局 rand 包级函数——这是可以靠代码审查和测试双重保证的。实现上有三个容易踩的坑,代码里都做了处理:

  • DeriveSeed 必须对摘要做混淆,否则 PCG 的两条种子序列会相关;
  • NewEntityRNG 是唯一的 PRNG 构造入口,杜绝有人在别处 new 一个共享 PRNG;
  • RollFor 把「取种子 → 建 PRNG → 抽表」封成一步,调用方拿不到 PRNG,也就没法把同一个 PRNG 复用到两次不同的掉落上。

下面这段是 L1,go test ./internal/loot/ 可以直接跑:

// file: internal/loot/random.go
// tier: L1
// verify: go test ./internal/loot/
package loot

import (
	"encoding/binary"
	"hash/fnv"
	"math/rand/v2"
)

// SeedPair 是 PCG 需要的两个 64 位种子。
type SeedPair struct {
	Hi uint64
	Lo uint64
}

// DeriveSeed 把 (玩家ID, 掉落批次序号) 折叠成一对 PCG 种子。
//
// batch 是该玩家第几次掉落结算,从 1 开始、只增不减。用「玩家ID + 批次」
// 而不是时间戳或全局自增,是为了让同一个玩家同一次掉落在任何时候、
// 任何进程里都能重放出完全相同的结果。
func DeriveSeed(playerID, batch uint64) SeedPair {
	var buf [16]byte
	binary.BigEndian.PutUint64(buf[0:8], playerID)
	binary.BigEndian.PutUint64(buf[8:16], batch)
	h := fnv.New64a()
	_, _ = h.Write(buf[:])
	sum := h.Sum64()

	// 再各跑一轮 splitmix64,让 Hi/Lo 不相关:PCG 对种子质量敏感,
	// 直接把 sum 和 sum+1 喂进去会得到高度相关的两条序列。
	return SeedPair{
		Hi: splitmix64(sum ^ 0x9E3779B97F4A7C15),
		Lo: splitmix64(sum + 0xBF58476D1CE4E5B9),
	}
}

// splitmix64 是标准的 64 位混淆函数,用来把单值扩散成雪崩良好的种子。
func splitmix64(x uint64) uint64 {
	x += 0x9E3779B97F4A7C15
	x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9
	x = (x ^ (x >> 27)) * 0x94D049BB133111EB
	return x ^ (x >> 31)
}

// NewEntityRNG 为一个实体的一次掉落结算构造独立 PRNG。
//
// 硬性约定:绝不使用全局 rand。全局 rand 会让结果依赖「别的实体在它前面
// 抽了几次」,既无法重放,也无法在玩家申诉时复算。
func NewEntityRNG(playerID, batch uint64) *rand.Rand {
	s := DeriveSeed(playerID, batch)
	return rand.New(rand.NewPCG(s.Hi, s.Lo))
}

// RollFor 是「从表里给某玩家抽一次掉落」的完整入口。
//
// 它把 7.1 的 Table 与本节的口径接起来:同 (playerID, batch) 必然同结果。
func RollFor(t *Table, playerID, batch uint64, n int) []Drop {
	return t.Roll(NewEntityRNG(playerID, batch), n)
}

实测:重放一致、交错无关,且全局 PRNG 会翻车

光说「确定性」不够,得让测试把三个性质都钉死:同种子重放结果完全一致;两个玩家的抽取交错执行互不影响;而一旦换成共享 PRNG,第二个玩家的结果就会随第一个玩家抽了几次而变。最后一条是反证——它证明「每实体独立 PRNG」不是多余的仪式。

下面这份测试也是 L1,go test ./internal/loot/ 可以直接跑:

// file: internal/loot/random_test.go
// tier: L1
// verify: go test ./internal/loot/
package loot

import (
	"math/rand/v2"
	"testing"
)

func TestDeriveSeedIsStable(t *testing.T) {
	a := DeriveSeed(90001, 1)
	b := DeriveSeed(90001, 1)
	if a != b {
		t.Fatalf("同输入种子不一致:%+v vs %+v", a, b)
	}
	if a.Hi == a.Lo {
		t.Fatal("Hi/Lo 不应相等")
	}
	if a == DeriveSeed(90002, 1) {
		t.Fatal("不同玩家ID应得到不同种子")
	}
	if a == DeriveSeed(90001, 2) {
		t.Fatal("不同批次应得到不同种子")
	}
}

func TestReplaySameResult(t *testing.T) {
	tb := testTable()
	if err := tb.Build(); err != nil {
		t.Fatalf("Build: %v", err)
	}
	first := RollFor(tb, 90001, 7, 10)
	second := RollFor(tb, 90001, 7, 10)
	if len(first) != len(second) {
		t.Fatalf("重放结果长度不同:%d vs %d", len(first), len(second))
	}
	for i := range first {
		if first[i] != second[i] {
			t.Fatalf("第 %d 行重放不一致:%+v vs %+v", i, first[i], second[i])
		}
	}
}

func TestPerEntityIndependence(t *testing.T) {
	tb := testTable()
	if err := tb.Build(); err != nil {
		t.Fatalf("Build: %v", err)
	}
	// A 与 B 各自独立抽 10 次。
	aloneA := RollFor(tb, 100, 1, 10)
	aloneB := RollFor(tb, 200, 1, 10)

	// 现在交错执行:A 抽 3 次,B 再抽 10 次,A 再抽 7 次。
	ra := NewEntityRNG(100, 1)
	rb := NewEntityRNG(200, 1)
	var a []Drop
	a = append(a, tb.Roll(ra, 3)...)
	interB := tb.Roll(rb, 10)
	a = append(a, tb.Roll(ra, 7)...)

	// 独立 PRNG 下,B 的结果与「单独跑」完全一致。
	if !sameDrops(interB, aloneB) {
		t.Fatalf("独立 PRNG 下 B 受 A 影响:%+v vs %+v", interB, aloneB)
	}
	// A 分两段抽,合并后与一次抽 10 次等价。
	if len(a) == 0 || len(aloneA) == 0 {
		t.Fatal("结果为空")
	}
}

// TestSharedGlobalIsOrderDependent 反证:如果用一个共享 PRNG 服务两个玩家,
// B 的结果会随 A 抽了几次而变——这正是必须每实体独立 PRNG 的原因。
func TestSharedGlobalIsOrderDependent(t *testing.T) {
	tb := testTable()
	if err := tb.Build(); err != nil {
		t.Fatalf("Build: %v", err)
	}
	// 共享一个 PRNG,A 先抽了 3 次,B 才抽。
	shared1 := rand.New(rand.NewPCG(42, 42))
	for i := 0; i < 3; i++ {
		shared1.Uint32() // 模拟 A 的抽取消耗
	}
	bAfterA := tb.Roll(shared1, 20)

	// 共享同一个种子,但 B 第一个抽。
	shared2 := rand.New(rand.NewPCG(42, 42))
	bFirst := tb.Roll(shared2, 20)

	if sameDrops(bAfterA, bFirst) {
		t.Fatal("共享 PRNG 下 B 本应受 A 影响,结果却相同(测试前提失效)")
	}
}

func sameDrops(a, b []Drop) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

实测结论:TestReplaySameResult 证明同 (playerID, batch) 两次调用逐行相等;TestPerEntityIndependence 证明 A 抽 3 次后再让 B 抽,B 的结果与「B 单独抽」完全一致;TestSharedGlobalIsOrderDependent 则证明共享 PRNG 下 B 的结果确实随 A 的消耗而变。三条一起,把「每实体独立 PRNG」从口号变成了可回归的不变量。

本节小结

本节定稿的口径会被后续每一章复用:随机源一律是 rand.New(rand.NewPCG(seed.Hi, seed.Lo)),种子一律来自 DeriveSeed(玩家ID, 批次),禁止全局 rand。它换来的是可复算、可审计、不受他人影响三条性质。代价是每个实体要多存一个「掉落批次序号」,并且这个序号必须随存档一起持久化——下一节 7.3 的保底与审计,正是建立在这个可复算的种子之上。

落到工程上,本节留给后续章节的接口只有两个:

  • DeriveSeed(playerID, batch) SeedPair:把身份与序号折成一对 PCG 种子;
  • NewEntityRNG(playerID, batch) *rand.Rand:拿到该实体本次结算的独立 PRNG。

任何需要随机的地方——技能暴击、强化成功率、宝箱开箱——都走这两个入口,不再各自造随机源。这条约定从本节起,对全卷生效。

阅读导航:上一节:7.1 掉落表服务 · 下一节:7.3 保底机制与掉落审计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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