diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 27edb95c7..56ceb0320 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -176,6 +176,9 @@ 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 c0363f1c5..f81bbadb0 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 @@ -27,9 +27,9 @@ use shared_contracts::game_creation_app::{ }; use shared_contracts::game_distribution::{ GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, - GameDistributionDeviceSupport, GameDistributionForkSource, GameDistributionInputMode, - GameDistributionOrientation, GameDistributionPublishMetadataSuggestion, - GameDistributionPublishMetadataSuggestionRequest, + GameDistributionDeviceSupport, GameDistributionForkDeclaration, GameDistributionForkSource, + GameDistributionInputMode, GameDistributionOrientation, + GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, }; use std::path::Path; use std::time::Duration; @@ -1486,6 +1486,20 @@ pub(crate) async fn publish_local_project_game( if target_game_id.is_some() && expected_publication_revision.is_none() { return Err("缺少公开修订号,请重新打开发布面板后再试".to_string()); } + // 改编声明只来自项目内真实取过件的来源记录(`.agent/fork-source.json`,由「从平台作品开始 + // 创作」写入),渲染层传不进也伪造不了。声明只在**首次发布**并入:服务端对「复用既有作品 + // 身份时再声明来源」失败关闭(`resolve_game_distribution_fork_declaration_tx`),更新发布 + // (带 gameId)重复带声明会变成不可恢复的 409。判据就是目标 gameId 是否属于更新发布。 + let mut metadata = metadata; + if target_game_id.is_none() { + if let Some(source) = crate::project::read_project_fork_source(Path::new(project_path.trim())) + { + metadata.fork = Some(GameDistributionForkDeclaration { + parent_game_id: source.game_id, + parent_version_id: source.version_id, + }); + } + } let digest = metadata_digest(&metadata)?; // 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析: // 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上; @@ -1844,6 +1858,22 @@ mod tests { assert_eq!(metadata_digest(&metadata).expect("stable"), first); metadata.title = "新标题".to_string(); assert_ne!(metadata_digest(&metadata).expect("changed"), first); + + // 改编声明也参与幂等摘要:同内容稳定,并入声明即变(这是预期行为,不是漂移)。 + metadata.title = "游戏".to_string(); + assert_eq!(metadata_digest(&metadata).expect("restored"), first); + metadata.fork = Some( + shared_contracts::game_distribution::GameDistributionForkDeclaration { + parent_game_id: "game_parent".to_string(), + parent_version_id: "gamever_parent".to_string(), + }, + ); + let with_fork = metadata_digest(&metadata).expect("digest with fork"); + assert_ne!(with_fork, first); + assert_eq!( + metadata_digest(&metadata).expect("stable with fork"), + with_fork + ); } #[test] diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 51dc44f27..84e3a959f 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -5,7 +5,13 @@ import { type GameCreationAppManifest, resolveGameCreationAppProjectVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution'; +import { + GAME_DISTRIBUTION_CATEGORIES, + GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, + GAME_DISTRIBUTION_FORK_AUTHORIZATIONS, + type GameDistributionForkAuthorization, +} from '../../../../../packages/shared/src/contracts/gameDistribution'; import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectExportPackageResult } from '../../app/types'; @@ -237,6 +243,16 @@ export function GameDistributionPublishPanel({ const [summary, setSummary] = useState(''); const [category, setCategory] = useState('其他'); + /** + * 上架时的共创授权档位(三态)。默认「禁止共创」,与库表默认和旧客户端行为一致。 + * + * 只在**首次发布**随创建作品请求生效:作品创建后档位只能单向提升(网页端「我的作品」), + * 因此更新发布时这一栏只读,避免让作者以为改这里能改线上档位。 + */ + const [forkAuthorization, setForkAuthorization] = + useState( + GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, + ); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState( @@ -736,6 +752,9 @@ export function GameDistributionPublishPanel({ .map((item) => item.assetObjectId), // 更新发布沿用线上冻结的设备/输入/方向声明;首次发布没有这些值,服务端默认。 ...(restoredDeclarationsRef.current ?? {}), + // 三态共创授权:只在首次发布生效;更新发布时控件只读,这里照原值提交, + // 保证同一次资料对应的幂等摘要稳定。 + forkAuthorization, }, gameId: publicationState === 'update' @@ -766,6 +785,8 @@ export function GameDistributionPublishPanel({ const screenshotsUploading = screenshots.some( (item) => item.status === 'uploading', ); + /** 更新发布不新建作品,共创授权档位只在首次发布生效,因此这一栏在更新模式下只读。 */ + const forkAuthorizationLocked = publicationState === 'update'; const coverPriceLabel = coverGenerationPrice === null ? '消耗泥点' : `${coverGenerationPrice} 泥点`; @@ -891,6 +912,44 @@ export function GameDistributionPublishPanel({ ))} + {/* + 授权共创三态单选。与分类一样是「label + 受控控件」单元,但三态必须都能看见, + 因此用 radio 组而不是 select;文案直接复用平台契约里的标签常量,不另造词。 + */} +
+ 授权共创 +
+ {GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.map((value) => ( + + ))} +
+

+ {forkAuthorizationLocked + ? '共创授权只在首次发布时生效;上架后只能在网页端「我的作品」单向提升。' + : '开启后可被他人复刻改编(仅能拿到已构建成品,用于试玩与参考);上架后只能单向提升,不能撤销。'} +

+