diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 214b3f16d..6b66c9420 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -27,15 +27,7 @@ pub(crate) fn export_local_project_package_at( ) -> Result { validate_project_root(root)?; super::verification::validate_project_game_entry(root)?; - ensure_project_export_package_dir(root, "exports")?; - let readme_path = resolve_local_project_path(root, "exports/README.md")?; - if !readme_path.is_file() { - return Err("导出试玩包前需要先生成 exports/README.md".to_string()); - } - let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?; - if !readme_metadata.is_file() { - return Err("导出试玩包前需要先生成 exports/README.md".to_string()); - } + ensure_project_export_readme(root)?; let files = collect_project_export_package_files(root)?; let total_bytes = files.iter().map(|(_, _, size)| *size).sum::(); @@ -140,6 +132,20 @@ pub(crate) fn export_local_project_package_at( /// 给足时间但必须有界),避免发布路径越过校验器允许的区间。 pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300; +fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool { + let has_completed_prototype_task = manifest.tasks.iter().any(|task| { + task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed + }); + let has_running_preview = manifest.preview.as_ref().is_some_and(|preview| { + preview.status == GameCreationAppPreviewStatus::Running + && preview + .url + .as_deref() + .is_some_and(|url| !url.trim().is_empty()) + }); + has_completed_prototype_task || has_running_preview +} + /// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。 /// /// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控 @@ -256,6 +262,12 @@ pub(crate) async fn export_local_project_package_for_publish_at( if validate_project_game_entry(root).is_ok() { return export_local_project_package_at(root); } + let manifest = read_manifest_for_project(root)?; + if !project_has_runnable_prototype(&manifest) { + return Err( + "首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。".to_string(), + ); + } let plan = resolve_publish_build_plan(root)?; // `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装, // 否则作者要点两次(先 bootstrap 再发布)。 @@ -477,6 +489,54 @@ pub(crate) fn list_local_project_export_packages_at( }) } +/// 发布导出前确保 `exports/README.md` 存在。 +/// +/// 缺失时按项目 manifest 生成最小发布说明;已有文件原样保留,避免覆盖作者或 Agent 的正式文案。 +/// README 本身仍沿用试玩包的普通文件 / 符号链接安全口径。 +fn ensure_project_export_readme(root: &Path) -> Result { + let readme_path = resolve_local_project_path(root, "exports/README.md")?; + match fs::symlink_metadata(&readme_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err("试玩包不能包含符号链接:exports/README.md".to_string()); + } + if !metadata.is_file() { + return Err("exports/README.md 必须是普通文件".to_string()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let manifest = read_manifest_for_project(root)?; + let content = render_project_export_readme(&manifest); + write_game_creator_private_file(&readme_path, content.as_bytes(), "试玩包发布说明")?; + } + Err(error) => { + return Err(format!( + "读取 exports/README.md 元数据失败:{}: {error}", + readme_path.display() + )); + } + } + Ok(readme_path) +} + +fn render_project_export_readme(manifest: &GameCreationAppManifest) -> String { + let title = manifest.name.trim(); + let title = if title.is_empty() { + "试玩游戏" + } else { + title + }; + let summary = manifest + .goal + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("这是一个可直接在浏览器中试玩的游戏原型。"); + format!( + "# {title} 试玩说明\n\n## 简介\n\n{summary}\n\n## 试玩方式\n\n- 解压试玩包后打开根目录的 `index.html`。\n- 使用游戏内提示开始试玩。\n\n## 反馈\n\n请记录问题发生步骤、预期结果和实际结果,便于快速定位。\n" + ) +} + pub(crate) fn collect_project_export_package_files( root: &Path, ) -> Result, String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 9b0f1a480..4a1a034fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -4018,6 +4018,12 @@ writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild "#, ) .expect("write build script"); + update_manifest_task_status_at( + &root, + "code-prototype", + GameCreationAppTaskStatus::Completed, + ) + .expect("mark prototype completed"); write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); let result = export_local_project_package_for_publish_at(&root) @@ -4045,13 +4051,21 @@ async fn publish_export_skips_build_when_playable_entry_exists() { .expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); - write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); let result = export_local_project_package_for_publish_at(&root) .await - .expect("已有可玩入口时直接导出"); + .expect("已有可玩入口且缺少 README 时应自动生成并直接导出"); assert!(result.package_relative_path.ends_with(".zip")); + let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme"); + assert!(readme.contains("# 已构建发布项目 试玩说明"), "{readme}"); + let payload = read_local_project_export_package_at(&root, &result.package_relative_path) + .expect("read publishable package"); + assert!(payload.files.iter().any(|file| file.path == "index.html")); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); assert!( !log.contains("project.verify build"), @@ -4061,11 +4075,56 @@ async fn publish_export_skips_build_when_playable_entry_exists() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn publish_export_blocks_build_until_runnable_prototype_is_completed() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-prototype-pending", "原型未完成项目") + .expect("project init"); + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "publish-prototype-pending-fixture", + "private": true, + "scripts": { "build": "node build-should-not-run.mjs" } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + fs::write( + root.join("build-should-not-run.mjs"), + "import { writeFileSync } from 'node:fs'; writeFileSync('build-ran.txt', 'yes');", + ) + .expect("write build marker script"); + + let error = export_local_project_package_for_publish_at(&root) + .await + .expect_err("原型未完成时禁止触发用户项目构建"); + + assert!( + error.contains("首个可运行原型尚未完成"), + "错误应说明原型未完成:{error}" + ); + assert!(!root.join("build-ran.txt").exists()); + let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); + assert!( + !log.contains("project.verify build"), + "原型未完成时不应执行项目构建:{log}" + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn publish_export_reports_actionable_error_without_entry_or_build_script() { let root = unique_project_path(); init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目") .expect("project init"); + update_manifest_task_status_at( + &root, + "code-prototype", + GameCreationAppTaskStatus::Completed, + ) + .expect("mark prototype completed"); // 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。 fs::write( root.join("game/package.json"), @@ -4103,6 +4162,10 @@ fn local_project_export_package_publish_payload_contains_bytes_and_file_digests( write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); let exported = export_local_project_package_at(&root).expect("export package"); + assert_eq!( + fs::read_to_string(root.join("exports/README.md")).unwrap(), + "publish notes" + ); let payload = read_local_project_export_package_at(&root, &exported.package_relative_path) .expect("read publish payload"); @@ -4251,18 +4314,29 @@ fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { } #[test] -fn local_project_export_package_requires_playable_html_and_readme() { +fn local_project_export_package_generates_missing_readme_after_playable_html() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); - let missing_readme = + let missing_entry = export_local_project_package_at(&root).expect_err("default html is not playable"); - assert!(missing_readme.contains("游戏入口必须包含可渲染画布")); + assert!(missing_entry.contains("游戏入口必须包含可渲染画布")); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); - let missing_readme = - export_local_project_package_at(&root).expect_err("readme should be required"); - assert!(missing_readme.contains("exports/README.md")); + fs::remove_dir_all(root.join("exports")).expect("remove exports dir"); + + let exported = + export_local_project_package_at(&root).expect("missing readme should be generated"); + let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme"); + assert!(readme.contains("# 像素动作原型 试玩说明"), "{readme}"); + assert!(readme.contains("解压试玩包后打开根目录"), "{readme}"); + + let payload = read_local_project_export_package_at(&root, &exported.package_relative_path) + .expect("read generated package"); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d3f1a814a..0351188c0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -48,6 +48,10 @@ import type { TauriInvoke, } from './app/types'; import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; +import { + GamePublishProgressDialog, + type GamePublishProgressState, +} from './components/game-distribution/GamePublishProgressDialog'; import { agentConversationId, agentRuntimeStateFromResult, @@ -368,6 +372,8 @@ export function App({ const [publishPackageResult, setPublishPackageResult] = useState(null); const [publishPanelOpen, setPublishPanelOpen] = useState(false); + const [publishProgress, setPublishProgress] = + useState(null); // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); @@ -1126,40 +1132,6 @@ export function App({ } } - async function queueProjectPolicyConfirmationIfNeeded( - invoke: TauriInvoke, - commandId: GameCreationAppCommandDescriptor['id'], - projectPath: string, - detail: string, - readyMessage: string, - onConfirm: () => void, - ) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath }, - ); - if (policyView.policy.deniedCommands.includes(commandId)) { - const message = `项目权限策略拒绝执行:${commandId}`; - markProjectPolicyDenied(commandId, message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - directProjectChatRef.current?.announce(message); - return true; - } - if (!policyView.policy.confirmCommands.includes(commandId)) { - return false; - } - requestProjectPolicyConfirmation(commandId, projectPath, detail, onConfirm); - setMessages((current) => [ - ...current, - { role: 'assistant', text: readyMessage }, - ]); - directProjectChatRef.current?.announce(readyMessage); - return true; - } - async function denyPendingCommandIfNeeded( commandId: GameCreationAppCommandDescriptor['id'], projectPath: string | null, @@ -1206,8 +1178,8 @@ export function App({ /** * 导出试玩包并打开发布面板。 * - * 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出; - * 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。 + * 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区, + * 导出失败在弹窗内回显;成功后才切换到发布资料面板。 */ /** * 发布相关提示同时写工作台状态与 DirectProject 对话。 @@ -1232,52 +1204,42 @@ export function App({ announcePublishMessage('先打开一个项目再发布'); return; } - // 权限查询也可能慢或挂住,先回一条即时反馈,别让按钮看起来没反应。 - announcePublishMessage('正在检查发布权限…'); - const runExport = async () => { - // 导出可能包含构建步骤,再回一条即时反馈。 - announcePublishMessage('正在构建并打包试玩包,请稍候…'); - try { - const result = await invoke( - 'export_local_project_package', - { projectPath: nextProjectPath }, - ); - announcePublishMessage( - `已构建并打包试玩包:${result.packageRelativePath}`, - ); - setPublishPackageResult(result); - setPublishPanelOpen(true); - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'project.export_package', - ); - } catch (error) { - announcePublishMessage( - error instanceof Error ? error.message : String(error), - ); - } - }; - try { - const queued = await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'project.export_package', - nextProjectPath, - '导出试玩包并打开「发布到游戏广场」面板。', - '导出试玩包需要确认,确认后继续。', - () => void runExport(), - ); - if (!queued) { - await runExport(); - } - } catch (error) { - // 权限查询失败也必须回话:onClick 的 Promise 没有 catch 时用户只会看到 - // 「点了没反应」,这里把它收敛成聊天里的可读错误。 + 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) { announcePublishMessage( - `发布前权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, + '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', ); + 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), + }); } } @@ -1723,20 +1685,40 @@ export function App({ return true; } try { - const paused = await queueProjectPolicyConfirmationIfNeeded( - invoke, + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: input.projectPath }, + ); + if (policyView.policy.deniedCommands.includes('conversation.write')) { + const message = '项目权限策略拒绝执行:conversation.write'; + markProjectPolicyDenied('conversation.write', message); + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + directProjectChatRef.current?.announce(message); + return false; + } + if (!policyView.policy.confirmCommands.includes('conversation.write')) { + projectConversationWriteConfirmedRef.current = input.projectPath; + return true; + } + requestProjectPolicyConfirmation( 'conversation.write', input.projectPath, '写入 DirectProject 对话历史', - 'DirectProject 对话写入需要确认。', () => { projectConversationWriteConfirmedRef.current = input.projectPath; input.onConfirmed(); }, ); - if (paused) return false; - projectConversationWriteConfirmedRef.current = input.projectPath; - return true; + const message = 'DirectProject 对话写入需要确认。'; + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + directProjectChatRef.current?.announce(message); + return false; } catch (error) { if (localProjectPathRef.current === input.projectPath) { setProjectChatError( @@ -2351,6 +2333,10 @@ export function App({ packageResult={publishPackageResult} onClose={() => setPublishPanelOpen(false)} /> + setPublishProgress(null)} + /> ); } @@ -2502,6 +2488,10 @@ export function App({ packageResult={publishPackageResult} onClose={() => setPublishPanelOpen(false)} /> + setPublishProgress(null)} + /> ); } diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GamePublishProgressDialog.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GamePublishProgressDialog.tsx new file mode 100644 index 000000000..b784c2b9d --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GamePublishProgressDialog.tsx @@ -0,0 +1,58 @@ +import { CircleAlert, LoaderCircle } from 'lucide-react'; + +import { ThemedModal } from '../modal/ThemedModal'; + +export type GamePublishProgressState = + | { + phase: 'running'; + message: string; + } + | { + phase: 'failed'; + message: string; + detail: string; + }; + +export function GamePublishProgressDialog({ + progress, + onClose, +}: { + progress: GamePublishProgressState | null; + onClose: () => void; +}) { + const failed = progress?.phase === 'failed'; + return ( + + +

{failed ? '发布失败' : '正在发布'}

+

+ {failed ? progress.detail : progress?.message} +

+ {failed ? ( + + ) : ( + 发布完成前请保持客户端开启,页面暂时不可操作。 + )} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 41a36fceb..75c0b2d0e 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -9588,6 +9588,88 @@ iframe.preview-frame { gap: 4px; } +.game-publish-progress-overlay { + position: fixed; + z-index: 500; + inset: 0; + background: rgb(35 24 19 / 62%); + backdrop-filter: blur(3px); + pointer-events: auto; +} + +.game-publish-progress-dialog { + display: grid; + width: min(440px, calc(100vw - 40px)); + outline: none; + justify-items: center; + gap: 12px; + padding: 28px; + border: 1px solid #e4c8ba; + border-radius: 18px; + box-shadow: 0 24px 64px rgb(62 37 27 / 28%); + text-align: center; +} + +.game-publish-progress-dialog h2, +.game-publish-progress-dialog p, +.game-publish-progress-dialog small { + margin: 0; +} + +.game-publish-progress-dialog h2 { + font-size: 18px; +} + +.game-publish-progress-dialog p, +.game-publish-progress-dialog small { + color: #8f7367; + font-size: 12px; +} + +.game-publish-progress-dialog p[role='alert'] { + max-height: 180px; + overflow: auto; + color: #a33f2a; + text-align: left; + white-space: pre-wrap; +} + +.game-publish-progress-icon { + display: grid; + width: 52px; + height: 52px; + border-radius: 16px; + background: #f6dfd0; + color: #c7653d; + place-items: center; +} + +.game-publish-progress-icon.is-failed { + background: #f8d8d0; + color: #b43c25; +} + +.game-publish-progress-icon .is-spinning { + animation: app-update-spin 0.9s linear infinite; +} + +.game-publish-progress-dialog button { + min-width: 96px; + padding: 8px 16px; + border: 0; + border-radius: 8px; + outline: none; + background: #c7653d; + box-shadow: 0 0 0 0 rgb(199 101 61 / 0%); + color: #fff; + cursor: pointer; + font-weight: 700; +} + +.game-publish-progress-dialog button:focus-visible { + box-shadow: 0 0 0 3px rgb(199 101 61 / 28%); +} + .game-approval-backdrop { position: fixed; top: var(--window-chrome-height); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index baa020d5b..a05b8ce65 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -2,9 +2,11 @@ /** * 客户端「发布」入口的可见反馈。 * - * DirectProject 项目不发工作台状态行,发布动作的提示必须回到项目对话; - * 权限要求确认时也要在聊天里给出确认/取消,否则表现就是「点了没反应」。 + * 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片; + * 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。 */ +import { readFileSync } from 'node:fs'; + import { beforeEach, describe, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; @@ -20,6 +22,12 @@ import { waitFor, within, } from './appSurface/harness'; +import { repoPath } from './repoPath'; +import { + declaration, + parseStyleSheet, + resolveDeclarations, +} from './styleCascade'; const readGamePublishAvailabilityMock = vi.hoisted(() => vi.fn(async () => true), @@ -39,18 +47,41 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { const PROJECT_PATH = '/tmp/game-publish-feedback-project'; const PROJECT_ID = 'game-publish-feedback-project'; +function withPrototypeStatus( + manifest: GameCreationAppManifest, + status: 'pending' | 'completed', +): GameCreationAppManifest { + return { + ...manifest, + tasks: manifest.tasks.map((task) => + task.id === 'code-prototype' ? { ...task, status } : task, + ), + }; +} + function createFixtureManifest(): GameCreationAppManifest { - return createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'); + return withPrototypeStatus( + createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'), + 'completed', + ); +} + +function createPendingPrototypeManifest(): GameCreationAppManifest { + return withPrototypeStatus( + createGameCreationAppManifest(PROJECT_ID, '原型未完成项目'), + 'pending', + ); } function installTauri( options: { exportPackage?: () => unknown; + manifest?: GameCreationAppManifest; policy?: ReturnType; readPolicy?: () => unknown; } = {}, ) { - const manifest = createFixtureManifest(); + const manifest = options.manifest ?? createFixtureManifest(); const chatHarness = createProjectChatRuntimeHarness({ projectPath: PROJECT_PATH, }); @@ -82,12 +113,11 @@ function installTauri( return { invoke }; } -function renderPublishProject() { +function renderPublishProject( + manifest: GameCreationAppManifest = createFixtureManifest(), +) { return render( - , + , ); } @@ -125,76 +155,115 @@ function createConfirmPolicy() { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + describe('客户端发布入口的可见反馈', () => { - it('导出成功时先回即时反馈,再回结果并打开发布面板', async () => { - installTauri({ exportPackage: createExportPackageResult }); - renderPublishProject(); + it('首个可运行原型未完成时直接阻止发布,不触发用户项目构建', async () => { + const exportPackage = vi.fn(createExportPackageResult); + const manifest = createPendingPrototypeManifest(); + installTauri({ exportPackage, manifest }); + + renderPublishProject(manifest); const surface = await clickPublish(); await waitFor(() => { - expect(surface.textContent ?? '').toContain('正在检查发布权限…'); expect(surface.textContent ?? '').toContain( - '正在构建并打包试玩包,请稍候…', - ); - expect(surface.textContent ?? '').toContain( - '已构建并打包试玩包:exports/game.zip', + '首个可运行原型尚未完成,暂不能发布', ); }); + expect(surface.textContent ?? '').not.toContain('正在检查发布权限…'); + expect(exportPackage).not.toHaveBeenCalled(); + }); + + it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => { + const deferred = + createDeferred>(); + const exportPackage = vi.fn(() => deferred.promise); + installTauri({ exportPackage }); + renderPublishProject(); + + fireEvent.click( + await screen.findByRole('button', { name: '发布到游戏广场' }), + ); + + const progressDialog = await screen.findByRole('dialog', { + name: '发布进度', + }); + expect(progressDialog.textContent ?? '').toContain( + '正在构建并打包试玩包,请稍候…', + ); + expect(progressDialog.textContent ?? '').toContain( + '发布完成前请保持客户端开启,页面暂时不可操作。', + ); + expect( + progressDialog.closest('.game-publish-progress-overlay'), + ).not.toBeNull(); + expect(exportPackage).toHaveBeenCalledTimes(1); + expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull(); + + deferred.resolve(createExportPackageResult()); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull(); + }); expect( await screen.findByRole('dialog', { name: '发布到游戏广场' }), ).not.toBeNull(); }); - it('导出失败时把可读错误写回项目对话', async () => { + it('发布失败时在进度弹窗里显示错误并允许关闭', async () => { installTauri({ exportPackage: () => { - throw new Error('导出试玩包前需要先生成 exports/README.md'); + throw new Error('构建可玩版本失败:缺少入口'); }, }); renderPublishProject(); - const surface = await clickPublish(); + fireEvent.click( + await screen.findByRole('button', { name: '发布到游戏广场' }), + ); + + const failureDialog = await screen.findByRole('dialog', { + name: '发布失败', + }); + expect(failureDialog.textContent ?? '').toContain( + '构建可玩版本失败:缺少入口', + ); + fireEvent.click( + within(failureDialog).getByRole('button', { name: '关闭' }), + ); await waitFor(() => { - expect(surface.textContent ?? '').toContain( - '导出试玩包前需要先生成 exports/README.md', - ); + expect(screen.queryByRole('dialog', { name: '发布失败' })).toBeNull(); }); }); - it('策略要求确认时在 DirectProject 里展示确认卡片,确认后继续导出', async () => { - const exportPackage = vi.fn(createExportPackageResult); - installTauri({ exportPackage, policy: createConfirmPolicy() }); - renderPublishProject(); - - const publish = await screen.findByRole('button', { - name: '发布到游戏广场', - }); - fireEvent.click(publish); - - const commandLabel = await screen.findByText('project.export_package'); - const card = commandLabel.closest('.pending-command'); - expect(card).not.toBeNull(); - expect(exportPackage).not.toHaveBeenCalled(); - expect( - within(card as HTMLElement).getByText( - '导出试玩包并打开「发布到游戏广场」面板。', + it('发布进度遮罩固定覆盖整个工作区并压暗背景', () => { + const rules = parseStyleSheet( + readFileSync( + repoPath('apps/ai-game-creator-shell/src/styles.css'), + 'utf8', ), - ).not.toBeNull(); - - fireEvent.click( - within(card as HTMLElement).getByRole('button', { name: '确认' }), ); - await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); - const surface = await screen.findByLabelText('陶泥儿项目对话'); - expect(surface.textContent ?? '').toContain( - '正在构建并打包试玩包,请稍候…', - ); - expect(surface.textContent ?? '').toContain( - '已构建并打包试玩包:exports/game.zip', + const overlay = resolveDeclarations( + rules, + ['.game-publish-progress-overlay'], + 1440, ); + expect(declaration(overlay, 'position')).toBe('fixed'); + expect(declaration(overlay, 'inset')).toBe('0'); + expect(declaration(overlay, 'z-index')).toBe('500'); + expect(declaration(overlay, 'pointer-events')).toBe('auto'); + expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)'); }); - it('取消权限确认时把取消结果写回项目对话', async () => { + it('策略要求确认时不再回到聊天确认卡片,直接进入进度弹窗', async () => { const exportPackage = vi.fn(createExportPackageResult); installTauri({ exportPackage, policy: createConfirmPolicy() }); renderPublishProject(); @@ -202,71 +271,9 @@ describe('客户端发布入口的可见反馈', () => { fireEvent.click( await screen.findByRole('button', { name: '发布到游戏广场' }), ); - const commandLabel = await screen.findByText('project.export_package'); - const card = commandLabel.closest('.pending-command'); - expect(card).not.toBeNull(); - fireEvent.click( - within(card as HTMLElement).getByRole('button', { name: '取消' }), - ); - const surface = await screen.findByLabelText('陶泥儿项目对话'); - await waitFor(() => { - expect(surface.textContent ?? '').toContain('已取消导出本地试玩包'); - }); - expect(exportPackage).not.toHaveBeenCalled(); - }); - - it('确认时策略已改为拒绝,也要把拒绝原因写回项目对话', async () => { - const exportPackage = vi.fn(createExportPackageResult); - let denyOnNextPolicyRead = false; - installTauri({ - exportPackage, - readPolicy: () => { - if (!denyOnNextPolicyRead) return createConfirmPolicy(); - return { - path: '.agent/policy.json', - policy: { - deniedCommands: ['project.export_package'], - confirmCommands: [], - }, - }; - }, - }); - renderPublishProject(); - - fireEvent.click( - await screen.findByRole('button', { name: '发布到游戏广场' }), - ); - const commandLabel = await screen.findByText('project.export_package'); - const card = commandLabel.closest('.pending-command'); - expect(card).not.toBeNull(); - denyOnNextPolicyRead = true; - fireEvent.click( - within(card as HTMLElement).getByRole('button', { name: '确认' }), - ); - - const surface = await screen.findByLabelText('陶泥儿项目对话'); - await waitFor(() => { - expect(surface.textContent ?? '').toContain( - '项目权限策略拒绝执行:project.export_package', - ); - }); - expect(exportPackage).not.toHaveBeenCalled(); - }); - - it('权限查询失败时也在项目对话里回显错误', async () => { - installTauri({ - readPolicy: () => { - throw new Error('策略文件损坏'); - }, - }); - renderPublishProject(); - - const surface = await clickPublish(); - await waitFor(() => { - expect(surface.textContent ?? '').toContain( - '发布前权限检查失败:策略文件损坏', - ); - }); + await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('project.export_package')).toBeNull(); + expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull(); }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e35ac4bff..f1b42991f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-09-23 AGC 发布前先守可运行原型门禁 + +- 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。 +- 决策:`project.export_package` 的发布专用导出链路先检查可玩入口;没有入口时,只有 `code-prototype` 已完成或存在运行中的预览才允许执行 `build`,否则直接返回“首个可运行原型尚未完成,暂不能发布”。发布不再进入聊天确认卡,改为独立全屏进度弹窗;运行中遮罩覆盖整个工作区并阻止交互,失败留在弹窗内,成功后切换到发布资料面板。 +- 边界:已有可运行入口仍直接打包;原型已完成但缺构建产物时保留原有自动构建;缺失 `exports/README.md` 仍在导出前自动生成。 +- 验证:Rust `publish_export` 4/4、前端发布相关测试 16/16、AGC `tsc`、编码检查和 `git diff --check` 通过。 + ## 2026-09-22 Direct 埋点与业务持久化锁隔离 - Direct 采集身份和最新成果编号改由独立纯内存状态保存,初始化时从最终执行账本冻结项目与原 run 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a311db87c..10b7897e9 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1334,7 +1334,7 @@ game-project/ - 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。 - 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。 - 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并切换到客户端内运行视图;该命令不开放任意 shell。 -- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;导出前重新校验 `game/index.html` 是可试玩自包含 HTML,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。 +- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;缺少 `exports/README.md` 时先按项目 manifest 生成最小试玩说明,已有文件原样保留。发布前若 `code-prototype` 未完成且没有运行中的预览,直接阻止发布,不触发用户项目构建;可运行原型完成后才允许按 `build` 脚本补齐产物。发布进度使用独立模态弹窗展示,遮罩覆盖整个工作区并阻止交互,不再使用聊天确认卡;导出前重新校验可玩入口,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。 - 聊天输入 `/exports` 会只读执行 `project.export_list`,列出当前项目 `exports/playtest-package-*.zip` 历史试玩包,并提供显示目录或继续 `/export` 的草稿;该命令不删除文件、不分享文件、不新增面板。 - 聊天输入 `/preview` 会生成待确认的 `preview.start` 内置命令,确认后启动只读本地 HTTP 预览并切换到客户端内运行视图;`/open-preview` 在本地项目已初始化后生成待确认的 `preview.open`,只激活当前授权项目对应的 `127.0.0.1` 运行容器;`/preview-status` 只查询当前授权项目的本地 HTTP 预览并写入 `preview.status` 命令日志;`/preview-stop` 只停止当前项目预览,不展示或停止其它项目遗留的全局预览。 - 聊天输入 `/memory [short|long|blackboard]` 读取短期、长期或黑板记忆;`/remember [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并追加短期、长期或黑板记忆,未写 scope 时默认追加长期记忆;主窗口“记到黑板”“覆盖黑板”“清空黑板”只填入 `/remember blackboard `、`/memory-set blackboard ` 或 `/forget-memory blackboard` 草稿,仍由用户补内容并走聊天确认;`/memory-set [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并覆盖保存对应记忆;`/forget-memory [short|long|blackboard]` 生成待确认的 `memory.delete`。