一次热更,让历史抽奖无法复盘
《群岛纪元》的掉落表放在配置中心,运营可以直接改权重。某次活动把稀有材料的权重从 1 调到 3,热更生效。两周后玩家投诉“同一场战斗,回放里掉的是材料,背包里却是金币”。排查发现:战斗结算时读的是旧表,发奖时读的是新表,而抽奖记录只存了“掉了什么”,没存“用的是哪一版表、哪个随机数”。
这不是随机数算法的问题。即使换成密码学安全的随机源,只要“当时的输入”没有被完整记录,事后就无法复现,也无法证明服务端没有偏袒。经济系统的可信边界,首先是一条可审计的边界,其次才是“不可预测”。
《裂隙战场》的诉求不同。开箱结果需要让玩家相信没有被操控,所以采用种子提交-揭示:开局前公布承诺,结算后公布种子,玩家可以自行验证。《泡泡工坊》则只需要服务端权威加流水对账,玩家不验证概率,但运营必须能解释每一笔补偿。
三种随机方案与取舍
| 方案 | 谁能验证 | 防运营作弊 | 实现成本 | 适用 |
|---|---|---|---|---|
| 纯服务端随机 | 只有服务端 | 弱,依赖审计 | 低 | 休闲、单机 |
| 客户端提供随机种子 | 客户端 | 无,客户端可挑结果 | 低 | 不推荐 |
| 提交-揭示 + 双种子 | 服务端与玩家 | 强,承诺不可抵赖 | 中 | 竞技、开箱 |
| 链上可验证随机 | 所有人 | 最强 | 高,依赖外部网络 | 特殊场景 |
选择的关键不是“哪个更安全”,而是“谁需要验证”。如果只有运营需要复盘,服务端权威加完整流水就够了;如果玩家会质疑概率,提交-揭示才值得引入。链上方案在延迟、成本和合规上都有额外负担,不适合作为默认选项。
提交-揭示的顺序决定它是否有效
提交-揭示只有顺序正确才成立:
开局前 服务端生成 serverSeed,公布 commit = SHA256(serverSeed)
玩家提交 clientSeed(或在对局开始时固定)
结算时 rand = HMAC(serverSeed, clientSeed || round)
按 rand 在掉落表当前版本中取结果
公布后 服务端公布 serverSeed
玩家校验 SHA256(serverSeed) == commit,并自行重算 rand
如果服务端先看到 clientSeed 再生成 serverSeed,它就可以反复挑选种子来得到想要的结果。承诺必须在玩家输入之前发出,或者把玩家输入绑定进承诺的验证路径。本节实现假设承诺先于结算,代码只负责验证承诺与派生随机数,时序必须由房间服的状态机保证。取模偏差在总权重远小于 2^64 时可以忽略;权重接近该量级时应改用拒绝采样。
掉落表必须版本化
掉落表有三个容易踩的坑:
第一,原地修改。运营在配置中心把权重改掉,历史记录失去复现依据。正确做法是每次调整生成新版本,旧版本永久保留。
第二,只存结果不存版本。抽奖记录里只有 item=稀有材料,没有 table=boss_drop, version=7,审计时无法判断当时用的是哪张表。
第三,用“最新表”重算历史。对账脚本为了省事直接读当前版本,会把一次正常的旧结果判成异常,或者把一次真实的配置错误洗掉。
因此抽奖记录至少包含:表名、表版本、serverSeed 的承诺、clientSeed、轮次、派生随机数、结果。有了这七项,任何一次抽奖都可以离线重放。
双分录账本:让每一笔产出都有对手方
随机数决定“掉什么”,账本决定“东西怎么进来的”。单边记账只记录“玩家 +100 金币”,无法回答“这 100 金币从哪来”。双分录要求每笔交易至少两条分录,且和为零:
掉落: player:1 +100 system:faucet -100
消费: player:1 -30 system:sink +30
补偿: player:1 +50 system:support -50
system:* 是系统账户,允许为负;玩家账户的负值策略由业务决定,账本本身只保证每笔平衡。这样做的收益是:任何时刻所有账户余额之和为零,凭空多出的货币一定对应某个系统账户的异常,审计范围从“全量流水”缩小到“系统账户”。
对账是账本与外部快照的比较。快照可能来自数据库备份、运营后台或第三方支付。对账只报告差异,不自动修正;自动修正会覆盖掉“哪一边错了”的证据,让下一次对账继续错。
代码:economy 包
下面的实现包含四部分:承诺与验证、HMAC 随机派生、不可变掉落表与版本集合、双分录账本与对账。它不包含发奖流程,也不包含数据库,因为这两部分应该由经济服务和存储层负责。
// file: internal/economy/economy.go
// tier: L1
// verify: go test ./internal/economy/
package economy
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"sort"
)
// Commit seals a server seed before the round starts. SHA-256 is enough here
// because the seed is 32 random bytes, not a guessable user value.
func Commit(serverSeed []byte) string {
sum := sha256.Sum256(serverSeed)
return hex.EncodeToString(sum[:])
}
func Reveal(serverSeed []byte, commit string) bool { return Commit(serverSeed) == commit }
// Roll derives a uint64 from both seeds and the round number. HMAC keeps the
// server seed from being recoverable from published outcomes.
func Roll(serverSeed, clientSeed []byte, round uint64) uint64 {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], round)
mac := hmac.New(sha256.New, serverSeed)
mac.Write(clientSeed)
mac.Write(buf[:])
sum := mac.Sum(nil)
return binary.BigEndian.Uint64(sum[:8])
}
type Entry struct {
Item string
Weight int
}
// DropTable is immutable. A rebalance creates a new version instead of editing
// an existing one, so historical draws can always be re-derived.
type DropTable struct {
Name string
Version int
entries []Entry
total int
}
func NewTable(name string, version int, entries []Entry) (*DropTable, error) {
if name == "" {
return nil, errors.New("economy: empty table name")
}
if version < 1 {
return nil, errors.New("economy: version must be >= 1")
}
if len(entries) == 0 {
return nil, errors.New("economy: empty drop table")
}
t := &DropTable{Name: name, Version: version, entries: append([]Entry(nil), entries...)}
seen := make(map[string]bool, len(entries))
for _, e := range t.entries {
if e.Item == "" {
return nil, errors.New("economy: empty item name")
}
if e.Weight <= 0 {
return nil, fmt.Errorf("economy: item %s has non-positive weight", e.Item)
}
if seen[e.Item] {
return nil, fmt.Errorf("economy: duplicate item %s", e.Item)
}
seen[e.Item] = true
t.total += e.Weight
}
return t, nil
}
func (t *DropTable) Total() int { return t.total }
// Draw maps an already-derived random value onto the table. Callers must pass a
// value from Roll, never a client-supplied index.
func (t *DropTable) Draw(rand uint64) (string, error) {
if t.total <= 0 {
return "", errors.New("economy: table has no weight")
}
roll := int(rand % uint64(t.total))
acc := 0
for _, e := range t.entries {
acc += e.Weight
if roll < acc {
return e.Item, nil
}
}
return "", errors.New("economy: roll outside table")
}
// TableSet keeps every published version. Get is how an audit re-runs an old
// draw; Latest is how live traffic picks the current table.
type TableSet struct {
tables map[string][]*DropTable
}
func NewTableSet() *TableSet {
return &TableSet{tables: make(map[string][]*DropTable)}
}
func (s *TableSet) Add(t *DropTable) error {
for _, old := range s.tables[t.Name] {
if old.Version == t.Version {
return fmt.Errorf("economy: table %s version %d already exists", t.Name, t.Version)
}
}
s.tables[t.Name] = append(s.tables[t.Name], t)
sort.Slice(s.tables[t.Name], func(i, j int) bool {
return s.tables[t.Name][i].Version < s.tables[t.Name][j].Version
})
return nil
}
func (s *TableSet) Get(name string, version int) (*DropTable, error) {
for _, t := range s.tables[name] {
if t.Version == version {
return t, nil
}
}
return nil, fmt.Errorf("economy: table %s version %d not found", name, version)
}
func (s *TableSet) Latest(name string) (*DropTable, error) {
versions := s.tables[name]
if len(versions) == 0 {
return nil, fmt.Errorf("economy: table %s not found", name)
}
return versions[len(versions)-1], nil
}
type Posting struct {
Account string
Amount int64
}
// Journal is one balanced transaction. Positive amounts debit, negative
// amounts credit; the names are bookkeeping, not game semantics.
type Journal struct {
ID string
Round uint64
Memo string
Postings []Posting
}
var (
ErrUnbalanced = errors.New("economy: journal does not sum to zero")
ErrDuplicate = errors.New("economy: duplicate journal id")
ErrNoPostings = errors.New("economy: journal has no postings")
)
// Ledger is an append-only double-entry book. Balances are a cache derived from
// journals; Verify rebuilds them to catch accidental direct writes.
type Ledger struct {
balances map[string]int64
journals []Journal
seen map[string]bool
}
func NewLedger() *Ledger {
return &Ledger{balances: make(map[string]int64), seen: make(map[string]bool)}
}
func (l *Ledger) Post(j Journal) error {
if j.ID == "" {
return errors.New("economy: empty journal id")
}
if l.seen[j.ID] {
return ErrDuplicate
}
if len(j.Postings) == 0 {
return ErrNoPostings
}
var sum int64
for _, p := range j.Postings {
if p.Account == "" {
return errors.New("economy: empty account")
}
if p.Amount == 0 {
return errors.New("economy: zero posting")
}
sum += p.Amount
}
if sum != 0 {
return ErrUnbalanced
}
l.seen[j.ID] = true
for _, p := range j.Postings {
l.balances[p.Account] += p.Amount
}
copied := j
copied.Postings = append([]Posting(nil), j.Postings...)
l.journals = append(l.journals, copied)
return nil
}
func (l *Ledger) Balance(account string) int64 { return l.balances[account] }
func (l *Ledger) Verify() error {
rebuilt := make(map[string]int64)
for _, j := range l.journals {
var sum int64
for _, p := range j.Postings {
rebuilt[p.Account] += p.Amount
sum += p.Amount
}
if sum != 0 {
return fmt.Errorf("economy: journal %s is unbalanced", j.ID)
}
}
if len(rebuilt) != len(l.balances) {
return errors.New("economy: account set drift")
}
for account, want := range rebuilt {
if l.balances[account] != want {
return fmt.Errorf("economy: balance drift on %s", account)
}
}
return nil
}
type Diff struct {
Account string
Ledger int64
Snapshot int64
}
// Reconcile compares the ledger with an external snapshot. It reports drift; it
// never "fixes" it, because an automatic fix destroys the evidence needed to
// decide whether the ledger or the snapshot is wrong.
func (l *Ledger) Reconcile(snapshot map[string]int64) []Diff {
seen := make(map[string]bool, len(l.balances))
var out []Diff
for account, balance := range l.balances {
seen[account] = true
if snap, ok := snapshot[account]; !ok || snap != balance {
out = append(out, Diff{Account: account, Ledger: balance, Snapshot: snap})
}
}
for account, snap := range snapshot {
if !seen[account] {
out = append(out, Diff{Account: account, Ledger: 0, Snapshot: snap})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Account < out[j].Account })
return out
}
DropTable 的字段全部私有,构造后没有修改入口。版本集合 TableSet 按版本号排序,Get 用于审计重放,Latest 用于线上抽取;两者分开,避免“为了查历史顺手用了最新表”。
Ledger.Post 在写入前完成全部校验:ID 非空且唯一、分录非空、账户非空、金额非零、总和为零。任何一条不满足都不写入,也不留下半成品。余额只是缓存,Verify 会从流水重新计算并逐账户比对。
Reconcile 返回按账户名排序的差异列表,包含“账本有、快照没有”和“快照有、账本没有”两种情况,排序让输出稳定、便于比对。
测试覆盖了什么
lab 里的 internal/economy 有 7 个测试函数,其中 4 个是表驱动:
| 测试 | 覆盖的失败模式 |
|---|---|
TestCommitReveal | 正确种子、错误种子、被篡改的承诺 |
TestTableValidation | 空表名、零版本、空条目、零权重、重复物品 |
TestDrawBoundaries | 权重边界与取模回绕 |
TestLedgerRejectsInvalidJournals | 不平、无分录、零金额、重复 ID |
TestRollIsDeterministicPerRound | 同输入同结果,不同轮次不同结果 |
TestVersionPinning | 旧版本可重放,最新版本独立演进 |
TestLedgerVerifyAndReconcile | 余额重算、差异报告、直接改余额被发现 |
本机运行结果:
$ go test -count=1 ./internal/economy/
ok gsarchlab/internal/economy 0.468s
失败模式与兜底
种子提前泄露。 承诺已公布但种子在结算前泄露,玩家可以预测结果。泄露应按事故处理:作废该轮、换种子、重新承诺,并保留事故记录;不能“假装没发生”继续发奖。
热更原地改表。 版本号没变但内容变了,历史重放全部失效。配置系统必须禁止原地修改已发布版本,或者在加载时计算内容哈希并与版本绑定。
补偿写成了单边分录。 客服直接给玩家加钱,没有对手方账户,账本总和不再为零。应统一走 system:support 账户,让补偿也进入对账范围。
对账自动修正。 脚本发现差异后直接以快照为准覆盖账本,下一次差异继续被覆盖,问题永远查不出来。对账输出必须只读,修正要走人工审批和新的分录。
常见错误与边界
第一,用 math/rand 的全局源做经济随机。它可预测、可并发争用,且种子往往来自时间;经济随机必须使用密码学随机源派生,并记录派生输入。
第二,只让客户端提供种子。客户端可以反复重连直到拿到想要的结果;客户端种子只能作为附加熵,不能成为唯一随机源。
第三,抽奖记录只存物品 ID。没有表版本、种子和轮次,任何“概率不公”的质疑都无法自证。
第四,账本只存玩家侧分录。没有对手方就无法判断货币是产出、转移还是复制,也无法做全局守恒校验。
第五,把“可审计”理解成“不可预测”。对玩家而言不可预测很重要,对运营而言可复现更重要;两者不冲突,但实现顺序应该是先可审计,再谈不可预测。
小结
经济系统的可信边界由三件事组成:随机数的输入可复现、掉落表的版本可追溯、货币的流动可守恒。提交-揭示解决“玩家凭什么相信”,版本化解决“运营凭什么复盘”,双分录解决“系统凭什么自证没有凭空造钱”。
internal/economy 把这三点压缩成可测试的包。它不追求密码学协议的完备性,也不替代账务系统;它给出的是最小可信骨架:任何一次掉落都能重放,任何一笔货币都有对手方,任何一次对账差异都不会被静默抹平。
下一章进入具体品类:MOBA 的房间、tick 与帧同步,看看这些权威与信任约束如何在 5v5 对局里落地。
阅读导航:上一节:6.2 反作弊应该放在架构的哪一层 · 下一节:7.1 MOBA 的房间、tick 与帧同步 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。