《游戏服务端编程实践》8.2.3 战斗日志与回放

厘清战斗日志该记录输入还是结果,比较事件重放与快照增量的适用边界,拆解帧号、随机种子、规则快照与事件排序对确定性的影响,并给出可用同一种子重放出相同状态的 Recorder 与 Replay 实现,同时讨论日志体积控制、分段压缩以及回放与线上配置隔离的取舍。

一、日志记录的是输入,不是结果

“战斗日志”这个词容易被理解成“把每次伤害打印出来”。如果日志里只有“玩家 A 对 B 造成 123 点伤害”,它能用于客服查询,却不能用于回放:你不知道这个 123 是怎么来的,也无法判断换一个随机种子会不会得到不同结果。

可回放的日志必须记录输入:谁在第几帧提交了什么指令、当时的世界初始状态是什么、随机种子是多少、规则配置是哪一版。伤害、暴击、死亡都是这些输入经过确定性计算后的输出。只要输入和规则一致,输出就应该一致;如果回放结果不一致,说明计算过程中还有未记录的输入。

这也决定了日志的边界。玩家在客户端看到的动画、声效、飘字不需要进入战斗日志;服务器内部的临时缓存、对象地址、goroutine ID 也不应该进入。日志要足够复现,但不能把整个进程内存倒进去。

二、字段设计:帧号、指令、种子、关键状态变更

一条可回放的事件至少需要帧号、序号、事件类型、发起者、目标和参数。帧号决定跨帧顺序,序号决定同帧顺序;只有帧号没有序号,同一帧内的多条事件在回放时可能被重新排列。

字段是否必需说明
version是日志格式版本,升级时拒绝旧版本而不是猜测
seed是本场战斗的随机种子,所有随机判定由它派生
rules是暴击率、倍率等规则快照,不读取线上当前配置
units是战斗开始时的单位状态,作为重放起点
events是按帧号与序号排序的输入事件
frame是逻辑帧,决定事件发生时间
seq是录制时的到达序号,决定同帧顺序
actor / target是事件参与者,必须能在初始单位表中找到
amount视类型基础数值,不记录最终伤害

关键状态变更要不要记?如果每次状态变更都记全量快照,日志会迅速膨胀;如果完全不记,回放只能依赖计算路径完全一致。折中做法是记录“检查点”:每隔若干帧或每个战斗阶段结束时记一份状态摘要,用于快速定位回放偏差出现的区间,而不是用它替代事件重放。

三、回放的两条路线:事件重放与快照增量

回放有两种基本实现,适用场景不同。事件重放保存输入,重新跑一遍逻辑;快照增量保存状态,按增量恢复。两者可以组合:以事件重放为主,定期插入快照作为校验点。

维度事件重放快照 + 增量
存储内容输入事件、种子、初始状态周期性全量状态、状态差分
日志体积小,随指令数增长大,随状态大小与快照频率增长
回放速度需要重新计算,较慢可直接跳到某个快照,较快
确定性要求高,任何未记录输入都会导致偏差较低,状态直接恢复
适合场景战斗复盘、数值验证、反作弊断线重连、观战追帧、长局回放

战斗复盘通常选事件重放,因为体积小、能验证“输入是否导致该结果”;断线重连选快照增量,因为客户端需要的是当前状态,不是重新打一遍。把两者混用时要明确快照的语义:快照之后的事件必须从快照帧继续,不能重复应用快照之前的事件。

四、确定性从哪里来

事件重放能否成功,取决于计算过程是否确定。常见的破坏因素有四类:遍历顺序、随机数、配置版本、浮点数。

遍历顺序包括 map 遍历、goroutine 调度、数据库返回顺序。战斗逻辑必须串行执行,所有实体按 ID 或固定顺序处理;同一帧的事件按帧号和序号排序。随机数必须由日志中的种子派生,且每条事件消耗随机数的次数固定,不能因为“目标已死亡”就跳过抽取,否则后续随机序列会整体错位。

配置版本必须随日志落盘。今天回放三个月前的战斗,如果读取的是今天的技能表,伤害、冷却、Buff 层数都可能已经改变。日志里存规则快照或配置版本号,回放时使用快照或加载对应版本,不能默默使用线上当前值。

浮点数要谨慎。不同 CPU、不同编译器优化、不同语言的浮点运算结果可能不完全一致;如果战斗判定依赖浮点比较,回放可能在边界上分叉。金额、血量、伤害尽量用整数或定点数,必须用浮点时固定运算顺序和精度。

五、日志体积:格式、压缩、分段

一场十分钟的副本战斗,事件数量可能在几千到几万条。格式选择直接影响存储成本和排查效率。

