
我们做了一版"对局时长反作弊":客户端只负责老实记录,判定权全部交给服务端。这篇不讲代码有多难,讲一个人人都能听懂的核心问题------两个时钟口径不一样,怎么比?
先说结论:问题的本质是"没人能证明你玩了多久"
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 继续,累计值不回退
}
有几个真正容易踩的坑,比函数本身重要得多:
- 存档逻辑必须适配暂停态 :暂停中触发存档,
BeforeSave要Reset()(保持冻结)而不是Restart()。否则一次自动存档就把暂停中的秒表重新跑起来了------这是实现时最容易翻车的点。 isPaused不进存档 :进程被杀后重启必然在前台,初始化时重新Start()即可,存它反而会带来"重启后永远暂停"的诡异 bug。- 挂钩位置要常驻 :挂在全局常驻的
Bootstrap上处理OnApplicationPause,OnApplicationFocus(false)作为兜底。系统弹窗、通知栏会反复触发失焦,所以同一个isPaused守卫兼做防抖。 - 顺序固定 :暂停时 先停表 → 再上报 → 再落盘。顺序错了,落盘里就会混进后台段。
- 暂停时落盘是新增行为:原来唯一的落盘触发是"切换地点"。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, 客户端增量 − 服务端墙钟增量)。它不需要两端对时、容差不累积,天然免疫离线续玩和切后台,并且能精确定位到"哪一段凭空多出了时间"。
至于"真的挂在线上刷时长",那是物理上界,不是算法能解决的------该承认的边界,写在方案里比藏在代码里强。