From e3bae05351229d5509b0ea9cbc220dfba625a773 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 02:39:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=87=AA=E5=8A=A8=E5=B8=A6=E6=94=B9=E7=BC=96?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=EF=BC=8C=E4=B8=8A=E6=9E=B6=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E5=85=B1=E5=88=9B=E6=8E=88=E6=9D=83=E6=A1=A3=E4=BD=8D=EF=BC=88?= =?UTF-8?q?AGC=20=E9=9D=A2=E6=9D=BF=E4=B8=89=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改编声明:`publish_local_project_game` 在**首次发布**(不带 gameId)时读取项目内 `.agent/fork-source.json`,把 `fork: { parentGameId, parentVersionId }` 并入创建作品请求; 带 gameId 的更新发布不重复带声明(服务端对「复用既有作品身份时再声明来源」失败关闭, 重复带会变成不可恢复的 409)。判据与注入点都在原生链路,渲染层既传不进也伪造不了; 声明在 `metadata_digest` 之前并入,幂等根键随之变化属预期行为。 - 授权档位:`GameDistributionPublishMetadata` / `normalizeMetadata` / `toCreateGameRequest` 接通 `forkAuthorization`(缺省 `forbidden`),发布面板新增「授权共创」三态单选, 文案复用 `packages/shared/src/contracts/gameDistribution.ts` 的标签常量;更新发布时该栏只读 并提示「共创授权只在首次发布时生效」——档位只在创建作品时生效,上架后只能单向提升。 附带按 `styles.css` 既有面板变量补齐这组 radio 的样式(不能套用整行输入框那条规则)。 - 门禁登记:新命令本轮只落 native 能力,按 `scripts/check-config.mjs` 的 native-only 清单登记。 - 测试:Rust 钉住「资料摘要同内容稳定、并入声明即变」;TS 钉住请求体形状 (带/不带档位、声明不由渲染层提交)与面板三态受控行为、更新模式只读。 --- .../scripts/check-config.mjs | 3 + .../src/game_distribution_publish.rs | 36 ++++++++- .../GameDistributionPublishPanel.tsx | 61 ++++++++++++++- .../src/services/gameDistributionPublish.ts | 28 +++++-- apps/ai-game-creator-shell/src/styles.css | 55 +++++++++++++ .../tests/gameDistributionPublish.test.ts | 33 ++++++++ .../gameDistributionPublishPanel.test.tsx | 77 +++++++++++++++++++ 7 files changed, 283 insertions(+), 10 deletions(-) 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 + ? '共创授权只在首次发布时生效;上架后只能在网页端「我的作品」单向提升。' + : '开启后可被他人复刻改编(仅能拿到已构建成品,用于试玩与参考);上架后只能单向提升,不能撤销。'} +

+