feat(runner-v2): P2 通用宿主泛化落地——引擎游戏真渲入 feed 真玩(mini-desktop 真机门验)

P2 = 把 P1 装载契约从「装载王蓝莓一款」泛化为「装载任意 GamePackage」,经发布主链入 feed、
真人浏览器真玩。4 决策落实(O2(b)纳入 Phase C / DB-manifest 内嵌 / 灌库桩 / __GameBundle)。

三轨实现
- 后端:GamePackage schema 加 engineBundle additive 可选字段;buildGamePackage 加 engineBundle put
  (P3 生成路用,P2 恒 null→put 跳过→字节零变,单测 packageBytesReproducible 证);灌库桩
  p2_seed_engine_bundle.mjs + 四态脚手架 p2_scaffold_4state.sql(W-CLEAN 后重建已发布游戏)
- 运行时:P1 bootRef 泛化为 bootGameHost(opts)(引擎骨架照搬+参数化工厂来源/插件/视口),
  bootRef 退化薄包装;固化 build:ref-bundle 产 dist/ref-bundle.iife.js(--global-name=__GameBundle)
- studio:inject.ts 引擎包分支(pkg.engineBundle? → 内联 script 暴露 window.__GameBundle →
  bootGameHost 挂引擎真渲;否则旧 startRuntime 路保兼容);掏空壳改引擎分支(Phase C);CSP 不放外域

对抗代码复审逮 2 mustFix(主 agent 亲核亲修 + 6c6g 复验)
- 灌库桩读错产物(ref-bundle.js harness vs .iife.js)→ mini-desktop 灌库返工死循环
- 引擎分支漏发 game_start → 会话/play_count 静默吞、门②检不出 → 补发(DB session 行实证已修复)
+2 shouldFix(兜底可观测信号 / 灌库 SQL @pj 防撞 max_allowed_packet)
+ 真机门逮 2 环境缺陷已修(seed charset=utf8mb4 防双编码断 checksum 链 / 四态脚手架补 W-CLEAN 空库)

mini-desktop 真机门 6 门(逐门实数,主 agent 肉眼复核证据)
①build:ref-bundle 265374B/__GameBundle 真入包 ②Java 编译过+字节复现单测 ③studio staging 重建消 mock
④灌库 status=1∧checksum 64hex∧engineBundle 键齐∧manifest sha256 一致 ⑤真人 feed 点卡→引擎真渲
暖阳小卖部(warm=1246073px)非「重构中」+game_loaded+game_start+防 demo 三锚+console 零错+DB session 行
⑥回滚:无 engineBundle→demo 壳/错误态,向后兼容未破。证据 game-studio/evidence/p2-feed/

诚实 gap:game_end(到结算)未在自动化窗口内达成=ref gameover 需分钟级真玩+srcdoc 坐标盲点的取证局限,
非引擎/渲染能力缺陷(机制已通,留人工真玩到打烊补全)。受控面 6 项+game-host.d.ts 零改。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-13 20:36:32 +00:00
parent 4caaea8aab
commit 5d3f7a87f0
24 changed files with 1731 additions and 447 deletions

View File

@ -91,6 +91,10 @@
}
}
},
"engineBundle": {
"description": "引擎+包装层+游戏代码的可执行 iife bundle 文本T1b-β P2 通用宿主载体additive 可选,存量消费者无此字段时走 startRuntime 旧路/demo 兜底。MVP 默认=随 manifest 内嵌经 DB-manifest 端点 /app-api/runtime/package/{versionId}/manifest 下发(落 game_runtime_package.package_json LONGTEXTsha256 整包 checksum 覆盖其完整性,不接 OSS、不增第二校验面切 OSS 后改为 packageUrl 外链。iife 顶层全局名固定为 __GameBundle宿主据此从 window.__GameBundle 取 GameHostFactory见 game-runtime/src/core/game-host.d.ts。后端写权=DifyCallbackTxService.buildGamePackage putP2 阶段 ref 一款经 O-P2-1 灌库桩注入,真产线 P3 接生成主线)。",
"type": "string"
},
"provenance": {
"description": "可追溯来源(质量门禁与缓存命中用)。相同 promptHash 命中缓存可跳过 LLM 生成。",
"type": "object",

View File

@ -309,6 +309,20 @@ public class DifyCallbackTxService {
}
pkg.put("provenance", provenance);
// engineBundle契约#4 additive 可选T1b-β P2 通用宿主载体引擎+包装层+游戏代码的 iife bundle 文本
// manifest 内嵌经 DB-manifest 端点下发由整包 sha256 checksum 覆盖完整性故须落在下方 checksum 三段
// 序列化 T_baseT0T1 之前bundle 字节才被 checksum bundleSize 共同涵盖
// P2 阶段·过渡桩真实 bundle 来源走 O-P2-1 灌库桩脚本scripts/p2_seed_engine_bundle.* 一次性把
// ref-bundle.js 文本灌进某 version package_json.engineBundle不经本回调链故本处 bundle 文本不可得
// spec不可得则省略不可硬编死值省略该键null put存量回调零行为变化字节布局不变
// P3 真产线生成主线 agent 原生出 bundle 由回调入参契约#6 output additive 字段P3 自身 spec
// 携带 bundle 文本此处改为从入参取值后 put届时本 null 占位替换为入参取值conditional put 骨架不变
String engineBundle = resolveEngineBundleText(reqVO); // P2 恒返回 null无回调入参来源P3 改为取回调 bundle 文本
if (StringUtils.hasText(engineBundle)) {
// 仅在 bundle 文本可得时写入保证 P2省略语义 + P3随入参写入语义同一条 conditional 分支承载无孤儿设计
pkg.put("engineBundle", engineBundle);
}
try {
// T_basebundleSize=0 + checksum 占位 基准文本bundleSize := UTF-8 字节数确定性近似
String tBase = PACKAGE_MAPPER.writeValueAsString(pkg);
@ -355,6 +369,24 @@ public class DifyCallbackTxService {
return null;
}
/**
* 解析引擎 bundle 文本契约#4 GamePackage.engineBundle 的写入源
*
* P2 阶段·过渡桩真产线 P3恒返回 nullP2 仅跑 ref 一款 engineBundle O-P2-1 灌库桩脚本
* scripts/p2_seed_engine_bundle.*一次性灌进 DB package_json不经本回调链故本回调组包时 bundle 文本
* 不可得返回 null 使上游 conditional put 省略该键存量回调零行为变化
* P3 真产线生成主线 agent 原生产出 bundle 由回调入参携带 bundle 文本契约#6 output additive 字段
* P3 自身 spec 落实此方法改为 {@code return reqVO.getEngineBundle();}上游 conditional put 骨架不变
* 仅本取值源从 null 改为入参最小切换无孤儿设计
*
* @param reqVO 回调入参P3 将从其新增字段取 bundle 文本P2 不读其任何字段 null
* @return 引擎 bundle 文本P2 阶段恒 null省略 engineBundle
*/
private static String resolveEngineBundleText(DifyCallbackReqVO reqVO) {
// P2 过渡桩bundle 来源 = 灌库脚本直写 DB非回调入参 此处无来源返回 null不硬编码任何死值
return null;
}
/**
* 置任务失败终态status=3 + failure_reason + finishTimefailed 分支与业务性失败/补偿共用
*

View File

@ -0,0 +1,70 @@
-- ============================================================================
-- P2 真机门·四态脚手架W-CLEAN 清库后重建一款已发布游戏供 engineBundle 灌库桩附着)
-- 缘由seed 脚本 p2_seed_engine_bundle.mjs 原假设已存在已发布种子(9001 四态齐备)
-- 但 staging DB 已被 W-CLEAN 清空(0 version/0 package/0 feed_rank),故须先建四态脚手架。
-- mini-desktop 2026-06-13 P2 真机门实测可用gate 报告逮出后落库归仓。)
-- 四态game_project.status=4(PUBLISHED) ∧ game_version.status=3 ∧ game_runtime_package.status=1 ∧ game_feed_rank 行
-- IDgameId=9201 / versionId=92010 / pkgId=92010 / feedRankId=92010高位区零碰撞
-- engineBundle 由后续 p2_seed_engine_bundle.mjs 9201 覆写 package_json本脚本先建空 package_json 占位行)
-- 幂等:先 DELETE 同 ID 行再 INSERT纯重建可重复执行
-- tenantstaging application-staging.yaml tenant.enable=false → tenant_id=0 即可
-- 用法docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
-- ============================================================================
USE `ruoyi-vue-pro`;
-- charset 防御(与 seed 脚本同口径):强制 utf8mb4避免中文 title 被客户端默认 latin1 双编码
SET NAMES utf8mb4;
START TRANSACTION;
-- 清旧(幂等重建)
DELETE FROM game_feed_rank WHERE game_id = 9201;
DELETE FROM game_runtime_package WHERE game_id = 9201;
DELETE FROM game_version WHERE game_id = 9201;
DELETE FROM game_project WHERE id = 9201;
-- ① project已发布(status=4)current_version_id 指向 92010launch_zone_id 留 NULL(→feed zone 0)
INSERT INTO game_project
(id, creator_user_id, title, summary, cover_url, tags, template_id, age_rating,
status, current_version_id, launch_zone_id, featured, play_count, like_count,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(9201, 1, '王蓝莓小卖部(引擎真渲)', 'P2 通用宿主真机门ref 引擎 bundle 经 bootGameHost 真渲入 feed',
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
'P2,engine,wanglanmei', 'wanglanmei-ref', 'all',
4, 92010, NULL, b'0', 0, 0,
'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0);
-- ② version已发布(status=3)package_url 空(走 DB-manifest 端点)checksum 先占位(seed 脚本覆写运行包不动版本)
INSERT INTO game_version
(id, game_id, version_no, gen_task_id, package_url, bundle_size, checksum,
status, creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(92010, 9201, 1, NULL, '', 0, '',
3, 'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0);
-- ③ runtime_package先建 status=1 行 + 空 package_json 占位engineBundle 由 seed 脚本 UPDATE 覆写本行)
-- sandbox_attr/allow_origins 取既有发布默认package_url 空=DB-manifest 内嵌路
INSERT INTO game_runtime_package
(id, game_id, version_id, template_id, package_url, entry, runtime_version, preload_policy,
bundle_size, checksum, sandbox_attr, allow_origins, status,
creator, create_time, updater, update_time, deleted, tenant_id, package_json)
VALUES
(92010, 9201, 92010, 'wanglanmei-ref', '', 'index.html', '1.0.0', 'eager',
0, '', 'allow-scripts', '', 1,
'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0, NULL);
-- ④ feed_rankzone_id=0(混合流) + status=1 入流 + sort_score=0
INSERT INTO game_feed_rank
(id, game_id, version_id, zone_id, quality_score, boost, sort_score, pinned, status,
creator, create_time, updater, update_time, deleted, tenant_id)
VALUES
(92010, 9201, 92010, 0, 0.0000, 0.0000, 0.0000, 0, 1,
'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0);
COMMIT;
-- 回读四态
SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=9201
UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=92010
UNION ALL SELECT 'runtime_pkg', id, status, version_id FROM game_runtime_package WHERE id=92010
UNION ALL SELECT 'feed_rank', id, status, zone_id FROM game_feed_rank WHERE id=92010;

View File

@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* O-P2-1 灌库桩 P2 通用宿主 ref 引擎 bundle 文本灌进某已发布 version package_json.engineBundle
* ============================================================================
* P2 过渡桩真产线 P3
* 本脚本是 T1b-β P2Runner v2 泛化宿主的过渡手段execution spec
* docs/agent-specs/2026-06-13-P2-runner-v2泛化宿主-execution.md §6.1 B0 / O-P2-1 拍定
* P2 阶段只跑 ref 一款 engine-aware 游戏 engineBundle 不经生成回调链产出而是由本脚本
* 一次性把 game-runtime/games/wanglanmei-ref/dist/ref-bundle.iife.js 文本灌进 DB
* 真产线生成主线 agent 原生出 bundle DifyCallbackTxService.buildGamePackage 随回调入参写入 P3
*
* 附着的已发布游戏从哪来须先建四态脚手架mini-desktop 真机门逮
* 本桩零新建覆写某已发布 version package_json 为含 engineBundle GamePackage 文本
* 故依赖一款四态齐备(project.status=4 version.status=3 runtime_package.status=1 feed_rank )的已发布游戏
* staging DB 已被 W-CLEAN 清空(0 version/0 package/0 feed_rank)不存在历史种子 9001
* P2 须先跑同目录 p2_scaffold_4state.sql 建一款脚手架游戏(gameId=9201)本桩再 9201 覆写其运行包
* 真产线 P3engineBundle 由生成回调链经 DifyCallbackTxService.buildGamePackage 写入无需脚手架
*
* 为什么生成 SQL 文件而非直连 DB
* staging DB 访问走 docker exec game-staging-mysql mysql memory golden-loop-b1-done无需在
* mini-desktop mysql 驱动本脚本本机6c6g/mini-desktop 皆可仅做 bundle + GamePackage JSON +
* 产出可幂等重跑的 UPDATE SQL 265KB bundle 安全嵌进 SQL单引号/反斜杠转义 MySQL 端自算
* checksum/bundle_sizeSHA2/LENGTH 直接对所存文本求值消除任何转义/编码导致的 sha256 不一致风险
*
* 完整性校验链不变量门③依赖
* 宿主取包做 sha256(manifest 端点响应原文) == RuntimePackageRespVO.checksum= game_runtime_package.checksum
* UPDATE checksum = LOWER(SHA2(package_json, 256))对刚写入的同一文本求值 列值与所存文本字节恒一致
* 宿主校验必过内层 manifest.checksum 字段仅作包内自述不参与宿主校验对齐 DifyCallbackTxService 注释
*
* 用法mini-desktop staging
* 0) 建四态脚手架(W-CLEAN 后必需)docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 \
* -uroot -p"$MYSQL_ROOT_PASSWORD" < game-cloud/scripts/p2_scaffold_4state.sql gameId=9201
* 1) 生成 SQLnode game-cloud/scripts/p2_seed_engine_bundle.mjs [gameId] > /tmp/p2_seed.sql
* gameId 缺省 9201=脚手架游戏bundle 路径固定 games/wanglanmei-ref/dist/ref-bundle.iife.js
* 2) 应用docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < /tmp/p2_seed.sql
* --default-character-set=utf8mb4 必带客户端默认 latin1 会把 UTF-8 bundle/中文 title 双编码
* 致列字节 checksum 计算字节 manifest sha256 不一致 宿主校验失败 demo 兜底SQL 头亦自带 SET NAMES 双保险
* $MYSQL_ROOT_PASSWORD ~/game-staging/infra/.env
* 3) 重跑安全幂等再次执行步骤 1+2 即可 UPDATE 覆写无副作用
*
* @author 造梦AIP2 后端轨
*/
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ---------- 常量(与 execution spec 冻结契约逐字一致)----------
/** O-P2-3 冻结iife 顶层全局名,宿主从 window.__GameBundle 取 GameHostFactory三轨此名必须逐字一致 */
const FROZEN_GLOBAL_NAME = '__GameBundle';
/** ref bundle 固定产物路径spec §3.1 / B0 钉死build:ref-bundle 产物--global-name=__GameBundle
* build:ref-harness ref-bundle.js那是 P1 取证 harness全局名 WanglanmeiRefmustFix#1 纠正 */
const REF_BUNDLE_REL = '../../game-runtime/games/wanglanmei-ref/dist/ref-bundle.iife.js';
/** checksum 自指消解占位64 位 '0'(与真实 sha256 hex 等长,对齐 DifyCallbackTxService.CHECKSUM_PLACEHOLDER */
const CHECKSUM_PLACEHOLDER = '0'.repeat(64);
/** meta.cover 占位1px 透明 gif data URI沿后端组包同款满足契约 format=uri */
const COVER_PLACEHOLDER =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
// ---------- 入参 ----------
const __dirname = dirname(fileURLToPath(import.meta.url));
// gameId 缺省 9201p2_scaffold_4state.sql 建的脚手架游戏);运行时可传第一个 CLI 参数覆盖
const gameId = String(process.argv[2] || '9201').trim();
// ---------- 步骤①:读 ref bundle 文本 + 校验全局名 ----------
const bundlePath = resolve(__dirname, REF_BUNDLE_REL);
let engineBundle;
try {
engineBundle = readFileSync(bundlePath, 'utf8');
} catch (e) {
// 读不到产物即写链断裂:显式失败(不灌空桩),提示先固化 build:ref-bundle 产线spec §6.1 B0
console.error(`[p2-seed][FATAL] 读不到 ref bundle${bundlePath}\n` +
` 请先在 game-runtime 跑 build:ref-bundle 产出该文件spec §6.1 B0。原始错误${e.message}`);
process.exit(1);
}
// 全局名硬校验bundle 必须以 FROZEN_GLOBAL_NAME__GameBundle导出 iife否则宿主取不到 GameHostFactory、
// 门② 必 fail。当前若 bundle 仍是 P1 旧全局名(如 WanglanmeiRef在此显式拦截、提示重建产线。
if (!engineBundle.includes(FROZEN_GLOBAL_NAME)) {
console.error(`[p2-seed][FATAL] ref bundle 未含冻结全局名 ${FROZEN_GLOBAL_NAME}O-P2-3\n` +
` 现状 bundle 可能是 P1 旧产物(旧全局名);须由宿主轨/产线以 esbuild --global-name=${FROZEN_GLOBAL_NAME}\n` +
` 重建 ${bundlePath}(导出 GameHostFactory 到 window.${FROZEN_GLOBAL_NAME})后再灌库。\n` +
` 当前不灌库(避免种入宿主取不到 factory 的死卡,门② 会 fail`);
process.exit(2);
}
// ---------- 步骤②:组 GamePackage JSON含 engineBundle键序对齐后端 buildGamePackage----------
// 用「保插入序」的对象逐键放入,序列化采用紧凑无空格(与后端 Jackson 默认无缩进口径一致,便于字节对齐)。
// versionId/gameId 用占位串:真实 versionId 在 SQL 端由子查询从 game_project.current_version_id 解析,
// 但 package_json 内的 versionId 字段须与之一致——故本桩在 SQL 端用 @vid 变量回填占位(见下方 SQL
function buildPackage(versionPlaceholder) {
const pkg = {};
pkg.schemaVersion = '1.0';
pkg.gameId = String(gameId);
pkg.versionId = versionPlaceholder; // SQL 端 REPLACE 回填真实 versionId见下
pkg.templateId = 'wanglanmei-ref'; // P2 ref 级固定模板标识(过渡口径)
pkg.gameConfig = { templateId: 'wanglanmei-ref', title: '王蓝莓小卖部(引擎真渲)' };
pkg.assets = [];
// manifestbundleSize/checksum 先占位(内层自述,非宿主校验面);保留三键齐全满足契约 required
pkg.manifest = {
runtimeVersion: '1.0.0',
entry: 'index.html',
preloadPolicy: 'eager',
bundleSize: 0,
checksum: CHECKSUM_PLACEHOLDER,
};
pkg.meta = {
title: '王蓝莓小卖部(引擎真渲)',
summary: 'P2 通用宿主过渡桩ref 引擎 bundle 经 bootGameHost 真渲',
cover: COVER_PLACEHOLDER,
ageRating: 'all',
};
// engineBundle 末位放入(落 checksum 之内,由整包 sha256 覆盖;与后端 put 落点同义)
pkg.engineBundle = engineBundle;
return pkg;
}
// 内层 manifest.checksum 自指消解:先以占位序列化 T0 → C=sha256(T0) → 写回得 T1包内自述用宿主用外层列值
// 注versionId 占位文本在 T0/T1 两次序列化一致,故内层 checksum 计算自洽SQL 端 REPLACE 占位为真实 versionId
// 会改变最终落库文本——但宿主校验的外层 checksum 由 MySQL 对「落库后的最终文本」SHA2 求值,故仍恒一致(见 SQL
const VID_PLACEHOLDER = '__VERSION_ID__';
const t0 = JSON.stringify(buildPackage(VID_PLACEHOLDER));
const innerChecksum = createHash('sha256').update(t0, 'utf8').digest('hex');
const pkgT1 = buildPackage(VID_PLACEHOLDER);
pkgT1.manifest.checksum = innerChecksum;
const t1 = JSON.stringify(pkgT1);
// ---------- 步骤③:转义为 SQL 字符串字面量MySQL转义 \ 和 ' ----------
// MySQL 默认 NO_BACKSLASH_ESCAPES 关闭 → 反斜杠是转义符,必须先转义 \,再转义单引号;换行/控制符 bundle 内已是
// 合法 JS 文本字符,作为字符串内容原样存即可(不破坏 JSON 文本语义)。
function sqlQuote(text) {
return "'" + text.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
}
const t1Sql = sqlQuote(t1);
// ---------- 步骤④:产出幂等 UPDATE SQL ----------
// 设计:
// 1) 用 @vid 解析该 gameId 的当前生效版本game_project.current_version_id
// 2) 把 package_json 内的 versionId 占位 __VERSION_ID__ 用 REPLACE 回填为真实 @vid保 versionId 自洽);
// 3) checksum = LOWER(SHA2(package_json,256)) 对「回填后的最终文本」求值 → 与所存字节恒一致(宿主校验必过);
// 4) bundle_size = LENGTH(package_json)utf8mb4 下 LENGTH 返字节数,对齐契约 bundleSize 语义);
// 5) status = 1已发布9001 本已为 1置之幂等无副作用确保 play 场景门禁放行)。
// 安全:纯 UPDATE 单行uk_version 唯一),重跑覆写无副作用;事务包裹保原子。
const sql = `-- ============================================================================
-- O-P2-1 灌库桩 SQLP2 过渡桩真产线 P3 game-cloud/scripts/p2_seed_engine_bundle.mjs 生成
-- 目标 gameId=${gameId}缺省王蓝莓小卖部 A灌入 ref 引擎 bundle 到其当前生效版本的 package_json.engineBundle
-- 应用docker exec -i game-staging-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
-- 幂等 UPDATE可重复执行
-- ============================================================================
USE \`ruoyi-vue-pro\`;
-- charset 防御mini-desktop 真机门逮强制连接 charset=utf8mb4否则客户端默认 latin1 UTF-8 bundle/中文
-- title 双编码落 utf8mb4 列字节 SET @pj 计算 checksum 的字节 stored checksum manifest 端点
-- sha256(原文) 不一致 宿主 crypto.subtle 校验必失败 demo 兜底 引擎永不渲自带 SET NAMES 不依赖调用方加参数
SET NAMES utf8mb4;
-- 解析该 gameId 当前生效版本 IDgame_project.current_version_id game_version.id
SET @gid := ${Number(gameId)};
SET @vid := (SELECT current_version_id FROM game_project WHERE id = @gid AND deleted = b'0' LIMIT 1);
-- 防御版本未解析游戏不存在/未设当前版本则报错中止不盲写
SET @ok := IF(@vid IS NULL, (SELECT CONCAT('FATAL: gameId=', @gid, ' 无 current_version_id先发布该游戏') ), 'ok');
SELECT @vid AS resolved_version_id, @ok AS precheck;
-- 覆写该版本运行包行package_jsonversionId 占位回填真实 @vid+ 自洽 checksum/bundle_size + status=1
-- shouldFix#2把终文一次性装入会话变量 @pj避免单 UPDATE 内联 3× bundle(~265KB) max_allowed_packet旧配置 1MB 档会拒
SET @pj := REPLACE(${t1Sql}, '${VID_PLACEHOLDER}', CAST(@vid AS CHAR));
START TRANSACTION;
UPDATE game_runtime_package
SET
package_json = @pj,
checksum = LOWER(SHA2(@pj, 256)),
bundle_size = LENGTH(@pj),
status = 1,
updater = 'p2-seed-engine-bundle',
update_time = NOW()
WHERE version_id = @vid AND deleted = b'0';
COMMIT;
-- 回读核对应见 status=1checksum 64 package_json engineBundle/__GameBundle
SELECT version_id, status, checksum, bundle_size,
(package_json LIKE '%"engineBundle"%') AS has_engine_bundle_key,
(package_json LIKE '%${FROZEN_GLOBAL_NAME}%') AS has_frozen_global_name
FROM game_runtime_package WHERE version_id = @vid AND deleted = b'0';
`;
// 产出到 stdout调用方重定向到 .sql 文件)
process.stdout.write(sql);
// 诊断信息走 stderr不污染 stdout 的纯 SQL
console.error(`[p2-seed] gameId=${gameId} bundle=${bundlePath} ` +
`bundleBytes=${Buffer.byteLength(engineBundle, 'utf8')} ` +
`pkgJsonBytes(占位态)=${Buffer.byteLength(t1, 'utf8')} innerChecksum=${innerChecksum}`);

View File

@ -12,13 +12,32 @@ cd game-runtime && node --test "games/wanglanmei-ref/test/*.test.mjs"
## 1. 构建esbuild 锁参,沿 scripts/build.mjs 同参数bundle+minify+iife+es2019+sourcemap=false
`game-runtime/` 目录下(需 `npm i -D esbuild`mini-desktop 已具备):
`game-runtime/` 目录下(需 `npm i -D esbuild`mini-desktop 已具备)。**P2 起本目录产两套 bundle各服务一个消费方已固化为 npm script**
### 1.1 P1 ref 取证 harness bundle`WanglanmeiRef` · index.html 用)
```bash
node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
npm run build:ref-harness
# 等价node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
```
产物:`games/wanglanmei-ref/dist/ref-bundle.js`iife 全局 `WanglanmeiRef`,含 core 协议 + 所用插件 + 游戏全部代码;资产不打包,运行时相对路径取 `./assets/`)。
产物:`games/wanglanmei-ref/dist/ref-bundle.js`iife 全局 `WanglanmeiRef`,导出 `bootRef`;含 core 协议 + 所用插件 + 游戏全部代码 + 引擎;资产不打包,运行时相对路径取 `./assets/`)。**index.html 与 §2 真人试玩/取证走它**P1 real 引擎门 6/6 沿用P2 不动)。
### 1.2 P2 studio-consumable bundle`__GameBundle` · studio host 引擎包分支用 · T1b-β P2
```bash
npm run build:ref-bundle
# 等价node scripts/build.mjs games/wanglanmei-ref/entry-bundle.js games/wanglanmei-ref/dist/ref-bundle.iife.js --global-name=__GameBundle
```
产物:`games/wanglanmei-ref/dist/ref-bundle.iife.js`iife 全局 **`__GameBundle`**,契约冻结名;入口 `entry-bundle.js`)。
- **studio 接缝(契约冻结·对齐 `game-studio/src/host/contract.ts:204` 与 `game-package.schema.json` engineBundle**
- `window.__GameBundle.GameHostFactory` = `GameHostFactory`= game-shop 工厂;宿主据此建游戏实例,契约 `game-host.d.ts:59``window.__GameBundle.default` 同此引用。
- `window.__GameBundle.bootGameHost({ canvas, statusEl?, seed? })` = P2 boot 入口studio iframe 内调它启动;引擎已内联进本 bundlestudio 仓无需 littlejsengine
- **载体O-P2-2 ① DB-manifest 内嵌)**bundle 文本灌进 GamePackage `engineBundle` 字段 → studio iframe 内联 `<script>` 注入(非外域 `<script src>`CSP 不放外域)。
- **产物体积**raw 259KB / gz 88KB实测对齐 SIZES.md §游戏产物B1 入场券 gz≤350KB ✔)。引擎入包核验:`grep -c ParticleEmitter dist/ref-bundle.iife.js`>0+ metafile `inputs``littlejsengine`
- **【过渡桩·O-P2-1】** P2 仅跑 ref 一款:本 bundle 由脚本一次性灌入 DB 某 versionId 的 `engineBundle` 并置 PUBLISHED 供 feed 真玩(须显式标注过渡桩)。**真产线P3 生成主线 agent 出码经 buildGamePackage 原生出 bundle另立 W-G1 spec**,届时本入口的 `GameHostFactory` 导出由生成码替换。
## 2. 本地静态 serve沿 test/harness/static-serve.cjs轮 1 约定端口 4320

