《Cocos 游戏联机实战》8.2 关键数据校验

客户端不可信,所以这一节校验的是服务端下发的快照:字段范围、时序合理性、序号连续性、与本地预测的偏差四类检查,配上接受、平滑修正、丢弃、上报四种处理策略,并明确客户端校验只保可用性与健壮性、不阻止作弊,最后给出可跑的快照校验器、假传输层与 vitest 单测。

8.2 关键数据校验

8.1 立了一条原则:客户端不可信,只发意图、不发结果。这句话有个容易被忽略的推论——既然客户端不可信,那么「校验客户端发来的数据」这件事就不该由客户端自己做。你没法用一个不可信的裁判去监督不可信的选手。

那客户端还剩什么可校验的?答案是服务端下发给它的东西。服务端虽然是权威,但它下发的快照也可能因为程序 bug、协议版本不一致、或者一条被中间人篡改的链路而变得不可用。客户端校验这些数据,是为了在异常发生时优雅地兜住,而不是为了抓作弊者。这一节先把「校验什么」拆成四类,再讲「发现异常怎么办」的四种策略,最后落到一个可跑的快照校验器。

8.2.1 校验什么:四类异常

《方块冲刺》的客户端每收到一个快照,可以做四层检查。这四层从「硬约束」到「软判断」排列:

检查判据命中说明
字段范围位置是否落在赛道边界内硬约束,越界一定是错的
时序合理性相邻两帧位移是否超过理论最大速度超过就是瞬移,可能是 bug 也可能是伪造
序号连续性tick 是否严格递增、间隔是否过大断裂说明丢包或乱序
预测偏差快照与本地预测的差是否超阈值超了要走和解重演,不是错误

前两类是硬约束:越界、超速的数据在物理上不可能,遇到就该拒绝。后两类是软判断:序号断裂可能只是丢包(第 5.2 节讲过),预测偏差更是常态(第 4.2 节的和解就是干这个的),它们触发的是「处理策略」,不是「报错」。一个量级参考:60 Hz 逻辑帧下,移动速度上限 800 定点单位/帧的实体,相邻两帧位移超过 800 就值得怀疑——这个阈值要按你自己的移动速度与帧率标定,参考值,需在你的网络条件下复测。

8.2.2 关键认知:客户端校验不阻止作弊

必须把话说死:客户端的这些校验,防不住作弊者。原因很直白——作弊者可以改客户端,那自然也能把这段校验代码删掉、或者把它改成永远返回「正常」。

所以客户端校验的真实价值是另外两件事:

  1. 可用性:服务端偶尔下发一份坏数据(版本不一致、字段没填),校验器能让客户端丢弃它而不是把角色瞬移到地图外、或者让画面崩掉。
  2. 健壮性:弱网下丢包、乱序、重复是常态,校验器负责把这些「网络噪声」和「真正的异常」区分开,让后面的和解逻辑不至于被噪声带偏。

一句话:它是防御性编程,不是安全机制。真正阻止作弊的是服务端的裁决——服务端永远不会因为客户端说「我在 (20000, 0)」就相信它。客户端校验只是让「服务端说你在 (20000, 0)」这件事在本地不至于变成一个崩溃。

8.2.3 异常处理策略:四种动作

校验出问题后,客户端有四种动作,从轻到重:

动作什么时候用做什么
接受一切正常直接应用快照
平滑修正预测偏差在可接受范围不硬切,插值过渡到服务端位置
丢弃硬约束失败(越界、tick 倒退)不应用这一帧,保留上一帧状态
上报可疑但不致命(瞬移、序号大断裂)丢弃该帧,并向服务端上报一条异常记录

关键是分级:不能一有异常就上报,否则弱网下会刷屏;也不能一律接受,否则坏数据会污染本地状态。把「硬约束失败」和「软判断失败」分开,就自然分出了「丢弃」与「上报」。

8.2.4 快照校验器(L1)

先把传输层抽象出来。所有网络代码都依赖这个 Transport 接口,而不是直接依赖 WebSocket——这样测试才能注入「延迟 + 丢包 + 乱序」的假实现:

// net/session/transport.ts —— 传输层抽象,无 cc 依赖
export interface Transport {
  send(data: Uint8Array): void;
  onMessage(handler: (data: Uint8Array) => void): void;
  close(): void;
}

下面是校验器本体。它同样不依赖 cc,是纯逻辑:

// net/sync/validate.ts —— 纯逻辑,无 cc 依赖
export interface EntityState {
  readonly id: number;
  readonly x: number; // 定点整数,1 单位 = 1/1000 赛道像素
  readonly y: number;
  readonly seq: number;
}

export interface Snapshot {
  readonly tick: number;
  readonly entities: readonly EntityState[];
}

export interface ValidateConfig {
  readonly minX: number;
  readonly maxX: number;
  readonly minY: number;
  readonly maxY: number;
  readonly maxSpeedPerTick: number; // 每逻辑帧最大位移(同定点单位)
  readonly maxDeviation: number;    // 与本地预测的最大可接受偏差
  readonly maxTickGap: number;      // 快照间隔超过这么多帧就视为异常
}

