一、重连不是重新登录
登录建立的是账号会话,重连恢复的是“战斗内的实体”。两者失败时表现完全不同:登录失败可以回大厅,重连失败意味着玩家还占着战斗里的位置,却收不到任何帧。
如果重连走完整登录流程,玩家会被当作新会话创建,战斗侧要么找不到旧实体,要么创建第二个实体。正确做法是把“战斗 ID + 玩家 ID”作为战斗实体的唯一键,重连只做状态恢复,不重新创建。
二、重连请求必须带的四样东西
| 字段 | 作用 | 缺失后果 |
|---|---|---|
| 战斗 ID | 定位实例与快照 | 恢复到另一场战斗或找不到 |
| 玩家 ID | 绑定实体与权限 | 把别人的角色恢复给自己 |
| 最后确认帧 | 决定从哪里开始补 | 重放起点错误,状态漂移 |
| 会话凭证 | 证明这是原玩家 | 任意客户端可顶号 |
最后确认帧不是“客户端当前帧”,而是客户端最后一次收到服务器确认的帧。它可能落后当前服务器帧很多,恢复逻辑要据此判断是补输入、拉快照还是拒绝。
三、快照加帧号,还是重放全部输入
| 方式 | 数据量 | 恢复速度 | 适用 |
|---|---|---|---|
| 全量快照 + 当前帧继续 | 一个快照 | 快 | 常规重连 |
| 从快照帧重放输入 | 快照 + 增量输入 | 中 | 需要精确复现 |
| 重放全部历史输入 | 整场输入日志 | 慢,随时间增长 | 调试、回放 |
| 只发当前状态 | 最小 | 最快 | 无法保证确定性时不可用 |
常规重连用“最近快照 + 从快照帧到当前帧的输入”即可。重放全部历史只适合录像和排障,长时间战斗的输入日志可能比快照大几个数量级,恢复时间不可控。
快照必须有年龄上限。客户端带来的最后确认帧如果比服务器保留的快照还旧,说明缺口已经超出可重放窗口,应明确返回“快照过期”,让客户端走重新入场,而不是假装能从旧状态补齐。
四、断线期间:冻结、托管还是踢出
| 策略 | 玩家体验 | 对战斗的影响 | 适用 |
|---|---|---|---|
| 冻结 | 角色原地不动 | 可预测,但可能被集火 | 短断线、回合制 |
| AI 托管 | 角色继续行动 | 结果可能与玩家意图不符 | 长断线、可接受托管 |
| 踢出并保留实体 | 允许重连 | 占用位置,需超时清理 | 大多数实时战斗 |
| 立即移除 | 无法恢复 | 队伍少人,状态突变 | 断线即判负模式 |
无论选哪种,都必须让其他玩家知道当前状态。悄悄让角色继续行动、又不通知任何人,是最差的选择:队友以为有人操作,对手以为在挂机。
五、失败场景:重复重连创建两个实体
移动网络切换时,客户端可能在旧连接尚未超时就发起新连接。如果服务端每次 Resume 都无条件创建实体,战斗里会出现两个同玩家 ID 的角色:一个由旧连接驱动,一个由新连接驱动。输入重复执行,伤害翻倍,排名和结算全部错乱。
幂等是唯一可靠的解法:以“战斗 ID + 玩家 ID”为键记录活跃会话,重复请求返回同一份恢复结果并标记 Duplicate,不增加实体计数。只有显式 Drop 或超时清理后,下一次重连才创建新会话。
六、Go 实现:快照加帧号的幂等 Resume
ReconnectManager 校验战斗、凭证、帧号窗口,再用活跃会话表保证重复重连不创建第二个实体。返回的状态做深拷贝,避免调用方修改影响管理器内部快照。
// file: ch08/lockstep/reconnect.go
// tier: L1
// verify: go test ./ch08/lockstep/
package lockstep
import (
"errors"
"sync"
)
var (
ErrBattleNotFound = errors.New("reconnect: battle not found")
ErrBadSession = errors.New("reconnect: bad session token")
ErrSnapshotExpired = errors.New("reconnect: snapshot expired")
ErrResumeFrameAhead = errors.New("reconnect: confirmed frame ahead of server")
)
// BattleSnapshot 是服务器定期保存的战斗状态,Frame 表示该状态对应的帧。
type BattleSnapshot struct {
BattleID uint64
Frame Frame
State []byte
Token string
}
type ResumeRequest struct {
BattleID uint64
PlayerID uint64
LastConfirmed Frame
Token string
}
type ResumeResult struct {
Frame Frame
State []byte
Replayed int32
Duplicate bool
}
type sessionKey struct {
battleID uint64
playerID uint64
}
// ReconnectManager 用“快照 + 帧号”恢复玩家,并把重复重连做成幂等操作。
type ReconnectManager struct {
mu sync.Mutex
maxGap int32
battles map[uint64]BattleSnapshot
active map[sessionKey]struct{}
entities int
}
// NewReconnectManager 的 maxGap 是允许从快照重放的最大帧数,超过即要求重新拉取快照。
func NewReconnectManager(maxGap int32) *ReconnectManager {
return &ReconnectManager{
maxGap: maxGap,
battles: make(map[uint64]BattleSnapshot),
active: make(map[sessionKey]struct{}),
}
}
func (m *ReconnectManager) SetSnapshot(s BattleSnapshot) {
m.mu.Lock()
m.battles[s.BattleID] = cloneSnapshot(s)
m.mu.Unlock()
}
// Advance 推进服务器权威帧,并把战斗状态更新为新的快照。
func (m *ReconnectManager) Advance(battleID uint64, frame Frame, state []byte) {
m.mu.Lock()
if b, ok := m.battles[battleID]; ok && frame.After(b.Frame) {
b.Frame = frame
b.State = cloneBytes(state)
m.battles[battleID] = b
}
m.mu.Unlock()
}
func (m *ReconnectManager) Resume(req ResumeRequest) (ResumeResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.battles[req.BattleID]
if !ok {
return ResumeResult{}, ErrBattleNotFound
}
if req.Token == "" || req.Token != b.Token {
return ResumeResult{}, ErrBadSession
}
if req.LastConfirmed.After(b.Frame) {
return ResumeResult{}, ErrResumeFrameAhead
}
if b.Frame.Sub(req.LastConfirmed) > m.maxGap {
return ResumeResult{}, ErrSnapshotExpired
}
key := sessionKey{battleID: req.BattleID, playerID: req.PlayerID}
if _, exists := m.active[key]; exists {
return ResumeResult{Frame: b.Frame, State: cloneBytes(b.State), Duplicate: true}, nil
}
m.active[key] = struct{}{}
m.entities++
return ResumeResult{
Frame: b.Frame,
State: cloneBytes(b.State),
Replayed: b.Frame.Sub(req.LastConfirmed),
}, nil
}
// Drop 标记玩家离线;重复调用不会把实体计数减成负数。
func (m *ReconnectManager) Drop(battleID, playerID uint64) {
m.mu.Lock()
key := sessionKey{battleID: battleID, playerID: playerID}
if _, ok := m.active[key]; ok {
delete(m.active, key)
m.entities--
}
m.mu.Unlock()
}
func (m *ReconnectManager) ActiveEntities() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.entities
}
func cloneSnapshot(s BattleSnapshot) BattleSnapshot {
s.State = cloneBytes(s.State)
return s
}
func cloneBytes(b []byte) []byte {
if b == nil {
return nil
}
out := make([]byte, len(b))
copy(out, b)
return out
}
maxGap 是允许从快照重放的最大帧数。超过它返回 ErrSnapshotExpired,客户端必须重新拉取快照;最后确认帧超前于服务器帧返回 ErrResumeFrameAhead,通常意味着客户端状态被篡改或帧号串场。
七、测试:过期、超前与重复重连
测试覆盖正常恢复、快照过期、帧号超前、凭证错误、战斗不存在,以及重复重连的幂等性和 Drop 后的重新入场。
// file: ch08/lockstep/reconnect_test.go
// tier: L1
// verify: go test ./ch08/lockstep/
package lockstep
import (
"errors"
"testing"
)
func newTestManager(t *testing.T) *ReconnectManager {
t.Helper()
m := NewReconnectManager(30)
m.SetSnapshot(BattleSnapshot{BattleID: 7, Frame: 100, State: []byte("snap-100"), Token: "tok"})
m.Advance(7, 110, []byte("snap-110"))
return m
}
func TestResumeUsesSnapshotAndFrame(t *testing.T) {
m := newTestManager(t)
res, err := m.Resume(ResumeRequest{BattleID: 7, PlayerID: 42, LastConfirmed: 108, Token: "tok"})
if err != nil {
t.Fatal(err)
}
if res.Frame != 110 || res.Replayed != 2 || res.Duplicate {
t.Fatalf("resume = %+v, want frame 110 replayed 2", res)
}
if string(res.State) != "snap-110" {
t.Fatalf("state = %q", res.State)
}
if m.ActiveEntities() != 1 {
t.Fatalf("entities = %d, want 1", m.ActiveEntities())
}
res.State[0] = 'X'
again, err := m.Resume(ResumeRequest{BattleID: 7, PlayerID: 42, LastConfirmed: 108, Token: "tok"})
if err != nil {
t.Fatal(err)
}
if string(again.State) != "snap-110" {
t.Fatalf("manager state leaked: %q", again.State)
}
}
func TestResumeExpiredAndAhead(t *testing.T) {
m := NewReconnectManager(5)
m.SetSnapshot(BattleSnapshot{BattleID: 1, Frame: 100, State: []byte("s"), Token: "t"})
m.Advance(1, 110, []byte("s"))
if _, err := m.Resume(ResumeRequest{BattleID: 1, PlayerID: 1, LastConfirmed: 100, Token: "t"}); !errors.Is(err, ErrSnapshotExpired) {
t.Fatalf("want ErrSnapshotExpired, got %v", err)
}
if _, err := m.Resume(ResumeRequest{BattleID: 1, PlayerID: 1, LastConfirmed: 111, Token: "t"}); !errors.Is(err, ErrResumeFrameAhead) {
t.Fatalf("want ErrResumeFrameAhead, got %v", err)
}
if _, err := m.Resume(ResumeRequest{BattleID: 1, PlayerID: 1, LastConfirmed: 109, Token: "bad"}); !errors.Is(err, ErrBadSession) {
t.Fatalf("want ErrBadSession, got %v", err)
}
if _, err := m.Resume(ResumeRequest{BattleID: 2, PlayerID: 1, LastConfirmed: 109, Token: "t"}); !errors.Is(err, ErrBattleNotFound) {
t.Fatalf("want ErrBattleNotFound, got %v", err)
}
}
func TestResumeDuplicateIsIdempotent(t *testing.T) {
m := newTestManager(t)
req := ResumeRequest{BattleID: 7, PlayerID: 42, LastConfirmed: 109, Token: "tok"}
first, err := m.Resume(req)
if err != nil {
t.Fatal(err)
}
second, err := m.Resume(req)
if err != nil {
t.Fatal(err)
}
if first.Duplicate || !second.Duplicate {
t.Fatalf("duplicate flags = %v, %v", first.Duplicate, second.Duplicate)
}
if m.ActiveEntities() != 1 {
t.Fatalf("duplicate reconnect created %d entities", m.ActiveEntities())
}
m.Drop(7, 42)
m.Drop(7, 42)
if m.ActiveEntities() != 0 {
t.Fatalf("entities after drop = %d", m.ActiveEntities())
}
third, err := m.Resume(req)
if err != nil {
t.Fatal(err)
}
if third.Duplicate {
t.Fatal("resume after drop should be a fresh session")
}
if m.ActiveEntities() != 1 {
t.Fatalf("entities after re-resume = %d", m.ActiveEntities())
}
}
重复重连测试同时断言实体计数保持为 1。只检查返回值不够:返回 Duplicate 但仍创建实体,是最容易漏掉的实现错误。
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch08/lockstep && go vet ./ch08/lockstep/ && go test ./ch08/lockstep/ -count=1
ok gspractice/ch08/lockstep 0.371s
小结
重连恢复的是战斗实体,不是账号会话。请求必须带战斗 ID、玩家 ID、最后确认帧和会话凭证;恢复优先用最近快照加增量输入,超出窗口就明确拒绝。重复重连必须以会话键做幂等,否则两个实体同时驱动同一角色,状态和结算都会错乱。
常见错误与边界
- 重连走完整登录流程,战斗侧创建第二个实体。
- 不带最后确认帧,从错误的起点重放,状态持续漂移。
- 快照没有年龄上限,用早已淘汰的状态补齐当前帧。
- 重复重连不幂等,同一玩家出现两个角色。
- 断线期间角色继续行动却不通知队友,体验和结算都失真。
- 返回内部状态切片给调用方,调用方修改后污染后续恢复。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。