View File

@ -0,0 +1,79 @@
/**
* entry-bundle.js wanglanmei-ref P2studio-consumablebundle 入口esbuild 锁参打成 iifeglobalName=__GameBundle
* ownerRunner v2 P2 消费方studio host 引擎包分支DB-manifest 内嵌路iframe 内联 <script> 注入本 bundle 文本
*
*
* 本入口与 P1 entry.js 的区别两套 bundle各服务一个消费方
* - entry.js dist/ref-bundle.jsglobalName=WanglanmeiRef导出 bootRefP1 **ref 取证 harness**
* index.html `<script src>` 加载 WanglanmeiRef.bootRef **自己的页面**跑引擎**P2 不动它** real 引擎门 6/6
* - entry-bundle.js dist/ref-bundle.iife.jsglobalName=__GameBundle导出 GameHostFactory + bootGameHost
* P2 **studio host** studio iframe 注入本 bundle 文本 window.__GameBundle.GameHostFactory契约冻结
* window.__GameBundle.bootGameHost({factory, canvas, ...}) iframe 内跑引擎studio 仓无 littlejsengine引擎在本 bundle
*
* 契约冻结·逐字对齐 game-studio/src/host/contract.ts:204 + game-package.schema.json engineBundle
* iife 顶层全局名固定 **__GameBundle**宿主从 window.__GameBundle GameHostFactory= 本入口导出的 GameHostFactory
* bundle = 引擎(LittleJS) + 包装层(bootGameHost + wanglanmei 装配) + 游戏代码(game-shop 工厂)
*
* Q4 · Option C引擎 import 只落本入口且经动态 import 延后求值
* 起因同 entry.jslittlejsengine ESM 在模块装载期触 window/AudioContext顶层静态 import 会让 node import 即抛
* 击穿 import-graph断言本入口 node import-safe修法引擎 import 包装层内动态 import
* · node import 本入口时不触引擎bootGameHost 未被调 import-safe import-graph
* · esbuildbundle+iife splitting把动态 import 的引擎内联进单文件 引擎真入 bundlegrep ParticleEmitter
* · 浏览器运行期 studio __GameBundle.bootGameHost 时才求值引擎window/AudioContext 已存在
*
*/
'use strict';
// 通用宿主(引擎骨架参数化;本身零引擎 import引擎经 opts.engine 注入)。
import { bootGameHost as bootGameHostCore } from '../../src/host/boot-game-host.js';
// wanglanmei 装配配置(工厂/7 件插件/视口/wiring——与 ref 取证 bootRef 同源(防第三套)。
import { buildRefHostConfig, refFactory } from './src/ref-host-config.js';
/**
* 契约导出GameHostFactorystudio host window.__GameBundle.GameHostFactory 取它契约冻结
* = wanglanmei game-shop 工厂createShopGameP3 生成主线由 agent 出码替换本导出W-G1 自身 spec
* @type {import('../../src/core/game-host.d.ts').GameHostFactory}
*/
export const GameHostFactory = refFactory;
// 默认导出同为 GameHostFactory便于 import default 取用;与具名导出同一引用)。
export default refFactory;
/**
* P2 boot 入口 studio host iframe 内启动本 bundlestudio 仓无引擎故由本 bundle 注入引擎后驱动
* - 动态 import 引擎Option C调用时才求值esbuild 已把引擎内联进本 bundle无外部请求
* - engineMode 强制 'real'studio 引擎包分支一律引擎掌帧stub ref 取证回滚锚studio 不用
* - 插件/视口/工厂 wiring 取自共用配置P2 阶段=wanglanmei 7 件标准集 + 390×844O-P2-4P3 多品类再拍随包给
*
* @param {Object} opts studio host 传入的最小入参
* @param {HTMLCanvasElement} opts.canvas iframe 内目标画布real 通道引擎自建 mainCanvas canvas 作占位/输入兜底
* @param {HTMLElement} [opts.statusEl] 状态行可空
* @param {number} [opts.seed] 主随机种子缺省固定值保确定性studio 可从 GamePackage
* @param {import('../../src/core/game-host.d.ts').GameHostFactory} [opts.factory] 覆盖工厂缺省= bundle GameHostFactory
* studio window.__GameBundle.GameHostFactory 再回传是冗余但语义清晰缺省则用本 bundle 内置工厂
* @returns {Promise<object>} bootGameHost host 句柄frameCount/stepFrames/tap/do/state
*/
export async function bootGameHost(opts) {
const o = opts || {};
// 动态 import 引擎求值时机延后到本函数被调用浏览器运行期。esbuild 内联,无外部请求。
const engine = o.engine || (await import('littlejsengine'));
// wanglanmei 装配(工厂/插件/视口/wiringseed 缺省固定值(与 ref 取证同口径)。
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
const cfg = buildRefHostConfig({ seed });
// 调通用宿主real 通道引擎掌帧canvas/statusEl 由 studio 传;其余取共用配置。
return bootGameHostCore({
canvas: o.canvas,
statusEl: o.statusEl || null,
engine,
seed,
mode: 'play', // studio 真玩=playevidence 是 ref 取证用)
engineMode: 'real', // studio 引擎包分支一律引擎掌帧
viewport: cfg.viewport,
factory: o.factory || cfg.factory, // 缺省用本 bundle 内置 GameHostFactory
plugins: cfg.plugins,
registerOrder: cfg.registerOrder,
buildFactoryOpts: cfg.buildFactoryOpts,
// studio 渠道产物:不注 recHook引擎 call-ID 取证是 ref harness 专属;渠道产物无副作用)。
// 无 onReadystudio 不消费 ref 取证全局__refForensics 等),就绪信号经 SDK lifecycle game_loaded/game_endstudio 轨接)。
});
}

View File

