场景:结果还在,过程没了
《潮汐方舟》一场战斗持续 8–12 分钟,服务端以 10Hz 下发快照,每条 350B。按十二分钟算,单场就是 7,200 条快照、约 2.5MB;按八分钟算也有 4,800 条、约 1.7MB。这些快照在服务端是权威状态,却不会原样留在客户端。
玩家申诉「最后一击明明是我打的」,客服能看到的往往只有胜负和最终积分。没有过程,就没有复盘;没有最小数据集,就只能把整段快照搬来搬去。
战报要解决的正是这个缺口:用一份远小于快照流的记录,回答「谁在什么时候做了什么、结果为什么是这样」。它不是录像,也不是反外挂证据,而是一份可检索、可对齐、可校验的摘要。
战报不是录像:三层各管一件事
| 层 | 内容 | 作用 | 体积特征 |
|---|---|---|---|
| 结果层 | 战斗 id、赛季、模式、时长、胜负、快照序号 | 定位与检索 | 固定几十字节 |
| 关键事件层 | 击杀、死亡、技能、目标、掉线、重连 | 还原关键节点 | 受配额约束 |
| 校验层 | 全量事件摘要、分桶直方图 | 判断是否同一场、覆盖是否完整 | 与事件数弱相关 |
三层要分开设计,因为它们的读者不同。结果层给客服和榜单;关键事件层给复盘工具和玩家;校验层给数据管道做去重与对账。
常见错误是把三层揉成一份「大 JSON」:结果字段和事件数组平铺,摘要可有可无。这样既无法按战斗 id 快速检索,也无法在事件被裁剪后判断原始记录是否完整。
事件从哪里来:快照、输入与本地观测
服务端快照是 10Hz、350B;客户端上行输入是 20Hz、每条 48B。两者频率不同,不能直接混在一个序列里。战报里的事件序号应锚定服务端快照序号,客户端本地观测只作为补充,并带上产生它的快照区间。
这样做的好处是:事件顺序有唯一的权威参照。客户端可以补「我看到技能命中了」,但不能自己发明一个与服务端无关的序号;否则两份战报放在一起时,无法判断谁先谁后。
事件也不等于快照。快照是状态,事件是状态变化的解释。把每条快照都转成事件,等于没有压缩;正确做法是先定义少数几类对复盘有意义的变化,再从快照流或战斗逻辑里提取。
| 来源 | 频率 | 单条体积 | 在战报里的角色 |
|---|---|---|---|
| 服务端快照 | 10Hz | 350B | 权威状态与事件序号锚点 |
| 客户端输入 | 20Hz | 48B | 解释操作意图,辅助回放 |
| 客户端本地上报 | 事件触发 | 数十字节 | 补充服务端看不到的观测 |
保留规则:配额、分层与均匀采样
战报的预算必须显式。模型里有三个参数:总事件上限 maxEvents、单类上限 maxPerKind、分桶窗口 bucketMs。它们不是案例事实,而是每次发布可以调整的实验设定。
| 优先级 | 事件类型 | 理由 |
|---|---|---|
| 1 | objective | 目标决定比赛走向 |
| 2 | death | 死亡与复活影响资源交换 |
| 3 | disconnect / resync | 网络异常需要单独解释 |
| 4 | kill | 战斗细节,数量可能很大 |
| 5 | skill | 信息密度低,只做抽样 |
只按「重要性」取前 N 条会出问题:一场团战可能在十秒内产生大量击杀,把配额吃光,后半场只剩空白。模型先保留首尾锚点,再按类型分层,每类内部用均匀采样,保证整场战斗都有代表点。
均匀采样不是随机采样。随机采样每次结果不同,难以复现;按序号等距取点则确定、可测试,也更容易向玩家解释「为什么这条战报里有这一条」。单类配额还防止某一种事件淹没其他类型,总量配额则保证上传体积有上限。
校验层:摘要覆盖全量事件
裁剪之后,战报里的 events 只是子集。如果摘要也只覆盖子集,两份战报就无法判断「是不是同一场、只是保留策略不同」。模型的做法是:摘要对入参的全量事件计算,分桶统计也对全量事件计算,只有事件列表是子集。
摘要用 32 位 FNV-1a,输出 8 位十六进制。它适合做一致性比对和去重,不适合做防篡改:客户端可以重算摘要,服务端不能拿它当反作弊证据。真正的权威记录仍在服务端。
分桶直方图解决了另一个问题:即使事件被裁掉,读者仍能看到「第几分钟打得多、哪一段没有目标事件」。桶只存计数,不存明细,体积随战斗时长线性增长,而不随事件数增长。
// file: src/net/battle-report.ts
// tier: L1
// verify: node --test test/net/battle-report.test.ts
/**
* 战报最小数据集模型(mga-net-lab)。
* 事件配额、分桶窗口与体积估算均为实验设定,不是案例事实。
*/
export type EventKind =
| "kill"
| "death"
| "skill"
| "objective"
| "disconnect"
| "resync";
export type BattleResult = "win" | "lose" | "draw";
export interface BattleMeta {
readonly battleId: string;
readonly seasonId: string;
readonly mode: string;
readonly startedAt: number;
readonly durationMs: number;
readonly result: BattleResult;
readonly snapshotSeq: number;
}
export interface BattleEvent {
readonly seq: number;
readonly atMs: number;
readonly kind: EventKind;
readonly actor: string;
readonly target?: string;
readonly detail?: string;
}
export interface ReportLimits {
readonly maxEvents: number;
readonly maxPerKind: number;
readonly bucketMs: number;
}
export interface ReportBucket {
readonly startMs: number;
readonly endMs: number;
readonly events: number;
readonly kills: number;
readonly objectives: number;
}
export interface BattleReport {
readonly meta: BattleMeta;
readonly events: readonly BattleEvent[];
readonly dropped: number;
readonly digest: string;
readonly buckets: readonly ReportBucket[];
}
export const DEFAULT_LIMITS: ReportLimits = {
maxEvents: 64,
maxPerKind: 12,
bucketMs: 30_000,
};
const BASE_BYTES = 180;
const EVENT_BYTES = 42;
const BUCKET_BYTES = 18;
const KIND_ORDER: readonly EventKind[] = [
"objective",
"death",
"disconnect",
"resync",
"kill",
"skill",
];
function assertInteger(value: number, label: string, min: number): void {
if (!Number.isInteger(value) || value < min) {
throw new RangeError(`${label} 必须是不小于 ${min} 的整数,收到 ${value}`);
}
}
function assertLimits(limits: ReportLimits): void {
assertInteger(limits.maxEvents, "maxEvents", 1);
assertInteger(limits.maxPerKind, "maxPerKind", 1);
assertInteger(limits.bucketMs, "bucketMs", 1);
}
function validateMeta(meta: BattleMeta): void {
if (meta.battleId.trim() === "") {
throw new Error("battleId 不能为空");
}
assertInteger(meta.startedAt, "startedAt", 0);
assertInteger(meta.durationMs, "durationMs", 1);
assertInteger(meta.snapshotSeq, "snapshotSeq", 0);
}
function validateEvents(meta: BattleMeta, events: readonly BattleEvent[]): void {
let previousSeq = -1;
let previousAt = -1;
for (const event of events) {
assertInteger(event.seq, "event.seq", 0);
assertInteger(event.atMs, "event.atMs", 0);
if (event.seq <= previousSeq) {
throw new Error(`事件 seq 必须严格递增:${previousSeq} → ${event.seq}`);
}
if (event.atMs < previousAt) {
throw new Error(`事件时间不能倒退:${previousAt}ms → ${event.atMs}ms`);
}
if (event.atMs > meta.durationMs) {
throw new Error(
`事件时间 ${event.atMs}ms 超出战斗时长 ${meta.durationMs}ms`,
);
}
if (event.actor.trim() === "") {
throw new Error(`事件 ${event.seq} 缺少 actor`);
}
previousSeq = event.seq;
previousAt = event.atMs;
}
}
function fnv1a(input: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
export function digestEvents(events: readonly BattleEvent[]): string {
const canonical = events
.map((event) =>
[
event.seq,
event.atMs,
event.kind,
event.actor,
event.target ?? "",
event.detail ?? "",
].join("\u0001"),
)
.join("\u0000");
return fnv1a(canonical);
}
function pickEvenly<T>(items: readonly T[], limit: number): T[] {
if (limit <= 0 || items.length === 0) {
return [];
}
if (items.length <= limit) {
return [...items];
}
if (limit === 1) {
return [items[0]];
}
const picked: T[] = [];
for (let index = 0; index < limit; index += 1) {
const position = Math.round((index * (items.length - 1)) / (limit - 1));
const item = items[position];
if (!picked.includes(item)) {
picked.push(item);
}
}
return picked;
}
export function selectEvents(
events: readonly BattleEvent[],
limits: ReportLimits = DEFAULT_LIMITS,
): readonly BattleEvent[] {
assertLimits(limits);
const selected = new Set<BattleEvent>();
if (events.length > 0) {
selected.add(events[0]);
selected.add(events[events.length - 1]);
}
for (const kind of KIND_ORDER) {
if (selected.size >= limits.maxEvents) {
break;
}
const pool = events.filter((event) => event.kind === kind);
for (const event of pickEvenly(pool, limits.maxPerKind)) {
if (selected.size >= limits.maxEvents) {
break;
}
selected.add(event);
}
}
return [...selected].sort((left, right) => left.seq - right.seq);
}
function buildBuckets(
meta: BattleMeta,
events: readonly BattleEvent[],
bucketMs: number,
): readonly ReportBucket[] {
const count = Math.ceil(meta.durationMs / bucketMs);
const totals = new Array<number>(count).fill(0);
const kills = new Array<number>(count).fill(0);
const objectives = new Array<number>(count).fill(0);
for (const event of events) {
const index = Math.min(count - 1, Math.floor(event.atMs / bucketMs));
totals[index] += 1;
if (event.kind === "kill") {
kills[index] += 1;
}
if (event.kind === "objective") {
objectives[index] += 1;
}
}
return totals.map((total, index) => ({
startMs: index * bucketMs,
endMs: Math.min((index + 1) * bucketMs, meta.durationMs),
events: total,
kills: kills[index],
objectives: objectives[index],
}));
}
export function buildBattleReport(
meta: BattleMeta,
events: readonly BattleEvent[],
limits: ReportLimits = DEFAULT_LIMITS,
): BattleReport {
validateMeta(meta);
validateEvents(meta, events);
assertLimits(limits);
const kept = selectEvents(events, limits);
return {
meta,
events: kept,
dropped: events.length - kept.length,
digest: digestEvents(events),
buckets: buildBuckets(meta, events, limits.bucketMs),
};
}
export function estimateReportBytes(report: BattleReport): number {
let detailBytes = 0;
for (const event of report.events) {
detailBytes += (event.target?.length ?? 0) + (event.detail?.length ?? 0);
}
return (
BASE_BYTES +
report.events.length * EVENT_BYTES +
report.buckets.length * BUCKET_BYTES +
detailBytes
);
}
export function describeReport(report: BattleReport): string {
const total = report.events.length + report.dropped;
return `战报 ${report.meta.battleId}:${report.meta.result},时长 ${report.meta.durationMs}ms,事件 ${report.events.length}/${total},桶 ${report.buckets.length},摘要 ${report.digest},估算 ${estimateReportBytes(report)} 字节`;
}
模型导出六个入口:默认配额、摘要、事件采样、战报构建、体积估算与摘要文案。构建时先校验元数据与事件顺序,再按类型分层采样,最后用全量事件生成摘要和分桶。配套测试覆盖十条路径:默认配额、紧配额下的目标优先、单类均匀采样、分桶计数、seq 乱序、时间越界、时间倒退、非法配额、空事件集与体积估算。
双案例:方舟的事件流与消消的回合摘要
方舟是强联网、8–12 分钟一场、服务端 10Hz 快照的形态,事件密度高,战报必须做配额与分层。它的价值不在还原每一帧,而在把「目标、死亡、掉线、重连」这些节点固定下来,让客服和赛季申诉有共同参照。
消消一局 90 秒,只有 14–22 次提交,每次 600B–1.2KB;战报几乎就是提交日志本身,不需要事件采样,重点是保证提交顺序与幂等键完整。
它的弱网会话占 22%,断线率 2.1%,重连成功率 97.2%;战报要能标出哪次提交发生在重连之后,否则分数对不上时无法解释。
与相邻章节的关系
6.2 会把这份战报真正跑一遍:用 20Hz 输入回放,按 10Hz 检查点比对,判断分歧是表现漂移还是结果漂移。5.3 讨论延迟补偿的客户端边界,本节只记录发生了什么,不判断补偿是否合理。
7.1 会从会话视角统计流量,战报上传是其中一项,但本节不展开带宽预算。两节的共同前提是:记录要小到可以随会话上传,又要大到足以回答申诉。
小结
- 战报是摘要,不是录像;三层分别是结果、关键事件与校验。
- 事件序号锚定服务端快照,客户端观测只能补充,不能另起序列。
- 配额要同时限制总量、单类数量与分桶窗口;首尾锚点保证覆盖。
- 均匀采样优先于随机采样,因为可复现、可解释、可测试。
- 摘要与分桶覆盖全量事件,事件列表只保留子集;摘要用于对账,不用于反作弊。
阅读导航:上一节:5.3 延迟补偿的客户端边界 · 下一节:6.2 战报回放与一致性校验 。
常见错误与边界
- 把战报当录像,保留全部快照。单场 1.7–2.5MB,既占带宽也难检索;应先定结果层与校验层,再决定事件层保留多少。
- 事件序号由客户端自增。没有服务端锚点,两份战报无法对齐;事件序号应绑定快照序号或快照区间。
- 只按重要性取前 N 条。团战爆发会吃光配额,后半场空白;要按类型分层并保留首尾锚点。
- 随机采样。每次结果不同,难以复现和解释;用按序号等距的确定性采样。
- 摘要只覆盖保留事件。裁剪策略一变,摘要就变,无法判断是否同一场;摘要应覆盖全量入参。
- 把摘要当防篡改证据。客户端可以重算,服务端必须保留权威记录;摘要只用于对账与去重。
- 分桶按保留事件统计。会掩盖被裁掉的时段;直方图应基于全量事件。
- 事件时间允许倒退。回放和排序都会出错;构建时应拒绝时间倒退与超出战斗时长的记录。
- 配额参数写死在客户端。不同模式、不同版本可能需要不同预算;配额应随配置下发并可测试。
- 边界:战报不能替代服务端日志。客户端崩溃、换机或裁剪之后,战报可能缺失;客服结论仍要以服务端记录为准。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。