export const Action = {
  ACCEPT: 'accept',
  SMOOTH: 'smooth',
  DROP: 'drop',
  REPORT: 'report',
} as const;
export type Action = (typeof Action)[keyof typeof Action];

const RANK: Record<Action, number> = { accept: 0, smooth: 1, drop: 2, report: 3 };

export interface Verdict {
  readonly action: Action;
  readonly reasons: readonly string[];
}

export class SnapshotValidator {
  private readonly cfg: ValidateConfig;
  private lastTick = -1;
  private readonly lastPos = new Map<number, { x: number; y: number }>();

  constructor(cfg: ValidateConfig) {
    this.cfg = cfg;
  }

  validate(
    snap: Snapshot,
    predicted?: ReadonlyMap<number, { x: number; y: number }>,
  ): Verdict {
    const reasons: string[] = [];
    // 显式 as Action:否则 TS 会把 action 收窄成 'accept',后续比较报错
    let action = Action.ACCEPT as Action;
    const bump = (a: Action): void => {
      if (RANK[a] > RANK[action]) action = a;
    };

    // 1) 字段范围:位置必须落在赛道内
    for (const e of snap.entities) {
      if (e.x < this.cfg.minX || e.x > this.cfg.maxX
        || e.y < this.cfg.minY || e.y > this.cfg.maxY) {
        reasons.push(`实体 ${e.id} 越界 (${e.x}, ${e.y})`);
        bump(Action.DROP);
      }
    }
    if (action === Action.DROP) return { action, reasons };

    // 2) 序号连续性:tick 必须严格递增,间隔不能过大
    if (this.lastTick >= 0 && snap.tick <= this.lastTick) {
      reasons.push(`tick 未递增:${this.lastTick} → ${snap.tick}`);
      bump(Action.DROP);
    }
    const tickDelta = this.lastTick < 0 ? 1 : snap.tick - this.lastTick;
    if (tickDelta > this.cfg.maxTickGap) {
      reasons.push(`快照间隔 ${tickDelta} 帧,超过上限`);
      bump(Action.REPORT);
    }

    // 3) 时序合理性:位移不能超过理论最大速度
    for (const e of snap.entities) {
      const prev = this.lastPos.get(e.id);
      if (!prev) continue;
      const dist = Math.hypot(e.x - prev.x, e.y - prev.y);
      if (dist > this.cfg.maxSpeedPerTick * tickDelta) {
        reasons.push(`实体 ${e.id} 瞬移 ${dist}`);
        bump(Action.REPORT);
      }
    }

    // 4) 与本地预测的偏差:偏得越多,越需要重演
    if (predicted) {
      for (const e of snap.entities) {
        const p = predicted.get(e.id);
        if (!p) continue;
        const dev = Math.hypot(e.x - p.x, e.y - p.y);
        if (dev > this.cfg.maxDeviation) {
          reasons.push(`实体 ${e.id} 预测偏差 ${dev}`);
          bump(Action.SMOOTH);
        }
      }
    }

    // 只有被接受或被平滑的快照才更新基准;被丢弃的帧不污染它
    if (action === Action.ACCEPT || action === Action.SMOOTH) {
      this.lastTick = snap.tick;
      for (const e of snap.entities) this.lastPos.set(e.id, { x: e.x, y: e.y });
    }
    return { action, reasons };
  }
}

注意两个设计选择:RANK 让多个检查命中时取最严重的动作;被丢弃的帧不更新基准,所以连续多帧异常会连续触发上报——这正是我们想要的「持续性异常」信号。

8.2.5 假传输层与异常上报(L1)

上报走 Transport,这样测试里可以断言「上报确实发出了」。先给假传输层:

// net/session/fake-transport.ts —— 测试用假传输层
import type { Transport } from './transport';

export class FakeTransport implements Transport {
  private handler: ((data: Uint8Array) => void) | null = null;
  readonly sent: Uint8Array[] = [];

  send(data: Uint8Array): void {
    this.sent.push(data);
  }

  onMessage(handler: (data: Uint8Array) => void): void {
    this.handler = handler;
  }

  close(): void { /* 假实现无需释放资源 */ }

  // 测试辅助:模拟服务端把字节喂进客户端
  deliver(data: Uint8Array): void {
    this.handler?.(data);
  }
}

上报函数只依赖 Transport 接口,不关心底下是真是假:

// net/sync/report.ts
import type { Transport } from '../session/transport';

export function reportAnomaly(t: Transport, reasons: readonly string[]): void {
  const payload = JSON.stringify({ t: 'anomaly', reasons });
  t.send(new TextEncoder().encode(payload));
}

8.2.6 vitest:越界被拒、瞬移上报