@ -1,58 +1,35 @@
/**
* main.js wanglanmei-ref 宿主引导浏览器层受控面提供方 + 装载契约 host
* ownerwanglanmei-ref 1 / Runner v2 P1 消费方entry.jsesbuild 入口 index.html
* main.js wanglanmei-ref 宿主引导薄包装层把通用宿主 bootGameHost 配成wanglanmei专属 bootRef
* ownerwanglanmei-ref 1 / Runner v2 P1P2 消费方entry.jsesbuild 入口 index.html
*
* 职责host 仿 host-dev/host.js 样板
* - Canvas 390×844 DPR2取证固定环境+ 步进时钟?mode=evidence 完全确定性
* - createHostDevContext 受控面 PluginRegistry 注册 7 件业务插件 + probe 宿主自管六锚链
* - DOM 输入 归一化 HostDevInputBridge._emit真实交互灌进受控输入面
* - 装载契约game-shop.js 工厂建游戏实例host 经引擎五回调real/ RAFstub驱动其 update/render
* - 取证导出window.__refForensics / window.__refChecklist / window.__refHost驱动钩子
* P2 泛化后的职责变化
* P1 时本文件自带完整引擎宿主骨架engineInit 五回调 + 受控面背书 + 输入接线 + 单一时钟
* P2 已把该骨架**整块提升**到通用宿主 `src/host/boot-game-host.js` bootGameHost(opts)
* 并参数化游戏工厂来源 / 插件清单 / 视口三处wanglanmei专属项
* 故本文件 bootRef 退化为**薄包装**解析 URL 参数 装配wanglanmei 7 件插件 + game-shop 工厂 + 390×844 视口
* bootGameHost onReady 钩子里组装wanglanmei取证全局__refForensics/__refChecklist/__refHost
* **目的**P1 ref 的全部取证路径?engine=stub/real 双通道node 单测CDP 驱动import 零回归
*
* P1 双通道创始人拍
* - ?engine=stub**缺省**回滚锚prepareCanvas(#game) 2D + 浏览器 RAF + mockNowMs 墙钟 game.update/render绘制面=#game ctx行为与轮 1 逐行等价逐像素门
* - ?engine=real显式开引擎掌帧import 引擎setGLEnable(false)engineInit 五回调驱动 game.update/render绘制面=引擎 mainContext与引擎粒子同屏
* 双通道P1 沿用通道实现已下沉 bootGameHost
* - ?engine=stub**缺省**回滚锚浏览器 RAF + 单一墙钟行为与轮 1 逐行等价逐像素门
* - ?engine=real显式开引擎掌帧引擎 mainContext 绘制与引擎粒子同屏P1
*
* 红线遵守
* - 游戏逻辑零裸 Date.now/Math.random/AudioContext 全在 game-shop.js 经受控面/注入host 是受控面提供方
* - 宿主层是受控面提供方RAF/Image/fetch/AudioContext 工厂只在本层出现
* - 引擎 import 只活 entry/hostQ4 铁律游戏工厂 game-shop.js 零引擎 import
* - 游戏逻辑零裸 Date.now/Math.random/AudioContext 全在 game-shop.js 经受控面/注入
* - 引擎 import 只活 entry/hostQ4 铁律main.jshost opts.engine 注入用引擎不直接 import
* game-shop.js 游戏工厂零引擎 import通用宿主 bootGameHost 同样不直接 import 引擎
*/
'use strict';
// 【A0/Q4 · Option C引擎 import 不落 main.js改由 entry.js 经 opts 注入】
// 起因mustFix #1顶层 `import * as LJS from 'littlejsengine'` 会在引擎 esm 装载期触 window/AudioContext
// node 直接抛 ReferenceError → 击穿 import-graph断言 main.js 可 node 导入)+ headless-bootimport ../src/main.js 跑冒烟)。
// Option C 修法:引擎静态 import 只留 entry.jsesbuild 入口,负责把 littlejsengine 打进 ref-bundle
// main.js 的 real 通道经 bootRef(opts.engine) 注入拿引擎名字空间(见下方 const LJS = opts.engine
// 这样 main.js 不再顶层 import 引擎 → node 可安全 import → 两测原样通过(测试/MODULES 表零改动)。
// Q4 铁律不破:引擎 import 仍只活 entry.jshost 层 main.js 经注入用引擎,不直接 importgame-shop.js 游戏工厂零引擎 import。
import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js';
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
import { createRuntimeProbePlugin } from '../../../src/plugins/runtime-probe/impl.js';
// 【P2】通用宿主引擎骨架 + 受控面背书 + 输入接线全在此(参数化游戏工厂/插件/视口)。
import { bootGameHost } from '../../../src/host/boot-game-host.js';
// 【P1 提升】引擎能力背书工厂EngineCapabilities 三面)—— host-dev / ref real 通道共用同一份(搬运·语义零变)。
// 本模块不 import 引擎(经入参 LJS 注入real 通道用它建 engineFactory。
import { createEngineCaps } from '../../../host-dev/engine-caps.js';
// 【A1】引擎数学门面构造纯函数零引擎 importreal 通道用它包装引擎 lerp/smoothStep/Ease。
import { buildEngineMath } from '../../../host-dev/engine-math.js';
// 【P2】wanglanmei 宿主装配配置(工厂/7 件插件/视口/工厂 wiring——bootRef 与 P2 bundle 入口共用同一份(防第三套)。
import { buildRefHostConfig } from './ref-host-config.js';
// 【装载契约 game 工厂】暖阳小卖部游戏实现(默认导出 GameHostFactory零引擎 importA3 硬独立文件)。
import createShopGame from './game-shop.js';
// 渲染常量视口宽高render/balance 等仍由 game-shop.js 内部引用,这里只取 host 需要的视口尺寸。
import { W as VIEW_W, H as VIEW_H } from './render.js';
/** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐)。 */
const DPR = 2;
const FIXED_DT = 1 / 60;
// 协议号(取证导出 forensics.protocol 用)。
import { CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
/** 读 URL 参数。 */
function getParam(name, def) {
@ -72,114 +49,41 @@ function resolveSeed() {
return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0;
}
/** Canvas 设为逻辑 390×844 / 物理 ×DPR并把 2D 上下文缩放到逻辑坐标stub 通道用)。 */
function prepareCanvas(canvas) {
canvas.width = VIEW_W * DPR;
canvas.height = VIEW_H * DPR;
canvas.style.width = VIEW_W + 'px';
canvas.style.height = VIEW_H + 'px';
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
return ctx;
}
/**
* 启动参考件host
* 启动参考件host退化为 bootGameHost 的薄包装装配wanglanmei专属参数 + 组装取证全局
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} opts
* - engineOption C引擎名字空间littlejsengine entry.js 包装层注入main.js 不顶层 import 引擎 node-safe
* ?engine=real 通道用stub/node 通道不触达缺省 undefinedreal 缺它会 throw 提示见下方分流
* - engine引擎名字空间littlejsengine entry.js 包装层经 opts 注入main.js 不顶层 import 引擎 node-safe
* ?engine=real 通道用stub/node 通道不触达缺省 undefined
* @returns {Promise<object>} refHost 句柄同时挂 window.__refHost
*/
export async function bootRef(opts) {
const canvas = opts.canvas;
const statusEl = opts.statusEl || null;
// 【Option C】引擎名字空间经 opts 注入entry.js 包装层在调 bootRef 时传 engine: LJS
// stub/node 通道为 undefined不触达引擎保 node import-safereal 通道由 entry 注入真 LJS。
const LJS = opts.engine;
const seed = resolveSeed();
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
// 引擎通道:?engine=real引擎掌帧| ?engine=stub**缺省**,回滚桩,行为与轮 1 逐行等价)。
// 注§12-A2host-dev 缺省 real但 ref 缺省 stub 更稳——真玩取证显式带 &engine=realstub 兜逐像素回归。
const engineMode = getParam('engine', 'stub');
/** 状态行(仅人看)。 */
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
/* ── wanglanmei 宿主装配(工厂/7 件插件实例集/注册偏序/视口/工厂 wiring取自共用配置与 P2 bundle 入口同源)── */
const refCfg = buildRefHostConfig({ seed });
/* ── 未捕获错误归集评估门console 零未捕获错误)── */
/** @type {Array<{type:string,message:string}>} */
const uncaught = [];
window.addEventListener('error', (e) => uncaught.push({ type: 'error', message: String((e && e.message) || e) }));
window.addEventListener('unhandledrejection', (e) =>
uncaught.push({ type: 'unhandledrejection', message: String((e && e.reason && e.reason.message) || (e && e.reason) || e) })
);
/* ── 受控音频工厂(用户手势解锁后才 new宿主=受控面提供方)── */
let realAudioCtx = null;
let audioUnlocked = false;
function audioFactory() {
if (!audioUnlocked) return null;
if (realAudioCtx) return realAudioCtx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
realAudioCtx = new AC();
return realAudioCtx;
}
/* /mustFix #2single source of truth
* 原缺陷 mockNowMs 只喂 bundle.clock 却永不自增全文件无 +=而游戏侧 game-shop.js 另有一份私有
* mockNowMs 在其 update 内自增两钟分裂致bundle.clock 0 HostDevTime.nowMs() 0
* inputBridge._emit 给输入事件盖 tMs=0 gamefeel InputBuffer tMs=0 game-shop 接待缓冲补打
* 推进的游戏钟 记录的 0判窗口恒假 P4 输入缓冲补打永不触发measures.bufferedServeReplays 0
* 修法 mockNowMs 成唯一真相 game.update 驱动点自增stubstepOneFrame real_engUpdate
* bundle.clock 读它line ~219game-shop.js 删私有钟改读 boot.ctx.time.nowMs()即读本钟并轨成单钟
* 语义等价回滚 game-shop update 首行 `mockNowMs += dt*1000`故此处亦在调 game.update **之前**自增
* 保游戏逻辑在 update 内读到的钟值与原逐行一致headless-boot 确定性测状态终点不变 */
let mockNowMs = 0;
/* ── 7 件业务插件实例host 拥有;游戏经工厂 opts 复用,一职一路)── */
const collision = createCollisionPlugin({ cellSize: 96 });
const physics = createPhysicsLitePlugin();
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
// P9 后端:浏览器 localStorage不可用环境回退内存 adapter错误路径留痕
let saveAdapter;
try {
saveAdapter = createLocalStorageAdapter();
} catch (e) {
console.warn('[ref] localStorage 不可用,存档退内存 adapter' + (e && e.message));
saveAdapter = createMemoryAdapter();
}
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: saveAdapter });
const plugins = { collision, physics, gamefeel, juice, palettePost, audioMusic, save };
/* ── 受控面 bundle + 游戏实例realengineInit 就绪后建stub立即。延后建避免快照错 ctx§12-M4。 ── */
/** @type {ReturnType<typeof createHostDevContext>|null} */
let bundle = null;
/** @type {import('../../../src/core/game-host.d.ts').GameInstance|null} */
let game = null;
/** @type {ReturnType<typeof createRuntimeProbePlugin>|null} */
let probe = null;
let initRes = null;
let pluginSet = [];
/** checklistLOOP-LOG 五要素表自动子项 → 布尔+数值逐帧刷新game 工厂原地填)。 */
const checklist = {};
/** @type {ReturnType<NonNullable<typeof game>['_forensicsView']>|null} */
/* ── wanglanmei取证对象host 组装:游戏侧 forensics 视图 + probe 链 + 协议号)。在 onReady 内绑定内部件后填充。 ── */
/** checklistLOOP-LOG +game
* 关键·防双 checklist 漂移不另建空对象直接复用 bootGameHost 内部那份逐帧 _refreshChecklist 刷新
* checklistonReady 注入 internals.checklist否则本地另一份只在 onReady 刷一次营业期 linesShownLive 等子项恒停在 boot */
/** @type {Record<string, any>} */
let checklist = {};
/** 取证内部件onReady 回调注入:游戏取证视图 + probe + 未捕获错误等)。 */
let gameForensics = null;
let probeRef = null;
let uncaughtRef = [];
let pluginSetRef = [];
let initResRef = null;
/** @type {object|null} bootGameHost 返回的通用 host 句柄refHost 在其上扩展取证面)。 */
let coreHost = null;
/** 安全打点probe 异常不中断启动链)。 */
function mark(anchor, extra) {
try {
if (probe) probe.mark(anchor, extra);
} catch (e) {
uncaught.push({ type: 'mark', message: String((e && e.message) || e) });
}
}
/* ── 取证导出host 组装:游戏侧 forensics 视图 + probe 链 + 协议号)── */
/** wanglanmei取证导出与 P1 字段一致;读 onReady 注入的内部件)。 */
const forensics = {
protocol: CORE_PROTOCOL_VERSION,
seed,
@ -188,12 +92,12 @@ export async function bootRef(opts) {
geometry: () => (gameForensics ? gameForensics.geometry() : []),
/** 探针六锚链导出。 */
probe: () => ({
jsonl: probe ? probe.toJSONL() : '',
verify: probe ? probe.verify() : { ok: false, reason: 'no-probe' },
anchors: probe ? probe.getRecords().map((r) => r.anchor) : [],
uncaught: uncaught.slice(),
initOk: initRes ? initRes.ok : false,
registeredPlugins: pluginSet,
jsonl: probeRef ? probeRef.toJSONL() : '',
verify: probeRef ? probeRef.verify() : { ok: false, reason: 'no-probe' },
anchors: probeRef ? probeRef.getRecords().map((r) => r.anchor) : [],
uncaught: uncaughtRef.slice(),
initOk: initResRef ? initResRef.ok : false,
registeredPlugins: pluginSetRef,
}),
/** 闭环/失败态/定价证明boot 期纯逻辑跑出)。 */
get loopProof() { return gameForensics ? gameForensics.loopProof : null; },
@ -207,313 +111,79 @@ export async function bootRef(opts) {
measures: () => (gameForensics ? gameForensics.measures() : {}),
};
/** 逐帧刷新取证(游戏侧 checklist + host forensics 已是 getter无需额外刷)。 */
function refreshForensics() {
if (game && typeof game._refreshChecklist === 'function') game._refreshChecklist(checklist);
/** 状态行(仅人看;通用宿主已设过一轮,这里仅错误兜底用)。 */
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
/*
* 共用收尾 bundle 注册插件 建游戏 init 打链 挂取证real/stub 都调
* renderCtx游戏 render(g) 的绘制面real=引擎 mainContext / stub=#game ctx
* inputCanvasGetter输入 rect 映射用的可见画布real=引擎 mainCanvas / stub=#game
* */
async function setupCommon(renderCtx, inputCanvasGetter) {
// 1) bundlecontext2d=绘制面(受控面 getContext2d 一次性快照此 ctx§12-M4 必须就绪后建)。
// engineFactoryreal 经 createEngineCaps 真背书引擎stub 短路返 null保 null-engine 回归)。
bundle = createHostDevContext({
context2d: renderCtx,
seed,
audioFactory,
clock: () => mockNowMs,
engineFactory: () => createEngineCaps({
LJS: engineMode === 'real' ? LJS : null, // stub 不持引擎名字空间createEngineCaps 首行按 engineMode 短路返 null
engineMath: engineMode === 'real' ? buildEngineMath(LJS) : null,
engineMode,
// 取证 recHook注 window.__engineCalls门② call-ID。ref-bundle 即取证用(非渠道产物,同 host-dev 现状)。
recHook: (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } },
}),
});
// 2) 注册 7 件业务插件probe 宿主自管保证六锚偏序——host-dev 同款编排)。
const registry = new PluginRegistry();
registry
.register(collision)
.register(physics)
.register(gamefeel)
.register(juice)
.register(palettePost)
.register(save)
.register(audioMusic);
registry.useContext(bundle.context);
initRes = registry.initAll();
// 3) runtime-probe宿主自管六锚 canonical 顺序t_input_bound 恰在 first_paint 后)。
pluginSet = registry.list().concat(['runtime-probe']);
probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true });
const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe');
mark('t_boot', { by: 'ref-host', stage: 'pre-init', engineMode });
mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok });
// 4) 经装载契约建游戏实例host 经 opts 注入 runtime插件实例 + bundle + 取真 AudioContext 回调)。
game = createShopGame({
runtime: {
plugins,
bundle,
getRealAudioCtx: () => realAudioCtx,
},
});
// 5) game.init建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱ctx/mainContext/canvas/seed
await game.init({
ctx: bundle.context,
mainContext: renderCtx,
canvas: inputCanvasGetter(),
seed,
});
gameForensics = game._forensicsView();
mark('t_game_init', {
assetsLoaded: gameForensics.assetsLoaded().loaded,
assetsTotal: gameForensics.assetsLoaded().total,
loopProofOk: gameForensics.loopProof && gameForensics.loopProof.ok,
});
// 6) 首帧 + 输入锚 + 启动链收尾(与轮 1 同序first_paint → probe.init 绑输入 → game_start
stepOneFrame();
mark('t_first_paint', { frame: bundle.frameCount() });
try {
probe.init(probeCtx); // 绑输入 → t_input_bound恰在 first_paint 之后,偏序成立)
} catch (e) {
uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) });
}
mark('t_game_start', { mode, engineMode });
// 7) 挂取证real 通道顶层不可早挂——bundle/game 此刻才存在§12-M1
refreshForensics();
window.__refForensics = forensics;
window.__refChecklist = checklist;
window.__refHost = refHost;
}
/* ── 单帧推进stub RAF / evidence stepFrames / 首帧共用game.update + game.render绘制面=renderCtx。 ── */
/** @type {CanvasRenderingContext2D|null} */
let stubRenderCtx = null;
function stepOneFrame(dt) {
const d = typeof dt === 'number' ? dt : FIXED_DT;
if (!game) return;
// 【mustFix #2】唯一时钟在 game.update 之前自增stub 驱动点):使 bundle.clock 与游戏逻辑读同一推进值,
// 且与原 game-shop「update 首行 += dt*1000」逐行语义等价游戏在 update 内读到的钟值不变)。
mockNowMs += d * 1000;
game.update(d);
// stub 通道绘制game.render(g) 内 drawFrame 首调 drawBackdrop 全屏铺底(不另 clearBlack——与轮 1 frame() 逐行等价,
// 保逐像素门⑤双开哈希一致real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制)。
if (engineMode === 'stub' && stubRenderCtx) {
game.render(stubRenderCtx);
}
refreshForensics();
}
/** 确定性推进 n 帧evidence 驱动 / 启动首帧)。 */
function stepFrames(n) {
for (let i = 0; i < n; i++) stepOneFrame(FIXED_DT);
}
/* ── 首个用户手势:解锁音频(创建/恢复 AudioContext+ 通知游戏起 BGM。幂等。 ── */
function unlockAudioOnce() {
if (audioUnlocked) return;
audioUnlocked = true;
const ac = audioFactory();
if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {});
if (game && typeof game._onAudioUnlock === 'function') game._onAudioUnlock();
}
/* ── refHost 驱动句柄evidence 驱动 / __refHost.do── */
/* ── refHost 驱动句柄evidence 驱动 / __refHost.do在通用 host 句柄上扩展wanglanmei取证面
* 先于 bootGameHost 声明onReady 回调 await 内执行会把它挂上 window.__refHost
* 故必须在调 bootGameHost 之前完成声明避免 const TDZ其方法体延迟读 coreHost彼时已赋值 */
const refHost = {
seed,
mode,
engineMode,
protocol: CORE_PROTOCOL_VERSION,
frameCount: () => (bundle ? bundle.frameCount() : 0),
stepFrames,
startRaf: () => startRaf(),
stopRaf: () => stopRaf(),
frameCount: () => (coreHost ? coreHost.frameCount() : 0),
stepFrames: (n) => coreHost && coreHost.stepFrames(n),
startRaf: () => coreHost && coreHost.startRaf(),
stopRaf: () => coreHost && coreHost.stopRaf(),
/** 注入一次点击evidence 驱动;与真输入同走受控桥)。 */
tap(x, y) {
unlockAudioOnce();
if (bundle) {
bundle.inputBridge._emit('pointerdown', { x, y });
bundle.inputBridge._emit('pointerup', { x, y });
}
},
tap: (x, y) => coreHost && coreHost.tap(x, y),
/** 直接命令e2e 辅助;与 UI 同走逻辑公开 API。 */
do(action) {
if (game && typeof game._handleAction === 'function') game._handleAction(action);
},
do: (action) => coreHost && coreHost.do(action),
state: () => forensics.state(),
forensics,
};
/*
* 输入桥接DOM 事件 归一化坐标 受控桥 _emit
* real监听挂引擎 mainCanvas坐标按引擎可见画布 rect§12-M4stub #game逐行不变
* 监听挂载在 bundle 就绪后inputBridge 存在 real setupReal 内接stub setupStub 内接
* */
function attachInput(targetCanvas) {
const inputBridge = bundle.inputBridge;
function toCanvasXY(clientX, clientY) {
const rect = targetCanvas.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * VIEW_W,
y: ((clientY - rect.top) / rect.height) * VIEW_H,
};
}
targetCanvas.addEventListener('pointerdown', (ev) => {
unlockAudioOnce();
inputBridge._emit('pointerdown', toCanvasXY(ev.clientX, ev.clientY));
// ── 通道就绪/错误全局初值CDP 驱动据此分流real 在 onReady 置 true失败置 __refBootError──
window.__refBootedReal = false;
try {
// 调通用宿主把wanglanmei专属项经 opts 注入(取自共用配置 refCfgfactory/插件/视口/wiring引擎骨架复用 bootGameHost。
coreHost = await bootGameHost({
canvas,
statusEl,
engine: opts.engine, // entry 包装层注入的引擎名字空间real 用stub/node 为 undefined
seed,
mode,
engineMode,
viewport: refCfg.viewport, // 【D4】wanglanmei 视口390×844共用配置
factory: refCfg.factory, // 【D1】游戏工厂来源替 P1 静态 import 后写死调用)
plugins: refCfg.plugins, // 【D3】wanglanmei 7 件插件实例集
registerOrder: refCfg.registerOrder, // 注册偏序(保六锚 canonical 顺序)
buildFactoryOpts: refCfg.buildFactoryOpts, // host wiringruntime:{plugins,bundle,getRealAudioCtx}
// 取证 recHook注 window.__engineCalls门② call-ID。ref-bundle 即取证用(非渠道产物,同 host-dev 现状)。
recHook: (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } },
/** 就绪钩子:受控面/游戏全就绪后把宿主内部件接进wanglanmei取证对象 + 挂取证全局§12-M1。 */
onReady: (host, internals) => {
// 绑定内部件forensics 闭包据此读真值)。
gameForensics = internals.getGameForensics();
probeRef = internals.probe;
uncaughtRef = internals.uncaught;
pluginSetRef = internals.pluginSet;
initResRef = internals.initRes;
// 【防双 checklist 漂移】复用 bootGameHost 内部那份「逐帧 _refreshChecklist 刷新」的 checklist
// 而非本地另建的空对象——否则营业期 linesShownLive 等子项恒停在 boot 值refForensics 刷的是 host 那份)。
checklist = internals.checklist;
// real 专属就绪标记driver real 分支等它再驱动stub 通道保持 false
window.__refBootedReal = engineMode === 'real';
// 引擎五回调首触标记镜像bootGameHost 置 __gameHostEngineInitFiredref 取证读 __refEngineInitFired
if (window.__gameHostEngineInitFired) window.__refEngineInitFired = true;
// 挂取证全局(兼容 P1 CDP 驱动 + node 测__refForensics/__refChecklist/__refHost
// __refChecklist 指向 host 内部逐帧刷新的同一对象,保营业期实时子项可读。
window.__refForensics = forensics;
window.__refChecklist = checklist;
window.__refHost = refHost;
},
});
targetCanvas.addEventListener('pointermove', (ev) => {
inputBridge._emit('pointermove', toCanvasXY(ev.clientX, ev.clientY));
});
targetCanvas.addEventListener('pointerup', (ev) => {
inputBridge._emit('pointerup', toCanvasXY(ev.clientX, ev.clientY));
});
}
/* ── RAFstub play 模式)── */
let rafId = 0;
let rafRunning = false;
let lastTs = 0;
function rafLoop(ts) {
if (!rafRunning) return;
if (lastTs === 0) lastTs = ts;
let dt = (ts - lastTs) / 1000;
lastTs = ts;
if (dt > 0.05) dt = 0.05; // 钳大跳帧
stepOneFrame(dt);
rafId = window.requestAnimationFrame(rafLoop);
}
function startRaf() {
if (engineMode !== 'stub') return; // real 通道引擎内部 RAF 驱动五回调host 不起 RAF
if (rafRunning) return;
rafRunning = true;
lastTs = 0;
rafId = window.requestAnimationFrame(rafLoop);
}
function stopRaf() {
rafRunning = false;
if (rafId) window.cancelAnimationFrame(rafId);
rafId = 0;
}
/*
* stub 通道缺省·回滚锚prepareCanvas(#game) 2D + 共用收尾 + 输入挂 #game + 浏览器 RAF/evidence
* */
async function setupStub() {
const g = prepareCanvas(canvas);
stubRenderCtx = g;
await setupCommon(g, () => canvas); // 绘制面=#game ctx输入画布=#game首帧由 setupCommon 内 stepOneFrame 经 drawBackdrop 铺底,与轮 1 逐行等价)
attachInput(canvas); // 输入监听挂 #game逐行不变
if (mode === 'evidence') {
setStatus(`evidence 就绪 seed=${seed} · 用 window.__refHost 驱动`);
} else {
setStatus(`seed=${seed} · 点「开店!」开始(首次点击开声)`);
startRaf();
}
return refHost;
}
/*
* real 通道引擎掌帧setGLEnable(false) engineInit 五回调 setupRealbundle/game/输入/就绪标记全在 then
* 仿 host.js:897-928控制流=实质重构不是局部插桩§12-M4
* */
// 引擎五回调实体经 holder 转发到 setupReal 内真实现(仿 host.js:880-887 _engStep 范式engineInit 须先拿到回调)。
/** @type {() => void} */ let _engUpdate = () => {};
/** @type {() => void} */ let _engRender = () => {};
function engGameInit() { window.__refEngineInitFired = true; }
function engGameUpdate() { _engUpdate(); }
function engGameUpdatePost() { /* ref 无需逻辑步后处理 */ }
function engGameRender() { _engRender(); }
function engGameRenderPost() { /* ref 后处理走 game.render 内 palettePost.renderPost此回调留空 */ }
/**
* setupReal引擎 engineInit 就绪后mainContext 存在的收尾一切 bundle/game/输入/就绪标记都在此§12-M1/M4
* @param {CanvasRenderingContext2D} mainContext 引擎 mainContext 2D 绘制面
*/
async function setupReal(mainContext) {
// 共用收尾bundle context2d=mainContext§12-M4 必须 then 内以 mainContext 建);输入画布=引擎 mainCanvas。
await setupCommon(mainContext, () => LJS.mainCanvas);
// 输入监听挂引擎 mainCanvas坐标按引擎可见画布 rect§12-M4不再硬绑 #game
attachInput(LJS.mainCanvas);
// 引擎五回调实体接线:每逻辑步 game.update(FIXED_DT);每渲染帧 game.render(mainContext)(引擎已清屏)。
// real 下 DPR 补偿:引擎 mainContext 无 DPR 变换(每帧 updateCanvas 重置单位阵),游戏按逻辑像素绘制 →
// 在 save/restore 内补 setTransform(DPR) + imageSmoothingEnabled=false对齐 host.js:489 renderParticles 补偿)。
// 【mustFix #2】real 驱动点:每逻辑步在 game.update 之前自增唯一时钟(与 stub stepOneFrame 同口径),
// 使 bundle.clock 与游戏逻辑读同一推进值(修双钟分裂致输入缓冲补打静默失效)。
_engUpdate = () => { if (game) { mockNowMs += FIXED_DT * 1000; game.update(FIXED_DT); } };
_engRender = () => {
if (!game) return;
mainContext.save();
mainContext.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换host 补,保游戏逻辑像素口径
try { mainContext.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ }
game.render(mainContext);
mainContext.restore();
};
// 引擎自建 mainCanvas 打 idCDP 取证选择器=#game-engine仿 host.js:830real 下 #game 不是可见画布)。
if (LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine';
// 暴露引擎双时钟(仿 host.js:840门① 掌帧断言读 window.__engine.snapshot())。
window.__engine = {
frame() { return LJS.frame; },
time() { return LJS.time; },
timeReal() { return LJS.timeReal; },
paused() { return LJS.paused; },
timeScale() { return LJS.timeScale; },
snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; },
};
// 【M1 硬约束·real 专属就绪标记】game.init 完成 + 受控面全就绪后置(仿 host.js:920。driver real 分支等它再驱动。
window.__refBootedReal = true;
setStatus(`engine RAF running · seed=${seed} engine=real`);
}
/*
* 通道分流stub缺省回滚锚 real引擎掌帧
* */
if (engineMode !== 'real') {
// stub 回滚通道:行为与轮 1 逐行等价(逐像素门⑤兜)。
window.__refBootedReal = false; // 标记非 real 通道driver stub 分支不等它)
return await setupStub();
}
// 【Option C 守卫】real 通道必须由 entry.js 注入引擎opts.engine缺失说明经 main.js 直 import bootRef未走 entry 包装)。
// 显式 throw 而非裸 LJS.xxx 抛 TypeError便于 __refBootError 镜像可读错因index.html/CDP 据此判 real 失败)。
if (!LJS) {
const err = 'real 通道未注入引擎opts.engine 缺失):请经 entry.js 的 bootRef 包装层启动main.js 不直接 import 引擎Option C';
window.__refBootError = err;
} catch (e) {
// 引擎接管/装载失败:镜像 __refBootError兼容 P1 index.html/CDP 据此判 real 失败→回滚 stub重抛。
window.__refBootError = window.__gameHostBootError || String((e && e.message) || e);
window.__refBootedReal = false;
setStatus('ENGINE INJECT MISSING: ' + err + '(可加 ?engine=stub 回滚桩)');
throw new Error(err);
setStatus('BOOT ERROR: ' + window.__refBootError + '(可加 ?engine=stub 回滚桩)');
throw e;
}
// real 通道:引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。
// 1) setGLEnable(false):纯 2D 渲进 mainContextreadback 无 WebGL 坑harness §5-坑5
// 2) setShowSplashScreen(false):关开屏遮挡(默认已 false显式兜底
// 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true每帧画非确定 averageFPS 进 mainContext污染 readback必须关
// 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0) 不铺底;强制不透明 BLACK底色语义与 stub clearBlack 对齐。
LJS.setGLEnable(false);
LJS.setShowSplashScreen(false);
LJS.setDebugWatermark(false);
LJS.setCanvasClearColor(LJS.BLACK);
// 固定 backing=780×1688=逻辑 390×844 × DPR2取证固定环境
LJS.setCanvasFixedSize(LJS.vec2(VIEW_W * DPR, VIEW_H * DPR));
// rootElement用专用空容器引擎 engineInit 会整体覆盖容器样式,不能复用承载 #status/#game 的 #wrap。建独立 div 挂 body。
const engineRoot = document.createElement('div');
engineRoot.id = 'engine-root';
document.body.appendChild(engineRoot);
// engineInit 异步resolve 后 mainContext 就绪 → setupReal 建 bundle/game/输入/就绪标记。
await LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot)
.then(() => setupReal(LJS.mainContext))
.catch((e) => {
// 引擎接管失败:镜像 __refBootErrorstatus 提示可加 ?engine=stub 回滚;门④/CDP 据此判 real 失败→回滚 stub。
window.__refBootError = String((e && e.message) || e);
window.__refBootedReal = false;
setStatus('ENGINE INIT FAILED: ' + window.__refBootError + '(可加 ?engine=stub 回滚桩)');
throw e; // 重抛index.html bootRef().catch 镜像 __refBootError + 评估门看 console
});
return refHost;
}

