feat(save-cap): W-SAVE-CAP 存档面定容+schemaVersion——单槽版本信封,北极星三款共享基建

契约先行(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。
This commit is contained in:
lili 2026-07-06 09:24:44 -07:00
parent 207bb79719
commit 2ce0ed3a9d
13 changed files with 605 additions and 39 deletions

View File

@ -108,11 +108,26 @@ export interface PostMessageEnvelope<T = unknown> {
/* ----- storage 消息 payloadidle 离线产出存档HJ-MC-TPL-EXEC-002 §6.1.1T1b-β 上提semver 只增不改) ----- */
/**
* W-SAVE-CAP 2026-07-06 additive宿
* - `idle:{gameId}:{versionId}`线 4096 value.length
* - `save:{gameId}:{versionId}` 65536 ****
* SaveEnvelope JSON 宿 fail-closed/
* key = UTF-16 宿 localStorage
* save-progress maxValueBytes UTF-8 32KB
*/
export interface SaveEnvelope {
/** 存档 schema 版本(整数 ≥1无版本旧档由读取侧按 0 处理进迁移链——见 save-progress getVersioned */
schemaVersion: number;
/** 存档正文(纯对象;数组/标量非法——与 StorageResultPayload 的「对象语义不含数组」一致) */
data: Record<string, unknown>;
}
/** storage 写入请求 payload游戏→宿主保存键值 */
export interface StorageSetPayload {
/** 存档键runtime 侧按 'idle:'+gameId+':'+versionId 约定,宿主侧白名单前缀校验) */
/** 存档键(键族与上限见 SaveEnvelope 注释:'idle:'|'save:' + gameId + ':' + versionId宿主侧白名单恰等校验) */
key: string;
/** 存档值JSON 字符串,宿主侧落 localStorage 前校验大小上限) */
/** 存档值JSON 字符串,宿主侧落 localStorage 前按键族校验大小上限save: 族还须为 SaveEnvelope 信封 */
value: string;
}

View File

@ -71,7 +71,7 @@ flowchart LR
这张类图回答「平台到底向不可信游戏注入了什么能力、又是怎么注入的」。结论是:iframe 内的游戏只能经一个全局对象 `window.WanxiangGameSDK`(加上引擎装载契约)与外界打交道,受控面之外一律被 CSP 和 sandbox 封死——没有这个 SDK,平台就只是个静态文件托管。图把受控面拆成 Core 层和 Plugin 层,并把另一条并列的入口(引擎装载契约)单列在右栏。
**Core 层内联进游戏入口、压缩后小于 8KB、首屏即在**,暴露 `init` / `on` / `track` / `reportError` / `ad` / `pay``init` 注入 `traceId` 贯穿「生成 → 运行 → 上报」全链路;`track` 是 10 条 / 5s 的批量缓冲、写类 fire-and-forget;`ad` / `pay` 经桥的 `request` 走 5s 超时降级。**Plugin 层按需懒加载、首屏不付代价**,其中 storage 这个面尤其要小心,因为它直接写宿主的真 localStorage——图上把它的四道闸列全了:闸①只放行恰好等于 `idle:gameId:versionId` 的 key(tycoon 这类无离线态的模板根本不发 storage、白名单也不含它的前缀),闸②挡 4KB 以上的 value(idle 存档实际不到 200B),闸③在写入前用 `JSON.parse` 校验、回读时只接受对象形态(防有人手工篡改 localStorage 注入脏数据),闸④落盘时加 `wxgame:` 前缀做命名空间隔离。这里有个设计点值得点出:回包给游戏的是宿主侧**已经解析好的对象**、不是原始字符串,解析在宿主这道受信边界做、runtime 侧零 `JSON.parse`,守住「游戏代码不引可抛路径」的红线。
**Core 层内联进游戏入口、压缩后小于 8KB、首屏即在**,暴露 `init` / `on` / `track` / `reportError` / `ad` / `pay``init` 注入 `traceId` 贯穿「生成 → 运行 → 上报」全链路;`track` 是 10 条 / 5s 的批量缓冲、写类 fire-and-forget;`ad` / `pay` 经桥的 `request` 走 5s 超时降级。**Plugin 层按需懒加载、首屏不付代价**,其中 storage 这个面尤其要小心,因为它直接写宿主的真 localStorage——图上把它的四道闸列全了:闸①只放行恰好等于 `idle:gameId:versionId` 的 key(tycoon 这类无离线态的模板根本不发 storage、白名单也不含它的前缀),闸②挡 4KB 以上的 value(idle 存档实际不到 200B),闸③在写入前用 `JSON.parse` 校验、回读时只接受对象形态(防有人手工篡改 localStorage 注入脏数据),闸④落盘时加 `wxgame:` 前缀做命名空间隔离。这里有个设计点值得点出:回包给游戏的是宿主侧**已经解析好的对象**、不是原始字符串,解析在宿主这道受信边界做、runtime 侧零 `JSON.parse`,守住「游戏代码不引可抛路径」的红线。W-SAVE-CAP 增补 2026-07-06:白名单增第二个恰等键族 `save:gameId:versionId`(复杂游戏主存档、北极星三款起用)——单槽 65536 码元、写入必须是 `{schemaVersion≥1, data:纯对象}` 版本信封 fail-closed;`idle:` 族逐字不变;闸逻辑抽纯函数 `game-studio/src/host/storageGate.ts`,细则见《产物执行沙箱》同日增补段与 W-SAVE-CAP plan。
整张图最该让人记住的是那条贯穿所有受控面的**降级铁律**:异常绝不向游戏抛、超时就降级、写类操作 fire-and-forget。它的根源不是工程洁癖,而是玩家验收门那五条 AND 里的「无错」——游戏运行期一旦抛出未捕获错误就判不过,所以受控面任何意外都得自己吞掉。右栏的引擎装载契约(`window.__GameBundle.bootGameHost` / `render(mainContext)` / `ctx.getEngine()`)是**与 SDK 并列的第二条受控入口**,但它属于另一条正交边界(详见图 5),图上特意提醒:软著 `ruanzhu-5` 和专利 `01-受控插件引擎` 写的是这条受控插件面、不是 iframe 产物沙箱,引用时别张冠李戴。图下方的 8 种 postMessage type 表是桥 schema 闸的枚举全集,把每个 type 的方向和用途列清,让人对照图 3 看清「双校验放行的到底是哪 8 类消息」。

View File

@ -125,6 +125,8 @@ storage 这个面尤其要小心,因为它直接写宿主的 localStorage。`Gam
- **闸③·JSON 校验**:写入前 `JSON.parse` 校验是合法 JSON 才落(:359-365);回读时也校验、且只接受对象形态(:382-385),防有人手工篡改 localStorage 注入脏数据。
- **闸④·命名空间隔离**:落盘时键加 `wxgame:` 前缀(:348),不污染其它 localStorage。
W-SAVE-CAP 增补 2026-07-06白名单增第二个恰等键族 `save:gameId:versionId`(复杂游戏主存档,北极星三款起用):单槽上限 65536 码元,写入值必须是 `{schemaVersion: 整数≥1, data: 纯对象}` 版本信封、闸内 fail-closed;`idle:` 族行为逐字不变。闸逻辑抽成纯函数 `game-studio/src/host/storageGate.ts`(可 node 直测),`handleStorage` 改为调它;本节原行号引用以现码为准。契约 = `contracts/sdk-interface.d.ts` SaveEnvelope;游戏侧配套 = save-progress 插件 v1.1.0 `setVersioned/getVersioned`(逐级迁移+降档不毁档兜底);设计与验收 = `docs/plans/2026-07-06-002-feat-W-SAVE-CAP-存档定容与schemaVersion-plan.md`
读分支有个设计点:回包给游戏的是**宿主侧已经解析好的对象**,不是原始字符串(:373-374)。解析在宿主这道受信边界做,runtime 侧零 `JSON.parse`,守住"游戏代码不引可抛路径"的红线。整个 `handleStorage` 任何意外异常都静默吞、不崩宿主(:391-394)。
ad / pay 的桩弹层(:402-464)按 `requestId` 配对回包,把广告 / 支付请求弹成一层桩 UI,玩家点完再把结果回包给游戏。生命周期 `handleLifecycle`(:294-319)把 `game_loaded` 翻成 `phase=ready` 并清掉加载超时,把 `game_end` 翻成 `emit('end')`

View File

@ -0,0 +1,54 @@
---
date: 2026-07-06
topic: W-SAVE-CAP 存档面定容与 schemaVersion
status: 设计+实现同单交付(创始人 2026-07-06 拍"立即独立切单");代码已落、测试见 §6
sot-impact: 不新建 canonical topic;触点 = 产物执行沙箱(storage 四道闸增 save: 键族,两份图说各加一句增补注记、原行号引用标"以现码为准")、contracts/sdk-interface.d.ts 与 game-studio/src/host/contract.ts(additive:SaveEnvelope 类型与 save: 键族注释,semver 只增)、save-progress 插件(v1.0.0→v1.1.0 additive 两方法);O-6 版本语义全案(意图重放/版本卡/兼容检查器)不在本单
上级: docs/mvp/MVP作战清单.md
---
# 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 升级路"按子树分槽"另切小单。

View File

@ -7,20 +7,20 @@
| 叠加项 | 累计 raw | 累计 gz | 增量 raw | 增量 gz | gz 预算 | 判定 |
|---|--:|--:|--:|--:|--:|:--|
| 基线壳 | 0 | 0 | — | — | — | 基准 |
| +core | 9327 | 3387 | 9327 | 3387 | — | — |
| +example | 9871 | 3630 | 544 | 243 | 2048 | ✔ ≤2048 |
| +audio-music | 15944 | 5702 | 6073 | 2072 | 12288 | ✔ ≤12288 |
| +collision | 20797 | 7733 | 4853 | 2031 | 8192 | ✔ ≤8192 |
| +gamefeel | 24714 | 8895 | 3917 | 1162 | 4096 | ✔ ≤4096 |
| +hud-ui | 29982 | 10709 | 5268 | 1814 | 4096 | ✔ ≤4096 |
| +palette-post | 34391 | 12290 | 4409 | 1581 | 8192 | ✔ ≤8192 |
| +particles-juice | 38652 | 13869 | 4261 | 1579 | 10240 | ✔ ≤10240 |
| +physics-lite | 41058 | 14788 | 2406 | 919 | 6144 | ✔ ≤6144 |
| +runtime-probe | 44014 | 16017 | 2956 | 1229 | 6144 | ✔ ≤6144 |
| +save-progress | 48054 | 17339 | 4040 | 1322 | 2048 | ✔ ≤2048 |
| +scene-fsm | 49383 | 17808 | 1329 | 469 | 3072 | ✔ ≤3072 |
| +session-score | 50304 | 18097 | 921 | 289 | 2048 | ✔ ≤2048 |
| +timer-scheduler | 51755 | 18557 | 1451 | 460 | 3072 | ✔ ≤3072 |
| +core | 9959 | 3655 | 9959 | 3655 | — | — |
| +example | 10503 | 3895 | 544 | 240 | 2048 | ✔ ≤2048 |
| +audio-music | 17029 | 6134 | 6526 | 2239 | 12288 | ✔ ≤12288 |
| +collision | 22253 | 8291 | 5224 | 2157 | 8192 | ✔ ≤8192 |
| +gamefeel | 26267 | 9496 | 4014 | 1205 | 4096 | ✔ ≤4096 |
| +hud-ui | 31560 | 11305 | 5293 | 1809 | 4096 | ✔ ≤4096 |
| +palette-post | 36467 | 12990 | 4907 | 1685 | 8192 | ✔ ≤8192 |
| +particles-juice | 40779 | 14575 | 4312 | 1585 | 10240 | ✔ ≤10240 |
| +physics-lite | 43285 | 15521 | 2506 | 946 | 6144 | ✔ ≤6144 |
| +runtime-probe | 46246 | 16736 | 2961 | 1215 | 6144 | ✔ ≤6144 |
| +save-progress | 52198 | 18656 | 5952 | 1920 | 2048 | ✔ ≤2048 |
| +scene-fsm | 53526 | 19126 | 1328 | 470 | 3072 | ✔ ≤3072 |
| +session-score | 54484 | 19450 | 958 | 324 | 2048 | ✔ ≤2048 |
| +timer-scheduler | 55937 | 19897 | 1453 | 447 | 3072 | ✔ ≤3072 |
合计插件 gz 增量 ≈ **15170B**(自律线 60K超 60K 须创始人显式放行,执行版 §5
合计插件 gz 增量 ≈ **16242B**(自律线 60K超 60K 须创始人显式放行,执行版 §5

View File

@ -98,12 +98,54 @@ export interface SaveProgressPlugin extends Plugin {
* @returns key 便/
*/
clear(): number;
/**
* W-SAVE-CAP v1.1.0 additive `{schemaVersion, data}` set
* // SaveEnvelope contracts/sdk-interface.d.ts
* - `schemaVersion` 1 `data` //null false
* @param key
* @param schemaVersion schema 1
* @param data
* @returns
*/
setVersioned(key: string, schemaVersion: number, data: Record<string, unknown>): boolean;
/**
* W-SAVE-CAP v1.1.0 additive
* - / JSON `defaultValue`
* - === currentVersion data
* - < currentVersion `migrators[v]`vv+1
* `defaultValue` + + versionFallbacks
* versionMigrations + **best-effort **
* - > currentVersion `defaultValue` + + ****
* - schemaVersion `{schemaVersion: 0, data: 原对象}`
* 01 `defaultValue`
* - // data `defaultValue` +
* 线
* @param key
* @param currentVersion schema 1
* @param opts
*/
getVersioned<T extends Record<string, unknown> = Record<string, unknown>>(
key: string,
currentVersion: number,
opts?: VersionedGetOptions<T>
): T | undefined;
/**
* /
*/
readonly probe: () => SaveProgressProbeSnapshot;
}
/** 【W-SAVE-CAP v1.1.0】getVersioned 的迁移配置。 */
export interface VersionedGetOptions<T extends Record<string, unknown> = Record<string, unknown>> {
/**
* `migrators[v]` v data v+1
* 0 01 defaultValue
*/
migrators?: Record<number, (data: Record<string, unknown>) => Record<string, unknown>>;
/** 缺失/坏档/迁移失败/降档读新档时的回退值(缺省 undefined。 */
defaultValue?: T;
}
/** P9 探针快照(仅测试/诊断用)。 */
export interface SaveProgressProbeSnapshot {
/** 当前生效命名空间。 */
@ -118,6 +160,10 @@ export interface SaveProgressProbeSnapshot {
corruptRecoveries: number;
/** 累计触发「超上限拒绝写入」的次数(诊断膨胀用)。 */
rejectedWrites: number;
/** 【W-SAVE-CAP v1.1.0】累计成功完成的版本迁移次数getVersioned 逐级迁移全程走通)。 */
versionMigrations: number;
/** 【W-SAVE-CAP v1.1.0】累计版本兜底次数(缺迁移器/迁移失败/降档读新档/坏信封 → 回退默认值)。 */
versionFallbacks: number;
}
/**

View File

@ -158,6 +158,8 @@ export function createSaveProgressPlugin(opts) {
// ── 诊断计数(探针用)──────────────────────────────────────────────────────
let corruptRecoveries = 0; // 坏档容错回退次数
let rejectedWrites = 0; // 超上限拒绝写入次数
let versionMigrations = 0; // 【W-SAVE-CAP v1.1.0】成功完成的版本迁移次数getVersioned 逐级走通)
let versionFallbacks = 0; // 【W-SAVE-CAP v1.1.0】版本兜底次数(缺迁移器/迁移失败/降档读新档/坏信封)
// 「坏档告警只发一次」的闸(防同一坏 key 反复读时刷屏;按 key 记一次更细,但本波从简:全局一次)。
let corruptWarned = false;
@ -194,7 +196,7 @@ export function createSaveProgressPlugin(opts) {
/** @type {SaveProgressPlugin} */
const plugin = {
name: 'save-progress',
version: '1.0.0',
version: '1.1.0',
dependencies: [],
/**
@ -306,6 +308,103 @@ export function createSaveProgressPlugin(opts) {
return removed;
},
/**
* W-SAVE-CAP v1.1.0按版本信封写存档 {schemaVersion, data} 走既有 set 管道
* 序列化/字节闸/异常不外抛信封语义对齐根契约 SaveEnvelopecontracts/sdk-interface.d.ts
* @param {string} key 业务键不含前缀
* @param {number} schemaVersion 存档 schema 版本整数 1
* @param {Record<string, unknown>} data 存档正文纯对象
* @returns {boolean} 是否写入成功
*/
setVersioned(key, schemaVersion, data) {
if (!isValidKey(key)) {
logWarn('[save-progress] setVersioned 收到非法 key拒绝写入' + String(key));
return false;
}
if (!isEnvelopeVersion(schemaVersion)) {
// 版本非 ≥1 整数:没有版本纪律的信封不允许落盘(与宿主 save: 闸 fail-closed 同口径)。
rejectedWrites += 1;
logWarn('[save-progress] setVersioned schemaVersion 非法(须 ≥1 整数),拒绝写入 key=' + key);
return false;
}
if (!isPlainObject(data)) {
rejectedWrites += 1;
logWarn('[save-progress] setVersioned data 非纯对象(数组/标量非法),拒绝写入 key=' + key);
return false;
}
return plugin.set(key, { schemaVersion, data });
},
/**
* W-SAVE-CAP v1.1.0按版本信封读存档带基本迁移兜底行为矩阵见 api.d.ts
* 全程异常不外抛坏档/缺迁移器/迁移失败/降档读新档一律回退默认值并计数绝不毁原档
* @param {string} key 业务键不含前缀
* @param {number} currentVersion 当前代码期望的 schema 版本整数 1
* @param {import('./api.d.ts').VersionedGetOptions} [opts] 迁移器表与默认值
* @returns {any} data当前版本 defaultValue
*/
getVersioned(key, currentVersion, opts) {
const options = opts || {};
const defaultValue = options.defaultValue;
if (!isValidKey(key) || !isEnvelopeVersion(currentVersion)) return defaultValue;
// 走既有 get缺失/坏 JSON 已在 get 内降级(返 undefined 哨兵区分「没档」与「有档」)。
const raw = plugin.get(key, undefined);
if (raw === undefined || raw === null) return defaultValue;
// 形态归一:合法信封 → 取版本与正文;旧档无版本(纯对象、无 schemaVersion 字段)→ 按 0 版进迁移链。
let fromVersion;
let data;
if (isPlainObject(raw) && isEnvelopeVersion(raw.schemaVersion) && isPlainObject(raw.data)) {
fromVersion = raw.schemaVersion;
data = raw.data;
} else if (isPlainObject(raw) && raw.schemaVersion === undefined) {
fromVersion = 0; // 旧档无版本:视为 {schemaVersion:0, data:原对象}
data = raw;
} else {
// 其余形态(数组/标量/信封 data 非纯对象/版本字段非法)按坏信封兜底。
versionFallbacks += 1;
logWarn('[save-progress] getVersioned 读到非法信封形态,回退默认值 key=' + key);
return defaultValue;
}
if (fromVersion === currentVersion) return data;
if (fromVersion > currentVersion) {
// 降档读到新档:回退默认值且**不回写不毁档**(升回新版本代码时原档仍在)。
versionFallbacks += 1;
logWarn(
'[save-progress] getVersioned 读到高于当前版本的存档(' + fromVersion + ' > ' + currentVersion +
'),回退默认值且不改动原档 key=' + key
);
return defaultValue;
}
// 逐级迁移 fromVersion → currentVersion缺任一级即兜底不做跳级猜测
const migrators = options.migrators || {};
let cur = data;
for (let v = fromVersion; v < currentVersion; v++) {
const fn = migrators[v];
if (typeof fn !== 'function') {
versionFallbacks += 1;
logWarn('[save-progress] getVersioned 缺 v' + v + '→v' + (v + 1) + ' 迁移器,回退默认值 key=' + key);
return defaultValue;
}
try {
cur = fn(cur);
} catch (e) {
// 迁移函数抛错:兜底默认值(错误路径留痕),原档不动。
versionFallbacks += 1;
logError('[save-progress] getVersioned 迁移器 v' + v + ' 抛错,回退默认值 key=' + key, e);
return defaultValue;
}
if (!isPlainObject(cur)) {
versionFallbacks += 1;
logWarn('[save-progress] getVersioned 迁移器 v' + v + ' 产物非纯对象,回退默认值 key=' + key);
return defaultValue;
}
}
versionMigrations += 1;
// best-effort 回写迁移后信封(下次读免迁移);回写失败不影响本次返回。
plugin.set(key, { schemaVersion: currentVersion, data: cur });
return cur;
},
/**
* 只读探针仅测试/诊断
* @returns {SaveProgressProbeSnapshot}
@ -318,6 +417,8 @@ export function createSaveProgressPlugin(opts) {
size: namespacedFullKeys().length,
corruptRecoveries,
rejectedWrites,
versionMigrations,
versionFallbacks,
};
},
};
@ -334,6 +435,25 @@ function isValidKey(key) {
return typeof key === 'string' && key.length > 0;
}
/**
* W-SAVE-CAP校验纯对象 null/非数组的 object信封正文与迁移产物的合法形态
* @param {*} v
* @returns {v is Record<string, unknown>}
*/
function isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
/**
* W-SAVE-CAP校验信封版本号合法整数 1旧档0 只在 getVersioned 内部归一时出现
* 不允许显式写入setVersioned 与宿主 save: 闸同口径 fail-closed
* @param {*} v
* @returns {boolean}
*/
function isEnvelopeVersion(v) {
return typeof v === 'number' && Number.isInteger(v) && v >= 1;
}
/**
* 校验注入的 adapter 形状合法四方法齐备不齐备则回退内置内存 adapter
* @param {*} a

View File

@ -1,6 +1,6 @@
{
"name": "save-progress",
"version": "1.0.0",
"version": "1.1.0",
"gzBudgetBytes": 2048,
"dependencies": [],
"exports": {
@ -12,5 +12,5 @@
"createLocalStorageAdapter"
]
},
"description": "键值持久化引擎能力:统一 get/set/remove/clear + 命名空间隔离 + JSON 序列化 + 32KB 上限守卫 + 损坏数据容错。alpha 后端=本地 adapterhost-dev 内存 Map / 浏览器 localStorage 不默认接线)。无玩法语义。"
"description": "键值持久化引擎能力:统一 get/set/remove/clear + 命名空间隔离 + JSON 序列化 + 32KB 上限守卫 + 损坏数据容错v1.1.0 增 setVersioned/getVersioned 版本信封与逐级迁移兜底W-SAVE-CAP。alpha 后端=本地 adapter不默认接线 localStorage。无玩法语义。"
}

View File

@ -0,0 +1,126 @@
/**
* save-progress/test/versioned.test.mjs W-SAVE-CAP v1.1.0 版本信封与迁移兜底单测(node --test 直跑)
* 运行:node --test src/plugins/save-progress/test/versioned.test.mjs
*
* 覆盖(W-SAVE-CAP plan §6 验收断言①):
* 1. setVersioned/getVersioned 信封 roundtrip(同版本直读);
* 2. 无版本旧档 0 版进迁移链(01)迁移成功 + best-effort 回写新信封 + versionMigrations 计数;
* 3. 缺迁移器 回退默认值 + versionFallbacks 计数 + 原档不动;
* 4. 降档读新档(存档版本 > 当前版本) 回退默认值 + 不回写不毁档;
* 5. 迁移函数抛错 回退默认值(异常不外抛);
* 6. 非法入参拒绝:setVersioned 版本 0/负数/小数data 为数组 false + 不落脏数据;
* 7. 超字节上限的版本信封写入被既有字节闸拒绝(rejectedWrites 计数)
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createSaveProgressPlugin, createMemoryAdapter } from '../impl.js';
/** 静音 console.warn/error 跑一段(兜底路径必然告警,避免污染测试输出),返回被测块结果。 */
function silenced(fn) {
const ow = console.warn;
const oe = console.error;
console.warn = () => {};
console.error = () => {};
try {
return fn();
} finally {
console.warn = ow;
console.error = oe;
}
}
test('1. 版本信封 roundtrip:setVersioned 落信封,getVersioned 同版本直读 data', () => {
const p = createSaveProgressPlugin({ namespace: 'ver.rt' });
const data = { coins: 42, unlocked: { a: true } };
assert.equal(p.setVersioned('slot', 3, data), true);
// 底层落的是完整信封(经既有 get 直读验证形状)。
const envelope = p.get('slot');
assert.deepEqual(envelope, { schemaVersion: 3, data });
// getVersioned 同版本直接返回 data,零迁移零兜底。
assert.deepEqual(p.getVersioned('slot', 3), data);
assert.equal(p.probe().versionMigrations, 0);
assert.equal(p.probe().versionFallbacks, 0);
});
test('2. 无版本旧档按 0 版进迁移链:0→1 迁移成功 + 回写新信封 + 计数', () => {
const adapter = createMemoryAdapter();
const p = createSaveProgressPlugin({ namespace: 'ver.legacy', adapter });
// 直接用既有 set 模拟"旧档":纯对象、无 schemaVersion 字段。
assert.equal(p.set('slot', { coins: 7 }), true);
const migrated = p.getVersioned('slot', 1, {
migrators: { 0: (d) => ({ ...d, gems: 0 }) }, // 0→1:补 gems 字段
defaultValue: { coins: 0, gems: 0 },
});
assert.deepEqual(migrated, { coins: 7, gems: 0 });
assert.equal(p.probe().versionMigrations, 1);
// best-effort 回写:再读免迁移(底层已是 v1 信封)。
assert.deepEqual(p.get('slot'), { schemaVersion: 1, data: { coins: 7, gems: 0 } });
assert.deepEqual(p.getVersioned('slot', 1), { coins: 7, gems: 0 });
assert.equal(p.probe().versionMigrations, 1); // 第二次读没有再迁移
});
test('3. 缺迁移器兜底:回退默认值 + versionFallbacks 计数 + 原档不动', () => {
const p = createSaveProgressPlugin({ namespace: 'ver.gap' });
assert.equal(p.setVersioned('slot', 1, { coins: 1 }), true);
const fallback = silenced(() =>
// 当前版本 3,只给了 1→2 缺 2→3:整链兜底,不做跳级猜测。
p.getVersioned('slot', 3, { migrators: { 1: (d) => d }, defaultValue: { coins: 0 } })
);
assert.deepEqual(fallback, { coins: 0 });
assert.equal(p.probe().versionFallbacks, 1);
// 原档保持 v1 不动(兜底绝不毁档)。
assert.deepEqual(p.get('slot'), { schemaVersion: 1, data: { coins: 1 } });
});
test('4. 降档读新档:回退默认值 + 不回写不毁档', () => {
const p = createSaveProgressPlugin({ namespace: 'ver.down' });
assert.equal(p.setVersioned('slot', 5, { coins: 99 }), true);
const got = silenced(() => p.getVersioned('slot', 2, { defaultValue: { coins: 0 } }));
assert.deepEqual(got, { coins: 0 });
assert.equal(p.probe().versionFallbacks, 1);
// 新档原样保留:升回 v5 代码后还能读到。
assert.deepEqual(p.getVersioned('slot', 5), { coins: 99 });
});
test('5. 迁移函数抛错:兜底默认值、异常不外抛、原档不动', () => {
const p = createSaveProgressPlugin({ namespace: 'ver.throw' });
assert.equal(p.setVersioned('slot', 1, { coins: 1 }), true);
const got = silenced(() =>
p.getVersioned('slot', 2, {
migrators: { 1: () => { throw new Error('迁移器炸了'); } },
defaultValue: { coins: -1 },
})
);
assert.deepEqual(got, { coins: -1 });
assert.equal(p.probe().versionFallbacks, 1);
assert.deepEqual(p.get('slot'), { schemaVersion: 1, data: { coins: 1 } });
});
test('6. 非法入参拒绝:版本非 ≥1 整数 / data 非纯对象 → false 不落脏数据', () => {
const p = createSaveProgressPlugin({ namespace: 'ver.bad' });
silenced(() => {
assert.equal(p.setVersioned('slot', 0, { a: 1 }), false); // 版本 0 非法(0 只在读侧归一旧档)
assert.equal(p.setVersioned('slot', -2, { a: 1 }), false); // 负数非法
assert.equal(p.setVersioned('slot', 1.5, { a: 1 }), false); // 小数非法
assert.equal(p.setVersioned('slot', 1, [1, 2]), false); // 数组非法(对象语义不含数组)
assert.equal(p.setVersioned('slot', 1, null), false); // null 非法
});
assert.equal(p.get('slot'), undefined); // 全部被拒,无脏数据
assert.equal(p.probe().rejectedWrites >= 4, true);
// 坏信封读兜底:底层被塞非信封形态(数组)时 getVersioned 回退默认值。
assert.equal(p.set('arr', [1, 2, 3]), true);
const got = silenced(() => p.getVersioned('arr', 1, { defaultValue: { ok: true } }));
assert.deepEqual(got, { ok: true });
assert.equal(p.probe().versionFallbacks >= 1, true);
});
test('7. 超字节上限的信封写入被既有字节闸拒绝', () => {
const p = createSaveProgressPlugin({ namespace: 'ver.size', maxValueBytes: 64 });
const before = p.probe().rejectedWrites;
const ok = silenced(() => p.setVersioned('slot', 1, { blob: 'x'.repeat(200) }));
assert.equal(ok, false);
assert.equal(p.probe().rejectedWrites, before + 1);
assert.equal(p.get('slot'), undefined);
});

View File

@ -0,0 +1,83 @@
/**
* storage-gate-demo.mjs 宿主 storage 闸负向演示(W-SAVE-CAP,测真源非复制品)
*
* game-studio 无测试跑器,本脚本用 game-runtime esbuild 把真源 src/host/storageGate.ts
* 转译成临时 ESM 后由 node 断言运行(仓根):
* node game-studio/scripts/storage-gate-demo.mjs
* 断言面:idle 4096 边界行为不变 / save 合法信封放行 / 超限拒 / 坏信封拒 / 白名单外 key
*/
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import assert from 'node:assert/strict';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..', '..');
const src = join(repoRoot, 'game-studio', 'src', 'host', 'storageGate.ts');
const esbuild = join(repoRoot, 'game-runtime', 'node_modules', '.bin', 'esbuild');
const outDir = mkdtempSync(join(tmpdir(), 'storage-gate-'));
const outFile = join(outDir, 'storageGate.mjs');
try {
// esbuild 纯转译(strip 类型),产物即真源逻辑。
execFileSync(esbuild, [src, '--format=esm', `--outfile=${outFile}`], { stdio: 'inherit' });
const gate = await import(pathToFileURL(outFile).href);
const { gateStorageKey, gateStorageWrite, IDLE_MAX_CHARS, SAVE_MAX_CHARS } = gate;
const G = 'g1', V = 'v1';
const idleKey = `idle:${G}:${V}`;
const saveKey = `save:${G}:${V}`;
// ① idle 存量行为:恰等 key 放行、4096 边界(=过 / >拒)、坏 JSON 拒、落盘键带 wxgame: 前缀。
assert.equal(gateStorageKey(idleKey, G, V).ok, true);
assert.equal(gateStorageKey(idleKey, G, V).storageKey, 'wxgame:' + idleKey);
const okIdle = gateStorageWrite(idleKey, JSON.stringify({ a: 1 }), G, V);
assert.equal(okIdle.ok, true);
const at4096 = '"' + 'x'.repeat(IDLE_MAX_CHARS - 2) + '"'; // 恰 4096 码元的合法 JSON 字符串
assert.equal(at4096.length, IDLE_MAX_CHARS);
assert.equal(gateStorageWrite(idleKey, at4096, G, V).ok, true); // 边界=过(沿现行 > 判定)
assert.equal(gateStorageWrite(idleKey, at4096 + 'x', G, V).ok, false); // 超一码元=拒
assert.equal(gateStorageWrite(idleKey, '{bad json', G, V).ok, false); // 坏 JSON=拒
// idle 不要求信封:任意合法 JSON 对象即可(存量语义)。
assert.equal(gateStorageWrite(idleKey, JSON.stringify({ noEnvelope: 1 }), G, V).ok, true);
// ② save 族:合法信封放行;非信封 fail-closed;上限 65536。
const env = JSON.stringify({ schemaVersion: 1, data: { coins: 1 } });
const okSave = gateStorageWrite(saveKey, env, G, V);
assert.equal(okSave.ok, true);
assert.equal(okSave.family, 'save');
assert.equal(okSave.storageKey, 'wxgame:' + saveKey);
// 坏信封五连拒:无版本 / 版本 0 / 版本小数 / data 数组 / 顶层数组。
for (const bad of [
JSON.stringify({ data: { a: 1 } }),
JSON.stringify({ schemaVersion: 0, data: { a: 1 } }),
JSON.stringify({ schemaVersion: 1.5, data: { a: 1 } }),
JSON.stringify({ schemaVersion: 1, data: [1, 2] }),
JSON.stringify([{ schemaVersion: 1, data: {} }]),
]) {
assert.equal(gateStorageWrite(saveKey, bad, G, V).ok, false, 'save 坏信封必须拒:' + bad.slice(0, 40));
}
// 上限:恰 65536 过 / 超一码元拒(用信封内填充串控制总长)。
const padTo = (total) => {
const shell = JSON.stringify({ schemaVersion: 1, data: { p: '' } });
return JSON.stringify({ schemaVersion: 1, data: { p: 'x'.repeat(total - shell.length) } });
};
const at65536 = padTo(SAVE_MAX_CHARS);
assert.equal(at65536.length, SAVE_MAX_CHARS);
assert.equal(gateStorageWrite(saveKey, at65536, G, V).ok, true);
assert.equal(gateStorageWrite(saveKey, padTo(SAVE_MAX_CHARS + 1), G, V).ok, false);
// save 的大值对 idle 族同样超限(4096 闸不动)。
assert.equal(gateStorageWrite(idleKey, at65536, G, V).ok, false);
// ③ 白名单外一律拒:错前缀 / 错 gameId / 前缀通配尝试 / 空 key。
for (const badKey of ['tycoon:' + G + ':' + V, 'save:other:' + V, saveKey + ':extra', 'idle:' + G, '']) {
assert.equal(gateStorageKey(badKey, G, V).ok, false, 'key 必须拒:' + badKey);
}
console.log('storage-gate-demo:全部断言通过(idle 存量行为不变 / save 信封 fail-closed / 分层上限 / 白名单恰等)');
} finally {
rmSync(outDir, { recursive: true, force: true });
}

View File

@ -31,6 +31,7 @@ import {
type RuntimePackageRespVO,
} from './contract';
import { HostBridge, buildOriginAllowlist, type RejectReason } from './bridge';
import { gateStorageKey, gateStorageWrite } from './storageGate';
import { buildIframeSrcdoc, buildDemoPackage, fetchAndVerifyManifest } from './inject';
import { getRuntimePackage } from './runtimeApi';
@ -338,29 +339,29 @@ function handleStorage(payload: unknown, direction: MessageDirection, requestId?
try {
const p = payload as { key?: unknown; value?: unknown };
const key = typeof p.key === 'string' ? p.key : '';
// key idle idle:gameId:versionIdtycoon 线 storage tycoon:
const allowedPrefix = 'idle:' + props.gameId + ':' + props.versionId;
if (!key || key !== allowedPrefix) {
// key idle:/save: + gameId:versionIdW-SAVE-CAP
// ./storageGate.tstycoon 线 storage
const keyGate = gateStorageKey(key, props.gameId, props.versionId);
if (!keyGate.ok || !keyGate.storageKey) {
// eslint-disable-next-line no-console
console.warn('[GamePlayer] storage key 不在白名单,丢弃', { key });
console.warn('[GamePlayer] storage key 不在白名单,丢弃', { key, reason: keyGate.reason });
return;
}
const storageKey = 'wxgame:' + key; // 宿wxgame: localStorage
const storageKey = keyGate.storageKey; // 宿wxgame: localStorage
if (direction === 'game_to_host' && !requestId) {
// set宿 requestIdfire-and-forget
const value = typeof p.value === 'string' ? p.value : '';
// 4KB localStorage idle <200B
if (value.length > 4096) {
// +idle: 4096 JSON
// save: 65536 {schemaVersion1, data:} fail-closed
const writeGate = gateStorageWrite(key, value, props.gameId, props.versionId);
if (!writeGate.ok) {
// eslint-disable-next-line no-console
console.warn('[GamePlayer] storage value 超 4KB 上限,丢弃', { len: value.length });
return;
}
// JSON JSON
try {
JSON.parse(value);
} catch {
// eslint-disable-next-line no-console
console.warn('[GamePlayer] storage value 非合法 JSON丢弃');
console.warn('[GamePlayer] storage 写入被闸拒绝,丢弃', {
key,
family: writeGate.family,
reason: writeGate.reason,
len: value.length,
});
return;
}
try {

View File

@ -117,11 +117,25 @@ export interface PayResultPayload {
/* ----- storage 消息 payloadidle 离线产出存档HJ-MC-TPL-EXEC-002 §6.1.1semver 只增不改) ----- */
/**
* W-SAVE-CAP 2026-07-06 additive contracts/sdk-interface.d.ts
* 宿
* - `idle:{gameId}:{versionId}`线 4096
* - `save:{gameId}:{versionId}` 65536
* SaveEnvelope JSON 宿 fail-closed
*/
export interface SaveEnvelope {
/** 存档 schema 版本(整数 ≥1无版本旧档由读取侧按 0 处理进迁移链) */
schemaVersion: number;
/** 存档正文(纯对象;数组/标量非法——与 StorageResultPayload「对象语义不含数组」一致 */
data: Record<string, unknown>;
}
/** storage 写入请求 payload游戏→宿主保存键值 */
export interface StorageSetPayload {
/** 存档键runtime 侧按 'idle:'+gameId+':'+versionId 约定,宿主侧白名单前缀校验) */
/** 存档键(键族见 SaveEnvelope 注释:'idle:'|'save:' + gameId + ':' + versionId宿主侧白名单恰等校验) */
key: string;
/** 存档值JSON 字符串,宿主侧落 localStorage 前校验大小上限) */
/** 存档值JSON 字符串,宿主侧按键族校验大小上限save: 族还须为 SaveEnvelope 信封 */
value: string;
}

View File

@ -0,0 +1,105 @@
/**
* storageGate.ts 宿 storage (W-SAVE-CAP 2026-07-06 )
*
* GamePlayer.vue `handleStorage` (key )(/),
* 唯一动机是可测性:game-studio , esbuild node ()
* (wxgame: 前缀命名空间隔离) storageKey
*
* ( ./contract.ts SaveEnvelope; contracts/sdk-interface.d.ts )
* - `idle:{gameId}:{versionId}`:线4096 JSON
* - `save:{gameId}:{versionId}`:()65536 ,
* {schemaVersion: 整数1, data: 纯对象} ,fail-closed()
* (), key
* = UTF-16 (value.length), localStorage
*/
/** idle: 族单槽上限(码元;存量口径,不动) */
export const IDLE_MAX_CHARS = 4096;
/** save: 族单槽上限(码元;65536 码元 ≈ localStorage 实占 128KB/槽) */
export const SAVE_MAX_CHARS = 65536;
/** 存档键族 */
export type StorageKeyFamily = 'idle' | 'save';
/** key 门判定结果 */
export interface StorageKeyGate {
ok: boolean;
/** 命中的键族(ok=true 时有) */
family?: StorageKeyFamily;
/** 闸④:落盘键(wxgame: 前缀命名空间隔离;ok=true 时有) */
storageKey?: string;
/** 拒因(ok=false 时有;中文,直接可入告警日志) */
reason?: string;
}
/** 写门判定结果(含 key 门) */
export interface StorageWriteGate {
ok: boolean;
family?: StorageKeyFamily;
storageKey?: string;
reason?: string;
}
/**
* +:key ()
* @param key
* @param gameId id(宿 props,)
* @param versionId id(宿 props,)
*/
export function gateStorageKey(key: string, gameId: string, versionId: string): StorageKeyGate {
if (!key) return { ok: false, reason: 'key 为空' };
const idleKey = 'idle:' + gameId + ':' + versionId;
const saveKey = 'save:' + gameId + ':' + versionId;
if (key === idleKey) return { ok: true, family: 'idle', storageKey: 'wxgame:' + key };
if (key === saveKey) return { ok: true, family: 'save', storageKey: 'wxgame:' + key };
return { ok: false, reason: 'key 不在白名单(恰等两族:idle:/save: + gameId:versionId)' };
}
/**
* save: 族版本信封形态校验(,): + schemaVersion 1 + data ()
* SaveEnvelope ; !Array.isArray
*/
export function isSaveEnvelope(parsed: unknown): boolean {
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
const env = parsed as { schemaVersion?: unknown; data?: unknown };
if (typeof env.schemaVersion !== 'number' || !Number.isInteger(env.schemaVersion) || env.schemaVersion < 1) {
return false;
}
return env.data !== null && typeof env.data === 'object' && !Array.isArray(env.data);
}
/**
* 合一的写门:key JSON (save: )
* ok=false + ;(JSON.parse try-catch)
* @param key
* @param value (JSON )
* @param gameId id
* @param versionId id
*/
export function gateStorageWrite(
key: string,
value: string,
gameId: string,
versionId: string,
): StorageWriteGate {
const keyGate = gateStorageKey(key, gameId, versionId);
if (!keyGate.ok) return keyGate;
const family = keyGate.family as StorageKeyFamily;
// 闸②:按键族分层大小上限(码元口径;idle 4096 存量不动 / save 65536 定容)
const maxChars = family === 'save' ? SAVE_MAX_CHARS : IDLE_MAX_CHARS;
if (value.length > maxChars) {
return { ok: false, family, reason: '超上限(' + value.length + ' > ' + maxChars + ' 码元)' };
}
// 闸③:JSON 合法性(解析失败丢弃,不落脏数据;解析异常在此吞掉,保宿主"任何异常不崩"铁律)
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return { ok: false, family, reason: '非合法 JSON' };
}
// 闸③(save: 增强):必须是版本信封,fail-closed——没有版本纪律的复杂存档不允许落盘
if (family === 'save' && !isSaveEnvelope(parsed)) {
return { ok: false, family, reason: '非法版本信封(须 {schemaVersion:整数≥1, data:纯对象})' };
}
return { ok: true, family, storageKey: keyGate.storageKey };
}