AGC 发布版本标签改由工程内部版本派生,拆除 projectVersion 写链路
- shared contracts:resolveGameCreationAppProjectVersion 改为按 manifest.versions 条数派生,删除 isGameCreationAppProjectVersion,projectVersion 注释降级为遗留兼容位 - Rust shared-contracts:删除 resolve_game_creation_app_project_version / is_game_creation_app_project_version / GAME_CREATION_APP_FIRST_PROJECT_VERSION,project_version 注释改遗留位并重写往返测试 - AGC 发布面板:项目版本改为 useMemo 派生只读输入框,删除本地 state、失焦落盘与 onManifestUpdated 回调 - AGC 发布服务:删除 saveGameDistributionProjectVersion,提交版本号缺省改为按 versions 派生 - AGC 原生:发布成功回写与绑定回填不再写 project_version(置空),删除 update_game_distribution_project_version 命令 - AGC 命令注册与白名单:desktop.rs 注销命令、check-config.mjs 移除白名单条目 - AGC 前端装配:App.tsx 移除两处 onManifestUpdated 传递 - AGC 测试:publish/panel 两条测试改为覆盖派生口径,删除四条写回用例 - 注释对齐:game_publish_attempt.rs / project/manifest.rs / styles.css / shared-contracts 的 game_distribution.rs 把版本标签口径改为「由 versions[] 派生」,清掉 projectVersion 遗留位表述 - 文档:decision-log 追加本决策条目,两份 2026-10-02 计划加取代标注,新增 2026-10-03 里程碑与实施计划
This commit is contained in:
@@ -135,7 +135,6 @@ const allowedUncalledTauriCommands = [
|
||||
'read_game_cover_generation_price',
|
||||
'suggest_game_distribution_publish_metadata',
|
||||
'read_game_distribution_publication',
|
||||
'update_game_distribution_project_version',
|
||||
'upload_local_project_game_package',
|
||||
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
|
||||
@@ -611,7 +611,6 @@ pub(super) fn run() {
|
||||
read_game_cover_generation_price,
|
||||
generate_game_distribution_cover,
|
||||
read_game_distribution_publication,
|
||||
update_game_distribution_project_version,
|
||||
publish_local_project_game,
|
||||
read_game_creator_app_config,
|
||||
clear_game_creator_acl_elevation_denials,
|
||||
|
||||
@@ -22,8 +22,7 @@ use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
|
||||
use shared_contracts::game_creation_app::{
|
||||
resolve_game_creation_app_project_version, GameCreationAppManifest,
|
||||
GameCreationAppPublicationBinding,
|
||||
GameCreationAppManifest, GameCreationAppPublicationBinding,
|
||||
};
|
||||
use shared_contracts::game_distribution::{
|
||||
GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest,
|
||||
@@ -524,20 +523,17 @@ fn unavailable_state(message: Option<String>) -> GameDistributionPublicationRead
|
||||
}
|
||||
}
|
||||
|
||||
/// 回写发布绑定;旧清单缺 `projectVersion` 时按绑定回填。
|
||||
/// 回写发布绑定;本地遗留的 `projectVersion` 一并清空。
|
||||
///
|
||||
/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离,所以回填只会用**本项目**的线上版本号,
|
||||
/// 不会把别的账号/环境的版本写进这份清单。已有值一律保留:用户手动改过的版本标签,重开项目
|
||||
/// 不能被线上最大值悄悄覆盖。
|
||||
/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不再从绑定推导(它由
|
||||
/// AGC 工程内部版本 `versions[]` 派生、只读),所以这里只落绑定,并把存量清单里可能残留的
|
||||
/// 遗留标签置空——`skip_serializing_if` 会让该键随下一次落盘消失。
|
||||
fn write_publication_binding(
|
||||
root: &Path,
|
||||
binding: GameCreationAppPublicationBinding,
|
||||
) -> Result<(), String> {
|
||||
crate::project::mutate_manifest_at(root, |manifest| {
|
||||
manifest.project_version = Some(resolve_game_creation_app_project_version(
|
||||
manifest.project_version,
|
||||
Some(&binding),
|
||||
));
|
||||
manifest.project_version = None;
|
||||
manifest.publication = Some(binding.clone());
|
||||
Ok(())
|
||||
})
|
||||
@@ -934,36 +930,6 @@ pub(crate) async fn read_game_distribution_publication(
|
||||
}
|
||||
}
|
||||
|
||||
/// 只写项目清单里的用户发行版本标签(`项目版本 vN`),不发布、不改发布绑定。
|
||||
///
|
||||
/// 版本标签是"对同一个 gameId 反复提交的标签",所以这里不做递增校验、也不与线上版本比较:
|
||||
/// 重复提交同一标签与回退到更小标签都合法,具体提交实例由不可变 versionId 区分。非法输入
|
||||
/// (0 或超出安全整数)在这里失败关闭,避免坏值落进清单。
|
||||
///
|
||||
/// 返回整份 manifest,调用方据此同步工作台状态(与发布成功同一条回写语义)。
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_game_distribution_project_version(
|
||||
project_path: String,
|
||||
project_version: u64,
|
||||
) -> Result<GameCreationAppManifest, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err("发布需要绑定本地项目".to_string());
|
||||
}
|
||||
if project_version == 0 {
|
||||
return Err("项目版本必须是大于 0 的整数".to_string());
|
||||
}
|
||||
if project_version > MAX_SAFE_VERSION_NUMBER {
|
||||
return Err("项目版本超出安全整数范围".to_string());
|
||||
}
|
||||
// 与发布同一条门禁:改版本标签属于发布资料的一部分,不是独立的新权限。
|
||||
crate::project::enforce_project_permission_policy(root, "project.export_package")?;
|
||||
crate::project::mutate_manifest_at(root, |manifest| {
|
||||
manifest.project_version = Some(project_version);
|
||||
Ok(manifest.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn read_cover_asset_id(payload: &CoverGenerationPayload) -> Option<String> {
|
||||
payload
|
||||
.asset_object_id
|
||||
@@ -1320,7 +1286,7 @@ pub(crate) async fn publish_local_project_game(
|
||||
let digest = metadata_digest(&metadata)?;
|
||||
// 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析:
|
||||
// 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上;
|
||||
// 包内容、目标版本或资料任一变化都换键,用户改的版本号不会被幂等收据吞掉。
|
||||
// 包内容、目标版本标签或资料任一变化都换键,新标签的提交不会被幂等收据吞掉。
|
||||
let root_key = match idempotency_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
@@ -1515,8 +1481,8 @@ pub(crate) async fn publish_local_project_game(
|
||||
uploaded.status.clone()
|
||||
}
|
||||
});
|
||||
// 发布成功:把平台作品绑定和最近版本状态写回本地清单,并把项目版本标签对齐到服务端返回值
|
||||
// (服务端是版本号的唯一事实来源;旧客户端没传版本号时这里会把自动递增的结果记下来)。
|
||||
// 发布成功:把平台作品绑定和最近版本状态写回本地清单。项目版本标签不在这里对齐——它由
|
||||
// 工程内部版本派生,这次提交的平台版本号不回写本地(遗留 `projectVersion` 一并清空)。
|
||||
// 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布,
|
||||
// 甚至可能重复建作品。
|
||||
let binding = GameCreationAppPublicationBinding {
|
||||
@@ -1529,7 +1495,8 @@ pub(crate) async fn publish_local_project_game(
|
||||
status: Some(status.clone()),
|
||||
};
|
||||
let manifest = crate::project::mutate_manifest_at(root, |manifest| {
|
||||
manifest.project_version = Some(version.version_number);
|
||||
// 项目版本标签由工程内部版本派生,不由这次提交的平台版本号回写;遗留位一并清空。
|
||||
manifest.project_version = None;
|
||||
manifest.publication = Some(binding.clone());
|
||||
Ok(manifest.clone())
|
||||
})?;
|
||||
|
||||
@@ -36,7 +36,8 @@ struct GamePublishAttemptRecord {
|
||||
/// 目标游戏身份:更新已有作品时非空;首次发布为空。
|
||||
#[serde(default)]
|
||||
target_game_id: Option<String>,
|
||||
/// 这次发布提交的项目版本标签(清单里的 `projectVersion`):换了标签就是一次新尝试。
|
||||
/// 这次发布提交的项目版本标签(由清单 `versions[]` 的条数派生,不由 `projectVersion`
|
||||
/// 这种遗留兼容位提供):换了标签就是一次新尝试。
|
||||
#[serde(default)]
|
||||
target_version_number: Option<u64>,
|
||||
/// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。
|
||||
@@ -49,8 +50,8 @@ struct GamePublishAttemptRecord {
|
||||
|
||||
/// 一次发布的意图:账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」复用。
|
||||
///
|
||||
/// 只要其中任何一项变化,就必须换根幂等键:否则「同一份 ZIP、但用户把目标版本从 3 改成 4」
|
||||
/// 会命中上一次尝试的幂等收据,用户改的版本号静默失效。
|
||||
/// 只要其中任何一项变化,就必须换根幂等键:否则「同一份 ZIP、但目标版本标签变了(工程内部版本
|
||||
/// 推进后重发)的这次提交」会命中上一次尝试的幂等收据,新标签静默失效。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct GamePublishAttemptIntent<'a> {
|
||||
pub(crate) account_id: &'a str,
|
||||
@@ -256,7 +257,7 @@ pub(crate) fn resolve_game_publish_root_key(
|
||||
return Ok((existing.root_key.clone(), true));
|
||||
}
|
||||
let root_key = candidate.root_key.clone();
|
||||
// 同一项目只保留一个未收口尝试:用户改了目标版本或资料后,旧意图的半成品不再续用。
|
||||
// 同一项目只保留一个未收口尝试:目标版本标签或资料变化后,旧意图的半成品不再续用。
|
||||
attempts.retain(|record| {
|
||||
!(record.account_id == candidate.account_id
|
||||
&& record.origin == candidate.origin
|
||||
@@ -390,8 +391,8 @@ mod tests {
|
||||
base.target_version_number = Some(3);
|
||||
let (first, _) = resolve_game_publish_root_key(&dir, base).expect("first key");
|
||||
|
||||
// 同一份 ZIP、同一资料,只是用户把目标版本从 3 改成 4:必须换根键,
|
||||
// 否则服务端幂等收据会把用户填的版本号静默吞掉。
|
||||
// 同一份 ZIP、同一资料,只是目标版本标签从 3 变成 4(例如工程内部版本推进后重发):
|
||||
// 必须换根键,否则服务端幂等收据会把这次新标签静默吞掉。
|
||||
let mut bumped_version = base;
|
||||
bumped_version.target_version_number = Some(4);
|
||||
let (bumped, reused) =
|
||||
|
||||
@@ -205,10 +205,11 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 唯一用户发行版本标签:缺省合法(旧清单),写了就必须是正整数。
|
||||
/// 遗留字段 `projectVersion` 的取值范围:缺省合法(旧清单),写了就必须是正整数。
|
||||
///
|
||||
/// `0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0,服务端与面板都会把它当成非法
|
||||
/// 版本号,所以在读写两侧都失败关闭,避免坏值被静默传播。
|
||||
/// 它**不参与**发布版本标签的推导(标签由 `versions[]` 的条数派生),只是为了让存量清单继续
|
||||
/// 可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值,所以在读
|
||||
/// 写两侧都失败关闭,避免坏值被静默传播。
|
||||
fn validate_manifest_project_version(value: Option<u64>) -> Result<(), String> {
|
||||
if value == Some(0) {
|
||||
return Err("manifest projectVersion 必须是大于 0 的整数".to_string());
|
||||
|
||||
@@ -1813,7 +1813,6 @@ export function App({
|
||||
// 工作台状态,两处面板挂载点(普通项目 / DirectProject)共享同一份状态。
|
||||
if (published.manifest) setManifest(published.manifest);
|
||||
}}
|
||||
onManifestUpdated={setManifest}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
@@ -1948,7 +1947,6 @@ export function App({
|
||||
onPublished={(published) => {
|
||||
if (published.manifest) setManifest(published.manifest);
|
||||
}}
|
||||
onManifestUpdated={setManifest}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
|
||||
+30
-84
@@ -1,5 +1,5 @@
|
||||
import { Eye, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameCreationAppManifest,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
readGameDistributionPublication,
|
||||
saveGameDistributionProjectVersion,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../../services/gameDistributionPublish';
|
||||
import {
|
||||
@@ -62,7 +61,11 @@ type PanelScreenshotEntry = {
|
||||
*
|
||||
* `unavailable` 时禁止提交:恢复失败(网络、401、5xx)不能退化成首次发布,否则会多建作品。
|
||||
*/
|
||||
type PanelPublicationState = 'checking' | 'first-publish' | 'update' | 'unavailable';
|
||||
type PanelPublicationState =
|
||||
| 'checking'
|
||||
| 'first-publish'
|
||||
| 'update'
|
||||
| 'unavailable';
|
||||
|
||||
function resolvePanelImageLabel(kind: PanelImageKind) {
|
||||
return kind === 'cover' ? '游戏封面' : '游戏截图';
|
||||
@@ -126,15 +129,6 @@ function publicationVersionStatusLabel(status: string | null | undefined) {
|
||||
return PUBLICATION_VERSION_STATUS_LABELS[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
/** 项目版本只接受正整数安全整数;非法输入返回 null,由调用方给出可读错误。 */
|
||||
function parseProjectVersion(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/u.test(trimmed)) return null;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 已发布作品的封面恢复:预览用换签地址,提交仍复用冻结素材 ID。 */
|
||||
function buildRestoredCover(
|
||||
cover: GameDistributionPublicationImageDraft,
|
||||
@@ -231,7 +225,6 @@ export function GameDistributionPublishPanel({
|
||||
packageResult,
|
||||
onClose,
|
||||
onPublished,
|
||||
onManifestUpdated,
|
||||
}: {
|
||||
open: boolean;
|
||||
projectPath: string;
|
||||
@@ -239,8 +232,6 @@ export function GameDistributionPublishPanel({
|
||||
packageResult: LocalProjectExportPackageResult | null;
|
||||
onClose: () => void;
|
||||
onPublished?: (result: GameDistributionPublishResult) => void;
|
||||
/** 项目版本写回本地清单后回调整份 manifest,让工作台同步这份唯一发行版本。 */
|
||||
onManifestUpdated?: (manifest: GameCreationAppManifest) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
@@ -279,13 +270,18 @@ export function GameDistributionPublishPanel({
|
||||
>(null);
|
||||
const [publicationRefreshed, setPublicationRefreshed] = useState(false);
|
||||
const [publicationMessage, setPublicationMessage] = useState('');
|
||||
/** 项目清单里唯一的用户发行版本标签(`项目版本 vN`),输入框的当前文本。 */
|
||||
const [projectVersion, setProjectVersion] = useState('1');
|
||||
/** 已写回清单的项目版本;失焦时用它判断是否需要落盘。 */
|
||||
const [savedProjectVersion, setSavedProjectVersion] = useState(0);
|
||||
/** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */
|
||||
const [latestVersionNumber, setLatestVersionNumber] = useState(0);
|
||||
const projectVersionSaveRequestRef = useRef(0);
|
||||
/**
|
||||
* 面板展示并提交的「项目版本」:AGC 工程内部版本序数,唯一来源是清单的 `versions[]`。
|
||||
*
|
||||
* 只读派生值,不是可编辑 state:用户不能改,平台版本号也改不了它。每次智能体修订追加一条
|
||||
* 内部版本,这个值随之推进。
|
||||
*/
|
||||
const projectVersion = useMemo(
|
||||
() => resolveGameCreationAppProjectVersion(manifest),
|
||||
[manifest],
|
||||
);
|
||||
const metadataSuggestionRequestRef = useRef(0);
|
||||
const publicationRequestRef = useRef(0);
|
||||
const publicationDraftAppliedRef = useRef(false);
|
||||
@@ -328,8 +324,6 @@ export function GameDistributionPublishPanel({
|
||||
setPublicationBinding(null);
|
||||
setPublicationRefreshed(false);
|
||||
setPublicationMessage('');
|
||||
setProjectVersion(String(resolveGameCreationAppProjectVersion(manifest)));
|
||||
setSavedProjectVersion(resolveGameCreationAppProjectVersion(manifest));
|
||||
setLatestVersionNumber(0);
|
||||
summaryTouchedRef.current = false;
|
||||
categoryTouchedRef.current = false;
|
||||
@@ -345,7 +339,8 @@ export function GameDistributionPublishPanel({
|
||||
context: buildPublishProjectContext(manifest),
|
||||
})
|
||||
.then((suggestion) => {
|
||||
if (suggestionRequestId !== metadataSuggestionRequestRef.current) return;
|
||||
if (suggestionRequestId !== metadataSuggestionRequestRef.current)
|
||||
return;
|
||||
// 更新发布以线上冻结资料为准:已有草稿回填时不再用 AI 建议覆盖。
|
||||
if (
|
||||
!publicationDraftAppliedRef.current &&
|
||||
@@ -393,19 +388,13 @@ export function GameDistributionPublishPanel({
|
||||
return;
|
||||
}
|
||||
if (recovery.state === 'update') {
|
||||
// 线上最近提交的版本号只用于展示与旧清单回填;用户输入的项目版本才是这次提交的标签。
|
||||
// 线上最近提交的版本号只用于展示平台侧事实;面板的项目版本由内部版本派生,与它无关。
|
||||
const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0;
|
||||
const resolved = resolveGameCreationAppProjectVersion(
|
||||
manifest,
|
||||
recovery.binding,
|
||||
);
|
||||
setPublicationState('update');
|
||||
setPublicationBinding(recovery.binding);
|
||||
setPublicationRefreshed(recovery.refreshed);
|
||||
setPublicationMessage(recovery.message?.trim() ?? '');
|
||||
setLatestVersionNumber(latestVersionNumber);
|
||||
setProjectVersion(String(resolved));
|
||||
setSavedProjectVersion(resolved);
|
||||
if (recovery.draft) {
|
||||
applyPublicationDraft(recovery.draft);
|
||||
}
|
||||
@@ -674,40 +663,6 @@ export function GameDistributionPublishPanel({
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 失焦即落盘:清单里的项目版本必须是用户改过的那个值,而不是只在这次面板里有效。
|
||||
*
|
||||
* 非法输入不写盘、直接回退到已保存值并给出可读原因;落盘失败保留用户输入(发布仍用输入值),
|
||||
* 只提示失败,避免"看起来没改成功、其实改了一半"。
|
||||
*/
|
||||
async function handleProjectVersionCommit() {
|
||||
const parsed = parseProjectVersion(projectVersion);
|
||||
if (parsed === null) {
|
||||
setError('项目版本必须是大于 0 的整数');
|
||||
setProjectVersion(String(savedProjectVersion));
|
||||
return;
|
||||
}
|
||||
if (parsed === savedProjectVersion) return;
|
||||
const requestId = ++projectVersionSaveRequestRef.current;
|
||||
try {
|
||||
const updated = await saveGameDistributionProjectVersion({
|
||||
projectPath,
|
||||
projectVersion: parsed,
|
||||
});
|
||||
if (requestId !== projectVersionSaveRequestRef.current) return;
|
||||
setSavedProjectVersion(parsed);
|
||||
setError('');
|
||||
if (updated) onManifestUpdated?.(updated);
|
||||
} catch (saveError) {
|
||||
if (requestId !== projectVersionSaveRequestRef.current) return;
|
||||
setError(
|
||||
saveError instanceof Error
|
||||
? saveError.message
|
||||
: '项目版本保存失败,请重试',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!packageResult || !projectPath.trim()) {
|
||||
setError('请先导出有效的试玩包');
|
||||
@@ -728,11 +683,6 @@ export function GameDistributionPublishPanel({
|
||||
);
|
||||
return;
|
||||
}
|
||||
const parsedProjectVersion = parseProjectVersion(projectVersion);
|
||||
if (parsedProjectVersion === null) {
|
||||
setError('项目版本必须是大于 0 的整数');
|
||||
return;
|
||||
}
|
||||
if (!cover) {
|
||||
setError('请先选择游戏封面(JPG/PNG/WebP)');
|
||||
return;
|
||||
@@ -800,10 +750,14 @@ export function GameDistributionPublishPanel({
|
||||
...(restoredDeclarationsRef.current ?? {}),
|
||||
},
|
||||
gameId:
|
||||
publicationState === 'update' ? publicationBinding?.gameId ?? null : null,
|
||||
versionNumber: parsedProjectVersion,
|
||||
publicationState === 'update'
|
||||
? (publicationBinding?.gameId ?? null)
|
||||
: null,
|
||||
versionNumber: projectVersion,
|
||||
expectedPublicationRevision:
|
||||
publicationState === 'update' ? publicationBinding?.revision ?? null : null,
|
||||
publicationState === 'update'
|
||||
? (publicationBinding?.revision ?? null)
|
||||
: null,
|
||||
});
|
||||
setResult(next);
|
||||
onPublished?.(next);
|
||||
@@ -953,7 +907,7 @@ export function GameDistributionPublishPanel({
|
||||
<span className="game-distribution-publish-panel__version-label">
|
||||
项目版本
|
||||
<span className="game-distribution-publish-panel__version-tag">
|
||||
{`v${projectVersion.trim() || '—'}`}
|
||||
{`v${projectVersion}`}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
@@ -962,21 +916,13 @@ export function GameDistributionPublishPanel({
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
aria-label="项目版本"
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
value={projectVersion}
|
||||
disabled={
|
||||
busy ||
|
||||
generatingCover ||
|
||||
publicationState === 'checking' ||
|
||||
publicationState === 'unavailable'
|
||||
}
|
||||
onChange={(event) => setProjectVersion(event.target.value)}
|
||||
onBlur={() => void handleProjectVersionCommit()}
|
||||
/>
|
||||
</label>
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
{latestVersionNumber > 0
|
||||
? `版本标签不要求递增:可以重复提交同一个版本,也可以回退到更小的版本号(线上最近提交 v${latestVersionNumber})。`
|
||||
: '第一次发布建议用 v1;版本号是大于 0 的整数,之后可以重复提交或回退,不要求递增。'}
|
||||
{`来自 AGC 内部版本(版本 ${projectVersion}):每次智能体修订追加一个内部版本,发布标签随之推进;${latestVersionNumber > 0 ? `线上最近提交 v${latestVersionNumber} 是平台侧事实,与本标签互不推导。` : '第一次发布即第一版。'}`}
|
||||
</span>
|
||||
<span
|
||||
className="game-distribution-publish-panel__metadata-hint"
|
||||
|
||||
@@ -136,36 +136,6 @@ export async function readGameDistributionPublication(
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「项目版本」写回本地清单。
|
||||
*
|
||||
* 版本标签是清单里唯一的用户发行版本字段:面板里改完就要落盘,重开项目显示的才是同一个值。
|
||||
* 这里只改这个字段,不发布、不动发布绑定与线上状态。
|
||||
*
|
||||
* @returns 写回后的整份 manifest;原生宿主没有返回清单(例如测试替身)时为 null,调用方保留
|
||||
* 本地状态即可,不视为失败。
|
||||
*/
|
||||
export async function saveGameDistributionProjectVersion(
|
||||
args: { projectPath: string; projectVersion: number },
|
||||
invoke?: TauriInvoke,
|
||||
): Promise<GameCreationAppManifest | null> {
|
||||
const projectPath = args.projectPath.trim();
|
||||
if (!projectPath) {
|
||||
throw new Error('发布需要绑定本地项目');
|
||||
}
|
||||
const projectVersion = args.projectVersion;
|
||||
if (!Number.isSafeInteger(projectVersion) || projectVersion <= 0) {
|
||||
throw new Error('项目版本必须是大于 0 的安全整数');
|
||||
}
|
||||
const payload = await requireNativeInvoke(invoke)<
|
||||
GameCreationAppManifest | null | undefined
|
||||
>('update_game_distribution_project_version', {
|
||||
projectPath,
|
||||
projectVersion,
|
||||
});
|
||||
return payload ?? null;
|
||||
}
|
||||
|
||||
export type GameDistributionPublishMetadataSuggestion = {
|
||||
summary: string;
|
||||
category: GameDistributionCategory;
|
||||
@@ -331,9 +301,9 @@ export async function publishLocalProjectGame(args: {
|
||||
/**
|
||||
* 这次提交使用的项目版本标签。
|
||||
*
|
||||
* 缺省时取清单的唯一发行版本 `manifest.projectVersion`(缺失按发布绑定回填,最终落到首版 1):
|
||||
* 发布面板编辑的就是这个字段,所以「用户改了什么、这次就提交什么」,不存在第二份版本来源。
|
||||
* 同一个版本标签可以反复提交,每次提交生成新的 `versionId`。
|
||||
* 缺省时按 AGC 工程内部版本 `versions[]` 派生(见 `resolveGameCreationAppProjectVersion`):
|
||||
* 标签等于当前存活的内部版本条数,发布面板展示的只读值就是它,不存在第二份版本来源,也不
|
||||
* 读取平台绑定里的版本号。同一个版本标签可以反复提交,每次提交生成新的 `versionId`。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
/** 更新送审所需的公开修订号 CAS;来自发布绑定。 */
|
||||
@@ -357,8 +327,8 @@ export async function publishLocalProjectGame(args: {
|
||||
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
|
||||
}
|
||||
const gameId = args.gameId?.trim() ?? '';
|
||||
// 版本标签只有一个来源:调用方显式传入的值优先(发布面板编辑中的输入),否则回落到清单里
|
||||
// 的项目版本(旧清单按发布绑定回填)。服务端的自动递增路径只服务不传版本号的老客户端。
|
||||
// 版本标签只有一个来源:调用方显式传入的值优先(发布面板展示的派生值),否则回落到按 AGC
|
||||
// 工程内部版本派生的同一个值。服务端的自动递增路径只服务不传版本号的老客户端。
|
||||
const versionNumber =
|
||||
args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest);
|
||||
if (!Number.isSafeInteger(versionNumber) || versionNumber <= 0) {
|
||||
|
||||
@@ -3737,7 +3737,7 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 项目版本标签:与榜单/详情页看到的「vN」一致,避免用户以为它是内部版本号。 */
|
||||
/* 项目版本标签:值就是 AGC 工程内部版本序数(资源总览「项目版本」栏目里最新那张卡的「版本 N」)。 */
|
||||
.game-distribution-publish-panel__version-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
readGameDistributionPublication,
|
||||
saveGameDistributionProjectVersion,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
const MANIFEST = {
|
||||
@@ -313,10 +312,10 @@ describe('Rust 发布 facade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('未显式给出项目版本时沿用清单里的唯一发行版本', async () => {
|
||||
test('未显式给出项目版本时按工程内部版本条数派生', async () => {
|
||||
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
|
||||
|
||||
// 旧清单没有 projectVersion:首版回落 1,不把「没写」变成服务端自动递增。
|
||||
// 没有任何内部版本记录:首版回落 1,不把「没写」变成服务端自动递增。
|
||||
await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
@@ -326,15 +325,35 @@ describe('Rust 发布 facade', () => {
|
||||
});
|
||||
expect(calls[0]?.args).toMatchObject({ versionNumber: 1 });
|
||||
|
||||
// 清单写过的项目版本就是这次提交的标签;同一标签重复提交由服务端接受。
|
||||
// 4 条内部版本 → 标签就是最新那张卡的序数 4;平台绑定里的版本号不参与派生。
|
||||
const withVersions = {
|
||||
...MANIFEST,
|
||||
versions: [
|
||||
{ versionId: 'initial-13', parentVersionId: null, projectRevision: 13 },
|
||||
{
|
||||
versionId: 'agent-22',
|
||||
parentVersionId: 'initial-13',
|
||||
projectRevision: 22,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-42',
|
||||
parentVersionId: 'agent-22',
|
||||
projectRevision: 42,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-46',
|
||||
parentVersionId: 'agent-42',
|
||||
projectRevision: 46,
|
||||
},
|
||||
],
|
||||
projectVersion: 6,
|
||||
publication: { latestVersionNumber: 6 },
|
||||
} as unknown as GameCreationAppManifest;
|
||||
await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
projectVersion: 4,
|
||||
} as unknown as GameCreationAppManifest,
|
||||
manifest: withVersions,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
gameId: 'game_9',
|
||||
expectedPublicationRevision: 7,
|
||||
@@ -378,42 +397,4 @@ describe('Rust 发布 facade', () => {
|
||||
}
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('项目版本写回清单通过单个 native command 并回传整份 manifest', async () => {
|
||||
const savedManifest = {
|
||||
...MANIFEST,
|
||||
projectVersion: 6,
|
||||
} as unknown as GameCreationAppManifest;
|
||||
const { invoke, calls } = installNativeInvoke(() => savedManifest);
|
||||
|
||||
const updated = await saveGameDistributionProjectVersion(
|
||||
{ projectPath: '/tmp/project', projectVersion: 6 },
|
||||
invoke,
|
||||
);
|
||||
|
||||
expect(updated).toEqual(savedManifest);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: 'update_game_distribution_project_version',
|
||||
args: { projectPath: '/tmp/project', projectVersion: 6 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('项目版本写回在非法输入时失败关闭', async () => {
|
||||
const invoke = vi.fn() as unknown as TauriInvoke;
|
||||
await expect(
|
||||
saveGameDistributionProjectVersion(
|
||||
{ projectPath: '/tmp/project', projectVersion: 0 },
|
||||
invoke,
|
||||
),
|
||||
).rejects.toThrow('项目版本必须是大于 0 的安全整数');
|
||||
await expect(
|
||||
saveGameDistributionProjectVersion(
|
||||
{ projectPath: ' ', projectVersion: 2 },
|
||||
invoke,
|
||||
),
|
||||
).rejects.toThrow('发布需要绑定本地项目');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
readGameDistributionPublication,
|
||||
saveGameDistributionProjectVersion,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
import { GAME_PACKAGE_UPLOAD_PROGRESS_EVENT } from '../src/services/gamePackageUploadProgress';
|
||||
@@ -44,7 +43,6 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
publishLocalProjectGame: vi.fn(),
|
||||
readGameCoverGenerationPrice: vi.fn(),
|
||||
readGameDistributionPublication: vi.fn(),
|
||||
saveGameDistributionProjectVersion: vi.fn(),
|
||||
suggestGameDistributionPublishMetadata: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -147,7 +145,6 @@ afterEach(() => {
|
||||
vi.mocked(publishLocalProjectGame).mockReset();
|
||||
vi.mocked(readGameCoverGenerationPrice).mockReset();
|
||||
vi.mocked(readGameDistributionPublication).mockReset();
|
||||
vi.mocked(saveGameDistributionProjectVersion).mockReset();
|
||||
vi.mocked(suggestGameDistributionPublishMetadata).mockReset();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockReset();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
@@ -503,9 +500,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
renderPanel();
|
||||
|
||||
// 绑定恢复完成前提交按钮显示「正在检查发布状态…」,等待回到首次发布后再点击。
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布游戏' }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '发布游戏' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
|
||||
@@ -685,7 +680,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
|
||||
});
|
||||
|
||||
test('已有绑定时进入更新模式,回填线上资料并按项目版本提交原 gameId', async () => {
|
||||
test('已有绑定时进入更新模式,回填线上资料并按派生项目版本提交原 gameId', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'update',
|
||||
@@ -732,7 +727,8 @@ describe('GameDistributionPublishPanel', () => {
|
||||
});
|
||||
const { onPublished } = renderPanel();
|
||||
|
||||
// 回填线上冻结资料;项目版本按绑定回填成线上最近提交的版本标签(旧清单缺字段时用它)。
|
||||
// 回填线上冻结资料;项目版本按工程内部版本派生(本 fixture 没有 versions → 首版 1),
|
||||
// 平台绑定的线上版本号只用于展示,不参与派生。
|
||||
await screen.findByRole('button', { name: '更新游戏' });
|
||||
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
|
||||
'value',
|
||||
@@ -742,14 +738,12 @@ describe('GameDistributionPublishPanel', () => {
|
||||
'value',
|
||||
'线上简介',
|
||||
);
|
||||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2');
|
||||
expect(screen.getByText('v2')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/线上最近提交 v2 · 审核中/u),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/版本标签不要求递增/u),
|
||||
).not.toBeNull();
|
||||
const versionInput = screen.getByLabelText('项目版本');
|
||||
expect(versionInput).toHaveProperty('value', '1');
|
||||
expect(versionInput).toHaveProperty('readOnly', true);
|
||||
expect(screen.getByText('v1')).not.toBeNull();
|
||||
expect(screen.getByText(/线上最近提交 v2 · 审核中/u)).not.toBeNull();
|
||||
expect(screen.getByText(/来自 AGC 内部版本(版本 1)/u)).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||||
await waitFor(() =>
|
||||
@@ -757,8 +751,8 @@ describe('GameDistributionPublishPanel', () => {
|
||||
);
|
||||
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
|
||||
expect(args?.gameId).toBe('game_9');
|
||||
// 同一个版本标签可以重复提交:不做递增改写,提交的就是清单里的 projectVersion。
|
||||
expect(args?.versionNumber).toBe(2);
|
||||
// 提交的就是派生出来的内部版本序数,与线上最近提交的 v2 无关。
|
||||
expect(args?.versionNumber).toBe(1);
|
||||
expect(args?.expectedPublicationRevision).toBe(7);
|
||||
// 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。
|
||||
expect(args?.metadata?.coverAssetId).toBe('asset_cover_old');
|
||||
@@ -775,7 +769,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(onPublished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('清单里的项目版本优先于线上版本,回退到更小版本也能提交', async () => {
|
||||
test('项目版本由工程内部版本条数派生,线上版本号与遗留字段都不参与', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'update',
|
||||
@@ -795,7 +789,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||||
gameId: 'game_9',
|
||||
versionId: 'gamever_6',
|
||||
versionNumber: 2,
|
||||
versionNumber: 4,
|
||||
status: 'pending_review',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
@@ -804,13 +798,40 @@ describe('GameDistributionPublishPanel', () => {
|
||||
renderPanel({
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
projectVersion: 2,
|
||||
// 4 条内部版本 → 标签 4;遗留 projectVersion 6 与线上 v5 都不得影响它。
|
||||
versions: [
|
||||
{
|
||||
versionId: 'initial-13',
|
||||
parentVersionId: null,
|
||||
projectRevision: 13,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-22',
|
||||
parentVersionId: 'initial-13',
|
||||
projectRevision: 22,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-42',
|
||||
parentVersionId: 'agent-22',
|
||||
projectRevision: 42,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-46',
|
||||
parentVersionId: 'agent-42',
|
||||
projectRevision: 46,
|
||||
},
|
||||
],
|
||||
projectVersion: 6,
|
||||
} as unknown as GameCreationAppManifest,
|
||||
});
|
||||
await screen.findByRole('button', { name: '更新游戏' });
|
||||
|
||||
// 清单写过的项目版本胜出:不被线上最大版本号悄悄改成 5 或 6。
|
||||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2');
|
||||
const input = screen.getByLabelText('项目版本');
|
||||
expect(input).toHaveProperty('value', '4');
|
||||
expect(input).toHaveProperty('readOnly', true);
|
||||
expect(screen.getByText('v4')).not.toBeNull();
|
||||
expect(screen.getByText(/来自 AGC 内部版本(版本 4)/u)).not.toBeNull();
|
||||
expect(screen.getByText(/线上最近提交 v5 · 已公开/u)).not.toBeNull();
|
||||
await selectCover();
|
||||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||||
|
||||
@@ -819,52 +840,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
);
|
||||
expect(
|
||||
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].versionNumber,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test('修改项目版本后失焦写回清单并回调工作台', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
const savedManifest = {
|
||||
...MANIFEST,
|
||||
projectVersion: 4,
|
||||
} as unknown as GameCreationAppManifest;
|
||||
vi.mocked(saveGameDistributionProjectVersion).mockResolvedValue(
|
||||
savedManifest,
|
||||
);
|
||||
const onManifestUpdated = vi.fn();
|
||||
renderPanel({ onManifestUpdated });
|
||||
await screen.findByLabelText('项目版本');
|
||||
|
||||
const input = screen.getByLabelText('项目版本');
|
||||
fireEvent.change(input, { target: { value: '4' } });
|
||||
expect(screen.getByText('v4')).not.toBeNull();
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveGameDistributionProjectVersion).toHaveBeenCalledWith({
|
||||
projectPath: '/tmp/authorized-game',
|
||||
projectVersion: 4,
|
||||
}),
|
||||
);
|
||||
expect(onManifestUpdated).toHaveBeenCalledWith(savedManifest);
|
||||
});
|
||||
|
||||
test('项目版本非法时失焦回退到已保存值且不写盘', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
const input = await screen.findByLabelText('项目版本');
|
||||
expect(input).toHaveProperty('value', '1');
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(
|
||||
await screen.findByText('项目版本必须是大于 0 的整数'),
|
||||
).not.toBeNull();
|
||||
expect(saveGameDistributionProjectVersion).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '1');
|
||||
});
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 【实施计划】AGC 发布版本以工程内部版本为准
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Milestone | `docs/project-memory/plans/【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md` |
|
||||
| Status | proposed |
|
||||
| Owner | Genarrative Agent |
|
||||
| 关联 | PR #565(`feat/game-works-management`);取代 `【里程碑/实施计划】AGC已发布游戏版本更新-2026-10-02` 中「用户可编辑发行版本标签」的口径 |
|
||||
|
||||
## 修改边界(不动的东西)
|
||||
|
||||
- 不改 `server-rs/crates/{spacetime-module,module-game-distribution,api-server,spacetime-client}`:服务端 `versionNumber` 语义与 `publicationRevision` CAS 保持不变。
|
||||
- 不改 `apps/admin-web/**`、不改 `src-tauri/src/project/export.rs`(两者正被其它 worker 修改)。
|
||||
- 不删除本地字段 `projectVersion`(Rust DTO 带 `deny_unknown_fields`:`server-rs/crates/shared-contracts/src/game_creation_app.rs:1121`)。
|
||||
|
||||
## 修改顺序
|
||||
|
||||
1. 文档:主规范改写 → 本里程碑 → 本实现计划 → `decision-log` 条目 → 两份 2026-10-02 计划文档的取代标注。
|
||||
2. 契约:TS 解析器改为内部版本派生。
|
||||
3. Rust:拆掉两条 `project_version` 写链路 + 删除手改命令。
|
||||
4. 渲染层:面板改只读派生、删失焦写回与 `onManifestUpdated`。
|
||||
5. service:删保存函数、改默认值与注释。
|
||||
6. 测试改写 + 门禁全跑。
|
||||
|
||||
## 逐条改动(文件 → 位置 → 改什么 → 验收)
|
||||
|
||||
| # | 文件 | 位置 | 改什么 | 验收 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | `packages/shared/src/contracts/gameCreationApp.ts` | `:1001-1013` | `resolveGameCreationAppProjectVersion` 去掉 `binding` 形参,返回 `versions.length > 0 ? versions.length : GAME_CREATION_APP_FIRST_PROJECT_VERSION`;注释改为「唯一来源 = 工程内部版本」 | `npm run agc:typecheck`;面板/服务用例 |
|
||||
| 2 | 同上 | `:1034-1037` | `projectVersion?: number \| null` 保留,注释改「遗留位:旧清单兼容读,不再读写」 | 静态检查 |
|
||||
| 3 | `server-rs/crates/shared-contracts/src/game_creation_app.rs` | `:1107-1118`、`:1090-1096` | 删 `resolve_game_creation_app_project_version` 与 `GAME_CREATION_APP_FIRST_PROJECT_VERSION` / `is_game_creation_app_project_version`;对应单测改为「字段被忽略」 | `cargo test -p shared-contracts` |
|
||||
| 4 | 同上 | `:1146-1150` | `project_version` 字段保留,注释改「遗留:仅为旧清单可解析,不再读写」 | 同上 |
|
||||
| 5 | `apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs` | `:24-27` | 去掉解析器 import | `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_distribution_publish` |
|
||||
| 6 | 同上 | `:536-541`(`write_publication_binding`) | 删除 `manifest.project_version = Some(resolve(...))`,改为置 `None`;保留 `manifest.publication = Some(binding.clone())` | 同上 + `jq` 自检 |
|
||||
| 7 | 同上 | `:1531-1535`(发布成功回写) | 删除 `manifest.project_version = Some(version.version_number)`,改为置 `None`;保留 publication 回写 | 同上 |
|
||||
| 8 | 同上 | `:944-965` | 删除 `update_game_distribution_project_version` 命令 | 编译 + `rg` 零命中 |
|
||||
| 9 | `apps/ai-game-creator-shell/src-tauri/src/desktop.rs` | `:614` | 从 invoke handler 清单删除该命令 | 编译 |
|
||||
| 10 | `apps/ai-game-creator-shell/scripts/check-config.mjs` | `:138` | 从命令注册表删除 `'update_game_distribution_project_version'` | `npm run agc:typecheck` |
|
||||
| 11 | `apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` | `:148-167` | 删除 `saveGameDistributionProjectVersion` | typecheck + 用例 |
|
||||
| 12 | 同上 | `:329-337`、`:362-363` | 注释改为「唯一来源 = 工程内部版本」;默认值 `args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest)` | 同上 |
|
||||
| 13 | `apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx` | `:129-136`、`:283-288`、`:331-332`、`:397-403`、`:683-708`、`:731-734`、`:804` | 删 `parseProjectVersion`、`projectVersion`/`savedProjectVersion` state、`projectVersionSaveRequestRef`、`handleProjectVersionCommit` 与三处 setState;新增派生值 `useMemo(() => resolveGameCreationAppProjectVersion(manifest), [manifest.versions])`;提交改为 `versionNumber: projectVersion` | 面板用例 |
|
||||
| 14 | 同上 | `:947-1000` | 「项目版本」输入框改 `readOnly`(保留 `aria-label="项目版本"`),删 `onChange`/`onBlur`/`disabled`;提示语改为「来自 AGC 内部版本(版本 N)…」 | 面板用例 |
|
||||
| 15 | 同上 | `:242-243`、`:700` | 删除 `onManifestUpdated` prop 与唯一调用点 | typecheck |
|
||||
| 16 | `apps/ai-game-creator-shell/src/App.tsx` | `:1816`、`:1951` | 删除两处 `onManifestUpdated={setManifest}` | typecheck |
|
||||
| 17 | `apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx` | `:735-761`、`:778-823`、`:825-868` | 前两条改为「标签 = 内部版本数」;后两条(失焦写回 / 非法回退)整块删除;fixture `MANIFEST` 补非空 `versions` | vitest |
|
||||
| 18 | `apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts` | `:319-420` | 删 `saveGameDistributionProjectVersion` 用例块;默认值断言改为「无 `versions` → 1;N 条 → N」 | vitest |
|
||||
| 19 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` | `:97`、`:99`、`:106` + 新增两条 | 见主规范新条款 | `npm run check:doc-index` |
|
||||
| 20 | `docs/project-memory/plans/【实施计划】AGC已发布游戏版本更新-2026-10-02.md` | `:11`、`:23` | 行内追加「被 2026-10-03 里程碑取代」标注 | 阅读 |
|
||||
| 21 | `docs/project-memory/plans/【里程碑】AGC已发布游戏版本更新-2026-10-02.md` | `:27-30` | 同上,逐条标注被取代/收窄 | 阅读 |
|
||||
| 22 | `docs/project-memory/shared-memory/decision-log.md` | 最新条目区 | 追加本决策条目 | `npm run check:doc-index` |
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts \
|
||||
apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx \
|
||||
apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
|
||||
npm run agc:typecheck
|
||||
cd server-rs && cargo test -p shared-contracts
|
||||
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_distribution_publish
|
||||
npm run check:doc-index
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
# 静态断言:命令与字段来源都已拆干净
|
||||
rg -n "update_game_distribution_project_version" . # 期望 0 命中
|
||||
rg -n "projectVersion" apps/ai-game-creator-shell/src packages/shared/src # 期望仅剩遗留声明/注释
|
||||
```
|
||||
|
||||
## 数据迁移
|
||||
|
||||
- 无迁移脚本。读路径立即忽略 `projectVersion`;改动点 6/7 会在下一次「平台回读绑定 / 发布成功」时把它置空,`skip_serializing_if` 使该键随下次落盘消失。
|
||||
- 平台侧历史版本行不做任何改写(不可变留痕)。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 回滚点:只动 AGC 客户端 + 本地契约 + 文档;回滚 = 还原提交,服务端数据无副作用。
|
||||
- 旧客户端降级:清单里 `projectVersion` 被置空后,旧版 AGC 会回落到绑定 `latestVersionNumber`(旧行为重现),仅影响本机,需写 release note。
|
||||
- 派生标签会因「截尾删除版本」而回退(删素材连带删版本),平台接受该回退,但会产生「同一标签先后指向不同包」——这属平台现行契约允许的形态(用户手动重复提交同版本同样会产生)。
|
||||
|
||||
## 不做(本里程碑之外)
|
||||
|
||||
1. 「线上最近提交 vN」加「(平台)」字样(纯文案)。
|
||||
2. 平台历史版本号对齐(**明确不做**:不可变历史行无改写入口,会破坏审核留痕)。
|
||||
3. 后台/作者侧的「同标签多实例」可辨识列(落在 `apps/admin-web/**`,正被其它 worker 修改)。
|
||||
4. 「截尾删除致标签回退」的产品兜底(引入单调计数器 = 新字段 = SpacetimeDB schema 变更,须单独评审)。
|
||||
5. `edit-<operationId>` 形状的资源编辑版本是否计入内部版本序数(当前计入,与资源卡口径一致)。
|
||||
6. 旧客户端降级提示写 release note。
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 修改边界
|
||||
|
||||
- `packages/shared/src/contracts/gameCreationApp.ts`:本地唯一 `projectVersion` 和 `publication` 绑定,不混入内部编辑迭代 `versions[]`。
|
||||
- `packages/shared/src/contracts/gameCreationApp.ts`:本地唯一 `projectVersion` 和 `publication` 绑定,不混入内部编辑迭代 `versions[]`。**(2026-10-03 取代:发布标签改为由工程内部 `versions[]` 派生、只读;`projectVersion` 降级为不再读写的遗留兼容位,见 `【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md`。)**
|
||||
- `packages/shared/src/contracts/gameDistribution.ts` 与 Rust shared-contracts:创建版本请求支持正整数 `versionNumber`,允许重复标签提交。
|
||||
- `apps/ai-game-creator-shell`:绑定回读、首次发布/更新 UI、项目版本输入、原 gameId 新提交、结果回写。
|
||||
- `api-server`、`spacetime-client`、`spacetime-module`:创建版本事务只校验正整数;同 gameId/同 versionNumber 生成新 versionId,publicationRevision 继续 CAS。
|
||||
@@ -20,7 +20,7 @@
|
||||
2. 创建版本事务不再要求大于 max;未传时保留旧客户端自动递增,传入时校验正整数,并按同版本最新有效提交处理 pending 替代。
|
||||
3. AGC 发布绑定保存 gameId、最新 versionId、状态和 revision;按账号和 API origin 隔离绑定。
|
||||
4. AGC 打开项目回读已有作品;旧项目通过 localProjectId 恢复关联。
|
||||
5. 发布面板编辑 projectVersion,区分首次发布/更新,允许重复提交和回退,不再存在独立 targetVersion。
|
||||
5. 发布面板编辑 projectVersion,区分首次发布/更新,允许重复提交和回退,不再存在独立 targetVersion。**(2026-10-03 取代:面板不再编辑版本标签,标签由工程内部 `versions[]` 派生且只读;仍区分首次发布/更新。)**
|
||||
6. Rust 发布 facade 在更新模式跳过 create game,使用原 gameId 创建新 version;幂等账本把目标 projectVersion、资料和提交意图纳入发布意图。
|
||||
7. 验证重复提交同版本、回退版本、响应丢失、切换账号、publicationRevision 冲突和旧公开版本继续可玩。
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 【里程碑】AGC 发布版本以工程内部版本为准
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | proposed |
|
||||
| Date | 2026-10-03 |
|
||||
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(「身份、状态、审核与更新」章) |
|
||||
|
||||
## 目标
|
||||
|
||||
让 AGC 发布面板的「项目版本」只有一个权威来源:AGC 工程内部版本记录 `manifest.versions[]`(即资源总览「项目版本」栏目里的版本卡)。标签等于当前存活的内部版本条数(最新卡的「版本 N」),只读、不可手填、不被平台 `version_number` 回填或覆盖,与 `publicationRevision` 无关。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不改服务端能力:`publicationRevision` 继续严格 CAS;平台 `versionNumber` 继续接受正整数、重复与回退(网页端与历史客户端依赖该兼容口径)。
|
||||
- 不新增或修改 SpacetimeDB 表、字段与 procedure;`game_distribution_version.version_number` 不动。
|
||||
- 不重写平台历史版本行;不改公开地址、审核状态机与发行网关。
|
||||
- 不做版本历史页、版本对比、一键回滚 UI。
|
||||
- 不删除本地清单字段 `projectVersion`(Rust DTO 带 `deny_unknown_fields`,删除会让存量清单解析失败),它降级为不再读写的兼容位。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 发布面板显示的「项目版本」等于当前 `manifest.versions` 的条数(`versions` 为空时为 1),与平台 `version_number`、`publicationRevision` 均无关。
|
||||
- [ ] 面板不提供任何修改该值的入口;提交时 `versionNumber` 逐字等于该派生值。
|
||||
- [ ] 打开已有线上作品(线上最近提交 v6、内部版本 4 条)时,面板显示 v4,且仍并列显示「线上最近提交 v6 · 审核中」。
|
||||
- [ ] AGC 任何路径都不再写入 `projectVersion`(含发布成功回写、绑定回读回填、面板失焦写回)。
|
||||
- [ ] 存量清单里已有的 `projectVersion` 值不影响展示与提交。
|
||||
- [ ] 内部版本被截尾删除(长度 4 → 3)后,面板显示 v3 且能正常提交,不需要任何本地冲突处理。
|
||||
|
||||
## 依赖
|
||||
|
||||
- 无外部依赖;与「AGC 已发布游戏版本更新(2026-10-02)」是**取代**关系(见该文档内的取代标注)。
|
||||
@@ -23,9 +23,9 @@
|
||||
|
||||
- [ ] 首次发布完成后项目清单保存 `gameId`、`versionId`、版本号、状态和 `publicationRevision`。
|
||||
- [ ] 重新打开同一项目显示“更新游戏”,不再显示“创建平台游戏”。
|
||||
- [ ] 项目清单只有一个用户发行版本字段,发布面板编辑它,不存在独立 targetVersion。
|
||||
- [ ] 项目清单只有一个用户发行版本字段,发布面板编辑它,不存在独立 targetVersion。**(2026-10-03 取代:发布标签改由 AGC 工程内部 `versions[]` 派生且只读,面板不再编辑;见 `【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md`。)**
|
||||
- [ ] 同一 `gameId`、同一 `versionNumber` 可以重复提交,生成新的 `versionId`,不覆盖旧公开实例。
|
||||
- [ ] 项目版本可以低于线上最新版本,服务端允许回退标签,但仍校验为正整数。
|
||||
- [ ] 项目版本可以低于线上最新版本,服务端允许回退标签,但仍校验为正整数。**(2026-10-03 收窄:平台侧继续成立;AGC 侧不再由用户手填回退,派生标签只在内部版本被截尾删除时变小。)**
|
||||
- [ ] 同版本旧 pending 提交被取消或被最新提交替代,审核队列不产生重复有效任务。
|
||||
- [ ] `publicationRevision` 仍用于并发 CAS,与用户版本号规则独立。
|
||||
- [ ] 更新使用同一 `gameId`,只增加一个新 `versionId`,公开地址保持不变。
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径
|
||||
|
||||
- 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。
|
||||
- 决策:AGC 发布面板的「项目版本」改为**由 AGC 工程内部版本记录 `versions[]` 派生**(标签 = 当前存活内部版本条数,即最新版本卡的「版本 N」,空数组取 1),**只读**,且不得被平台 `version_number` 回填或覆盖、不得读取 `publicationRevision`。平台侧 `versionNumber` 语义不变(正整数、允许重复与回退;`None` 仍自动 `max+1` 以兼容网页端与历史客户端)。本地字段 `projectVersion` 降级为遗留兼容位:保留可解析、不再读写(Rust DTO 带 `deny_unknown_fields`,删字段会让存量清单解析失败)。
|
||||
- 取代(逐条):
|
||||
1. `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md:97,99`(原「项目清单保存唯一用户发行版本 `projectVersion`」「AGC 发布面板直接编辑项目清单的 `projectVersion`」)→ 已就地改写为「派生只读 + 遗留兼容位」。
|
||||
2. 同文件原 `:105`(「允许用户修改项目版本标签、重复提交同版本和回退到旧版本标签」)→ 已就地改写为「用户不再编辑版本标签;回退与重复仍由平台侧接受」。
|
||||
3. `docs/project-memory/plans/【实施计划】AGC已发布游戏版本更新-2026-10-02.md:11,23`(「本地唯一 `projectVersion`…不混入内部编辑迭代 `versions[]`」「发布面板编辑 `projectVersion`」)→ 行内追加取代标注。
|
||||
4. `docs/project-memory/plans/【里程碑】AGC已发布游戏版本更新-2026-10-02.md:27-30` 的验收项「项目清单只有一个用户发行版本字段,发布面板编辑它」被取代;「项目版本可以低于线上最新版本」在平台侧继续成立,AGC 侧口径改为「派生标签可能因截尾删除而变小」。
|
||||
- 边界:不改 SpacetimeDB 表/字段/procedure,不改 `publicationRevision` CAS,不改公开地址与审核状态机,不重写平台历史版本行,不做版本历史/回滚 UI。AGC 工程内部版本 `versions` 只能追加或按显式放行删除一个后缀(删素材连带删版本),因此派生标签不保证单调;平台必须继续接受回退标签。
|
||||
- 影响范围:`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs`、`apps/ai-game-creator-shell/src-tauri/src/{game_distribution_publish.rs,desktop.rs}`、`apps/ai-game-creator-shell/src/{services/gameDistributionPublish.ts,components/game-distribution/GameDistributionPublishPanel.tsx,App.tsx}`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/**`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/project-memory/plans/`。
|
||||
- 验证方式:`npx vitest run …gameDistributionPublish*.test.*`、`npm run agc:typecheck`(含 `scripts/check-config.mjs`)、`cargo test -p shared-contracts`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_distribution_publish`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check`;真机:内部版本 4 条、线上最近提交 v6 的项目打开面板显示 v4 且只读,提交 `versionNumber: 4`。
|
||||
- 关联文档:[平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md)、[里程碑](【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md)。
|
||||
|
||||
## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359)
|
||||
|
||||
- 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。
|
||||
|
||||
@@ -976,40 +976,25 @@ export interface GameCreationAppPublicationBinding {
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
/** 项目版本标签的唯一合法形状:正整数,且能安全走 JSON number。 */
|
||||
export function isGameCreationAppProjectVersion(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** 项目版本标签的建议默认值:没有任何线上记录时的首个版本。 */
|
||||
/** 项目版本标签的建议默认值:没有任何内部版本记录时的首个版本。 */
|
||||
export const GAME_CREATION_APP_FIRST_PROJECT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 项目版本标签的有效值。
|
||||
* 发布面板展示的「项目版本」的唯一权威值:AGC 工程内部版本序数。
|
||||
*
|
||||
* 新清单直接用写下来的 `projectVersion`;旧清单缺这个字段时按发布绑定回填——线上已有版本号
|
||||
* (`latestVersionNumber > 0`)就用它,否则用首版 1。
|
||||
* 唯一来源是清单里的 `versions[]`(AGC 工程内部版本记录,即资源总览「项目版本」栏目里的
|
||||
* 版本卡):标签等于当前存活的内部版本条数,也就是最新内部版本卡的「版本 N」;没有版本
|
||||
* 记录时用首版 1。
|
||||
*
|
||||
* `binding` 必须已经按**当前会话**(`accountId` + `apiBaseUrl`)与 `gameId` 隔离:调用方传
|
||||
* 别的账号 / 别的环境的绑定,就会把该项目的版本标签回填成别人的线上版本号。原生回读与发布
|
||||
* 写回都遵守这条隔离,渲染层只消费原生已经隔离过的绑定。
|
||||
* 这条值**只读**,且不由平台侧任何字段推导:不读发布绑定里的 `latestVersionNumber`,
|
||||
* 也不读 `publicationRevision`。平台 `versionNumber` 与本地标签是两套独立概念,并列展示、
|
||||
* 互不推导。清单里遗留的 `projectVersion` 只做兼容解析,不参与本函数。
|
||||
*/
|
||||
export function resolveGameCreationAppProjectVersion(
|
||||
manifest: Pick<GameCreationAppManifest, 'projectVersion'>,
|
||||
binding?: GameCreationAppPublicationBinding | null,
|
||||
manifest: Pick<GameCreationAppManifest, 'versions'>,
|
||||
): number {
|
||||
if (isGameCreationAppProjectVersion(manifest.projectVersion)) {
|
||||
return manifest.projectVersion;
|
||||
}
|
||||
const latest = binding?.latestVersionNumber;
|
||||
if (isGameCreationAppProjectVersion(latest)) {
|
||||
return latest;
|
||||
}
|
||||
return GAME_CREATION_APP_FIRST_PROJECT_VERSION;
|
||||
const count = manifest.versions?.length ?? 0;
|
||||
return count > 0 ? count : GAME_CREATION_APP_FIRST_PROJECT_VERSION;
|
||||
}
|
||||
|
||||
export interface GameCreationAppManifest {
|
||||
@@ -1025,14 +1010,10 @@ export interface GameCreationAppManifest {
|
||||
godotProjectRoot?: string | null;
|
||||
cocosProjectRoot?: string | null;
|
||||
/**
|
||||
* 唯一用户发行版本标签(`项目版本 vN`),正整数,不需要递增。
|
||||
* 遗留位:历史清单写过的用户发行版本标签,仅为兼容旧清单可解析而保留。
|
||||
*
|
||||
* 同一个 `gameId` 的同一个版本标签可以反复提交:每次具体包/资料/审核提交由不可变
|
||||
* `versionId` 区分,因此这里允许重复与回退(低于线上当前版本)。它**不是**内部编辑迭代
|
||||
* `versions[].projectRevision`,也不是 semver 字符串。
|
||||
*
|
||||
* 旧清单没有这个字段:读取方按当前账号 / origin / gameId 隔离后的发布绑定回填(见
|
||||
* `resolveGameCreationAppProjectVersion`),发布或改版本标签时由原生写回。
|
||||
* 它**不再**是发行版本来源:读取方一律用 `resolveGameCreationAppProjectVersion` 按工程
|
||||
* 内部版本 `versions[]` 派生,原生也不再写入或回填这个字段(下一次落盘时会被抹掉)。
|
||||
*/
|
||||
projectVersion?: number | null;
|
||||
/** 平台发布绑定;缺失即该项目尚未发布过。 */
|
||||
|
||||
@@ -169,7 +169,7 @@ export type GameDistributionCreateGameRequest = {
|
||||
export type GameDistributionCreateVersionRequest = {
|
||||
localProjectId?: string | null;
|
||||
/**
|
||||
* 用户可见的正整数版本标签(AGC 里就是 `projectVersion`)。
|
||||
* 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
|
||||
*
|
||||
* 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的
|
||||
* `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。
|
||||
|
||||
@@ -1087,36 +1087,6 @@ pub struct GameCreationAppPublicationBinding {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// 项目版本标签的建议默认值:没有任何线上记录时的首个版本。
|
||||
pub const GAME_CREATION_APP_FIRST_PROJECT_VERSION: u64 = 1;
|
||||
|
||||
/// 项目版本标签的唯一合法形状:正整数(`0` 永远是非法标签,不表示"未设置")。
|
||||
pub fn is_game_creation_app_project_version(value: u64) -> bool {
|
||||
value > 0
|
||||
}
|
||||
|
||||
/// 项目版本标签的有效值。
|
||||
///
|
||||
/// 新清单直接用写下来的 `project_version`;旧清单缺这个字段时按发布绑定回填——线上已有版本号
|
||||
/// (`latest_version_number > 0`)就用它,否则用首版 `1`。`0` 与缺失同义(历史上没有任何写入口
|
||||
/// 会落 0),所以两者都走回填分支。
|
||||
///
|
||||
/// `binding` 必须已经按**当前会话**(`account_id` + `api_base_url`)与 `game_id` 隔离:传别的
|
||||
/// 账号 / 别的环境的绑定,就会把该项目的版本标签回填成别人的线上版本号。原生回读与发布写回都
|
||||
/// 遵守这条隔离。
|
||||
pub fn resolve_game_creation_app_project_version(
|
||||
project_version: Option<u64>,
|
||||
binding: Option<&GameCreationAppPublicationBinding>,
|
||||
) -> u64 {
|
||||
if let Some(project_version) = project_version.filter(|value| *value > 0) {
|
||||
return project_version;
|
||||
}
|
||||
binding
|
||||
.and_then(|binding| binding.latest_version_number)
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(GAME_CREATION_APP_FIRST_PROJECT_VERSION)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct GameCreationAppManifest {
|
||||
@@ -1138,14 +1108,11 @@ pub struct GameCreationAppManifest {
|
||||
pub godot_project_root: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cocos_project_root: Option<String>,
|
||||
/// 唯一用户发行版本标签(`项目版本 vN`),正整数,不需要递增。
|
||||
/// 遗留位:历史清单写过的用户发行版本标签,仅为兼容旧清单可解析而保留。
|
||||
///
|
||||
/// 同一个 gameId 的同一个版本标签可以反复提交:每次具体包/资料/审核提交由不可变
|
||||
/// `versionId` 区分,所以这里允许重复与回退(低于线上当前版本)。它不是内部编辑迭代
|
||||
/// `versions[].project_revision`,也不是 semver 字符串。
|
||||
///
|
||||
/// 旧清单没有这个字段:读取方用 [`resolve_game_creation_app_project_version`] 按当前会话
|
||||
/// 隔离后的发布绑定回填,发布或改版本标签时由原生写回。
|
||||
/// 它**不再**是发行版本来源:发布面板的「项目版本」由 AGC 工程内部版本记录 `versions[]`
|
||||
/// 派生(标签 = 存活内部版本条数),原生不再写入或回填这个字段。字段本身不能删除:
|
||||
/// 本结构体带 `deny_unknown_fields`,删掉它会让所有含该键的存量清单解析失败。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_version: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -2223,23 +2190,30 @@ mod tests {
|
||||
assert_eq!(payload["godotProjectRoot"], json!("game-source"));
|
||||
}
|
||||
|
||||
/// 项目版本标签是清单里唯一的用户发行版本:线上线同一字段名(`projectVersion`),
|
||||
/// 缺省时按已隔离的发布绑定回填,且绝不落 0。
|
||||
/// `projectVersion` 降级为遗留兼容位:存量清单里带这个键时必须照旧可读、可往返
|
||||
/// (本结构体带 `deny_unknown_fields`,拒掉它等于让老项目打不开),但它不再是发行版本
|
||||
/// 来源——Rust 侧不再有任何解析或回填入口,发布面板的「项目版本」改由工程内部版本
|
||||
/// `versions[]` 派生。
|
||||
#[test]
|
||||
fn manifest_project_version_round_trips_and_backfills_from_binding() {
|
||||
fn manifest_project_version_is_a_legacy_compatibility_field() {
|
||||
let manifest = new_game_creation_app_manifest("project-1", "像素动作原型");
|
||||
let payload = serde_json::to_value(&manifest).expect("manifest should serialize");
|
||||
assert!(
|
||||
payload.get("projectVersion").is_none(),
|
||||
"旧清单语义:没有版本标签时不写字段,由发布/回读回填"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(manifest.project_version, None),
|
||||
GAME_CREATION_APP_FIRST_PROJECT_VERSION
|
||||
"原生不再写入版本标签,新清单不带这个键"
|
||||
);
|
||||
|
||||
let mut published = manifest.clone();
|
||||
published.project_version = Some(4);
|
||||
let mut legacy = payload.clone();
|
||||
legacy["projectVersion"] = json!(6);
|
||||
let parsed: GameCreationAppManifest =
|
||||
serde_json::from_value(legacy).expect("含遗留 projectVersion 的存量清单必须仍可解析");
|
||||
assert_eq!(parsed.project_version, Some(6));
|
||||
assert!(
|
||||
parsed.publication.is_none(),
|
||||
"遗留键与发布绑定互相独立,不会凭它推导任何东西"
|
||||
);
|
||||
|
||||
let mut published = parsed.clone();
|
||||
published.publication = Some(GameCreationAppPublicationBinding {
|
||||
account_id: "user-1".to_string(),
|
||||
api_base_url: "https://dev.test".to_string(),
|
||||
@@ -2250,37 +2224,13 @@ mod tests {
|
||||
status: Some("pending_review".to_string()),
|
||||
});
|
||||
let payload = serde_json::to_value(&published).expect("published manifest serializes");
|
||||
assert_eq!(payload["projectVersion"], json!(4));
|
||||
assert_eq!(payload["projectVersion"], json!(6));
|
||||
assert_eq!(payload["publication"]["latestVersionNumber"], json!(4));
|
||||
assert_eq!(
|
||||
serde_json::from_value::<GameCreationAppManifest>(payload)
|
||||
.expect("project version must round-trip")
|
||||
.expect("legacy project version must round-trip")
|
||||
.project_version,
|
||||
Some(4)
|
||||
);
|
||||
|
||||
// 缺失或 0(不是合法标签)都走回填:线上有版本号就用它,否则首版 1。
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(None, published.publication.as_ref()),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(Some(0), published.publication.as_ref()),
|
||||
4
|
||||
);
|
||||
let mut without_latest = published.publication.clone();
|
||||
without_latest
|
||||
.as_mut()
|
||||
.expect("binding")
|
||||
.latest_version_number = Some(0);
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(None, without_latest.as_ref()),
|
||||
GAME_CREATION_APP_FIRST_PROJECT_VERSION
|
||||
);
|
||||
// 清单里写过的值胜出:重开项目不能被线上版本号悄悄改写。
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(Some(2), published.publication.as_ref()),
|
||||
2
|
||||
Some(6)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ pub struct GameDistributionCreateGameRequest {
|
||||
pub struct GameDistributionCreateVersionRequest {
|
||||
#[serde(default)]
|
||||
pub local_project_id: Option<String>,
|
||||
/// 用户可见的正整数版本标签(AGC 里就是 `projectVersion`)。
|
||||
/// 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
|
||||
///
|
||||
/// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的
|
||||
/// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端
|
||||
|
||||
Reference in New Issue
Block a user