diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 0b66656d1..9c4b04cf0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -38,18 +38,11 @@ import type { LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, - LocalProjectExportPackageResult, LocalProjectKind, PendingUiConfirmation, ProjectPermissionPolicyView, TauriInvoke, } from './app/types'; -import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; -import { GamePublishBlockedDialog } from './components/game-distribution/GamePublishBlockedDialog'; -import { - GamePublishProgressDialog, - type GamePublishProgressState, -} from './components/game-distribution/GamePublishProgressDialog'; import { agentConversationId, createAgentChatRunId, @@ -88,7 +81,6 @@ import { type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; -import { readGamePublishAvailability } from './services/gameDistributionPublish'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -326,19 +318,8 @@ export function App({ const chatComposerRef = useRef(null); const [chatAgentBusy, setChatAgentBusy] = useState(false); - // 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。 - const [publishPackageResult, setPublishPackageResult] = - useState(null); - const [publishPanelOpen, setPublishPanelOpen] = useState(false); // 导出产物:全屏面板由工作台壳按需挂载,关闭即卸载,面板状态不留在壳上。 const [exportPanelOpen, setExportPanelOpen] = useState(false); - const [publishProgress, setPublishProgress] = - useState(null); - const [publishBlockedMessage, setPublishBlockedMessage] = useState< - string | null - >(null); - // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 - const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); const designReplyAnimation = useDesignReplyAnimation(); const [designAgentStatus, setDesignAgentStatus] = useState(''); @@ -869,77 +850,6 @@ export function App({ } } - useEffect(() => { - let cancelled = false; - void readGamePublishAvailability() - .then((allowed) => { - if (!cancelled) setGamePublishAllowed(allowed); - }) - .catch(() => { - if (!cancelled) setGamePublishAllowed(false); - }); - return () => { - cancelled = true; - }; - }, [localProject?.projectPath]); - - /** - * 导出试玩包并打开发布面板。 - * - * 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区, - * 导出失败在弹窗内回显;成功后才切换到发布资料面板。 - */ - async function requestGamePublish() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setPublishBlockedMessage('需要在 Tauri App 内发布'); - return; - } - const nextProjectPath = - resolveChatProjectPath(localProject) ?? projectPath.trim(); - if (!nextProjectPath) { - setPublishBlockedMessage('先打开一个项目再发布'); - return; - } - const publishManifest = manifestRef.current; - const hasCompletedPrototype = publishManifest.tasks.some( - (task) => task.id === 'code-prototype' && task.status === 'completed', - ); - const hasRunningPreview = - publishManifest.preview?.status === 'running' && - Boolean(publishManifest.preview.url?.trim()); - if (!hasCompletedPrototype && !hasRunningPreview) { - setPublishBlockedMessage( - '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', - ); - return; - } - setPublishProgress({ - phase: 'running', - message: '正在构建并打包试玩包,请稍候…', - }); - try { - const result = await invoke( - 'export_local_project_package', - { projectPath: nextProjectPath }, - ); - setPublishProgress(null); - setPublishPackageResult(result); - setPublishPanelOpen(true); - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'project.export_package', - ); - } catch (error) { - setPublishProgress({ - phase: 'failed', - message: '发布失败', - detail: error instanceof Error ? error.message : String(error), - }); - } - } - async function confirmUiCommand() { const pending = pendingUiConfirmation; if (!pending) { @@ -1850,9 +1760,6 @@ export function App({ ensureConversationReadAllowed={ensureDirectHistoryReadAllowed} ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed} initialTurn={initialDirectTurn} - onRequestGamePublish={ - gamePublishAllowed ? requestGamePublish : undefined - } onRequestExport={ localProject?.projectPath || projectPath ? () => setExportPanelOpen(true) @@ -1872,21 +1779,6 @@ export function App({ onClose={() => setExportPanelOpen(false)} /> ) : null} - setPublishPanelOpen(false)} - /> - setPublishProgress(null)} - /> - setPublishBlockedMessage(null)} - /> ); } @@ -2003,21 +1895,6 @@ export function App({ onClose={() => setRuntimeConfigOpen(false)} /> ) : null} - setPublishPanelOpen(false)} - /> - setPublishProgress(null)} - /> - setPublishBlockedMessage(null)} - /> ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index e33dabdd0..26fa9281d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -75,8 +75,6 @@ export type DirectProjectChatViewProps = { ensureConversationReadAllowed: DirectProjectConversationReadGate; /** 项目对话写权限门:只在发起回合时检查;同样必填,缺省会让未传入的调用方静默跳过授权。 */ ensureConversationWriteAllowed: DirectProjectConversationWriteGate; - /** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */ - onRequestGamePublish?: () => void; /** 导出产物入口:由工作台壳持有导出面板,聊天只提供触发按钮。 */ onRequestExport?: () => void; /** @@ -91,7 +89,6 @@ export type DirectProjectChatViewProps = { }; export function DirectProjectChatView({ - onRequestGamePublish, onRequestExport, onConfirmConfirmation, onCancelConfirmation, @@ -230,7 +227,6 @@ export function DirectProjectChatView({ turnStatus={turnStatus} activeTurnStartedAt={activeTurnStartedAt} statusText={statusText} - {...(onRequestGamePublish ? { onRequestGamePublish } : {})} {...(onRequestExport ? { onRequestExport } : {})} {...(pendingConfirmation ? { pendingConfirmation } : {})} {...(onConfirmConfirmation ? { onConfirmConfirmation } : {})} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatBox/DirectProjectChatBox.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatBox/DirectProjectChatBox.tsx index e4b1ded5f..8beb7d96d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatBox/DirectProjectChatBox.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatBox/DirectProjectChatBox.tsx @@ -52,7 +52,6 @@ export type DirectProjectChatBoxProps = { turnStatus: DirectProjectTurnStatus; activeTurnStartedAt: number; statusText: string; - onRequestGamePublish?: () => void; onRequestExport?: () => void; pendingConfirmation?: PendingUiConfirmation | null; onConfirmConfirmation?: () => void; @@ -75,7 +74,6 @@ export function DirectProjectChatBox({ turnStatus, activeTurnStartedAt, statusText, - onRequestGamePublish, onRequestExport, pendingConfirmation = null, onConfirmConfirmation, @@ -100,7 +98,6 @@ export function DirectProjectChatBox({ busy={turnStatus.displayBusy} statusText={statusText} onOpenSettings={() => setSettingsOpen(true)} - {...(onRequestGamePublish ? { onRequestGamePublish } : {})} {...(onRequestExport ? { onRequestExport } : {})} /> void; - /** 没有项目或不在 Tauri 宿主时不传;传了才渲染发布入口。 */ - onRequestGamePublish?: () => void; /** 没有项目时不传;传了才渲染导出产物入口。 */ onRequestExport?: () => void; }) { @@ -43,19 +40,6 @@ export function DirectProjectChatHeader({ 导出 ) : null} - {onRequestGamePublish ? ( - - ) : null} + + ) : ( +

正在读取项目清单…

+ )} + {manifest ? ( + + ) : null} + + + + ); } diff --git a/apps/ai-game-creator-shell/tests/directProjectChatHeader.test.tsx b/apps/ai-game-creator-shell/tests/directProjectChatHeader.test.tsx index 812e27427..f3ef05b9c 100644 --- a/apps/ai-game-creator-shell/tests/directProjectChatHeader.test.tsx +++ b/apps/ai-game-creator-shell/tests/directProjectChatHeader.test.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom /** - * DirectProject 聊天头的发布入口。 + * DirectProject 聊天头的导出入口。 * - * 发布面板与试玩包导出由工作台壳持有,聊天头只负责在壳传入回调时渲染入口; - * 没有回调(例如策划聊天面)时不能出现点不动的按钮。 + * 发布入口归导出产物面板的陶泥儿 tab,聊天头不再持有;这里只验证导出产物入口按回调渲染, + * 以及没有回调(例如策划聊天面)时不出现点不动的按钮。 */ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -18,42 +18,38 @@ function renderHeader( overrides: Partial[0]> = {}, ) { const onOpenSettings = vi.fn(); - const onRequestGamePublish = vi.fn(); + const onRequestExport = vi.fn(); render( , ); - return { onOpenSettings, onRequestGamePublish }; + return { onOpenSettings, onRequestExport }; } describe('DirectProjectChatHeader', () => { - it('传入发布回调时渲染发布入口并可触发', () => { - const { onRequestGamePublish } = renderHeader(); + it('传入导出回调时渲染导出入口并可触发', () => { + const { onRequestExport } = renderHeader(); - const publish = screen.getByRole('button', { name: '发布到游戏广场' }); - expect(publish).toHaveProperty('disabled', false); - fireEvent.click(publish); - expect(onRequestGamePublish).toHaveBeenCalledTimes(1); + const exportButton = screen.getByRole('button', { name: '导出产物' }); + fireEvent.click(exportButton); + expect(onRequestExport).toHaveBeenCalledTimes(1); }); - it('没有发布回调时不渲染发布入口', () => { - renderHeader({ onRequestGamePublish: undefined }); + it('没有导出回调时不渲染导出入口', () => { + renderHeader({ onRequestExport: undefined }); - expect(screen.queryByRole('button', { name: '发布到游戏广场' })).toBeNull(); + expect(screen.queryByRole('button', { name: '导出产物' })).toBeNull(); expect(screen.getByRole('button', { name: '设置' })).not.toBeNull(); }); - it('回合进行中禁用发布入口,避免与运行时抢占', () => { - const { onRequestGamePublish } = renderHeader({ busy: true }); + it('不再渲染发布入口', () => { + renderHeader(); - const publish = screen.getByRole('button', { name: '发布到游戏广场' }); - expect(publish).toHaveProperty('disabled', true); - fireEvent.click(publish); - expect(onRequestGamePublish).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '发布到游戏广场' })).toBeNull(); }); }); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 8bae3c725..58ad289d2 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -123,11 +123,21 @@ function renderPublishProject( ); } -async function clickPublish() { +/** + * 发布入口在导出产物面板的陶泥儿 tab:先开导出面板,再进陶泥儿,等灰度读到后才可点。 + */ +async function openTaonierPublishEntry() { + fireEvent.click(await screen.findByRole('button', { name: '导出产物' })); + fireEvent.click(await screen.findByRole('button', { name: '陶泥儿' })); const publish = await screen.findByRole('button', { name: '发布到游戏广场', }); - fireEvent.click(publish); + await waitFor(() => expect(publish).toHaveProperty('disabled', false)); + return publish; +} + +async function clickPublish() { + fireEvent.click(await openTaonierPublishEntry()); return screen.findByLabelText('陶泥儿项目对话'); } @@ -201,9 +211,7 @@ describe('客户端发布入口的可见反馈', () => { installTauri({ exportPackage }); renderPublishProject(); - fireEvent.click( - await screen.findByRole('button', { name: '发布到游戏广场' }), - ); + fireEvent.click(await openTaonierPublishEntry()); const progressDialog = await screen.findByRole('dialog', { name: '发布进度', @@ -237,9 +245,7 @@ describe('客户端发布入口的可见反馈', () => { }); renderPublishProject(); - fireEvent.click( - await screen.findByRole('button', { name: '发布到游戏广场' }), - ); + fireEvent.click(await openTaonierPublishEntry()); const failureDialog = await screen.findByRole('dialog', { name: '发布失败', @@ -283,9 +289,7 @@ describe('客户端发布入口的可见反馈', () => { installTauri({ exportPackage, policy: createConfirmPolicy() }); renderPublishProject(); - fireEvent.click( - await screen.findByRole('button', { name: '发布到游戏广场' }), - ); + fireEvent.click(await openTaonierPublishEntry()); await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); expect(screen.queryByText('project.export_package')).toBeNull(); diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index f6263e3aa..640cb8c0c 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -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 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 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 的「发布到游戏广场」入口据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 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。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。