diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 56ceb0320..27edb95c7 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -176,9 +176,6 @@ const allowedUncalledTauriCommands = [ 'sync_local_project_snapshot', 'reset_design_agent_session', 'stop_local_game_preview_if_matches', - // 「从平台作品开始创作」本轮只落 native 能力:取件、摘要校验与建项都在 Rust 完成, - // App 前端的改编入口(作品详情页「改造这个作品」/ 首页入口)尚未接线,先按 native-only 登记。 - 'create_local_project_from_platform_game', 'start_game_creator_external_mcp', 'stop_game_creator_external_mcp', 'list_agc_plugins', diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index f81bbadb0..14c7d9b47 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -514,6 +514,10 @@ const FORK_SOURCE_PACKAGE_MAX_BYTES: u64 = 512 * 1024 * 1024; /// 状态码本身就是服务端合同(`fork_source_target`):行不存在 → 404;不可用作来源 /// (未公开 / 已软删除 / 没有当前公开版本 / 缺版本元数据)→ 409;授权禁止或未知 → 403; /// 未登录 → 401。因此这里按状态映射,不依赖 `error.code`。 +/// +/// 四个分类各自带稳定前缀(`authentication-required:` / `permission-denied:` / +/// `fork-source-not-found:` / `fork-source-not-available:`):渲染层按前缀区分「需要登录 / +/// 未开放授权 / 作品不存在 / 作品不可用」并按需要去掉前缀展示,不做子串猜测。 fn map_fork_source_http_error(status: StatusCode, body: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); let (code, message) = parse_error_payload(body); @@ -524,9 +528,10 @@ fn map_fork_source_http_error(status: StatusCode, body: &str) -> String { StatusCode::FORBIDDEN => { "permission-denied: 作者没有开放这个作品的共创授权,无法改编".to_string() } - StatusCode::NOT_FOUND => "该作品不存在或已被删除,无法改编".to_string(), + StatusCode::NOT_FOUND => "fork-source-not-found: 该作品不存在或已被删除,无法改编".to_string(), StatusCode::CONFLICT => { - "该作品当前不能作为改编来源(未公开、已下架或没有公开版本)".to_string() + "fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)" + .to_string() } _ => format!( "读取改编来源失败:{}", @@ -1894,14 +1899,14 @@ mod tests { StatusCode::NOT_FOUND, r#"{"error":{"code":"FORK_SOURCE_NOT_FOUND"}}"# ), - "该作品不存在或已被删除,无法改编" + "fork-source-not-found: 该作品不存在或已被删除,无法改编" ); assert_eq!( map_fork_source_http_error( StatusCode::CONFLICT, r#"{"error":{"code":"FORK_SOURCE_NOT_AVAILABLE"}}"# ), - "该作品当前不能作为改编来源(未公开、已下架或没有公开版本)" + "fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)" ); // 其它失败保留原始 detail,便于排障,但仍是可读文案而不是裸 HTTP 码。 assert_eq!( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index c6b82189b..6e5ed55b8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -807,6 +807,19 @@ export function WorkspaceLauncherShell({ } templateLibraryError={templateLibrary.error} onTemplateLibraryOpen={() => setLauncherView('template-library')} + // 平台作品改编建出的项目与模板项目一样「原生已落盘」,复用同一条进项目通道; + // 项目来源事实(改编自哪个作品)由命令写入 .agent/fork-source.json,首页负责展示。 + onPlatformGameProjectCreated={async ( + result, + isCurrent, + analytics, + ) => { + await homeProject.enterCreatedTemplateProject( + result, + isCurrent, + analytics, + ); + }} /> ) : launcherView === 'projects' ? ( ` 的首个非 detail 段。 + const candidate = segments + .slice(gamesIndex + 1) + .find((segment) => segment !== 'detail'); + return candidate && GAME_ID_PATTERN.test(candidate) ? candidate : null; +} + +export type PlatformForkFailureKind = + /** 未登录 / 登录态过期:可操作提示是「重新登录」。 */ + | 'auth' + /** 403:作者没有开放共创授权。 */ + | 'forbidden' + /** 404:作品不存在或已被删除。 */ + | 'notFound' + /** 409:作品未公开 / 已下架 / 没有公开版本。 */ + | 'unavailable' + /** 其它失败(网络、超时、未预期状态):原样展示原生文案。 */ + | 'other'; + +export type PlatformForkFailure = { + kind: PlatformForkFailureKind; + /** 去掉机器前缀后的用户可见文案。 */ + message: string; +}; + +const FAILURE_PREFIXES: readonly [string, PlatformForkFailureKind][] = [ + ['authentication-required:', 'auth'], + ['permission-denied:', 'forbidden'], + ['fork-source-not-found:', 'notFound'], + ['fork-source-not-available:', 'unavailable'], +]; + +/** + * 把原生取件失败折成「可区分 + 可直接展示」的两段式结果。 + * + * 前缀是原生错误契约的一部分(`map_fork_source_http_error`),因此这里按前缀精确匹配, + * 绝不用文案子串猜测错误类型;未命中前缀的一律按 `other` 原样展示,不吞掉排障信息。 + */ +export function classifyPlatformForkFailure(error: unknown): PlatformForkFailure { + const raw = error instanceof Error ? error.message : String(error); + const text = raw.trim(); + for (const [prefix, kind] of FAILURE_PREFIXES) { + if (text.startsWith(prefix)) { + const message = text.slice(prefix.length).trim(); + return { kind, message: message || raw.trim() }; + } + } + return { kind: 'other', message: text }; +} + +/** 输入非法(解析不出作品 ID)时的统一提示:本地拦截,不发请求。 */ +export const PLATFORM_FORK_INPUT_ERROR = + '请输入平台作品 ID 或作品链接(例如 game_ab12… 或 https://…/games/detail?id=game_ab12…)'; diff --git a/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx b/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx new file mode 100644 index 000000000..8dfa54bf1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx @@ -0,0 +1,179 @@ +/** + * 首页「从平台作品开始创作」入口:输入平台作品 ID 或作品链接,取件并新建本地项目。 + * + * 产品口径(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §2.5 / §3.5.1 一致): + * 平台下发的是**已构建的发行成品包**,只能当「可玩参考 + 素材来源」,**不是可编辑的工程**, + * 也不能直接再次发布;参考产物铺在新项目的 `reference/<作品ID>/` 下,用户要在新建的合规工程 + * (Phaser 4 + Vite 脚手架)里自己改造后发布。这里绝不写成「一键复刻工程」。 + * + * 取件、摘要校验、解压与来源记录全在原生命令里(`create_local_project_from_platform_game`), + * 渲染层只做输入解析、加载态与失败分类。 + */ + +import { useRef, useState } from 'react'; +import type { FormEvent } from 'react'; + +import type { InitLocalProjectResult } from '../../app/types'; +import { resolveTauriInvoke } from '../../app/tauri'; +import { + classifyPlatformForkFailure, + parsePlatformGameId, + PLATFORM_FORK_INPUT_ERROR, +} from '../../features/platform-fork/platformGameForkModel'; +import { + beginProjectOpenAnalytics, + type ProjectOpenAnalytics, +} from '../../services/clientAnalytics'; + +type PlatformGameForkSectionProps = { + /** + * 建项成功后的进项目通道:与模板库共用同一条「原生已建好 → 登记最近项目并进入工作区」路径, + * 因此这里只透传结果、代次判据与项目打开埋点,不自己碰工作区状态。 + */ + onProjectCreated: ( + result: InitLocalProjectResult, + isCurrent: () => boolean, + analytics: ProjectOpenAnalytics, + ) => Promise | void; +}; + +type PlatformGameForkSuccess = { + gameId: string; + projectPath: string; + projectName: string; +}; + +export default function PlatformGameForkSection({ + onProjectCreated, +}: PlatformGameForkSectionProps) { + const [input, setInput] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(null); + /** 只认最后一次提交:迟到的结果不再改状态,也不进入项目。 */ + const requestSequenceRef = useRef(0); + /** 同一 tick 内的重复提交靠 ref 拦截:`busy` 状态要等下一次渲染才生效。 */ + const busyRef = useRef(false); + + async function submitForkRequest(event: FormEvent) { + event.preventDefault(); + if (busyRef.current) { + return; + } + const gameId = parsePlatformGameId(input); + if (!gameId) { + setSuccess(null); + setError(PLATFORM_FORK_INPUT_ERROR); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setSuccess(null); + setError('需要在陶泥儿客户端内运行'); + return; + } + const sequence = requestSequenceRef.current + 1; + requestSequenceRef.current = sequence; + const isCurrent = () => requestSequenceRef.current === sequence; + busyRef.current = true; + setBusy(true); + setError(''); + setSuccess(null); + const analytics = beginProjectOpenAnalytics(invoke, 'create'); + try { + const result = await invoke( + 'create_local_project_from_platform_game', + { gameId, name: null, planning: false }, + ); + if (!isCurrent()) { + return; + } + setSuccess({ + gameId, + projectPath: result.projectPath, + projectName: result.manifest.name || gameId, + }); + await onProjectCreated(result, isCurrent, analytics); + } catch (nextError) { + if (!isCurrent()) { + return; + } + setError(classifyPlatformForkFailure(nextError).message); + } finally { + if (isCurrent()) { + busyRef.current = false; + setBusy(false); + } + } + } + + return ( +
+
+

+ 从平台作品开始创作 +

+ + 参考改编 + +
+ {/* + 口径必须写清楚:拿到的是成品包(可玩参考 + 素材),不是可编辑工程,也不能直接再发布。 + 否则用户会以为「一键复刻」之后可以直接上架。 + */} +

+ 输入平台上已开放共创的作品 ID 或作品链接。我们会取回该作品的 + 已构建成品 + 作为可玩参考与素材,放进新项目(合规的 Phaser 4 + Vite 工程)的{' '} + reference/<作品ID>/{' '} + 目录。成品包里没有源码,不能直接再次发布,请在新工程里改造后发布。 +

+
+ setInput(event.target.value)} + /> + +
+ {busy ? ( +

+ 正在取回作品成品、校验摘要并创建项目;发行包较大时下载与解压需要几分钟,请不要关闭客户端。 +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {success ? ( +

+ 已创建项目《{success.projectName}》,改编自平台作品 {success.gameId} + (来源已记录在项目的 .agent/fork-source.json)。参考产物在 reference/ + {success.gameId}/ 下,请在新工程里改造后发布。 +

+ ) : null} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 64307288d..e41681053 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -12,10 +12,15 @@ import type { FormEvent } from 'react'; import { useRef, useState } from 'react'; import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; -import type { ProjectStartMode } from '../../app/types'; +import type { + InitLocalProjectResult, + ProjectStartMode, +} from '../../app/types'; import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure'; import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect'; import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; +import type { ProjectOpenAnalytics } from '../../services/clientAnalytics'; +import PlatformGameForkSection from './PlatformGameForkSection'; import RichInputArea, { UploadButton } from './components/RichInputArea'; import { richTextToAttachments, @@ -124,6 +129,15 @@ type HomeViewProps = { templateLibraryLoading: boolean; templateLibraryError: string; onTemplateLibraryOpen: () => void; + /** + * 「从平台作品开始创作」建成项目后的进项目通道:与模板库共用同一条原生已建好 → 登记最近项目 + * 并进入工作区的路径,首页只透传结果与项目打开埋点。 + */ + onPlatformGameProjectCreated: ( + result: InitLocalProjectResult, + isCurrent: () => boolean, + analytics: ProjectOpenAnalytics, + ) => Promise | void; }; export default function HomeView({ @@ -141,6 +155,7 @@ export default function HomeView({ templateLibraryLoading, templateLibraryError, onTemplateLibraryOpen, + onPlatformGameProjectCreated, }: HomeViewProps) { const homeCreationType = useLauncherHomeDraftStore( (state) => state.creationType, @@ -455,6 +470,12 @@ export default function HomeView({ /> ) : null} + + {/* + 「从平台作品开始创作」放在模板库之后:两者都是「从现成内容起步」,但这条走平台作品的 + 已授权成品包(可玩参考 + 素材),不是可编辑工程,因此与模板库分开成节。 + */} + ); } diff --git a/apps/ai-game-creator-shell/tests/platformGameFork.test.tsx b/apps/ai-game-creator-shell/tests/platformGameFork.test.tsx new file mode 100644 index 000000000..a2149131c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/platformGameFork.test.tsx @@ -0,0 +1,266 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { InitLocalProjectResult } from '../src/app/types'; +import { + classifyPlatformForkFailure, + parsePlatformGameId, +} from '../src/features/platform-fork/platformGameForkModel'; +import PlatformGameForkSection from '../src/view/home/PlatformGameForkSection'; + +const FORK_COMMAND = 'create_local_project_from_platform_game'; + +const PROJECT_RESULT = { + projectPath: 'C:\\projects\\gameagent-fork-1', + manifestPath: 'C:\\projects\\gameagent-fork-1\\.agent\\manifest.json', + manifest: { name: '改编项目 1' }, +} as unknown as InitLocalProjectResult; + +type InvokeCall = { command: string; args?: Record }; + +function installInvoke( + implementation: (command: string, args?: Record) => unknown, +) { + const calls: InvokeCall[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + calls.push({ command, args }); + return implementation(command, args); + }, + ); + (window as unknown as { __TAURI__?: unknown }).__TAURI__ = { core: { invoke } }; + return calls; +} + +function renderSection() { + const onProjectCreated = vi.fn(); + render(); + return { onProjectCreated }; +} + +function typeGameInput(value: string) { + fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), { + target: { value }, + }); +} + +/** 直接提交表单:空输入时按钮是 disabled,只有提交表单才能覆盖「本地拦截」这条路径。 */ +function submitForm() { + const form = screen + .getByLabelText('平台作品 ID 或链接') + .closest('form') as HTMLFormElement; + fireEvent.submit(form); +} + +function forkCalls(calls: InvokeCall[]) { + return calls.filter((call) => call.command === FORK_COMMAND); +} + +afterEach(() => { + cleanup(); + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; +}); + +describe('游戏共创入口输入解析', () => { + it('接受裸作品 ID 与常见作品链接形态', () => { + expect(parsePlatformGameId(' game_ab12cd ')).toBe('game_ab12cd'); + expect(parsePlatformGameId('https://platform.test/games/detail?id=game_1')).toBe( + 'game_1', + ); + expect(parsePlatformGameId('https://platform.test/games/detail?gameId=game_2')).toBe( + 'game_2', + ); + expect(parsePlatformGameId('platform.test/games/game_3?from=share')).toBe( + 'game_3', + ); + expect(parsePlatformGameId('https://platform.test/games/game_3/play')).toBe( + 'game_3', + ); + }); + + it('解析不出作品 ID 时返回 null(本地拦截,不发请求)', () => { + for (const input of [ + '', + ' ', + 'game/1', + 'game 1', + 'https://platform.test/other/path', + 'https://platform.test/games/detail', + 'https://platform.test/games', + ]) { + expect(parsePlatformGameId(input), input).toBeNull(); + } + }); + + it('按原生前缀分类失败,并去掉机器前缀后展示', () => { + expect( + classifyPlatformForkFailure( + new Error('authentication-required: 陶泥儿登录态已过期,请重新登录后重试'), + ), + ).toEqual({ + kind: 'auth', + message: '陶泥儿登录态已过期,请重新登录后重试', + }); + expect( + classifyPlatformForkFailure( + 'permission-denied: 作者没有开放这个作品的共创授权,无法改编', + ), + ).toEqual({ + kind: 'forbidden', + message: '作者没有开放这个作品的共创授权,无法改编', + }); + expect( + classifyPlatformForkFailure( + 'fork-source-not-found: 该作品不存在或已被删除,无法改编', + ), + ).toEqual({ kind: 'notFound', message: '该作品不存在或已被删除,无法改编' }); + expect( + classifyPlatformForkFailure('fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)'), + ).toEqual({ + kind: 'unavailable', + message: '该作品当前不能作为改编来源(未公开、已下架或没有公开版本)', + }); + // 未命中前缀:原样展示,不吞掉排障信息。 + expect(classifyPlatformForkFailure(new Error('网络不可用'))).toEqual({ + kind: 'other', + message: '网络不可用', + }); + }); +}); + +describe('PlatformGameForkSection', () => { + it('空或非法输入不发请求,并给出可操作提示', () => { + const calls = installInvoke(() => PROJECT_RESULT); + renderSection(); + + submitForm(); + expect(forkCalls(calls)).toHaveLength(0); + expect(screen.getByRole('alert').textContent).toContain( + '请输入平台作品 ID 或作品链接', + ); + + typeGameInput(' '); + submitForm(); + expect(forkCalls(calls)).toHaveLength(0); + + typeGameInput('https://platform.test/other/path'); + submitForm(); + expect(forkCalls(calls)).toHaveLength(0); + expect(screen.getByRole('alert').textContent).toContain( + '请输入平台作品 ID 或作品链接', + ); + }); + + it('403 未开放授权:展示服务端文案、不建项目且可重试', async () => { + installInvoke(() => { + throw new Error('permission-denied: 作者没有开放这个作品的共创授权,无法改编'); + }); + const { onProjectCreated } = renderSection(); + + typeGameInput('game_locked'); + submitForm(); + + expect((await screen.findByRole('alert')).textContent).toBe( + '作者没有开放这个作品的共创授权,无法改编', + ); + expect(onProjectCreated).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', { name: '取回并创建项目' }), + ).toHaveProperty('disabled', false); + }); + + it('409 作品不可用与 401 未登录:各自给出对应提示且不建项目', async () => { + let failure = new Error( + 'fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)', + ); + installInvoke(() => { + throw failure; + }); + const { onProjectCreated } = renderSection(); + + typeGameInput('game_private'); + submitForm(); + expect((await screen.findByRole('alert')).textContent).toContain( + '该作品当前不能作为改编来源', + ); + + failure = new Error( + 'authentication-required: 陶泥儿登录态已过期,请重新登录后重试', + ); + typeGameInput('game_private'); + submitForm(); + expect((await screen.findByRole('alert')).textContent).toBe( + '陶泥儿登录态已过期,请重新登录后重试', + ); + expect(onProjectCreated).not.toHaveBeenCalled(); + }); + + it('取件期间展示加载态、禁止重复提交,完成后回到可提交状态', async () => { + // `Promise.withResolvers` 需要 lib ES2024,本仓 AGC 走 ES2022(同 + // `gameDistributionPublishPanel.test.tsx` 的既有写法),因此手写等价 deferred。 + let resolveFork: (value: unknown) => void = () => undefined; + const calls = installInvoke((command) => + command === FORK_COMMAND + ? new Promise((resolve) => { + resolveFork = resolve as (value: unknown) => void; + }) + : null, + ); + renderSection(); + + typeGameInput('game_parent_1'); + submitForm(); + + expect(await screen.findByText(/正在取回作品成品/u)).not.toBeNull(); + expect(screen.getByRole('button', { name: '正在取回…' })).toHaveProperty( + 'disabled', + true, + ); + // 同一 tick 内重复提交:ref 守卫必须挡住第二次请求。 + submitForm(); + expect(forkCalls(calls)).toHaveLength(1); + + resolveFork(PROJECT_RESULT); + await waitFor(() => + expect(screen.queryByText(/正在取回作品成品/u)).toBeNull(), + ); + }); + + it('成功:用解析出的作品 ID 调命令、进入新项目并展示「改编自」事实', async () => { + const calls = installInvoke((command) => + command === FORK_COMMAND ? PROJECT_RESULT : null, + ); + const { onProjectCreated } = renderSection(); + + typeGameInput('https://platform.test/games/detail?id=game_parent_1'); + submitForm(); + + await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1)); + expect(forkCalls(calls)).toEqual([ + { + command: FORK_COMMAND, + args: { gameId: 'game_parent_1', name: null, planning: false }, + }, + ]); + const [result, isCurrent, analytics] = onProjectCreated.mock.calls[0] ?? []; + expect(result).toBe(PROJECT_RESULT); + expect(typeof isCurrent).toBe('function'); + expect(analytics).not.toBeNull(); + + const statusText = screen + .getAllByRole('status') + .map((node) => node.textContent ?? '') + .join('\n'); + expect(statusText).toContain('改编自平台作品 game_parent_1'); + expect(statusText).toContain('reference/game_parent_1/'); + // 产品口径:只到「可玩参考 + 素材」,不是可编辑工程。 + expect(statusText).toContain('请在新工程里改造后发布'); + }); +});