lili 88d3a23787 feat(amodel-gen): 根治B(input→L1 handleTap)+driver起局扫点+5 footgun蒸馏+wanglanmei E2E→studio可玩
根治 B(缩 M3 错面,结构性根除"点击在错 phase 消费/永不消费→启动不了"):
- input 订阅收归 L1(_template/src/game.js wrapper):L3 只写实例方法 handleTap(x,y)/handleKey(key),
  L1 在 pointerdown/keydown 直达派发;_template/src/game-logic.js 范例改用 handleTap。
- tools.mjs check:拦 ctx.getInput + 必须暴露 ≥1 输入方法;prompt.mjs/README/skill 同步输入契约。
- 机制+门双实证(node 全链路:menu→点开始→play、点客加分、点空白不加分;check 对 getInput/零输入方法拦截)。

driver(play.cdp.cjs):occupied 族起局从固定点 (195,422) 改中线纵向扫点,robust 命中各游戏「开始」按钮
  (原固定点落空→死菜单假阴;实测 wanglanmei「开门营业」在 y≈502)。

资产通道(boot-game-host.loadHostAssets + gen.mjs --assets-dir):M3 只决定怎么用资产、不造。
入库工具 publish-amgen.mjs:staged bundle → game_runtime_package(package_json.engineBundle,status=1)→ studio /play。

5 个 M3 footgun 蒸馏进 prompt.mjs + skill(均 wanglanmei-v2 实测翻车):
  ① scene update 回调签名 (dt) 非 (running,dt)  ② ctx.random.next() 非 ctx.random()
  ③ ctx.time.nowMs() 非 ctx.nowMs()  ④ _forensicsView 的 phase/targets 必须在 state() 内实时算(否则定格 boot 快照)
  ⑤ update(dt) 的 dt 是秒,毫秒计时要 ×1000。

