《游戏服务端架构·核心系统与契约》5.3 战斗日志、回放与审计

客服申诉、作弊复核和经济异常,最终都要落到同一份战斗日志上。本节对比原始输入、裁决事件和结果摘要三层日志的体积与保留期,分析“重演模拟”和“重读记录”两种回放路线的成本、可信度与适用场景,并给出哈希链校验、回放摘要比对和按操作者查询的可运行代码。

一场战斗结束后,日志要回答什么

《裂隙战场》的客服收到申诉:玩家认为自己最后一击先命中,系统却判对方赢。《群岛纪元》的运营发现某个账号每次战报的伤兵数都异常接近上限。《泡泡工坊》的一次活动奖励被批量领取,怀疑有人重放了结算请求。三个问题看起来不同,需要的证据却是同一类:在某个 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 货币、产出与消耗 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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