7.3 保底机制与掉落审计
7.1 有了掉落表,7.2 有了可复算的随机源,但一个能上线的掉落系统还差两块拼图。第一块是保底:纯随机的尾部是残酷的,按 1% 的橙装概率,连抽 300 次不出的玩家并不罕见——他不会去理解「独立同分布」,他只会觉得游戏在针对他。第二块是审计:当玩家申诉、当运营要核账、当有人怀疑掉率被暗改时,服务端必须能拿出证据,证明某一次掉落就是那么掉的。
保底和审计看似无关,其实是同一件事的两面:它们都要求「这次掉落发生过什么」是可追溯的。保底要追溯的是「这个玩家已经连续失败了几次」,审计要追溯的是「这一次到底掉了什么、凭什么」。本节就在 internal/loot 里把这两个部件补齐,全部是 L1,go test 直接跑。
硬保底:把「最多等多久」封死
硬保底是最简单也最常用的一种:连续 N 次没出稀有,第 N 次强制出。PityTracker 只维护一个计数器,不碰任何随机数——这正是它能被单独单测、也方便随存档持久化的原因。Resolve(naturalRare) 在自然抽取结果之上叠加判定:自然出稀有就清零,否则计数加一,达到阈值就强制出并清零。把随机数排除在外,意味着保底逻辑本身是可穷举验证的确定性状态机,没有「概率上应该对」这种模糊地带。
下面这段是 L1,go test ./internal/loot/ 可以直接跑:
// file: internal/loot/pity.go
// tier: L1
// verify: go test ./internal/loot/
package loot
import "math/rand/v2"
// PityTracker 实现「硬保底」:连续 Threshold 次没出稀有,第 Threshold 次强制出。
//
// 它只维护计数,不碰任何随机数,所以可以被单独单测,也方便存档与回档。
type PityTracker struct {
Threshold uint32
misses uint32
}
// NewPity 构造一个硬保底计数器;Threshold 为 0 表示不保底。
func NewPity(threshold uint32) *PityTracker {
return &PityTracker{Threshold: threshold}
}
// Resolve 在自然抽取结果之上叠加保底判定。
//
// naturalRare 为真表示本次自然抽中稀有;返回 forcedRare 为真表示本次是
// 保底强制产出(此时自然结果按未中处理)。两种情况都会清零计数。
func (p *PityTracker) Resolve(naturalRare bool) (forcedRare bool) {
if naturalRare {
p.misses = 0
return false
}
if p.Threshold == 0 {
return false
}
p.misses++
if p.misses >= p.Threshold {
p.misses = 0
return true
}
return false
}
// Misses 返回当前连续未出稀有的次数。
func (p *PityTracker) Misses() uint32 { return p.misses }
// SoftPity 实现「软保底」:次数越接近硬保底,稀有概率被抬得越高。
//
// 硬保底给的是「最多等多久」的上界,软保底给的是「越等越容易出」的手感。
// 两者叠加后,期望抽取次数显著低于纯硬保底,但最坏情况仍被封死。
type SoftPity struct {
Start uint32 // 从第 Start 次未中开始抬概率
Hard uint32 // 硬保底次数;达到即强制出
BaseRate float64 // 起始概率
MaxRate float64 // 抬升上限
misses uint32
}
// Rate 返回当前这一次的稀有概率(不含硬保底强制)。
func (s *SoftPity) Rate() float64 {
if s.misses < s.Start {
return s.BaseRate
}
span := float64(s.Hard) - float64(s.Start)
if span <= 0 {
return s.MaxRate
}
t := float64(s.misses-s.Start) / span
r := s.BaseRate + t*(s.MaxRate-s.BaseRate)
if r > s.MaxRate {
return s.MaxRate
}
if r < s.BaseRate {
return s.BaseRate
}
return r
}
// Roll 用该实体的独立 PRNG 做一次软保底判定。
func (s *SoftPity) Roll(r *rand.Rand) (rare bool) {
if s.Hard > 0 && s.misses+1 >= s.Hard {
s.misses = 0
return true
}
if r.Float64() < s.Rate() {
s.misses = 0
return true
}
s.misses++
return false
}
// Misses 返回当前连续未出稀有的次数。
func (s *SoftPity) Misses() uint32 { return s.misses }
软保底:让「越等越容易」有手感
SoftPity 在硬保底之上加了一层随次数上升的概率曲线。Rate() 在 misses < Start 时返回基础概率,之后线性抬升,直到 Hard 处被 Roll 的硬保底分支直接拦截。注意 Roll 的顺序:先判硬保底,再判自然概率——这样 Hard 次一定出,无论曲线怎么算。两个保底叠加的效果是:期望抽取次数明显下降,而「最坏多少次必出」仍然是一个可以写进公示的确定数字。
保底的计数必须和 7.2 的批次序号一样持久化。玩家下线再上线、服务端重启,misses 都不能归零——否则玩家只要反复重登就能「刷新」保底进度,等于白送。这也是为什么 PityTracker 被设计成纯数据、可序列化,而不是藏在某个会随进程消失的对象里。
审计链:让每次掉落可被复算与追责
审计日志要回答两个问题:这次掉落「凭什么」是这些结果,以及这些记录「有没有被动过」。第一个问题由 7.2 的种子回答——只要记下 (Player, Batch, Seed),就能重放出同样的掉落。第二个问题由哈希链回答:每条记录都带上一条的链哈希,改任何一条都会让它后面的链全部对不上。DigestDrops 把掉落结果压成指纹,Verify 则从头重算整条链。
下面这段也是 L1,go test ./internal/loot/ 可以直接跑:
// file: internal/loot/audit.go
// tier: L1
// verify: go test ./internal/loot/
package loot
import (
"encoding/binary"
"hash/fnv"
)
// AuditRecord 是一次掉落结算的可复算凭证。
//
// 有了 Player + Batch + Seed 就能重放出同样的掉落;Digest 是结果的指纹,
// 用来和重放结果比对;Prev/Chain 把记录串成一条哈希链,任何一条被改都会断链。
type AuditRecord struct {
Batch uint64 // 掉落批次序号
Player uint64 // 玩家ID
SeedHi uint64 // 复算用的 PCG 种子高位
SeedLo uint64 // 复算用的 PCG 种子低位
Digest uint64 // 掉落结果指纹
Prev uint64 // 上一条记录的链哈希
Chain uint64 // 本条记录的链哈希
}
// AuditLog 是内存里的追加式哈希链。
type AuditLog struct {
records []AuditRecord
head uint64
}
// Append 记录一次掉落并返回写入的记录。
func (l *AuditLog) Append(player, batch uint64, seed SeedPair, drops []Drop) AuditRecord {
rec := AuditRecord{
Batch: batch,
Player: player,
SeedHi: seed.Hi,
SeedLo: seed.Lo,
Digest: DigestDrops(drops),
Prev: l.head,
}
rec.Chain = chainHash(rec)
l.head = rec.Chain
l.records = append(l.records, rec)
return rec
}
// Records 返回链上的全部记录(只读用途)。
func (l *AuditLog) Records() []AuditRecord { return l.records }
// Head 返回当前链尾哈希。
func (l *AuditLog) Head() uint64 { return l.head }
// Verify 从头重算整条链,任一条被篡改或次序被调换都会返回 false。
func (l *AuditLog) Verify() bool {
var prev uint64
for _, rec := range l.records {
if rec.Prev != prev {
return false
}
if chainHash(rec) != rec.Chain {
return false
}
prev = rec.Chain
}
return prev == l.head
}
// DigestDrops 把掉落结果压成一个 64 位指纹。
//
// 结果必须已按 ItemID 排序(Table.Roll 保证),否则指纹会随遍历顺序抖动。
func DigestDrops(drops []Drop) uint64 {
h := fnv.New64a()
var b [8]byte
for _, d := range drops {
binary.BigEndian.PutUint32(b[0:4], uint32(d.ItemID))
binary.BigEndian.PutUint32(b[4:8], d.Count)
_, _ = h.Write(b[:])
}
return h.Sum64()
}
func chainHash(rec AuditRecord) uint64 {
var b [40]byte
binary.BigEndian.PutUint64(b[0:8], rec.Prev)
binary.BigEndian.PutUint64(b[8:16], rec.Player)
binary.BigEndian.PutUint64(b[16:24], rec.Batch)
binary.BigEndian.PutUint64(b[24:32], rec.SeedHi)
binary.BigEndian.PutUint64(b[32:40], rec.SeedLo)
h := fnv.New64a()
_, _ = h.Write(b[:])
var d [8]byte
binary.BigEndian.PutUint64(d[:], rec.Digest)
_, _ = h.Write(d[:])
return h.Sum64()
}
审计链的三个性质都在测试里钉死了:未篡改的链 Verify 返回真;把中间一条的指纹改掉一位,Verify 立刻返回假;拿记录里的 (Player, Batch) 重新建 PRNG 重放,DigestDrops 算出的指纹与记录里的 Digest 逐位相等。第三条是客服复算的核心——它把「服务端说没算错」变成了「任何人都能自己算一遍」。
本节小结
本节把掉落系统补齐成「表 + 随机 + 保底 + 审计」四件套。四者的关系是:掉落表定义候选,确定性随机定义抽取,保底修正尾部体验,审计把整条链路变成可复算、可追责的证据。实测:go test ./internal/loot/ 全绿,其中 TestAuditTamperDetected 证明改一位指纹就会断链,TestAuditReplayMatchesDigest 证明重放指纹与原记录一致,TestHardPityFiresOnThreshold 证明第 N 次必触发且计数归零。
至此第 7 章结束。下一章进入背包与道具:掉落算出来的 Drop 只是「应该给什么」,真正把它塞进玩家背包、并处理堆叠与并发修改,是 8.1 的主题。
阅读导航:上一节:7.2 确定性随机与随机种子 · 下一节:8.1 物品与堆叠模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。