一局游戏的时长,凭什么信客户端说的?

我们做了一版"对局时长反作弊":客户端只负责老实记录,判定权全部交给服务端。这篇不讲代码有多难,讲一个人人都能听懂的核心问题------两个时钟口径不一样,怎么比?

先说结论:问题的本质是"没人能证明你玩了多久"

Roguelike 手游里,单局时长(GameRecord.Duration)是个很忙的数字:

  • 它上排行榜的"时间分"(5 分钟满分,60 分钟归零);
  • 它是掉落效率的分母(每金币上限 = 每分钟上限 × 时长);
  • 每日挑战还会拿它算"速通分"。

问题是,这个时长完全是客户端自己报的------改存档、改内存、改代码,都能把它改大。

那服务端有没有对照量?有,叫 ServerDurationMs:从"开始这局"到"结算请求到达服务端"的墙钟时间。

于是很多人第一反应是:

"那两个数比一比,差太多就判作弊,不就完了?"

不行。而且不是容差没调好,是根本比不了。

为什么不能直接比:两个钟在三种情况下走法不同

同一个玩家,同一局游戏,客户端秒表和服务端墙钟的差异来自三个场景:

场景 客户端时长 服务端墙钟
打到一半杀进程,半小时后重进接着玩 停(只算到上次存档) 照走(那半小时全算进去了)
切后台、进程还活着(iOS 挂起也一样) 照走(秒表读的是系统时间,跟进程有没有被调度无关) 照走
改存档/改内存/改代码虚报 多出伪造量 不变

看出来了吗:第一行就足以让任何容差失效。

偏差 = 玩家离线的那段时间,而离线时长无界。玩家挂机一小时,两个数就差一小时;挂一天就差一天。容差调小 → 正常玩家被误杀;容差调大 → 作弊的人随便就绕过去了。

这就是俗称的"要么误杀、要么形同虚设"。

顺带一句,切后台这件事还有个更尴尬的后果:根本不用作弊工具 。玩家把游戏切到后台放着,秒表照走,时长变长,掉落上限的分母跟着变大------白送收益增幅。这个洞我们这次一并堵了(后面说)。

破局的思路:不比总数,比"增量"

我们最后落地的判定,核心不变量只有一句话:

客户端在一次上报里新增的时长,不可能超过这两次上报之间服务端真实流逝的墙钟时间。

打个通俗的比方:

  • 比总数,就像拿"水表读数"和"小区总管读数"对账------中间停过水、漏过水,永远对不上。
  • 比增量 ,就像每两分钟抄一次水表:这次读数 − 上次读数,绝不可能是"抄表间隔里不可能用掉的水量"。多出来的那部分,就是有人动了手脚。

具体判定公式(超额量):

ini 复制代码
excess = Σ max(0, 客户端增量 − 服务端墙钟增量)
  • 每次客户端上报,带上"本局已经玩了多久"(gameGuid + elapsed);
  • 服务端把每次上报的到达时间也记下来;
  • 结算时把每一段的"客户端说自己涨了多少"和"服务端看着过了多久"逐段相减;
  • 只累加正 的差额(客户端涨得比墙钟快 = 凭空造时间),累加值超过阈值 → 命中 InvalidDuration。

这个口径好在哪:三个性质

1. 不需要两端的时钟同步

我们比的不是"时刻 ",而是"时长增量"。客户端只提供自己单调秒表的相对量,服务端只用自己的时钟。

所以玩家改手机系统时间也没用------那改变的是"绝对时刻",不是"相对增量",判定完全不受影响。

2. 容差不随上报次数累积

每次诚实的上报里,客户端增量 和 服务端墙钟增量 量的是同一段时间,差异只有网络往返抖动,秒级、且正负互相抵消。

所以容差是"整局总预算 ",不是"每次留一点余量"。否则 120 秒报一次、一局报 30 次,能藏的时长会被放大成 30 × 余量。

3. 明确知道自己的物理上界

诚实地说,这个方案有个天花板:

可藏额度 = 容差 + 这一局里"完全没流量"的总时长

