feat(游戏共创): 发布自动带改编声明,上架可选共创授权档位(AGC 面板三态)

- 改编声明:`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 钉住请求体形状
  (带/不带档位、声明不由渲染层提交)与面板三态受控行为、更新模式只读。
This commit is contained in:
2026-10-05 02:39:31 +08:00
parent c5564e59ed
commit e3bae05351
7 changed files with 283 additions and 10 deletions
@@ -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',
@@ -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]
@@ -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<GameDistributionPublishMetadata['category']>('其他');
/**
* 上架时的共创授权档位(三态)。默认「禁止共创」,与库表默认和旧客户端行为一致。
*
* 只在**首次发布**随创建作品请求生效:作品创建后档位只能单向提升(网页端「我的作品」),
* 因此更新发布时这一栏只读,避免让作者以为改这里能改线上档位。
*/
const [forkAuthorization, setForkAuthorization] =
useState<GameDistributionForkAuthorization>(
GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION,
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<GameDistributionPublishResult | null>(
@@ -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({
))}
</select>
</label>
{/*
授权共创三态单选。与分类一样是「label + 受控控件」单元,但三态必须都能看见,
因此用 radio 组而不是 select;文案直接复用平台契约里的标签常量,不另造词。
*/}
<fieldset
className={
'game-distribution-publish-panel__fork-authorization' +
(forkAuthorizationLocked ? ' is-locked' : '')
}
>
<legend>授权共创</legend>
<div
className="game-distribution-publish-panel__fork-options"
role="radiogroup"
aria-label="授权共创"
>
{GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.map((value) => (
<label key={value}>
<input
type="radio"
name="game-distribution-fork-authorization"
value={value}
checked={forkAuthorization === value}
disabled={forkAuthorizationLocked}
onChange={() => setForkAuthorization(value)}
/>
<span>
{GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[value]}
</span>
</label>
))}
</div>
<p className="game-distribution-publish-panel__fork-hint">
{forkAuthorizationLocked
? '共创授权只在首次发布时生效;上架后只能在网页端「我的作品」单向提升。'
: '开启后可被他人复刻改编(仅能拿到已构建成品,用于试玩与参考);上架后只能单向提升,不能撤销。'}
</p>
</fieldset>
<label>
<span className="game-distribution-publish-panel__version-label">
项目版本
@@ -3,12 +3,14 @@ import {
type GameCreationAppPublicationBinding,
resolveGameCreationAppProjectVersion,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameDistributionCategory,
GameDistributionCreateGameRequest,
GameDistributionDeviceSupport,
GameDistributionInputMode,
GameDistributionOrientation,
import {
GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION,
type GameDistributionCategory,
type GameDistributionCreateGameRequest,
type GameDistributionDeviceSupport,
type GameDistributionForkAuthorization,
type GameDistributionInputMode,
type GameDistributionOrientation,
} from '../../../../packages/shared/src/contracts/gameDistribution';
import { resolveTauriInvoke } from '../app/tauri';
import type { TauriInvoke } from '../app/types';
@@ -30,6 +32,11 @@ export type GameDistributionPublishMetadata = {
};
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
/**
* 上架时的共创授权档位(三态单选,默认禁止共创);只在**首次发布**生效——创建作品以后
* 只能通过网页端「我的作品」单向提升,更新版本不会改动它。
*/
forkAuthorization?: GameDistributionForkAuthorization;
};
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
@@ -265,6 +272,8 @@ function normalizeMetadata(
},
inputModes: metadata?.inputModes ?? ['keyboard', 'mouse', 'touch'],
orientation: metadata?.orientation ?? 'responsive',
forkAuthorization:
metadata?.forkAuthorization ?? GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION,
};
}
@@ -284,6 +293,13 @@ function toCreateGameRequest(
deviceSupport: metadata.deviceSupport,
inputModes: metadata.inputModes,
orientation: metadata.orientation,
// 上架时的共创授权档位:渲染层只提交用户选择(或默认值),省略与显式传 `forbidden`
// 在服务端等价,因此这里始终显式提交,面板状态与请求体一一对应。
forkAuthorization:
metadata.forkAuthorization ?? GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION,
// 改编来源声明**不**由渲染层提交:它必须来自项目内真实取过件的
// `.agent/fork-source.json`,由原生发布链路在首次发布时读取并并入请求体,
// 渲染层既拿不到也无法伪造。
};
}
+55
View File
@@ -3647,6 +3647,61 @@ textarea {
outline-offset: 1px;
}
/* 授权共创三态:与其它字段同宽同位,但 radio 不能套用上面那条「整行输入框」样式。 */
.game-distribution-publish-panel__fork-authorization {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
border: 0;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
}
.game-distribution-publish-panel__fork-authorization legend {
padding: 0;
}
.game-distribution-publish-panel__fork-authorization.is-locked {
opacity: 0.6;
}
.game-distribution-publish-panel__fork-options {
display: grid;
gap: 6px;
}
.game-distribution-publish-panel__fields
.game-distribution-publish-panel__fork-options
label {
display: flex;
align-items: center;
gap: 8px;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 600;
}
.game-distribution-publish-panel__fields
.game-distribution-publish-panel__fork-options
input[type='radio'] {
box-sizing: border-box;
width: auto;
min-width: 0;
margin: 0;
padding: 0;
accent-color: #a8663d;
}
.game-distribution-publish-panel__fork-hint {
margin: 0;
color: var(--platform-text-muted);
font-size: 11px;
font-weight: 500;
line-height: 1.5;
}
.game-distribution-publish-panel__media {
display: grid;
gap: 14px;
@@ -115,6 +115,39 @@ describe('Rust 发布 facade', () => {
});
});
test('共创授权档位随创建作品请求提交,改编声明不来自渲染层', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: {
coverAssetId: 'asset_cover',
forkAuthorization: 'nonCommercial',
},
});
expect(calls[0]?.args).toMatchObject({
metadata: { forkAuthorization: 'nonCommercial' },
});
// 改编声明只能来自项目内真实取过件的 `.agent/fork-source.json`:原生发布链路在首次
// 发布时读取并入请求体,渲染层既没有入口也伪造不了。
expect(calls[0]?.args?.metadata).not.toHaveProperty('fork');
// 未选择时按契约默认值「禁止共创」提交(与省略该字段在服务端等价)。
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
});
expect(calls[1]?.args).toMatchObject({
metadata: { forkAuthorization: 'forbidden' },
});
});
test('缺少本地项目标识时在发起 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
await expect(
@@ -326,6 +326,8 @@ describe('GameDistributionPublishPanel', () => {
category: '动作',
coverAssetId: 'asset_cover',
screenshots: ['asset_shot_1'],
// 三态共创授权默认「禁止共创」,首次发布随资料一起提交。
forkAuthorization: 'forbidden',
});
// 根幂等键由原生发布链路按「账号 + 本地项目 + 包摘要」的账本解析并持久化,
// 渲染层不再自己铸键,因此这里不带 idempotencyKey。
@@ -337,6 +339,81 @@ describe('GameDistributionPublishPanel', () => {
expect(onPublished).toHaveBeenCalledTimes(1);
});
test('共创授权三态默认禁止共创,选择后随资料提交', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
renderPanel();
// 三个档位都在场,且默认落在「禁止共创」(与库表默认、旧客户端行为一致)。
expect(screen.getAllByRole('radio')).toHaveLength(3);
expect(
screen.getByRole('radio', { name: '禁止共创' }),
).toHaveProperty('checked', true);
expect(
screen.getByRole('radio', { name: '允许非商用共创' }),
).toHaveProperty('checked', false);
expect(
screen.getByRole('radio', { name: '允许全开放共创' }),
).toHaveProperty('checked', false);
fireEvent.click(screen.getByRole('radio', { name: '允许全开放共创' }));
// 受控:点击后选中态跟着 state 走,单选组里只有一个被选中。
expect(
screen.getByRole('radio', { name: '允许全开放共创' }),
).toHaveProperty('checked', true);
expect(
screen.getByRole('radio', { name: '禁止共创' }),
).toHaveProperty('checked', false);
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
expect(args?.metadata?.forkAuthorization).toBe('full');
});
test('更新发布时共创授权只读并给出可操作提示', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'update',
binding: {
accountId: 'user-1',
apiBaseUrl: 'https://dev.test',
gameId: 'game_9',
revision: 7,
latestVersionId: 'gamever_2',
latestVersionNumber: 2,
status: 'pending_review',
},
refreshed: true,
draft: null,
visibility: 'published',
});
renderPanel();
await screen.findByRole('button', { name: '更新游戏' });
// 档位只在首次发布生效:更新模式下三态全部只读,避免让人以为改这里能改线上档位。
for (const name of ['禁止共创', '允许非商用共创', '允许全开放共创']) {
expect(screen.getByRole('radio', { name })).toHaveProperty(
'disabled',
true,
);
}
expect(
screen.getByText(/共创授权只在首次发布时生效/u),
).not.toBeNull();
});
test('连续点击只提交一次,上传中禁用动作按钮', async () => {
installTauriInvoke(async () => undefined);
let resolvePublish: (value: unknown) => void = () => undefined;