feat(agc): 面板按衍生作品采集核心改动说明、只读继承授权并登记详情命令
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-06 18:26:23 +08:00
parent 6ae7913cd2
commit 0c425aeae1
7 changed files with 390 additions and 35 deletions
@@ -144,6 +144,10 @@ const allowedUncalledTauriCommands = [
// 同一批的 `list_platform_game_catalog` 与 `create_local_project_from_platform_game`
// 都是字面量调用点,不需要登记。
'read_public_game_cover_preview',
// 父作品卡(Fork 工程确认页与发布面板):与封面同理,命令名以常量 `DETAIL_COMMAND` 传给
// **注入的** invoke(`src/features/platform-fork/forkParentCard.ts`),静态扫描只认字面量
// 调用点,因此显式登记为 native-only 白名单。
'read_public_game_detail',
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
// 里以字面量出现,静态扫描仍按共享注册表核验。
'read_profile_recharge_center',
@@ -17,6 +17,7 @@ import {
import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectExportPackageResult } from '../../app/types';
import { loadForkParentCard } from '../../features/platform-fork/forkParentCard';
import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload';
import {
type GameDistributionPublicationImageDraft,
@@ -37,6 +38,12 @@ import {
} from '../../services/gamePackageUploadProgress';
import type { TauriEventUnsubscribe } from '../../services/tauriEventSubscription';
import { ThemedModal } from '../modal/ThemedModal';
import {
CHANGE_SUMMARY_MAX_CHARS,
CHANGE_SUMMARY_MIN_CHARS,
describePublishFailure,
validateChangeSummary,
} from './changeSummary';
import { GamePublishPhaseSteps } from './GamePublishPhaseSteps';
/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */
@@ -264,6 +271,16 @@ export function GameDistributionPublishPanel({
* 只是退化为「只能被产物级改编」。
*/
const [projectBundleConfirmed, setProjectBundleConfirmed] = useState(true);
/**
* 本项目的改编来源记录(衍生作品才有)。
*
* 衍生作品的差别是服务端口径:**「本次核心改动说明」必填**、**授权档位继承父作品且不可选**。
*/
const [forkSource, setForkSource] = useState<{ gameId: string } | null>(null);
/** 父作品的授权档位文案(只读展示用);取不到就只显示「继承自父作品」。 */
const [parentAuthorizationLabel, setParentAuthorizationLabel] = useState('');
const [changeSummary, setChangeSummary] = useState('');
const [changeSummaryError, setChangeSummaryError] = useState('');
/** 付费方式与买断价输入;校验口径与网页、服务端一致。 */
const [priceMode, setPriceMode] = useState<PlatformGamePricingMode>('free');
const [priceInput, setPriceInput] = useState('');
@@ -493,6 +510,55 @@ export function GameDistributionPublishPanel({
}
}, [manifest, open, packageResult?.packageRelativePath, projectPath]);
/**
* 衍生作品的判定与父作品档位:读项目里的 `.agent/fork-source.json`。
*
* 服务端口径:衍生作品的「本次核心改动说明」必填、授权档位继承父作品且不可选;母版反之。
* 记录缺失/损坏按「母版」处理(与发布链路读它的口径一致),父作品档位取不到就只显示
* 「继承自父作品」——不猜档位。
*/
useEffect(() => {
if (!open || !projectPath.trim()) {
setForkSource(null);
setParentAuthorizationLabel('');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setForkSource(null);
setParentAuthorizationLabel('');
return;
}
let cancelled = false;
void invoke<{ gameId?: string } | null>('read_local_project_fork_source', {
projectPath,
})
.then(async (record) => {
if (cancelled) return;
const parentGameId =
typeof record?.gameId === 'string' ? record.gameId.trim() : '';
setForkSource(parentGameId ? { gameId: parentGameId } : null);
if (!parentGameId) {
setParentAuthorizationLabel('');
return;
}
const card = await loadForkParentCard(parentGameId);
if (!cancelled) {
setParentAuthorizationLabel(card?.authorizationLabel ?? '');
}
})
.catch(() => {
// 读来源记录失败不阻塞发布:按母版处理,必填校验与授权判据仍由服务端兜底。
if (!cancelled) {
setForkSource(null);
setParentAuthorizationLabel('');
}
});
return () => {
cancelled = true;
};
}, [open, projectPath]);
useEffect(() => {
if (!open) return;
let cancelled = false;
@@ -749,6 +815,16 @@ export function GameDistributionPublishPanel({
setError('素材还在上传中,请稍候再发布');
return;
}
// 衍生作品的「本次核心改动说明」必填(母版可空):本地先拦一次给可读提示,
// 服务端仍是唯一判据(超限/缺失都会带稳定错误码回来)。
if (forkSource) {
const summaryError = validateChangeSummary(changeSummary);
if (summaryError) {
setChangeSummaryError(summaryError);
return;
}
}
setChangeSummaryError('');
const price = resolvePlatformGamePriceMudPoints(priceMode, priceInput);
if (price.error) {
setPriceError(price.error);
@@ -824,13 +900,15 @@ export function GameDistributionPublishPanel({
...(forkAuthorization !== 'forbidden' && !projectBundleConfirmed
? { includeProjectBundle: false }
: {}),
// 衍生作品必填的「本次核心改动说明」;母版不传(服务端对母版忽略该字段)。
...(forkSource && changeSummary.trim()
? { changeSummary: changeSummary.trim() }
: {}),
});
setResult(next);
onPublished?.(next);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
setError(describePublishFailure(nextError));
} finally {
unsubscribe();
setBusy(false);
@@ -983,6 +1061,34 @@ export function GameDistributionPublishPanel({
))}
</select>
</label>
{/*
衍生作品必填的「本次核心改动说明」:版本级事实,母版不需要(服务端对母版忽略它)。
长度上限与服务端同值,按字符计;本地只拦「能不能提交」,判据仍在服务端。
*/}
{forkSource ? (
<label>
本次核心改动说明({CHANGE_SUMMARY_MIN_CHARS}–
{CHANGE_SUMMARY_MAX_CHARS} 字,必填)
<textarea
aria-label="本次核心改动说明"
maxLength={CHANGE_SUMMARY_MAX_CHARS}
rows={3}
value={changeSummary}
onChange={(event) => {
setChangeSummary(event.target.value);
if (changeSummaryError) {
setChangeSummaryError('');
}
}}
placeholder="这一版相对父作品改了什么(用于族谱溯源里的差异说明)"
disabled={busy || publicationState === 'checking'}
/>
<small className="game-distribution-publish-panel__fork-hint">
已填 {Array.from(changeSummary.trim()).length} 字
{changeSummaryError ? ` · ${changeSummaryError}` : ''}
</small>
</label>
) : null}
{/*
授权共创三态单选。与分类一样是「label + 受控控件」单元,但三态必须都能看见,
因此用 radio 组而不是 select;文案直接复用平台契约里的标签常量,不另造词。
@@ -994,43 +1100,67 @@ export function GameDistributionPublishPanel({
}
>
<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);
// 每次选择授权档位都回到「默认已勾选」:避免把上一次的取消状态带到
// 新的档位选择上(禁止共创时不显示也不上传,值本身无意义)。
setProjectBundleConfirmed(value !== 'forbidden');
}}
/>
<span>
{GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[value]}
</span>
</label>
))}
</div>
{/*
衍生作品的授权由服务端**继承父作品**、且之后禁止再提升,因此面板不提供选择:
这里只读展示父作品档位(取不到就只说「继承自父作品」,不猜档位)。
母版才给三态单选。
*/}
{forkSource ? (
<p
className="game-distribution-publish-panel__fork-hint"
aria-label="授权继承"
>
{`授权继承自父作品${
parentAuthorizationLabel
? `:${parentAuthorizationLabel}`
: '(档位读取中或不可用)'
}。衍生作品的授权档位在创建时由父作品快照继承,本页与网页端都不能再修改(服务端会拒绝任何提升)。`}
</p>
) : (
<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);
// 每次选择授权档位都回到「默认已勾选」:避免把上一次的取消状态带到
// 新的档位选择上(禁止共创时不显示也不上传,值本身无意义)。
setProjectBundleConfirmed(value !== 'forbidden');
}}
/>
<span>
{GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[value]}
</span>
</label>
))}
</div>
)}
<p className="game-distribution-publish-panel__fork-hint">
{forkAuthorizationLocked
? '共创授权只在首次发布时生效;上架后只能在网页端「我的作品」单向提升。'
: '授权非「禁止共创」即允许他人 Fork 你的作品;开启共创并上传工程时,整个工程(含素材与文档)会对他人可见,且不可撤销。'}
{forkSource
? '授权非「禁止共创」即允许他人 Fork 你的作品;开启共创并上传工程时,整个工程(含素材与文档)会对他人可见,且不可撤销。'
: forkAuthorizationLocked
? '共创授权只在首次发布时生效;上架后只能在网页端「我的作品」单向提升。'
: '授权非「禁止共创」即允许他人 Fork 你的作品;开启共创并上传工程时,整个工程(含素材与文档)会对他人可见,且不可撤销。'}
</p>
{/*
工程源码外发是**知情同意**:工程源包的拒绝规则是黑名单,作者的 docs/、截图、
设计稿、笔记等不在任何拒绝集内,会随包一起对任何登录用户公开。默认勾选与授权
语义一致(允许共创即开放源码),但作者可以取消:取消即本次不上传源码。
衍生作品没有本地档位可选,按父作品档位判断(读不到就按「非禁止」展示——
既然能 Fork 出来,父作品档位必然不是禁止)。
*/}
{forkAuthorization !== 'forbidden' &&
{(forkSource
? parentAuthorizationLabel !== '禁止共创'
: forkAuthorization !== 'forbidden') &&
!forkAuthorizationLocked ? (
<label className="game-distribution-publish-panel__bundle-consent">
<input
@@ -0,0 +1,38 @@
/**
* 衍生作品「本次核心改动说明」的本地拦挡与错误文案。
*
* 长度区间与服务端 `GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS` / `_MAX_CHARS` 同值
* (`server-rs/crates/module-game-distribution/src/domain.rs:369,377`),按**字符数**计、
* 忽略首尾空白;唯一判据仍在服务端(`game_distribution_change_summary_violation`),
* 这里只负责「能不能提交」与可读提示,避免两套判据漂移。
*/
export const CHANGE_SUMMARY_MIN_CHARS = 20;
export const CHANGE_SUMMARY_MAX_CHARS = 500;
/** 衍生作品的核心改动说明校验:返回可读错误(空串 = 通过)。 */
export function validateChangeSummary(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return '衍生作品发布新版本必须填写「本次核心改动说明」';
}
const length = Array.from(trimmed).length;
if (length < CHANGE_SUMMARY_MIN_CHARS || length > CHANGE_SUMMARY_MAX_CHARS) {
return `「本次核心改动说明」需为 ${CHANGE_SUMMARY_MIN_CHARS} 到 ${CHANGE_SUMMARY_MAX_CHARS} 个字符(当前 ${length})`;
}
return '';
}
/**
* 发布失败的展示文案:**保留服务端稳定错误码**(客户端要能按码分支、排障也要看到码),
* 核心改动说明的两个码额外给一句可操作说明。
*/
export function describePublishFailure(error: unknown): string {
const text = error instanceof Error ? error.message : String(error);
if (text.includes('FORK_CHANGE_SUMMARY_REQUIRED')) {
return `FORK_CHANGE_SUMMARY_REQUIRED:衍生作品发布新版本必须填写「本次核心改动说明」(${CHANGE_SUMMARY_MIN_CHARS}–${CHANGE_SUMMARY_MAX_CHARS} 字)`;
}
if (text.includes('FORK_CHANGE_SUMMARY_INVALID')) {
return `FORK_CHANGE_SUMMARY_INVALID:本次核心改动说明需为 ${CHANGE_SUMMARY_MIN_CHARS} 到 ${CHANGE_SUMMARY_MAX_CHARS} 个字符(按字符计,忽略首尾空白)`;
}
return text;
}
@@ -87,7 +87,9 @@ const FAILURE_PREFIXES: readonly [string, PlatformForkFailureKind][] = [
* 前缀是原生错误契约的一部分(`map_fork_source_http_error`),因此这里按前缀精确匹配,
* 绝不用文案子串猜测错误类型;未命中前缀的一律按 `other` 原样展示,不吞掉排障信息。
*/
export function classifyPlatformForkFailure(error: unknown): PlatformForkFailure {
export function classifyPlatformForkFailure(
error: unknown,
): PlatformForkFailure {
const raw = error instanceof Error ? error.message : String(error);
const text = raw.trim();
for (const [prefix, kind] of FAILURE_PREFIXES) {
@@ -34,8 +34,12 @@ export type GameDistributionPublishMetadata = {
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
/**
* 上架时的共创授权档位(三态单选,默认禁止共创);只在**首次发布**生效——创建作品以后
* 上架时的共创授权档位(三态单选,默认禁止共创)。只在**母版**的首次发布生效:母版创建后
* 只能通过网页端「我的作品」单向提升,更新版本不会改动它。
*
* 衍生作品(项目里有 `.agent/fork-source.json`)传了也会被服务端忽略——档位在创建时由父作品
* 快照继承,之后任何提升都被拒绝(`FORK_AUTHORIZATION_INHERITED`),所以面板对衍生作品根本
* 不渲染三态选择。这里保留字段只是为了母版链路不变。
*/
forkAuthorization?: GameDistributionForkAuthorization;
};
@@ -367,6 +371,13 @@ export async function publishLocalProjectGame(args: {
* 缺省即「授权非禁止时上传」;`false` 表示作者主动取消——源码不外发,作品只能被产物级改编。
*/
includeProjectBundle?: boolean;
/**
* 衍生作品的「本次核心改动说明」(母版不需要)。
*
* 服务端口径:衍生作品的版本必填,trim 后 20–500 个字符;这里原样透传,不做本地判据(校验只有
* 服务端那一份,客户端只负责采集与给可读提示)。
*/
changeSummary?: string;
}): Promise<GameDistributionPublishResult> {
const projectPath = args.projectPath.trim();
const packageRelativePath = args.packageRelativePath.trim();
@@ -425,6 +436,10 @@ export async function publishLocalProjectGame(args: {
...(args.includeProjectBundle === false
? { includeProjectBundle: false }
: {}),
// 衍生作品的核心改动说明:原样透传(服务端负责 trim、必填与长度判据)。
...(typeof args.changeSummary === 'string' && args.changeSummary.trim()
? { changeSummary: args.changeSummary.trim() }
: {}),
},
);
}
@@ -153,6 +153,37 @@ describe('Rust 发布 facade', () => {
expect(calls[1]?.args).not.toHaveProperty('includeProjectBundle');
});
test('衍生作品的核心改动说明 trim 后透传,母版留空不带字段', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
// 衍生作品的必填与 20–500 字符判据都在服务端;渲染层只做 trim 透传。
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
versionNumber: 2,
changeSummary:
' 把跳台改成三段,并重画全部背景与手感,还新增了两个关卡 ',
});
expect(calls[0]?.args).toMatchObject({
changeSummary: '把跳台改成三段,并重画全部背景与手感,还新增了两个关卡',
});
// 空串/纯空白视为没写:不带字段(母版本来也不该带),由服务端对衍生作品回必填码。
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
versionNumber: 2,
changeSummary: ' ',
});
expect(calls[1]?.args).not.toHaveProperty('changeSummary');
});
test('共创授权档位随创建作品请求提交,改编声明不来自渲染层', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
@@ -560,6 +560,141 @@ describe('GameDistributionPublishPanel', () => {
expect(warning.textContent).toContain('只能被产物级改编');
});
test('衍生作品:核心改动说明必填,授权只读继承父作品', async () => {
const invoke = installTauriInvoke(async (command: string) => {
if (command === 'read_local_project_fork_source') {
return { gameId: 'game_parent_1', versionId: 'gamever_parent_1' };
}
if (command === 'read_public_game_detail') {
return { id: 'game_parent_1', forkAuthorization: 'nonCommercial' };
}
return 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(
await screen.findByText(/授权继承自父作品:允许非商用共创/u),
).not.toBeNull();
expect(screen.queryByRole('radiogroup', { name: '授权共创' })).toBeNull();
const summaryInput = screen.getByLabelText('本次核心改动说明');
expect(summaryInput).not.toBeNull();
// 没填就提交:本地拦下,不发发布请求。
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
expect(publishLocalProjectGame).not.toHaveBeenCalled();
expect(
await screen.findByText(/必须填写「本次核心改动说明」/u),
).not.toBeNull();
// 填够 20 字才允许提交,并把 trim 后的值透传给原生。
fireEvent.change(summaryInput, {
target: {
value: ' 把跳台改成三段,并重画全部背景与手感,还新增了两个关卡 ',
},
});
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
expect(vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0]).toMatchObject(
{
changeSummary: '把跳台改成三段,并重画全部背景与手感,还新增了两个关卡',
},
);
expect(invoke).toHaveBeenCalledWith('read_local_project_fork_source', {
projectPath: '/tmp/authorized-game',
});
});
test('衍生作品:核心改动说明不足 20 字也拦下并给可读提示', async () => {
installTauriInvoke(async (command: string) =>
command === 'read_local_project_fork_source'
? { gameId: 'game_parent_1', versionId: 'gamever_parent_1' }
: undefined,
);
renderPanel();
const summaryInput = await screen.findByLabelText('本次核心改动说明');
fireEvent.change(summaryInput, { target: { value: '改了点' } });
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
expect(publishLocalProjectGame).not.toHaveBeenCalled();
expect(
await screen.findByText(/需为 20 到 500 个字符(当前 3)/u),
).not.toBeNull();
});
test('母版:不显示核心改动说明,授权三态仍可选', async () => {
installTauriInvoke(async (command: string) =>
command === 'read_local_project_fork_source' ? null : 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(
await screen.findByRole('radiogroup', { name: '授权共创' }),
).toBeTruthy();
expect(screen.queryByLabelText('本次核心改动说明')).toBeNull();
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
// 母版不带核心改动说明(服务端对母版忽略该字段)。
expect(
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0],
).not.toHaveProperty('changeSummary');
});
test('核心改动说明的服务端稳定码原样展示', async () => {
installTauriInvoke(async (command: string) =>
command === 'read_local_project_fork_source'
? { gameId: 'game_parent_1', versionId: 'gamever_parent_1' }
: undefined,
);
vi.mocked(publishLocalProjectGame).mockRejectedValue(
new Error(
'FORK_CHANGE_SUMMARY_INVALID: 本次核心改动说明需为 20 到 500 个字符(按字符计,忽略首尾空白)',
),
);
renderPanel();
const summaryInput = await screen.findByLabelText('本次核心改动说明');
fireEvent.change(summaryInput, {
target: {
value: '把跳台改成三段,并重画全部背景与手感,还新增了两个关卡',
},
});
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('FORK_CHANGE_SUMMARY_INVALID');
expect(alert.textContent).toContain('20');
expect(alert.textContent).toContain('500');
});
test('更新发布时共创授权只读并给出可操作提示', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({