• 产品手册
  • 编辑器功能手册
  • 云脚本
  • 反作弊实战指南

Y3 云脚本反作弊实战指南

适用读者:Y3 地图作者 核心目标:用云脚本保护跨局货币、抽卡洗练、装备养成、特权判定与通关结算。


1. 核心原则与双端边界

1.1 三条基本原则

  1. 不要仅依赖客户端校验:客户端虽有基础反作弊与多人帧同步校验,但本地脚本、配置密钥、单人局内存及 y3.save_data 存档在面对深度逆向或高级外挂时仍有可能被提取、篡改或绕过。
  2. 云脚本是唯一真相源:核心资产与随机结果必须由云脚本计算并存入云存档;客户端只负责发送“操作意图”和表现结果。
  3. 设计优先于检测:在机制上让客户端“改了也没用、刷了也没收益”,比事后写复杂的反外挂检测更可靠。

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 章模板):

  1. 频率限制:用令牌桶拦截高频刷包,防止打满单帧耗时或刷爆日志/Redis。
  2. 安全解析 JSON:先校验 type(msg_json) == "string" and #msg_json <= 上限,再用 pcall(cjson.decode, msg_json) 解析并确认返回 table。
  3. 身份绑定:只认回调参数的 role_id,严禁使用 JSON 里的玩家 ID 字段。
  4. 参数类型与范围:
    • 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。
  5. 业务状态校验:检查云脚本内存中的余额、前置等级与冷却状态。

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 下发结果。客户端看到结果前服务端缓冲已更新,强退、断线或后续发垃圾包均无法回档。
  • 冷热分离,精简云存档体积:宠物名称、抽卡记录、通关日志、键位设置等不影响实际收益的纯展示数据,建议直接存放在客户端普通存档(y3.save_data)中;云脚本存档只存货币、装备 ID、等级等核心数值(若有文本或列表需限制长度与条数),从根源上避免云存档膨胀超过 1MB 上限导致 MsSaveScriptArchive 返回 false。

2.4 进阶:为自建外部游戏服签发“防伪登录票据”(AuthTicket)

若地图通过 y3.network 连接团队自建的外部服务器,切勿让客户端自报玩家 ID 登录(静态加密密钥一旦被逆向,外挂即可冒充任意玩家账号)。目前推荐方案是让云脚本充当“第三方公证人”开具防伪介绍信:

  1. 云脚本开信:开局在 _playerjoin 事件的第 4 个参数 data(JSON 格式 {"plat_aid":"<玩家平台唯一ID>"})中读取房间内所有玩家的真实平台账号 ID,结合 MsGetPlayerName(role_id) 与开局时间 MsGetRoomLoadedTs(),将“当前玩家 plat_aid、同房队友列表、房间号、签发时间”打包,用只有云脚本和外部服知道的密钥加密并 MsBase64Encode,通过 MsSendMlEvent 发给客户端。
  2. 客户端只跑腿:客户端不存密钥、不解密,收到属于自己的票据后原样转发给外部游戏服(若票据临近过期则向云脚本节流申请刷新)。
  3. 外部服验真伪:外部服解密后直接从票据中拿到可信的 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 CloudBridge

5. 本地调试差异与上线自检

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 保护。