一场战斗结束后,日志要回答什么
《裂隙战场》的客服收到申诉:玩家认为自己最后一击先命中,系统却判对方赢。《群岛纪元》的运营发现某个账号每次战报的伤兵数都异常接近上限。《泡泡工坊》的一次活动奖励被批量领取,怀疑有人重放了结算请求。三个问题看起来不同,需要的证据却是同一类:在某个 tick,谁做了什么,系统依据什么做出裁决,之后谁改过结果。
三层日志与保留期
| 层级 | 内容 | 体积(示例值) | 用途 | 保留期(示例策略) |
|---|---|---|---|---|
| 原始输入 | 客户端输入、位置、技能请求 | 每局数 MB 到数十 MB | 作弊复核、极端争议 | 休闲 7 天,竞技 30 天 |
| 裁决事件 | 命中、伤害、状态变化、改判 | 每局数百 KB | 回放、结算依据 | 180 天 |
| 结果摘要 | 胜负、参战者、奖励、版本 | 每局数 KB | 客服查询、报表、对账 | 730 天 |
回放:重演还是重读
| 路线 | 依赖 | 成本 | 可信度 | 适用场景 |
|---|---|---|---|---|
| 重演模拟 | 原始输入 + 确定性模拟 | 高,需要固定版本与随机种子 | 能验证“如果重跑会不会一样” | 强竞技、反作弊、引擎回归 |
| 重读记录 | 裁决事件 + 结果摘要 | 低,只回放已记录的事件 | 只能验证“记录有没有被改” | 客服申诉、战报回放、运营复盘 |
重演模拟听起来更强,但它要求模拟器完全确定:浮点、随机数、并发顺序、配置版本都要固定。任何一项变化都会让重放结果与线上不同,最后还是要靠记录判断。北辰工作室的选择是:竞技品类保留重演能力,但日常申诉与回放走“重读记录”;重演只用于引擎回归和作弊复核。
重读记录的可信度来自哈希链。每条事件带上前一条的哈希,任何删除、插入或修改都会在链上留下断点。它不能证明“当时的模拟正确”,但能证明“这份记录在落库之后没有被悄悄改过”。
审计查询:谁在何时改了什么
《裂隙战场》的一次争议里,运营在赛后把胜方从 A 队改成 B 队,但没有记录原因。客服只能看到最终结果,无法向玩家解释。修复方式不是加更多日志,而是把改判做成一条不可省略的审计事件:actor=ops-17、type=override、payload=old:A,new:B,reason:referee-review。查询接口按 actor 和 type 过滤,就能还原完整操作链。
可运行片段:事件日志、回放校验与审计查询
下面的 internal/battleaudit 把三层事件、哈希链、回放摘要和查询过滤写成可测试的包。它不实现战斗模拟,只保证“记录可校验、回放可复现、查询可追责”。
// file: internal/battleaudit/battleaudit.go
// tier: L1
// verify: go test ./internal/battleaudit/
package battleaudit
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
)
// Level separates raw input, adjudication events and the result summary.
type Level int
const (
RawInput Level = iota
Adjudication
Summary
)
var (
ErrBadEvent = errors.New("battleaudit: invalid event")
ErrChain = errors.New("battleaudit: hash chain broken")
ErrReplay = errors.New("battleaudit: replay digest mismatch")
)
// Event is one append-only record. PrevHash plus Hash make deletion and edits
// detectable without trusting the storage layer.
type Event struct {
Seq uint64
BattleID string
Tick int
Level Level
Actor string
Type string
Payload string
PrevHash string
Hash string
}
type Log struct {
battleID string
events []Event
}
func NewLog(battleID string) (*Log, error) {
if battleID == "" {
return nil, fmt.Errorf("%w: empty battle id", ErrBadEvent)
}
return &Log{battleID: battleID}, nil
}
// Append enforces non-decreasing ticks; equal ticks are allowed because one
// tick can produce several adjudication events.
func (l *Log) Append(level Level, tick int, actor, typ, payload string) (Event, error) {
if level < RawInput || level > Summary || tick < 0 || actor == "" || typ == "" {
return Event{}, fmt.Errorf("%w: level=%v tick=%d", ErrBadEvent, level, tick)
}
prev := ""
if n := len(l.events); n > 0 {
if last := l.events[n-1]; tick < last.Tick {
return Event{}, fmt.Errorf("%w: tick %d after %d", ErrBadEvent, tick, last.Tick)
} else {
prev = last.Hash
}
}
e := Event{Seq: uint64(len(l.events)) + 1, BattleID: l.battleID, Tick: tick,
Level: level, Actor: actor, Type: typ, Payload: payload, PrevHash: prev}
e.Hash = digest(e)
l.events = append(l.events, e)
return e, nil
}
func digest(e Event) string {
h := sha256.New()
fmt.Fprintf(h, "%d\x00%s\x00%d\x00%d\x00%s\x00%s\x00%s\x00%s",
e.Seq, e.BattleID, e.Tick, e.Level, e.Actor, e.Type, e.Payload, e.PrevHash)
return hex.EncodeToString(h.Sum(nil))
}
// Verify walks the chain and reports the first broken link. An audit log that
// silently repairs itself is not evidence.
func (l *Log) Verify() error {
prev := ""
for i, e := range l.events {
if e.Seq != uint64(i+1) || e.PrevHash != prev || digest(e) != e.Hash {
return fmt.Errorf("%w: seq %d", ErrChain, e.Seq)
}
prev = e.Hash
}
return nil
}
// Replay digests adjudication and summary events. It proves the replay source
// did not change, not that the original simulation was deterministic.
type Replay struct {
BattleID string
LastTick int
Events int
Digest string
}
func (l *Log) BuildReplay() (Replay, error) {
if err := l.Verify(); err != nil {
return Replay{}, err
}
h := sha256.New()
r := Replay{BattleID: l.battleID}
last := 0
for _, e := range l.events {
if e.Level == RawInput {
continue
}
if e.Tick < last {
return Replay{}, fmt.Errorf("%w: tick %d after %d", ErrBadEvent, e.Tick, last)
}
last = e.Tick
r.Events++
fmt.Fprintf(h, "%d\x00%s\x00%s\x00%s\x00", e.Tick, e.Actor, e.Type, e.Payload)
}
r.LastTick, r.Digest = last, hex.EncodeToString(h.Sum(nil))
return r, nil
}
func (r Replay) Matches(l *Log) error {
got, err := l.BuildReplay()
if err != nil {
return err
}
if got.Digest != r.Digest {
return fmt.Errorf("%w: have %s want %s", ErrReplay, got.Digest, r.Digest)
}
return nil
}
// Query is the support-facing filter. ToTick zero means "to the end".
type Query struct {
Actor string
Type string
Level *Level
FromTick int
ToTick int
}
func (l *Log) Query(q Query) []Event {
out := make([]Event, 0)
for _, e := range l.events {
if (q.Actor != "" && e.Actor != q.Actor) || (q.Type != "" && e.Type != q.Type) {
continue
}
if (q.Level != nil && e.Level != *q.Level) || e.Tick < q.FromTick || (q.ToTick > 0 && e.Tick > q.ToTick) {
continue
}
out = append(out, e)
}
return out
}
// RetentionDays is the policy table. Competitive titles keep raw input longer.
func RetentionDays(level Level, competitive bool) int {
switch level {
case RawInput:
if competitive {
return 30
}
return 7
case Adjudication:
return 180
case Summary:
return 730
}
return 0
}
实现取舍:
Append只允许 tick 不回退,允许同一 tick 多条事件;回放时按记录顺序读取,不重新排序。- 哈希链覆盖序号、战斗 ID、tick、层级、操作者、类型、载荷和前哈希。改任何字段都会让
Verify失败。 BuildReplay只摘要裁决与结果事件,原始输入不进入回放摘要。这样原始输入的采样或清理不会影响回放校验。Query支持按操作者、类型、层级和 tick 窗口过滤,对应客服申诉与运营复核的常见入口。
对应的表驱动测试覆盖篡改检测、回放摘要、查询过滤和保留期策略:
// file: internal/battleaudit/battleaudit_test.go
// tier: L1
// verify: go test ./internal/battleaudit/
package battleaudit
import (
"errors"
"reflect"
"testing"
)
func seeded(t *testing.T) *Log {
t.Helper()
l, err := NewLog("b-42")
if err != nil {
t.Fatalf("new: %v", err)
}
must := func(level Level, tick int, actor, typ, data string) {
if _, err := l.Append(level, tick, actor, typ, data); err != nil {
t.Fatalf("append: %v", err)
}
}
must(RawInput, 0, "p1", "input", "move:1,2")
must(Adjudication, 0, "server", "hit", "p1->p2:17")
must(Summary, 1, "server", "result", "team1:win")
must(Adjudication, 2, "ops", "override", "p2:disconnect")
return l
}
func TestVerifyDetectsTampering(t *testing.T) {
cases := []struct {
name string
mutate func(*Log)
want error
}{
{"clean chain", func(*Log) {}, nil},
{"payload edited", func(l *Log) { l.events[1].Payload = "p1->p2:99" }, ErrChain},
{"seq edited", func(l *Log) { l.events[2].Seq = 9 }, ErrChain},
{"event deleted", func(l *Log) { l.events = append(l.events[:1], l.events[2:]...) }, ErrChain},
}
for _, c := range cases {
l := seeded(t)
c.mutate(l)
if err := l.Verify(); !errors.Is(err, c.want) {
t.Errorf("%s: err=%v, want %v", c.name, err, c.want)
}
}
}
func TestReplayDigestCoversOnlyAdjudicationAndSummary(t *testing.T) {
ref, err := seeded(t).BuildReplay()
if err != nil {
t.Fatalf("build replay: %v", err)
}
cases := []struct {
name string
mutate func(*Log)
wantSame bool
}{
{"raw input does not change the digest", func(l *Log) {
if _, err := l.Append(RawInput, 2, "p1", "input", "jump"); err != nil {
t.Fatalf("append raw: %v", err)
}
}, true},
{"adjudication changes the digest", func(l *Log) {
if _, err := l.Append(Adjudication, 2, "server", "hit", "p1->p2:18"); err != nil {
t.Fatalf("append adjudication: %v", err)
}
}, false},
}
for _, c := range cases {
l := seeded(t)
c.mutate(l)
got, err := l.BuildReplay()
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if (got.Digest == ref.Digest) != c.wantSame {
t.Errorf("%s: digest=%s ref=%s", c.name, got.Digest, ref.Digest)
}
}
}
func TestQueryFiltersByActorLevelAndTick(t *testing.T) {
adj := Adjudication
cases := []struct {
name string
q Query
want []uint64
}{
{"all events", Query{}, []uint64{1, 2, 3, 4}},
{"actor only", Query{Actor: "server"}, []uint64{2, 3}},
{"type only", Query{Type: "override"}, []uint64{4}},
{"level only", Query{Level: &adj}, []uint64{2, 4}},
{"tick window", Query{FromTick: 1, ToTick: 2}, []uint64{3, 4}},
}
for _, c := range cases {
got := seeded(t).Query(c.q)
seqs := make([]uint64, 0, len(got))
for _, e := range got {
seqs = append(seqs, e.Seq)
}
if !reflect.DeepEqual(seqs, c.want) {
t.Errorf("%s: seqs=%v, want %v", c.name, seqs, c.want)
}
}
}
func TestRetentionDays(t *testing.T) {
cases := []struct {
level Level
competitive bool
want int
}{
{RawInput, false, 7}, {RawInput, true, 30},
{Adjudication, true, 180}, {Summary, true, 730}, {Level(9), true, 0},
}
for _, c := range cases {
if got := RetentionDays(c.level, c.competitive); got != c.want {
t.Errorf("%v competitive=%v: days=%d, want %d", c.level, c.competitive, got, c.want)
}
}
}
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/battleaudit
$ go vet ./internal/battleaudit/...
$ go test -count=1 ./internal/battleaudit/
ok gsarchlab/internal/battleaudit
四条表驱动测试全部通过,gofmt -l 与 go vet 均无输出。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 日志被采样 | 争议局缺少关键 tick | 竞技局全量记录裁决事件;原始输入按比例采样但要标记 |
| 哈希链断裂 | 回放校验失败,无法判断哪条被改 | 保留副本与写入端签名,断裂日志只用于参考 |
| 回放依赖旧版本 | 引擎升级后重演结果不同 | 记录模拟器版本与随机种子,回放时锁定版本 |
| 审计事件缺失 | 客服看不到谁改判 | 改判接口强制写入审计事件,失败则拒绝操作 |
| 保留期一刀切 | 要么存储爆炸,要么证据过期 | 按层级和品类分别设置保留期,定期归档摘要 |
常见错误
第一,只记结果不记裁决过程。结果摘要能回答“谁赢了”,回答不了“最后一击为什么判给对方”。
第二,哈希链只算载荷。不覆盖序号、操作者和前哈希,删除与重排就检测不出来。
第三,改判不写审计事件。没有操作者、原因和旧值,客服无法向玩家解释,也无法追责。
小结
战斗日志按原始输入、裁决事件、结果摘要三层拆分,分别服务于作弊复核、回放与客服查询。回放路线要在“重演模拟”和“重读记录”之间按品类选择;哈希链保证记录未被篡改,审计事件保证改判可追责。至此第 5 章闭环:战斗跑在合适的模型上,结算不重不漏,日志为争议提供证据。下一章进入经济系统。
阅读导航:上一节:5.2 结算契约与防重复发奖 · 下一节:6.1 货币、产出与消耗 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。