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:
@@ -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]
|
||||
|
||||
+60
-1
@@ -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`,由原生发布链路在首次发布时读取并并入请求体,
|
||||
// 渲染层既拿不到也无法伪造。
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user