• 产品手册
  • 编辑器功能手册
  • 云脚本
  • 存档迁移方案

存档迁移云脚本方案

说明:地图脚本存档由客户端脚本计算并通过 SaveData 等接口写入,运行时保存在游戏内存,最终落库在平台数据库。

下文的“物品系统”是示例模块,<...> 是占位符;替换成你自己的模块、槽位、字段和阈值即可。


1. 结论

  1. 旧存档的内容由客户端写入,可以被伪造或篡改,不再作为可信数据。迁移后,云脚本存档是该模块数据的唯一权威来源,地图客户端只保留只读镜像。
  2. 改动只涉及两部分:地图 Lua 和本地图的云脚本 Lua。
  3. 云档只由云脚本写入,云脚本即权威,因此不做签名。服务端校验只负责:读取时校验结构、保证旧档只迁移一次、在服务端生成随机结果。它不能证明旧存档是否作弊得来,也不能替代业务校验。
  4. 客户端应该只发送“操作意图”给云脚本。数值增减、随机结果和每日计数都由云脚本计算。
  5. 旧存档迁移提供两套方案:方案 A 既往不咎(只做结构合规化)、方案 B 有界清洗(阈值可配置)。
  6. 迁移状态由云档迁移标记和旧存档槽的 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 / MsSaveScriptArchive

5. 云存档格式与校验

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 会把 JSON null 解成 cjson.null,不是 Lua nil。
  • 超过 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,可重试
  1. _playerjoin 时读取云档,进入 UNKNOWN;判定完成前拒绝该玩家所有该模块操作。
  2. 云档有效:进入 READY,下发快照和 <module>_mark_migrated。客户端只在标记不为 true 时写入;被清档清掉的标记会在下次读取时自动补写。
  3. 云档为空:进入 AWAIT_LEGACY,下发 <module>_req_legacy。客户端只读一次 <旧存档槽>(如 player:get_save_data_table(<旧存档槽>)),复制成普通 Lua 表(含 cloud_migrated)后上传。
  4. 只在 AWAIT_LEGACY 且请求后的短窗口内接受上传:含 cloud_migrated → LOCKED;不含则按方案 A / B 清洗、写入 <module>.imported = true、保存、进入 READY 并下发快照(本局不下发标记事件)。
  5. 旧档为空按新玩家初始化并保存;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)。