Y3 云脚本反作弊实战指南
适用读者:Y3 地图作者 核心目标:用云脚本保护跨局货币、抽卡洗练、装备养成、特权判定与通关结算。
1. 核心原则与双端边界
1.1 三条基本原则
- 不要仅依赖客户端校验:客户端虽有基础反作弊与多人帧同步校验,但本地脚本、配置密钥、单人局内存及
y3.save_data存档在面对深度逆向或高级外挂时仍有可能被提取、篡改或绕过。 - 云脚本是唯一真相源:核心资产与随机结果必须由云脚本计算并存入云存档;客户端只负责发送“操作意图”和表现结果。
- 设计优先于检测:在机制上让客户端“改了也没用、刷了也没收益”,比事后写复杂的反外挂检测更可靠。
1.2 双端通信与存储接口
| 模块 | 接口 | 限制与安全边界 |
|---|---|---|
| 客户端 → 云脚本 | Lua:y3.game.send_mlscript_msg(event_name, tab)ECA: 游戏 - 发送云脚本自定义消息 | tab 自动序列化为 JSON(上限 10KB)。外挂可高频伪造任意字段,不可直接信任。 |
| 云脚本监听 | RegisterEvent(event_name, function(idx, role_id, name, msg_json)) | 第 2 个参数 role_id 由服务端可信注入,永远只用它识别玩家身份。 |
| 云脚本 → 客户端 | 云脚本:MsSendMlEvent(role_id, event_name, event_msg)客户端 Lua: y3.game:event('玩家-收到云脚本消息', ...)客户端 ECA: 游戏 - 收到云脚本自定义消息 | event_name ≤ 32 字节,event_msg 为 JSON(上限 10KB)。 |
| 云脚本存档 | MsGetScriptArchive(role_id) / MsSaveScriptArchive(role_id, data) | 单玩家上限 1MB;仅云脚本可读写,专存核心资产。宠物名称、抽卡历史记录、通关日志等纯展示数据建议存客户端普通存档(y3.save_data)。 |
MsSendMlEvent 的两条关键机制:
- 天然帧同步一致:
MsSendMlEvent下发的消息会通过帧同步流广播给房间内所有客户端,客户端会在同一逻辑帧自动解码为table并触发【收到云脚本自定义消息】。在该回调里给对应role_id的玩家加属性或发装备,天然全房同步,无需二次广播。 - 不要下发暗牌:正因为同房所有人的电脑都会收到这条消息(只是事件里带了目标
role_id),不要在MsSendMlEvent中下发“狼人身份、底牌”等需要对同房其他玩家保密的信息。
1.3 双端职责切分
| 逻辑类型 | 归属端 | 安全实现方式 |
|---|---|---|
| 走位、普攻、技能碰撞、UI | 客户端(帧同步) | 多人局靠帧同步互验;单人局靠云脚本在结算时校验关卡耗时与产出上限 |
| 永久货币、跨局等级、装备养成 | 云脚本(MsSaveScriptArchive) | 开局 _roomloaded 读取并下发局内属性;升级消耗、成功率、抽卡权重只写在云脚本中 |
| 抽卡、开箱、词条洗练 | 云脚本(随机 + 存档) | 客户端只发请求;云脚本完成“扣资源 → math.random → 存档”后再下发结果(见 2.3) |
| 平台道具使用消耗 | 云脚本(MsTryConsumeItem) | 发起消耗后,在 _consume_item_notify 回调中按实际扣减量发放效果(见 2.2) |
| VIP / 地图等级 / 时长特权 | 云脚本(MsGet* 接口) | 直接调 MsGetPlayerMapVipLevel、MsGetPlayerMapLevel、MsGetPlayedTime 判定并记入云存档 |
2. 入口校验与存档安全
2.1 云脚本入口 5 步校验
所有自定义 RegisterEvent 回调,均按以下顺序校验(完整实现见 第 4 章模板):
- 频率限制:用令牌桶拦截高频刷包,防止打满单帧耗时或刷爆日志/Redis。
- 安全解析 JSON:先校验
type(msg_json) == "string" and #msg_json <= 上限,再用pcall(cjson.decode, msg_json)解析并确认返回table。 - 身份绑定:只认回调参数的
role_id,严禁使用 JSON 里的玩家 ID 字段。 - 参数类型与范围:
- Lua 中
NaN(0/0)的type也是"number",但与任何数比较均返回false,会绕过v < 1 or v > 100;只查v > 0还会漏掉小数0.001或负数反向刷资源。 - 云脚本运行于 Lua 5.4,
cjson.decode会将整数解析为integer子类型,将小数、NaN、Inf解析为float子类型。因此只需math.type(v) == "integer" and v >= min and v <= max即可一行拦截所有非整数、负数、小数与NaN/Inf。
- Lua 中
- 业务状态校验:检查云脚本内存中的余额、前置等级与冷却状态。
2.2 平台道具消耗:以 _consume_item_notify 回调为准
MsTryConsumeItem(role_id, store_id, cnt) 是异步接口(平台已保证扣减原子性):返回 0(MlLuaErrorCode.OK)仅代表请求已发出,切勿在此时直接发放效果。应只在 _consume_item_notify 回调中按 before_cnt - after_cnt 发放:
function use_platform_item(role_id, store_id, cnt)
if math.type(cnt) ~= "integer" or cnt < 1 or cnt > 10 then return false end
return MsTryConsumeItem(role_id, store_id, cnt) == MlLuaErrorCode.OK
end
RegisterEvent("_consume_item_notify", function(_, role_id, _, store_id, before_cnt, after_cnt)
local consumed = before_cnt - after_cnt
if consumed <= 0 then return end
PlayerMgr.add_stamina(role_id, consumed * 50)
PlayerMgr.save(role_id)
end)2.3 存档防回档与体积控制
对局中途调用 MsSaveScriptArchive 仅更新服务端内存中的存档缓冲(开销极小),真正写数据库在玩家退出(_playerexit)或房间结束(_roomover)时由系统自动完成。
- 当场保存杜绝强退回档(SL 大法):
- 错误写法:平时抽卡只改 Lua 表,等
_playerexit退房才调MsSaveScriptArchive。若玩家抽卡失利后故意往 Lua 表塞垃圾数据使其超过 1MB 上限,退房保存就会失败,直接回退到进房时的旧存档。 - 正确写法:抽卡、洗练、强化时,当场完成“扣资源 → 算结果 → 调
MsSaveScriptArchive”,确认返回true后再调MsSendMlEvent下发结果。客户端看到结果前服务端缓冲已更新,强退、断线或后续发垃圾包均无法回档。
- 错误写法:平时抽卡只改 Lua 表,等
- 冷热分离,精简云存档体积:宠物名称、抽卡记录、通关日志、键位设置等不影响实际收益的纯展示数据,建议直接存放在客户端普通存档(
y3.save_data)中;云脚本存档只存货币、装备 ID、等级等核心数值(若有文本或列表需限制长度与条数),从根源上避免云存档膨胀超过 1MB 上限导致MsSaveScriptArchive返回false。
2.4 进阶:为自建外部游戏服签发“防伪登录票据”(AuthTicket)
若地图通过 y3.network 连接团队自建的外部服务器,切勿让客户端自报玩家 ID 登录(静态加密密钥一旦被逆向,外挂即可冒充任意玩家账号)。目前推荐方案是让云脚本充当“第三方公证人”开具防伪介绍信:
- 云脚本开信:开局在
_playerjoin事件的第 4 个参数data(JSON 格式{"plat_aid":"<玩家平台唯一ID>"})中读取房间内所有玩家的真实平台账号 ID,结合MsGetPlayerName(role_id)与开局时间MsGetRoomLoadedTs(),将“当前玩家plat_aid、同房队友列表、房间号、签发时间”打包,用只有云脚本和外部服知道的密钥加密并MsBase64Encode,通过MsSendMlEvent发给客户端。 - 客户端只跑腿:客户端不存密钥、不解密,收到属于自己的票据后原样转发给外部游戏服(若票据临近过期则向云脚本节流申请刷新)。
- 外部服验真伪:外部服解密后直接从票据中拿到可信的
plat_aid,并核查两件事——5 分钟时效窗口(防旧票据跨局使用)与密文指纹去重(防同一张票重放刷接口)。
3. 服务端检测与分级处置
当请求格式合法,但玩家在单人局开倍速/秒杀挂或用脚本宏刷收益时,由云脚本做行为检测与分级处置。
3.1 检测原则与五种实战手段
原则:只信云脚本时钟(MsGetRoomGameTime() / Timer),不单靠客户端检测上报;发现异常先止损(拒发或截断收益),后处罚;用多信号累加可疑度(Suspicion Score),避免单次卡顿误判。
| 检测手段 | 实现方式 | 拦截目标 |
|---|---|---|
| 关卡最短耗时 + 顺序检查点 | 记录上一关完成时的 MsGetRoomGameTime(),校验 stage_id == cur_stage + 1 且 耗时 >= 理论最快秒数 | CE 秒杀挂、跳关发包领终极奖 |
| 双时钟“对表”(抓倍速齿轮) | 客户端每次发请求顺带上报本地游戏时间,云脚本比对“本地经过的秒数 cli_delta”与“云脚本 MsGetRoomGameTime() 经过的秒数 srv_delta”,本地明显跑得更快即判定加速 | 变速齿轮 / 局内加速挂 |
| 定期回源“对账”(抓改内存) | 客户端每隔几分钟(或过关时)将局内当前生效的装备词条、随从等级、核心属性发给云脚本,与云存档中的权威真本比对;本地高于真本即判定内存篡改 | CE 直接修改局内属性、词条、等级 |
| 产出速率上限(局内 + Redis 跨局) | 单局按 MsGetRoomGameTime() 限制每分钟产出;跨局用 redis:IncrBy 统计玩家每小时获取总量 | 脚本宏挂机、多开秒进秒退刷首通 |
| 蜜罐事件(Honeypot) | 客户端留一个正常流程永不调用的事件名(如 "gm_add_gem_debug"),云脚本收到即直接拉满可疑度 | 逆向地图包并批量扫描调用接口的外挂 |
3.2 四级后果阶梯
避免一发现异常就弹窗报错(会帮外挂作者快速二分试出检测阈值):
| 等级 | 可疑度 | 处置动作 | 云脚本实现 |
|---|---|---|---|
| L1 静默记录 | 1 ~ 29 | 仅记日志 | Log.warn 记录 role_id、MsGetPlayerName(role_id)、游戏时长与参数 |
| L2 静默无害化 | 30 ~ 59 | 截断收益 / 丢弃 | 用 math.min 将单次奖励夹紧到理论上限,并用 MsSendMlEvent 覆盖客户端显示 |
| L3 本局静默隔离 | 60 ~ 99 | 冻结本局存档 | 标记 p.quarantined = true,照常回包演戏,但跳过 MsSaveScriptArchive,让外挂白打一局 |
| L4 跨局限制 | >= 100 | 剥夺榜单 / 封禁 | 在云存档或 Redis 标记 banned = true,下次进房禁止上榜或通知客户端结束流程 |
4. 实战代码模板(云脚本 + 客户端)
4.1 云脚本端(main.lua)
local SCHEMA_VER = 1
local Players = {} -- [role_id] = { aid, name, data, quarantined, suspicion, stage_ts, clock_anchor }
local Buckets = {}
local now_ms = 0
Timer.new_timer(200, function() now_ms = now_ms + 200; return true end)
-- 令牌桶限流(cap: 最大突发次数, window_ms: 回满毫秒数)
local function allow_rate(role_id, key, cap, window_ms)
Buckets[role_id] = Buckets[role_id] or {}
local b = Buckets[role_id][key]
if not b then
Buckets[role_id][key] = { tokens = cap - 1, last = now_ms }
return true
end
local elapsed = now_ms - b.last
if elapsed > 0 then
b.tokens = math.min(cap, b.tokens + elapsed * (cap / window_ms))
b.last = now_ms
end
if b.tokens >= 1 then b.tokens = b.tokens - 1; return true end
return false
end
-- 整数范围校验(Lua 5.4 一行排除 nil、字符串、小数、NaN、Inf)
local function is_safe_int(v, min_v, max_v)
return math.type(v) == "integer" and v >= (min_v or 1) and v <= (max_v or 2147483647)
end
local function add_suspicion(p, role_id, score, reason)
p.suspicion = p.suspicion + score
Log.warn("[AntiCheat] role=%d aid=%s score=%d reason=%s", role_id, tostring(p.aid), p.suspicion, reason)
if p.suspicion >= 80 then p.quarantined = true end -- 触发 L3 本局静默隔离(停止存档落盘)
end
-- 双时钟对表:校验客户端自报逻辑时间流速 vs 云脚本真实时间流速(抓变速齿轮)
local function check_client_clock(p, role_id, client_sec)
if type(client_sec) ~= "number" or client_sec ~= client_sec then return end
local srv_sec = MsGetRoomGameTime() or 0
if not p.clock_anchor then
p.clock_anchor = { srv = srv_sec, cli = client_sec }
return
end
local srv_delta = srv_sec - p.clock_anchor.srv
if srv_delta < 30 then return end -- 至少间隔 30 秒采样一次,避免网络抖动误判
local cli_delta = client_sec - p.clock_anchor.cli
p.clock_anchor = { srv = srv_sec, cli = client_sec }
if cli_delta / srv_delta > 1.5 then
add_suspicion(p, role_id, 60, string.format("speedhack_rate:%.2f", cli_delta / srv_delta))
end
end
local function init_player(role_id, plat_aid)
local p = Players[role_id]
if p then
if plat_aid then p.aid = plat_aid end
return
end
local raw = MsGetScriptArchive(role_id)
local ok, data = false, nil
if type(raw) == "string" and #raw > 0 then ok, data = pcall(cjson.decode, raw) end
if not ok or type(data) ~= "table" or data.ver ~= SCHEMA_VER then
data = { ver = SCHEMA_VER, seq = 1, gold = 500, stage = 0, equip_lv = { [1] = 1, [2] = 1 } }
end
Players[role_id] = {
aid = plat_aid, name = MsGetPlayerName(role_id) or "",
data = data, quarantined = false, suspicion = 0,
stage_ts = MsGetRoomGameTime() or 0,
}
MsSendMlEvent(role_id, "SyncState", cjson.encode({
gold = data.gold, stage = data.stage, equip_lv = data.equip_lv,
vip = MsGetPlayerMapVipLevel(role_id) or 0,
}))
end
local function save_player(role_id)
local p = Players[role_id]
if not p or p.quarantined then return false end
p.data.seq = p.data.seq + 1
local raw = cjson.encode(p.data)
if #raw > 900 * 1024 then return false end -- 预留余量,防超 1MB
return MsSaveScriptArchive(role_id, raw)
end
-- 房间生命周期(兼容线上 _roomloaded 与本地模拟 _localmodeloaded / _playerjoin)
RegisterEvent("_roomloaded", function(_, _, _, role_ids)
for _, rid in ipairs(role_ids or {}) do init_player(rid, nil) end
end)
RegisterEvent("_localmodeloaded", function()
Log.info("local mode loaded")
end)
RegisterEvent("_playerjoin", function(_, role_id, _, payload)
local ok, info = pcall(cjson.decode, payload or "")
local aid = (ok and type(info) == "table") and (tonumber(info.plat_aid) or info.plat_aid) or nil
init_player(role_id, aid)
end)
RegisterEvent("_playerexit", function(_, rid)
-- 每次资产变更已当场调用 save_player 更新服务端缓冲,退房时由系统自动落库,此处仅清理内存
Players[rid] = nil
Buckets[rid] = nil
end)
RegisterEvent("_roomover", function()
Log.info("room over")
end)
-- 示例 1:权威抽卡(当场扣资源 + 随机 + 调 MsSaveScriptArchive,成功后再下发结果,杜绝强退 SL)
RegisterEvent("DrawCard", function(_, role_id)
if not allow_rate(role_id, "draw", 5, 5000) then return end
local p = Players[role_id]
if not p or p.quarantined or p.data.gold < 100 then return end
p.data.gold = p.data.gold - 100
local slot = math.random(1, 2)
p.data.equip_lv[slot] = (p.data.equip_lv[slot] or 1) + 1
if not save_player(role_id) then
p.data.gold = p.data.gold + 100
p.data.equip_lv[slot] = p.data.equip_lv[slot] - 1
return
end
MsSendMlEvent(role_id, "DrawRes", cjson.encode({
gold = p.data.gold, slot = slot, lv = p.data.equip_lv[slot]
}))
end)
-- 示例 2:关卡结算(顺序检查点 + 最短耗时门禁 + 双时钟流速对表)
RegisterEvent("StageClear", function(_, role_id, _, msg_json)
if not allow_rate(role_id, "stage", 2, 5000) or type(msg_json) ~= "string" or #msg_json > 128 then return end
local p = Players[role_id]
if not p or p.quarantined then return end
local ok, req = pcall(cjson.decode, msg_json)
if not ok or type(req) ~= "table" or not is_safe_int(req.stage_id, 1, 50) then return end
check_client_clock(p, role_id, req.client_time)
local now_sec = MsGetRoomGameTime() or 0
if req.stage_id ~= p.data.stage + 1 or (now_sec - p.stage_ts) < 30 then
add_suspicion(p, role_id, 50, string.format("stage:%d_elapsed:%d", req.stage_id, now_sec - p.stage_ts))
return
end
p.stage_ts = now_sec
p.data.stage = req.stage_id
p.data.gold = p.data.gold + 300
save_player(role_id)
MsSendMlEvent(role_id, "StageAck", cjson.encode({ stage = p.data.stage, gold = p.data.gold }))
end)
-- 示例 3:装备等级回源对账(客户端周期上报局内快照,与云存档权威真本比对,抓 CE 改内存)
RegisterEvent("VerifySnapshot", function(_, role_id, _, msg_json)
if not allow_rate(role_id, "verify", 2, 10000) or type(msg_json) ~= "string" or #msg_json > 256 then return end
local p = Players[role_id]
if not p or p.quarantined then return end
local ok, req = pcall(cjson.decode, msg_json)
if not ok or type(req) ~= "table" or type(req.equip_lv) ~= "table" then return end
check_client_clock(p, role_id, req.client_time)
for slot, client_lv in pairs(req.equip_lv) do
local auth_lv = p.data.equip_lv[tonumber(slot)] or 1
if type(client_lv) == "number" and client_lv > auth_lv then
add_suspicion(p, role_id, 80, string.format("mem_tamper_slot_%s:%s>%d", tostring(slot), tostring(client_lv), auth_lv))
return
end
end
end)4.2 客户端(地图 Lua 对接)
local CloudBridge = {}
local equip_lv_by_pid = {}
-- 获取客户端当前逻辑时间(秒),随请求上报供云脚本对表防倍速齿轮
local function get_logic_sec()
return y3.ltimer.clock() / 1000
end
function CloudBridge.req_draw()
y3.game.send_mlscript_msg("DrawCard", {})
end
function CloudBridge.req_stage_clear(stage_id)
y3.game.send_mlscript_msg("StageClear", {
stage_id = stage_id,
client_time = get_logic_sec(),
})
end
-- 每 60 秒主动将本地局内生效等级快照上报云脚本对账(防 CE 直接修改本地内存)
y3.ltimer.loop(60, function()
local local_pid = y3.player.get_local():get_id()
y3.game.send_mlscript_msg("VerifySnapshot", {
equip_lv = equip_lv_by_pid[local_pid] or { [1] = 1, [2] = 1 },
client_time = get_logic_sec(),
})
end)
-- 监听云脚本下行事件(全房间客户端在同一逻辑帧收到并同步执行)
y3.game:event("玩家-收到云脚本消息", function(_, data)
if not data or not data.player then return end
local player = data.player
local pid = player:get_id()
local ev_name = data.mlscript_event_name
local payload = data.mlscript_event_data
if type(payload) ~= "table" then return end
if ev_name == "SyncState" then
player:set("gold", payload.gold)
if type(payload.equip_lv) == "table" then equip_lv_by_pid[pid] = payload.equip_lv end
elseif ev_name == "StageAck" then
player:set("gold", payload.gold)
elseif ev_name == "DrawRes" then
player:set("gold", payload.gold)
equip_lv_by_pid[pid] = equip_lv_by_pid[pid] or { [1] = 1, [2] = 1 }
equip_lv_by_pid[pid][payload.slot] = payload.lv
end
end)
return CloudBridge5. 本地调试差异与上线自检
5.1 本地调试环境(Mock)注意事项
- 区分环境:
MsIsProdEnv()在线上正式环境返回true,本地调试环境恒返回false。 - 本地固定返回值:本地调试下
MsGetPlayerMapLevel恒为1、MsGetPlayedCount恒为1、MsGetPlayedTime/MsGetPlayerPlusVipLevel/MsGetPlayerMapVipLevel等数值型平台信息接口恒为0,布尔型平台信息接口(如MsGetPlayerIsHonorVip、MsGetPlayerIsAuthor等)恒为false。 - 测试后门必加环境锁:调试特权或 GM 指令必须包裹
if not MsIsProdEnv() then ... end。严禁裸写if role_id == 1 then ...(线上房间 1 号位玩家的role_id也是1)。
5.2 上线自检清单(Checklist)
- 核心资产归云端:跨局货币、抽卡结果、装备养成、特权状态全部收归
MsGetScriptArchive/MsSaveScriptArchive,不信客户端上传的计算结果。 - 入口五步校验:自定义
RegisterEvent均包含令牌桶限流、长度检查 +pcall(cjson.decode)、只认回调参数role_id、math.type(v) == "integer"范围校验。 - 道具消耗等回调:
MsTryConsumeItem仅在_consume_item_notify回调中按before_cnt - after_cnt > 0发放效果。 - 当场保存防回档:抽卡、洗练、强化在当次事件内立即调
MsSaveScriptArchive,确认返回true后再调MsSendMlEvent下发结果,失败则回滚内存。 - 下行不放暗牌:
MsSendMlEvent会广播给同房所有客户端,不在其中下发需对同房其他玩家保密的底牌或私密数据。 - 对表、对账与耗时校验:关卡结算用
MsGetRoomGameTime()校验顺序与最短耗时;请求夹带本地时间与云脚本对表防倍速;定期回源对账装备/属性防改内存。 - 存档精简与调试锁:宠物名称、抽卡记录等纯展示数据放客户端普通存档,云存档只存核心资产(远小于
1MB);所有测试 GM 逻辑均受if not MsIsProdEnv() then保护。

