games-development-ai/game-cloud/scripts/p2_seed_engine_bundle.mjs
zizi 5d3f7a87f0 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>
2026-06-13 20:36:32 +00:00

193 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* O-P2-1 灌库桩 —— P2 通用宿主:把 ref 引擎 bundle 文本灌进某已发布 version 的 package_json.engineBundle
* ============================================================================
* 【⚠️ P2 过渡桩,真产线 P3】
* 本脚本是 T1b-β P2「Runner 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全局名 WanglanmeiRef。mustFix#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=1、checksum 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}`);