From f82d480dfd04a47dbd292b5ea21dd1aca63b32c8 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 10 Jun 2026 16:46:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(host-storage):=20M-c=E6=89=B9=E2=91=A1=20i?= =?UTF-8?q?dle=E7=A6=BB=E7=BA=BF=E5=AD=98=E6=A1=A3=E4=B8=89=E7=AB=AF?= =?UTF-8?q?=E2=80=94=E2=80=94contract=20storage=20payload+GamePlayer?= =?UTF-8?q?=E5=8F=97=E4=BF=A1=E8=BE=B9=E7=95=8C=E5=9B=9B=E9=81=93=E9=97=B8?= =?UTF-8?q?+inject=E6=B3=A8=E5=85=A5=E4=BE=A7=E5=BA=94=E7=AD=94=E7=AB=AF;?= =?UTF-8?q?=E4=B8=BBagent=E9=80=AE=E4=BF=AEloadState=E4=B8=89=E7=AB=AF?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E9=94=99=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contract.ts:+3 storage payload interface;GamePlayer handleStorage(键白名单idle:/4KB上限/JSON校验/wxgame:隔离);inject mountStorageBridge(requestId→resolve映射,5s超时resolve null绝不reject,幂等防重挂)+包装runtimeView传startRuntime;不触sdk/(Core红线),demo锚不动 - 【主agent核验逮修·承重接口】loadState三端协议错配:runtime自裁object签名(避§5.5零解析红线)但GamePlayer/inject照spec原草案回string→runtime读字段全undefined→离线补发恒0静默失效。统一到object(宿主受信边界解析):contract value string→Record(批②新增契约无存量消费者,同波定稿不破semver)+GamePlayer读分支接住parse结果回包对象+inject:130 resolve对象。runtime内部saveState/loadState字段(v/resource/autoYield/upgraded/leftAtMs)闭环自洽 HJ-MC-TPL-EXEC-002 §6.1/§6.2 Co-Authored-By: Claude Opus 4.8 --- game-studio/src/host/GamePlayer.vue | 76 ++++++++++++++++++++++++++++- game-studio/src/host/contract.ts | 28 +++++++++++ game-studio/src/host/inject.ts | 70 +++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 2 deletions(-) diff --git a/game-studio/src/host/GamePlayer.vue b/game-studio/src/host/GamePlayer.vue index 7b3c6c2c..6621af94 100644 --- a/game-studio/src/host/GamePlayer.vue +++ b/game-studio/src/host/GamePlayer.vue @@ -22,6 +22,7 @@ import { type GamePlayerProps, type GamePackage, type PostMessageEnvelope, + type MessageDirection, type LifecyclePayload, type TelemetryPayload, type ErrorPayload, @@ -247,8 +248,11 @@ function handleEnvelope(env: PostMessageEnvelope): void { case 'pay': handlePayRequest(env.payload as PayRequestPayload, env.requestId); break; + case 'storage': + handleStorage(env.payload, env.direction, env.requestId); + break; default: - // social/storage 等骨架阶段不处理(留契约挂点,不私造行为) + // social 等骨架阶段不处理(留契约挂点,不私造行为) break; } } @@ -288,6 +292,76 @@ function handleReject(reason: RejectReason, _raw: unknown, origin: string): void console.warn('[GamePlayer] postMessage 被拒绝并丢弃', { reason, origin }); } +/* ============================================================================ + * storage 受信边界(idle 离线产出存档;HJ-MC-TPL-EXEC-002 §6.1) + * ========================================================================== */ + +/** + * storage 消息处理(idle 离线产出存档,HJ-MC-TPL-EXEC-002 §6.1)。 + * 宿主受信边界:键白名单 + 大小上限 + JSON 解析 try-catch + key 隔离落 localStorage。 + * runtime 侧不引可抛路径(守 player 五条 AND 之④「无错」);本函数任何异常静默吞、不崩宿主。 + */ +function handleStorage(payload: unknown, direction: MessageDirection, requestId?: string): void { + 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) { + // eslint-disable-next-line no-console + console.warn('[GamePlayer] storage key 不在白名单,丢弃', { key }); + return; + } + const storageKey = 'wxgame:' + key; // 闸④:宿主侧命名空间隔离(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) { + // 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,丢弃'); + return; + } + try { + localStorage.setItem(storageKey, value); + } catch { + /* 配额满/隐私模式:静默降级,不崩 */ + } + } else { + // 读取(get):游戏→宿主(带 requestId)→ 宿主回包 storage 结果 + // 回包 = 宿主受信边界已解析好的对象(非原始字符串):解析在此(受信边界)做, + // runtime 侧零 JSON.parse(守 runtime §5.5 红线);contract StorageResultPayload.value=对象|null。 + let value: Record | null = null; + try { + const raw = localStorage.getItem(storageKey); + // 闸③:回读校验 JSON 合法性(防手工篡改 localStorage 注入脏数据)+ 接住解析结果回包对象 + if (raw !== null) { + const parsed = JSON.parse(raw); + // 仅接受对象形态(防被篡改成数组/标量后 runtime 读字段出错);非对象按无存档处理 + value = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } + } catch { + value = null; // 解析失败/读失败(隐私模式 localStorage 不可用)→ null(runtime 侧按无存档处理) + } + bridge?.post('storage', { key, value }, traceId.value, requestId); // 回包(host_to_game,value=对象|null) + } + } catch (e) { + // 受信边界铁律:任何意外异常静默吞,绝不向上抛、不崩宿主(守五条 AND 之④) + // eslint-disable-next-line no-console + console.warn('[GamePlayer] handleStorage 异常(已吞)', e); + } +} + /* ============================================================================ * ad/pay 桩:弹层 UI + requestId 配对回包 * ========================================================================== */ diff --git a/game-studio/src/host/contract.ts b/game-studio/src/host/contract.ts index 09becdab..418ec100 100644 --- a/game-studio/src/host/contract.ts +++ b/game-studio/src/host/contract.ts @@ -115,6 +115,34 @@ export interface PayResultPayload { paid: boolean; } +/* ----- storage 消息 payload(idle 离线产出存档;HJ-MC-TPL-EXEC-002 §6.1.1,semver 只增不改) ----- */ + +/** storage 写入请求 payload(游戏→宿主:保存键值) */ +export interface StorageSetPayload { + /** 存档键(runtime 侧按 'idle:'+gameId+':'+versionId 约定,宿主侧白名单前缀校验) */ + key: string; + /** 存档值(JSON 字符串,宿主侧落 localStorage 前校验大小上限) */ + value: string; +} + +/** storage 读取请求 payload(游戏→宿主:按 key 取值;带 requestId,宿主 handleStorage 读分支按同 requestId 回包,iframe 侧 inject 应答端 §6.2.1 按 requestId resolve) */ +export interface StorageGetPayload { + key: string; +} + +/** storage 读取结果 payload(宿主→游戏:回包 value,无则 null) */ +export interface StorageResultPayload { + key: string; + /** + * 命中的存档值 = 宿主受信边界已解析好的对象(非原始 JSON 字符串);未命中/校验失败为 null。 + * 解析在宿主侧做:runtime 侧零 JSON.parse(守 runtime §5.5 红线「runtime 内不引可抛解析路径」), + * loadState resolve 出对象后 runtime 直接读字段。写入 payload(StorageSetPayload.value)仍是 JSON 字符串。 + * 注:storage 系批② 新增契约(HJ-MC-TPL-EXEC-002 §6.1.1),无存量消费者;本波内由草案 string 定稿为 + * 对象(消解 §6.2 草案 string 签名与 runtime §5.5 零解析红线的矛盾),不触发 semver 破坏。 + */ + value: Record | null; +} + /* ========================================================================== * 二、GamePackage 类型(对齐契约 #4 game-package.schema.json) * 供 Runtime 按 manifest/assets 加载,供宿主透传 manifest 给 iframe。 diff --git a/game-studio/src/host/inject.ts b/game-studio/src/host/inject.ts index f5aa0f7e..824cf31f 100644 --- a/game-studio/src/host/inject.ts +++ b/game-studio/src/host/inject.ts @@ -103,6 +103,72 @@ export function buildIframeSrcdoc( // ===== 注入 Runtime 并启动 ===== var startRuntime = ${runtimeFactorySrc}; + // ===== idle 离线存档:iframe 侧 storage 应答端(HJ-MC-TPL-EXEC-002 §6.2.1)===== + // 承重端落 inject 引导内(非 SDK Core):iframe 内无 bridge 对象,只能用本引导自建的 + // postToHost/onHostMessage 两原语(见上)。沿 SDK 内 ad/pay 的 pendingCallbacks 请求-应答范式: + // 自管一套 requestId→resolve 映射,loadState 发请求登记 resolve、5s 超时 resolve(null) 绝不 reject。 + var storagePending = {}; // requestId → { resolve, timer }(iframe 侧请求-应答配对表) + var storageMounted = false; // 幂等防重挂守卫(onHostMessage 处理器只挂一次) + + function genStorageReqId(){ // 生成 requestId(沙箱可能无 crypto.randomUUID,降级时间戳+随机) + try { var c = (typeof crypto!=='undefined') ? crypto : null; + if (c && typeof c.randomUUID === 'function') return c.randomUUID(); } catch(e){} + return 's_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); + } + + function mountStorageBridge(){ + if (storageMounted) return; // 幂等:只挂一次,防重复注册造重复 resolve + storageMounted = true; + // 消费宿主 storage 回包(host_to_game 同 requestId)→ 找 resolve 兑现并清表清定时器 + onHostMessage(function(type, payload, requestId){ + if (type !== 'storage' || !requestId) return; // 只认带 requestId 的 storage 回包 + var entry = storagePending[requestId]; + if (!entry) return; // 无对应请求(超时已清/重复回包)忽略 + clearTimeout(entry.timer); delete storagePending[requestId]; + try { + var v = (payload && typeof payload === 'object') ? payload.value : null; + // 回包 value = 宿主受信边界已解析好的对象(非字符串):解析在宿主侧做,inject 仅透传对象; + // 仅接受对象形态,其余(含字符串/数组/标量)→ null,runtime 按无存档处理(守 runtime §5.5 零解析)。 + entry.resolve(v && typeof v === 'object' && !Array.isArray(v) ? v : null); + } catch(e){ try{ entry.resolve(null); }catch(_){} } // 兑现异常兜底 null,绝不抛 + }); + } + + // 注入给 Runtime 的 storage 视图(fire-and-forget 写 + Promise 化读): + // saveState:发 storage 写消息(game_to_host,无 requestId),宿主 handleStorage 写入分支落 localStorage(§6.1.2) + // loadState:发 storage 读请求(game_to_host 带 requestId)→ 登记 resolve;5s 超时 resolve(null) 绝不 reject + function makeStorageView(){ + mountStorageBridge(); + return { + saveState: function(key, value){ + try { postToHost('storage', { key: key, value: value }); } // 无 requestId = 写(set) + catch(e){ /* 写失败静默吞,runtime 侧不引可抛路径(守五条 AND 之④)*/ } + }, + loadState: function(key){ + return new Promise(function(resolve){ + var rid; + try { rid = genStorageReqId(); } catch(e){ resolve(null); return; } + // 5s 超时兜底:到点强制 resolve(null) 并清表(绝不 reject、不阻塞 runtime) + var timer = setTimeout(function(){ delete storagePending[rid]; try{ resolve(null); }catch(_){} }, 5000); + storagePending[rid] = { resolve: resolve, timer: timer }; + try { postToHost('storage', { key: key }, rid); } // 带 requestId = 读(get) + catch(e){ clearTimeout(timer); delete storagePending[rid]; resolve(null); } // 发送失败立即 resolve(null) + }); + } + }; + } + + // 包装 RuntimeSDKView:在原 sdk 上叠加 saveState/loadState(不改 sdk 对象本身,只组合传给 startRuntime) + var runtimeView = sdk; + try { + var sv = makeStorageView(); + // 浅合并:保留 sdk 的 track/reportError/__emit,叠加 storage 两回调(§6.2 RuntimeSDKView 扩展面) + runtimeView = Object.assign({}, sdk, { saveState: sv.saveState, loadState: sv.loadState }); + // __emit 是挂在 sdk 上的私有键,Object.assign 会一并复制(同 iframe 内枚举可见); + // 若个别环境 __emit 不可枚举,显式补一手,保 life() 生命周期不丢: + if (typeof runtimeView.__emit !== 'function' && typeof sdk.__emit === 'function') runtimeView.__emit = sdk.__emit; + } catch(e){ runtimeView = sdk; /* 组合失败:退回纯 sdk,idle 退化无离线态,不崩 */ } + // 宿主会在 iframe 就绪后下发 init;这里也兜底:DOM 就绪即自驱动启动(骨架阶段 // 不强依赖宿主 init 时序,保证 demo 链路在 srcdoc 注入后即可自跑通)。 function boot(){ @@ -113,7 +179,9 @@ export function buildIframeSrcdoc( canvas.height = Math.floor(canvas.clientHeight * dpr) || 640; // 先 init(发 sdk_ready),再起 Runtime(发 game_loaded → 等点击 game_start) sdk.init({ gameId: CTX.gameId, versionId: CTX.versionId, traceId: CTX.traceId }); - startRuntime(canvas, CTX.pkg, sdk); + // 传 runtimeView(= sdk 叠加 saveState/loadState,§6.2.1)而非裸 sdk,保 idle 离线态可用; + // 非 idle 模板 runtime 不调 storage 回调,runtimeView 与裸 sdk 行为等价(仅多两可选方法)。 + startRuntime(canvas, CTX.pkg, runtimeView); } // 收到宿主 init 也可触发(幂等由 SDK 内部保证);同时自启动兜底 onHostMessage(function(type){ if (type === 'init') { /* 已自启动,幂等忽略 */ } });