diff --git a/contracts/sdk-interface.d.ts b/contracts/sdk-interface.d.ts index 5245c898..72b092b5 100644 --- a/contracts/sdk-interface.d.ts +++ b/contracts/sdk-interface.d.ts @@ -108,11 +108,26 @@ export interface PostMessageEnvelope { /* ----- storage 消息 payload(idle 离线产出存档;HJ-MC-TPL-EXEC-002 §6.1.1,T1b-β 上提,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; +} + /** storage 写入请求 payload(游戏→宿主:保存键值) */ export interface StorageSetPayload { - /** 存档键(runtime 侧按 'idle:'+gameId+':'+versionId 约定,宿主侧白名单前缀校验) */ + /** 存档键(键族与上限见 SaveEnvelope 注释:'idle:'|'save:' + gameId + ':' + versionId,宿主侧白名单恰等校验) */ key: string; - /** 存档值(JSON 字符串,宿主侧落 localStorage 前校验大小上限) */ + /** 存档值(JSON 字符串,宿主侧落 localStorage 前按键族校验大小上限;save: 族还须为 SaveEnvelope 信封) */ value: string; } diff --git a/docs/architecture/03-产物执行沙箱图说.md b/docs/architecture/03-产物执行沙箱图说.md index 5c8b0d19..59dbf23d 100644 --- a/docs/architecture/03-产物执行沙箱图说.md +++ b/docs/architecture/03-产物执行沙箱图说.md @@ -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 类消息」。 diff --git a/docs/architecture/架构/产物执行沙箱.md b/docs/architecture/架构/产物执行沙箱.md index adfa6864..e1e7b135 100644 --- a/docs/architecture/架构/产物执行沙箱.md +++ b/docs/architecture/架构/产物执行沙箱.md @@ -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')`。 diff --git a/docs/plans/2026-07-06-002-feat-W-SAVE-CAP-存档定容与schemaVersion-plan.md b/docs/plans/2026-07-06-002-feat-W-SAVE-CAP-存档定容与schemaVersion-plan.md new file mode 100644 index 00000000..610ac085 --- /dev/null +++ b/docs/plans/2026-07-06-002-feat-W-SAVE-CAP-存档定容与schemaVersion-plan.md @@ -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 升级路"按子树分槽"另切小单。 diff --git a/game-runtime/SIZES.md b/game-runtime/SIZES.md index f521a96c..68ee6863 100644 --- a/game-runtime/SIZES.md +++ b/game-runtime/SIZES.md @@ -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)。 diff --git a/game-runtime/src/plugins/save-progress/api.d.ts b/game-runtime/src/plugins/save-progress/api.d.ts index a9f98a5b..d6b9b07f 100644 --- a/game-runtime/src/plugins/save-progress/api.d.ts +++ b/game-runtime/src/plugins/save-progress/api.d.ts @@ -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): boolean; + /** + * 【W-SAVE-CAP v1.1.0 additive】按版本信封读一个存档,带基本迁移兜底。行为矩阵: + * - 缺失/坏 JSON → `defaultValue`; + * - 信封版本 === currentVersion → 返回 data; + * - 版本 < currentVersion → 从该版本起逐级跑 `migrators[v]`(v→v+1);缺任一级、迁移函数抛错、 + * 或迁移产物非纯对象 → `defaultValue` + 告警 + versionFallbacks 计数;迁移成功 → + * versionMigrations 计数 + **best-effort 回写**迁移后信封(回写失败不影响返回); + * - 版本 > currentVersion(降档读到新档)→ `defaultValue` + 告警 + 计数,**不回写不毁档**; + * - 旧档无版本(纯对象但无合法 schemaVersion 字段)→ 视为 `{schemaVersion: 0, data: 原对象}` + * 进迁移链(提供 0→1 迁移器则迁,否则 `defaultValue`); + * - 其余形态(数组/标量/信封 data 非纯对象)按坏档 → `defaultValue` + 计数。 + * 全程异常不外抛,守「存档绝不把游戏带崩」底线。 + * @param key 业务键(不含前缀)。 + * @param currentVersion 当前代码期望的 schema 版本(整数 ≥1)。 + * @param opts 迁移器表与默认值。 + */ + getVersioned = Record>( + key: string, + currentVersion: number, + opts?: VersionedGetOptions + ): T | undefined; /** * 只读探针:返回插件内部状态快照(仅供测试/诊断观察,非玩法能力面,生产可删)。 */ readonly probe: () => SaveProgressProbeSnapshot; } +/** 【W-SAVE-CAP v1.1.0】getVersioned 的迁移配置。 */ +export interface VersionedGetOptions = Record> { + /** + * 迁移器表:`migrators[v]` 把 v 版 data 迁到 v+1 版(纯函数,入参出参均为纯对象)。 + * 旧档无版本按 0 版进链(需提供 0→1)。缺级即兜底 defaultValue,不做跳级猜测。 + */ + migrators?: Record) => Record>; + /** 缺失/坏档/迁移失败/降档读新档时的回退值(缺省 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; } /** diff --git a/game-runtime/src/plugins/save-progress/impl.js b/game-runtime/src/plugins/save-progress/impl.js index 584cc651..a9a7a09e 100644 --- a/game-runtime/src/plugins/save-progress/impl.js +++ b/game-runtime/src/plugins/save-progress/impl.js @@ -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 管道 + * (序列化/字节闸/异常不外抛)。信封语义对齐根契约 SaveEnvelope(contracts/sdk-interface.d.ts)。 + * @param {string} key 业务键(不含前缀) + * @param {number} schemaVersion 存档 schema 版本(整数 ≥1) + * @param {Record} 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} + */ +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 diff --git a/game-runtime/src/plugins/save-progress/manifest.json b/game-runtime/src/plugins/save-progress/manifest.json index c088b245..c6cc558b 100644 --- a/game-runtime/src/plugins/save-progress/manifest.json +++ b/game-runtime/src/plugins/save-progress/manifest.json @@ -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 后端=本地 adapter(host-dev 内存 Map / 浏览器 localStorage 不默认接线)。无玩法语义。" + "description": "键值持久化引擎能力:统一 get/set/remove/clear + 命名空间隔离 + JSON 序列化 + 32KB 上限守卫 + 损坏数据容错;v1.1.0 增 setVersioned/getVersioned 版本信封与逐级迁移兜底(W-SAVE-CAP)。alpha 后端=本地 adapter,不默认接线 localStorage。无玩法语义。" } diff --git a/game-runtime/src/plugins/save-progress/test/versioned.test.mjs b/game-runtime/src/plugins/save-progress/test/versioned.test.mjs new file mode 100644 index 00000000..14636100 --- /dev/null +++ b/game-runtime/src/plugins/save-progress/test/versioned.test.mjs @@ -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 版进迁移链(0→1)迁移成功 + 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); +}); diff --git a/game-studio/scripts/storage-gate-demo.mjs b/game-studio/scripts/storage-gate-demo.mjs new file mode 100644 index 00000000..3f504fd8 --- /dev/null +++ b/game-studio/scripts/storage-gate-demo.mjs @@ -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 }); +} diff --git a/game-studio/src/host/GamePlayer.vue b/game-studio/src/host/GamePlayer.vue index 62852b21..c4d4dda4 100644 --- a/game-studio/src/host/GamePlayer.vue +++ b/game-studio/src/host/GamePlayer.vue @@ -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:versionId;tycoon 无离线态不发 storage,白名单不含 tycoon:) - const allowedPrefix = 'idle:' + props.gameId + ':' + props.versionId; - if (!key || key !== allowedPrefix) { + // 闸①:key 白名单(恰等匹配两族 idle:/save: + gameId:versionId——W-SAVE-CAP 定容; + // 纯函数闸见 ./storageGate.ts;tycoon 无离线态不发 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):游戏→宿主,无 requestId(fire-and-forget) const value = typeof p.value === 'string' ? p.value : ''; - // 闸②:大小上限(4KB,防滥用 localStorage 配额;idle 存档实际 <200B) - if (value.length > 4096) { + // 闸②+③:按键族分层校验(idle: ≤4096 码元合法 JSON 存量不变; + // save: ≤65536 码元且必须 {schemaVersion≥1, 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 { diff --git a/game-studio/src/host/contract.ts b/game-studio/src/host/contract.ts index 4fcb7664..d65105d0 100644 --- a/game-studio/src/host/contract.ts +++ b/game-studio/src/host/contract.ts @@ -117,11 +117,25 @@ export interface PayResultPayload { /* ----- storage 消息 payload(idle 离线产出存档;HJ-MC-TPL-EXEC-002 §6.1.1,semver 只增不改) ----- */ +/** + * 存档键族与分层上限(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; +} + /** storage 写入请求 payload(游戏→宿主:保存键值) */ export interface StorageSetPayload { - /** 存档键(runtime 侧按 'idle:'+gameId+':'+versionId 约定,宿主侧白名单前缀校验) */ + /** 存档键(键族见 SaveEnvelope 注释:'idle:'|'save:' + gameId + ':' + versionId,宿主侧白名单恰等校验) */ key: string; - /** 存档值(JSON 字符串,宿主侧落 localStorage 前校验大小上限) */ + /** 存档值(JSON 字符串,宿主侧按键族校验大小上限;save: 族还须为 SaveEnvelope 信封) */ value: string; } diff --git a/game-studio/src/host/storageGate.ts b/game-studio/src/host/storageGate.ts new file mode 100644 index 00000000..0a1651b6 --- /dev/null +++ b/game-studio/src/host/storageGate.ts @@ -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 }; +}