合并远端 master 到美术包合同修复分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m16s
Project CI / Backend tests (pull_request) Successful in 7m25s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m28s
Project CI / Repository checks (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Has been cancelled

同步 origin/master 的最新代码、测试与文档变更

保留图集按实际产物返回的提示词并移除已退役发布说明

合并技能包清单并递增版本,保留双方项目经验记录
This commit is contained in:
2026-10-07 16:56:36 +08:00
452 changed files with 32702 additions and 10399 deletions
@@ -31,14 +31,6 @@ const PAIRS = [
['CreatorConnection', 'CreatorConnection'],
['CreatorConnections', 'CreatorConnections'],
['CreatorRelationshipResponse', 'CreatorRelationshipResponse'],
[
'GameDistributionPublishMetadataSuggestionRequest',
'GameDistributionPublishMetadataSuggestionRequest',
],
[
'GameDistributionPublishMetadataSuggestion',
'GameDistributionPublishMetadataSuggestion',
],
['GameDistributionVersionStatus', 'GameDistributionVersionStatus'],
['GameDistributionVisibility', 'GameDistributionGameVisibility'],
['GameDistributionInputMode', 'GameDistributionInputMode'],
File diff suppressed because it is too large Load Diff
@@ -23,10 +23,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
@@ -61,20 +63,26 @@ function section(title) {
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -162,57 +170,18 @@ async function setPublishGate(admin, enabled, rolloutPercent) {
return response;
}
async function uploadCover(token, id) {
const fileName = `ops-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'ops-rollback', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(`创建直传凭证失败 ${ticket.status}`);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-ops-rollback',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `ops-${id}.png` });
function gameMetadata(title, coverAssetId) {
function gameMetadata(title, coverObjectKey) {
return {
title,
summary: '发布开关回滚窗口 E2E',
description: '',
category: '休闲',
tags: ['ops'],
coverAssetId,
coverObjectKey,
screenshots: [],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
@@ -242,13 +211,13 @@ async function createVersion(token, gameId, metadata, bytes, fileCount, key) {
method: 'POST',
token,
headers: { 'Idempotency-Key': key },
body: {
formData: publishFormData({
packageSha256: createHash('sha256').update(bytes).digest('hex'),
packageBytes: bytes.length,
packageFileCount: fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
}),
});
}
@@ -369,25 +338,30 @@ async function main() {
.map((pair) => pair.split('=').slice(1).join('='))
.find((value) => value && value.length > 20);
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
const title = `发布回滚 ${String(stamp).slice(-6)}`;
const metadata = gameMetadata(title, coverAssetId);
const metadata = gameMetadata(title, null);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-game') },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check('创建游戏成功', created.status === 200 && Boolean(gameId));
if (!gameId) process.exit(1);
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
const resolvedMetadata = {
...metadata,
coverObjectKey: created.data.coverObjectKey,
};
section('基线:v1 公开、v2/v3 待审、v4 只有版本记录');
const v1 = await buildPackage('V1-OK');
const v1Version = await createVersion(
author,
gameId,
metadata,
resolvedMetadata,
v1.bytes,
v1.fileCount,
key('ops-v1'),
@@ -476,7 +450,7 @@ async function main() {
const version = await createVersion(
author,
gameId,
metadata,
resolvedMetadata,
built.bytes,
built.fileCount,
key(`ops-${marker}`),
@@ -507,7 +481,7 @@ async function main() {
const v4Version = await createVersion(
author,
gameId,
metadata,
resolvedMetadata,
v4Built.bytes,
v4Built.fileCount,
key('ops-v4'),
@@ -530,7 +504,12 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-game') },
body: metadata,
// 「新图」语义:带二进制 cover 时 metadata 的 coverObjectKey 必须是 null,别同时给
// 一个可复用 objectKey,否则 payload 含义含糊,网关检查顺序变化时结论会漂。
formData: publishFormData(
{ ...resolvedMetadata, coverObjectKey: null },
{ cover },
),
}),
],
[
@@ -539,15 +518,15 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-version') },
body: {
formData: publishFormData({
packageSha256: createHash('sha256')
.update(v4Built.bytes)
.digest('hex'),
packageBytes: v4Built.bytes.length,
packageFileCount: v4Built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
gameMetadata: resolvedMetadata,
}),
}),
],
[
@@ -9,12 +9,14 @@
// 确认分包、送审、撤回全部按「不可见」处理 → 未认证读私有版本 401 → 越权写没有副作用
// (作者侧已收字节仍为 0)→ 请求体里伪造 owner 不生效(游戏仍归请求者、不出现在被冒名账号
// 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(dev bucket,走现役直传 + 确认链路),
// 发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(新合同由服务端把 cover part 直写项目
// 快照桶),发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
@@ -39,20 +41,26 @@ function check(name, ok, detail = '') {
}
async function api(path, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -85,56 +93,8 @@ function gameMetadata(title) {
};
}
async function uploadCover(token, id) {
const fileName = `owner-isolation-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'owner-isolation', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) {
throw new Error(`直传对象存储失败 ${put.status}`);
}
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-owner-isolation',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `owner-isolation-${id}.png` });
async function register(prefix) {
const response = await api('/api/auth/entry', {
@@ -198,12 +158,15 @@ async function main() {
const stamp = Date.now();
const title = `越权隔离 ${String(stamp).slice(-6)}`;
const coverAssetId = await uploadCover(author.token, stamp);
const cover = coverImagePart(stamp);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-game-${stamp}` },
body: { ...gameMetadata(title), coverAssetId },
formData: publishFormData(
{ ...gameMetadata(title), coverObjectKey: null, screenshots: [] },
{ cover },
),
});
const gameId = created.data?.id;
check(
@@ -219,13 +182,18 @@ async function main() {
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-version-${stamp}` },
body: {
formData: publishFormData({
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: { ...gameMetadata(title), coverAssetId },
},
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
gameMetadata: {
...gameMetadata(title),
coverObjectKey: created.data.coverObjectKey,
screenshots: [],
},
}),
},
);
const versionId = versionResponse.data?.versionId;
@@ -391,20 +359,21 @@ async function main() {
`status=${authorState.status} receivedBytes=${authorState.data?.receivedBytes}`,
);
const intruderCoverAssetId = await uploadCover(
intruder.token,
`${stamp}-intruder`,
);
const intruderCover = coverImagePart(`${stamp}-intruder`);
const forged = await api('/api/game-distribution/games', {
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-forged-${stamp}` },
body: {
...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`),
coverAssetId: intruderCoverAssetId,
ownerUserId: 'user_forged',
owner_user_id: 'user_forged',
},
formData: publishFormData(
{
...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`),
coverObjectKey: null,
screenshots: [],
ownerUserId: 'user_forged',
owner_user_id: 'user_forged',
},
{ cover: intruderCover },
),
});
const forgedGameId = forged.data?.id;
check(
@@ -9,10 +9,12 @@ import { createHash, randomBytes } from 'node:crypto';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const API_PORT = Number(new URL(API).port || '80');
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -35,20 +37,26 @@ function check(name, ok, detail = '') {
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -80,51 +88,12 @@ function apiServerWorkingSetMb() {
}
}
async function uploadCover(token, id) {
const fileName = `limits-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'limits', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200)
throw new Error(`创建直传凭证失败 ${ticket.status}`);
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-limits',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `limits-${id}.png` });
async function createGameWithVersion(
author,
coverAssetId,
cover,
title,
suffix,
declaration,
@@ -135,7 +104,8 @@ async function createGameWithVersion(
description: '',
category: '休闲',
tags: ['limits'],
coverAssetId,
coverObjectKey: null,
screenshots: [],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
@@ -144,7 +114,7 @@ async function createGameWithVersion(
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `limits-game-${suffix}` },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
if (created.status !== 200 || !gameId) {
@@ -156,13 +126,17 @@ async function createGameWithVersion(
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `limits-version-${suffix}` },
body: {
formData: publishFormData({
packageSha256: declaration.packageSha256 ?? 'a'.repeat(64),
packageBytes: declaration.packageBytes,
packageFileCount: declaration.packageFileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
}),
});
return { gameId, metadata, version };
}
@@ -232,12 +206,12 @@ async function main() {
`status=${register.status}`,
);
if (!author) process.exit(1);
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
// A. 声明超过 200 MiB:创建版本阶段就该失败关闭,不必真的传包。
const declaredOver = await createGameWithVersion(
author,
coverAssetId,
cover,
`边界 声明超限 ${String(stamp).slice(-5)}`,
`${stamp}-declared`,
{ packageBytes: 200 * MIB + 1, packageFileCount: 1 },
@@ -262,7 +236,7 @@ async function main() {
);
const manyCase = await createGameWithVersion(
author,
coverAssetId,
cover,
`边界 文件数 ${String(stamp).slice(-5)}`,
`${stamp}-many`,
{
@@ -301,7 +275,7 @@ async function main() {
);
const bigFileCase = await createGameWithVersion(
author,
coverAssetId,
cover,
`边界 单文件 ${String(stamp).slice(-5)}`,
`${stamp}-bigfile`,
{
@@ -352,7 +326,7 @@ async function main() {
);
const expandedCase = await createGameWithVersion(
author,
coverAssetId,
cover,
`边界 展开量 ${String(stamp).slice(-5)}`,
`${stamp}-expanded`,
{
@@ -389,7 +363,7 @@ async function main() {
);
const ratioCase = await createGameWithVersion(
author,
coverAssetId,
cover,
`边界 压缩比 ${String(stamp).slice(-5)}`,
`${stamp}-ratio`,
{
@@ -442,7 +416,7 @@ async function main() {
);
const bigCase = await createGameWithVersion(
author,
coverAssetId,
cover,
`边界 200MiB 档 ${String(stamp).slice(-5)}`,
`${stamp}-big`,
{
@@ -51,6 +51,12 @@ import { fileURLToPath } from 'node:url';
import JSZip from 'jszip';
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DEV_STACK_PATH = resolve(REPO_ROOT, '.app/dev-stack.json');
@@ -88,12 +94,6 @@ const REQUIRE_PLATFORM_ORIGIN =
(process.env.E2E_REQUIRE_PLATFORM_ORIGIN ?? '').trim() === '1';
const GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE = 'game_purchase';
/** 1x1 透明 PNG:仅用于满足发布必填封面,内容不影响本用例断言。 */
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
let failures = 0;
let passes = 0;
let warnings = 0;
@@ -167,20 +167,26 @@ async function resolveWebBase() {
}
async function api(path, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -213,56 +219,8 @@ async function registerAccount(prefix) {
return { status: entry.status, token: entry.data?.token ?? '', phone };
}
async function uploadCover(token, id) {
const fileName = `cover-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'cover', id],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) {
throw new Error(`直传对象存储失败 ${put.status}`);
}
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-cover',
},
});
if (confirm.status !== 200) {
throw new Error(
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `cover-${id}.png` });
/**
* 生成最小可玩发行包:入口页里带唯一标记,用来证明播放会话读到的是同一份包内容。
@@ -287,7 +245,7 @@ async function buildPackage(marker) {
};
}
function gameMetadata(title, coverAssetId) {
function gameMetadata(title, coverObjectKey) {
return {
title,
summary: '买断制泥点付费真实链路验证',
@@ -297,7 +255,7 @@ function gameMetadata(title, coverAssetId) {
deviceSupport: { desktop: true, mobile: true, touch: true },
inputModes: ['keyboard', 'mouse', 'touch'],
orientation: 'responsive',
coverAssetId,
coverObjectKey,
screenshots: [],
};
}
@@ -309,24 +267,24 @@ async function createVersionAndSubmit({
price,
marker,
title,
coverAssetId,
coverObjectKey,
expectedPublicationRevision,
}) {
const id = stamp();
const metadata = gameMetadata(title, coverAssetId);
const metadata = gameMetadata(title, coverObjectKey);
const pkg = await buildPackage(marker);
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: authorToken,
headers: { 'Idempotency-Key': `e2e-purchase-version-${id}` },
body: {
formData: publishFormData({
priceMudPoints: price,
packageSha256: pkg.sha256,
packageBytes: pkg.bytes.length,
packageFileCount: pkg.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
}),
});
if (version.status !== 200 || !version.data?.versionId) {
throw new Error(
@@ -374,18 +332,18 @@ async function createVersionAndSubmit({
async function publishGame({
authorToken,
adminToken,
coverAssetId,
cover,
price,
marker,
title,
}) {
const id = stamp();
const metadata = gameMetadata(title, coverAssetId);
const metadata = gameMetadata(title, null);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: authorToken,
headers: { 'Idempotency-Key': `e2e-purchase-game-${id}` },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
if (created.status !== 200 || !created.data?.id) {
throw new Error(
@@ -393,6 +351,8 @@ async function publishGame({
);
}
const gameId = created.data.id;
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
const coverObjectKey = created.data.coverObjectKey;
const { versionId, pkg } = await createVersionAndSubmit({
authorToken,
@@ -400,7 +360,7 @@ async function publishGame({
price,
marker,
title,
coverAssetId,
coverObjectKey,
expectedPublicationRevision: created.data.publicationRevision ?? 0,
});
@@ -425,7 +385,7 @@ async function publishGame({
`审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`,
);
}
return { gameId, versionId, pkg };
return { gameId, versionId, pkg, coverObjectKey };
}
async function walletBalance(token) {
@@ -750,11 +710,11 @@ async function main() {
`status=${gateOpen.status}`,
);
const coverAssetId = await uploadCover(author.token, stamp());
const cover = coverImagePart(stamp());
check(
'作者封面素材直传并 confirm',
Boolean(coverAssetId),
String(coverAssetId).slice(0, 24),
'作者封面图片 part 准备完成',
cover.bytes.length > 0 && cover.contentType === 'image/png',
`${cover.fileName} bytes=${cover.bytes.length}`,
);
// 2. 付费游戏:发布 → 审核通过
@@ -762,7 +722,7 @@ async function main() {
const paid = await publishGame({
authorToken: author.token,
adminToken,
coverAssetId,
cover,
price: PRICE_MUD_POINTS,
marker: paidMarker,
title: `买断制支付验证 ${stamp().slice(-6)}`,
@@ -1091,7 +1051,7 @@ async function main() {
const free = await publishGame({
authorToken: author.token,
adminToken,
coverAssetId,
cover,
price: 0,
marker: freeMarker,
title: `免费游戏回归 ${stamp().slice(-6)}`,
@@ -1151,7 +1111,7 @@ async function main() {
const pricey = await publishGame({
authorToken: author.token,
adminToken,
coverAssetId,
cover,
price: 1_000_000,
marker: priceyMarker,
title: `余额不足验证 ${stamp().slice(-6)}`,
@@ -1413,7 +1373,8 @@ async function main() {
price: PRICE_MUD_POINTS,
marker: pendingMarker,
title: `待审付费版本 ${stamp().slice(-6)}`,
coverAssetId,
// 沿用该付费游戏当前封面 objectKey。
coverObjectKey: paid.coverObjectKey,
expectedPublicationRevision:
paidPublicBeforePending.data?.publicationRevision ?? 0,
});
@@ -1490,24 +1451,32 @@ async function main() {
// 定价边界用例用独立作者账号:作者自有列表按条数上限聚合返回版本,作品多的账号
// 会让目标游戏的版本查不到(既有口径),独立账号才能用公开接口可靠数版本。
const boundsAuthor = await registerAccount('132');
const boundsCoverAssetId = await uploadCover(boundsAuthor.token, stamp());
const boundsCover = coverImagePart(stamp());
check(
'定价边界用例的准备作者与封面就绪',
boundsAuthor.status === 200 && Boolean(boundsCoverAssetId),
`status=${boundsAuthor.status} cover=${Boolean(boundsCoverAssetId)}`,
boundsAuthor.status === 200 && boundsCover.bytes.length > 0,
`status=${boundsAuthor.status} coverBytes=${boundsCover.bytes.length}`,
);
const boundsGameCreated = await api('/api/game-distribution/games', {
method: 'POST',
token: boundsAuthor.token,
headers: { 'Idempotency-Key': `e2e-bounds-game-${stamp()}` },
body: gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, boundsCoverAssetId),
formData: publishFormData(
gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, null),
{ cover: boundsCover },
),
});
const boundsGameId = boundsGameCreated.data?.id;
// 建版沿用创建时服务端派生的封面 objectKey。
const boundsCoverObjectKey = boundsGameCreated.data?.coverObjectKey;
check(
'定价边界用例的准备游戏创建成功',
boundsGameCreated.status === 200 && Boolean(boundsGameId),
`status=${boundsGameCreated.status} gameId=${String(boundsGameId)}`,
);
// 建游失败时下面的 /my-games/undefined 与 /games/undefined/versions 会级联报错,
// 掩盖真正的根因;这里与其它脚本一致,先记录失败再退出。
if (!boundsGameId) process.exit(1);
// 单游戏作者视图把自有条目挂在 `data.game` 下(`versions` 在条目里,不在顶层)。
const countBoundsVersions = async () => {
const ownerEntry = await api(
@@ -1522,7 +1491,7 @@ async function main() {
method: 'POST',
token: boundsAuthor.token,
headers: { 'Idempotency-Key': `e2e-bounds-version-${suffix}-${stamp()}` },
body: {
formData: publishFormData({
priceMudPoints: price,
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
@@ -1530,9 +1499,9 @@ async function main() {
packageEntryPath: 'index.html',
gameMetadata: gameMetadata(
`定价边界验证 ${suffix}`,
boundsCoverAssetId,
boundsCoverObjectKey,
),
},
}),
});
const tooHigh = await declareVersion(1_000_001, 'toohigh');
check(
@@ -120,7 +120,8 @@ async function createGame(owner, title) {
description: null,
category: '休闲',
tags_json: '["e2e"]',
cover_asset_id: null,
cover_object_key: null,
screenshots_json: null,
author_name: [0, owner.user.displayName],
author_avatar_url: null,
device_support_desktop: true,
@@ -139,7 +140,6 @@ async function publish(gameId, owner, revision) {
title: '网站评价验收',
summary: '本地评价 fixture,发行资源不上传',
category: '休闲',
coverAssetId: 'reviews-e2e-cover',
coverObjectKey: 'reviews-e2e/cover.png',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: true, touch: true },
+38 -68
View File
@@ -15,10 +15,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
@@ -41,20 +43,26 @@ function check(name, ok, detail = '') {
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -85,54 +93,8 @@ function gameMetadata(title) {
};
}
async function uploadCover(token, id) {
const fileName = `sandbox-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'sandbox', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-sandbox',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `sandbox-${id}.png` });
const PROBE_HELPER = "export const marker = 'helper-ok';\n";
@@ -333,15 +295,19 @@ async function main() {
.createHash('sha256')
.update(packageBytes)
.digest('hex');
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
const title = `发行沙箱 ${String(stamp).slice(-6)}`;
const metadata = { ...gameMetadata(title), coverAssetId };
const metadata = {
...gameMetadata(title),
coverObjectKey: null,
screenshots: [],
};
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-game-${stamp}` },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check(
@@ -357,13 +323,17 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-version-${stamp}` },
body: {
formData: publishFormData({
packageSha256,
packageBytes: packageBytes.length,
packageFileCount: 3,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
// 建版沿用创建时服务端派生的封面 objectKey。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
}),
},
);
const versionId = versionResponse.data?.versionId;
@@ -21,10 +21,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
@@ -55,20 +57,26 @@ function check(name, ok, detail = '') {
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -138,56 +146,8 @@ function gameMetadata(title) {
};
}
async function uploadCover(token, id) {
const fileName = `upload-resume-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'upload-resume', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) {
throw new Error(`直传对象存储失败 ${put.status}`);
}
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-upload-resume',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `upload-resume-${id}.png` });
// 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。
async function buildLargeFixtureZip() {
@@ -246,14 +206,18 @@ async function runPrepare(paths) {
);
await writeFile(paths.zip, zipBytes);
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
const title = `分片续传 ${String(stamp).slice(-6)}`;
const metadata = { ...gameMetadata(title), coverAssetId };
const metadata = {
...gameMetadata(title),
coverObjectKey: null,
screenshots: [],
};
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `resume-game-${stamp}` },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check(
@@ -269,13 +233,17 @@ async function runPrepare(paths) {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `resume-version-${stamp}` },
body: {
formData: publishFormData({
packageSha256: zipSha256,
packageBytes: zipBytes.length,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
// 建版沿用创建时服务端派生的封面 objectKey。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
}),
},
);
const versionId = versionResponse.data?.versionId;
@@ -21,10 +21,12 @@ import { createHash } from 'node:crypto';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
@@ -49,20 +51,26 @@ function check(name, ok, detail = '') {
}
async function api(path, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -102,56 +110,8 @@ function gameMetadata(title) {
};
}
async function uploadCover(token, id) {
const fileName = `upload-safety-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'upload-safety', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) {
throw new Error(`直传对象存储失败 ${put.status}`);
}
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-upload-safety',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `upload-safety-${id}.png` });
async function main() {
const adminLogin = await api('/admin/api/login', {
@@ -205,9 +165,13 @@ async function main() {
const zipBytes = await buildFixtureZip();
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex');
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
const title = `并发上传 ${String(stamp).slice(-6)}`;
const metadata = { ...gameMetadata(title), coverAssetId };
const metadata = {
...gameMetadata(title),
coverObjectKey: null,
screenshots: [],
};
// 1. 幂等重放:同 key 同请求返回同一条记录,同 key 换请求体冲突。
const gameKey = `safety-game-${stamp}`;
@@ -215,7 +179,7 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': gameKey },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check(
@@ -229,7 +193,7 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': gameKey },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
check(
'同 key 同请求重放建游戏返回同一 game',
@@ -241,7 +205,7 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': gameKey },
body: { ...metadata, title: `${title} 改` },
formData: publishFormData({ ...metadata, title: `${title} 改` }, { cover }),
});
check(
'同 key 换请求体建游戏被拒(不产生第二份)',
@@ -255,7 +219,11 @@ async function main() {
packageBytes: zipBytes.length,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: metadata,
// 建版沿用创建时服务端派生的封面 objectKey。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
};
const versionResponse = await api(
`/api/game-distribution/games/${gameId}/versions`,
@@ -263,7 +231,7 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': versionKey },
body: versionBody,
formData: publishFormData(versionBody),
},
);
const versionId = versionResponse.data?.versionId;
@@ -283,7 +251,7 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': versionKey },
body: versionBody,
formData: publishFormData(versionBody),
},
);
check(
@@ -440,7 +408,7 @@ async function main() {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `safety-version-recovery-${stamp}` },
body: versionBody,
formData: publishFormData(versionBody),
},
);
const recoveryVersionId = recoveryVersion.data?.versionId;
@@ -15,10 +15,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const API_PORT = Number(new URL(API).port || '80');
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -49,20 +51,26 @@ function check(name, ok, detail = '') {
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -94,56 +102,18 @@ async function adminToken() {
return token;
}
async function uploadCover(token, id) {
const fileName = `restart-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'restart', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200)
throw new Error(`创建直传凭证失败 ${ticket.status}`);
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-restart',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `restart-${id}.png` });
function gameMetadata(title, coverAssetId) {
function gameMetadata(title, coverObjectKey) {
return {
title,
summary: '校验重启恢复 E2E',
description: '',
category: '休闲',
tags: ['restart'],
coverAssetId,
coverObjectKey,
screenshots: [],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
@@ -162,13 +132,13 @@ async function createVersion(
method: 'POST',
token,
headers: { 'Idempotency-Key': `restart-version-${suffix}` },
body: {
formData: publishFormData({
packageSha256: createHash('sha256').update(bytes).digest('hex'),
packageBytes: bytes.length,
packageFileCount: fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
}),
});
}
@@ -224,25 +194,33 @@ async function runPrepare() {
const author = register.data?.token;
check('作者注册拿到 token', register.status === 200 && Boolean(author));
if (!author) process.exit(1);
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
const title = `校验重启 ${String(stamp).slice(-6)}`;
const metadata = gameMetadata(title, coverAssetId);
const metadata = gameMetadata(title, null);
// 先发布 v1(公开版本),用来验证后续失败/重试不会误删公开内容。
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-game-${stamp}` },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check('创建游戏成功', created.status === 200 && Boolean(gameId));
// `check` 只记失败不中断:创建失败时 created.data 为空,继续解引用会抛未捕获 TypeError
// 把脚本直接打崩,拿不到后面成组的 check 结论。
if (!gameId) process.exit(1);
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
const resolvedMetadata = {
...metadata,
coverObjectKey: created.data.coverObjectKey,
};
const v1Package = await buildPackage(1, 'V1-OK');
const v1Bytes = v1Package.bytes;
const v1 = await createVersion(
author,
gameId,
metadata,
resolvedMetadata,
v1Bytes,
`${stamp}-v1`,
v1Package.fileCount,
@@ -279,7 +257,7 @@ async function runPrepare() {
const v2 = await createVersion(
author,
gameId,
metadata,
resolvedMetadata,
v2Bytes,
`${stamp}-v2`,
v2Package.fileCount,
@@ -330,7 +308,7 @@ async function runPrepare() {
password: DEV_PASSWORD,
gameId,
title,
coverAssetId,
coverObjectKey: resolvedMetadata.coverObjectKey,
versionId,
v1VersionId: v1.data?.versionId,
v2Bytes: v2Bytes.length,
@@ -430,20 +408,31 @@ async function runResume() {
Buffer.from('PK\x03\x04broken', 'binary'),
randomBytes(1024),
]);
const metadata = gameMetadata(state.title, state.coverAssetId);
// 兼容旧版脚本写下的 STATE_FILE:当时只存 coverAssetId(旧素材对象 ID,不是对象存储 key)。
// 建版沿用封面要求等于游戏当前的 cover_object_key,所以恢复时先查回真实 key,绝不能拿
// coverAssetId 冒充;v1 在写 STATE_FILE 前已审核通过,公开详情能返回当前封面。
const current = state.coverObjectKey
? null
: await api(`/api/game-distribution/games/${state.gameId}`, {
token: author,
});
const metadata = gameMetadata(
state.title,
state.coverObjectKey ?? current?.data?.coverObjectKey,
);
const v3 = await api(
`/api/game-distribution/games/${state.gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-version-v3-${state.stamp}` },
body: {
formData: publishFormData({
packageSha256: createHash('sha256').update(brokenBytes).digest('hex'),
packageBytes: brokenBytes.length,
packageFileCount: 1,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
}),
},
);
const v3Id = v3.data?.versionId;
@@ -10,10 +10,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -35,20 +37,26 @@ function check(name, ok, detail = '') {
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -67,47 +75,8 @@ async function loadPlaywright() {
return requireFromDir('playwright');
}
async function uploadCover(token, id) {
const fileName = `a11y-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'a11y', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200)
throw new Error(`创建直传凭证失败 ${ticket.status}`);
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-a11y',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `a11y-${id}.png` });
async function loginThroughWebUi(page, phone) {
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
@@ -192,14 +161,15 @@ async function publishFixtureGame(adminToken, stamp, title) {
.createHash('sha256')
.update(bytes)
.digest('hex');
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
const metadata = {
title,
summary: '可达性 E2E 临时游戏',
description: '',
category: '休闲',
tags: ['e2e'],
coverAssetId,
coverObjectKey: null,
screenshots: [],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
@@ -208,20 +178,27 @@ async function publishFixtureGame(adminToken, stamp, title) {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `a11y-game-${stamp}` },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
// 创建失败时 created.data 为空,下面解引用 coverObjectKey 会抛未捕获 TypeError 把脚本打崩,
// 拿不到成组的可达性 check 结论;先退出,保留一条正常的失败退出码。
if (!gameId) process.exit(1);
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `a11y-version-${stamp}` },
body: {
formData: publishFormData({
packageSha256: sha256,
packageBytes: bytes.length,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
// 建版沿用创建时服务端派生的封面 objectKey。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
}),
});
const versionId = version.data?.versionId;
const uploaded = await api(
+35 -64
View File
@@ -12,10 +12,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -69,20 +71,26 @@ async function warmUpWebRoutesInBrowser(browser, urls) {
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -100,50 +108,8 @@ async function api(pathname, options = {}) {
};
}
async function uploadCover(token, id) {
const fileName = `web-e2e-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'web-e2e', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(`创建直传凭证失败 ${ticket.status}`);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-web-e2e',
},
});
if (confirm.status !== 200) {
throw new Error(`确认素材失败 ${confirm.status}`);
}
return confirm.data.assetObject.assetObjectId;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `web-e2e-${id}.png` });
async function publishFixtureGame(stamp, title) {
const adminLogin = await api('/admin/api/login', {
@@ -202,14 +168,15 @@ async function publishFixtureGame(stamp, title) {
.update(packageBytes)
.digest('hex');
const coverAssetId = await uploadCover(author, stamp);
const cover = coverImagePart(stamp);
const metadata = {
title,
summary: '网页链路 E2E 临时游戏',
description: '',
category: '休闲',
tags: ['e2e'],
coverAssetId,
coverObjectKey: null,
screenshots: [],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
@@ -219,7 +186,7 @@ async function publishFixtureGame(stamp, title) {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `web-e2e-game-${stamp}` },
body: metadata,
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
if (created.status !== 200 || !gameId) {
@@ -233,13 +200,17 @@ async function publishFixtureGame(stamp, title) {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `web-e2e-version-${stamp}` },
body: {
formData: publishFormData({
packageSha256,
packageBytes: packageBytes.length,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
}),
},
);
const versionId = versionResponse.data?.versionId;
+7 -4
View File
@@ -6,9 +6,10 @@
* 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接——
* 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。
*
* 覆盖两套 crate、四个目录:
* 覆盖两套 crate、五个目录:
* - `shared-contracts` → `packages/shared/src/contracts/generated`;
* - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types`。
* - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types` +
* `export/generated`。
*
* 生成文件一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写:这些目录
* 同时登记在 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,所以这里直接逐字节
@@ -30,6 +31,8 @@ const AGC_CHAT_DIR =
const AGC_SERVICES_DIR = 'apps/ai-game-creator-shell/src/services/generated';
const AGC_UI_EDITOR_DIR =
'apps/ai-game-creator-shell/src/features/ui-editor/types';
const AGC_EXPORT_DIR =
'apps/ai-game-creator-shell/src/view/project-development/export/generated';
/** 一套绑定的生成入口、覆盖目录与"怎么重新生成"的提示。 */
const targets = [
@@ -51,7 +54,7 @@ const targets = [
},
{
label: 'ai-game-creator-shell',
dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR],
dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR, AGC_EXPORT_DIR],
cargoArgs: [
'test',
'--locked',
@@ -70,7 +73,7 @@ function snapshot(dir) {
// "0 个文件"静默通过——`export_to` 路径被改名或生成文件被整体删除时,校验形同虚设。
throw new Error(`生成绑定目录不存在,无法校验:${dir}`);
}
// 四个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录);
// 五个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录);
// 不用 `recursive`,避免依赖 Node 18.17 / 20.1 才有的选项(旧版本会静默退化成只读顶层)。
const entries = fs.readdirSync(absolute);
return new Map(
+82
View File
@@ -0,0 +1,82 @@
// 游戏分发 E2E 脚本共用的 multipart 发布 helper。
//
// 这些脚本都直连 api-server,发布口是同一套契约:`metadata` JSON 文本 part + 可选 `cover`
// 二进制 part + 按顺序重复的 `screenshot` 二进制 part;各自的 `api()` 也只在 body 形态上分支。
// 这里收敛这段稳定契约,脚本只保留自己的路径、鉴权头与返回形状差异。
/** 1x1 透明 PNG:仅用于满足发布必填的封面/截图 part,内容不参与断言。 */
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
/**
* 发布用图片 part。
*
* 默认文件名 `<kind>-<id>.png`、`image/png`;脚本可通过 `options.fileName` 保留自己的前缀,
* 或用 `options.bytes` / `options.contentType` 覆盖字节与类型。
*/
export function imagePart(kind, id, options = {}) {
const {
bytes = COVER_PNG,
fileName = `${kind}-${id}.png`,
contentType = 'image/png',
} = options;
return { bytes, fileName, contentType };
}
/**
* 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 按顺序重复的 `screenshot` 二进制 part。
*
* `metadata.screenshots` 里的 `null` 槽位按出现顺序消费 `screenshots` 数组里的 part;string
* 槽位沿用该 objectKey。调用方负责保证 null 槽位数量与 part 数量一致(不一致服务端会 400)。
*/
export function publishFormData(metadata, { cover, screenshots = [] } = {}) {
const form = new FormData();
form.append('metadata', JSON.stringify(metadata));
if (cover) {
form.append(
'cover',
new Blob([cover.bytes], { type: cover.contentType }),
cover.fileName,
);
}
for (const screenshot of screenshots) {
if (!screenshot) continue;
form.append(
'screenshot',
new Blob([screenshot.bytes], { type: screenshot.contentType }),
screenshot.fileName,
);
}
return form;
}
/**
* 推导 `api()` 的最终请求头与请求体。
*
* - `formData`:multipart/form-data,不手动设 `Content-Type`,交给 fetch 生成 boundary。
* - `binary`:直接作为 body(例如分片 PUT 的字节)。
* - `body !== undefined`:按 JSON 序列化,并补 `Content-Type: application/json`。
*
* 调用方先自行合并 `{ ...ENVELOPE, ...headers }` 与 `Authorization`,再把结果作为 `headers`
* 传进来;返回值里的 `headers` 是补完 `Content-Type` 后的副本。
*/
export function resolveApiRequest({
formData,
binary,
body,
headers = {},
} = {}) {
const finalHeaders = { ...headers };
let finalBody;
if (formData) {
finalBody = formData;
} else if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
return { headers: finalHeaders, body: finalBody };
}
+10 -4
View File
@@ -428,12 +428,19 @@ function assertFixtureRepository(cwd, env) {
);
}
const fixtureTempRoot = join(homedir(), 'data', 'tmp');
function isolatedGitEnvironment() {
// Git hooks export repository/index paths that override cwd, including in
// linked worktrees. Fixture Git and lint-staged must never inherit them.
return Object.fromEntries(
const env = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !/^GIT_/iu.test(name)),
);
// 夹具建在 ~/data/tmp 下,而 home 自身也可能是 Git 仓库(部分 CI 容器即如此)。
// 用 ceiling 阻止 git 向上发现父仓库,否则尚未 `git init` 的夹具会被
// assertFixtureRepository 误判成“命令落到外部仓库”。
env.GIT_CEILING_DIRECTORIES = fixtureTempRoot;
return env;
}
// 守卫用例把本文件跑在毒化环境里;子进程必须真的继承 GIT_DIR,否则断言会空转。
@@ -472,7 +479,6 @@ function assertHookClearsGitEnvironment(hookName, contents, command) {
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
function createTempDirectory(prefix) {
const tempRoot = join(homedir(), 'data', 'tmp');
mkdirSync(tempRoot, { recursive: true });
return mkdtempSync(join(tempRoot, prefix));
mkdirSync(fixtureTempRoot, { recursive: true });
return mkdtempSync(join(fixtureTempRoot, prefix));
}
+1 -1
View File
@@ -191,7 +191,7 @@ def main():
game_id = f"creator-web-fixture-{prefix}-{n:03}"
version_id = game_id + "-v1"
created = {"__timestamp_micros_since_unix_epoch__": 1800000000000000 + (1000 if prefix == "other" else 0) + n}
game = dict(game_id=game_id, owner_user_id=owner, title=game_id, summary="测试", description="测试", category="adventure", tags_json="[]", cover_asset_id=none, author_name=none, author_avatar_url=none, device_support_desktop=True, device_support_mobile=True, device_support_touch=True, input_modes_json='["touch"]', orientation="any", publication_revision=1, active_version_id=some(version_id), visibility="published", play_count=0, created_at=created, updated_at=created, local_project_id=none, cover_object_key=none, screenshots_json=none, deleted_at=none)
game = dict(game_id=game_id, owner_user_id=owner, title=game_id, summary="测试", description="测试", category="adventure", tags_json="[]", author_name=none, author_avatar_url=none, device_support_desktop=True, device_support_mobile=True, device_support_touch=True, input_modes_json='["touch"]', orientation="any", publication_revision=1, active_version_id=some(version_id), visibility="published", play_count=0, created_at=created, updated_at=created, local_project_id=none, cover_object_key=none, screenshots_json=none, deleted_at=none, price_mud_points=0)
version = dict(version_id=version_id, game_id=game_id, owner_user_id=owner, version_number=1, package_sha256="0" * 64, package_bytes=1, package_file_count=1, package_entry_path="index.html", status="published", review_reason=none, created_at=created, updated_at=created, local_project_id=none, package_object_key=none, package_manifest_json=none, entry_url=some("/games/game_" + hashlib.sha256(game_id.encode()).hexdigest()[:32] + "/"), reviewed_by_user_id=none, uploaded_at=none, submitted_at=none, reviewed_at=none, published_at=some(created), revoked_at=none, last_error_code=none, last_error_message=none, metadata_json=none)
if prefix == "hidden":
if n == 0: game["visibility"] = "unpublished"