Merge branch 'master' into fix/agc-recent-project-status-retry
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 4m35s
Project CI / Backend tests (pull_request) Successful in 3m56s
Project CI / Frontend tests (pull_request) Successful in 1m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m40s
Project CI / Native shell tests (pull_request) Successful in 6m17s
Project CI / Repository checks (pull_request) Successful in 2m28s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m57s

This commit is contained in:
2026-09-23 15:22:32 +08:00
8 changed files with 499 additions and 221 deletions
@@ -27,15 +27,7 @@ pub(crate) fn export_local_project_package_at(
) -> Result<LocalProjectExportPackageResult, String> {
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::<u64>();
@@ -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<PathBuf, String> {
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<Vec<(String, PathBuf, u64)>, String> {
@@ -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();
}
+76 -86
View File
@@ -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<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
const [publishProgress, setPublishProgress] =
useState<GamePublishProgressState | null>(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<ProjectPermissionPolicyView>(
'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<LocalProjectExportPackageResult>(
'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<LocalProjectExportPackageResult>(
'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<ProjectPermissionPolicyView>(
'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)}
/>
<GamePublishProgressDialog
progress={publishProgress}
onClose={() => setPublishProgress(null)}
/>
</>
);
}
@@ -2502,6 +2488,10 @@ export function App({
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
<GamePublishProgressDialog
progress={publishProgress}
onClose={() => setPublishProgress(null)}
/>
</>
);
}
@@ -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 (
<ThemedModal
open={progress !== null}
ariaLabel={failed ? '发布失败' : '发布进度'}
onClose={onClose}
closeOnBackdrop={false}
closeOnEscape={failed}
overlayClassName="game-publish-progress-overlay"
panelClassName="game-publish-progress-dialog"
panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
>
<div
className={`game-publish-progress-icon${failed ? ' is-failed' : ''}`}
aria-hidden="true"
>
{failed ? (
<CircleAlert size={30} />
) : (
<LoaderCircle className="is-spinning" size={30} />
)}
</div>
<h2>{failed ? '发布失败' : '正在发布'}</h2>
<p role={failed ? 'alert' : 'status'}>
{failed ? progress.detail : progress?.message}
</p>
{failed ? (
<button type="button" onClick={onClose}>
</button>
) : (
<small></small>
)}
</ThemedModal>
);
}
+82
View File
@@ -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);
@@ -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<typeof emptyProjectPolicy>;
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(
<App
initialProjectManifest={createFixtureManifest()}
initialProjectPath={PROJECT_PATH}
/>,
<App initialProjectManifest={manifest} initialProjectPath={PROJECT_PATH} />,
);
}
@@ -125,76 +155,115 @@ function createConfirmPolicy() {
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<ReturnType<typeof createExportPackageResult>>();
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();
});
});
@@ -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 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。
@@ -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`