// validate.test.ts
import { describe, expect, it } from 'vitest';
import { Action, SnapshotValidator } from './validate';
import type { Snapshot, ValidateConfig } from './validate';
import { FakeTransport } from '../session/fake-transport';
import { reportAnomaly } from './report';

const CFG: ValidateConfig = {
  minX: 0, maxX: 100_000, minY: -2_000, maxY: 2_000,
  maxSpeedPerTick: 800, maxDeviation: 1_500, maxTickGap: 30,
};

const snap = (tick: number, x: number, y = 0): Snapshot => ({
  tick, entities: [{ id: 1, x, y, seq: tick }],
});

describe('快照校验器', () => {
  it('越界快照被拒(丢弃)', () => {
    const v = new SnapshotValidator(CFG);
    const verdict = v.validate(snap(1, 200_000));
    expect(verdict.action).toBe(Action.DROP);
    expect(verdict.reasons[0]).toMatch(/越界/);
  });

  it('高速瞬移触发上报', () => {
    const v = new SnapshotValidator(CFG);
    v.validate(snap(1, 0));                       // 建立基准
    const verdict = v.validate(snap(2, 80_000));  // 一帧跳 80000 > 800
    expect(verdict.action).toBe(Action.REPORT);
    expect(verdict.reasons.some((r) => r.includes('瞬移'))).toBe(true);
  });

  it('与本地预测偏差过大时平滑修正', () => {
    const v = new SnapshotValidator(CFG);
    v.validate(snap(1, 1_000));
    const predicted = new Map([[1, { x: 5_000, y: 0 }]]);
    expect(v.validate(snap(2, 1_600), predicted).action).toBe(Action.SMOOTH);
  });
});

describe('异常上报', () => {
  it('上报走 Transport 发出', () => {
    const t = new FakeTransport();
    reportAnomaly(t, ['实体 1 瞬移 80000']);
    expect(t.sent).toHaveLength(1);
    expect(JSON.parse(new TextDecoder().decode(t.sent[0])).reasons[0]).toMatch(/瞬移/);
  });
});

8.2.7 接进 Cocos 组件(L2)

校验逻辑是纯 TS,接进引擎只差一层「把动作映射到节点表现」的壳。下面这个组件依赖 cc 的 Component 与 warn,能过 tsc --noEmit:

import { _decorator, Component, warn } from 'cc';
import { Action, SnapshotValidator } from './validate';
import type { Snapshot, ValidateConfig } from './validate';
import { reportAnomaly } from './report';
import type { Transport } from '../session/transport';

const { ccclass } = _decorator;

const RACE_CFG: ValidateConfig = {
  minX: 0, maxX: 100_000, minY: -2_000, maxY: 2_000,
  maxSpeedPerTick: 800, maxDeviation: 1_500, maxTickGap: 30,
};

@ccclass('SnapshotGuard')
export class SnapshotGuard extends Component {
  private readonly validator = new SnapshotValidator(RACE_CFG);
  private transport: Transport | null = null;

  bind(t: Transport): void { this.transport = t; }

  onSnapshot(snap: Snapshot, predicted: ReadonlyMap<number, { x: number; y: number }>): void {
    const verdict = this.validator.validate(snap, predicted);
    switch (verdict.action) {
      case Action.ACCEPT: this.applyImmediate(snap); break;
      case Action.SMOOTH: this.applySmoothed(snap); break;
      case Action.REPORT:
        warn('可疑快照,已丢弃并上报', verdict.reasons);
        if (this.transport) reportAnomaly(this.transport, verdict.reasons);
        break;
      default: warn('丢弃非法快照', verdict.reasons);
    }
  }

  private applyImmediate(_snap: Snapshot): void {}
  private applySmoothed(_snap: Snapshot): void {}
}

applyImmediate 是第 4.2 节和解后的直接应用,applySmoothed 是第 4.3 节的插值过渡。这一节只负责「把快照分成四类」,怎么表现交给那两节。

8.2.8 工程决策:把校验做成分级的、可关的

三条落地建议:

  1. 校验要分级,别一异常就上报。硬约束(越界、tick 倒退)直接丢弃;软判断(瞬移、大断裂)才上报。否则弱网下会向服务端刷大量无意义的异常。
  2. 上报要限流。连续异常时,客户端应该按时间窗合并上报,而不是每帧发一条——第 3.3 节的带宽预算在这里同样适用。
  3. 校验要有「重基准」策略。如果连续 N 帧都被判为瞬移,很可能是「复活点重定位」这类合法的大位移。生产里要允许在连续异常后重建基准,否则会把正常玩法误判成异常。这个阈值同样参考值,需在你的网络条件下复测。

回到主线:客户端的校验只解决「数据能不能用」,解决不了「数据可不可信」。下一节 8.3 进入通信层——客户端侧的加密配合到底能防住什么、防不住什么,以及为什么「客户端持有密钥」永远不能当成安全边界。

阅读导航:上一节:8.1 客户端权威边界 · 下一节:8.3 通信加密与防篡改 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画