契约先行(sdk-interface.d.ts+镜像 additive):白名单新增恰等键族 save:{gameId}:{versionId}(≤65536 码元,
写入必须 {schemaVersion≥1, data} 信封,宿主闸 fail-closed);idle: 族 4096 逐字不动;弃多 key 分片。
宿主闸抽纯函数模块 storageGate.ts(四闸合一可 node 直测);save-progress 插件 v1.0.0→v1.1.0 additive:
setVersioned/getVersioned 逐级迁移/缺级兜底/降档读新档不毁档/无版本旧档按 0 版进链+探针两计数。
证据:插件单测 18/18、game-runtime 31/31、宿主闸负向演示脚本入仓真跑全断言过、vue-tsc+vite build 绿。
三书 M 门影子存档降级口径解除条件就位(卡牌 M2/割草 M4/夜市 M5 切换)。设计页 docs/plans/2026-07-06-002。
6.9 KiB
W-SAVE-CAP:存档面定容 + schemaVersion
1. 问题与现状(一手核实)
现行 H5 存档面是为「无离线态模板、存档 <200B」设计的四道闸,真实现在 game-studio/src/host/GamePlayer.vue 的 handleStorage:闸① key 恰等 idle:{gameId}:{versionId} 才放行;闸② value.length > 4096 丢弃;闸③ 写前 JSON.parse 校验、回读只接受对象形态;闸④ 落盘加 wxgame: 前缀。游戏侧的 L2 save-progress 插件(P9)是通用 KV 面:命名空间 + JSON 序列化 + 单值 32KB(UTF-8 字节)上限 + 坏档容错,alpha 后端 = 本地 adapter,无任何版本字段。
北极星三款的复杂存档(夜市 save_state 多子树/卡牌 run+全局进度/割草 meta 进度)顶破 4KB,且无 schemaVersion 就没有迁移纪律——这是复杂游戏北极星件 §1.1.3 B 段坐实的前置增补需求,三书均以"三款共享基建单"引用本单:卡牌 M2 前硬需要(最早)、割草 M4、夜市 M5。
2. 方案对比与选定
方案A·单槽版本信封定容(选定):白名单新增一个恰等键 save:{gameId}:{versionId}(一游戏一版本一槽),该槽上限提到 65536 码元,写入必须是版本信封 {schemaVersion: 整数≥1, data: 纯对象},闸内 fail-closed。方案B·多 key 结构化分片(不选):按子树拆多个 key。A 胜在三点:单键单写是一致快照,B 的多键分写存在撕裂写(部分子树新、部分旧)且宿主闸没有事务;白名单保持恰等匹配纪律,不开前缀通配面;一信封一版本,迁移语义最简。B 只有在单槽超 65536 码元时才有必要——按三书首发定容(卡牌 run+meta、割草 meta、夜市 M5 前影子存档过渡)均在其下;若未来顶破,升级路是"按子树分槽 + 每槽各带信封"(additive,不推翻本单)。
上限值与两处口径:宿主闸沿现行 value.length 码元口径(localStorage 按 UTF-16 存储,65536 码元 ≈ 实占 128KB/槽;即便 20 个版本各一槽也仅 ~2.5MB,处于常见浏览器 5MB/源配额内——配额数字为公知量级,〔来源缺口:各浏览器精确配额未一手核〕,不作为验收判据);插件侧沿既有 UTF-8 字节口径,缺省 32768 字节不动(它是更紧的缺省内闸),复杂存档游戏经 maxValueBytes 上调,受宿主码元闸封顶。换算参考:全中文 JSON 一码元 ≈ 3 UTF-8 字节,插件缺省 32KB 字节 ≈ 1.1 万汉字。
idle: 键族完全不动:上限仍 4096、无信封要求,存量行为逐字保持。
3. schemaVersion 与迁移兜底(save-progress v1.1.0,additive)
插件新增两个方法,既有 get/set/remove/clear 签名不动:
setVersioned(key, schemaVersion, data):校验 schemaVersion 为 ≥1 整数、data 为纯对象(非数组),包成信封走既有 set 管道(序列化/字节闸/异常不外抛),返回 boolean。getVersioned(key, currentVersion, opts):opts = { migrators?: {[fromVersion]: (data)=>data}, defaultValue? }。行为矩阵——缺失 → defaultValue;信封版本 === current → 返 data;版本 < current → 从该版本起逐级跑 migrator(缺任一级或迁移函数抛错 → defaultValue + 告警 +versionFallbacks计数),迁移成功 →versionMigrations计数 + best-effort 回写新信封(回写失败不影响返回);版本 > current(降档读到新档)→ defaultValue + 告警 + 计数,不回写不毁档;旧档无版本(纯对象但无合法 schemaVersion)→ 视为{schemaVersion: 0, data: 原对象}进迁移链(提供 0→1 迁移器则迁,否则 defaultValue)。全部异常不外抛,守"存档绝不把游戏带崩"底线。
探针补 versionMigrations / versionFallbacks 两计数;manifest 1.0.0→1.1.0。
4. 契约先行(additive,semver 只增)
contracts/sdk-interface.d.ts 与镜像 game-studio/src/host/contract.ts(storage 三套形状之二;第三套 inject.ts 应答端只做 requestId 配对、不触 key/value 语义,零改动)各新增 SaveEnvelope 接口与 save: 键族语义注释:键族两员(idle: 4096 码元无信封 / save: 65536 码元强制信封),写入 payload 仍是 JSON 字符串、回读仍是对象|null,与现行读写形状零破坏。
5. 宿主闸实现形态
闸逻辑从 handleStorage 抽成纯函数模块 game-studio/src/host/storageGate.ts(key 门 + 写门:族判定/长度分层/JSON 校验/save: 信封校验,返回判定与拒因),GamePlayer.vue 写路径与读路径 key 判定改调它,idle: 行为与告警语义逐字等价。抽纯函数的唯一动机是可测性:game-studio 无测试跑器,纯函数可由 esbuild 转译后 node 直接断言(测的是真源文件,不是复制品)。
6. 失败模式与验收断言
失败模式:save: 写超 65536 码元 → 丢弃 + warn(游戏侧 fire-and-forget 不崩);写非信封/版本非法 → 丢弃 + warn;读到被篡改的非对象 → null(沿现行);插件读到坏 JSON/坏信封 → defaultValue + 计数;降档读新档 → defaultValue,原档保留。
验收断言(全部真跑,结果见执行记录):①插件单测(node --test):版本信封 roundtrip / 无版本旧档 0→1 迁移+回写 / 缺迁移器兜底 / 新于当前版本兜底不毁档 / 超限拒写计数;②宿主闸负向演示(esbuild 转译真源 + node 断言):idle 4096 边界行为不变 / save 合法信封放行 / 超限拒 / 坏信封拒 / 白名单外 key 拒;③game-runtime 全量 npm test + npm run validate(manifest 门)+ gz 预算核;④game-studio vue-tsc -b && vite build 编译门。
7. 三书 M 门降级口径的解除条件
本单落地(代码 merge 进 dev/2.0.0)即满足三书写的"正式 SDK Storage 定容 + schemaVersion"前置的存档面部分:卡牌 M2 入口门、割草 M4、夜市 M5 的切换对象从"影子存档"改为 save: 槽 + setVersioned/getVersioned。仍不解除的:跨 iframe storage 后端的 β 契约接线(save-progress alpha 后端是本地 adapter,postMessage 通道到插件 adapter 的接线是既有 β/第 9 契约组欠账,不属本单新增欠账——北极星游戏 M 门验收时走 SDK storage 通道即可,插件 adapter 接线随各款集成段做)与 O-6 版本语义全案(兼容检查器/意图重放)。
8. 待拍(不阻塞)
无必须即刻拍的项。一项预留:若某款单槽逼近 65536 码元(仿真灌满内容量表后实测),按 §2 升级路"按子树分槽"另切小单。