格式优点缺点适用
JSON可读、易调试、跨语言体积大、解析慢开发期、客服单场查询
二进制定长体积小、解析快扩展字段麻烦、可读性差线上全量录制
变长编码体积与扩展性平衡需要严格的 schema 管理长期存档、跨版本
压缩后二进制体积最小随机读取困难、需要解压冷存储、批量分析

压缩通常放在分段之后,而不是先压缩再切分。按战斗或按时间分段,每段独立可解压、可校验;单段建议有大小上限,避免一个异常长局生成几个 GB 的文件。分段元数据要记录起始帧、结束帧、事件数量、校验和,方便定位缺失段。

日志写入不能阻塞战斗线程。常见做法是逻辑线程把事件写入内存环形缓冲,后台 goroutine 批量落盘;缓冲满时要么丢弃低优先级事件并计数,要么降级采样,绝不能同步等待磁盘。丢弃必须可观测,否则回放缺段时无人知道是丢包还是逻辑错误。

六、失败场景:日志只记结果

某项目为了“节省体积”,战斗日志只记录最终伤害和死亡结果,不记录技能释放与随机种子。上线后玩家投诉“同样的装备,伤害忽高忽低”,运营拿日志查不到原因:日志里只有结果,没有输入,无法判断是暴击、Buff 层数还是目标防御变化。

更麻烦的是回放。客服尝试用日志复现战斗,发现同一段日志重放出来的血量与记录不一致,因为日志缺少初始状态和随机种子。开发只能加更多结果字段,日志越来越大,问题却越来越难查。

修复方向是把日志从“结果报表”改成“输入录像”:记录初始单位状态、随机种子、规则快照和按帧排序的事件;结果字段只作为校验和或展示摘要,不作为回放依据。体积会上升,但可以通过二进制编码、压缩和分段控制;缺少输入的日志再小也没有诊断价值。

七、Go 实现

下面的 Recorder 记录初始单位、种子、规则和事件,Finish 冻结并按帧号、序号排序;Replay 用录像中的种子与规则重新执行,返回最终状态。每条事件固定消耗一次随机数,死亡跳过也不改变后续序列。

// file: ch08/skill/replay.go
// tier: L1
// verify: go test ./ch08/skill/

package skill

import (
	"errors"
	"fmt"
	"math/rand"
	"sort"
)

const replayVersion = 1

var (
	ErrReplayVersion = errors.New("unsupported replay version")
	ErrReplayEvent   = errors.New("invalid replay event")
	ErrRecorderDone  = errors.New("recorder already finished")
)

type EventType string

const (
	EventAttack EventType = "attack"
	EventHeal   EventType = "heal"
)

type Unit struct {
	ID     uint64 `json:"id"`
	HP     int    `json:"hp"`
	MaxHP  int    `json:"max_hp"`
	Attack int    `json:"attack"`
	Dead   bool   `json:"dead"`
}

// Event 只记录输入:谁在什么帧对谁做了什么,不记录伤害结果。
type Event struct {
	Frame  uint64    `json:"frame"`
	Seq    uint64    `json:"seq"`
	Type   EventType `json:"type"`
	Actor  uint64    `json:"actor"`
	Target uint64    `json:"target"`
	Amount int       `json:"amount"`
}

// Rules 随录像一起落盘,回放不读取线上当前配置。
type Rules struct {
	CritPercent    int `json:"crit_percent"`
	CritMultiplier int `json:"crit_multiplier"`
}

type Recording struct {
	Version int     `json:"version"`
	Seed    uint64  `json:"seed"`
	Rules   Rules   `json:"rules"`
	Units   []Unit  `json:"units"`
	Events  []Event `json:"events"`
}

type BattleState struct {
	Units  []Unit
	Frame  uint64
	Events int
}

type Recorder struct {
	seed   uint64
	rules  Rules
	units  []Unit
	events []Event
	seq    uint64
	closed bool
}

func NewRecorder(seed uint64, rules Rules, units []Unit) *Recorder {
	if rules.CritMultiplier <= 0 {
		rules.CritMultiplier = 2
	}
	if rules.CritPercent < 0 {
		rules.CritPercent = 0
	}
	if rules.CritPercent > 100 {
		rules.CritPercent = 100
	}
	return &Recorder{seed: seed, rules: rules, units: append([]Unit(nil), units...)}
}

func (r *Recorder) Record(ev Event) error {
	if r.closed {
		return ErrRecorderDone
	}
	if ev.Type != EventAttack && ev.Type != EventHeal {
		return fmt.Errorf("%w: unknown type %q", ErrReplayEvent, ev.Type)
	}
	if ev.Actor == 0 || ev.Target == 0 || ev.Amount <= 0 {
		return fmt.Errorf("%w: actor/target/amount", ErrReplayEvent)
	}
	r.seq++
	ev.Seq = r.seq
	r.events = append(r.events, ev)
	return nil
}

