跳转到内容

第 33 章 · 反作弊近似

约 6 分钟 难度:4 动手章

这一章解决:没有权威服务器时,哪些异常能挡住,哪些只能记录和降级,哪些必须在玩法承诺上避开。

先看一下

第 31 章允许客户端上报命中,Owner 做合理性检查。这个方案能让合作 PvE 顺滑,但也暴露了边界:如果某个客户端持续上报不可能的命中、超高频技能、离谱移动,GameState Owner 应该怎么处理?

这一章会拿到什么

  • 一条基本判断:反作弊近似不是安全模型
  • 五类轻量检查:冷却、范围、速度、分数突变、状态机合法性
  • 一个异常计数 + 降级处理片段
  • 一份主持人干预接口的边界说明

依赖前面

  • 第 20 章的 requestId 幂等与 GameState 端冷却
  • 第 30 章的 Owner 伪权威
  • 第 31 章的命中合理性检查
  • 第 32 章的优雅降级顺序

真正的反作弊依赖可信服务器:客户端只提交输入,服务器保存状态、计算结果、拒绝伪造。VRChat 世界没有这层服务器。GameState Owner 运行在玩家客户端上,能协调一局游戏,但不能成为可信安全边界。

所以本章讲的是反作弊近似:挡掉明显不可能的请求,记录可疑行为,在异常持续时降级玩法或交给主持人处理。它提高公开房的韧性,不保证竞技公平。

这个降级很重要。技术上做不到的承诺,应该在玩法设计阶段避开。把 PvP 命中改成低频技能、区域控制、合作目标、主持人房间,比在 Udon 里堆一套不可信的复杂校验更稳。


检查挡什么成本
冷却验证高频连发、按钮宏、重复请求
范围 / 角度验证离谱命中、隔墙式报告的一部分
速度上限瞬移、超速移动类请求
分数突变单次请求加过大分数、重复结算
状态机合法性RESULT 阶段继续加分、LOBBY 阶段释放技能

这些检查都放在 Owner 裁决点。它们不证明请求可信,只判断「是否离谱到必须拒绝」。

private bool AcceptSkillRequest(SkillRequest r, VRCPlayerApi who)
{
int slot = GetPlayerSlot(who.playerId);
if (slot < 0) return false;
if (phase != PHASE_INGAME) return Reject(who, REJECT_PHASE);
if (r.requestId <= lastProcessedRequestId[slot]) return false;
if (TooSoon(slot, r.skillId)) return Reject(who, REJECT_COOLDOWN);
if (!IsTargetInRange(r.targetId, r.origin, r.maxRange)) return Reject(who, REJECT_RANGE);
if (r.scoreDelta > maxScoreDeltaPerRequest) return Reject(who, REJECT_SCORE_SPIKE);
return true;
}

Reject 可以同时发回执和记异常计数。拒绝重复请求时通常不记异常,因为网络重发和 UI 重复点击都可能造成重复;冷却、范围、分数突变连续出现时才记。


用 per-player 计数即可,不要一开始就写复杂系统。

[UdonSynced] private int[] suspiciousScore = new int[100];
private bool Reject(VRCPlayerApi who, byte reason)
{
int slot = GetPlayerSlot(who.playerId);
if (slot < 0) return false;
if (IsSuspiciousReason(reason))
suspiciousScore[slot] += 1;
SendRejectReceipt(who.playerId, reason);
return false;
}

GetPlayerSlot 可以先做成范围检查:playerId 小于 0 或大于数组长度就拒绝记录。更完整的项目可以维护 playerId → slot 映射。重点是不要让外部传入的 playerId 直接决定数组写入位置。

ApplySoftPenalty 不建议直接踢人或毁掉一局。VRChat 世界里更稳的处理是软降级:拒绝该玩家的高风险请求一段时间;把他的攻击改成本地表现不结算;向主持人 UI 标红;在调试面板显示最近异常原因。

如果世界是熟人房或活动房,主持人干预比自动惩罚更可靠。如果世界是公开竞技房,这一套仍然不够。


异常日志记录事实,不写判断。

字段例子
playerId12
requestId48
reasonREJECT_RANGE
phaseINGAME
serverTime1234.56
detailsdistance=38.2 max=15.0

日志文本可以在本地 Debug 面板显示,也可以只在开发版打印。公开世界里不要把「作弊者」这类标签直接展示给所有人。先记录可复现事实:谁在什么阶段、发了哪条请求、哪个检查没过。


有些世界需要主持人。接口要小,职责要清。

接口作用边界
MuteCombat(playerId)暂停某玩家的攻击结算不影响移动和观战
ForceSpectator(playerId)转旁观要写入流程字段,迟入可恢复
ResetSuspicion(playerId)清异常计数只给主持人 UI
ShowRecentRejects(playerId)查看最近拒绝原因调试信息,不公开广播

主持人接口仍然要经过 GameState Owner,不要让任意客户端直接改全局字段。主持人身份本身也不能只靠本地 UI 开关,至少要在场景配置里固定名单、房主流程或活动模式里明确来源。更严格的身份系统属于跨会话与运营层,放到后续卷处理。


「防外挂」不作为本章承诺。客户端上报的命中只作为请求材料,IsMaster 也不作为可信管理员。复杂混淆代码不能让客户端变可信;惩罚逻辑膨胀到超过核心玩法时,维护成本会反过来拖垮世界。

本卷的目标是把状态系统做得自洽:普通网络抖动不会破局,明显异常不会轻易污染分数和胜负,开发者能从日志看出哪类请求在出问题。这个目标已经足够有价值。


挑一条试。

  • 给第 31 章的 hitscan 检查加一个 REJECT_ANGLEminAimDot 设多严格时,正常玩家会开始误伤?
  • 设计一个主持人按钮:把某位玩家转成旁观。它要改哪些同步字段?迟入玩家如何看到这个结果?
场景预期实际
1. 冷却内连续发技能请求被拒绝,回执显示冷却,异常计数不增加或轻微增加
2. 上报超远命中请求被拒绝,记录 REJECT_RANGE
3. 单次请求加超大分数请求被拒绝,分数不变,记录 REJECT_SCORE_SPIKE
4. RESULT 阶段继续攻击状态机检查拒绝,普通 UI 提示无效
5. 异常计数超过阈值触发软降级,主持人 UI 可见
  • 能解释为什么 VRChat 世界只能做反作弊近似
  • 能列出冷却、范围、速度、分数突变、状态机 5 类检查
  • 能区分请求失败和可疑异常
  • 能设计一个软降级,不直接破坏整局
  • 能说明主持人接口为什么仍要经过 GameState Owner