《游戏服务端架构·品类、状态与拓扑》9.2 回合制卡牌的确定性与回放

以回合制卡牌为例,说明服务端权威洗牌种子、固定版本的随机数、确定性结算顺序与状态哈希如何共同支撑可验证回放;给出 Go 实现的 SplitMix64 洗牌、出牌状态机、SHA-256 状态指纹与回放校验,覆盖同种子复现、非法出牌、篡改动作与截断回放测试,并讨论断线重连与防客户端篡改的边界。

一场无法复现的申诉

《泡泡工坊》的卡牌模式上线第二周,客服收到一条申诉:玩家坚称自己先打出了致命一击,结算却判他输。运营拉出对局记录:只有出牌顺序,没有洗牌种子;服务端日志里写着“随机数由进程启动时间初始化”。

回合制卡牌不需要高帧率,却对另一件事极其敏感:同一个输入必须得到同一个输出。做不到这一点,回放、申诉、审计、断线重连都无从谈起。

回放复现的是状态,不是画面

回放系统常见的误解是“把客户端画面录下来再播一遍”。服务端真正需要复现的是状态:

种子 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 以外的任何结果;动作非法或最终哈希不一致都会失败。

回放校验与断线重连

回放校验的标准流程是:

  1. 服务端在对局开始时生成种子,写入对局记录;
  2. 每个动作分配递增序号,服务端校验合法性后追加到日志;
  3. 对局结束或定时快照时计算状态哈希并写入记录;申诉或审计时从种子和动作日志重放并比对。

断线重连不需要重放整局。服务端保存最近一次快照(状态 + 已确认的动作序号),重连时下发快照,客户端从下一个序号继续。快照必须带规则版本和哈希,客户端只负责渲染,不能提交自己计算的状态。

防篡改的边界也要说清楚:客户端可以伪造动作,但伪造不了结算。服务端收到动作后重新校验回合、手牌、能量和费用;回放时再校验一次。客户端上报的“最终哈希”只能用于快速比对,不能作为权威。

本机验证

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 小游戏后端的轻量化与快速上线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查