验证:M3 生成 wanglanmei-tycoon-v2(+上述 5 footgun 修复)→ 九门 PASS(score 0→364,distinct 43/43)
  → 入库 → studio /play/80002/80002 加载+boot+渲染菜单真玩(art 走程序化回退,水彩 art 需引擎资产线 E2/E3)。
  (root-fix A 门可信化先于 cbe42933 提交;SAA Java 驱动绑定另track 暂不入此 commit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:48:03 -07:00

123 lines
6.9 KiB
JavaScript

/**
* publish-amgen.mjs — 把 amodel-gen(SAA 模式)产出的自包含 bundle 入库到 game_runtime_package,
* 使其在 studio /play/<gameId>/<versionId> 可试玩。复刻 SAA emit + publish_flip 的产物形态(package_json.engineBundle + status=1)。
*
* 用法(在 mini 上跑):
* node publish-amgen.mjs --staged <_wg1-gen/<gid> 绝对路径> --game-id <数字> --version-id <数字> --title "..." [--summary "..."] [--exec]
* 不带 --exec = 干跑(只打印 SQL 摘要 + 校验和,不写库);带 --exec = 真写库(经 docker exec mysql)。
*
* 注意:engineBundle 是大文本,SQL 字面量经 JS 侧转义(\\ 与 ')可靠回环;package_json 入 JSON 列由 MySQL 校验。
*/
'use strict';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
function arg(n, d) { const i = process.argv.indexOf('--' + n); return i >= 0 ? process.argv[i + 1] : d; }
const sha256 = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
// MySQL 单引号字面量转义(默认 NO_BACKSLASH_ESCAPES off):\ → \\,' → \'。回环精确。
const sqlStr = (s) => "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
const staged = arg('staged');
const gameId = arg('game-id');
const versionId = arg('version-id');
const title = arg('title', '网兰梅的小卖部');
const summary = arg('summary', 'A-model 生成·街角小卖部经营(点客成单赚营业额)');
const templateId = arg('template-id', 'generic');
const doExec = process.argv.includes('--exec');
if (!staged || !gameId || !versionId) {
console.error('缺参:--staged <dir> --game-id <n> --version-id <n> [--title ..] [--exec]');
process.exit(2);
}
const bundlePath = join(staged, 'bundle.iife.js');
if (!existsSync(bundlePath)) { console.error('找不到 bundle.iife.js:' + bundlePath + '(先确认 gen stage 成功)'); process.exit(2); }
const engineBundle = readFileSync(bundlePath, 'utf8');
if (!/__GameBundle/.test(engineBundle)) { console.error('⚠ bundle 未见 __GameBundle 全局名,可能不是 SAA 信封产物'); }
const bundleSize = Buffer.byteLength(engineBundle, 'utf8');
const bundleChecksum = sha256(engineBundle); // manifest.checksum = bundle sha256(宿主完整性校验 T-RT-15)
const cover = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
// package_json 形态对齐线上已发布包(93142 实测):meta/assets/gameId/manifest/versionId/gameConfig/provenance/templateId/schemaVersion/engineBundle。
const pkg = {
meta: { cover, title, summary, ageRating: 'all' },
assets: [], // 本期引擎不消费 runtime 图资产(E2/E3 未做)→ 留空;游戏走程序化回退
gameId: String(gameId),
manifest: { entry: 'index.html', checksum: bundleChecksum, bundleSize, preloadPolicy: 'eager', runtimeVersion: '1.0.0' },
versionId: String(versionId),
gameConfig: { theme: 'sim', title, templateId, engineDriven: true },
provenance: { traceId: 'amgen-publish-' + gameId, promptHash: '', generatedAt: '' },
templateId,
schemaVersion: '1.0',
engineBundle,
};
const pkgJson = JSON.stringify(pkg);
const rowChecksum = sha256(pkgJson); // 行列 checksum = sha256(package_json)(publish_flip 自洽校验口径)
const sql = `USE \`ruoyi-vue-pro\`;
SET NAMES utf8mb4;
START TRANSACTION;
DELETE FROM game_feed_rank WHERE game_id = ${gameId};
DELETE FROM game_runtime_package WHERE game_id = ${gameId};
DELETE FROM game_version WHERE game_id = ${gameId};
DELETE FROM game_project WHERE id = ${gameId};
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
(${gameId}, 1, ${sqlStr(title)}, ${sqlStr(summary)}, ${sqlStr(cover)},
'amgen,sim,wanglanmei', ${sqlStr(templateId)}, 'all',
4, ${versionId}, NULL, b'0', 0, 0,
'0', NOW(), '0', NOW(), b'0', 0);
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
(${versionId}, ${gameId}, 2, NULL, '', ${bundleSize}, ${sqlStr(bundleChecksum)},
3, '0', NOW(), '0', NOW(), b'0', 0);
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
(${versionId}, ${gameId}, ${versionId}, ${sqlStr(templateId)}, '', 'index.html', '1.0.0', 'eager',
${bundleSize}, ${sqlStr(rowChecksum)}, 'allow-scripts', '', 1,
'0', NOW(), '0', NOW(), b'0', 0, ${sqlStr(pkgJson)});
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
(${versionId}, ${gameId}, ${versionId}, 0, 0.9000, 0.0000, 0.9000, 0, 1,
'0', NOW(), '0', NOW(), b'0', 0);
COMMIT;
SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=${gameId}
UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=${versionId}
UNION ALL SELECT 'pkg', id, status, version_id FROM game_runtime_package WHERE id=${versionId}
UNION ALL SELECT 'feed', id, status, version_id FROM game_feed_rank WHERE id=${versionId};
SELECT 'engineBundle_len' k, CHAR_LENGTH(JSON_UNQUOTE(JSON_EXTRACT(package_json,'$.engineBundle'))) v FROM game_runtime_package WHERE id=${versionId};
SELECT 'checksum_selfconsistent' k, (checksum = LOWER(SHA2(package_json,256))) v FROM game_runtime_package WHERE id=${versionId};
`;
console.log(`[publish] staged=${staged}`);
console.log(`[publish] gameId=${gameId} versionId=${versionId} title=${title}`);
console.log(`[publish] bundleSize=${bundleSize}B bundleChecksum=${bundleChecksum.slice(0, 16)}… rowChecksum=${rowChecksum.slice(0, 16)}…`);
console.log(`[publish] package_json 长度=${pkgJson.length} SQL 长度=${sql.length}`);
if (!doExec) {
console.log('\n[dry-run] 未带 --exec,未写库。带 --exec 真写库。');
process.exit(0);
}
// 取 MySQL root 密码(docker 容器 env)→ 管道 SQL 入 mysql。
const pwRes = spawnSync('docker', ['exec', 'game-staging-mysql', 'printenv', 'MYSQL_ROOT_PASSWORD'], { encoding: 'utf8' });
const pw = (pwRes.stdout || '').trim();
if (!pw) { console.error('取不到 MYSQL_ROOT_PASSWORD'); process.exit(1); }
const res = spawnSync('docker', ['exec', '-i', 'game-staging-mysql', 'mysql', '--default-character-set=utf8mb4', '-uroot', '-p' + pw, 'ruoyi-vue-pro'],
{ input: sql, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
process.stdout.write(res.stdout || '');
if (res.status !== 0) { console.error('[publish] mysql 退出码 ' + res.status + '\n' + (res.stderr || '')); process.exit(1); }
console.log('\n✅ 入库完成。studio 试玩:/play/' + gameId + '/' + versionId);