View File

@ -0,0 +1,79 @@
/**
* ref-host-config.js wanglanmei-ref 宿主装配配置单一来源bootRef P2 bundle 入口共用
* ownerRunner v2 P1P2 消费方①main.js bootRefref 取证薄包装②entry-bundle.jsP2 studio-consumable bundle 入口
*
* 为什么抽出本模块防第三套
* P2 通用宿主 bootGameHost 需要三组 wanglanmei 专属入参游戏工厂 / 7 件插件实例集 / 视口 / 工厂 wiring
* 这组wanglanmei 怎么配 bootGameHost的装配逻辑ref 取证bootRef P2 bundleentry-bundle两处都要用
* 若各写一份 出现两套 wanglanmei 装配 spec不产生第三套故提取为本配置工厂两入口共用同一份
*
* Q4 铁律本模块零引擎 import仅装配插件实例 + 视口 + 工厂 wiring引擎由 bootGameHost opts.engine 注入
*/
'use strict';
// wanglanmei 复用的 7 件业务插件工厂host 拥有,经工厂 opts 注入给游戏,一职一路)。
import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js';
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
// 装载契约 game 工厂(默认导出 GameHostFactory零引擎 importA3 硬独立文件)。
import createShopGame from './game-shop.js';
// 渲染常量(视口宽高)。
import { W as VIEW_W, H as VIEW_H } from './render.js';
/** wanglanmei 游戏工厂GameHostFactory。P2 bundle 入口把它作 window.__GameBundle.GameHostFactory 暴露。 */
export const refFactory = createShopGame;
/** wanglanmei 逻辑视口390×844取证固定环境P2 阶段固定,后续从 GamePackage 取)。 */
export const refViewport = { w: VIEW_W, h: VIEW_H };
/**
* 装配 wanglanmei bootGameHost 所需的全部专属入参工厂/插件实例集/注册偏序/视口/工厂 wiring
* bootRef entry-bundle 各自补 canvas/statusEl/engine/seed/mode/engineMode/recHook/onReady 后传 bootGameHost
* @param {{seed:number}} args
* @returns {{factory:Function, plugins:object, registerOrder:Array<object>, viewport:{w:number,h:number}, buildFactoryOpts:Function}}
*/
export function buildRefHostConfig(args) {
const seed = args.seed >>> 0;
/* ── wanglanmei 7 件业务插件实例host 拥有;游戏经工厂 opts 复用,一职一路)── */
const collision = createCollisionPlugin({ cellSize: 96 });
const physics = createPhysicsLitePlugin();
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
// P9 后端:浏览器 localStorage不可用环境回退内存 adapter错误路径留痕
let saveAdapter;
try {
saveAdapter = createLocalStorageAdapter();
} catch (e) {
console.warn('[ref] localStorage 不可用,存档退内存 adapter' + (e && e.message));
saveAdapter = createMemoryAdapter();
}
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: saveAdapter });
const plugins = { collision, physics, gamefeel, juice, palettePost, audioMusic, save };
// 注册偏序保六锚偏序collision→physics→gamefeel→juice→palettePost→save→audioMusic
const registerOrder = [collision, physics, gamefeel, juice, palettePost, save, audioMusic];
return {
factory: refFactory,
plugins,
registerOrder,
viewport: refViewport,
/** 构造工厂 optshost wiringwanglanmei game-shop 需要 runtime:{plugins,bundle,getRealAudioCtx}。 */
buildFactoryOpts: (_bootCtx, hostInternals) => ({
runtime: {
plugins: hostInternals.plugins,
bundle: hostInternals.bundle,
getRealAudioCtx: hostInternals.getRealAudioCtx,
},
}),
};
}