你全程在线,就被预算卡死;但一旦"关掉游戏 / 长时间断网",这个窗口本身就变成了可藏额度------服务端没法区分"关着游戏"和"玩着但故意没网"。

想虚报 1 小时?那就得让这局真实挂够 1 小时(1:1 的日历时间 )。所以这个方案的增益是"取证定位 + 抓住典型改档",不是把上界收得更紧------上界本质上由物理规律决定,谁都改不了。

客户端侧:我们改了 8 年的一个老毛病

停表/续表

现状有点意外:游戏逻辑里完全没有 生命周期处理。切后台的时间全部计入 Duration。

实现思路很朴素,暂停时把秒表快照进累计值、然后清零停表;恢复时从 0 继续跑,累计值不回退:

csharp 复制代码
// 暂停:快照进 accumulativeTime 后清零秒表,GetTime() 冻结在暂停点
public void Pause()
{
    if (isPaused) return;
    isPaused = true;
    accumulativeTime = GetTime();   // ProtectedLong 写入
    stopwatch.Reset();              // 清零并停止,避免恢复后重复计时
}

public void Resume()
{
    if (!isPaused) return;
    isPaused = false;
    stopwatch.Start();              // 从 0 继续,累计值不回退
}

有几个真正容易踩的坑,比函数本身重要得多:

  1. 存档逻辑必须适配暂停态 :暂停中触发存档,BeforeSave 要 Reset()(保持冻结)而不是 Restart()。否则一次自动存档就把暂停中的秒表重新跑起来了------这是实现时最容易翻车的点。
  2. isPaused 不进存档 :进程被杀后重启必然在前台,初始化时重新 Start() 即可,存它反而会带来"重启后永远暂停"的诡异 bug。
  3. 挂钩位置要常驻 :挂在全局常驻的 Bootstrap 上处理 OnApplicationPause,OnApplicationFocus(false) 作为兜底。系统弹窗、通知栏会反复触发失焦,所以同一个 isPaused 守卫兼做防抖。
  4. 顺序固定 :暂停时 先停表 → 再上报 → 再落盘。顺序错了,落盘里就会混进后台段。
  5. 暂停时落盘是新增行为:原来唯一的落盘触发是"切换地点"。iOS 后台被系统回收是已知场景,不落盘就丢掉暂停前的进度。

上报点:不新增"每次必到"的强要求

上报只在四个时机发生:

时机 说明
进局 / 恢复局内 锚点,新局 elapsed ≈ 0
每次房间存档 零新增定时器,天然跟随游戏节奏
120 秒周期 兜底 + 缩短"改代码连续放大时钟"这类作弊的检测延迟
暂停 / 恢复 暂停点必须报一次

失败策略是静默:超时、失败都不重试,等下一次周期上报自然自愈;老服务端返回 404 直接吞掉,不影响任何游戏流程;上报不阻塞主线程、不弹 Loading。

正确性验证:重点是"别冤枉人"

反作弊最怕的不是漏,是误杀。我们的验收用例里,一半在测"不该报的要干净":

场景 期望
正常打完一局 excess ≈ 0
局内杀进程,30 分钟后重进再结算 excess ≈ 0(客户端增量 ≪ 墙钟增量)
局内切后台 30 分钟 Duration 不含 后台段(停表生效),excess ≈ 0
手改存档把累计时长 +2 小时 excess ≈ 2h → 命中 InvalidDuration
patch 让 GetTime() 返回 ×10 一个上报周期内就超阈值
老客户端(不发上报) 规则整条跳过,无证据、无分数

最后一条是关键的兼容性设计 :老客户端没有上报,服务端就没有 claim,判定直接 fail-open(宁漏报不误杀)。Redis 拿不到数据也一样跳过。

诚实地说清楚:这个方案抓不到什么

