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 dceef09c2..cc4d8cff0 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 @@ -130,6 +130,13 @@ pub(crate) struct GameDistributionPublicationReadResult { pub(crate) draft: Option, #[serde(default)] pub(crate) visibility: Option, + /// 平台侧这次发布实际会用的版本号(作者视图已有版本的最大 `versionNumber + 1`)。 + /// + /// 首次发布会新建作品,服务端从 `1` 开始派发,所以这里也是 `1`。拿不到平台数据的路径 + /// (`unavailable`、以及「有本地绑定但没能刷新线上状态」)不下发:渲染层不得回落到本地工程 + /// 迭代计数,只能据此禁用提交。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) next_version_number: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) message: Option, } @@ -238,6 +245,13 @@ struct OwnerGameEntry { publication_revision: u64, #[serde(default)] latest_version: Option, + /// 该作者作品的版本列表(作者自有视图按最近更新倒序、单游戏最多 10 条)。 + /// + /// 平台只在客户端不传版本号时按 `max_existing + 1` 自动派发,所以「下一个版本号」必须取这里 + /// 已下发版本的最大 `versionNumber`;`latestVersion` 是最近**更新**的那条(可能是被改过状态的 + /// 历史版本),单靠它或本地工程迭代计数都会推出重复 / 回退的版本号。 + #[serde(default)] + versions: Vec, #[serde(default)] device_support: Option, #[serde(default)] @@ -531,6 +545,8 @@ fn first_publish_state(message: Option) -> GameDistributionPublicationRe refreshed: true, draft: None, visibility: None, + // 首次发布会新建作品,平台自己也从 `1` 派发这一版的版本号。 + next_version_number: Some(1), message, } } @@ -542,15 +558,35 @@ fn unavailable_state(message: Option) -> GameDistributionPublicationRead refreshed: false, draft: None, visibility: None, + // 拿不到平台数据就不给版本号:渲染层据此禁用提交,不得回落到本地工程迭代计数。 + next_version_number: None, message, } } +/// 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1`。 +/// +/// 与平台自身的派发口径一致(`module-game-distribution::resolve_version_number` 的 +/// `max_existing + 1`)。`latestVersion` 只是最近更新的一条,必须与版本列表一起取最大值。 +/// 作者视图单游戏最多下发 10 条版本(`GAME_DISTRIBUTION_MAX_OWNER_VERSIONS_PER_GAME`): +/// 号最大的那条被挤出列表时会低估一个已经存在的版本号,这是当前作者视图的取数边界。 +fn resolve_owner_next_version_number(entry: &OwnerGameEntry) -> u64 { + entry + .versions + .iter() + .chain(entry.latest_version.iter()) + .map(|version| version.version_number) + .max() + .unwrap_or(0) + .saturating_add(1) +} + /// 回写发布绑定;本地遗留的 `projectVersion` 一并清空。 /// -/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不再从绑定推导(它由 -/// AGC 工程内部版本 `versions[]` 派生、只读),所以这里只落绑定,并把存量清单里可能残留的 -/// 遗留标签置空——`skip_serializing_if` 会让该键随下一次落盘消失。 +/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不从绑定推导,也不来自 +/// AGC 工程内部版本:它取平台作者视图里该作品的最大版本号 + 1(发布时显式提交同一个值), +/// 所以这里只落绑定,并把存量清单里可能残留的遗留标签置空——`skip_serializing_if` 会让该键 +/// 随下一次落盘消失。 fn write_publication_binding( root: &Path, binding: GameCreationAppPublicationBinding, @@ -855,6 +891,7 @@ async fn bound_publication_result( refreshed: true, draft: Some(draft), visibility: Some(entry.status.clone()).filter(|status| !status.is_empty()), + next_version_number: Some(resolve_owner_next_version_number(&entry)), message: None, }) } @@ -907,12 +944,14 @@ pub(crate) async fn read_game_distribution_publication( } Err(GameDistributionReadError::Unavailable(message)) => { // 结果未知:保留本地绑定,按更新模式返回但标注未刷新,由发布时的 CAS 兜底。 + // 这一路没有平台版本数据,所以不给版本号:渲染层禁用提交,等下一次刷新成功。 Ok(GameDistributionPublicationReadResult { state: "update".to_string(), binding: Some(binding), refreshed: false, draft: None, visibility: None, + next_version_number: None, message: Some(format!("无法刷新线上状态:{message}")), }) } @@ -1526,7 +1565,8 @@ pub(crate) async fn publish_local_project_game( } }); // 发布成功:把平台作品绑定和最近版本状态写回本地清单。项目版本标签不在这里对齐——它由 - // 工程内部版本派生,这次提交的平台版本号不回写本地(遗留 `projectVersion` 一并清空)。 + // 平台作者视图的最大版本号 + 1 推出、在提交前已经决定了下一次要用的值(遗留 `projectVersion` + // 一并清空)。 // 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布, // 甚至可能重复建作品。 let binding = GameCreationAppPublicationBinding { @@ -1539,7 +1579,7 @@ pub(crate) async fn publish_local_project_game( status: Some(status.clone()), }; let manifest = crate::project::mutate_manifest_at(root, |manifest| { - // 项目版本标签由工程内部版本派生,不由这次提交的平台版本号回写;遗留位一并清空。 + // 项目版本标签由平台作者视图派生并在提交前传下去,不由这次提交的平台版本号回写;遗留位一并清空。 manifest.project_version = None; manifest.publication = Some(binding.clone()); Ok(manifest.clone()) @@ -1699,13 +1739,28 @@ mod tests { "versionNumber": 2, "status": "pending_review", "priceMudPoints": 300 - } + }, + "versions": [ + { + "versionId": "gamever-2", + "versionNumber": 2, + "status": "pending_review", + "priceMudPoints": 300 + }, + { + "versionId": "gamever-5", + "versionNumber": 5, + "status": "published", + "priceMudPoints": 0 + } + ] })) .expect("owner entry"); assert_eq!(entry.id, "game-1"); assert_eq!(entry.local_project_id.as_deref(), Some("proj-1")); assert_eq!(entry.publication_revision, 4); assert_eq!(entry.price_mud_points, 240); + assert_eq!(entry.versions.len(), 2); let latest = entry.latest_version.expect("latest version"); assert_eq!(latest.version_id, "gamever-2"); assert_eq!(latest.version_number, 2); @@ -1713,6 +1768,47 @@ mod tests { assert_eq!(latest.price_mud_points, 300); } + #[test] + fn next_version_number_follows_platform_maximum_not_latest_or_local_count() { + // 作者视图按最近更新倒序:`latestVersion` 是 v2(最近被改过状态),列表里号最大的是 v5。 + // 平台派发口径是 max_existing + 1,所以下一个版本号必须是 6,不能是 2 + 1。 + let entry = OwnerGameEntry { + latest_version: Some(OwnerGameVersion { + version_number: 2, + ..Default::default() + }), + versions: vec![ + OwnerGameVersion { + version_number: 2, + ..Default::default() + }, + OwnerGameVersion { + version_number: 5, + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(resolve_owner_next_version_number(&entry), 6); + + // 没有任何版本记录的作品:平台会从 1 开始派发。 + assert_eq!( + resolve_owner_next_version_number(&OwnerGameEntry::default()), + 1 + ); + + // 列表被条数上限截断、只带回旧版本时也只能按已下发的最大值推出(取数边界见函数注释)。 + let truncated = OwnerGameEntry { + latest_version: None, + versions: vec![OwnerGameVersion { + version_number: 1, + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(resolve_owner_next_version_number(&truncated), 2); + } + #[test] fn publication_prefill_price_prefers_latest_frozen_price_and_falls_back() { // 最新版本冻结价 > 0:与网页更新模式同源,直接用它。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs b/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs index bd7d94085..735072077 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs @@ -36,8 +36,8 @@ struct GamePublishAttemptRecord { /// 目标游戏身份:更新已有作品时非空;首次发布为空。 #[serde(default)] target_game_id: Option, - /// 这次发布提交的项目版本标签(由清单 `versions[]` 的条数派生,不由 `projectVersion` - /// 这种遗留兼容位提供):换了标签就是一次新尝试。 + /// 这次发布提交的平台版本号(作者视图里该作品的最大版本号 + 1,首次发布为 `1`; + /// 不由清单 `versions[]` 的条数或遗留 `projectVersion` 位提供):换了版本号就是一次新尝试。 #[serde(default)] target_version_number: Option, /// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 94242824d..44e55db43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -207,9 +207,9 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin /// 遗留字段 `projectVersion` 的取值范围:缺省合法(旧清单),写了就必须是正整数。 /// -/// 它**不参与**发布版本标签的推导(标签由 `versions[]` 的条数派生),只是为了让存量清单继续 -/// 可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值,所以在读 -/// 写两侧都失败关闭,避免坏值被静默传播。 +/// 它**不参与**发布版本号的推导(版本号取平台作者视图里该作品的最大版本号 + 1),只是为了让 +/// 存量清单继续可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值, +/// 所以在读写两侧都失败关闭,避免坏值被静默传播。 fn validate_manifest_project_version(value: Option) -> Result<(), String> { if value == Some(0) { return Err("manifest projectVersion 必须是大于 0 的整数".to_string()); 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 666dc09b1..8b4efb099 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 @@ -4,12 +4,9 @@ import { resolvePlatformGamePriceMudPoints, } from '@genarrative/shared/components'; import { Eye, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; -import { - type GameCreationAppManifest, - resolveGameCreationAppProjectVersion, -} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { type GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution'; import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { resolveTauriInvoke } from '../../app/tauri'; @@ -279,14 +276,15 @@ export function GameDistributionPublishPanel({ /** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */ const [latestVersionNumber, setLatestVersionNumber] = useState(0); /** - * 面板展示并提交的「项目版本」:AGC 工程内部版本序数,唯一来源是清单的 `versions[]`。 + * 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1` + * (首次发布为 `1`),由原生回读下发。 * - * 只读派生值,不是可编辑 state:用户不能改,平台版本号也改不了它。每次智能体修订追加一条 - * 内部版本,这个值随之推进。 + * `null` = 还没拿到平台数据(恢复失败、或更新模式没能刷新线上状态):这时不允许提交, + * 也不回落到 AGC 工程内部版本 `versions[]` 的条数——那是本地迭代计数,会提交重复 / + * 回退的平台版本号。面板展示的值与提交的值都只取这一个来源。 */ - const projectVersion = useMemo( - () => resolveGameCreationAppProjectVersion(manifest), - [manifest], + const [nextVersionNumber, setNextVersionNumber] = useState( + null, ); const metadataSuggestionRequestRef = useRef(0); const publicationRequestRef = useRef(0); @@ -332,6 +330,7 @@ export function GameDistributionPublishPanel({ setPublicationRefreshed(false); setPublicationMessage(''); setLatestVersionNumber(0); + setNextVersionNumber(null); summaryTouchedRef.current = false; categoryTouchedRef.current = false; publicationDraftAppliedRef.current = false; @@ -371,7 +370,8 @@ export function GameDistributionPublishPanel({ // 面板据此禁用提交而不是退化成首次发布。 const invoke = resolveTauriInvoke(); if (!invoke) { - // 非 Tauri 宿主:没有可恢复的原生状态,交给提交时的失败提示兜底。 + // 非 Tauri 宿主:没有可恢复的原生状态,也就拿不到平台版本号——按首次发布展示表单, + // 提交按钮因为版本号未知保持禁用(发布本身也需要原生宿主)。 setPublicationState('first-publish'); return; } @@ -387,8 +387,15 @@ export function GameDistributionPublishPanel({ ); return; } + // 版本号只认原生按平台作者视图推出来的值;缺字段(旧原生端 / 没刷新成功)就保持未知。 + const platformNextVersion = recovery.nextVersionNumber; + setNextVersionNumber( + typeof platformNextVersion === 'number' && platformNextVersion > 0 + ? platformNextVersion + : null, + ); if (recovery.state === 'update') { - // 线上最近提交的版本号只用于展示平台侧事实;面板的项目版本由内部版本派生,与它无关。 + // 线上最近提交的版本号只用于展示平台侧事实,与这次要用的版本号无关。 const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0; setPublicationState('update'); setPublicationBinding(recovery.binding); @@ -682,6 +689,11 @@ export function GameDistributionPublishPanel({ setError('请先导出有效的试玩包'); return; } + // 版本号必须来自平台作者视图;拿不到就不发请求,也不猜本地工程迭代计数。 + if (nextVersionNumber === null) { + setError('无法确认项目版本,请重新打开发布面板后再试'); + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setError('需要在 Tauri App 内发布'); @@ -773,7 +785,8 @@ export function GameDistributionPublishPanel({ publicationState === 'update' ? (publicationBinding?.gameId ?? null) : null, - versionNumber: projectVersion, + // 展示与提交同一个平台版本号(作者视图最大版本号 + 1,首次发布为 1)。 + versionNumber: nextVersionNumber, priceMudPoints: price.priceMudPoints, expectedPublicationRevision: publicationState === 'update' @@ -924,24 +937,28 @@ export function GameDistributionPublishPanel({ ))} -