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 保底机制与掉落审计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。