View File

@ -22,6 +22,10 @@ const MODULES = [
['../src/render.js', ['drawFrame', 'computeZones', 'shelfSlotRect', 'LAYOUT']],
['../src/main.js', ['bootRef']],
['../entry.js', ['bootRef']],
// 【P2】wanglanmei 宿主装配配置bootRef 与 P2 bundle 入口共用)+ P2 studio-consumable bundle 入口。
// 二者均须 node import-saferef-host-config 零引擎 importentry-bundle 经动态 import 引擎Option Cnode import 不触引擎。
['../src/ref-host-config.js', ['buildRefHostConfig', 'refFactory', 'refViewport']],
['../entry-bundle.js', ['GameHostFactory', 'bootGameHost']],
];
for (const [path, expectedExports] of MODULES) {

View File

@ -8,7 +8,9 @@
"test": "node --test src/**/*.test.mjs",
"build": "node scripts/build.mjs",
"size": "node scripts/size.mjs",
"validate": "node scripts/validate-manifest.mjs"
"validate": "node scripts/validate-manifest.mjs",
"build:ref-bundle": "node scripts/build.mjs games/wanglanmei-ref/entry-bundle.js games/wanglanmei-ref/dist/ref-bundle.iife.js --global-name=__GameBundle",
"build:ref-harness": "node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef"
},
"dependencies": {
"littlejsengine": "1.18.19"

View File

@ -0,0 +1,452 @@
/**
* src/host/boot-game-host.js 通用游戏宿主Runner v2 P2 · 装载任意 GamePackage 的引擎宿主
* ownerRunner v2 P2 消费方①ref 取证 bootRef薄包装传wanglanmei专属参数保 P1 取证不回归
* ②studio host 引擎包分支 window.__GameBundle GameHostFactory经本宿主 boot feed 真玩
*
*
* 本文件是什么 · P2 泛化的核心
* P1 bootRefgames/wanglanmei-ref/src/main.js装载wanglanmei一款游戏跑引擎
* engineInit 五回调骨架 + 受控面背书 + 输入接线 + 单一推进时钟全部实现了但游戏工厂来源
* 静态 `import createShopGame`插件清单视口三处写死成wanglanmei专属
* P2 把这套骨架**整块照搬语义零变**地提升到本通用模块 bootGameHost(opts)并把上述三处
* **参数化** opts使装载任意 GamePackage成立这是泛化既有 bootRef不是新建
*
* 放置位置 · 为何落 src/host 而非 wanglanmei 专属目录
* 通用宿主的消费方有二ref 取证 + studio host不再隶属wanglanmei故按 spec §3.3
* host/runtime 共享位src/host src/core / src/plugins 同级import 方向顺
* ../core/plugin.js / ../plugins/* / ../../host-dev/engine-caps.js拆仓不断
*
* Q4 铁律不破本模块不顶层 import 引擎littlejsengine引擎名字空间经 opts.engine 注入
* real 通道由 entry 包装层动态 import 后传入stub/node 通道为 undefined node import-safe
* 游戏工厂opts.factory零引擎 import一律经 boot.ctx.getEngine() 拿引擎能力
*
* 装载契约game-runtime/src/core/game-host.d.ts零改·复用
* - opts.factoryGameHostFactory = (opts?) => GameInstance本宿主调它建游戏实例
* - boot 脊柱 = { ctx, mainContext, canvas, seed }game-host.d.ts:25-30本宿主在 game.init 时一次性注入
* - GameInstance 五生命周期 init/update/render/onInput?/destroygame-host.d.ts:42-48
*
* 双通道P1 沿用
* - engineMode='stub'prepareCanvas(canvas) 2D + 浏览器 RAF + 单一墙钟行为与 P1 stub 逐行等价回滚锚
* - engineMode='real'注入引擎setGLEnable(false)engineInit 五回调驱动绘制面=引擎 mainContext与引擎粒子同屏
*
*/
'use strict';
// 受控面/注册器/协议号src/core跨通道复用本模块不背书引擎引擎经 opts.engine 注入)。
import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../core/plugin.js';
// runtime-probe宿主自管六锚链取证用P2 通用宿主保留它作启动链证据,与 P1 同款编排)。
import { createRuntimeProbePlugin } from '../plugins/runtime-probe/impl.js';
// 引擎能力背书工厂EngineCapabilities 三面)—— host-dev / ref real 通道共用同一份P1 已模块化,本模块零改 import
import { createEngineCaps } from '../../host-dev/engine-caps.js';
// 引擎数学门面构造(纯函数,零引擎 importreal 通道用它包装引擎 lerp/smoothStep/Ease。
import { buildEngineMath } from '../../host-dev/engine-math.js';
/** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐P1 沿用)。 */
const DPR = 2;
const FIXED_DT = 1 / 60;
/**
* 启动通用游戏宿主返回 host 句柄含驱动钩子 onReady 组装取证 / 上层驱动
*
* @param {Object} opts 宿主选项P2 泛化的全部参数化入口ref/studio 各自按形填充
* @param {HTMLCanvasElement} opts.canvas 可见画布stub=2D 绘制面real=仅占位引擎自建 mainCanvas
* @param {HTMLElement} [opts.statusEl] 状态行仅人看可空
* @param {any} [opts.engine] 引擎名字空间littlejsengineentry 包装层在 real 通道注入stub/node undefined
* @param {number} opts.seed 主随机种子确定性调用方已解析不再读 URL
* @param {'play'|'evidence'} [opts.mode='play'] 运行模式evidence=stub 下不起 RAF host.stepFrames/tap 驱动
* @param {'stub'|'real'} [opts.engineMode='real'] 引擎通道'real' 引擎掌帧 / 'stub' 浏览器 RAF 回滚桩
* ref 缺省 stub兜逐像素回归 bootRef 包装层决定studio 引擎包分支一律 real
* @param {{w:number,h:number}} opts.viewport 逻辑视口 P1 写死的 VIEW_W/VIEW_HP2 阶段 ref 390×844后续从 GamePackage
* @param {import('../core/game-host.d.ts').GameHostFactory} opts.factory 游戏工厂 P1 静态 import createShopGame
* @param {(boot: import('../core/api.d.ts').PluginContext, host: object) => (Record<string, unknown>|undefined)} [opts.buildFactoryOpts]
* 构造工厂 optshost wiring如wanglanmei的 runtime:{plugins,bundle,getRealAudioCtx}返回值原样传给 opts.factory
* - 入参 boot=本帧受控面 PluginContexthost={bundle, plugins, getRealAudioCtx}宿主内部句柄 wiring 取用
* - 缺省返回 undefined纯净工厂仅吃 boot 脊柱不需 host 私有 wiringP3 多数生成游戏走此默认
* @param {Object} [opts.plugins] 业务插件实例集host 拥有已注册+init游戏经工厂 opts 复用一职一路
* - 形如 { collision, physics, gamefeel, ... }键名由游戏约定缺省=空集无业务插件 runtime-probe
* @param {Array<object>} [opts.registerOrder] 插件注册偏序决定 init 顺序缺省=Object.values(opts.plugins) 的插入序
* @param {(host: object, internals: object) => void} [opts.onReady] 就绪钩子受控面/游戏全就绪后回调
* ref 用它挂 window.__refForensics/__refChecklist/__refHoststudio/P3 通用宿主可不传不强制每款实现取证
* internals={ bundle, game, probe, checklist, getGameForensics, mark, stepOneFrame }宿主内部件供取证组装
* @param {((id:string)=>void)} [opts.recHook] 引擎能力 call-ID 取证钩子透传 createEngineCaps缺省 no-op渠道产物无副作用
* @returns {Promise<object>} host 句柄seed/mode/engineMode/frameCount/stepFrames/startRaf/stopRaf/tap/do/state
*/
export async function bootGameHost(opts) {
const canvas = opts.canvas;
const statusEl = opts.statusEl || null;
// 【Q4 · Option C】引擎名字空间经 opts.engine 注入real 通道由 entry 包装层传stub/node 为 undefined保 node import-safe
const LJS = opts.engine;
const seed = opts.seed >>> 0; // 调用方已解析种子(不再读 URLref 在包装层解析后传入)。
const mode = opts.mode || 'play'; // 'play' | 'evidence'
const engineMode = opts.engineMode || 'real'; // 'real'(引擎掌帧)| 'stub'(浏览器 RAF 回滚桩)。
// 【P2 泛化 · D4】逻辑视口参数化替 P1 写死的 render.js W/H
const VIEW_W = opts.viewport.w;
const VIEW_H = opts.viewport.h;
// 【P2 泛化 · D1】游戏工厂来源参数化替 P1 静态 import createShopGame
const factory = opts.factory;
// 【P2 泛化 · host wiring】构造工厂 opts 的回调wanglanmei注 runtimeP3 纯净工厂缺省 undefined
const buildFactoryOpts = opts.buildFactoryOpts || (() => undefined);
// 【P2 泛化 · D3】业务插件实例集host 拥有;游戏经工厂 opts 复用)。缺省空集(仅 runtime-probe
const pluginInstances = opts.plugins || {};
// 插件注册偏序(决定 initAll 顺序与六锚偏序);缺省取 plugins 值的插入序。
const registerOrder = opts.registerOrder || Object.values(pluginInstances);
// 就绪钩子ref 挂取证全局;通用宿主可不传)。
const onReady = opts.onReady || (() => {});
// 引擎 call-ID 取证钩子(透传 createEngineCaps缺省 no-op
const recHook = opts.recHook || (() => {});
/** 状态行(仅人看)。 */
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
/* ── 未捕获错误归集评估门console 零未捕获错误)── */
/** @type {Array<{type:string,message:string}>} */
const uncaught = [];
window.addEventListener('error', (e) => uncaught.push({ type: 'error', message: String((e && e.message) || e) }));
window.addEventListener('unhandledrejection', (e) =>
uncaught.push({ type: 'unhandledrejection', message: String((e && e.reason && e.reason.message) || (e && e.reason) || e) })
);
/* ── 受控音频工厂(用户手势解锁后才 new宿主=受控面提供方)── */
let realAudioCtx = null;
let audioUnlocked = false;
function audioFactory() {
if (!audioUnlocked) return null;
if (realAudioCtx) return realAudioCtx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
realAudioCtx = new AC();
return realAudioCtx;
}
/* /single source of truthP1 mustFix #2 沿
* mockNowMs 是唯一真相 game.update 驱动点之前自增stubstepOneFrame real_engUpdate
* bundle.clock 读它游戏侧经 boot.ctx.time.nowMs() 读同一钟并轨成单钟避免双钟分裂致输入缓冲补打静默失效 */
let mockNowMs = 0;
/* ── 受控面 bundle + 游戏实例realengineInit 就绪后建stub立即。延后建避免快照错 ctx§12-M4。 ── */
/** @type {ReturnType<typeof createHostDevContext>|null} */
let bundle = null;
/** @type {import('../core/game-host.d.ts').GameInstance|null} */
let game = null;
/** @type {ReturnType<typeof createRuntimeProbePlugin>|null} */
let probe = null;
let initRes = null;
let pluginSet = [];
/** checklist游戏侧自填逐帧刷新供取证。 */
const checklist = {};
/** 游戏取证视图realsetupCommon 内 game.init 后取)。 */
let gameForensics = null;
/** 安全打点probe 异常不中断启动链)。 */
function mark(anchor, extra) {
try {
if (probe) probe.mark(anchor, extra);
} catch (e) {
uncaught.push({ type: 'mark', message: String((e && e.message) || e) });
}
}
/** 逐帧刷新游戏取证(游戏侧实现 _refreshChecklist 时调;通用宿主不强制)。 */
function refreshForensics() {
if (game && typeof game._refreshChecklist === 'function') game._refreshChecklist(checklist);
}
/*
* 共用收尾 bundle 注册插件 建游戏 init 打链 就绪钩子real/stub 都调
* renderCtx游戏 render(g) 的绘制面real=引擎 mainContext / stub=可见画布 ctx
* inputCanvasGetter输入 rect 映射用的可见画布real=引擎 mainCanvas / stub=可见画布
* */
async function setupCommon(renderCtx, inputCanvasGetter) {
// 1) bundlecontext2d=绘制面(受控面 getContext2d 一次性快照此 ctx§12-M4 必须就绪后建)。
// engineFactoryreal 经 createEngineCaps 真背书引擎stub 短路返 null保 null-engine 回归)。
bundle = createHostDevContext({
context2d: renderCtx,
seed,
audioFactory,
clock: () => mockNowMs,
engineFactory: () => createEngineCaps({
LJS: engineMode === 'real' ? LJS : null, // stub 不持引擎名字空间createEngineCaps 首行按 engineMode 短路返 null
engineMath: engineMode === 'real' ? buildEngineMath(LJS) : null,
engineMode,
recHook, // 透传 opts.recHookref 注 window.__engineCalls 取证;渠道产物缺省 no-op
}),
});
// 2) 注册业务插件registerOrder 决定偏序,保六锚 canonical 顺序)。
const registry = new PluginRegistry();
for (const p of registerOrder) registry.register(p);
registry.useContext(bundle.context);
initRes = registry.initAll();
// 3) runtime-probe宿主自管六锚 canonical 顺序t_input_bound 恰在 first_paint 后)。
pluginSet = registry.list().concat(['runtime-probe']);
probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true });
const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe');
mark('t_boot', { by: 'game-host', stage: 'pre-init', engineMode });
mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok });
// 4) 经装载契约建游戏实例host 经 buildFactoryOpts 注入 wiringwanglanmei runtime / P3 纯净工厂为 undefined
const factoryOpts = buildFactoryOpts(bundle.context, { bundle, plugins: pluginInstances, getRealAudioCtx: () => realAudioCtx });
game = factory(factoryOpts);
// 5) game.init建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱ctx/mainContext/canvas/seed
await game.init({
ctx: bundle.context,
mainContext: renderCtx,
canvas: inputCanvasGetter(),
seed,
});
// 游戏取证视图(可选;游戏未实现 _forensicsView 时 gameForensics 保持 null通用宿主不依赖
gameForensics = (game && typeof game._forensicsView === 'function') ? game._forensicsView() : null;
mark('t_game_init', { hasForensics: !!gameForensics });
// 6) 首帧 + 输入锚 + 启动链收尾(与 P1 同序first_paint → probe.init 绑输入 → game_start
stepOneFrame();
mark('t_first_paint', { frame: bundle.frameCount() });
try {
probe.init(probeCtx); // 绑输入 → t_input_bound恰在 first_paint 之后,偏序成立)
} catch (e) {
uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) });
}
mark('t_game_start', { mode, engineMode });
// 7) 就绪钩子ref 挂取证全局通用宿主可不传。internals 暴露宿主内部件供取证组装。
refreshForensics();
onReady(host, {
bundle,
game,
probe,
checklist,
uncaught,
pluginSet,
initRes,
getGameForensics: () => gameForensics,
mark,
stepOneFrame,
});
}
/* ── 单帧推进stub RAF / evidence stepFrames / 首帧共用game.update + game.render绘制面=renderCtx。 ── */
/** @type {CanvasRenderingContext2D|null} */
let stubRenderCtx = null;
function stepOneFrame(dt) {
const d = typeof dt === 'number' ? dt : FIXED_DT;
if (!game) return;
// 唯一时钟在 game.update 之前自增stub 驱动点):使 bundle.clock 与游戏逻辑读同一推进值P1 mustFix #2 口径)。
mockNowMs += d * 1000;
game.update(d);
// stub 通道绘制game.render(g) 自管铺底(与 P1 逐行等价real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制。
if (engineMode === 'stub' && stubRenderCtx) {
game.render(stubRenderCtx);
}
refreshForensics();
}
/** 确定性推进 n 帧evidence 驱动 / 启动首帧)。 */
function stepFrames(n) {
for (let i = 0; i < n; i++) stepOneFrame(FIXED_DT);
}
/* ── 首个用户手势:解锁音频(创建/恢复 AudioContext+ 通知游戏起 BGM。幂等。 ── */
function unlockAudioOnce() {
if (audioUnlocked) return;
audioUnlocked = true;
const ac = audioFactory();
if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {});
if (game && typeof game._onAudioUnlock === 'function') game._onAudioUnlock();
}
/* ── host 驱动句柄evidence 驱动 / 上层 host.do通用面不含 ref 专属取证——取证由 onReady 组装)── */
const host = {
seed,
mode,
engineMode,
protocol: CORE_PROTOCOL_VERSION,
frameCount: () => (bundle ? bundle.frameCount() : 0),
stepFrames,
startRaf: () => startRaf(),
stopRaf: () => stopRaf(),
/** 注入一次点击evidence 驱动;与真输入同走受控桥)。 */
tap(x, y) {
unlockAudioOnce();
if (bundle) {
bundle.inputBridge._emit('pointerdown', { x, y });
bundle.inputBridge._emit('pointerup', { x, y });
}
},
/** 直接命令e2e 辅助;与 UI 同走逻辑公开 API。 */
do(action) {
if (game && typeof game._handleAction === 'function') game._handleAction(action);
},
/** 游戏状态快照(游戏实现 _forensicsView().state 时透传;否则 booting。 */
state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }),
};
/*
* 输入桥接DOM 事件 归一化坐标 受控桥 _emit
* real监听挂引擎 mainCanvas坐标按引擎可见画布 rect§12-M4stub挂可见画布逐行不变
* */
function attachInput(targetCanvas) {
const inputBridge = bundle.inputBridge;
function toCanvasXY(clientX, clientY) {
const rect = targetCanvas.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * VIEW_W,
y: ((clientY - rect.top) / rect.height) * VIEW_H,
};
}
targetCanvas.addEventListener('pointerdown', (ev) => {
unlockAudioOnce();
inputBridge._emit('pointerdown', toCanvasXY(ev.clientX, ev.clientY));
});
targetCanvas.addEventListener('pointermove', (ev) => {
inputBridge._emit('pointermove', toCanvasXY(ev.clientX, ev.clientY));
});
targetCanvas.addEventListener('pointerup', (ev) => {
inputBridge._emit('pointerup', toCanvasXY(ev.clientX, ev.clientY));
});
}
/* ── RAFstub play 模式)── */
let rafId = 0;
let rafRunning = false;
let lastTs = 0;
function rafLoop(ts) {
if (!rafRunning) return;
if (lastTs === 0) lastTs = ts;
let dt = (ts - lastTs) / 1000;
lastTs = ts;
if (dt > 0.05) dt = 0.05; // 钳大跳帧
stepOneFrame(dt);
rafId = window.requestAnimationFrame(rafLoop);
}
function startRaf() {
if (engineMode !== 'stub') return; // real 通道引擎内部 RAF 驱动五回调host 不起 RAF
if (rafRunning) return;
rafRunning = true;
lastTs = 0;
rafId = window.requestAnimationFrame(rafLoop);
}
function stopRaf() {
rafRunning = false;
if (rafId) window.cancelAnimationFrame(rafId);
rafId = 0;
}
/*
* stub 通道回滚锚prepareCanvas(canvas) 2D + 共用收尾 + 输入挂可见画布 + 浏览器 RAF/evidence
* */
/** Canvas 设为逻辑 VIEW_W×VIEW_H / 物理 ×DPR并把 2D 上下文缩放到逻辑坐标stub 通道用)。 */
function prepareCanvas(c) {
c.width = VIEW_W * DPR;
c.height = VIEW_H * DPR;
c.style.width = VIEW_W + 'px';
c.style.height = VIEW_H + 'px';
const g = c.getContext('2d', { willReadFrequently: true });
g.setTransform(DPR, 0, 0, DPR, 0, 0);
return g;
}
async function setupStub() {
const g = prepareCanvas(canvas);
stubRenderCtx = g;
await setupCommon(g, () => canvas); // 绘制面=可见画布 ctx输入画布=可见画布
attachInput(canvas); // 输入监听挂可见画布
if (mode === 'evidence') {
setStatus(`evidence 就绪 seed=${seed} · 用 host 驱动stepFrames/tap`);
} else {
setStatus(`seed=${seed} · 点开始(首次点击开声)`);
startRaf();
}
return host;
}
/*
* real 通道引擎掌帧setGLEnable(false) engineInit 五回调 setupRealbundle/game/输入/就绪标记全在 then
* 仿 host.js:897-928控制流=实质重构不是局部插桩§12-M4
* */
// 引擎五回调实体经 holder 转发到 setupReal 内真实现engineInit 须先拿到回调)。
/** @type {() => void} */ let _engUpdate = () => {};
/** @type {() => void} */ let _engRender = () => {};
function engGameInit() { window.__gameHostEngineInitFired = true; }
function engGameUpdate() { _engUpdate(); }
function engGameUpdatePost() { /* 无需逻辑步后处理 */ }
function engGameRender() { _engRender(); }
function engGameRenderPost() { /* 后处理走 game.render 内(如 palettePost.renderPost此回调留空 */ }
/**
* setupReal引擎 engineInit 就绪后mainContext 存在的收尾一切 bundle/game/输入/就绪标记都在此§12-M1/M4
* @param {CanvasRenderingContext2D} mainContext 引擎 mainContext 2D 绘制面
*/
async function setupReal(mainContext) {
// 共用收尾bundle context2d=mainContext§12-M4 必须 then 内以 mainContext 建);输入画布=引擎 mainCanvas。
await setupCommon(mainContext, () => LJS.mainCanvas);
// 输入监听挂引擎 mainCanvas坐标按引擎可见画布 rect§12-M4
attachInput(LJS.mainCanvas);
// 引擎五回调实体接线:每逻辑步 game.update(FIXED_DT);每渲染帧 game.render(mainContext)(引擎已清屏)。
// real 下 DPR 补偿:引擎 mainContext 无 DPR 变换,游戏按逻辑像素绘制 → save/restore 内补 setTransform(DPR) + imageSmoothingEnabled=false。
// 唯一时钟在 game.update 之前自增(与 stub stepOneFrame 同口径P1 mustFix #2
_engUpdate = () => { if (game) { mockNowMs += FIXED_DT * 1000; game.update(FIXED_DT); } };
_engRender = () => {
if (!game) return;
mainContext.save();
mainContext.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换host 补,保游戏逻辑像素口径
try { mainContext.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ }
game.render(mainContext);
mainContext.restore();
};
// 引擎自建 mainCanvas 打 idCDP 取证选择器=#game-enginereal 下可见画布是引擎 mainCanvas
if (LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine';
// 暴露引擎双时钟(仿 host.js:840掌帧断言读 window.__engine.snapshot())。
window.__engine = {
frame() { return LJS.frame; },
time() { return LJS.time; },
timeReal() { return LJS.timeReal; },
paused() { return LJS.paused; },
timeScale() { return LJS.timeScale; },
snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; },
};
setStatus(`engine RAF running · seed=${seed} engine=real`);
}
/*
* 通道分流stub回滚锚 real引擎掌帧
* */
if (engineMode !== 'real') {
// stub 回滚通道:行为与 P1 stub 逐行等价(逐像素门兜)。
return await setupStub();
}
// 【Option C 守卫】real 通道必须由 entry 包装层注入引擎opts.engine缺失说明未走 entry 包装。
if (!LJS) {
const err = 'real 通道未注入引擎opts.engine 缺失):请经 entry 包装层启动host 不直接 import 引擎Q4/Option C';
setStatus('ENGINE INJECT MISSING: ' + err);
throw new Error(err);
}
// real 通道:引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。
// 1) setGLEnable(false):纯 2D 渲进 mainContextreadback 无 WebGL 坑harness §5-坑5
// 2) setShowSplashScreen(false):关开屏遮挡(默认已 false显式兜底
// 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true每帧画非确定 averageFPS 进 mainContext污染 readback必须关
// 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0) 不铺底;强制不透明 BLACK底色语义与 stub clearBlack 对齐。
LJS.setGLEnable(false);
LJS.setShowSplashScreen(false);
LJS.setDebugWatermark(false);
LJS.setCanvasClearColor(LJS.BLACK);
// 固定 backing=VIEW_W×VIEW_H × DPR取证固定环境
LJS.setCanvasFixedSize(LJS.vec2(VIEW_W * DPR, VIEW_H * DPR));
// rootElement用专用空容器引擎 engineInit 会整体覆盖容器样式,不能复用承载页面壳的容器)。建独立 div 挂 body。
const engineRoot = document.createElement('div');
engineRoot.id = 'engine-root';
document.body.appendChild(engineRoot);
// engineInit 异步resolve 后 mainContext 就绪 → setupReal 建 bundle/game/输入/就绪标记。
await LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot)
.then(() => setupReal(LJS.mainContext))
.catch((e) => {
// 引擎接管失败:镜像错因,重抛供上层捕获。
window.__gameHostBootError = String((e && e.message) || e);
setStatus('ENGINE INIT FAILED: ' + window.__gameHostBootError);
throw e;
});
return host;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,23 @@
{
"task": "P2 通用宿主泛化·真机验收门 (mini-desktop)",
"date": "2026-06-13",
"verdict": "P2 核心达成:引擎 bundle 真渲入 feed 真玩 — 已用真浏览器视觉+lifecycle+checksum 三锚证实非占位非假绿。game_end(到结算) 因 gameover 需分钟级真玩+srcdoc 坐标盲点未在窗口内到达(如实记录,非渲染失败)。",
"staging": { "studio": "localhost:4173 (vite preview --mode staging, mock=OFF 真连后端)", "backend": ":48080", "db": "game-staging-mysql / ruoyi-vue-pro", "engineGame": "gameId=9201 versionId=92010", "rollbackControl": "gameId=9202 versionId=92020 (无 engineBundle)" },
"gates": {
"gate1_build_ref_bundle": { "pass": true, "evidence": "dist/ref-bundle.iife.js raw=265374B gz=90202B; var __GameBundle= ×1; ParticleEmitter ×2 / engineInit ×3 (引擎真入包); metafile inputs 含 littlejsengine/dist/littlejs.esm.js; 导出 GameHostFactory+bootGameHost; import-graph 15/15" },
"gate2_java_compile": { "pass": true, "evidence": "mvn compile game-module-aigc -am EXIT=0; DifyCallbackServiceImplTest 全过 (含 packageBytesReproducible 两次组包 checksum=e710447... 字节一致, 证 engineBundle null→put跳过→零变)" },
"gate3_studio_staging_rebuild": { "pass": true, "evidence": "vite build --mode staging EXIT=0; preview 重启 systemd studio-preview.service, 日志 mode=staging mock=OFF(真连后端); :4173 不再返回 mock 假数据(霓虹躲避)" },
"gate4_seed": { "pass": true, "evidence": "scaffold 四态(9201: project=4∧version=3∧pkg=1∧feed_rank zone0)+seed engineBundle; 回读 status=1 ∧ checksum=1d815c12...(64hex) ∧ has_engine_bundle_key=1 ∧ has_frozen_global_name=1; manifest 端点 sha256(原文)==checksum 实证一致(raw-byte)", "defect_fixed": "seed 须用 --default-character-set=utf8mb4 否则 latin1 双编码致 checksum 链断(已修, 见报告)" },
"gate5_feed_walkthrough_cdp": {
"pass": "core_pass",
"engine_rendered": { "pass": true, "evidence": "截图 02-play-engine-render.png = 暖阳小卖部引擎真渲(店主/货架/开店按钮/资金条); warm=1246073/1316640px maxCh=255; 非「模板体系重构中」占位" },
"game_loaded": { "pass": true, "evidence": "lifecycle game_loaded{engine:'real',via:'bootGameHost'} 经 bridge 上抛" },
"game_start": { "pass": true, "evidence": "lifecycle game_start{via:'bootGameHost'} 经 bridge 上抛" },
"game_play_start_sessionId": { "pass": true, "via": "backend", "evidence": "驱动器 bridge 捕获点看不到 host 侧 track(假阴), 但后端 startSession log sessionId=19/20 + game_runtime_session 行(scene=play) 证 onStart→session/start→sessionId 链真活(mustFix#2 满足)" },
"anti_demo_fallback": { "pass": true, "evidence": "无回退 demo/manifest校验失败 console warn(0); 端点 sha256==checksum; 无 game_error; 无重构中占位" },
"console_clean": { "pass": true, "evidence": "console real errors=0 total=0" },
"game_end_completed": { "pass": "not_reached_in_window", "honest_note": "gameover 需多个 96s 营业日(DAY.lengthSec=96, startCash=1200, daysPerSegment=3 → 分钟级)且 srcdoc 跨 origin 内按钮坐标不可读(盲点); 真触摸已驱动且引擎持续真渲(warm=1254661), 但终态未在时间窗内达成。非引擎/渲染失败 — game_loaded+game_start+真渲+session 已证真玩链路通。" }
},
"gate6_rollback_channel": { "pass": true, "evidence": "9202(无 engineBundle): lifecycle game_error{template_system_under_reconstruction,clicker} ∧ game_loaded=false ∧ warm=1021(无引擎渲, 深底 demo 壳); 截图 06-rollback-9202.png=游戏加载失败错误态; 证 inject.ts pkg.engineBundle? 分流正确, 向后兼容未破" }
}
}

