场景:同一场战斗,两个结果
方舟一局结算时,客户端显示胜利,服务端记了失败。日志里双方都有「最终分数」,但分数对不上。客服需要回答的不是「谁在说谎」,而是「从哪一帧开始不一致」。
十二分钟的战斗,客户端上行输入 20Hz,约 14,400 帧、0.69MB;服务端快照 10Hz,约 7,200 条、2.5MB。把两边全量对比不现实,回放要做的是用输入重跑客户端状态,再在少量检查点上与服务端对齐。
这也是回放与战报的分工:6.1 的战报决定「记录什么」,本节决定「记录能不能复现」。一份不能复现的战报,最多只能当事件列表看。
回放输入:帧、tick 与顺序
输入帧的身份由 seq 与 tick 共同确定。seq 保证同一 tick 内的处理顺序,tick 把帧落到时间轴上。模型要求 seq 严格递增、tick 不回退;同一 tick 可以有多帧,但必须按 seq 顺序应用。
20Hz 意味着相邻输入 tick 相隔 50ms。允许少量缺帧,比如网络抖动导致某一帧没到;但缺口超过上限就必须拒绝回放,而不是默默补 idle。默默补 idle 会让客户端「替玩家做了决定」,分歧原因就被掩盖了。
| 校验项 | 规则 | 失败后果 |
|---|---|---|
| seq | 严格递增 | 同 tick 内顺序不确定 |
| tick | 不回退 | 时间轴倒退 |
| tick 间隔 | 不超过上限 | 缺帧被误当成 idle |
| amount | 整数且在范围内 | 浮点误差累积 |
| 动作参数 | attack/guard 非负、idle 为 0 | 非法输入进入模拟 |
输入日志本身也要控制体积。十二分钟约 0.69MB,放在客户端可接受,但不能每场都全量上传;通常只在申诉、异常结算或抽样诊断时上传,其余场次本地滚动保留。
检查点:10Hz 快照只用来对齐
服务端快照是 10Hz,正好每两个输入 tick 一个。检查点不需要 350B 的完整快照,只需要参与比对的少数字段:位置、生命、分数。模型把检查点压缩成四个整数,比较时逐字段判断。
检查点 tick 必须严格递增,且不能超出回放范围。超出范围通常意味着输入日志被截断,或者服务端下发了不属于这场战斗的快照;两种情况都应该报错,而不是返回「一致」。
分歧分类:表现漂移与结果漂移
分歧要分级。位置不一致通常只是表现漂移:客户端插值、相机或碰撞回退可能造成坐标差异,不影响胜负。生命或分数不一致是结果漂移:它直接改变结算,必须阻塞自动结论,转人工或服务端复核。
| 分歧字段 | 分类 | 处理 |
|---|---|---|
| x / y | 表现漂移 cosmetic | 记录,允许自动结论 |
| hp | 结果漂移 outcome | 阻塞,转复核 |
| score | 结果漂移 outcome | 阻塞,转复核 |
| 多字段同时 | 取最高等级 | 按 outcome 处理 |
分类的意义在于避免两种极端:把所有坐标差异都当事故,诊断报告会被噪声淹没;把所有差异都当表现问题,真正的结算错误会被放过。
模型返回 firstDivergence,而不是只返回布尔值。第一个分歧点通常比分歧总数更有诊断价值:它告诉你从哪一帧开始两边走岔,后面的差异可能都是连锁反应。
确定性:整数状态与固定顺序
回放要可复现,模拟就必须确定。模型只使用整数:坐标、生命、分数都是整数,避免浮点在不同平台上的舍入差异。所有输入按 tick 分组,再按 seq 顺序应用;没有并发,也没有隐式迭代顺序。
边界要显式。坐标限制在固定范围,生命限制在 0 到上限,攻击与治疗的数量必须是非负整数。越界不是靠调用方自觉,而是在输入校验阶段直接拒绝。
这套规则不追求还原真实物理,而追求「同样的输入一定得到同样的状态」。只要客户端和服务端使用同一套规则与同一份输入,分歧就只剩数据问题,而不是实现问题。
// file: src/net/replay.ts
// tier: L1
// verify: node --test test/net/replay.test.ts
/**
* 战报回放与一致性校验模型(mga-net-lab)。
* 20Hz 输入节奏、检查点间隔与缺帧上限均为实验设定,不是案例事实。
*/
export const INPUT_TICK_MS = 50;
export const MAX_GAP_TICKS = 4;
export type Action = "move-x" | "move-y" | "attack" | "guard" | "idle";
export interface InputFrame {
readonly seq: number;
readonly tick: number;
readonly action: Action;
readonly amount: number;
}
export interface SimState {
readonly tick: number;
readonly x: number;
readonly y: number;
readonly hp: number;
readonly score: number;
}
export interface Checkpoint {
readonly tick: number;
readonly x: number;
readonly y: number;
readonly hp: number;
readonly score: number;
}
export interface Divergence {
readonly tick: number;
readonly fields: readonly string[];
readonly severity: "cosmetic" | "outcome";
}
export interface ReplayResult {
readonly finalState: SimState;
readonly checkpoints: number;
readonly matched: number;
readonly divergences: readonly Divergence[];
readonly firstDivergence?: Divergence;
readonly consistent: boolean;
}
const MIN_COORD = -1_000;
const MAX_COORD = 1_000;
const MAX_HP = 1_000;
const MAX_AMOUNT = 100;
function assertInteger(value: number, label: string, min: number): void {
if (!Number.isInteger(value) || value < min) {
throw new RangeError(`${label} 必须是不小于 ${min} 的整数,收到 ${value}`);
}
}
function validateInputs(inputs: readonly InputFrame[]): void {
let previousSeq = -1;
let previousTick = -1;
for (const frame of inputs) {
assertInteger(frame.seq, "frame.seq", 0);
assertInteger(frame.tick, "frame.tick", 0);
if (frame.seq <= previousSeq) {
throw new Error(`输入 seq 必须严格递增:${previousSeq} → ${frame.seq}`);
}
if (frame.tick < previousTick) {
throw new Error(`输入 tick 不能倒退:${previousTick} → ${frame.tick}`);
}
if (previousTick >= 0 && frame.tick - previousTick > MAX_GAP_TICKS) {
throw new Error(
`输入缺帧:tick ${previousTick} → ${frame.tick} 超过上限 ${MAX_GAP_TICKS}`,
);
}
if (!Number.isInteger(frame.amount) || Math.abs(frame.amount) > MAX_AMOUNT) {
throw new RangeError(
`frame.amount 必须是绝对值不超过 ${MAX_AMOUNT} 的整数,收到 ${frame.amount}`,
);
}
if (
(frame.action === "attack" || frame.action === "guard") &&
frame.amount < 0
) {
throw new RangeError(`${frame.action} 的 amount 不能为负:${frame.amount}`);
}
if (frame.action === "idle" && frame.amount !== 0) {
throw new RangeError(`idle 的 amount 必须为 0,收到 ${frame.amount}`);
}
previousSeq = frame.seq;
previousTick = frame.tick;
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function applyFrame(state: SimState, frame: InputFrame): SimState {
switch (frame.action) {
case "move-x":
return {
...state,
x: clamp(state.x + frame.amount, MIN_COORD, MAX_COORD),
};
case "move-y":
return {
...state,
y: clamp(state.y + frame.amount, MIN_COORD, MAX_COORD),
};
case "attack":
return { ...state, score: state.score + frame.amount };
case "guard":
return { ...state, hp: clamp(state.hp + frame.amount, 0, MAX_HP) };
case "idle":
return state;
default:
return state;
}
}
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 digestState(state: SimState): string {
return fnv1a(
[state.tick, state.x, state.y, state.hp, state.score].join("|"),
);
}
interface ReplayRun {
readonly finalState: SimState;
readonly snapshots: ReadonlyMap<number, SimState>;
}
function run(
inputs: readonly InputFrame[],
checkpointTicks: ReadonlySet<number>,
): ReplayRun {
validateInputs(inputs);
const byTick = new Map<number, InputFrame[]>();
for (const frame of inputs) {
const bucket = byTick.get(frame.tick);
if (bucket === undefined) {
byTick.set(frame.tick, [frame]);
} else {
bucket.push(frame);
}
}
const lastTick = inputs.length === 0 ? 0 : inputs[inputs.length - 1].tick;
let state: SimState = { tick: 0, x: 0, y: 0, hp: MAX_HP, score: 0 };
const snapshots = new Map<number, SimState>();
for (let tick = 0; tick <= lastTick; tick += 1) {
const frames = byTick.get(tick);
if (frames !== undefined) {
for (const frame of frames) {
state = applyFrame(state, frame);
}
}
state = { ...state, tick };
if (checkpointTicks.has(tick)) {
snapshots.set(tick, state);
}
}
return { finalState: state, snapshots };
}
export function simulate(inputs: readonly InputFrame[]): SimState {
return run(inputs, new Set<number>()).finalState;
}
function validateCheckpoints(checkpoints: readonly Checkpoint[]): void {
let previousTick = -1;
for (const checkpoint of checkpoints) {
assertInteger(checkpoint.tick, "checkpoint.tick", 0);
if (checkpoint.tick <= previousTick) {
throw new Error(
`检查点 tick 必须严格递增:${previousTick} → ${checkpoint.tick}`,
);
}
for (const value of [
checkpoint.x,
checkpoint.y,
checkpoint.hp,
checkpoint.score,
]) {
if (!Number.isInteger(value)) {
throw new RangeError(`检查点字段必须是整数,收到 ${value}`);
}
}
if (checkpoint.hp < 0 || checkpoint.hp > MAX_HP) {
throw new RangeError(`检查点 hp 超出 0..${MAX_HP}:${checkpoint.hp}`);
}
if (checkpoint.score < 0) {
throw new RangeError(`检查点 score 不能为负:${checkpoint.score}`);
}
previousTick = checkpoint.tick;
}
}
function compare(
state: SimState,
checkpoint: Checkpoint,
): Divergence | null {
const fields: string[] = [];
if (state.hp !== checkpoint.hp) {
fields.push("hp");
}
if (state.score !== checkpoint.score) {
fields.push("score");
}
if (state.x !== checkpoint.x) {
fields.push("x");
}
if (state.y !== checkpoint.y) {
fields.push("y");
}
if (fields.length === 0) {
return null;
}
const outcome =
state.hp !== checkpoint.hp || state.score !== checkpoint.score;
return {
tick: checkpoint.tick,
fields,
severity: outcome ? "outcome" : "cosmetic",
};
}
export function verifyReplay(
inputs: readonly InputFrame[],
checkpoints: readonly Checkpoint[],
): ReplayResult {
validateInputs(inputs);
validateCheckpoints(checkpoints);
const lastTick = inputs.length === 0 ? 0 : inputs[inputs.length - 1].tick;
for (const checkpoint of checkpoints) {
if (checkpoint.tick > lastTick) {
throw new Error(
`检查点 tick ${checkpoint.tick} 超出回放范围 ${lastTick}`,
);
}
}
const ticks = new Set(checkpoints.map((checkpoint) => checkpoint.tick));
const { finalState, snapshots } = run(inputs, ticks);
const divergences: Divergence[] = [];
for (const checkpoint of checkpoints) {
const state = snapshots.get(checkpoint.tick);
if (state === undefined) {
throw new Error(`检查点 tick ${checkpoint.tick} 未被回放覆盖`);
}
const divergence = compare(state, checkpoint);
if (divergence !== null) {
divergences.push(divergence);
}
}
const matched = checkpoints.length - divergences.length;
return {
finalState,
checkpoints: checkpoints.length,
matched,
divergences,
firstDivergence: divergences[0],
consistent: divergences.length === 0,
};
}
export function describeReplay(result: ReplayResult): string {
const first = result.firstDivergence;
const tail =
first === undefined
? ""
: `,首个分歧 tick ${first.tick}(${first.fields.join("+")},${first.severity})`;
return `回放 ${result.checkpoints} 个检查点:一致 ${result.matched},偏离 ${result.divergences.length},${result.consistent ? "通过" : "失败"}${tail}`;
}
模型导出六个入口:输入节奏常量、缺帧上限、状态摘要、模拟、校验与文案。模拟先校验输入,再按 tick 推进;校验在此基础上生成检查点快照并逐字段比较。测试覆盖十条路径:正常模拟与上限截断、摘要稳定性、全部命中、坐标漂移、生命漂移、分数漂移、seq 乱序、缺帧、检查点越界与检查点乱序。
双案例:方舟的帧级回放与消消的提交序列
方舟是强联网、60fps 表现、20Hz 输入、10Hz 快照的形态,帧级回放有意义:它能定位到 100ms 的检查点窗口。它的弱网会话占 14%,断线率 1.4%,重连成功率 98.1%,重连后 31% 需要补状态;这些场景正是回放最常被调用的时候。
消消一局 90 秒,只有 14–22 次提交,每次 600B–1.2KB;它不需要帧级回放,按提交序号重放即可,重点是幂等键与提交顺序。
消消断线率 2.1%,重连成功率 97.2%,重连后仅 8% 需要补状态;回放的主要用途是对账,而不是诊断高频输入。
与相邻章节的关系
6.1 定义战报里记录什么,本节验证这些记录能不能复现;6.3 会把视角从单场战斗拉到整个赛季,讨论结算数据的展示口径。5.3 的延迟补偿决定客户端在什么时候乐观应用输入,本节不重新设计补偿,只检查补偿后的状态是否与服务端一致。如果回放发现结果漂移,处理方式不属于本节:本节只负责定位与分类,是否判负、是否补分、是否回滚,仍由服务端规则决定。
小结
- 回放的最小输入是带 seq 与 tick 的帧序列;seq 定顺序,tick 定时间。
- tick 间隔要有上限,缺帧不能默默补 idle,否则会掩盖真实分歧。
- 检查点用 10Hz 快照的少数字段对齐,tick 必须递增且不超出回放范围。
- 坐标漂移是表现问题,生命与分数漂移是结果问题;后者必须阻塞自动结论。
- 确定性来自整数状态、固定顺序与显式边界,而不是更复杂的物理。
阅读导航:上一节:6.1 战报结构:可复盘的最小数据集 · 下一节:6.3 赛季结算与客户端展示口径 。
常见错误与边界
- 用浮点状态做回放。不同平台舍入不同,分歧无法归因;坐标、生命、分数都用整数。
- 缺帧时自动补 idle。看起来能跑通,实际上替玩家做了决定;缺口超过上限应直接拒绝。
- 只比较最终分数。只能知道「不一样」,不知道从哪一帧开始;要按检查点比较并返回首个分歧。
- 把所有坐标差异当事故。诊断报告会被表现漂移淹没;先分类再决定是否阻塞。
- 把所有差异都当表现问题。生命与分数变化会直接改变结算,必须按结果漂移处理。
- 检查点 tick 不校验范围。输入日志被截断时会误判为一致;超出回放范围应报错。
- 检查点乱序。比较顺序不稳定,首个分歧可能找错;tick 必须严格递增。
- 同一 tick 内按到达顺序应用。网络到达顺序不等于输入顺序;必须按 seq 排序。
- 回放结果直接用来改结算。回放是诊断工具,不是权威裁决;是否补分由服务端规则决定。
- 边界:回放不能修复缺失输入。日志缺帧、客户端崩溃或服务端未留存时,回放只能报告不可判定,不能猜测。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。