feat(host-storage): M-c批② idle离线存档三端——contract storage payload+GamePlayer受信边界四道闸+inject注入侧应答端;主agent逮修loadState三端协议错配

- 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 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-10 16:46:06 +00:00
parent 88388f7b46
commit f82d480dfd
3 changed files with 172 additions and 2 deletions

View File

@ -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:versionIdtycoon 线 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宿 requestIdfire-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<string, unknown> | 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<string, unknown>)
: null;
}
} catch {
value = null; // / localStorage nullruntime
}
bridge?.post('storage', { key, value }, traceId.value, requestId); // host_to_gamevalue=|null
}
} catch (e) {
// 宿 AND
// eslint-disable-next-line no-console
console.warn('[GamePlayer] handleStorage 异常(已吞)', e);
}
}
/* ============================================================================
* ad/pay 弹层 UI + requestId 配对回包
* ========================================================================== */

View File

@ -115,6 +115,34 @@ export interface PayResultPayload {
paid: boolean;
}
/* ----- storage 消息 payloadidle 离线产出存档HJ-MC-TPL-EXEC-002 §6.1.1semver 只增不改) ----- */
/** 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 payloadStorageSetPayload.value JSON
* storage HJ-MC-TPL-EXEC-002 §6.1.1 string 稿
* §6.2 string runtime §5.5 线 semver
*/
value: Record<string, unknown> | null;
}
/* ==========================================================================
* GamePackage #4 game-package.schema.json
* Runtime manifest/assets 宿 manifest iframe

View File

@ -103,6 +103,72 @@ export function buildIframeSrcdoc(
// ===== 注入 Runtime 并启动 =====
var startRuntime = ${runtimeFactorySrc};
// ===== idle 离线存档iframe 侧 storage 应答端HJ-MC-TPL-EXEC-002 §6.2.1=====
// 承重端落 inject 引导内(非 SDK Coreiframe 内无 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 仅透传对象;
// 仅接受对象形态,其余(含字符串/数组/标量)→ nullruntime 按无存档处理(守 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→ 登记 resolve5s 超时 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; /* 组合失败:退回纯 sdkidle 退化无离线态,不崩 */ }
// 宿主会在 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') { /* 已自启动,幂等忽略 */ } });