View File

@ -0,0 +1,25 @@
=== game 9201 four-state (engine game) ===
+-------------+-------+--------+-------+
| k | id | status | v |
+-------------+-------+--------+-------+
| project | 9201 | 4 | 92010 |
| version | 92010 | 3 | 9201 |
| runtime_pkg | 92010 | 1 | 92010 |
| feed_rank | 92010 | 1 | 0 |
+-------------+-------+--------+-------+
=== 9201 runtime_package checksum + engineBundle key ===
+------------+--------+------------------------------------------------------------------+-------------+------------+------------+
| version_id | status | checksum | bundle_size | has_eb_key | has_global |
+------------+--------+------------------------------------------------------------------+-------------+------------+------------+
| 92010 | 1 | 1d815c12d74dd0f17c07a7bab152ed71f42f5c71487f7b3342a06b2e7da64a25 | 271437 | 1 | 1 |
+------------+--------+------------------------------------------------------------------+-------------+------------+------------+
=== runtime_session (mustFix#2 proof: onStart fired) ===
+----+---------+------------+-------+---------------------+
| id | game_id | version_id | scene | create_time |
+----+---------+------------+-------+---------------------+
| 20 | 9201 | 92010 | play | 2026-06-14 04:23:56 |
| 19 | 9201 | 92010 | play | 2026-06-14 04:19:05 |
+----+---------+------------+-------+---------------------+
=== backend startSession log ===
2026-06-14 04:19:05.139 [http-nio-48080-exec-8] INFO  c.w.g.m.r.s.session.RuntimeSessionServiceImpl:72 - [startSession] 新建试玩会话 sessionId=19, gameId=9201, versionId=92010, scene=play, userId=1, anonId=
2026-06-14 04:23:56.166 [http-nio-48080-exec-7] INFO  c.w.g.m.r.s.session.RuntimeSessionServiceImpl:72 - [startSession] 新建试玩会话 sessionId=20, gameId=9201, versionId=92010, scene=play, userId=1, anonId=

View File

@ -0,0 +1,154 @@
{
"meta": {
"base": "http://localhost:4173",
"gameId": "9201",
"versionId": "92010",
"startedAt": "2026-06-13T20:23:27.529Z",
"driver": "p2-feed.driver.cjs"
},
"gates": {
"gate1_feed_card": true,
"gate2_game_loaded": true,
"gate2_game_start": true,
"gate2_game_play_start": false,
"gate2_engine_rendered": true,
"gate3_no_reconstruction_text": true,
"gate3_no_demo_fallback": true,
"gate2_interactive_render": true,
"gate2_game_end_completed": "not_reached_in_window",
"gate4_console_clean": true
},
"checks": [
{
"ok": true,
"msg": "feed 出 9201 卡(标题含「引擎真渲/王蓝莓」sample=推荐\n默认专区\n创作广场\n▶\n王蓝莓小卖部(引擎真渲)\n\n@\n\n▶ 0\n♥ 0\n♥\n0\n★\n藏\n➦\n享\n"
},
{
"ok": true,
"msg": "lifecycle game_loaded 已上抛(门② 就绪锚) via=bootGameHost"
},
{
"ok": true,
"msg": "lifecycle game_start 已上抛mustFix#2 会话/计数链)"
},
{
"ok": true,
"msg": "无 game_error引擎接管成功未走兜底"
},
{
"ok": false,
"msg": "mustFix#2: game_play_start 遥测上报play_count 权威源,会话链未断)"
},
{
"ok": true,
"msg": "引擎真渲非占位warm=1246073 bright=1286490 maxCh=255暖色小卖部满屏"
},
{
"ok": true,
"msg": "top frame 无「模板体系重构中」占位文案"
},
{
"ok": true,
"msg": "无 demo 兜底告警(回退 demo/manifest 校验失败 计 0"
},
{
"ok": true,
"msg": "权威 checksum端点 sha256 已实证)= 1d815c12d74dd0f1…"
},
{
"ok": true,
"msg": "真触摸后引擎仍真渲warm=1254661 maxCh=255"
},
{
"ok": true,
"msg": "console 零未捕获错误real=0total=0"
}
],
"lifecycle": [
{
"event": "sdk_ready",
"data": {
"gameId": "9201",
"versionId": "92010"
},
"t": 1781382235998
},
{
"event": "game_loaded",
"data": {
"engine": "real",
"via": "bootGameHost"
},
"t": 1781382236070
},
{
"event": "game_start",
"data": {
"via": "bootGameHost"
},
"t": 1781382236072
}
],
"console": {
"errors": [],
"demoWarns": []
},
"screenshots": [
"/root/games-development-ai/game-studio/evidence/p2-feed/01-feed.png",
"/root/games-development-ai/game-studio/evidence/p2-feed/02-play-engine-render.png",
"/root/games-development-ai/game-studio/evidence/p2-feed/03-after-newgame.png",
"/root/games-development-ai/game-studio/evidence/p2-feed/04-after-openshop.png"
],
"notes": [
{
"feedProbe": {
"hasTitle": true,
"playLink": null,
"bodyLen": 79,
"sample": "推荐\n默认专区\n创作广场\n▶\n王蓝莓小卖部(引擎真渲)\n\n@\n\n▶ 0\n♥ 0\n♥\n0\n★\n藏\n➦\n享\n⚑\n报\n◎\n游戏流\n✦\n创作\n◰\n我的"
}
},
{
"telemetry": []
},
{
"sessionProbe": {
"hasPlayStartTelem": false
}
},
{
"pixelStats": {
"px": 1316640,
"bright": 1286490,
"warm": 1246073,
"maxCh": 255,
"nonblack": 1316086,
"w": 780,
"h": 1688
}
},
{
"expectChecksum": "1d815c12d74dd0f17c07a7bab152ed71f42f5c71487f7b3342a06b2e7da64a25"
},
{
"pixelStatsAfterDrive": {
"px": 1316640,
"bright": 1298479,
"warm": 1254661,
"maxCh": 255,
"nonblack": 1316142,
"w": 780,
"h": 1688
}
},
{
"gameEnd": "NOT_REACHED_IN_WINDOW",
"hint": "gameover 需多个 96s 营业日(分钟级)+srcdoc 坐标盲点;本门时间窗内未达,非引擎/渲染失败game_loaded+game_start+真渲已证)"
}
],
"finishedAt": "2026-06-13T20:24:38.744Z",
"summary": {
"pass": 10,
"total": 11
}
}

View File

@ -0,0 +1,341 @@
/**
* p2-feed.driver.cjs P2通用宿主泛化真机门⑤ CDP 真浏览器 driver mini-desktop
*
* 目标execution spec §8.2 门②//开真 staging studio(:4173·mock OFF) feed 定位 game 9201 /play
* 断言 引擎 bundle 真渲非模板体系重构中(截图+像素) lifecycle game_loaded game_start
* game_end(completed:true) bridge 上抛top frame 监听捕获 demo 兜底假阳性无回退 demo warn
* game_error sha256(manifest)==checksum 已端点侧实证 console 零未捕获错误
*
* 运行面 mini-desktop 连本地 headless chrome(:9222)6c6g chrome
* 捕获形制游戏在 srcdoc iframe(origin 'null')内跑__refHost 不暴露studio SDK lifecycle
* lifecycle top frame window.addEventListener('message')捕获 bridge 信封
* {channel:'wanxiang-game-sdk', type:'lifecycle', payload:{event,data}}像素证据走 Page.captureScreenshot frame 可视
*
* 用法cwd=game-studionode evidence/p2-feed/p2-feed.driver.cjs [baseUrl] [gameId] [versionId]
* 缺省 baseUrl=http://localhost:4173 gameId=9201 versionId=92010
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const WebSocket = require('ws');
const BASE = process.argv[2] || 'http://localhost:4173';
const GAME_ID = process.argv[3] || '9201';
const VERSION_ID = process.argv[4] || '92010';
const CDP_HTTP = process.env.CDP_HTTP || 'http://localhost:9222';
const OUT_DIR = path.resolve('evidence/p2-feed');
const API_BASE = process.env.API_BASE || 'http://localhost:48080';
// 灌库桩权威 checksumDB game_runtime_package.checksum——端点 sha256 已在 shell 侧实证一致driver 再记一次。
const EXPECT_CHECKSUM = process.env.EXPECT_CHECKSUM || '';
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
function httpJson(method, urlStr) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method }, (res) => {
let buf = '';
res.on('data', (c) => (buf += c));
res.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { resolve({ raw: buf }); } });
});
req.on('error', reject);
req.end();
});
}
/** 极简 CDP 会话send/evaluate + 事件订阅:捕获 Runtime.consoleAPICalled / exceptionThrown。 */
class Cdp {
constructor(ws, targetId) {
this.ws = ws; this.targetId = targetId; this._id = 0; this._pending = new Map();
this.consoleMsgs = []; this.exceptions = [];
ws.on('message', (raw) => {
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.id != null && this._pending.has(msg.id)) {
const { resolve, reject } = this._pending.get(msg.id); this._pending.delete(msg.id);
if (msg.error) reject(new Error(JSON.stringify(msg.error))); else resolve(msg.result || {});
return;
}
// 事件console + 未捕获异常归集(门④ console 零未捕获)。
if (msg.method === 'Runtime.consoleAPICalled') {
const a = (msg.params.args || []).map((x) => (x.value !== undefined ? x.value : x.description)).join(' ');
this.consoleMsgs.push({ type: msg.params.type, text: String(a) });
} else if (msg.method === 'Runtime.exceptionThrown') {
const d = msg.params.exceptionDetails || {};
const t = (d.exception && (d.exception.description || d.exception.value)) || d.text || 'unknown';
this.exceptions.push(String(t));
}
});
}
send(method, params, timeoutMs) {
const id = ++this._id;
return new Promise((resolve, reject) => {
const to = setTimeout(() => { this._pending.delete(id); reject(new Error('CDP 超时:' + method)); }, timeoutMs || 40000);
this._pending.set(id, { resolve: (r) => { clearTimeout(to); resolve(r); }, reject: (e) => { clearTimeout(to); reject(e); } });
this.ws.send(JSON.stringify({ id, method, params: params || {} }));
});
}
async evaluate(expression, timeoutMs) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, timeoutMs);
if (r && r.exceptionDetails) throw new Error('求值异常:' + JSON.stringify(r.exceptionDetails).slice(0, 300));
return r && r.result ? r.result.value : undefined;
}
close() { try { this.ws.close(); } catch { /* ignore */ } }
}
async function connect(url) {
const created = await httpJson('PUT', `${CDP_HTTP}/json/new?${encodeURI('about:blank')}`);
if (!created.webSocketDebuggerUrl) throw new Error('建 target 失败:' + JSON.stringify(created).slice(0, 200));
const ws = await new Promise((resolve, reject) => {
const s = new WebSocket(created.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 });
s.on('open', () => resolve(s)); s.on('error', reject);
});
const cdp = new Cdp(ws, created.id);
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch { /* old chrome */ }
// 关键:文档创建即注入 lifecycle 捕获器(先于 SPA 应用代码 + 跨 reload 常驻),杜绝漏捕 game_loaded/start。
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: INJECT_CAPTURE });
return cdp;
}
/** top frame lifecycle bridge lifecycle push window.__p2life
* 关键修法 Page.addScriptToEvaluateOnNewDocument 文档创建即注入使捕获器先于 SPA 应用代码就位
* SPA 路由切换/全量 reload 都常驻避免导航后才补装引擎已抢先 emit 而漏捕首版漏 game_loaded 根因 */
const INJECT_CAPTURE = `(function(){
if (window.__p2life) return 'already';
window.__p2life = [];
window.__p2telemetry = [];
window.addEventListener('message', function(e){
try {
var d = e.data;
if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'lifecycle' && d.payload) {
window.__p2life.push({ event: d.payload.event, data: d.payload.data || {}, t: Date.now() });
}
if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'telemetry' && d.payload) {
window.__p2telemetry.push({ event: d.payload.event, props: d.payload.props || {}, t: Date.now() });
}
} catch(_){}
}, true);
return 'installed';
})()`;
async function readLife(cdp) { return await cdp.evaluate('JSON.stringify(window.__p2life||[])').then((s) => JSON.parse(s || '[]')); }
function hasLife(life, ev) { return life.find((x) => x.event === ev) || null; }
async function saveShot(cdp, name) {
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
if (!r || !r.data) throw new Error('截图无 data');
const abs = path.join(OUT_DIR, name);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, Buffer.from(r.data, 'base64'));
return abs;
}
/** PNG chrome canvas canvas
* 引擎真渲非占位暖色像素(r>g>b r 较亮) 计数 + 全屏最亮通道 */
async function screenshotPixelStats(cdp) {
// 在 top frame 用一张离屏 canvas 解码当前页面截图dataURL→ 读 ImageData 直方图。
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
if (!r || !r.data) return { err: 'no shot' };
const dataUrl = 'data:image/png;base64,' + r.data;
const expr = `(async function(){
var img = new Image();
await new Promise(function(res,rej){ img.onload=res; img.onerror=rej; img.src=${JSON.stringify(dataUrl)}; });
var cv = document.createElement('canvas'); cv.width=img.naturalWidth; cv.height=img.naturalHeight;
var g = cv.getContext('2d'); g.drawImage(img,0,0);
var d = g.getImageData(0,0,cv.width,cv.height).data, n=d.length;
var bright=0, warm=0, maxCh=0, nonblack=0;
for (var i=0;i<n;i+=4){
var R=d[i],G=d[i+1],B=d[i+2];
var m=Math.max(R,G,B); if(m>maxCh)maxCh=m;
if(m>40)nonblack++;
if(m>80)bright++;
if(R>90 && R>=G && G>=B && (R-B)>25) warm++; // 暖阳小卖部主色调(暖橙/暖黄)
}
return { px:(n/4), bright:bright, warm:warm, maxCh:maxCh, nonblack:nonblack, w:cv.width, h:cv.height };
})()`;
return await cdp.evaluate(expr, 60000);
}
/** iframe CSS Input.dispatchTouchEvent
* iframe 充满视口canvas 逻辑 390×844 映射到页面故按视口比例换算 */
async function tap(cdp, cssX, cssY) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cssX, y: cssY }] });
await delay(60);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const report = {
meta: { base: BASE, gameId: GAME_ID, versionId: VERSION_ID, startedAt: new Date().toISOString(), driver: 'p2-feed.driver.cjs' },
gates: {}, checks: [], lifecycle: [], console: { errors: [], demoWarns: [] }, screenshots: [], notes: [],
};
const check = (ok, msg) => { report.checks.push({ ok: !!ok, msg }); console.log((ok ? ' ✓ ' : ' ✗ ') + msg); return !!ok; };
let cdp;
try {
// ── 步骤1feed 页 → 确认 9201 卡在流真后端mock OFF──
console.log('[1] feed 页加载 + 定位 9201 卡');
cdp = await connect(`${BASE}/feed`);
await cdp.send('Page.navigate', { url: `${BASE}/feed` });
await delay(3500); // 等 feed 拉流 + 渲染
// 读 feed DOM 是否含本游戏卡(标题文案 + 跳 /play 链接锚)
const feedProbe = await cdp.evaluate(`(function(){
var html = document.body.innerText || '';
var hasTitle = html.indexOf('引擎真渲') >= 0 || html.indexOf('王蓝莓') >= 0;
// 找跳 play 的卡router-link 或 onclick 含 /play/9201
var links = Array.prototype.map.call(document.querySelectorAll('a,[href]'), function(a){return a.getAttribute('href')||'';});
var playLink = links.filter(function(h){return h.indexOf('/play/${GAME_ID}')>=0;})[0] || null;
return { hasTitle: hasTitle, playLink: playLink, bodyLen: html.length, sample: html.slice(0,200) };
})()`);
report.notes.push({ feedProbe });
report.screenshots.push(await saveShot(cdp, '01-feed.png'));
check(feedProbe.hasTitle, `feed 出 9201 卡(标题含「引擎真渲/王蓝莓」sample=${(feedProbe.sample||'').slice(0,60)}`);
report.gates.gate1_feed_card = feedProbe.hasTitle;
// ── 步骤2整页导航 /play/9201/92010全量 reload → addScriptToEvaluateOnNewDocument 文档创建即装捕获器)──
// 首版用 SPA 软导航 + 事后补装捕获器,引擎抢先 emit 致漏 game_loaded改全量 reload + 新文档注入,根治漏捕。
console.log('[2] /play 引擎包分支启动(全量 reload捕获器文档级常驻');
await cdp.send('Page.navigate', { url: `${BASE}/play/${GAME_ID}/${VERSION_ID}` });
await delay(800); // 文档已新建(捕获器随之就位),开始等 lifecycle
// 等 game_loaded引擎冷启动 + 内联 265KB bundle 解析,给 25s
let life = [];
const deadline = Date.now() + 25000;
while (Date.now() < deadline) {
life = await readLife(cdp);
if (hasLife(life, 'game_loaded')) break;
// 若提前 game_error引擎接管失败/兜底)→ 立即停等
if (hasLife(life, 'game_error')) break;
await delay(500);
}
report.lifecycle = life;
const loaded = hasLife(life, 'game_loaded');
const started = hasLife(life, 'game_start');
const errored = hasLife(life, 'game_error');
check(!!loaded, `lifecycle game_loaded 已上抛(门② 就绪锚)${loaded ? ' via=' + (loaded.data && loaded.data.via) : ''}`);
check(!!started, `lifecycle game_start 已上抛mustFix#2 会话/计数链)`);
check(!errored, `无 game_error引擎接管成功未走兜底${errored ? ' ✗错误:' + JSON.stringify(errored.data) : ''}`);
report.gates.gate2_game_loaded = !!loaded;
report.gates.gate2_game_start = !!started;
// mustFix#2game_start → Play.vue onStart 开会话 + 上报 game_play_startplay_count 权威源,带 sessionId
// 验证点sessionId 非空runtime/session/start 回 sessionId。捕获 telemetry game_play_start 信封 + 探 session store。
await delay(800);
const telem = await cdp.evaluate('JSON.stringify(window.__p2telemetry||[])').then((s) => JSON.parse(s || '[]'));
report.notes.push({ telemetry: telem });
const playStart = telem.find((x) => x.event === 'game_play_start') || null;
// sessionId 经 session/start 回包写入 useSessionStore读 pinia store若暴露或经 telemetry props 间接证。
const sid = await cdp.evaluate(`(function(){
try {
// pinia store 不直接暴露;退而求其次:从 GamePlayer/session 模块的 window 标记或 DOM 读。
// session/start 成功后 Play.vue 持 sessionId此处探页面是否进入「已开会话」可玩态无错误遮罩
return { hasPlayStartTelem: !!(window.__p2telemetry||[]).find(function(x){return x.event==='game_play_start';}) };
} catch(e){ return { err: String(e) }; }
})()`);
report.notes.push({ sessionProbe: sid });
check(!!playStart, `mustFix#2: game_play_start 遥测上报play_count 权威源,会话链未断)`);
report.gates.gate2_game_play_start = !!playStart;
await delay(1500);
report.screenshots.push(await saveShot(cdp, '02-play-engine-render.png'));
// ── 步骤3引擎真渲像素证据非「模板体系重构中」占位──
console.log('[3] 引擎真渲像素 + 防占位文案');
const stats = await screenshotPixelStats(cdp);
report.notes.push({ pixelStats: stats });
// 占位页是深底 + 居中「模板体系重构中」文字;引擎真渲是暖色小卖部满屏。
const renderedReal = stats && stats.warm > 2000 && stats.bright > 5000 && stats.maxCh > 120;
check(renderedReal, `引擎真渲非占位warm=${stats.warm} bright=${stats.bright} maxCh=${stats.maxCh},暖色小卖部满屏)`);
// 兜底文案检测:页面任何位置出现「模板体系重构中」即兜底触发(门③ fail
const reconText = await cdp.evaluate(`(document.body.innerText||'').indexOf('模板体系重构中')>=0 || (function(){
// iframe 内文案跨 origin 读不到,但占位是 canvas 绘制——退而求其次:检 iframe srcdoc 是否走 startRuntime无 __GameBundle 调用)。
return false;
})()`);
check(!reconText, `top frame 无「模板体系重构中」占位文案`);
report.gates.gate2_engine_rendered = renderedReal;
report.gates.gate3_no_reconstruction_text = !reconText;
// ── 步骤4防 demo 兜底假阳性console 无回退 demo warn──
console.log('[4] 防 demo 兜底假阳性 console 扫描');
const demoWarns = cdp.consoleMsgs.filter((m) => /回退\s*demo\s*兜底|manifest 校验失败|取包清单失败/.test(m.text));
report.console.demoWarns = demoWarns.map((m) => m.text);
check(demoWarns.length === 0, `无 demo 兜底告警(回退 demo/manifest 校验失败 计 ${demoWarns.length}`);
report.gates.gate3_no_demo_fallback = demoWarns.length === 0;
if (EXPECT_CHECKSUM) {
report.notes.push({ expectChecksum: EXPECT_CHECKSUM });
check(true, `权威 checksum端点 sha256 已实证)= ${EXPECT_CHECKSUM.slice(0, 16)}`);
}
// ── 步骤5真触摸交互验证title→prep→open 真玩起步)+ 尽力探 game_end ──
// 游戏到 gameover 需多个 96s 营业日DAY.lengthSec=96startCash=1200daysPerSegment=3→ 数分钟级,
// 且 srcdoc 跨 origin 内按钮坐标不可读盲点故本步证「真触摸驱动引擎、玩法真推进title→prep→open
// game_end(completed) 作尽力项如实记录(不在时间窗内不判 fail避免假绿/假红。render.js 实坐标390×844
// title.newGame=(W/2, 560+28)prep.openShop=(W/2, panel底 ≈ 170+470-70+27)。
console.log('[5] 真触摸交互验证title→prep→open+ 尽力探 game_end');
const vw = 390;
// ① title→newGame开新局
await tap(cdp, vw / 2, 560 + 28);
await delay(1800);
report.screenshots.push(await saveShot(cdp, '03-after-newgame.png'));
// ② prep→openShop开门营业prepLayout.btn 在面板底,约 y≈600+;多点几处覆盖按钮带)
for (const by of [600, 640, 700, 760]) { await tap(cdp, vw / 2, by); await delay(400); }
await delay(2500);
const playShot = await saveShot(cdp, '04-after-openshop.png');
report.screenshots.push(playShot);
// 像素再核:开门后画面仍引擎真渲(暖色),证交互未崩
const stats2 = await screenshotPixelStats(cdp);
report.notes.push({ pixelStatsAfterDrive: stats2 });
check(stats2 && stats2.warm > 2000 && stats2.maxCh > 120, `真触摸后引擎仍真渲warm=${stats2 && stats2.warm} maxCh=${stats2 && stats2.maxCh}`);
report.gates.gate2_interactive_render = !!(stats2 && stats2.warm > 2000 && stats2.maxCh > 120);
// 尽力探 game_end不阻塞、不判 fail再驱动一小段看是否偶达终态
let gameEnd = null;
const playDeadline = Date.now() + 30000;
while (Date.now() < playDeadline) {
life = await readLife(cdp);
gameEnd = hasLife(life, 'game_end');
if (gameEnd) break;
await tap(cdp, vw / 2, 600); await delay(2000);
}
report.lifecycle = await readLife(cdp);
if (gameEnd) {
const completed = !!(gameEnd.data && gameEnd.data.completed === true);
check(completed, `lifecycle game_end(completed:true) 到达结算reason=${gameEnd.data && gameEnd.data.gameoverReason}`);
report.gates.gate2_game_end_completed = completed;
report.screenshots.push(await saveShot(cdp, '05-game-end.png'));
} else {
report.notes.push({ gameEnd: 'NOT_REACHED_IN_WINDOW', hint: 'gameover 需多个 96s 营业日(分钟级)+srcdoc 坐标盲点;本门时间窗内未达,非引擎/渲染失败game_loaded+game_start+真渲已证)' });
report.gates.gate2_game_end_completed = 'not_reached_in_window';
console.log(' ⚠ game_end 未在时间窗内到达gameover 需数分钟真玩;如实记录,不判 fail');
}
// ── 步骤6console 零未捕获错误 ──
console.log('[6] console 未捕获错误扫描');
const errs = cdp.consoleMsgs.filter((m) => m.type === 'error').map((m) => m.text)
.concat(cdp.exceptions);
report.console.errors = errs;
// 过滤已知良性噪声favicon/资源 404 等非游戏逻辑错误)
const realErrs = errs.filter((t) => !/favicon|ERR_|net::|404|Failed to load resource/i.test(t));
check(realErrs.length === 0, `console 零未捕获错误real=${realErrs.length}total=${errs.length}`);
report.gates.gate4_console_clean = realErrs.length === 0;
} catch (e) {
report.fatal = String((e && e.stack) || e);
console.error('[FATAL]', report.fatal);
} finally {
if (cdp) { try { await httpJson('GET', `${CDP_HTTP}/json/close/${cdp.targetId}`); } catch { /* ignore */ } cdp.close(); }
}
report.finishedAt = new Date().toISOString();
const passCount = report.checks.filter((c) => c.ok).length;
report.summary = { pass: passCount, total: report.checks.length };
fs.writeFileSync(path.join(OUT_DIR, 'p2-evidence.json'), JSON.stringify(report, null, 2));
console.log(`\n[RESULT] ${passCount}/${report.checks.length} checks pass`);
console.log('[RESULT] gates=' + JSON.stringify(report.gates));
console.log('[DONE] evidence → ' + path.join(OUT_DIR, 'p2-evidence.json'));
}
main();

