#!/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 覆写其运行包。 * (真产线 P3:engineBundle 由生成回调链经 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_size(SHA2/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) 生成 SQL:node 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 绘境AI(P2 后端轨) */ 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 缺省 9201(p2_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 = []; // manifest:bundleSize/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 灌库桩 SQL(P2 过渡桩,真产线 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 当前生效版本 ID(game_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_json(versionId 占位回填真实 @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}`);