《MMO 服务器开发》7.3 保底机制与掉落审计

纯随机会让倒霉的玩家永远倒霉,所以要有保底;纯随机又让客服无法自证清白,所以要有审计。本节在 internal/loot 里加两个部件:硬保底与软保底计数器,以及把每次掉落串成哈希链的审计日志,让整条掉落链路可复算、可追责,并全部用 go test 真跑验证。

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 物品与堆叠模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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