写方案时把边界写明白,比吹能力有用:

  • 真正在线挂够时间的作弊者抓不到:服务端只能证伪"凭空的时长",不能证伪"你真的挂在线上"。这是口径的固有上限。
  • 它不能约束单日收益 :既然上界是 1:1 日历时间,挂了 3 小时就能虚报 3 小时。经济侧仍然需要单局/单日绝对上限兜底。
  • "关掉上报"就等于失效 :老客户端和"被改过、不再上报的客户端"在服务端看来一模一样 (都无 claim),规则静默跳过。所以必须做覆盖率监控(结算里带 claim 的比例,按客户端版本分组)。
  • 它是"时长篡改探测器",不是经济侧兜底:另一条"零报即绕过"的缺口需要独立防线。

还有一笔隐藏的账:停表会把所有阈值搞乱

这一点差点被漏掉------修好停表后,所有玩家的上报时长都会变小(后台段没了),于是所有"按时长算"的东西都跟着变:

受影响的东西 为什么会受影响
排行时间分 时长变短 → 时间分整体抬高
掉落效率阈值(max_coin_per_minute = 450 这类) 分母变小 → 单位时间效率抬高 → 按旧口径定的阈值会误伤正常玩家
TooFast 系列(最短通关时间等) 时长变短 → 更贴近下界,短局玩家有被误杀风险
每日挑战的速通分 时长变短 → 分数抬高
TA 埋点(game_time 等) 口径断点,跨版本对比要剔除

所以校准顺序是死规定:

先发客户端收新口径数据(阈值一律不动)→ 用新口径样本重取阈值 → 配置热更切换

切换之前,新规则只打点、不判定,先看线上分布,确认不误杀再开闸。

落地清单(文件级)

文件 改动
GameTimeManager.cs 新增 Pause()/Resume(),BeforeSave 适配暂停态
RunClockReporter.cs(新增) 120s 周期 + 恢复点 / 存档点上报,失败静默
GameStateInfo.cs 注册上报器,开局首次上报,结算后停报
SaveRunningGameAction.cs 存档后触发一次上报
Bootstrap.cs OnApplicationPause/Focus → 停表 + 上报 + 落盘
API.cs / NetworkApiExtension.cs 新增 POST /Gameplay/RunClock 契约与映射
网络单测 固化 JSON 键名(gameGuid / elapsedTicks)
服务端 共享 Redis 追加列表(TTL 48h)、分段增量判定、InvalidDuration 规则接入

存储上刻意选了最轻的形态:无锁、追加列表、TTL 48 小时、不需要数据迁移,全部是运行时数据,不碰玩家存档结构。

一句话总结

反作弊的难点往往不在"怎么检测"------而在于先把两个口径不同的量,变成两个可以相减的量。

这次我们的做法是:客户端只老实记录 monotonic 的累计时长,服务端拿自己的墙钟做逐段增量比对,excess = Σ max(0, 客户端增量 − 服务端墙钟增量)。它不需要两端对时、容差不累积,天然免疫离线续玩和切后台,并且能精确定位到"哪一段凭空多出了时间"。

至于"真的挂在线上刷时长",那是物理上界,不是算法能解决的------该承认的边界,写在方案里比藏在代码里强。

相关推荐
Behavior9 小时前
国内下载使用 Unity 6000 踩坑点总结
c#·unity3d·游戏开发
哈欠兽1 天前
Laya 1.x AS3 游戏开发 以玩家死亡为例子 了解如何通过事件调用函数
游戏开发
SmalBox1 天前
03-05-架构篇-操作异步体系(OperationSystem)
unity3d·游戏开发
SmalBox2 天前
03-04-架构篇-文件系统架构(IFileSystem)
unity3d·游戏开发
甲维斯10 天前
Opus5.5大考!做一个赛车游戏"秋名山车神"
人工智能·游戏开发
鑫鑫哥adam12 天前
一个 1x1 贴图,是怎么炸掉整个热更包的?
unity3d·游戏开发
Behavior13 天前
Unity 手游动态更换 App 图标 — Android 与 iOS 双端技术方案
c#·unity3d·游戏开发
SmalBox14 天前
03-03-架构篇-Runtime加载系统架构
unity3d·游戏开发
SmalBox14 天前
03-02-架构篇-Editor打包系统架构
unity3d·游戏开发