《移动游戏产品的架构·网络、资源与热更新》6.2 战报回放与一致性校验

同一场战斗,客户端和服务端给出不同结果时,靠什么判断谁对?本节以方舟的 20Hz 输入与 10Hz 快照为线索,拆开回放帧、检查点、分歧分类与确定性状态四件事,给出整数状态机、缺帧校验和表现漂移与结果漂移的判定,并配一套可运行的 TypeScript 回放模型。

场景:同一场战斗,两个结果

方舟一局结算时,客户端显示胜利,服务端记了失败。日志里双方都有「最终分数」,但分数对不上。客服需要回答的不是「谁在说谎」,而是「从哪一帧开始不一致」。

十二分钟的战斗,客户端上行输入 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 赛季结算与客户端展示口径 。

常见错误与边界

  1. 用浮点状态做回放。不同平台舍入不同,分歧无法归因;坐标、生命、分数都用整数。
  2. 缺帧时自动补 idle。看起来能跑通,实际上替玩家做了决定;缺口超过上限应直接拒绝。
  3. 只比较最终分数。只能知道「不一样」,不知道从哪一帧开始;要按检查点比较并返回首个分歧。
  4. 把所有坐标差异当事故。诊断报告会被表现漂移淹没;先分类再决定是否阻塞。
  5. 把所有差异都当表现问题。生命与分数变化会直接改变结算,必须按结果漂移处理。
  6. 检查点 tick 不校验范围。输入日志被截断时会误判为一致;超出回放范围应报错。
  7. 检查点乱序。比较顺序不稳定,首个分歧可能找错;tick 必须严格递增。
  8. 同一 tick 内按到达顺序应用。网络到达顺序不等于输入顺序;必须按 seq 排序。
  9. 回放结果直接用来改结算。回放是诊断工具,不是权威裁决;是否补分由服务端规则决定。
  10. 边界:回放不能修复缺失输入。日志缺帧、客户端崩溃或服务端未留存时,回放只能报告不可判定,不能猜测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查