{
+ vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
+ // 服务端 `version_detail_payload` 已把「待审版本冻结价优先、缺字段按 0」归一写进
+ // `game.priceMudPoints`,审核页只渲染这个字段。
+ game: { ...detailResponse.game, priceMudPoints: 30 },
+ version: {
+ ...detailResponse.version,
+ // 对抗用例:冻结资料里的价格与服务端归一结果故意不同(999 ≠ 30)。客户端若自行
+ // 复刻「冻结价优先」,就会显示 999 —— 这里必须仍然是服务端字段的 30。
+ frozenMetadata: { title: '测试游戏', priceMudPoints: 999 },
+ },
+ });
+
+ render(
+ ,
+ );
+ await screen.findByText(gameTitle);
+
+ fireEvent.click(screen.getByRole('button', { name: '详情' }));
+ expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
+
+ expect(screen.getByText('价格')).toBeTruthy();
+ expect(screen.getByText('30 泥点')).toBeTruthy();
+ expect(screen.queryByText('999 泥点')).toBeNull();
+ expect(screen.queryByText('免费')).toBeNull();
+});
+
+test('旧版本冻结资料没有价格时按免费回落', async () => {
+ vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
+ game: {
+ ...detailResponse.game,
+ priceMudPoints: undefined,
+ } as unknown as AdminGameDistributionGameDetail,
+ version: {
+ ...detailResponse.version,
+ frozenMetadata: { title: '测试游戏' },
+ },
+ });
+
+ render(
+ ,
+ );
+ await screen.findByText(gameTitle);
+
+ fireEvent.click(screen.getByRole('button', { name: '详情' }));
+ expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
+
+ expect(screen.getByText('免费')).toBeTruthy();
+ expect(screen.queryByText(/NaN/u)).toBeNull();
+});
diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx
index 158e5381e..0fe3d3118 100644
--- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx
+++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx
@@ -100,6 +100,8 @@ function applyFrozenGameMetadata(
typeof metadata.orientation === 'string'
? metadata.orientation
: game.orientation,
+ // 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、
+ // 缺字段按 0」把结果归一写进 `game.priceMudPoints`,客户端直接沿用该字段。
};
}
@@ -113,6 +115,11 @@ function formatBytes(value: number) {
return `${value} B`;
}
+/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */
+function formatPriceMudPoints(value: number | null | undefined) {
+ return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费';
+}
+
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
@@ -439,6 +446,7 @@ export function AdminGameDistributionReviewPage({
{ key: 'gameId', label: '游戏' },
{ key: 'versionNumber', label: '版本' },
{ key: 'packageBytes', label: '发行包' },
+ { key: 'priceMudPoints', label: '价格' },
{ key: 'createdAt', label: '提交时间' },
{ key: 'actions', label: '审核' },
]}
@@ -484,6 +492,7 @@ export function AdminGameDistributionReviewPage({
{entry.packageSha256.slice(0, 12)}
+ {formatPriceMudPoints(entry.priceMudPoints)} |
{formatAdminDateTime(entry.createdAt)} |
@@ -649,6 +658,10 @@ function AdminGameDistributionReviewDetailView({
versionLabel={`v${detail.version.versionNumber}`}
infoCards={[
{ label: '状态', value: detail.version.status },
+ {
+ label: '价格',
+ value: formatPriceMudPoints(detail.game.priceMudPoints),
+ },
{
label: '提交时间',
value: formatAdminDateTime(detail.version.createdAt),
diff --git a/apps/admin-web/src/pages/adminGameDistributionReview.css b/apps/admin-web/src/pages/adminGameDistributionReview.css
index 2eed66619..48d48ccc6 100644
--- a/apps/admin-web/src/pages/adminGameDistributionReview.css
+++ b/apps/admin-web/src/pages/adminGameDistributionReview.css
@@ -27,14 +27,22 @@ table.admin-game-review-table td:nth-child(3) {
width: 124px;
}
+/* 价格列:只展示「免费」或「N 泥点」,上限 1000000 泥点最长;给足宽度并允许换行,
+ 固定列宽 + overflow:hidden 下保持 nowrap 会把最大价硬裁掉。 */
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4) {
- width: 172px;
- white-space: nowrap;
+ width: 120px;
+ white-space: normal;
}
table.admin-game-review-table th:nth-child(5),
table.admin-game-review-table td:nth-child(5) {
+ width: 172px;
+ white-space: nowrap;
+}
+
+table.admin-game-review-table th:nth-child(6),
+table.admin-game-review-table td:nth-child(6) {
width: 240px;
}
@@ -100,7 +108,9 @@ table.admin-game-review-table td .admin-muted-text {
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4),
table.admin-game-review-table th:nth-child(5),
- table.admin-game-review-table td:nth-child(5) {
+ table.admin-game-review-table td:nth-child(5),
+ table.admin-game-review-table th:nth-child(6),
+ table.admin-game-review-table td:nth-child(6) {
width: auto;
}
}
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 b3ee96410..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
@@ -48,6 +48,9 @@ const PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress";
/// JS `Number.MAX_SAFE_INTEGER`:版本号经 JSON number 传递,超过它就会丢精度。
const MAX_SAFE_VERSION_NUMBER: u64 = 9_007_199_254_740_991;
+/// 买断价上限(整数泥点),与服务端 `MAX_GAME_PRICE_MUD_POINTS` 及前端共享常量一致。
+const MAX_GAME_PRICE_MUD_POINTS: u64 = 1_000_000;
+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameDistributionPublishResult {
@@ -101,6 +104,12 @@ pub(crate) struct GameDistributionPublicationDraft {
pub(crate) input_modes: Vec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) orientation: Option,
+ /// 更新面板预填的买断价(整数泥点,`0` 表示免费):取该作品**最近版本的冻结价**
+ /// (该版本可能仍处于 `pending_review` / `rejected`,因此预填值未必已生效);该版本
+ /// 冻结价缺失或为 `0` 时回落游戏行当前生效价,两条来源都取不到才按 `0`(免费),
+ /// 避免把已上线的付费作品预填成免费。旧原生响应缺该字段时按免费兜底。
+ #[serde(default)]
+ pub(crate) price_mud_points: u64,
}
/// 发布面板打开时的发布状态回读。
@@ -121,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,
}
@@ -229,12 +245,22 @@ 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)]
input_modes: Vec,
#[serde(default)]
orientation: Option,
+ /// 游戏行当前生效的买断价(作者自有视图的 `priceMudPoints`)。
+ #[serde(default)]
+ price_mud_points: u64,
}
#[derive(Clone, Debug, Default, Deserialize)]
@@ -245,6 +271,9 @@ struct OwnerGameVersion {
version_number: u64,
#[serde(default)]
status: String,
+ /// 该版本审核通过后冻结的买断价;历史版本缺省为 `0`。
+ #[serde(default)]
+ price_mud_points: u64,
}
#[derive(Clone, Debug, Deserialize)]
@@ -426,11 +455,19 @@ fn normalized_origin(value: &str) -> String {
value.trim().trim_end_matches('/').to_string()
}
-fn metadata_digest(metadata: &GameDistributionCreateGameRequest) -> Result {
+/// 发布资料摘要:随版本冻结的资料与买断价一起参与。
+///
+/// 价格必须进摘要:同一份包、同一版本标签只改价格时,幂等账本必须换键,否则会重放上一次
+/// 尝试的收据,把作者改后的价格静默丢掉。
+fn metadata_digest(
+ metadata: &GameDistributionCreateGameRequest,
+ price_mud_points: u64,
+) -> Result {
let bytes =
serde_json::to_vec(metadata).map_err(|error| format!("计算发布资料摘要失败:{error}"))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
+ hasher.update(price_mud_points.to_le_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
@@ -508,6 +545,8 @@ fn first_publish_state(message: Option) -> GameDistributionPublicationRe
refreshed: true,
draft: None,
visibility: None,
+ // 首次发布会新建作品,平台自己也从 `1` 派发这一版的版本号。
+ next_version_number: Some(1),
message,
}
}
@@ -519,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,
@@ -781,9 +840,21 @@ async fn build_publication_draft(
device_support,
input_modes: parse_input_modes(input_modes),
orientation: parse_orientation(orientation),
+ price_mud_points: resolve_publication_prefill_price(entry),
}
}
+/// 更新面板预填的买断价:优先取最近版本的冻结价(与网页更新模式同源),该版本历史无价格时
+/// 回退游戏行当前价。两条来源都拿不到才按 `0`(免费),避免把已上线的付费作品预填成免费。
+fn resolve_publication_prefill_price(entry: &OwnerGameEntry) -> u64 {
+ entry
+ .latest_version
+ .as_ref()
+ .map(|version| version.price_mud_points)
+ .filter(|price| *price > 0)
+ .unwrap_or(entry.price_mud_points)
+}
+
async fn bound_publication_result(
client: &reqwest::Client,
snapshot: &PlatformSessionSnapshot,
@@ -820,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,
})
}
@@ -872,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}")),
})
}
@@ -1237,6 +1311,8 @@ pub(crate) async fn publish_local_project_game(
version_number: Option,
expected_publication_revision: Option,
idempotency_key: Option,
+ // 买断价(整数泥点,`0` 表示免费)。旧前端不传时为 `None`,按免费提交,保持向后兼容。
+ price_mud_points: Option,
) -> Result {
let snapshot = require_platform_session()?;
let app_data_dir = app
@@ -1283,7 +1359,12 @@ pub(crate) async fn publish_local_project_game(
if target_game_id.is_some() && expected_publication_revision.is_none() {
return Err("缺少公开修订号,请重新打开发布面板后再试".to_string());
}
- let digest = metadata_digest(&metadata)?;
+ // 买断价只允许 `0..=上限`;面板已按同一口径拦住非法输入,这里对命令边界再收一次口。
+ let price_mud_points = price_mud_points.unwrap_or(0);
+ if price_mud_points > MAX_GAME_PRICE_MUD_POINTS {
+ return Err(format!("买断价不能超过 {MAX_GAME_PRICE_MUD_POINTS} 泥点"));
+ }
+ let digest = metadata_digest(&metadata, price_mud_points)?;
// 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析:
// 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上;
// 包内容、目标版本标签或资料任一变化都换键,新标签的提交不会被幂等收据吞掉。
@@ -1360,6 +1441,8 @@ pub(crate) async fn publish_local_project_game(
package_file_count: staged.package_file_count,
package_entry_path: "index.html".to_string(),
version_number,
+ // 买断价随版本冻结;面板已本地校验,这里透传服务端口径的整数泥点。
+ price_mud_points,
game_metadata: metadata,
};
let version_value = request_json(
@@ -1482,7 +1565,8 @@ pub(crate) async fn publish_local_project_game(
}
});
// 发布成功:把平台作品绑定和最近版本状态写回本地清单。项目版本标签不在这里对齐——它由
- // 工程内部版本派生,这次提交的平台版本号不回写本地(遗留 `projectVersion` 一并清空)。
+ // 平台作者视图的最大版本号 + 1 推出、在提交前已经决定了下一次要用的值(遗留 `projectVersion`
+ // 一并清空)。
// 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布,
// 甚至可能重复建作品。
let binding = GameCreationAppPublicationBinding {
@@ -1495,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())
@@ -1631,11 +1715,13 @@ mod tests {
orientation:
shared_contracts::game_distribution::GameDistributionOrientation::Responsive,
};
- let first = metadata_digest(&metadata).expect("digest");
+ let first = metadata_digest(&metadata, 0).expect("digest");
assert_eq!(first.len(), 64);
- assert_eq!(metadata_digest(&metadata).expect("stable"), first);
+ assert_eq!(metadata_digest(&metadata, 0).expect("stable"), first);
+ // 只改价格的提交必须换幂等键:否则账本会重放上一次收据,静默丢掉改价。
+ assert_ne!(metadata_digest(&metadata, 120).expect("priced"), first);
metadata.title = "新标题".to_string();
- assert_ne!(metadata_digest(&metadata).expect("changed"), first);
+ assert_ne!(metadata_digest(&metadata, 0).expect("changed"), first);
}
#[test]
@@ -1647,20 +1733,115 @@ mod tests {
"summary": "守住轨道城",
"status": "published",
"publicationRevision": 4,
+ "priceMudPoints": 240,
"latestVersion": {
"versionId": "gamever-2",
"versionNumber": 2,
- "status": "pending_review"
- }
+ "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);
assert_eq!(latest.status, "pending_review");
+ 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:与网页更新模式同源,直接用它。
+ let paid = OwnerGameEntry {
+ price_mud_points: 240,
+ latest_version: Some(OwnerGameVersion {
+ price_mud_points: 300,
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+ assert_eq!(resolve_publication_prefill_price(&paid), 300);
+
+ // 历史版本没有冻结价(0):回退游戏行当前价,避免把已上线的付费作品预填成免费。
+ let legacy = OwnerGameEntry {
+ price_mud_points: 240,
+ latest_version: Some(OwnerGameVersion::default()),
+ ..Default::default()
+ };
+ assert_eq!(resolve_publication_prefill_price(&legacy), 240);
+
+ // 免费作品、以及完全缺该字段的旧响应,都按 0(免费)。
+ assert_eq!(
+ resolve_publication_prefill_price(&OwnerGameEntry::default()),
+ 0
+ );
+ let legacy_entry = parse_owner_game_entry(json!({
+ "id": "game-2",
+ "title": "免费游戏",
+ "latestVersion": { "versionId": "v1", "versionNumber": 1, "status": "published" }
+ }))
+ .expect("legacy owner entry");
+ assert_eq!(resolve_publication_prefill_price(&legacy_entry), 0);
}
#[test]
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 22e0d20e8..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
@@ -1,10 +1,12 @@
-import { Eye, Trash2 } from 'lucide-react';
-import { useEffect, useMemo, useRef, useState } from 'react';
-
import {
- type GameCreationAppManifest,
- resolveGameCreationAppProjectVersion,
-} from '../../../../../packages/shared/src/contracts/gameCreationApp';
+ PlatformGamePricingField,
+ type PlatformGamePricingMode,
+ resolvePlatformGamePriceMudPoints,
+} from '@genarrative/shared/components';
+import { Eye, Trash2 } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+
+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';
@@ -237,6 +239,10 @@ export function GameDistributionPublishPanel({
const [summary, setSummary] = useState('');
const [category, setCategory] =
useState('其他');
+ /** 付费方式与买断价输入;校验口径与网页、服务端一致。 */
+ const [priceMode, setPriceMode] = useState('free');
+ const [priceInput, setPriceInput] = useState('');
+ const [priceError, setPriceError] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState(
@@ -270,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);
@@ -304,6 +311,9 @@ export function GameDistributionPublishPanel({
setTitle(manifest.name.trim());
setSummary(fallbackSummary);
setCategory('其他');
+ setPriceMode('free');
+ setPriceInput('');
+ setPriceError('');
setCover(null);
setScreenshots([]);
setBusy(false);
@@ -320,6 +330,7 @@ export function GameDistributionPublishPanel({
setPublicationRefreshed(false);
setPublicationMessage('');
setLatestVersionNumber(0);
+ setNextVersionNumber(null);
summaryTouchedRef.current = false;
categoryTouchedRef.current = false;
publicationDraftAppliedRef.current = false;
@@ -359,7 +370,8 @@ export function GameDistributionPublishPanel({
// 面板据此禁用提交而不是退化成首次发布。
const invoke = resolveTauriInvoke();
if (!invoke) {
- // 非 Tauri 宿主:没有可恢复的原生状态,交给提交时的失败提示兜底。
+ // 非 Tauri 宿主:没有可恢复的原生状态,也就拿不到平台版本号——按首次发布展示表单,
+ // 提交按钮因为版本号未知保持禁用(发布本身也需要原生宿主)。
setPublicationState('first-publish');
return;
}
@@ -375,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);
@@ -422,6 +441,20 @@ export function GameDistributionPublishPanel({
if (!categoryTouchedRef.current && draft.category) {
setCategory(draft.category);
}
+ // 线上价格预填:付费作品进面板默认免费会在送审后把作品改成免费,这里按回读的
+ // 当前价预填,作者不改就不变。历史响应缺该字段时按免费兜底。
+ const existingPriceMudPoints = draft.priceMudPoints ?? 0;
+ if (
+ typeof existingPriceMudPoints === 'number' &&
+ existingPriceMudPoints > 0
+ ) {
+ setPriceMode('paid');
+ setPriceInput(String(existingPriceMudPoints));
+ } else {
+ setPriceMode('free');
+ setPriceInput('');
+ }
+ setPriceError('');
if (draft.cover && (draft.cover.assetId || draft.cover.previewUrl)) {
setCover(buildRestoredCover(draft.cover));
}
@@ -656,6 +689,11 @@ export function GameDistributionPublishPanel({
setError('请先导出有效的试玩包');
return;
}
+ // 版本号必须来自平台作者视图;拿不到就不发请求,也不猜本地工程迭代计数。
+ if (nextVersionNumber === null) {
+ setError('无法确认项目版本,请重新打开发布面板后再试');
+ return;
+ }
const invoke = resolveTauriInvoke();
if (!invoke) {
setError('需要在 Tauri App 内发布');
@@ -686,6 +724,12 @@ export function GameDistributionPublishPanel({
setError('素材还在上传中,请稍候再发布');
return;
}
+ const price = resolvePlatformGamePriceMudPoints(priceMode, priceInput);
+ if (price.error) {
+ setPriceError(price.error);
+ return;
+ }
+ setPriceError('');
setBusy(true);
setError('');
setPublishPhase({
@@ -741,7 +785,9 @@ export function GameDistributionPublishPanel({
publicationState === 'update'
? (publicationBinding?.gameId ?? null)
: null,
- versionNumber: projectVersion,
+ // 展示与提交同一个平台版本号(作者视图最大版本号 + 1,首次发布为 1)。
+ versionNumber: nextVersionNumber,
+ priceMudPoints: price.priceMudPoints,
expectedPublicationRevision:
publicationState === 'update'
? (publicationBinding?.revision ?? null)
@@ -891,24 +937,42 @@ export function GameDistributionPublishPanel({
))}
- |