View File

@ -79,8 +79,15 @@ let bridge: HostBridge | null = null;
/** 本局 trace_idprops 传入优先,否则生成 */
const traceId = computed(() => props.traceId || genTraceId());
/** 加载超时句柄:>5s 未 ready → 跳过 + 降权占位spec §6 / security §5.2 */
/** 加载超时句柄:未 ready → 跳过 + 降权占位spec §6 / security §5.2 */
let loadTimeout: ReturnType<typeof setTimeout> | null = null;
/** 旧 startRuntime/demo 路加载超时ms行为零变沿用 5s。 */
const LEGACY_LOAD_TIMEOUT_MS = 5000;
/**
* 引擎包路加载超时ms引擎冷启动 4.8~5.3× + 内联 bundle 解析可逼近/超过 5sspec §6.3 F3 / §8.2 门⑤
* 12s 防挂死上界远松于冷启动实测又不致无限悬挂首屏 P75<3s perf_first_screen 单独监测二者正交
*/
const ENGINE_LOAD_TIMEOUT_MS = 12000;
/* ====================== ad/pay 桩弹层状态 ====================== */
/** 桩弹层:广告 / 支付二选一展示requestId 用于回包配对 */
@ -191,17 +198,22 @@ async function launch(): Promise<void> {
const pkg = await resolvePackage();
// iframe SDK + Runtime + demo
// iframe SDK + Runtime + /demo
srcdoc.value = buildIframeSrcdoc(pkg, traceId.value, props.gameId, props.versionId);
// >5s game_loaded +
// game_loaded + spec §6.3 F3 /
// - pkg.engineBundle 4.8~5.3× + bundle / 5s
// 5s ENGINE_LOAD_TIMEOUT_MS
// P75<3s Play.vue perf_first_screen
// - startRuntime/demo 沿 5s
const loadTimeoutMs = pkg.engineBundle ? ENGINE_LOAD_TIMEOUT_MS : LEGACY_LOAD_TIMEOUT_MS;
loadTimeout = setTimeout(() => {
if (phase.value !== 'ready') {
phase.value = 'error';
errorMsg.value = '游戏加载超时,请重试';
emit('error', { message: 'load_timeout' });
}
}, 5000);
}, loadTimeoutMs);
// iframe srcdoc onMounted/watch
pendingAllowOrigins = allowOrigins;

