存档迁移云脚本方案
说明:地图脚本存档由客户端脚本计算并通过
SaveData等接口写入,运行时保存在游戏内存,最终落库在平台数据库。下文的“物品系统”是示例模块,
<...>是占位符;替换成你自己的模块、槽位、字段和阈值即可。
1. 结论
- 旧存档的内容由客户端写入,可以被伪造或篡改,不再作为可信数据。迁移后,云脚本存档是该模块数据的唯一权威来源,地图客户端只保留只读镜像。
- 改动只涉及两部分:地图 Lua 和本地图的云脚本 Lua。
- 云档只由云脚本写入,云脚本即权威,因此不做签名。服务端校验只负责:读取时校验结构、保证旧档只迁移一次、在服务端生成随机结果。它不能证明旧存档是否作弊得来,也不能替代业务校验。
- 客户端应该只发送“操作意图”给云脚本。数值增减、随机结果和每日计数都由云脚本计算。
- 旧存档迁移提供两套方案:方案 A 既往不咎(只做结构合规化)、方案 B 有界清洗(阈值可配置)。
- 迁移状态由云档迁移标记和旧存档槽的
cloud_migrated标记共同判定。云档为空不等于新玩家;若存档标记存在,进入锁定,不导入、不保存。
2. 云脚本平台行为
| 行为 | 对方案的影响 |
|---|---|
| 云脚本只在服务端运行,玩家无法直接读写云脚本存档,也不随地图下发给客户端 | 存档权威转移到云脚本;代码与配置只在服务端,清洗阈值、名单等规则必须放在云脚本,不能放在地图 Lua |
MsSaveScriptArchive 上限 1 MB;保存后由平台在玩家离开或对局结束时持久化 | 进程异常或房间未能正常结束时,本局最近的变更可能丢失(见第 8 节) |
平台只写回本次对局中调用过 MsSaveScriptArchive 的玩家;没调用就不写 | 迁移判定完成前不保存,已有云档不会被覆盖成空档 |
MsGetScriptArchive 在无云档或读取异常时都返回空字符串 | 不能单独作为“尚未迁移”的依据,需结合存档标记(见 6.1) |
| 云脚本房间启动失败或玩家未能加入时,云脚本不会处理该玩家 | 客户端收不到快照,本局模块功能降级并提示 |
_playerjoin 事件提供玩家平台账号 plat_aid | 用 plat_aid 标识玩家(如“允许重新导入”名单) |
| 平台按玩家与地图读写云档,云脚本只能读写房间内玩家自己的云档 | 云档不会串号,无需在存档内绑定玩家身份 |
自定义事件回调中的 role_id 标识消息发送者 | 操作按 role_id 归属,不采用消息内容中的玩家标识 |
| 云脚本压缩包与地图客户端可分别更新,但都要走发布与审核流程 | 只调整迁移规则或业务参数时,更新云脚本包即可,不需要更新客户端 |
| 云脚本包在房间启动时加载,更新只影响之后新开的房间 | 进行中的房间继续使用旧版本 |
切换关卡时云脚本房间保持不变,Lua 内存保留,不会再次触发 _roomloaded / _playerjoin | 进入新关卡需由客户端主动发事件拉取快照 |
MsSendMlEvent 下发的事件会在房间内所有客户端触发 | 下发内容天然同步一致,但不保密 |
单条消息上限:MsSendMlEvent 10 KB,客户端 send_mlscript_msg 约 10 KB | 快照约 1–2 KB 可单条发完,超长分片;旧档一次性上传通常可容纳 |
3. 威胁模型
3.1 信任边界
| 对象 | 可信度 | 说明 |
|---|---|---|
| 玩家客户端、地图包、由客户端写入的存档数据 | 不可信 | 可修改内存、伪造存档写入、反编译地图包 |
| 云脚本 Lua | 可信 | 运行在服务端,不下发到客户端 |
旧存档槽中的 cloud_migrated 标记 | 不可信,仅作辅助 | 只用于识别云档读取异常;增删只影响玩家自身,不能绕过迁移清洗 |
| 云脚本平台接口 | 可信 | 地图等级、商城道具、扣道具结果、服务端时间、plat_aid |
| 客户端发来的消息内容 | 不可信 | 所有字段都需要校验,不能直接作为结果使用 |
| 云脚本存档内容 | 读取时校验 | 防止损坏、格式错误或跨版本误读 |
3.2 防护目标
| 威胁 | 处理方式 | 覆盖情况 |
|---|---|---|
| 伪造旧存档刷数值 | 迁移后旧存档不参与权威读取 | 覆盖 |
| 伪造“加数值 / 改品质”消息 | 不提供此类消息,客户端只能发意图 | 覆盖 |
| 预测随机结果(合成、掉落等) | 随机结果只在云脚本生成 | 覆盖 |
| 重复触发一次性迁移 | 云档迁移标记 + 内存防重入 | 覆盖 |
| 云档读取异常被当成新玩家,旧档覆盖云端进度 | 存档标记;状态未确认前不保存 | 基本覆盖,剩余窗口见第 9 节 |
| 云档损坏或格式错误 | 读取时结构校验,不通过即锁定 | 覆盖 |
| 首次迁移时上传伪造旧档 | 方案 A 只限制结构;方案 B 增加阈值 | 部分覆盖 |
4. 目标架构
客户端(地图 Lua)
├─ 发送意图:使用、合成、领取等
├─ 首次迁移:只读上传一次旧存档
├─ 存档标记:收到通知后写入 cloud_migrated
└─ 接收快照:更新内存镜像与 UI
│ send_mlscript_msg ▲ MsSendMlEvent
▼ │
云脚本(服务端)
├─ 玩家状态与业务状态机:迁移状态、数值变更
├─ 存档校验与迁移判定:结构校验 + imported + 存档标记
├─ 随机数:服务端 math.random
└─ 持久化:MsGetScriptArchive / MsSaveScriptArchive5. 云存档格式与校验
5.1 存档格式
MsSaveScriptArchive / MsGetScriptArchive 传递的是任意字节串:接口按长度处理,可以存放文本或二进制流(含 \0),上限 1 MB,具体内容格式由云脚本自行决定。
本方案选择在其中存放文本,即一个 JSON 对象的序列化字符串,使用云脚本内置的 cjson 编码与解码:
{
"schema": 1,
"<module>": {
"rev": 2,
"imported": true,
"legacy": true,
"sanitized": false,
"import_ts": 1793680000,
"balance": 120,
"entries": {
"1": { "id": "<配置ID>", "tier": 3, "at": 1793670000 },
"7": { "id": "<配置ID>", "tier": 1, "at": 1793671200 }
},
"loadout": { "1": 1, "4": 7 }
}
}| 层级 | 含义 |
|---|---|
顶层 schema | 容器版本;编码方式整体调整时递增 |
模块对象(如 <module>) | 每个模块一个顶层字段,字段见 5.2 |
要点:
- 模块各自带版本与迁移状态,新增模块时不需要改动已有模块,也不共用全局标志位。
- 保存时保留不认识的模块和字段,避免旧版本脚本回滚后写回时丢掉新模块数据。
- 键顺序不固定:任何逻辑都不得依赖字段顺序,也不做逐字节比较。
- 不签名、不加密:云档只由云脚本写入,客户端无法写入;数据本来也要下发给房间内所有客户端。
- 体积:满档约 2–3 KB,远低于 1 MB 上限;该上限由所有模块共享,保存前校验总长度。
5.2 模块字段
<module> 的内容由你的玩法决定,可以是任意字段结构;下表只是示例。迁移只要求保留 rev、imported 等元字段,业务字段由你定义:
| 字段 | 说明 |
|---|---|
rev | 模块版本,当前为 2 |
imported | 已完成迁移,等价于云档迁移标记 |
legacy / sanitized / import_ts | 是否来自旧档、是否清洗过、导入时间 |
<业务字段> | 任意结构。示例:balance(数值)、entries(槽位表,值为 {id,tier,at})、loadout(引用表) |
编码约束:
- 槽位键必须是字符串。cjson 遇到稀疏整数键会抛
excessively sparse array,entries和loadout都可能触发。 - 不写
null:空值直接省略字段。cjson.decode会把 JSONnull解成cjson.null,不是 Luanil。 - 超过 2^53 的整数用字符串表示;
plat_aid本来就是字符串,其余字段保持在范围内。 - 客户端上传旧档前也要转成字符串键:
send_mlscript_msg内部使用客户端 cjson 编码,整数稀疏键同样会报错。 - 不保存调试字段;云端调试功能只在
MsIsProdEnv() == false时可用,且不写入存档。
5.3 服务端随机
- 合成、掉落、礼包等随机结果只在云脚本生成。
- 使用云脚本 Lua 自带的
math.random。它的种子在云脚本虚拟机启动时由服务端生成,客户端无法观测或设置。 - 禁止调用
math.randomseed设置固定或可推算的种子(如os.time()、玩家 ID、房间 ID)。 math.random不是密码学随机数生成器,但状态只存在于服务端,满足本类玩法的掉落、合成等需要。- 不接受客户端提供的种子、索引或随机结果。
5.4 读取与校验
读取顺序:先 pcall(cjson.decode, raw),再校验顶层 schema、<module> 模块存在且 <module>.imported == true,最后做结构校验。结构校验与迁移清洗(6.2)使用同一套规则:按你的字段定义校验类型、范围、引用关系和数量上限,不要求固定结构;示例见 6.2。读取时只校验、不修正。
| 读取结果 | 处理 |
|---|---|
| 解码成功、容器与模块结构正确且结构校验通过 | 使用云端数据,忽略旧存档 |
| 空字符串 | 请求客户端上传旧存档,按 6.1 判定;不直接视为新玩家 |
JSON 解析失败、缺少 schema 或 <module>、<module>.imported 不为 true、或结构校验不通过 | 进入 LOCKED(见 6.1)并记录日志;不回退旧存档,也不自动重置 |
nil(玩家不存在) | 记录错误,不执行该模块逻辑 |
LOCKED 状态下不调用 MsSaveScriptArchive,平台中的原记录保持不变,供人工排查。
配置调整(例如下线某个配置 ID)时,需同步提供云档升级规则,并递增模块版本(如 <module>.rev 升为 3);否则持有该 ID 的玩家会在读取时被锁定。
6. 旧存档迁移
6.1 迁移标记与状态机
迁移状态由两个标记共同判定:
| 标记 | 位置 | 写入时机 | 作用 |
|---|---|---|---|
| 云档迁移标记 | 云档 <module>.imported | 迁移完成时随云档一起保存 | 云档有效且 imported 为 true 即表示已迁移 |
存档标记 cloud_migrated = true | 旧存档槽 | 云脚本读到有效云档后通知客户端写入 | 云档为空时,区分“从未迁移”与“云档读取异常” |
为什么需要存档标记:云档读取异常时同样返回空字符串,云档内的迁移标记也会丢失。两套存档系统独立,标记写在旧存档槽,不会与云档同时读失败;且只在读到有效云档后写入,因此有标记即说明云档曾落盘。
UNKNOWN ─┬─ 云档有效 ─────────────────────────▶ READY(通知客户端写入存档标记)
├─ 云档格式错误或结构校验不通过 ────────▶ LOCKED
└─ 云档为空 ──▶ AWAIT_LEGACY(请求上传旧存档)
├─ 上传内容含 cloud_migrated ──▶ LOCKED(判定为读取异常)
├─ 上传不含标记 ──清洗并保存──▶ READY(本局不写存档标记)
└─ 超时或数据非法 ──▶ 保持 AWAIT_LEGACY,可重试_playerjoin时读取云档,进入UNKNOWN;判定完成前拒绝该玩家所有该模块操作。- 云档有效:进入
READY,下发快照和<module>_mark_migrated。客户端只在标记不为true时写入;被清档清掉的标记会在下次读取时自动补写。 - 云档为空:进入
AWAIT_LEGACY,下发<module>_req_legacy。客户端只读一次<旧存档槽>(如player:get_save_data_table(<旧存档槽>)),复制成普通 Lua 表(含cloud_migrated)后上传。 - 只在
AWAIT_LEGACY且请求后的短窗口内接受上传:含cloud_migrated→LOCKED;不含则按方案 A / B 清洗、写入<module>.imported = true、保存、进入READY并下发快照(本局不下发标记事件)。 - 旧档为空按新玩家初始化并保存;
READY后拒绝再次导入;LOCKED不保存、不生效、提示并告警,下次进入重新判定。
客户端约束:
- 迁移后旧存档槽只允许写入
cloud_migrated(沿用现有写入与上传流程),其余字段保持只读,作为旧档备份。 - 存档规整逻辑不得删除
cloud_migrated。
人工恢复:旧存档槽有标记但云档持续为空时,先确认云端确无该玩家记录并核对 6.4 日志,再把 plat_aid 加入云脚本的“允许重新导入”名单并发布。名单内玩家忽略标记、按第 5 步重新导入,完成后移出名单。
6.2 方案 A:既往不咎
认可首次上传的业务数值,只做结构合规化。规则按字段类型配置,不要求固定结构;下面用示例字段说明几类常见处理:
- 集合 / 槽位类字段(示例
entries):只保留<槽位范围>,写入云档时统一转为字符串键;配置 ID 必须存在于云脚本内置的配置表;品质钳制为<品质范围>;时间为非负整数;如存在临时标记,统一置为false。 - 引用类字段(示例
loadout):只保留<装备槽范围>,写入云档时统一转为字符串键,且指向的槽位必须存在;同一配置只保留装备槽号最小的一件。 - 数值类字段(示例
balance):按字段定义钳制到<最小值>–<安全上限>。 - 枚举 / 周期类字段(示例每日计数和周期键):按当前周期归一化,非法值重置。
- 迁移导入时丢弃调试字段和旧档中的未知字段;云档中已存在的其他模块不参与清洗,按 5.1 原样保留。
- 规则沿用现有存档的规整逻辑,必要时抽成客户端与云脚本共用的纯函数模块。
风险:方案 A 的数据由客户端上传。作弊者可以在首次迁移时上传结构合法的满配存档,上限只受结构约束(你定义的字段范围与上限)。
6.3 方案 B:有界清洗导入
在方案 A 的基础上增加可配置的策略钩子:
local MIGRATION_POLICY = {
mode = 'CLAMP', -- 'CLAMP' 削峰保留 / 'RESET' 命中即重置
rules = {
-- { max_map_level = <等级阈值>, max_value = <数值上限>, ... }
},
}- 只用云脚本接口提供的数据判断:
MsGetPlayerMapLevel、MsGetPlayedTime、MsGetPlayedCount、MsGetPlayerMapVipLevel、MsGetPlayerPlusVipLevel、MsGetPlayerSigninDays等平台下发信息接口。 CLAMP:数值截断到上限;超出范围的高价值条目按等级从高到低降级或移除。RESET:命中即按新玩家初始化。- 清洗结果写入
<module>.sanitized = true,同时按 6.4 记录审计日志。
6.4 迁移审计日志
每次迁移处理(方案 A 或 B)在云脚本日志中写入一条固定格式的单行摘要,供事后排查和人工恢复使用。
[archive-migrate] aid=<plat_aid> ts=<服务器秒> mode=<A|B> rules=<规则名,逗号分隔|-> dropped=<字段名,逗号分隔|-> before=<清洗前摘要> after=<清洗后摘要>摘要内容由你的模块决定,建议只放聚合数量,例如总量、分类计数、已装备数。示例(示例模块):
[archive-migrate] aid=123456 ts=1793680000 mode=B rules=cap_balance,drop_top_tier dropped=debug_field before=balance=120000,entries=50,tiers=3/9/18/15/4/1,loadout=4 after=balance=5000,entries=49,tiers=3/9/18/15/4/0,loadout=4- 发生裁减、降级或移除时用
warn,其余用info;未完成的情况同样记录原因(上传超时、结构非法、判定为读取异常)。 - 云脚本日志会随对局同步到房间内客户端,因此摘要只记录聚合数量,不记录旧档原文和单件物品明细。
- 清洗规则调整后,按规则名可以从历史日志中还原当时的处理结果。
7. 变动入口映射
迁移的原则只有一条:把所有原本在客户端修改存档的地方,逐个改成云脚本事件。
- 客户端只发送意图:操作类型、目标(槽位或配置 ID)、数量。
- 客户端不发送存档内容、计算结果或可以直接落库的数值。
- 云脚本读取当前状态,校验条件、计算新状态、保存并下发快照。
- 客户端入口只保留“发起请求”和“显示结果”。
盘点时按这个模板逐个替换:
| 原来在客户端做的事 | 迁移后 |
|---|---|
| 直接改存档字段 | 发送意图,云端修改 |
| 本地计算数值或随机结果 | 云端计算 |
| 本地判断次数、冷却、上限 | 云端判断 |
| 本地扣道具后再写存档 | 先扣平台道具,确认后再写 |
| 本地维护每日计数 | 云端按服务端日期维护 |
涉及平台道具或货币扣减的入口,还要处理异步结果:确认扣减成功后才改存档。通知可能不带请求编号,同一玩家同一资源在确认前只允许一个待处理请求,并设置超时释放。
8. 持久化
- 每次业务变更后:更新内存状态 → 序列化为云档字符串 → 调用
MsSaveScriptArchive。 - 只在
READY状态保存(6.1 的迁移写入除外)。UNKNOWN、AWAIT_LEGACY、LOCKED状态一律不调用MsSaveScriptArchive。 - 持久化时机由平台决定(玩家离开或对局结束)。切图期间数据保存在云脚本内存中,不受影响。
- 已接受的风险:服务异常中断时,本局最近的变更可能丢失;扣道具的操作可能出现“道具已扣、数据未变”。处理方式:云脚本日志记录扣减确认和对应的数据变更,供人工补偿。
9. 残余风险
| 风险 | 影响 | 处理 |
|---|---|---|
| 方案 A 首次迁移时可上传伪造的满配存档 | 存量作弊数据在迁移时被固化 | 选择方案 B,或缩短迁移窗口 |
| 服务异常中断(含迁移当局) | 本局最近变更丢失;迁移当局未落盘则存档标记未写入 | 已接受;日志补偿,下次进入重新迁移 |
| 云档读取异常(已迁移玩家) | 本局进入 LOCKED,模块不生效 | 下次进入重新判定;云脚本告警 |
| 迁移后、首次读到有效云档前发生云档读取异常 | 存档标记尚未写入,按首次迁移重新导入,丢失迁移当局进度 | 已接受;窗口只有一局 |
| 存档标记被删除(篡改或清档) | 仅在碰上云档读取异常时重新导入;导入仍经过方案 A / B 清洗 | 下次读到有效云档时自动补写 |
| 云脚本房间启动失败或玩家未加入云脚本 | 本局收不到数据 | 本局模块不生效并提示 |
| 配置调整后旧云档不再通过结构校验 | 持有已下线配置的玩家被锁定 | 配置调整时同步发布云档升级规则(见 5.4) |
10. 地图改造范围
下表是模板,按你的地图替换成实际模块:
| 文件 / 模块 | 改造内容 |
|---|---|
| 存档模块(当前读写旧存档的地方) | 删除旧存档槽业务写入,只保留 cloud_migrated 标记写入;读取改为云端快照镜像;保留展示、效果计算和旧档只读导出;处理未收到快照的情况 |
| 操作界面 / 入口模块 | 操作改为发送意图,结果以云端快照为准 |
| 进入大厅 / 子地图初始化 | 主动拉取一次快照,收到后再启用该模块功能 |
| 玩家对象 / 统计模块 | 相关统计改读云端镜像;外部后台奖励不再修改该模块;清档名单是否保留旧存档槽在实现时确认 |
| 上报模块 | 统计数据上报改读云端镜像 |
| 云脚本(新增) | 迁移状态机(含“允许重新导入”名单)、业务状态机、存档结构校验、配置表 |
前置条件:在编辑器中为本地图启用云脚本并上传云脚本包。
11. 验证计划
以下以示例模块为例,字段与数值按你的定义替换。
11.1 存档格式与校验
- 合法云档经
cjson.encode保存、cjson.decode读取后内容一致。 - 槽位稀疏的表(如只有槽位 1、7、
<最大槽位>):使用字符串键能正常编解码;使用整数键会报excessively sparse array。 - JSON 损坏、缺少
schema或<module>、非法槽位、非法配置 ID、品质越界、负积分的云档:进入LOCKED,不读取旧存档、不自动重置、不保存。 - 含
null的云档按结构校验不通过处理,不会把cjson.null当成业务值。 - 满档的存档长度和快照消息长度均在限制内。
11.2 迁移
- 云档为空且旧档不含标记:方案 A / B 分别按规则导入,云档
<module>.imported置为true;本局不下发<module>_mark_migrated。 - 迁移后云档顶层只含
schema与<module>,且entries/loadout的键均为字符串。 - 方案 A / B 各产生一条迁移审计日志:包含清洗前后积分、物品数量、各品质数量、已装备数和命中规则;发生裁减时级别为
warn,且不含旧档原文和单件明细。 - 读到有效云档:下发
<module>_mark_migrated并写入标记,重复进入不重复写入;清档清掉后下次读取自动补写。 - 云档为空且旧档含
cloud_migrated(模拟云档读取异常):进入LOCKED,不导入、不保存,平台原记录不变。 - 迁移当局未落盘(模拟服务中断):下次进入时无存档标记,正常重新迁移。
- “允许重新导入”名单内的玩家:忽略存档标记,重新导入。
UNKNOWN、AWAIT_LEGACY、LOCKED状态下收到的该模块操作全部被拒绝。- 云脚本房间启动失败或超时未收到快照:本局模块不生效并提示。
- 已迁移后再次上传旧档:被拒绝;修改旧档后重进:云端数据不变。
- 畸形旧档(非法槽位、非法配置 ID、品质越界、负积分、调试字段):清洗结果符合规则。
11.3 业务
- 每个变动入口的成功与拒绝路径:未解锁、已装备、积分不足、次数用尽、道具不足。
- 扣道具入口:扣减确认前的重复请求被拒绝;确认后才修改存档。
- 切图往返:大厅 → 子地图 → 大厅,云端状态连续,快照正确。
- 随机:云脚本代码中没有
math.randomseed调用;大量抽样后的品质与配置 ID 分布符合权重。 - 多人房间:各客户端快照一致,各玩家数据互不覆盖。
11.4 环境说明
- 本地模拟服务中部分平台接口返回固定值(地图等级、游玩时长、是否正式环境等)。依赖这些值的解锁判断和方案 B 阈值需在线上环境验证。
- 持久化时机、异常中断表现和云档读取异常需在正式环境验证。
12. 回滚
- 云脚本回滚:上传上一版压缩包,对新开房间生效。云档格式未变时,已迁移玩家的数据仍可正常读取。
- 客户端回滚(最后手段):重新发布上一版地图。已迁移玩家会读到旧存档中迁移前的数据,迁移后的进度不在其中;回滚版本会恢复旧存档的写入,回滚期间的新进度不会同步到云档。这是已知代价。
- 单玩家恢复:清空该玩家的云档并删除存档标记后重新迁移(见 6.1)。

