一场无法复现的申诉
《泡泡工坊》的卡牌模式上线第二周,客服收到一条申诉:玩家坚称自己先打出了致命一击,结算却判他输。运营拉出对局记录:只有出牌顺序,没有洗牌种子;服务端日志里写着“随机数由进程启动时间初始化”。
回合制卡牌不需要高帧率,却对另一件事极其敏感:同一个输入必须得到同一个输出。做不到这一点,回放、申诉、审计、断线重连都无从谈起。
回放复现的是状态,不是画面
回放系统常见的误解是“把客户端画面录下来再播一遍”。服务端真正需要复现的是状态:
种子 seed + 出牌动作序列 actions + 规则版本 rules_version
│
▼
确定性结算引擎(纯函数式推进)
│
▼
最终状态 state → SHA-256 → 状态哈希
只要这四个输入一致,最终状态哈希就必须一致。画面、动画、音效都是客户端根据状态渲染的派生结果,不属于回放契约;种子、动作序列与规则版本必须一起保存。
随机数必须固定版本
最隐蔽的不确定性来自标准库。math/rand 的默认源在不同 Go 版本之间不保证序列稳定,团队也不会把“升级 Go 版本”当成破坏性变更来评审。一旦线上换了运行时,旧回放就可能对不上。
卡牌项目的约束是:
- 洗牌只用包内固定的 SplitMix64,不依赖
math/rand; - 种子由服务端用
crypto/rand生成,客户端永远不能指定; - 不把时间、goroutine 调度、网络到达顺序写进结算逻辑。
洗牌与结算的确定性边界
洗牌只是第一步。真正的确定性来自“结算顺序也被定义”:
| 不确定性来源 | 典型写法 | 修正方式 |
|---|---|---|
| 随机源 | rand.Shuffle、全局随机种子 | 包内固定 PRNG,种子显式传入 |
| 容器遍历 | 用 map 遍历玩家或效果 | 玩家按固定顺序,效果按优先级排序 |
| 并发 | 多个 goroutine 同时改状态 | 单线程推进,或按确定性顺序合并 |
| 时间 | 用 time.Now() 决定抽牌 | 回合数、动作序号驱动 |
| 客户端输入 | 客户端上报“我造成了 5 点伤害” | 只接受动作,伤害由服务端计算 |
动作本身要尽量小:出哪张牌、由谁出、结束回合。不要接受“结算结果”,也不要接受客户端计算出的随机数。服务端把动作追加到日志,再推进状态,日志和状态一起构成可验证的回放。
状态哈希覆盖什么
状态哈希不是“给状态拍个照”,而是回放契约的校验和。它应该覆盖所有影响胜负与后续合法动作的字段:
- 种子、当前回合玩家、是否结束、胜者、规则版本;
- 每个玩家的血量、能量、手牌及其顺序;
- 牌库剩余卡牌及其顺序。
不应该覆盖:渲染缓存、连接 ID、最后心跳时间、日志序号。把这些放进去,哈希会因为无关事件变化,回放校验反而失去意义。
序列化也必须确定:玩家按 ID 排序,卡牌按手牌和牌库的真实顺序拼接,布尔值用固定字面量
代码:确定性引擎
下面的 internal/cardgame/cardgame.go 把洗牌、出牌、结算、哈希和回放校验放在一个包里。NewGame 只接受种子和两名玩家;Apply 是唯一的状态变更入口;Replay.Verify 从种子和动作日志重建对局,并比对最终哈希。
// file: internal/cardgame/cardgame.go
// tier: L1
// verify: go test ./internal/cardgame/
package cardgame
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
)
const (
StartHP = 20
HandSize = 3
EnergyPerTurn = 3
KindPlay = "play"
KindEnd = "end"
)
var (
ErrBadPlayers = errors.New("cardgame: need two distinct players")
ErrNotYourTurn = errors.New("cardgame: not your turn")
ErrUnknownCard = errors.New("cardgame: card not in hand")
ErrNotEnoughEnergy = errors.New("cardgame: not enough energy")
ErrGameOver = errors.New("cardgame: game already over")
ErrBadAction = errors.New("cardgame: malformed action")
ErrReplayMismatch = errors.New("cardgame: replay hash mismatch")
)
// Card IDs are unique per copy. Two copies of "strike" are "strike-0" and
// "strike-1", so an action can never be ambiguous.
type Card struct {
ID string
Cost int
Damage int
Heal int
}
var blueprints = []struct {
name string
cost, damage int
heal int
}{
{"strike", 1, 3, 0},
{"guard", 1, 0, 2},
{"bolt", 2, 5, 0},
{"mend", 2, 0, 4},
{"jab", 0, 1, 0},
{"focus", 0, 0, 1},
}
func buildDeck() []Card {
deck := make([]Card, 0, len(blueprints)*2)
for _, b := range blueprints {
for i := 0; i < 2; i++ {
deck = append(deck, Card{
ID: fmt.Sprintf("%s-%d", b.name, i), Cost: b.cost,
Damage: b.damage, Heal: b.heal,
})
}
}
return deck
}
// SplitMix64 is fixed in this package. The Go runtime may change math/rand
// internals between releases, which would silently break old replays.
func splitmix(x *uint64) uint64 {
*x += 0x9E3779B97F4A7C15
z := *x
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
return z ^ (z >> 31)
}
// Shuffle is Fisher-Yates over splitmix64. Same seed and deck always produce
// the same order on every platform.
func Shuffle(cards []Card, seed uint64) []Card {
out := append([]Card(nil), cards...)
x := seed
for i := len(out) - 1; i > 0; i-- {
j := int(splitmix(&x) % uint64(i+1))
out[i], out[j] = out[j], out[i]
}
return out
}
type Action struct {
Kind string
Player string
CardID string
}
// State is the complete authoritative game state. Maps are always serialized
// in sorted player order by Hash.
type State struct {
Seed uint64
Players []string
Deck []Card
Hands map[string][]Card
HP map[string]int
Energy map[string]int
Turn string
Finished bool
Winner string
}
func NewGame(seed uint64, players []string) (*State, error) {
if len(players) != 2 || players[0] == "" || players[1] == "" ||
players[0] == players[1] {
return nil, ErrBadPlayers
}
s := &State{
Seed: seed, Players: append([]string(nil), players...),
Deck: Shuffle(buildDeck(), seed),
Hands: map[string][]Card{}, HP: map[string]int{},
Energy: map[string]int{}, Turn: players[0],
}
for _, p := range players {
s.HP[p] = StartHP
s.Energy[p] = EnergyPerTurn
}
for i := 0; i < HandSize; i++ {
for _, p := range players {
s.draw(p)
}
}
return s, nil
}
func (s *State) draw(player string) bool {
if len(s.Deck) == 0 {
return false
}
card := s.Deck[0]
s.Deck = s.Deck[1:]
s.Hands[player] = append(s.Hands[player], card)
return true
}
func (s *State) other(player string) string {
if player == s.Players[0] {
return s.Players[1]
}
return s.Players[0]
}
// Apply is the only mutation path. The client sends actions, never a claimed
// result; the server (or the replay verifier) recomputes the state.
func (s *State) Apply(a Action) error {
if s.Finished {
return ErrGameOver
}
if a.Player != s.Turn {
return ErrNotYourTurn
}
switch a.Kind {
case KindEnd:
s.endTurn()
return nil
case KindPlay:
return s.play(a)
default:
return ErrBadAction
}
}
func (s *State) play(a Action) error {
hand := s.Hands[a.Player]
idx := -1
for i, c := range hand {
if c.ID == a.CardID {
idx = i
break
}
}
if idx < 0 {
return ErrUnknownCard
}
card := hand[idx]
if s.Energy[a.Player] < card.Cost {
return ErrNotEnoughEnergy
}
s.Energy[a.Player] -= card.Cost
s.Hands[a.Player] = append(append([]Card{}, hand[:idx]...), hand[idx+1:]...)
target := s.other(a.Player)
if card.Damage > 0 {
s.HP[target] -= card.Damage
}
if card.Heal > 0 {
s.HP[a.Player] += card.Heal
}
if s.HP[target] <= 0 {
s.Finished = true
s.Winner = a.Player
}
return nil
}
func (s *State) endTurn() {
next := s.other(s.Turn)
s.Turn = next
s.Energy[next] = EnergyPerTurn
s.draw(next)
}
// Hash is the replay fingerprint. It covers every field that can change the
// outcome, including deck order and each hand's order.
func (s *State) Hash() string {
var b strings.Builder
fmt.Fprintf(&b, "cardgame-v1|seed=%d|turn=%s|finished=%t|winner=%s",
s.Seed, s.Turn, s.Finished, s.Winner)
players := append([]string(nil), s.Players...)
sort.Strings(players)
for _, p := range players {
fmt.Fprintf(&b, "|p=%s,hp=%d,energy=%d,hand=", p, s.HP[p], s.Energy[p])
for _, c := range s.Hands[p] {
b.WriteString(c.ID)
b.WriteByte(',')
}
}
b.WriteString("|deck=")
for _, c := range s.Deck {
b.WriteString(c.ID)
b.WriteByte(',')
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
type Replay struct {
Seed uint64
Players []string
Actions []Action
ExpectedHash string
}
// Verify rebuilds the game from the seed and action log. A tampered action is
// caught either as an illegal move or as a different final hash.
func (r Replay) Verify() (*State, error) {
if r.ExpectedHash == "" {
return nil, ErrBadAction
}
s, err := NewGame(r.Seed, r.Players)
if err != nil {
return nil, err
}
for i, a := range r.Actions {
if err := s.Apply(a); err != nil {
return nil, fmt.Errorf("action %d: %w", i, err)
}
}
if got := s.Hash(); got != r.ExpectedHash {
return nil, fmt.Errorf("%w: got %s want %s", ErrReplayMismatch, got, r.ExpectedHash)
}
return s, nil
}
几个设计点:
Hash对玩家 ID 排序,但保留手牌与牌库顺序。手牌顺序影响后续动作的索引语义,不能排序。Replay.Verify不信任调用方传入的ExpectedHash以外的任何结果;动作非法或最终哈希不一致都会失败。
回放校验与断线重连
回放校验的标准流程是:
- 服务端在对局开始时生成种子,写入对局记录;
- 每个动作分配递增序号,服务端校验合法性后追加到日志;
- 对局结束或定时快照时计算状态哈希并写入记录;申诉或审计时从种子和动作日志重放并比对。
断线重连不需要重放整局。服务端保存最近一次快照(状态 + 已确认的动作序号),重连时下发快照,客户端从下一个序号继续。快照必须带规则版本和哈希,客户端只负责渲染,不能提交自己计算的状态。
防篡改的边界也要说清楚:客户端可以伪造动作,但伪造不了结算。服务端收到动作后重新校验回合、手牌、能量和费用;回放时再校验一次。客户端上报的“最终哈希”只能用于快速比对,不能作为权威。
本机验证
internal/cardgame 有 5 个测试函数、23 个子测试。除了同种子复现和非法动作,还固定了一个 golden 哈希,防止后续重构悄悄改变结算结果。
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/cardgame
$ go vet ./internal/cardgame/
$ go test -count=1 ./internal/cardgame/
ok gsarchlab/internal/cardgame 0.525s
gofmt -l 与 go vet 均无输出,退出码为 0,耗时随机器变化。seed=42、6 个回合脚本的实测最终哈希为:
fbf8aef75cc3b123d05288a1f718d7d57d1c883d036d4e68643d364ca1e13c27
常见错误与边界
第一,用 math/rand 的全局种子。进程启动时间、运行时版本、并发调用都会影响序列。
第二,只存动作不存种子;或者哈希只覆盖血量。前者变不出牌序,后者漏掉手牌、牌库、能量与回合。
第三,客户端上报结算结果。服务端一旦接受,外挂只需要伪造结果,不需要伪造动作。
第四,回放不记录规则版本。版本升级后,旧对局会被新规则重新解释。
小结
卡牌回放的确定性来自四个固定:固定版本的随机数、服务端生成的种子、显式的结算顺序、覆盖完整状态的哈希。只要种子、动作日志和规则版本齐全,任何一台机器都能重建同一局;断线重连则用“快照 + 动作序号”避免全量重放。
下一节把视角拉回小游戏后端:当团队只有几个人、上线窗口只有两周时,哪些能力必须先有,哪些可以晚点再做。
阅读导航:上一节:9.1 无状态大厅加有状态房间 · 下一节:9.3 小游戏后端的轻量化与快速上线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。