AGC 陶泥儿发布表单内联进左栏并把状态提升到面板根

- 陶泥儿 tab 改为左右两栏:左半内联发布资料表单,右半第二个会话 chatbox

- 去掉「发布到游戏广场」二次跳转与弹窗表单,主行动改为内容末尾的橙色「打包并发布」

- 打包并发布一次点击先按需构建打包(沿用进度 / 阻断弹窗),成功后直接提交发布

- 抽出 useGameDistributionPublishForm 与 GameDistributionPublishFormView,弹窗与内联共用同一份表单

- 发布表单状态提升到面板根 useTaonierTab,切 tab 不丢已填资料;只在首次进入陶泥儿 tab 时初始化

- 同步更新发布反馈用例与玩法链路文档
This commit is contained in:
2026-10-06 17:48:58 +08:00
parent d4797abb85
commit 727aae5844
10 changed files with 1412 additions and 986 deletions
@@ -52,11 +52,18 @@ export function usePanel({
const [xiaohongshuVisited, setXiaohongshuVisited] = useState(false);
const xiaohongshuEntered =
xiaohongshuVisited || activeTarget === Target.Xiaohongshu;
/**
* 陶泥儿发布表单的初始化开关同样比 `enabled` 窄:只有真的到过陶泥儿 tab 才初始化
* (进入即触发表单预填与 AI 简介建议)。状态留在面板根,离开后再回来不重来。
*/
const [taonierVisited, setTaonierVisited] = useState(false);
const taonierEntered = taonierVisited || activeTarget === Target.Taonier;
/** 切到某个目标时的共同动作:两条入口(点卡片、点 tab 条)都必须经过它。 */
const enterTarget = useCallback((target: Target) => {
setActiveTarget(target);
if (target === Target.Xiaohongshu) setXiaohongshuVisited(true);
if (target === Target.Taonier) setTaonierVisited(true);
}, []);
const openTarget = useCallback(
@@ -72,7 +79,10 @@ export function usePanel({
const taonier: TaonierTabState = useTaonierTab({
enabled: workspaceEntered && Boolean(projectPath),
formEnabled: workspaceEntered && taonierEntered && Boolean(projectPath),
projectPath,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
});
const xiaohongshu: XiaohongshuTabState = useXiaohongshuTab({
@@ -1,11 +1,20 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { GameCreationAppManifest } from '../../../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../../../app/tauri';
import type { LocalProjectExportPackageResult } from '../../../../app/types';
import type { GamePublishProgressState } from '../../../../components/game-distribution/GamePublishProgressDialog';
import { useGameDistributionPublishForm } from '../../../../components/game-distribution/useGameDistributionPublishForm';
import { readGamePublishAvailability } from '../../../../services/gameDistributionPublish';
import {
type DirectProjectConversationReadGate,
type DirectProjectConversationWriteGate,
useDirectProjectChatController,
} from '../../chat/controller/useDirectProjectChatController';
import { useDirectProjectManifest } from '../../chat/controller/useDirectProjectManifest';
import { useDirectProjectTurnStatus } from '../../chat/controller/useDirectProjectTurnStatus';
import { buildDirectChatTurns } from '../../chat/conversation/directTurnPresentation';
import type { UserContentPart } from '../../chat/generated/UserContentPart';
export type TaonierTabState = ReturnType<typeof useTaonierTab>;
@@ -42,29 +51,46 @@ function appendPublishPermissionLog(projectPath: string) {
}
/**
* 陶泥儿导出 tab 的全部状态。
* 陶泥儿导出 tab 的全部状态:左半的发布编排 + 右半的第二个会话。
*
* 发布编排(构建 → 打包 → 发布资料面板)从工作台壳搬到这里:tab 页只渲染,状态挂在
* **面板根**(`usePanel`)上,所以切到别的 tab 再切回来时试玩包结果、封面与幂等键都还在。
* 关闭导出面板会卸载这个 hook,在途的 `export_local_project_package` 回来后由
* `mountedRef` 丢弃,不再向已卸载的组件写状态。
* 这个 hook 挂在**面板根**(`usePanel`)上,不挂在 tab 页里:切到别的 tab 时页面组件会卸载,
* 但发布灰度、试玩包结果、进度与右半的订阅、回合、草稿必须留在面板上。
*
* 右半会话走 `useDirectProjectChatController` 内部的 `useDirectThreadChatSubscription`:
* 那是同一 thread 的**第二个 subscriber**,有自己的 `subscriptionId` 与游标,不与主聊天互相推进;
* 输入草稿同样提升到面板层,Composer 随 tab 页卸载再挂回来也不丢字。
*
* 关闭导出面板会卸载这个 hook,在途的 `export_local_project_package` 回来后由 `mountedRef` 丢弃,
* 不再向已卸载的组件写状态。
*/
export function useTaonierTab({
enabled,
formEnabled,
projectPath,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
}: {
/** 进过一次工作台且项目路径就绪后才读灰度:进入前不产生任何后端访问。 */
/** 进过一次工作台且项目路径就绪后才读灰度与订阅:进入前不产生任何后端访问。 */
enabled: boolean;
/**
* 发布资料表单的初始化开关,比 `enabled` 更窄:只有真的到过陶泥儿 tab 才初始化。
* 表单状态仍留在面板根,切走再切回来不重来、也不丢已填内容。
*/
formEnabled: boolean;
projectPath: string | null;
/** 项目对话读 / 写权限门:第二订阅读历史、以及从这里发送回合时都必经这两道门。 */
ensureConversationReadAllowed: DirectProjectConversationReadGate;
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
}) {
const { manifest } = useDirectProjectManifest(projectPath);
const { assets, manifest, projectId, refresh, versions } =
useDirectProjectManifest(projectPath);
const manifestRef = useRef<GameCreationAppManifest | null>(manifest);
manifestRef.current = manifest;
const [allowed, setAllowed] = useState(false);
const [availabilityPending, setAvailabilityPending] = useState(true);
const [packageResult, setPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [progress, setProgress] = useState<GamePublishProgressState | null>(
null,
);
@@ -82,79 +108,156 @@ export function useTaonierTab({
if (!enabled) return;
let cancelled = false;
setAllowed(false);
setAvailabilityPending(true);
void readGamePublishAvailability()
.then((value) => {
if (!cancelled) setAllowed(value);
})
.catch(() => {
if (!cancelled) setAllowed(false);
})
.finally(() => {
if (!cancelled) setAvailabilityPending(false);
});
return () => {
cancelled = true;
};
}, [enabled, projectPath]);
const requestPublish = useCallback(async () => {
const currentProjectPath = projectPath?.trim() ?? '';
if (!currentProjectPath) {
setBlockedMessage('先打开一个项目再发布');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setBlockedMessage('需要在 Tauri App 内发布');
return;
}
const currentManifest = manifestRef.current;
if (!currentManifest) {
setBlockedMessage('项目清单尚未就绪,请稍后重试');
return;
}
if (!hasRunnablePrototype(currentManifest)) {
setBlockedMessage(
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
);
return;
}
setProgress({
phase: 'running',
message: '正在构建并打包试玩包,请稍候…',
});
try {
const result = await invoke<LocalProjectExportPackageResult>(
'export_local_project_package',
{ projectPath: currentProjectPath },
);
if (!mountedRef.current) return;
setProgress(null);
setPackageResult(result);
setPanelOpen(true);
appendPublishPermissionLog(currentProjectPath);
} catch (error) {
if (!mountedRef.current) return;
setProgress({
phase: 'failed',
message: '发布失败',
detail: error instanceof Error ? error.message : String(error),
});
}
}, [projectPath]);
const [runtimeNotice, setRuntimeNotice] = useState('');
/**
* 输入草稿提升到面板层:Composer 随 tab 页卸载后,正文不再随组件一起丢。
* 提交成功时 Composer 内部的 `clear()` 会经 `onDraftChange` 把空草稿回执上来。
*/
const [draft, setDraft] = useState<UserContentPart[]>([]);
const chat = useDirectProjectChatController({
enabled,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
onRuntimeError: setRuntimeNotice,
projectId,
projectPath,
refreshManifest: refresh,
});
const turns = useMemo(
() =>
buildDirectChatTurns({
entries: chat.directEntries,
localMessages: chat.localMessages,
turnRunning: chat.directTurnRunning,
// 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。
turnStartedAt: chat.directTurnStartedAt,
}),
[
chat.directEntries,
chat.localMessages,
chat.directTurnRunning,
chat.directTurnStartedAt,
],
);
const turnStatus = useDirectProjectTurnStatus({
turnRunning: chat.directTurnRunning,
commandInFlight: chat.commandInFlight,
pendingTurns: chat.pendingTurns,
turns,
});
// 运行中回合的读秒起点:只有最新一轮且还没结束才拿实时值,历史回合是 0(不读秒)。
const latestTurn = turns.at(-1) ?? null;
const activeTurnStartedAt =
latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
/**
* 构建并打包试玩包,返回结果供同一次点击继续发布。
*
* 灰度未命中、缺项目、宿主缺失、原型未完成或构建失败都返回 `null`:阻断 / 失败反馈由本 hook
* 的 `blockedMessage` / `progress` 承载,调用方只负责不再往下走。
*/
const ensurePackage =
useCallback(async (): Promise<LocalProjectExportPackageResult | null> => {
const currentProjectPath = projectPath?.trim() ?? '';
if (!currentProjectPath) {
setBlockedMessage('先打开一个项目再发布');
return null;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setBlockedMessage('需要在 Tauri App 内发布');
return null;
}
const currentManifest = manifestRef.current;
if (!currentManifest) {
setBlockedMessage('项目清单尚未就绪,请稍后重试');
return null;
}
if (!hasRunnablePrototype(currentManifest)) {
setBlockedMessage(
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
);
return null;
}
setProgress({
phase: 'running',
message: '正在构建并打包试玩包,请稍候…',
});
try {
const result = await invoke<LocalProjectExportPackageResult>(
'export_local_project_package',
{ projectPath: currentProjectPath },
);
if (!mountedRef.current) return null;
setProgress(null);
setPackageResult(result);
appendPublishPermissionLog(currentProjectPath);
return result;
} catch (error) {
if (!mountedRef.current) return null;
setProgress({
phase: 'failed',
message: '发布失败',
detail: error instanceof Error ? error.message : String(error),
});
return null;
}
}, [projectPath]);
const closePanel = useCallback(() => setPanelOpen(false), []);
const closeProgress = useCallback(() => setProgress(null), []);
const closeBlocked = useCallback(() => setBlockedMessage(null), []);
/**
* 发布资料表单的状态提升到面板根:切走 tab、页面组件卸载后已填的资料仍在。
*
* 内联形态(`inline: true`)与「打包并发布」共用同一个 `ensurePackage`:一次点击先按需
* 构建打包,再用返回的包继续提交发布。
*/
const publishForm = useGameDistributionPublishForm({
open: formEnabled,
inline: true,
projectPath: projectPath ?? '',
manifest,
packageResult,
onEnsurePackage: ensurePackage,
});
return {
enabled,
allowed,
availabilityPending,
manifest,
assets,
versions,
projectPath,
packageResult,
panelOpen,
publishForm,
progress,
blockedMessage,
requestPublish,
closePanel,
chat,
draft,
setDraft,
runtimeNotice,
turns,
turnStatus,
activeTurnStartedAt,
ensurePackage,
closeProgress,
closeBlocked,
};
@@ -1,61 +1,20 @@
import { Rocket } from 'lucide-react';
import { GameDistributionPublishPanel } from '../../../../../components/game-distribution/GameDistributionPublishPanel';
import { GamePublishBlockedDialog } from '../../../../../components/game-distribution/GamePublishBlockedDialog';
import { GamePublishProgressDialog } from '../../../../../components/game-distribution/GamePublishProgressDialog';
import type { TaonierTabState } from '../../state/useTaonierTab';
import { ChatPane } from './chat/Pane';
import { PublishPane } from './PublishPane';
/**
* 陶泥儿导出 tab:这里是「发布到游戏广场」的唯一入口。
* 陶泥儿发布工作台:中缝一分为二,左半是内联发布表单与「打包并发布」,右半是另一个 chatbox。
*
* 状态全部在面板根的 `useTaonierTab`(见 `usePanel`),这一页只负责渲染入口、挂发布资料
* 面板与两条反馈弹窗;构建、打包、灰度与幂等都由状态 hook 持有。
* 左右两侧的状态都在面板根的 `useTaonierTab`:这一页跟着 tab 切走时可以卸载,发布灰度、
* 试玩包结果、订阅、回合与草稿都留在面板上。两条发布反馈弹窗仍由面板根的状态驱动。
*/
export function TaonierPage({ tab }: { tab: TaonierTabState }) {
const manifest = tab.manifest;
const busy = tab.progress?.phase === 'running';
const canPublish = tab.allowed && Boolean(manifest) && !busy;
return (
<div
className="grid h-full min-h-0 place-items-center p-6"
aria-label="陶泥儿导出页"
>
{manifest ? (
<div className="w-full max-w-md rounded-3xl border border-[#e2cbb8] bg-[#fffaf5] p-8 text-center shadow-[0_18px_40px_rgba(112,57,30,0.08)]">
<h2 className="text-lg font-semibold text-[#3d1f10]">
发布到游戏广场
</h2>
<p className="mt-3 text-sm text-[#6f5848]">
先构建并打包当前项目的试玩包,再填写发行资料提交审核。
</p>
{tab.allowed ? null : (
<p className="mt-2 text-xs text-[#988476]">
当前账号暂未开放发布入口。
</p>
)}
<button
type="button"
disabled={!canPublish}
onClick={() => void tab.requestPublish()}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-4 py-2 text-sm font-medium text-white transition enabled:hover:bg-[#b3572f] disabled:cursor-not-allowed disabled:border-[#e2cbb8] disabled:bg-[#f4e6da] disabled:text-[#b9a08f]"
>
<Rocket size={16} aria-hidden="true" />
{busy ? '正在构建并打包…' : '发布到游戏广场'}
</button>
</div>
) : (
<p className="text-sm text-[#988476]">正在读取项目清单…</p>
)}
{manifest ? (
<GameDistributionPublishPanel
open={tab.panelOpen}
projectPath={tab.projectPath ?? ''}
manifest={manifest}
packageResult={tab.packageResult}
onClose={tab.closePanel}
/>
) : null}
<div className="grid h-full min-h-0 min-w-0 grid-cols-1 overflow-hidden md:grid-cols-2">
<PublishPane tab={tab} />
<ChatPane tab={tab} />
<GamePublishProgressDialog
progress={tab.progress}
onClose={tab.closeProgress}
@@ -0,0 +1,42 @@
import { GameDistributionPublishFormView } from '../../../../../components/game-distribution/GameDistributionPublishFormView';
import type { TaonierTabState } from '../../state/useTaonierTab';
/**
* 陶泥儿工作台左半:内联的发布资料表单 + 内容末尾整行橙色的「打包并发布」。
*
* 表单不再以弹窗出现(没有第二跳):状态在面板根的 `useTaonierTab`(`publishForm`),
* 这一页跟着 tab 切走时可以卸载,已填的资料不丢。灰度未命中时只挡发布,不挡看表单。
*/
export function PublishPane({ tab }: { tab: TaonierTabState }) {
const manifest = tab.manifest;
if (!manifest || tab.availabilityPending) {
return (
<div className="flex h-full min-h-0 items-center justify-center border-[#e2cbb8] bg-[#fffdf9]/40 p-6 md:border-r">
<p className="text-sm text-[#988476]">
{tab.availabilityPending ? '正在读取发布权限…' : '正在读取项目清单…'}
</p>
</div>
);
}
if (!tab.allowed) {
return (
<div className="flex h-full min-h-0 items-center justify-center border-[#e2cbb8] bg-[#fffdf9]/40 p-6 md:border-r">
<p className="text-sm text-[#988476]">当前账号暂未开放发布入口。</p>
</div>
);
}
return (
<div
className="flex h-full min-h-0 min-w-0 flex-col border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r"
aria-label="游戏发布表单区"
>
<GameDistributionPublishFormView
form={tab.publishForm}
inline
open
onClose={() => undefined}
packaging={tab.progress?.phase === 'running'}
/>
</div>
);
}
@@ -0,0 +1,19 @@
import type { TaonierTabState } from '../../../state/useTaonierTab';
import { ExportChatPane } from '../../common/ChatPane';
/**
* 陶泥儿发布工作台右半 chatbox:与左侧发布表单并列的第二个订阅会话。
*
* 会话真相在面板根的 `useTaonierTab`:订阅、回合与草稿都留在面板上,切走再切回来还是同一条
* 会话、同一份草稿。
*/
export function ChatPane({ tab }: { tab: TaonierTabState }) {
return (
<ExportChatPane
tab={tab}
ariaLabel="游戏发布对话栏"
chatAriaLabel="游戏发布对话"
fallbackStatusText="游戏发布对话"
/>
);
}
@@ -2,8 +2,8 @@
/**
* 客户端「发布」入口的可见反馈。
*
* 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片;
* 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。
* 陶泥儿 tab 左半是内联的发布资料表单,内容末尾的橙色「打包并发布」一次点击先构建打包、
* 再提交发布。打包用独立的全屏进度弹窗(失败留在弹窗内),成功后不再弹出发布面板。
*/
import { readFileSync } from 'node:fs';
@@ -129,16 +129,14 @@ function renderPublishProject(
async function openTaonierPublishEntry() {
fireEvent.click(await screen.findByRole('button', { name: '导出产物' }));
fireEvent.click(await screen.findByRole('button', { name: '陶泥儿' }));
const publish = await screen.findByRole('button', {
name: '发布到游戏广场',
});
const publish = await screen.findByRole('button', { name: '打包并发布' });
await waitFor(() => expect(publish).toHaveProperty('disabled', false));
return publish;
}
async function clickPublish() {
fireEvent.click(await openTaonierPublishEntry());
return screen.findByLabelText('陶泥儿项目对话');
return screen.findByLabelText('游戏发布对话栏');
}
beforeEach(() => {
@@ -204,7 +202,7 @@ describe('客户端发布入口的可见反馈', () => {
});
});
it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => {
it('打包时显示全屏进度遮罩,成功后直接进入发布提交(不再弹发布面板)', async () => {
const deferred =
createDeferred<ReturnType<typeof createExportPackageResult>>();
const exportPackage = vi.fn(() => deferred.promise);
@@ -232,12 +230,14 @@ describe('客户端发布入口的可见反馈', () => {
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull();
});
// 打包成功后直接提交发布;还没有封面时把错误留在表单里,而不是再弹一个发布面板。
expect(
await screen.findByRole('dialog', { name: '发布到游戏广场' }),
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
).not.toBeNull();
expect(screen.queryByRole('dialog', { name: '发布到游戏广场' })).toBeNull();
});
it('发布失败时在进度弹窗里显示错误并允许关闭', async () => {
it('打包失败时在进度弹窗里显示错误并允许关闭', async () => {
installTauri({
exportPackage: () => {
throw new Error('构建可玩版本失败:缺少入口');
@@ -86,7 +86,7 @@
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 导出产物面板陶泥儿 tab 的「发布到游戏广场」入口据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 导出产物面板陶泥儿 tab 的左半发布工作台(内联资料表单 + 内容末尾的橙色「打包并发布」按钮,与右半第二个会话 chatbox 左右并列)据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布表单不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。