// Finish 冻结录像,并按帧号、到达序号排序后返回。
func (r *Recorder) Finish() Recording {
	r.closed = true
	events := append([]Event(nil), r.events...)
	sort.Slice(events, func(i, j int) bool {
		if events[i].Frame != events[j].Frame {
			return events[i].Frame < events[j].Frame
		}
		return events[i].Seq < events[j].Seq
	})
	return Recording{
		Version: replayVersion,
		Seed:    r.seed,
		Rules:   r.rules,
		Units:   append([]Unit(nil), r.units...),
		Events:  events,
	}
}

// Replay 用录像中的种子和规则重新执行输入,得到与录制时一致的状态。
func Replay(rec Recording) (*BattleState, error) {
	if rec.Version != replayVersion {
		return nil, fmt.Errorf("%w: %d", ErrReplayVersion, rec.Version)
	}
	units := append([]Unit(nil), rec.Units...)
	index := make(map[uint64]int, len(units))
	for i := range units {
		if units[i].ID == 0 {
			return nil, fmt.Errorf("%w: zero unit id", ErrReplayEvent)
		}
		if _, ok := index[units[i].ID]; ok {
			return nil, fmt.Errorf("%w: duplicate unit %d", ErrReplayEvent, units[i].ID)
		}
		index[units[i].ID] = i
	}
	events := append([]Event(nil), rec.Events...)
	sort.Slice(events, func(i, j int) bool {
		if events[i].Frame != events[j].Frame {
			return events[i].Frame < events[j].Frame
		}
		return events[i].Seq < events[j].Seq
	})
	rng := rand.New(rand.NewSource(int64(rec.Seed)))
	state := &BattleState{Units: units}
	for _, ev := range events {
		// 每条事件固定消耗一次随机数,死亡跳过也不改变后续序列。
		roll := rng.Intn(100)
		ai, ok := index[ev.Actor]
		if !ok {
			return nil, fmt.Errorf("%w: actor %d", ErrReplayEvent, ev.Actor)
		}
		ti, ok := index[ev.Target]
		if !ok {
			return nil, fmt.Errorf("%w: target %d", ErrReplayEvent, ev.Target)
		}
		state.Frame = ev.Frame
		if state.Units[ai].Dead {
			continue
		}
		switch ev.Type {
		case EventAttack:
			dmg := ev.Amount
			if roll < rec.Rules.CritPercent {
				dmg *= rec.Rules.CritMultiplier
			}
			if dmg < 0 {
				dmg = 0
			}
			state.Units[ti].HP -= dmg
			if state.Units[ti].HP <= 0 {
				state.Units[ti].HP = 0
				state.Units[ti].Dead = true
			}
		case EventHeal:
			if state.Units[ti].Dead {
				continue
			}
			state.Units[ti].HP += ev.Amount
			if state.Units[ti].HP > state.Units[ti].MaxHP {
				state.Units[ti].HP = state.Units[ti].MaxHP
			}
		}
		state.Events++
	}
	return state, nil
}

实现上有意把规则放进 Recording,而不是让 Replay 读取全局配置。这样一份三个月前的录像,无论线上配置改了多少次,都能按当时规则重放。Finish 和 Replay 都对事件做一次排序,防止录制端或传输端打乱顺序。

八、测试与运行

测试覆盖同一录像重放两次结果一致、同种子同输入得到相同状态、回放使用录像内规则而不是线上配置、乱序事件被排序、非法版本与非法参与者被拒绝、死亡单位的后续指令被稳定跳过。

cd .local/checks/gs-practice-lab && gofmt -l ch08/skill && go vet ./ch08/skill/ && go test ./ch08/skill/ -count=1
ok  	gspractice/ch08/skill	0.293s

这些测试验证的是回放框架的确定性,不验证具体战斗平衡。真实项目还需要把技能表、Buff 表、地图碰撞等输入纳入规则快照,否则回放仍可能因为配置变化而分叉。

小结

战斗日志的价值在于“可复现”,而不是“字段多”。记录输入、种子、初始状态和规则快照,按帧号与序号排序,固定随机数消耗,使用整数或定点数,才能让事件重放稳定。快照增量适合快速恢复状态,事件重放适合复盘与验证;两者可以组合,但不能互相替代。

常见错误与边界

  • 日志只记伤害结果,不记技能、目标和随机种子,无法回放。
  • 随机数不落日志,回放时重新抽取,结果自然不同。
  • 回放读取线上当前配置,旧录像按新数值重演。
  • 事件没有序号,同一帧内顺序在回放时被打乱。
  • 用 map 遍历单位结算,回放结果依赖运行时顺序。
  • 日志同步写磁盘,战斗线程被 I/O 拖慢。
  • 压缩后再分段,单段损坏导致整场无法恢复。
  • 只记录关键帧快照,却声称可以逐事件复盘。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist