feat(runtime): M-c批② initIdle(离线闭环)+initTycoon(经营循环)——纯追加双case,initMerge零行删改

- RuntimeSDKView 扩 saveState?/loadState? 两可选方法(loadState 返 object|null,宿主受信边界已解析,runtime侧零JSON.parse守§5.5);缺失安全跳过(旧宿主/demo退化无离线态不崩)
- initIdle:loadState→离线补发公式(钳offlineMaxMin+折扣双闸防爆)→在线点击产料+每秒自动产出→节流saveState(≥2s)→资源达targetResource通关;score=floor(resource)
- initTycoon:进货带客(onDown waitingCustomers+1)→售出配对→金币达targetCoin通关;按钮布局契约(buy中心W*0.28/H*0.70,sell W*0.72/H*0.70)承重接口;score=coin
- clampInt/localXY各init函数体内复制(沿initMerge范式禁提升);零DOM/零可抛新增;hunk全>426(initMerge:255-426零删改)

HJ-MC-TPL-EXEC-002 §5

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 7033cef382
commit 88388f7b46

View File

@ -32,6 +32,16 @@ interface RuntimeSDKView {
e: 'sdk_ready' | 'game_loaded' | 'game_start' | 'game_end' | 'game_error',
d?: Record<string, unknown>,
) => void;
/** idle 离线存档(HJ-MC-TPL-EXEC-002 §6.2):fire-and-forget 写键值(宿主受信边界落 localStorage) */
saveState?: (key: string, value: string) => void;
/**
* idle 离线读档:按 key 取存档(宿主回包;5s 超时兜底返回 null,绝不抛/不阻塞)。
* 注:返回「宿主受信边界已解析好的对象」而非原始字符串——§5.5 红线要求 runtime 侧零字符串解析
* (解析在宿主受信边界做),故此处返回 object|null;runtime 直接读字段,不在 runtime 内解析。
* (此处对 §6.2 草案 Promise<string|null> 的最小修正见交付报告 open_issue:§6.2 草案返回类型与
* §5.5「runtime 无字符串解析」不自洽,依 §5.5 硬门取 object 返回,inject 侧应答端负责解析。)
*/
loadState?: (key: string) => Promise<Record<string, unknown> | null>;
}
/**
@ -425,6 +435,357 @@ export function startRuntime(
};
}
// ===========================================================================
// 玩法分支 3:idle(放置挂机 + 离线产出闭环,§5.2)—— 纯数值/Canvas,无物理、无 DOM
// ===========================================================================
function initIdle(): void {
// —— 自包含纯函数(沿 initMerge:258 clampInt 范式逐字照抄函数体;必须定义在本函数体内,
// toString() 注入约束;严禁引用 initMerge 内符号、严禁提升到共享作用域)——
/** 整数钳制:非整数/越界回落 def,最终夹在 [lo,hi] */
function clampInt(v: unknown, lo: number, hi: number, def: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : def;
return n < lo ? lo : n > hi ? hi : n;
}
/** 取指针在 canvas 内部像素坐标系的坐标(沿 initMerge:320 localXY 范式,自包含一份) */
function localXY(e: PointerEvent): [number, number] {
const sx = canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
const sy = canvas.clientHeight ? canvas.height / canvas.clientHeight : 1;
return [e.offsetX * sx, e.offsetY * sy];
}
/** 矩形命中判定:点 (px,py) 落在矩形 [x,x+w]×[y,y+h] 内即 true(纯算术,无可抛) */
function inRect(px: number, py: number, x: number, y: number, w: number, h: number): boolean {
return px >= x && px <= x + w && py >= y && py <= y + h;
}
// —— 读 config(缺省/越界钳制 + 跨字段钳制兜底,§5.2.1)——
const clickYield = clampInt(pkg.gameConfig?.clickYield, 1, 10, 1); // 每次点击产出资源数
const autoYieldBase = clampInt(pkg.gameConfig?.autoYield, 1, 20, 1); // 升级前每秒自动产出基准
const targetResource = clampInt(pkg.gameConfig?.targetResource, 50, 600, 100); // 通关目标资源量
let upgradeCost = clampInt(pkg.gameConfig?.upgradeCost, 10, 200, 50); // 升级成本
// §3.3 兜底③:钳到 < targetResource,防「升级比通关还贵」死局(LLM 违例也不致死局)
upgradeCost = Math.min(upgradeCost, targetResource - 1);
const upgradeMul = clampInt(pkg.gameConfig?.upgradeMultiplier, 2, 5, 2); // 升级倍率(整数倍)
const resourceLabel = cleanText(pkg.gameConfig?.resourceLabel, 8) || '资源'; // 资源文案缺省兜底
const offlineMaxMin = clampInt(pkg.gameConfig?.offlineMaxMinutes, 5, 120, 60); // 离线产出上限分钟
const offlineEff = clampInt(pkg.gameConfig?.offlineEfficiencyPercent, 10, 100, 50); // 离线效率折扣%
// —— 状态变量(§5.2.1)——
let resource = 0; // 当前资源量(浮点累加,绘制/判定时取整)
let autoYield = autoYieldBase; // 当前每秒自动产出(升级后翻 upgradeMul 倍)
let upgraded = false; // 是否已购升级(MVP 单档,买一次即满)
let lastTickMs = 0; // 上次自动产出累加时间戳(game_start 后首帧置)
let lastSaveMs = 0; // 上次节流存档时间戳(§5.2.4 节流 ≥2s 一次)
// —— 存档键名(§5.2.4:key 隔离,必须与宿主 handleStorage 白名单前缀逐字一致 §6.1.2)——
const saveKey = 'idle:' + pkg.gameId + ':' + pkg.versionId;
// —— 升级按钮布局(Canvas 内部像素坐标系;底部居中一个按钮区,纯几何)——
const ubX = canvas.width * 0.25;
const ubY = canvas.height * 0.78;
const ubW = canvas.width * 0.5;
const ubH = canvas.height * 0.14;
/**
* 节流存档(§5.2.4(b)):fire-and-forget 写当前态到宿主受信边界(落 localStorage)。
* saveState 是 RuntimeSDKView 可选方法——缺失时安全跳过(旧宿主/demo 兜底兼容,§6.2);
* 调用包 try-catch 静默(沿 sdk.track 范式),runtime 侧不引可抛路径(守五条 AND 之④)。
* JSON.stringify 对普通对象不抛,且整体包 try-catch,无新增可抛入口。
*/
function persist(force: boolean): void {
if (!sdk.saveState) return; // 可选方法缺失:idle 退化无离线态,安全跳过
const now = Date.now();
if (!force && now - lastSaveMs < 2000) return; // 节流:非强制时 ≥2s 一次,避免每帧写
lastSaveMs = now;
try {
// 存档值形态(§5.2.4(a)):版本位 + 资源 + 速率 + 升级态 + 离开时间戳
const json = JSON.stringify({
v: 1,
resource: Math.floor(resource),
autoYield: autoYield,
upgraded: upgraded,
leftAtMs: now,
});
sdk.saveState(saveKey, json);
} catch {
/* 存档失败静默吞,不影响主循环 */
}
}
// —— 渲染玩法层(背景/标题由 drawBase 画;本层做自动产出累加 + 绘进度/提示/升级按钮)——
drawGame = function (): void {
if (!ctx) return;
// 自动产出累加(仅 started 后;按帧间真实时间差,与帧率解耦,确定性,§5.2.2)
if (started && running) {
const now = Date.now();
if (lastTickMs === 0) lastTickMs = now;
const elapsedSec = (now - lastTickMs) / 1000;
if (elapsedSec > 0) {
resource += autoYield * elapsedSec; // 浮点累加,绘制时取整
lastTickMs = now;
persist(false); // 累加后节流存档(捕获在线进度)
}
// 资源达标即通关(单一来源 finish,score=最终资源量取整,§5.2.3 score 语义拍板)
if (resource >= targetResource) {
persist(true); // 通关前强制存一次最终态(§5.2.4(b))
finish(Math.floor(resource));
return;
}
}
const w = canvas.width;
const h = canvas.height;
// 进度文案:当前资源 / 目标资源(theme 模式高亮白 / 旧分支青色 scoreColor)
ctx.textAlign = 'center';
ctx.fillStyle = scoreColor;
ctx.font = 'bold 26px system-ui, sans-serif';
ctx.fillText(resourceLabel + ':' + Math.floor(resource) + ' / ' + targetResource, w / 2, h * 0.42);
// 点击提示(muted)
ctx.fillStyle = '#8d9ab6';
ctx.font = '14px system-ui, sans-serif';
ctx.fillText(
started ? '点击产出 +' + clickYield + '/秒自动 +' + autoYield : '点击屏幕开始挂机',
w / 2,
h * 0.52,
);
// 升级按钮区:未升级时绘制;resource≥upgradeCost 高亮可点,否则 muted
if (!upgraded) {
const affordable = resource >= upgradeCost;
ctx.fillStyle = affordable ? 'rgba(49,230,255,0.18)' : 'rgba(255,255,255,0.05)';
ctx.fillRect(ubX, ubY, ubW, ubH);
ctx.strokeStyle = affordable ? '#31e6ff' : 'rgba(141,154,182,0.5)';
ctx.strokeRect(ubX, ubY, ubW, ubH);
ctx.fillStyle = affordable ? '#eef6ff' : '#8d9ab6';
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText('升级 ×' + upgradeMul + '(花' + upgradeCost + ')', w / 2, ubY + ubH / 2 + 5);
}
};
// —— 交互(§5.2.3:pointerdown 点击产料 / 命中升级按钮购升级)——
function onDown(e: PointerEvent): void {
if (!running) return;
markStarted(); // 首次交互发 game_start(同 clicker/merge 范式)
const [px, py] = localXY(e);
// 命中升级按钮区 且 未升级 且 资源够:购升级
if (inRect(px, py, ubX, ubY, ubW, ubH) && !upgraded && resource >= upgradeCost) {
resource -= upgradeCost;
autoYield = autoYieldBase * upgradeMul; // 升级:自动产出翻 upgradeMul 倍
upgraded = true;
try {
sdk.track('idle_upgrade', { autoYield });
} catch {
/* ignore */
}
persist(true); // 升级是关键态变化,强制存一次
return;
}
// 否则:点击产料
resource += clickYield;
try {
sdk.track('idle_click', { resource: Math.floor(resource) });
} catch {
/* ignore */
}
persist(false); // 点击后节流存档
if (resource >= targetResource) {
persist(true);
finish(Math.floor(resource)); // 点击也可触发通关
}
}
// idle 仅绑 pointerdown 一种交互;finish/destroy 经 unbind 解绑 + 强制存档
canvas.addEventListener('pointerdown', onDown);
unbind = function (): void {
persist(true); // destroy/finish 时强制写一次最终态(§5.2.4(b))
canvas.removeEventListener('pointerdown', onDown);
};
// —— 再入补发离线产出(§5.2.4(c):启动时读档 → 补发公式 → 恢复升级态)——
// loadState 为可选方法;返回 Promise<对象|null>(宿主受信边界已解析好的对象,5s 超时兜底 null、
// 绝不 reject,§6.2)。runtime 侧直接读字段、零字符串解析(解析在宿主受信边界做,守 §5.5 红线)。
// 缺失/超时/无存档 → 从 0 开始在线循环(CDP 在线取证 §7.2 走此默认路径)。
if (sdk.loadState) {
sdk
.loadState(saveKey)
.then((saved: Record<string, unknown> | null) => {
// 无存档/已结束/已交互/版本不符:不补发(诚实边界,runtime 不解析仅读字段)
if (!saved || !running || started || saved.v !== 1) return;
// 恢复升级态(钳制防脏数据:autoYield 不超 autoYieldBase×upgradeMul 上限)
const savedAuto =
typeof saved.autoYield === 'number' && Number.isFinite(saved.autoYield)
? Math.min(Math.max(Math.round(saved.autoYield), autoYieldBase), autoYieldBase * upgradeMul)
: autoYieldBase;
const savedRes =
typeof saved.resource === 'number' && Number.isFinite(saved.resource) && saved.resource > 0
? Math.floor(saved.resource)
: 0;
const savedLeft =
typeof saved.leftAtMs === 'number' && Number.isFinite(saved.leftAtMs) ? saved.leftAtMs : 0;
// 离线补发公式(§5.2.4(c)):钳上限分钟 + 效率折扣,双闸防爆数值
const offlineSec = savedLeft > 0 ? (Date.now() - savedLeft) / 1000 : 0;
const offlineMin = Math.min(offlineSec / 60, offlineMaxMin); // 钳上限(即便改本地时钟也被钳)
const bonus = offlineMin > 0 ? Math.floor((savedAuto * offlineMin * 60 * offlineEff) / 100) : 0;
resource = savedRes + (bonus > 0 ? bonus : 0); // 补发到当前资源
autoYield = savedAuto; // 恢复升级后速率
upgraded = saved.upgraded === true; // 恢复升级态
if (bonus > 0) {
try {
sdk.track('idle_offline_bonus', { bonus, offlineMin: Math.floor(offlineMin) });
} catch {
/* ignore */
}
}
// ⚠ 诚实边界(§5.2.4(c)):补发只置初始 resource,不在此直接 finish(此时尚未 game_start,
// 时序不对);待玩家首次交互 game_start 后由 drawGame 判定通关,不绕过 game_start。
})
.catch(() => {
/* loadState 约定绝不 reject,此处仅兜底;失败 = 无存档从 0 开始 */
});
}
}
// ===========================================================================
// 玩法分支 4:tycoon(模拟经营循环,§5.3)—— 纯数值/Canvas,无物理、无 DOM、无 storage
// ===========================================================================
function initTycoon(): void {
// —— 自包含纯函数(沿 initMerge:258/:320 范式逐字照抄;定义在本函数体内,严禁引用 initMerge 符号)——
/** 整数钳制:非整数/越界回落 def,最终夹在 [lo,hi] */
function clampInt(v: unknown, lo: number, hi: number, def: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : def;
return n < lo ? lo : n > hi ? hi : n;
}
/** 取指针在 canvas 内部像素坐标系的坐标(沿 initMerge:320 localXY 范式,自包含一份) */
function localXY(e: PointerEvent): [number, number] {
const sx = canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
const sy = canvas.clientHeight ? canvas.height / canvas.clientHeight : 1;
return [e.offsetX * sx, e.offsetY * sy];
}
/** 矩形命中判定:点 (px,py) 落在矩形 [x,x+w]×[y,y+h] 内即 true(纯算术,无可抛) */
function inRect(px: number, py: number, x: number, y: number, w: number, h: number): boolean {
return px >= x && px <= x + w && py >= y && py <= y + h;
}
// —— 读 config(缺省/越界钳制 + 跨字段钳制兜底,§5.3.1)——
const purchaseCost = clampInt(pkg.gameConfig?.purchaseCost, 1, 50, 5); // 进货成本
let salePrice = clampInt(pkg.gameConfig?.salePrice, 2, 100, 10); // 售价
salePrice = Math.max(salePrice, purchaseCost + 1); // §3.5 兜底③:保正差价,防「越卖越亏」
const targetCoin = clampInt(pkg.gameConfig?.targetCoin, 50, 800, 200); // 通关目标金币
let initialCoin = clampInt(pkg.gameConfig?.initialCoin, 10, 100, 20); // 初始金币
initialCoin = Math.max(initialCoin, purchaseCost); // §3.5 兜底③:保首轮可进货,防「开局进不起货」
const custLevel = clampInt(pkg.gameConfig?.customerIntervalLevel, 1, 3, 2); // 顾客节奏档位
const goodsLabel = cleanText(pkg.gameConfig?.goodsLabel, 8) || '商品'; // 商品文案缺省兜底
const customerLabel = cleanText(pkg.gameConfig?.customerLabel, 8) || '顾客'; // 顾客文案缺省兜底
// 顾客到来间隔(runtime 内查表,§5.3.1):1=慢3s / 2=中2s / 3=快1s
const custIntervalMs = custLevel === 1 ? 3000 : custLevel === 3 ? 1000 : 2000;
// —— 状态(§5.3.1)——
let coin = initialCoin; // 当前金币
let stock = 0; // 当前库存(进货增、售出减)
let waitingCustomers = 0; // 待服务顾客数(进货带客 + 节奏累加;售出消耗,§5.3.4)
let lastCustMs = 0; // 上次顾客到来时间戳(game_start 后首帧置)
// —— tycoon 按钮布局契约(§5.3.3,canvas 内部像素坐标系;与 player 字面同一套系数,承重接口)——
const W = canvas.width;
const H = canvas.height;
// 进货按钮区(左半下方):中心 = (W*0.28, H*0.70)
const buyX = W * 0.1;
const buyY = H * 0.62;
const buyW = W * 0.36;
const buyH = H * 0.16;
// 售出按钮区(右半下方):中心 = (W*0.72, H*0.70)
const sellX = W * 0.54;
const sellY = H * 0.62;
const sellW = W * 0.36;
const sellH = H * 0.16;
// —— 渲染玩法层(背景/标题由 drawBase 画;本层做顾客节奏累加 + 通关判定 + 绘进度/双按钮)——
drawGame = function (): void {
if (!ctx) return;
// 顾客节奏(表现层;started 后按 custIntervalMs 累加待服务顾客;CDP 取证不依赖此,§7.3)
if (started && running) {
const now = Date.now();
if (lastCustMs === 0) lastCustMs = now;
if (now - lastCustMs >= custIntervalMs) {
waitingCustomers += Math.floor((now - lastCustMs) / custIntervalMs);
lastCustMs = now;
}
// 通关判定(金币达标):单一来源 finish,score=最终金币(§5.3.4 score 语义拍板)
if (coin >= targetCoin) {
finish(coin);
return;
}
}
const w = W;
const h = H;
ctx.textAlign = 'center';
// 金币进度(高亮主文字)
ctx.fillStyle = scoreColor;
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText('金币 ' + coin + ' / ' + targetCoin, w / 2, h * 0.40);
// 库存 + 待客(muted)
ctx.fillStyle = '#8d9ab6';
ctx.font = '14px system-ui, sans-serif';
ctx.fillText(
'库存 ' + stock + ' ' + goodsLabel + ' 待客 ' + waitingCustomers + ' ' + customerLabel,
w / 2,
h * 0.50,
);
// 进货按钮(goodsLabel;coin≥purchaseCost 高亮可点)
const canBuy = coin >= purchaseCost;
ctx.fillStyle = canBuy ? 'rgba(49,230,255,0.18)' : 'rgba(255,255,255,0.05)';
ctx.fillRect(buyX, buyY, buyW, buyH);
ctx.strokeStyle = canBuy ? '#31e6ff' : 'rgba(141,154,182,0.5)';
ctx.strokeRect(buyX, buyY, buyW, buyH);
ctx.fillStyle = canBuy ? '#eef6ff' : '#8d9ab6';
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText('进货 ' + goodsLabel + '(' + purchaseCost + ')', buyX + buyW / 2, buyY + buyH / 2 + 5);
// 售出按钮(customerLabel;stock>0 && waitingCustomers>0 高亮可点)
const canSell = stock > 0 && waitingCustomers > 0;
ctx.fillStyle = canSell ? 'rgba(49,230,255,0.18)' : 'rgba(255,255,255,0.05)';
ctx.fillRect(sellX, sellY, sellW, sellH);
ctx.strokeStyle = canSell ? '#31e6ff' : 'rgba(141,154,182,0.5)';
ctx.strokeRect(sellX, sellY, sellW, sellH);
ctx.fillStyle = canSell ? '#eef6ff' : '#8d9ab6';
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText('售出(+' + salePrice + ')', sellX + sellW / 2, sellY + sellH / 2 + 5);
};
// —— 交互(§5.3.4:pointerdown 进货 / 售出)——
function onDown(e: PointerEvent): void {
if (!running) return;
markStarted(); // 首次交互发 game_start
const [px, py] = localXY(e);
// 命中进货按钮 且 金币够:进货
if (inRect(px, py, buyX, buyY, buyW, buyH) && coin >= purchaseCost) {
coin -= purchaseCost; // 花金币
stock += 1; // 加库存
waitingCustomers += 1; // §5.3.4 进货带客:保「进货→售出」严格配对,CDP 无需等顾客(确定性循环)
try {
sdk.track('tycoon_buy', { coin, stock });
} catch {
/* ignore */
}
return;
}
// 命中售出按钮 且 有库存 且 有待客:售出
if (inRect(px, py, sellX, sellY, sellW, sellH) && stock > 0 && waitingCustomers > 0) {
coin += salePrice; // 得金币
stock -= 1; // 减库存
waitingCustomers -= 1; // 减待客
try {
sdk.track('tycoon_sell', { coin });
} catch {
/* ignore */
}
if (coin >= targetCoin) finish(coin); // 售出后判通关
}
}
// tycoon 仅绑 pointerdown 一种交互(点击双按钮);finish/destroy 经 unbind 解绑
canvas.addEventListener('pointerdown', onDown);
unbind = function (): void {
canvas.removeEventListener('pointerdown', onDown);
};
}
// ===========================================================================
// 模板分发(§5.1):读 templateId → 选玩法 init;未知模板走防御分支
// ===========================================================================
@ -437,6 +798,12 @@ export function startRuntime(
case 'merge':
initMerge();
break;
case 'idle': // ← 批② 追加:放置挂机(含离线产出闭环,§5.2)
initIdle();
break;
case 'tycoon': // ← 批② 追加:模拟经营循环(无离线态,§5.3)
initTycoon();
break;
default:
// 未知 templateId:可审计上报 + game_error,不起循环/不绑交互/不错渲染(§5.4 堵静默错渲染缺口)
try {