一、日志记录的是输入,不是结果
“战斗日志”这个词容易被理解成“把每次伤害打印出来”。如果日志里只有“玩家 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 拖慢。
- 压缩后再分段,单段损坏导致整场无法恢复。
- 只记录关键帧快照,却声称可以逐事件复盘。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。