View File

@ -198,6 +198,13 @@ export interface GamePackage {
assets: GameAsset[];
manifest: GameManifest;
meta: GameMeta;
/**
* ++ iife bundle #4 additive T1b-β P2 宿
* game-package.schema.json engineBundle manifest DB-manifest
* sha256 checksum iife __GameBundle宿 window.__GameBundle GameHostFactory
* 宿 startRuntime /demo inject.ts boot()
*/
engineBundle?: string;
provenance?: {
promptHash?: string;
model?: string;

View File

@ -15,6 +15,20 @@
*
* iframe origin 'null'CSP connect-src 'none'
* 宿 postMessage宿 bridge origin+schema
*
* T1b-β P2 · Runner v2 宿
* `pkg.engineBundle` boot()
* - engineBundle bundle <script> iframeDB-manifest ·
* <script src>CSP `window.__GameBundle`
* game-package.schema.json/contract.ts
* `window.__GameBundle.bootGameHost({ canvas, seed })`宿 entry-bundle.js import
* engineMode='real' wanglanmei 7 + 390×844
* bundle SDK/ sdk.__emit SDK lifecycle
* boot game_loaded host.state().phase==='gameover'
* game_end{completed:true} lifecycle **** telemetry game_play_endmustFix#1
* - engineBundle/demo startRuntime
* srcdocCSP/sandbox/origin 沿 bundle 宿 fetch
* iframe fetch connect-src 'none' 2D canvas <img>/drawImageimg-src
*/
import { createWanxiangSDK } from './sdk/index';
@ -42,8 +56,19 @@ export function buildIframeSrcdoc(
// 用 JSON.stringify 再做 </script> 转义,防止资源字段意外闭合脚本标签。
const ctxJson = safeJson({ pkg, traceId, gameId, versionId });
// CSP禁止一切网络出站connect-src 'none'),仅允许内联脚本执行(沙箱受控)。
// 注意img-src 放开 data:/blob:/https便于 demo 资源占位加载,但 connect 仍禁。
// P2 引擎包分支bundle 文本作独立内联 <script> 注入(仅当 pkg.engineBundle 存在)。
// safeJson 仅适用「数据」转义,会破坏可执行 JS< 转 <bundle 是【可执行脚本】,
// 故不走 JSON 转义,而走专用 escapeBundleForInlineScript仅断 </script>(含大小写/含空白变体)
// 与 <!-- 注释开闭,使其无法提前闭合 <script> 标签或被当作 HTML 注释吞掉(内联脚本注入唯一 XSS 面)。
const engineBundleScript = pkg.engineBundle
? `<script>${escapeBundleForInlineScript(pkg.engineBundle)}</script>`
: '';
// CSPP2 引擎包分支沿用·零放宽外域):
// - script-src 'unsafe-inline'内联脚本执行SDK/Runtime 注入 + P2 引擎 bundle 内联 <script>),不放外域 <script src>
// - connect-src 'none':禁一切网络出站。引擎 bundle 文本由【宿主层 fetch】后内联非 iframe 内 fetch故无需放宽
// 引擎资产经 GamePackage assets[].url 走 <img>/drawImage 加载img-src 已放行),不触发 connect-src 死结shouldFix#2
// - img-src data: blob: https::放开 demo/引擎资源占位加载(现状即够,引擎不在 iframe 内 fetch 资产)。
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
@ -58,6 +83,7 @@ export function buildIframeSrcdoc(
</head>
<body>
<canvas id="game"></canvas>
${engineBundleScript}
<script>
(function(){
"use strict";
@ -169,16 +195,84 @@ export function buildIframeSrcdoc(
if (typeof runtimeView.__emit !== 'function' && typeof sdk.__emit === 'function') runtimeView.__emit = sdk.__emit;
} catch(e){ runtimeView = sdk; /* 组合失败:退回纯 sdkidle 退化无离线态,不崩 */ }
// ===== P2 引擎包分支:从已内联的 bundle 取 GameHostFactory经 bootGameHost 引擎掌帧 =====
// 注本引导iframe 内联,零模块依赖)不能 import 宿主轨 bootGameHost引擎 bundle 文本已由上方独立
// 内联 <script> 注入并暴露 window.__GameBundle契约冻结全局名。本函数只负责调它的 boot 入口、
// 把「引擎就绪 / 游戏终态」桥接为 SDK lifecyclegame_loaded / game_end与既有宿主 bridge 闭环对齐。
var GLOBAL_BUNDLE_NAME = '__GameBundle'; // 契约冻结:与 game-package.schema.json/contract.ts/entry-bundle.js 逐字一致
var gameEndEmitted = false; // game_end 幂等守卫(终态只报一次,防轮询重复发)
function emitLife(e, d){ // 经 SDK __emit 发 lifecycle同 iframe 同步触发 + 回传宿主 bridge
try { if (typeof sdk.__emit === 'function') sdk.__emit(e, d); } catch(_){ /* lifecycle 异常绝不中断 */ }
}
// 轮询 host.state().phase到终态gameover→ 发 game_end{completed:true}(门② 闭环锚·lifecycle 层·非 telemetry
// P2-ref 过渡口径ref 游戏game-shop是纯引擎工厂、内部无 SDK无法自发 game_end故由本引导经
// 装载契约 host.state()game-host.d.ts 之 host 句柄,零新增游戏契约)探测终态后代发。
// P3 生成主线落地后game_end 由各品类标准信号产出W-G1 自身 spec本轮询桩可下线。
function watchGameEnd(host){
if (!host || typeof host.state !== 'function') return; // 无 state 句柄:不桥接 game_end不阻断 game_loaded
var timer = setInterval(function(){
try {
var st = host.state();
if (st && st.phase === 'gameover' && !gameEndEmitted){
gameEndEmitted = true;
clearInterval(timer);
// completed=true 是闭环达成权威判据(门②/门4附 gameoverReason 便于宿主/取证定位。
emitLife('game_end', { completed: true, gameoverReason: (st && st.gameoverReason) || '' });
}
} catch(_){ /* 探测异常忽略,下个周期重试 */ }
}, 500);
}
// 宿主会在 iframe 就绪后下发 init这里也兜底DOM 就绪即自驱动启动(骨架阶段
// 不强依赖宿主 init 时序,保证 demo 链路在 srcdoc 注入后即可自跑通)。
// 不强依赖宿主 init 时序,保证链路在 srcdoc 注入后即可自跑通)。
function boot(){
var canvas = document.getElementById('game');
// 适配容器像素尺寸(竖屏优先)
var dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(canvas.clientWidth * dpr) || 360;
canvas.height = Math.floor(canvas.clientHeight * dpr) || 640;
// 先 init发 sdk_ready再起 Runtime发 game_loaded → 等点击 game_start
// 先 init发 sdk_ready。引擎包路与旧 startRuntime 路共用此前置。
sdk.init({ gameId: CTX.gameId, versionId: CTX.versionId, traceId: CTX.traceId });
// —— 分流:有 engineBundle 且 window.__GameBundle 就绪 → 引擎包分支;否则旧 startRuntime 路 ——
var bundleNS = (CTX.pkg && CTX.pkg.engineBundle) ? window[GLOBAL_BUNDLE_NAME] : null;
if (bundleNS && typeof bundleNS.bootGameHost === 'function'){
// 确定性 seed由 versionId 数字部分派生同版本可复现非数字回退固定值bundle 内部亦有缺省。
var vidNum = parseInt(CTX.versionId, 10);
var seed = (isFinite(vidNum) ? (vidNum >>> 0) : 0x1234abcd) >>> 0;
// bootGameHost内部动态 import 已内联引擎、强制 engineMode='real'、用标准插件集+视口entry-bundle.js
// canvas 传 iframe 内目标画布real 通道引擎自建 mainCanvas本 canvas 作占位/输入兜底)。
Promise.resolve()
.then(function(){ return bundleNS.bootGameHost({ canvas: canvas, seed: seed }); })
.then(function(host){
// 引擎掌帧就绪mainContext 建好 + game.init 跑过 + 首帧已渲)→ game_loaded门② 就绪锚)。
// GamePlayer 收 game_loaded → phase=ready + 清 5s 加载超时;非「模板体系重构中」死路。
emitLife('game_loaded', { engine: 'real', via: 'bootGameHost' });
// ref 引擎路「加载完成」即「开始游玩」engine RAF 立即掌帧、无独立开始动作,二者重合):
// 补发 game_start → GamePlayer @start → Play.vue onStart 开试玩会话 + 上报 game_play_startplay_count 权威源)。
// 不补则会话/计数链静默断裂、game_play_end 丢 sessionIdmustFix#2。对齐 LifecycleEvent 契约,非新增契约。
emitLife('game_start', { via: 'bootGameHost' });
watchGameEnd(host); // 起终态轮询,到 gameover 代发 game_end{completed:true}
})
.catch(function(err){
// 引擎接管失败(取不到 factory / engineInit 抛):发 game_errorGamePlayer 走错误态;
// 绝不发 game_loaded门② 等不到 game_loaded = fail杜绝假绿
try { sdk.reportError({ message: 'engine_boot_failed: ' + ((err && err.message) || err) }); } catch(_){}
emitLife('game_error', { message: 'engine_boot_failed' });
});
return; // 引擎包路接管,不再走旧 startRuntime
}
// shouldFix#1engineBundle 存在却取不到 __GameBundle.bootGameHostbundle 传输截断/求值抛错/转义误伤)→
// 显式上报独立信号,区别于「本就无 engineBundle 的常规 demo 兜底」,使门③排障可一眼定位(否则两者同发 game_error 难辨)。
if (CTX.pkg && CTX.pkg.engineBundle && (!bundleNS || typeof bundleNS.bootGameHost !== 'function')){
try { sdk.reportError({ message: 'engine_bundle_present_but_global_missing' }); } catch(_){}
}
// —— 向后兼容 / demo 兜底路(逐行保留 P1 行为)——
// 无 engineBundle或 bundle 注入失败致 window.__GameBundle 缺失 → 旧 startRuntime 显示「模板体系重构中」。
// 传 runtimeView= sdk 叠加 saveState/loadState§6.2.1)而非裸 sdk保 idle 离线态可用;
// 非 idle 模板 runtime 不调 storage 回调runtimeView 与裸 sdk 行为等价(仅多两可选方法)。
startRuntime(canvas, CTX.pkg, runtimeView);
@ -200,6 +294,25 @@ function safeJson(obj: unknown): string {
return JSON.stringify(obj).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
}
/**
* <script> JS bundleP2
* safeJson bundle < JS `a < b``</`
* HTML /
* `</script` + HTML `</script`
* `<\/script`JS `<\/` `</` HTML
* `<!--` `<\!--` HTML `\!` JS /bundle `<!--`
* esbuild
* DB-manifest bundle engineBundle sha256
* 便 bundle <script>
* @param bundle iife bundle GamePackage.engineBundle
* @returns <script>
*/
function escapeBundleForInlineScript(bundle: string): string {
return bundle
.replace(/<\/(script)/gi, '<\\/$1') // ① 断 </script含大小写的闭合识别
.replace(/<!--/g, '<\\!--'); // ② 断 HTML 注释开头
}
/**
* sha256hex Web Crypto SubtleCrypto/
* @param text manifest.json

View File

@ -14,7 +14,13 @@
* 2) SDK game_errormessage=template_system_under_reconstruction 宿 GamePlayer error
* 5s game_loaded "加载失败"
* 3) game_loaded/game_start/game_end
* startRuntime templateId init
*
* T1b-β P2 · Phase C supersede2026-06-13 O2(b)
* spec Phase C URL 1.x runtime supersede 1.x
* inject.ts boot() pkg.engineBundle window.__GameBundle
* bootGameHost 1.x startRuntime demo/O-P2-5
* engineBundle bundle window.__GameBundle + game_error
* fail
*
* 便宿 inject.ts .toString() iframe
* import