Phaser 项目一键发布:发布前自动构建与打包
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- AGC:export_local_project_package 改为发布前构建——已有可玩入口直接打包;否则解析 game/(Phaser 4 + Vite 脚手架)或项目根的 npm build 脚本,缺 game/node_modules 时先跑 project.bootstrap(npm install),再走 project.verify 的受控 npm 运行器执行 npm run build,最后校验入口并打包;安装/构建失败返回带日志尾部的可操作错误。
- 新增 resolve_publish_build_plan 纯函数与用例:game 子工程优先并要求先装依赖、根 npm 工程回退、没有可构建工程时失败关闭。
- 后端:发行网关根路径(含尾斜杠)等价于 index.html,路由级用例覆盖 Cookie 拒绝门与根路径;生产仍由每游戏 origin 把根路径映射到该游戏入口。
- 脚本:check:game-distribution-media-e2e 支持 E2E_PACKAGE_ZIP 直接发布真实构建产物,断言从包内派生入口/资源,本地审核入口改为发行网关路径。
- 文档:玩法链路与实施计划记录「作者不构建、不打 ZIP」的 Phaser 发布口径与验证证据。
- 验证:真实 Phaser 4.2.1 + Vite 7 构建产物(相对引用)经发布链路后网关 index.html 200 / assets 1,388,719 B 200,播放页在 allow-scripts 沙箱 iframe 内渲染 PHASER-PUBLISH-OK 且点击交互生效。
This commit is contained in:
2026-09-22 20:38:00 +08:00
parent 944ae9a6eb
commit 57a72f652c
7 changed files with 426 additions and 15 deletions
+59 -10
View File
@@ -4,10 +4,15 @@
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-media-e2e
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。
// E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实
// Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。
// E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。
//
// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 →
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。
import { readFile } from 'node:fs/promises';
import JSZip from 'jszip';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401';
@@ -139,7 +144,39 @@ function gameMetadata(overrides = {}) {
};
}
const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim();
const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim();
/** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */
async function buildZip() {
if (externalPackageZip) {
const bytes = await readFile(externalPackageZip);
const archive = new JSZip();
const parsed = await archive.loadAsync(bytes);
const entryNames = Object.keys(parsed.files).filter(
(name) => !parsed.files[name].dir,
);
if (!entryNames.includes('index.html')) {
throw new Error(
`E2E_PACKAGE_ZIP 根目录缺少 index.html${externalPackageZip}`,
);
}
// 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。
const assetPath =
entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ??
entryNames.find((name) => name.endsWith('.js'));
if (!assetPath) {
throw new Error(
`E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`,
);
}
return {
bytes: Buffer.from(bytes),
fileCount: entryNames.length,
assetPath,
entryMarker: null,
};
}
const zip = new JSZip();
zip.file(
'index.html',
@@ -147,7 +184,12 @@ async function buildZip() {
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
const bytes = await zip.generateAsync({ type: 'uint8array' });
return Buffer.from(bytes);
return {
bytes: Buffer.from(bytes),
fileCount: 2,
assetPath: 'assets/app.js',
entryMarker: 'E2E-MEDIA-OK',
};
}
async function main() {
@@ -230,7 +272,7 @@ async function main() {
// 4. 创建游戏 + 版本(冻结资料)
const metadata = gameMetadata({
title: `分发媒体验证 ${id.slice(-6)}`,
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
});
@@ -284,7 +326,8 @@ async function main() {
`status=${ghostVersion.status}`,
);
const zipBytes = await buildZip();
const built = await buildZip();
const zipBytes = built.bytes;
const crypto = await import('node:crypto');
const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex');
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
@@ -294,7 +337,7 @@ async function main() {
body: {
packageSha256: sha256,
packageBytes: zipBytes.length,
packageFileCount: 2,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
@@ -423,7 +466,8 @@ async function main() {
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
entryUrl: `${API}`,
// 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。
entryUrl: `${API}/api/game-distribution/releases/${gameId}/`,
},
},
);
@@ -480,22 +524,27 @@ async function main() {
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseBody = await release.text();
const entryOk =
release.status === 200 &&
/<html|<!doctype html/iu.test(releaseBody) &&
(built.entryMarker === null || releaseBody.includes(built.entryMarker));
check(
'发行网关返回游戏入口',
release.status === 200 && releaseBody.includes('E2E-MEDIA-OK'),
`status=${release.status}`,
entryOk,
`status=${release.status} bytes=${releaseBody.length}`,
);
check(
'发行入口带 nosniff',
release.headers.get('x-content-type-options') === 'nosniff',
);
const releaseAsset = await fetch(
`${API}/api/game-distribution/releases/${gameId}/assets/app.js`,
`${API}/api/game-distribution/releases/${gameId}/${built.assetPath}`,
);
const assetBody = await releaseAsset.arrayBuffer();
check(
'发行网关返回包内资源',
releaseAsset.status === 200,
`status=${releaseAsset.status}`,
releaseAsset.status === 200 && assetBody.byteLength > 0,
`status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`,
);
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);