diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts
index 241019468..efdce973b 100644
--- a/apps/admin-web/src/api/adminApiClient.ts
+++ b/apps/admin-web/src/api/adminApiClient.ts
@@ -101,12 +101,34 @@ import type {
ProfileTaskConfigAdminResponse,
ProfileWalletConfigAdminResponse,
} from './adminApiTypes';
+import type {
+ AdminThemeStatusFilter,
+ GameDistributionAdminThemeListResponse,
+ GameDistributionAdminThemeMemberListResponse,
+ GameDistributionAdminThemeMemberRemovalResponse,
+ GameDistributionAdminThemeMemberResponse,
+ GameDistributionAdminThemeMutationResponse,
+ GameDistributionCreateThemeRequest,
+ GameDistributionUpdateThemeRequest,
+ GameDistributionUpsertThemeMemberRequest,
+} from './adminGameThemeTypes';
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
const ADMIN_API_BASE_URL = normalizeBaseUrl(
import.meta.env.VITE_ADMIN_API_BASE_URL ?? '',
);
+/**
+ * 后台主题列表的 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。
+ *
+ * 服务端按上限截断(不报错),客户端也按同一口径夹一次,避免发出一条注定被截断的请求。
+ */
+const ADMIN_THEME_LIST_LIMIT_MAX = 200;
+
+/** 后台成员名单每页条数:缺省与服务端一致,上限 50(超出服务端截断,这里先夹)。 */
+const ADMIN_THEME_MEMBER_PAGE_SIZE = 20;
+const ADMIN_THEME_MEMBER_PAGE_SIZE_MAX = 50;
+
interface AdminRequestOptions {
method?: string;
token?: string;
@@ -1512,3 +1534,156 @@ export function importAdminAgcTemplates(token: string, formData: FormData) {
},
);
}
+
+/**
+ * 后台共创主题列表:`GET /admin/api/game-distribution/themes?limit=&status=`。
+ *
+ * 含 draft / archived;`status=all`(或省略)不过滤。**没有游标**:`limit` 上限 200,服务端按
+ * 上限截断,这里也按同一口径夹一次,避免把一条必然被截断的请求发出去。
+ */
+export function listAdminGameDistributionThemes(
+ token: string,
+ options: {
+ limit?: number;
+ status?: AdminThemeStatusFilter;
+ } = {},
+ signal?: AbortSignal,
+) {
+ const requestedLimit = options.limit ?? ADMIN_THEME_LIST_LIMIT_MAX;
+ const normalizedLimit = Number.isFinite(requestedLimit)
+ ? Math.min(
+ Math.max(Math.trunc(requestedLimit), 1),
+ ADMIN_THEME_LIST_LIMIT_MAX,
+ )
+ : ADMIN_THEME_LIST_LIMIT_MAX;
+ const params = new URLSearchParams({ limit: String(normalizedLimit) });
+ const status = options.status?.trim();
+ if (status && status !== 'all') params.set('status', status);
+ return request(
+ `/admin/api/game-distribution/themes?${params.toString()}`,
+ { token, signal },
+ );
+}
+
+/** 后台创建主题:`theme_id` 由服务端生成,客户端只给字段;写操作必须带幂等键。 */
+export function createAdminGameDistributionTheme(
+ token: string,
+ idempotencyKey: string,
+ payload: GameDistributionCreateThemeRequest,
+) {
+ return request(
+ '/admin/api/game-distribution/themes',
+ {
+ token,
+ method: 'POST',
+ headers: {
+ 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey),
+ },
+ body: payload,
+ },
+ );
+}
+
+/** 后台整体覆盖主题字段:`name/summary/badge/sortOrder/status` 都是必填(不是部分更新)。 */
+export function updateAdminGameDistributionTheme(
+ token: string,
+ themeId: string,
+ idempotencyKey: string,
+ payload: GameDistributionUpdateThemeRequest,
+) {
+ return request(
+ `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`,
+ {
+ token,
+ method: 'PUT',
+ headers: {
+ 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey),
+ },
+ body: payload,
+ },
+ );
+}
+
+/**
+ * 增 / 改主题成员:**不要求幂等键**(成员身份完全由路径给出,重复调用只改 `sortOrder`)。
+ *
+ * 只允许根作品:非根作品服务端回 409 `THEME_MEMBER_NOT_ROOT`。
+ */
+export function upsertAdminGameDistributionThemeMember(
+ token: string,
+ themeId: string,
+ rootGameId: string,
+ payload: GameDistributionUpsertThemeMemberRequest,
+) {
+ return request(
+ `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
+ { token, method: 'PUT', body: payload },
+ );
+}
+
+/** 移除主题成员:不要求幂等键;成员不存在也算成功(200,返回同一个形状)。 */
+export function removeAdminGameDistributionThemeMember(
+ token: string,
+ themeId: string,
+ rootGameId: string,
+) {
+ return request(
+ `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
+ { token, method: 'DELETE' },
+ );
+}
+
+/**
+ * 后台主题成员名单:`GET …/themes/{themeId}/members?limit=&cursor=`。
+ *
+ * **不套公开可见性过滤**,草稿 / 归档主题照常可读——这正是这条接口存在的理由。每行带
+ * `visible`(此刻公开投影是否包含它)与 `visibility`(为什么:`published`/`unpublished`/
+ * `suspended`/`deleted`/`missing`)两个独立字段,界面不要互相推导。
+ *
+ * `limit` 缺省 20 / 上限 50(服务端口径),这里按同一口径夹一次;`cursor` 用上一页的 `nextCursor`。
+ */
+export function listAdminGameDistributionThemeMembers(
+ token: string,
+ themeId: string,
+ options: { limit?: number; cursor?: string | null } = {},
+ signal?: AbortSignal,
+) {
+ const requestedLimit = options.limit ?? ADMIN_THEME_MEMBER_PAGE_SIZE;
+ const normalizedLimit = Number.isFinite(requestedLimit)
+ ? Math.min(
+ Math.max(Math.trunc(requestedLimit), 1),
+ ADMIN_THEME_MEMBER_PAGE_SIZE_MAX,
+ )
+ : ADMIN_THEME_MEMBER_PAGE_SIZE;
+ const params = new URLSearchParams({ limit: String(normalizedLimit) });
+ const cursor = options.cursor?.trim();
+ if (cursor) params.set('cursor', cursor);
+ return request(
+ `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members?${params.toString()}`,
+ { token, signal },
+ );
+}
+
+function normalizeThemeId(themeId: string) {
+ const normalized = themeId.trim();
+ if (!normalized) {
+ throw new Error('缺少主题 ID');
+ }
+ return normalized;
+}
+
+function normalizeRootGameId(rootGameId: string) {
+ const normalized = rootGameId.trim();
+ if (!normalized) {
+ throw new Error('缺少根作品 ID');
+ }
+ return normalized;
+}
+
+function normalizeThemeIdempotencyKey(idempotencyKey: string) {
+ const normalized = idempotencyKey.trim();
+ if (!normalized || normalized.length > 128) {
+ throw new Error('主题写操作幂等键必须是 1 到 128 个字符');
+ }
+ return normalized;
+}
diff --git a/apps/admin-web/src/api/adminGameThemeTypes.ts b/apps/admin-web/src/api/adminGameThemeTypes.ts
new file mode 100644
index 000000000..81b4f6281
--- /dev/null
+++ b/apps/admin-web/src/api/adminGameThemeTypes.ts
@@ -0,0 +1,34 @@
+/**
+ * 后台「共创主题」接口用到的契约类型**透传**入口。
+ *
+ * 这里**不再重复定义任何载荷**:主题的后台类型已在
+ * `packages/shared/src/contracts/gameDistribution.ts` 登记(并纳入 DTO parity 的
+ * `TS_ONLY_TYPES` / `RESPONSE_BUILDERS`),后台 UI 直接用契约名,避免两份定义并存漂移。
+ *
+ * 为什么用相对路径而不是包名 `@genarrative/shared`:仓库根的
+ * `node_modules/@genarrative/shared` 软链指向**主工作树**,主题契约只在本分支上,走包名会拿到
+ * 缺这些类型的旧副本(实测 TS2724)。AGC 侧同样用这种相对引用。
+ */
+export type {
+ GameDistributionAdminTheme,
+ GameDistributionAdminThemeListResponse,
+ GameDistributionAdminThemeMemberListResponse,
+ GameDistributionAdminThemeMemberRemovalResponse,
+ GameDistributionAdminThemeMemberResponse,
+ GameDistributionAdminThemeMemberRow,
+ GameDistributionAdminThemeMemberVisibility,
+ GameDistributionAdminThemeMutationResponse,
+ GameDistributionCreateThemeRequest,
+ GameDistributionThemeStatus,
+ GameDistributionUpdateThemeRequest,
+ GameDistributionUpsertThemeMemberRequest,
+} from '../../../../packages/shared/src/contracts/gameDistribution';
+
+import type { GameDistributionThemeStatus } from '../../../../packages/shared/src/contracts/gameDistribution';
+
+/**
+ * 后台主题列表的 `status` 查询值。
+ *
+ * `all` 是**接口查询参数**的白名单取值(不过滤),不是主题状态本身,所以没有进共享契约。
+ */
+export type AdminThemeStatusFilter = GameDistributionThemeStatus | 'all';
diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx
index 7e7a5fe6f..47bc29188 100644
--- a/apps/admin-web/src/app/AdminApp.tsx
+++ b/apps/admin-web/src/app/AdminApp.tsx
@@ -33,6 +33,7 @@ import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
+import { AdminGameThemesPage } from '../pages/AdminGameThemesPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -360,6 +361,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
+ {activeRouteId === 'game-themes' ? (
+
+ ) : null}
{activeRouteId === 'editor-assets' ? (
({
+ isAdminApiError: vi.fn(
+ (error: unknown) =>
+ typeof error === 'object' &&
+ error !== null &&
+ 'status' in error &&
+ typeof error.status === 'number',
+ ),
+ formatAdminApiError: vi.fn((error: unknown) =>
+ error instanceof Error ? error.message : '请求失败',
+ ),
+ listAdminGameDistributionThemes: vi.fn(),
+ createAdminGameDistributionTheme: vi.fn(),
+ updateAdminGameDistributionTheme: vi.fn(),
+ upsertAdminGameDistributionThemeMember: vi.fn(),
+ removeAdminGameDistributionThemeMember: vi.fn(),
+ listAdminGameDistributionThemeMembers: vi.fn(),
+}));
+
+const publishedTheme: GameDistributionAdminTheme = {
+ themeId: 'theme_1',
+ name: '星际防线',
+ summary: '同一母版下的二创合集',
+ badge: '热门',
+ sortOrder: 10,
+ status: 'published',
+ memberCount: 3,
+ createdAt: '2026-10-01T08:00:00Z',
+ updatedAt: '2026-10-02T08:00:00Z',
+};
+
+const draftTheme: GameDistributionAdminTheme = {
+ ...publishedTheme,
+ themeId: 'theme_2',
+ name: '草稿主题',
+ badge: '新',
+ sortOrder: 20,
+ status: 'draft',
+ memberCount: 0,
+};
+
+function mockList(themes: GameDistributionAdminTheme[] = [publishedTheme]) {
+ vi.mocked(listAdminGameDistributionThemes).mockResolvedValue({ themes });
+}
+
+function renderPage(
+ onUnauthorized: (message?: string) => void = () => undefined,
+) {
+ return render(
+ ,
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockList();
+ // 成员面板默认给一份空名单,个别用例再覆盖。
+ vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({
+ themeId: 'theme_1',
+ totalMembers: 0,
+ members: [],
+ nextCursor: null,
+ });
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+test('列表展示名称、角标、状态、排序与成员数,并按状态过滤', async () => {
+ renderPage();
+
+ const row = (await screen.findByText('星际防线')).closest('tr')!;
+ expect(within(row).getByText('热门')).toBeTruthy();
+ expect(within(row).getByText('已发布')).toBeTruthy();
+ expect(within(row).getByText('10')).toBeTruthy();
+ expect(within(row).getByText('3')).toBeTruthy();
+ expect(within(row).getByText('theme_1')).toBeTruthy();
+
+ expect(listAdminGameDistributionThemes).toHaveBeenCalledWith(
+ 'admin-token',
+ { limit: 200, status: 'all' },
+ expect.anything(),
+ );
+
+ // 切到归档过滤:请求带上 status,且列表按新数据渲染。
+ mockList([{ ...draftTheme, status: 'archived', name: '归档主题' }]);
+ fireEvent.change(screen.getByLabelText('状态过滤'), {
+ target: { value: 'archived' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /查询/ }));
+
+ await waitFor(() =>
+ expect(listAdminGameDistributionThemes).toHaveBeenLastCalledWith(
+ 'admin-token',
+ { limit: 200, status: 'archived' },
+ expect.anything(),
+ ),
+ );
+ expect(await screen.findByText('归档主题')).toBeTruthy();
+});
+
+test('创建主题:先校验再写,写操作带幂等键,重放要提示', async () => {
+ vi.mocked(createAdminGameDistributionTheme).mockResolvedValue({
+ theme: publishedTheme,
+ replayed: true,
+ });
+ renderPage();
+ await screen.findByText('星际防线');
+
+ fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
+ const dialog = await screen.findByRole('dialog');
+
+ // 名称为空:本地就拦下,不发请求。
+ fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
+ expect(await within(dialog).findByText('主题名不能为空')).toBeTruthy();
+ expect(createAdminGameDistributionTheme).not.toHaveBeenCalled();
+
+ fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
+ target: { value: '星际防线二' },
+ });
+ fireEvent.change(within(dialog).getByLabelText('新建主题简介'), {
+ target: { value: '第二个主题' },
+ });
+ fireEvent.change(within(dialog).getByLabelText('新建主题角标'), {
+ target: { value: '新' },
+ });
+ fireEvent.change(within(dialog).getByLabelText('新建主题排序号'), {
+ target: { value: '5' },
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
+
+ // 写操作要先过确认对话框。
+ const confirm = await screen.findByRole('dialog', { name: '确认操作' });
+ fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
+
+ await waitFor(() =>
+ expect(createAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
+ );
+ const [token, idempotencyKey, payload] =
+ vi.mocked(createAdminGameDistributionTheme).mock.calls[0] ?? [];
+ expect(token).toBe('admin-token');
+ expect(payload).toEqual({
+ name: '星际防线二',
+ summary: '第二个主题',
+ badge: '新',
+ sortOrder: 5,
+ status: 'draft',
+ });
+ expect(String(idempotencyKey).length).toBeGreaterThan(8);
+ expect(String(idempotencyKey).length).toBeLessThanOrEqual(128);
+ expect(
+ await screen.findByText(/已存在(幂等重放,未重复写入)/),
+ ).toBeTruthy();
+});
+
+test('创建时同键换请求的 409 要原样解释给运营', async () => {
+ vi.mocked(createAdminGameDistributionTheme).mockRejectedValue(
+ Object.assign(new Error('Idempotency-Key 已用于不同的请求'), {
+ status: 409,
+ code: 'ADMIN_IDEMPOTENCY_CONFLICT',
+ }),
+ );
+ renderPage();
+ await screen.findByText('星际防线');
+
+ fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
+ target: { value: '冲突主题' },
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
+ const confirm = await screen.findByRole('dialog', { name: '确认操作' });
+ fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
+
+ expect(
+ await within(dialog).findByText(/Idempotency-Key 已用于不同的请求/),
+ ).toBeTruthy();
+});
+
+test('编辑主题:整体覆盖字段,并提示归档后公开侧不可读', async () => {
+ vi.mocked(updateAdminGameDistributionTheme).mockResolvedValue({
+ theme: { ...draftTheme, status: 'archived' },
+ replayed: false,
+ });
+ renderPage();
+ const row = (await screen.findByText('星际防线')).closest('tr')!;
+ fireEvent.click(within(row).getByRole('button', { name: '编辑' }));
+
+ const dialog = await screen.findByRole('dialog');
+ expect(
+ within(dialog).getByText(/归档(archived)后公开侧不可读/),
+ ).toBeTruthy();
+
+ fireEvent.change(within(dialog).getByLabelText('编辑主题主题名'), {
+ target: { value: '星际防线(归档)' },
+ });
+ fireEvent.change(within(dialog).getByLabelText('编辑主题状态'), {
+ target: { value: 'archived' },
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: '保存' }));
+ const confirm = await screen.findByRole('dialog', { name: '确认操作' });
+ fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
+
+ await waitFor(() =>
+ expect(updateAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
+ );
+ const [token, themeId, , payload] =
+ vi.mocked(updateAdminGameDistributionTheme).mock.calls[0] ?? [];
+ expect(token).toBe('admin-token');
+ expect(themeId).toBe('theme_1');
+ expect(payload).toMatchObject({
+ name: '星际防线(归档)',
+ status: 'archived',
+ });
+});
+
+test('加非根作品:把 409 翻成「只能收录根作品」的可读原因', async () => {
+ vi.mocked(upsertAdminGameDistributionThemeMember).mockRejectedValue(
+ Object.assign(new Error('not root'), {
+ status: 409,
+ code: 'THEME_MEMBER_NOT_ROOT',
+ }),
+ );
+ renderPage();
+ const row = (await screen.findByText('星际防线')).closest('tr')!;
+ fireEvent.click(within(row).getByRole('button', { name: '成员' }));
+
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.change(within(dialog).getByLabelText('根作品 ID'), {
+ target: { value: 'game_child_1' },
+ });
+ fireEvent.click(within(dialog).getByRole('button', { name: '添加成员' }));
+
+ expect(
+ await within(dialog).findByText(
+ '该作品不是根作品(母版);主题只能收录根作品',
+ ),
+ ).toBeTruthy();
+ expect(upsertAdminGameDistributionThemeMember).toHaveBeenCalledWith(
+ 'admin-token',
+ 'theme_1',
+ 'game_child_1',
+ { sortOrder: 0 },
+ );
+});
+
+test('成员面板:草稿主题也能读到成员名单,逐条可移除', async () => {
+ mockList([draftTheme]);
+ vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({
+ themeId: 'theme_2',
+ totalMembers: 2,
+ members: [
+ {
+ rootGameId: 'game_root_1',
+ title: '母版作品',
+ sortOrder: 0,
+ createdAt: '2026-10-01T08:00:00Z',
+ visible: true,
+ visibility: 'published',
+ },
+ {
+ rootGameId: 'game_root_2',
+ title: null,
+ sortOrder: 5,
+ createdAt: '2026-10-01T09:00:00Z',
+ visible: false,
+ visibility: 'missing',
+ },
+ ],
+ nextCursor: null,
+ });
+ vi.mocked(removeAdminGameDistributionThemeMember).mockResolvedValue({
+ themeId: 'theme_2',
+ rootGameId: 'game_root_1',
+ });
+ renderPage();
+ const row = (await screen.findByText('草稿主题')).closest('tr')!;
+ fireEvent.click(within(row).getByRole('button', { name: '成员' }));
+
+ // 草稿主题也走后台名单接口(不套公开可见性过滤,也不再借公开投影)。
+ expect(listAdminGameDistributionThemeMembers).toHaveBeenCalledWith(
+ 'admin-token',
+ 'theme_2',
+ { limit: 20 },
+ expect.anything(),
+ );
+
+ const dialog = await screen.findByRole('dialog');
+ const members = await within(dialog).findByRole('list', {
+ name: '主题成员名单',
+ });
+ expect(within(members).getByText('母版作品')).toBeTruthy();
+ // 游戏行不存在时标题为 null:仍要出现在名单里,否则与 totalMembers 对不上。
+ expect(within(members).getByText('(游戏行不存在)')).toBeTruthy();
+ expect(within(members).getByText('game_root_2')).toBeTruthy();
+ expect(within(members).getByText('公开可见')).toBeTruthy();
+ expect(within(members).getByText('游戏行不存在')).toBeTruthy();
+ expect(within(dialog).getByText(/成员行总数 2;已加载 2 条/)).toBeTruthy();
+
+ // 只移除点中的那一条。
+ fireEvent.click(within(members).getAllByRole('button', { name: '移除' })[0]!);
+ await waitFor(() =>
+ expect(removeAdminGameDistributionThemeMember).toHaveBeenCalledWith(
+ 'admin-token',
+ 'theme_2',
+ 'game_root_1',
+ ),
+ );
+ // 移除后重读第一页(不是本地删行:游标基于排序)。
+ await waitFor(() =>
+ expect(listAdminGameDistributionThemeMembers).toHaveBeenCalledTimes(2),
+ );
+});
+
+test('成员面板:区分「已公开但无公开版本」与其它不可见原因', async () => {
+ vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({
+ themeId: 'theme_1',
+ totalMembers: 3,
+ members: [
+ {
+ rootGameId: 'game_root_a',
+ title: '已公开无版本',
+ sortOrder: 0,
+ createdAt: '2026-10-01T08:00:00Z',
+ // 关键异常态:游戏行 published 但此刻公开投影不含它。
+ visible: false,
+ visibility: 'published',
+ },
+ {
+ rootGameId: 'game_root_b',
+ title: '已下架',
+ sortOrder: 1,
+ createdAt: '2026-10-01T08:00:00Z',
+ visible: false,
+ visibility: 'suspended',
+ },
+ {
+ rootGameId: 'game_root_c',
+ title: '已删除',
+ sortOrder: 2,
+ createdAt: '2026-10-01T08:00:00Z',
+ visible: false,
+ visibility: 'deleted',
+ },
+ ],
+ nextCursor: null,
+ });
+ renderPage();
+ const row = (await screen.findByText('星际防线')).closest('tr')!;
+ fireEvent.click(within(row).getByRole('button', { name: '成员' }));
+
+ const dialog = await screen.findByRole('dialog');
+ const members = await within(dialog).findByRole('list', {
+ name: '主题成员名单',
+ });
+ // `visible` 与 `visibility` 独立:published + 不可见必须说清是「没有公开版本」。
+ expect(within(members).getByText('已公开,但无公开版本')).toBeTruthy();
+ expect(within(members).getByText('作品已下架')).toBeTruthy();
+ expect(within(members).getByText('作品已删除')).toBeTruthy();
+});
+
+test('成员面板:成员多于首页时可按游标加载更多', async () => {
+ vi.mocked(listAdminGameDistributionThemeMembers)
+ .mockResolvedValueOnce({
+ themeId: 'theme_1',
+ totalMembers: 2,
+ members: [
+ {
+ rootGameId: 'game_root_1',
+ title: '母版一',
+ sortOrder: 0,
+ createdAt: '2026-10-01T08:00:00Z',
+ visible: true,
+ visibility: 'published',
+ },
+ ],
+ nextCursor: '0:theme_1:game_root_1',
+ })
+ .mockResolvedValueOnce({
+ themeId: 'theme_1',
+ totalMembers: 2,
+ members: [
+ {
+ rootGameId: 'game_root_2',
+ title: '母版二',
+ sortOrder: 1,
+ createdAt: '2026-10-01T09:00:00Z',
+ visible: true,
+ visibility: 'published',
+ },
+ ],
+ nextCursor: null,
+ });
+ renderPage();
+ const row = (await screen.findByText('星际防线')).closest('tr')!;
+ fireEvent.click(within(row).getByRole('button', { name: '成员' }));
+
+ const dialog = await screen.findByRole('dialog');
+ await within(dialog).findByRole('list', { name: '主题成员名单' });
+ fireEvent.click(within(dialog).getByRole('button', { name: '加载更多成员' }));
+
+ await waitFor(() =>
+ expect(listAdminGameDistributionThemeMembers).toHaveBeenLastCalledWith(
+ 'admin-token',
+ 'theme_1',
+ { limit: 20, cursor: '0:theme_1:game_root_1' },
+ ),
+ );
+ await waitFor(() =>
+ expect(within(dialog).getByText('game_root_2')).toBeTruthy(),
+ );
+ // 末页没有 nextCursor → 按钮消失。
+ expect(
+ within(dialog).queryByRole('button', { name: '加载更多成员' }),
+ ).toBeNull();
+});
+
+test('成员面板:读取失败时如实报错,不渲染成空名单', async () => {
+ vi.mocked(listAdminGameDistributionThemeMembers).mockRejectedValue(
+ Object.assign(new Error('游标非法'), {
+ status: 400,
+ code: 'THEME_INVALID_CURSOR',
+ }),
+ );
+ renderPage();
+ const row = (await screen.findByText('星际防线')).closest('tr')!;
+ fireEvent.click(within(row).getByRole('button', { name: '成员' }));
+
+ const dialog = await screen.findByRole('dialog');
+ expect(await within(dialog).findByText('游标非法')).toBeTruthy();
+ expect(within(dialog).queryByText(/还没有成员/)).toBeNull();
+});
+
+test('401 时交给上层处理登录失效,不把错误塞进页面', async () => {
+ const onUnauthorized = vi.fn();
+ vi.mocked(listAdminGameDistributionThemes).mockRejectedValue(
+ Object.assign(new Error('unauthorized'), { status: 401 }),
+ );
+ renderPage(onUnauthorized);
+
+ await waitFor(() =>
+ expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
+ );
+});
diff --git a/apps/admin-web/src/pages/AdminGameThemesPage.tsx b/apps/admin-web/src/pages/AdminGameThemesPage.tsx
new file mode 100644
index 000000000..891e7e6d5
--- /dev/null
+++ b/apps/admin-web/src/pages/AdminGameThemesPage.tsx
@@ -0,0 +1,1031 @@
+import type { AdminStatusTone } from '@genarrative/shared/components';
+import {
+ AdminActionRow,
+ AdminAlert,
+ AdminButton,
+ AdminDialog,
+ AdminField,
+ AdminListPanel,
+ AdminPage,
+ AdminPageHeading,
+ AdminStatusPill,
+ formatAdminDateTime,
+} from '@genarrative/shared/components';
+import { RefreshCcw, Search } from 'lucide-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import {
+ createAdminGameDistributionTheme,
+ isAdminApiError,
+ listAdminGameDistributionThemeMembers,
+ listAdminGameDistributionThemes,
+ removeAdminGameDistributionThemeMember,
+ updateAdminGameDistributionTheme,
+ upsertAdminGameDistributionThemeMember,
+} from '../api/adminApiClient';
+import type {
+ AdminThemeStatusFilter,
+ GameDistributionAdminTheme,
+ GameDistributionAdminThemeMemberRow,
+ GameDistributionAdminThemeMemberVisibility,
+ GameDistributionThemeStatus,
+} from '../api/adminGameThemeTypes';
+import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
+import { handlePageError } from './pageUtils';
+
+interface AdminGameThemesPageProps {
+ token: string;
+ onUnauthorized: (message?: string) => void;
+}
+
+/** 主题状态与文案:`archived` 后公开侧读不到,这一点必须在界面上说清。 */
+const THEME_STATUS_META: Record<
+ GameDistributionThemeStatus,
+ { label: string; tone: AdminStatusTone }
+> = {
+ draft: { label: '草稿', tone: 'pending' },
+ published: { label: '已发布', tone: 'ok' },
+ archived: { label: '已归档', tone: 'error' },
+};
+
+const THEME_STATUS_OPTIONS: {
+ value: GameDistributionThemeStatus;
+ label: string;
+}[] = [
+ { value: 'draft', label: '草稿(公开侧不可读)' },
+ { value: 'published', label: '已发布(公开侧可读)' },
+ { value: 'archived', label: '已归档(公开侧不可读)' },
+];
+
+const STATUS_FILTER_OPTIONS: {
+ value: AdminThemeStatusFilter;
+ label: string;
+}[] = [
+ { value: 'all', label: '全部状态' },
+ { value: 'draft', label: '草稿' },
+ { value: 'published', label: '已发布' },
+ { value: 'archived', label: '已归档' },
+];
+
+/** 与接口契约同值的文本上限(前端先拦,服务端仍会独立校验)。 */
+const THEME_NAME_MAX_CHARS = 40;
+const THEME_SUMMARY_MAX_CHARS = 200;
+const THEME_BADGE_MAX_CHARS = 16;
+/** 列表 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。 */
+const THEME_LIST_LIMIT = 200;
+/** 成员名单每页条数:与服务端缺省值同值(上限 50 由 api 客户端夹住)。 */
+const MEMBER_PAGE_SIZE = 20;
+
+interface ThemeFormDraft {
+ name: string;
+ summary: string;
+ badge: string;
+ sortOrder: string;
+ status: GameDistributionThemeStatus;
+}
+
+const EMPTY_THEME_FORM: ThemeFormDraft = {
+ name: '',
+ summary: '',
+ badge: '',
+ sortOrder: '0',
+ status: 'draft',
+};
+
+function themeStatusMeta(status: string) {
+ return (
+ THEME_STATUS_META[status as GameDistributionThemeStatus] ?? {
+ label: status || '—',
+ tone: 'pending' as AdminStatusTone,
+ }
+ );
+}
+
+function draftFromTheme(theme: GameDistributionAdminTheme): ThemeFormDraft {
+ return {
+ name: theme.name,
+ summary: theme.summary,
+ badge: theme.badge,
+ sortOrder: String(theme.sortOrder),
+ status: theme.status,
+ };
+}
+
+/**
+ * 成员行的可见性文案。
+ *
+ * `visible` 与 `visibility` 是**两个独立字段**,这里按服务端给的事实映射,不做互相推导:
+ * `visible === false` 且 `visibility === 'published'` 表示「作品已公开,但没有当前公开版本」——
+ * 这是刻意要让人看出来的异常,不能糊成一句「不可见」。
+ */
+function memberVisibilityMeta(member: GameDistributionAdminThemeMemberRow): {
+ label: string;
+ tone: AdminStatusTone;
+} {
+ if (member.visible) {
+ return { label: '公开可见', tone: 'ok' };
+ }
+ const reasons: Record = {
+ published: '已公开,但无公开版本',
+ unpublished: '作品未公开',
+ suspended: '作品已下架',
+ deleted: '作品已删除',
+ missing: '游戏行不存在',
+ };
+ const reason = reasons[member.visibility] ?? '不可见';
+ return {
+ label: reason,
+ tone: member.visibility === 'published' ? 'pending' : 'error',
+ };
+}
+
+/**
+ * 主题写操作的幂等键:**按请求指纹复用**。
+ *
+ * 同一次编辑(指纹不变)重试必须复用同一个键,否则服务端会把它当成新请求;而改了内容再提交就是
+ * 另一次操作,键必须换(同键换请求服务端回 409)。键前缀只用于排障时区分是哪个页面的写入。
+ */
+function createThemeIdempotencyKey(action: string) {
+ const random =
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ return `theme-${action}-${random}`.slice(0, 128);
+}
+
+/** 表单校验:返回可读错误(与接口契约同值),空串表示通过。 */
+export function validateThemeForm(draft: ThemeFormDraft): string {
+ const name = draft.name.trim();
+ if (!name) {
+ return '主题名不能为空';
+ }
+ if (Array.from(name).length > THEME_NAME_MAX_CHARS) {
+ return `主题名最多 ${THEME_NAME_MAX_CHARS} 个字符`;
+ }
+ if (Array.from(draft.summary.trim()).length > THEME_SUMMARY_MAX_CHARS) {
+ return `主题简介最多 ${THEME_SUMMARY_MAX_CHARS} 个字符`;
+ }
+ if (Array.from(draft.badge.trim()).length > THEME_BADGE_MAX_CHARS) {
+ return `角标最多 ${THEME_BADGE_MAX_CHARS} 个字符`;
+ }
+ if (
+ !Number.isFinite(Number(draft.sortOrder)) ||
+ !Number.isInteger(Number(draft.sortOrder))
+ ) {
+ return '排序号必须是整数';
+ }
+ return '';
+}
+
+/**
+ * 主题写操作的可读错误:按**服务端稳定错误码**分支(不匹配中文文案),
+ * 其余按服务端 message 原样展示(不吞掉真实原因)。
+ */
+export function themeWriteErrorMessage(error: unknown): string {
+ if (isAdminApiError(error)) {
+ switch (error.code) {
+ case 'THEME_MEMBER_NOT_ROOT':
+ return '该作品不是根作品(母版);主题只能收录根作品';
+ case 'THEME_MEMBER_GAME_NOT_FOUND':
+ return '没有找到这个根作品 ID,请确认后再添加';
+ case 'THEME_NOT_FOUND':
+ return '这个主题已不存在,请刷新列表';
+ case 'THEME_IDEMPOTENCY_CONFLICT':
+ return '这次提交的幂等键已经用在别的内容上,请刷新页面后重试';
+ default:
+ return error.message;
+ }
+ }
+ return error instanceof Error ? error.message : '操作失败';
+}
+
+export function AdminGameThemesPage({
+ token,
+ onUnauthorized,
+}: AdminGameThemesPageProps) {
+ const [themes, setThemes] = useState([]);
+ const [statusFilter, setStatusFilter] =
+ useState('all');
+ const [draftFilter, setDraftFilter] = useState('all');
+ const [isLoading, setIsLoading] = useState(false);
+ const [hasLoaded, setHasLoaded] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [statusMessage, setStatusMessage] = useState('');
+ const [createOpen, setCreateOpen] = useState(false);
+ const [createDraft, setCreateDraft] =
+ useState(EMPTY_THEME_FORM);
+ const [createError, setCreateError] = useState('');
+ const [editing, setEditing] = useState(
+ null,
+ );
+ const [editDraft, setEditDraft] = useState(EMPTY_THEME_FORM);
+ const [editError, setEditError] = useState('');
+ const [memberTheme, setMemberTheme] =
+ useState(null);
+ const [memberRootGameId, setMemberRootGameId] = useState('');
+ const [memberSortOrder, setMemberSortOrder] = useState('0');
+ const [memberError, setMemberError] = useState('');
+ const [memberNotice, setMemberNotice] = useState('');
+ /**
+ * 成员名单(后台接口,含当前对外不可见的成员)。
+ *
+ * `memberTotal` 是成员行总数、**不受分页影响**;`memberRows` 只是已加载的这几页,
+ * 两者不要互相推导。
+ */
+ const [memberRows, setMemberRows] = useState<
+ GameDistributionAdminThemeMemberRow[]
+ >([]);
+ const [memberTotal, setMemberTotal] = useState(0);
+ const [memberNextCursor, setMemberNextCursor] = useState(null);
+ const [memberListState, setMemberListState] = useState<
+ 'idle' | 'loading' | 'ready' | 'error'
+ >('idle');
+ const [memberListError, setMemberListError] = useState('');
+ const [memberBusyId, setMemberBusyId] = useState('');
+ const [busy, setBusy] = useState(false);
+ const writeConfirm = useAdminWriteConfirm();
+ const listController = useRef(null);
+ // 幂等键按「动作 + 请求指纹」复用:草稿不变时重试同一个键,改了内容才换键。
+ const idempotencyRef = useRef<{ fingerprint: string; key: string } | null>(
+ null,
+ );
+
+ const loadThemes = useCallback(
+ async (status: AdminThemeStatusFilter) => {
+ listController.current?.abort();
+ const controller = new AbortController();
+ listController.current = controller;
+ setIsLoading(true);
+ try {
+ const response = await listAdminGameDistributionThemes(
+ token,
+ { limit: THEME_LIST_LIMIT, status },
+ controller.signal,
+ );
+ setThemes(response.themes ?? []);
+ setHasLoaded(true);
+ setErrorMessage('');
+ } catch (error) {
+ if (controller.signal.aborted) {
+ return;
+ }
+ handlePageError(error, onUnauthorized, setErrorMessage);
+ } finally {
+ if (!controller.signal.aborted) {
+ setIsLoading(false);
+ }
+ }
+ },
+ [token, onUnauthorized],
+ );
+
+ useEffect(() => {
+ void loadThemes(statusFilter);
+ return () => {
+ listController.current?.abort();
+ };
+ }, [loadThemes, statusFilter]);
+
+ /**
+ * 成员面板打开时从**后台成员名单接口**读取首页。
+ *
+ * 这条接口不套公开可见性过滤,草稿 / 归档主题也照常可读——所以不再借公开投影(那条路对未发布
+ * 主题会 404,只能看到「可见成员」,两种口径混在一页里更让人困惑)。
+ */
+ useEffect(() => {
+ const themeId = memberTheme?.themeId;
+ if (!themeId) {
+ setMemberRows([]);
+ setMemberTotal(0);
+ setMemberNextCursor(null);
+ setMemberListState('idle');
+ setMemberListError('');
+ return;
+ }
+ const controller = new AbortController();
+ setMemberListState('loading');
+ setMemberListError('');
+ void listAdminGameDistributionThemeMembers(
+ token,
+ themeId,
+ { limit: MEMBER_PAGE_SIZE },
+ controller.signal,
+ )
+ .then((response) => {
+ setMemberRows(response.members ?? []);
+ setMemberTotal(response.totalMembers);
+ setMemberNextCursor(response.nextCursor);
+ setMemberListState('ready');
+ })
+ .catch((error: unknown) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ setMemberRows([]);
+ setMemberTotal(0);
+ setMemberNextCursor(null);
+ setMemberListState('error');
+ setMemberListError(
+ themeWriteErrorMessage(error) || '读取成员名单失败,请稍后重试',
+ );
+ });
+ return () => {
+ controller.abort();
+ };
+ }, [memberTheme?.themeId, token]);
+
+ /** 追加下一页成员(游标来自上一页的 `nextCursor`;非法游标服务端回 400,这里原样展示)。 */
+ async function loadMoreMembers() {
+ const themeId = memberTheme?.themeId;
+ const cursor = memberNextCursor;
+ if (!themeId || !cursor || memberListState === 'loading') {
+ return;
+ }
+ setMemberListState('loading');
+ setMemberListError('');
+ try {
+ const response = await listAdminGameDistributionThemeMembers(
+ token,
+ themeId,
+ {
+ limit: MEMBER_PAGE_SIZE,
+ cursor,
+ },
+ );
+ setMemberRows((current) => [...current, ...(response.members ?? [])]);
+ setMemberTotal(response.totalMembers);
+ setMemberNextCursor(response.nextCursor);
+ setMemberListState('ready');
+ } catch (error) {
+ setMemberListState('ready');
+ setMemberListError(themeWriteErrorMessage(error));
+ }
+ }
+
+ /** 成员名单重新读第一页(增删成员后调用,避免游标失效)。 */
+ async function reloadMembers(themeId: string) {
+ try {
+ const response = await listAdminGameDistributionThemeMembers(
+ token,
+ themeId,
+ {
+ limit: MEMBER_PAGE_SIZE,
+ },
+ );
+ setMemberRows(response.members ?? []);
+ setMemberTotal(response.totalMembers);
+ setMemberNextCursor(response.nextCursor);
+ setMemberListState('ready');
+ } catch (error) {
+ setMemberListState('error');
+ setMemberListError(themeWriteErrorMessage(error));
+ }
+ }
+
+ function themeFingerprint(action: string, payload: object) {
+ return `${action}-${JSON.stringify(payload)}`;
+ }
+
+ function stableThemeIdempotencyKey(action: string, payload: object) {
+ const fingerprint = themeFingerprint(action, payload);
+ if (idempotencyRef.current?.fingerprint === fingerprint) {
+ return idempotencyRef.current.key;
+ }
+ const key = createThemeIdempotencyKey(action);
+ idempotencyRef.current = { fingerprint, key };
+ return key;
+ }
+
+ async function submitCreate() {
+ const validation = validateThemeForm(createDraft);
+ if (validation) {
+ setCreateError(validation);
+ return;
+ }
+ const payload = {
+ name: createDraft.name.trim(),
+ summary: createDraft.summary.trim(),
+ badge: createDraft.badge.trim(),
+ sortOrder: Number(createDraft.sortOrder),
+ status: createDraft.status,
+ };
+ const confirmed = await writeConfirm.confirmWrite({
+ action: '创建共创主题',
+ target: payload.name,
+ });
+ if (!confirmed) {
+ return;
+ }
+ setBusy(true);
+ setCreateError('');
+ try {
+ const result = await createAdminGameDistributionTheme(
+ token,
+ stableThemeIdempotencyKey('create', payload),
+ payload,
+ );
+ setCreateOpen(false);
+ setCreateDraft(EMPTY_THEME_FORM);
+ idempotencyRef.current = null;
+ setStatusMessage(
+ result.replayed
+ ? `主题《${result.theme.name}》已存在(幂等重放,未重复写入)`
+ : `已创建主题《${result.theme.name}》`,
+ );
+ await loadThemes(statusFilter);
+ } catch (error) {
+ setCreateError(themeWriteErrorMessage(error));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function submitEdit() {
+ if (!editing) {
+ return;
+ }
+ const validation = validateThemeForm(editDraft);
+ if (validation) {
+ setEditError(validation);
+ return;
+ }
+ const payload = {
+ name: editDraft.name.trim(),
+ summary: editDraft.summary.trim(),
+ badge: editDraft.badge.trim(),
+ sortOrder: Number(editDraft.sortOrder),
+ status: editDraft.status,
+ };
+ const confirmed = await writeConfirm.confirmWrite({
+ action: '保存共创主题',
+ target: payload.name,
+ });
+ if (!confirmed) {
+ return;
+ }
+ setBusy(true);
+ setEditError('');
+ try {
+ const result = await updateAdminGameDistributionTheme(
+ token,
+ editing.themeId,
+ stableThemeIdempotencyKey('update', {
+ themeId: editing.themeId,
+ ...payload,
+ }),
+ payload,
+ );
+ setEditing(null);
+ idempotencyRef.current = null;
+ setStatusMessage(`已保存主题《${result.theme.name}》`);
+ await loadThemes(statusFilter);
+ } catch (error) {
+ setEditError(themeWriteErrorMessage(error));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function addMember() {
+ if (!memberTheme) {
+ return;
+ }
+ const rootGameId = memberRootGameId.trim();
+ if (!rootGameId) {
+ setMemberError('请填写根作品 ID');
+ return;
+ }
+ if (
+ !Number.isFinite(Number(memberSortOrder)) ||
+ !Number.isInteger(Number(memberSortOrder))
+ ) {
+ setMemberError('排序号必须是整数');
+ return;
+ }
+ setBusy(true);
+ setMemberError('');
+ setMemberNotice('');
+ try {
+ await upsertAdminGameDistributionThemeMember(
+ token,
+ memberTheme.themeId,
+ rootGameId,
+ { sortOrder: Number(memberSortOrder) },
+ );
+ setMemberRootGameId('');
+ setMemberSortOrder('0');
+ setMemberNotice(
+ `已把 ${rootGameId} 加入主题《${memberTheme.name}》(重复添加只更新排序号)`,
+ );
+ await loadThemes(statusFilter);
+ await reloadMembers(memberTheme.themeId);
+ } catch (error) {
+ setMemberError(themeWriteErrorMessage(error));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function removeMember(rootGameId: string) {
+ if (!memberTheme) {
+ return;
+ }
+ setBusy(true);
+ setMemberBusyId(rootGameId);
+ setMemberError('');
+ setMemberNotice('');
+ try {
+ await removeAdminGameDistributionThemeMember(
+ token,
+ memberTheme.themeId,
+ rootGameId,
+ );
+ setMemberNotice(`已从主题《${memberTheme.name}》移除 ${rootGameId}`);
+ await loadThemes(statusFilter);
+ // 重读第一页而不是本地删一行:游标基于排序,本地删会让后续翻页错位。
+ await reloadMembers(memberTheme.themeId);
+ } catch (error) {
+ setMemberError(themeWriteErrorMessage(error));
+ } finally {
+ setBusy(false);
+ setMemberBusyId('');
+ }
+ }
+
+ const currentTheme = memberTheme
+ ? (themes.find((theme) => theme.themeId === memberTheme.themeId) ??
+ memberTheme)
+ : null;
+
+ return (
+
+
+ {
+ setCreateDraft(EMPTY_THEME_FORM);
+ setCreateError('');
+ setCreateOpen(true);
+ }}
+ >
+ 新建主题
+
+ void loadThemes(statusFilter)}
+ disabled={isLoading}
+ >
+
+ 刷新
+
+ >
+ }
+ />
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+ {statusMessage ? (
+ {statusMessage}
+ ) : null}
+
+
+ 主题把同一母版(根作品)下的二创作品收在一起对外展示。最多一次读取{' '}
+ {THEME_LIST_LIMIT} 条(服务端上限,超出按上限截断,没有游标)。
+
+
+
+
+
+ {themes.length ? `共 ${themes.length} 条` : ''}
+
+ }
+ rows={themes}
+ size="wide"
+ tableClassName="admin-game-themes-table"
+ columns={[
+ { key: 'name', label: '名称' },
+ { key: 'badge', label: '角标' },
+ { key: 'status', label: '状态' },
+ { key: 'sortOrder', label: '排序' },
+ { key: 'memberCount', label: '成员数' },
+ { key: 'themeId', label: 'themeId' },
+ { key: 'actions', label: '操作' },
+ ]}
+ renderRow={(theme) => {
+ const status = themeStatusMeta(theme.status);
+ return (
+
+ |
+ {theme.name?.trim() || '—'}
+ {theme.summary?.trim() ? (
+ {theme.summary}
+ ) : null}
+ |
+ {theme.badge?.trim() || '—'} |
+
+
+ {status.label}
+
+ {theme.status === 'archived' ? (
+
+ 已归档:公开侧不可读
+
+ ) : null}
+ |
+ {theme.sortOrder} |
+ {theme.memberCount} |
+
+
+ {theme.themeId}
+
+ |
+
+
+ {
+ setEditDraft(draftFromTheme(theme));
+ setEditError('');
+ setEditing(theme);
+ }}
+ >
+ 编辑
+
+ {
+ setMemberTheme(theme);
+ setMemberRootGameId('');
+ setMemberSortOrder('0');
+ setMemberError('');
+ setMemberNotice('');
+ }}
+ >
+ 成员
+
+
+ |
+
+ );
+ }}
+ />
+
+ {createOpen ? (
+ setCreateOpen(false)}
+ onSubmit={(event) => {
+ event.preventDefault();
+ void submitCreate();
+ }}
+ >
+
+ {createError ? (
+
+ {createError}
+
+ ) : null}
+
+
+ {busy ? '提交中…' : '创建'}
+
+ setCreateOpen(false)}
+ >
+ 取消
+
+
+
+ ) : null}
+
+ {editing ? (
+ 整体覆盖主题字段({editing.themeId})>}
+ titleProps={{ id: 'admin-game-theme-edit-title' }}
+ onClose={() => setEditing(null)}
+ onSubmit={(event) => {
+ event.preventDefault();
+ void submitEdit();
+ }}
+ >
+
+ {editError ? (
+
+ {editError}
+
+ ) : null}
+
+
+ {busy ? '保存中…' : '保存'}
+
+ setEditing(null)}
+ >
+ 取消
+
+
+
+ ) : null}
+
+ {currentTheme ? (
+
+ 《{currentTheme.name}》· 成员数 {currentTheme.memberCount}
+ >
+ }
+ titleProps={{ id: 'admin-game-theme-members-title' }}
+ onClose={() => setMemberTheme(null)}
+ onSubmit={(event) => {
+ event.preventDefault();
+ void addMember();
+ }}
+ >
+
+ {
+ setMemberRootGameId(event.target.value);
+ if (memberError) setMemberError('');
+ }}
+ disabled={busy}
+ />
+
+
+ setMemberSortOrder(event.target.value)}
+ disabled={busy}
+ />
+
+ {memberError ? (
+
+ {memberError}
+
+ ) : null}
+ {memberNotice ? (
+ {memberNotice}
+ ) : null}
+ {/*
+ 成员名单来自后台接口:**不套公开可见性过滤**,草稿 / 归档主题也照常可读,且每行都带
+ `visible`(此刻公开投影是否包含它)与 `visibility`(为什么)。两者独立,界面不互相推导:
+ `published` + 不可见 = 「作品已公开、但没有公开版本」这种异常,必须能看出来。
+ */}
+ {memberListState === 'loading' && memberRows.length === 0 ? (
+ 正在读取成员名单…
+ ) : null}
+ {memberListError ? (
+
+ {memberListError}
+
+ ) : null}
+ {memberRows.length > 0 ? (
+ <>
+
+ {memberRows.map((member) => {
+ const visibility = memberVisibilityMeta(member);
+ return (
+ -
+
+ {member.title?.trim() || '(游戏行不存在)'}{' '}
+
{member.rootGameId}{' '}
+
+ 排序 {member.sortOrder} · 挂载于{' '}
+ {formatAdminDateTime(member.createdAt)}
+
+
+
+ {visibility.label}
+
+ void removeMember(member.rootGameId)}
+ >
+ {memberBusyId === member.rootGameId
+ ? '移除中…'
+ : '移除'}
+
+
+ );
+ })}
+
+
+ 成员行总数 {memberTotal};已加载 {memberRows.length} 条。
+
+ {memberNextCursor ? (
+ void loadMoreMembers()}
+ >
+ {memberListState === 'loading' ? '加载中…' : '加载更多成员'}
+
+ ) : null}
+ >
+ ) : memberListState === 'ready' ? (
+
+ 这个主题还没有成员;用上面的「根作品 ID」添加。
+
+ ) : null}
+
+
+ {busy ? '提交中…' : '添加成员'}
+
+ setMemberTheme(null)}
+ >
+ 关闭
+
+
+ {/*
+ 服务端没有「后台读取主题成员」的接口(只有 PUT/DELETE 按根作品 ID 操作),因此这里
+ 不假装给出一份成员名单:只能按 ID 移除。列表里的成员数是成员行总数。
+ */}
+
+ 目前只能按根作品 ID
+ 移除成员(后台暂无成员读取接口);成员总数见上方「成员数」。
+
+
+ ) : null}
+
+ {writeConfirm.confirmDialog}
+
+ );
+}
+
+function ThemeFormFields({
+ draft,
+ disabled,
+ namePrefix,
+ onChange,
+}: {
+ draft: ThemeFormDraft;
+ disabled: boolean;
+ namePrefix: string;
+ onChange: (draft: ThemeFormDraft) => void;
+}) {
+ return (
+ <>
+
+ onChange({ ...draft, name: event.target.value })}
+ disabled={disabled}
+ />
+
+
+
+
+
+ onChange({ ...draft, badge: event.target.value })
+ }
+ disabled={disabled}
+ />
+
+
+
+ onChange({ ...draft, sortOrder: event.target.value })
+ }
+ disabled={disabled}
+ />
+
+
+
+
+
+ 归档(archived)后公开侧不可读;草稿(draft)同样不对外展示,只有「已发布」会在主题页与
+ 作品详情里出现。
+
+ >
+ );
+}
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index 1d13d7c28..f176e5092 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -135,6 +135,20 @@ const allowedUncalledTauriCommands = [
// 资料建议三条命令已随旧的发布表单一起退役(Rust 侧实现与注册都已删除),不再登记。
'read_game_publish_availability',
'read_game_distribution_publication',
+ // 共创列表封面:命令名由渲染层以常量 `COVER_COMMAND` 传给**注入的** invoke
+ // (`src/features/platform-fork/platformForkCover.ts`,注入是为了让封面解析能单测),
+ // 静态扫描只认字面量调用点,因此采不到这条命令名 → 显式登记为 native-only 白名单。
+ // 同一批的 `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 发布链路改为在 Rust 内直读
+ // `.agent/fork-source.json`,本命令留给发布面板接线后复用),命令本身仍注册在 Rust 侧,
+ // 仅不出现在 App 前端源码里。
+ 'read_local_project_fork_source',
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
// 里以字面量出现,静态扫描仍按共享注册表核验。
'read_profile_recharge_center',
diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock
index 8f641dfe6..412a201ac 100644
--- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock
+++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock
@@ -671,7 +671,7 @@ dependencies = [
"tracing",
"url",
"which",
- "windows-registry",
+ "windows-registry 0.6.1",
]
[[package]]
@@ -820,6 +820,26 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
[[package]]
name = "const_fn"
version = "0.4.12"
@@ -1164,6 +1184,15 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "dlv-list"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
+dependencies = [
+ "const-random",
+]
+
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -1767,6 +1796,7 @@ dependencies = [
"jsonschema",
"libc",
"maud",
+ "module-game-distribution",
"nalgebra",
"oxc_allocator",
"oxc_ast",
@@ -1789,8 +1819,10 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
+ "tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-opener",
+ "tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"tokio",
@@ -2068,6 +2100,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2242,7 +2280,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
- "windows-registry",
+ "windows-registry 0.6.1",
]
[[package]]
@@ -2890,6 +2928,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "module-game-distribution"
+version = "0.1.0"
+dependencies = [
+ "hex",
+ "serde",
+ "sha2",
+ "shared-kernel",
+ "zip 2.4.2",
+]
+
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -3420,6 +3469,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-multimap"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
+dependencies = [
+ "dlv-list",
+ "hashbrown 0.14.5",
+]
+
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -4488,6 +4547,16 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rust-ini"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
+dependencies = [
+ "cfg-if",
+ "ordered-multimap",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -5000,6 +5069,14 @@ dependencies = [
"ts-rs 12.0.1",
]
+[[package]]
+name = "shared-kernel"
+version = "0.1.0"
+dependencies = [
+ "time",
+ "uuid",
+]
+
[[package]]
name = "shared_library"
version = "0.1.9"
@@ -5535,6 +5612,27 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "tauri-plugin-deep-link"
+version = "2.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6"
+dependencies = [
+ "dunce",
+ "plist",
+ "rust-ini",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "tracing",
+ "url",
+ "windows-registry 0.5.3",
+ "windows-result 0.3.4",
+]
+
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
@@ -5599,6 +5697,23 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "tauri-plugin-single-instance"
+version = "2.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin-deep-link",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "windows-sys 0.60.2",
+ "zbus",
+]
+
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
@@ -5859,6 +5974,15 @@ dependencies = [
"time-core",
]
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -6999,6 +7123,17 @@ dependencies = [
"windows-link 0.1.3",
]
+[[package]]
+name = "windows-registry"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
+dependencies = [
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
[[package]]
name = "windows-registry"
version = "0.6.1"
diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml
index 5f3f50505..cc8eb3042 100644
--- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml
+++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml
@@ -68,6 +68,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul
regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
tauri = { version = "2.11.2", features = [] }
+tauri-plugin-deep-link = "2.4.9"
+tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2.11.0"
@@ -82,6 +84,11 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-plugin-clipboard-manager = "2.3.2"
maud = "0.27.0"
+# P1-b 执行级交叉证据:客户端打包器产出的字节直接喂给服务端校验器
+# (`module-game-distribution::validate_project_bundle_zip`),替代只有文本级的一致性门禁。
+[dev-dependencies]
+module-game-distribution = { path = "../../../server-rs/crates/module-game-distribution" }
+
[target.'cfg(unix)'.dependencies]
libc = "0.2"
diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs
index 2c3e38b94..2d56ff21c 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs
@@ -60,7 +60,9 @@ values!(CreationSource {
HomeGame,
HomeDesign,
Template,
- SelectedDirectory
+ SelectedDirectory,
+ // 从平台作品 Fork:把平台作品的工程复制成本地项目继续改造。
+ PlatformGame
});
values!(OpenSource {
Create,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs
index 25eacdf5c..f5d787b4c 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs
@@ -275,3 +275,24 @@ fn frozen_context_and_origin_do_not_inherit_new_account() {
== false
);
}
+
+/// 新增创建来源变体(平台作品改编)不得改变既有取值或解析:老变体照常,新变体按 snake_case
+/// 落到 `platform_game`,未知值仍然失败关闭。
+#[test]
+fn creation_source_gains_platform_game_without_breaking_existing_values() {
+ for (wire, expected) in [
+ ("home_game", CreationSource::HomeGame),
+ ("home_design", CreationSource::HomeDesign),
+ ("template", CreationSource::Template),
+ ("selected_directory", CreationSource::SelectedDirectory),
+ ("platform_game", CreationSource::PlatformGame),
+ ] {
+ assert_eq!(
+ serde_json::from_value::(json!(wire)).unwrap(),
+ expected,
+ "{wire}"
+ );
+ assert_eq!(serde_json::to_value(expected).unwrap(), json!(wire));
+ }
+ assert!(serde_json::from_value::(json!("remix")).is_err());
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
index 8cceb9bef..bf2b43f39 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
@@ -16,6 +16,7 @@ use export::draft::xhs_minitool::commands::{
save_xhs_minitool_export_form,
};
use game_distribution_publish::*;
+use game_fork::*;
use plugin_host::{
call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel,
refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path,
@@ -398,6 +399,13 @@ pub(super) fn run() {
)));
let setup_log = Arc::clone(&startup_log);
let app = tauri::Builder::default()
+ // 单实例必须第一个注册:Windows/Linux 的深链是「带 URL 启动第二个进程」,
+ // 单实例插件把第二个实例的 argv 转交给已有实例(`deep-link` feature 会让 deep-link
+ // 插件据此发事件),否则用户点第二次链接会再开一个窗口而不是回到已有窗口。
+ .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
+ game_fork::focus_main_window_for_deep_link(app);
+ }))
+ .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -535,6 +543,8 @@ pub(super) fn run() {
)
})?;
setup_log.append("startup.runner.start.complete");
+ // Fork 深链:先取冷启动 URL,再订阅后续链接(并补一次协议注册)。
+ game_fork::initialize_fork_deep_link(app.handle());
setup_log.append("startup.setup.complete");
Ok(())
})
@@ -547,6 +557,7 @@ pub(super) fn run() {
stop_game_creator_external_mcp,
create_automatic_local_game_project,
create_automatic_local_game_project_from_template,
+ create_local_project_from_platform_game,
init_local_game_project,
fetch_game_template_library,
get_game_template_library_access,
@@ -618,6 +629,10 @@ pub(super) fn run() {
login_client_with_phone_code,
logout_client_session,
read_game_publish_availability,
+ list_platform_game_catalog,
+ read_public_game_cover_preview,
+ read_public_game_detail,
+ read_local_project_fork_source,
read_game_distribution_publication,
publish_local_project_game,
read_game_creator_app_config,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
index de9cfca44..6d0b85892 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
@@ -6,13 +6,15 @@
use crate::game_package_upload::{
game_package_upload_staging_dir, server_error_detail, stage_game_package_bytes,
- upload_staged_game_package, GamePackageUploadOutcome, GamePackageUploadRequest,
- StagedGamePackage,
+ upload_staged_version_asset, GamePackageUploadOutcome, GamePackageUploadRequest,
+ PackageUploadAsset, StagedGamePackage,
};
use crate::http_client::agc_main_site_client_builder;
use crate::platform_session::{
current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot,
};
+use base64::Engine;
+use futures::StreamExt;
use reqwest::header::{HeaderName, HeaderValue, CONTENT_TYPE};
use reqwest::{Method, StatusCode};
@@ -24,8 +26,9 @@ use shared_contracts::game_creation_app::{
GameCreationAppManifest, GameCreationAppPublicationBinding,
};
use shared_contracts::game_distribution::{
- GameDistributionDeviceSupport, GameDistributionInputMode, GameDistributionOrientation,
- GameMetadata,
+ GameDistributionDeviceSupport, GameDistributionForkAuthorization,
+ GameDistributionForkDeclaration, GameDistributionForkSource, GameDistributionInputMode,
+ GameDistributionListResponse, GameDistributionOrientation, GameMetadata,
};
use std::io::Read;
use std::path::Path;
@@ -38,6 +41,8 @@ const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client";
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
+const COVER_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024;
+
const PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress";
/// JS `Number.MAX_SAFE_INTEGER`:版本号经 JSON number 传递,超过它就会丢精度。
@@ -68,6 +73,12 @@ pub(crate) struct GameDistributionPublishResult {
/// 本次写回的发布绑定,便于调用方不解析 manifest 也能就地更新。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) publication: Option,
+ /// 工程源包**未**随版本上传时的用户可见原因(`None` = 未尝试或已成功)。
+ ///
+ /// 上传工程源包失败绝不阻断发布:作者的作品已经可玩可发布,只是退化为「产物级改编」。
+ /// 面板据此提示「未上传工程源码,本作品只能被产物级改编」。
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) project_bundle_warning: Option,
}
/// 回读版本冻结资料时带回的封面/截图草稿条目。
@@ -188,6 +199,11 @@ struct PublishVersionMetadata<'a> {
package_file_count: u32,
package_entry_path: &'a str,
game_metadata: &'a GameMetadata,
+ /// 二创「本次核心改动说明」:面板采集后原样透传;必填与长度规则只在服务端
+ /// (`game_distribution_change_summary_violation`),这里不预校验,避免两套判据漂移。
+ /// trim 后为空按「未填写」处理,与契约的 `skip_serializing_if` 语义一致。
+ #[serde(skip_serializing_if = "Option::is_none")]
+ change_summary: Option,
}
/// 统一发布响应:一次拿到游戏身份(含 `publicationRevision`)与冻结版本。
@@ -272,7 +288,10 @@ struct SubmittedVersionSummary {
status: Option,
}
-fn require_platform_session() -> Result {
+/// 取当前平台会话;缺失时给出可操作的登录提示。
+///
+/// `pub(crate)`:Fork 取件(`game_fork`)在建项前要冻结同一份会话身份。
+pub(crate) fn require_platform_session() -> Result {
current_platform_session()
.ok_or_else(|| "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string())
}
@@ -584,6 +603,227 @@ fn parse_owner_game_entry(value: Value) -> Result {
serde_json::from_value(value).map_err(|error| format!("作者作品响应无效:{error}"))
}
+/// 取件整包上限:与模板整包同一档(512 MiB),也远低于服务端进程内缓存上限的 4 倍余量。
+const FORK_SOURCE_PACKAGE_MAX_BYTES: u64 = 512 * 1024 * 1024;
+
+/// 取件失败的用户可见分类:404 / 409 / 403 / 401 四态必须可区分。
+///
+/// 状态码本身就是服务端合同(`fork_source_target`):行不存在 → 404;不可用作来源
+/// (未公开 / 已软删除 / 没有当前公开版本 / 缺版本元数据)→ 409;授权禁止或未知 → 403;
+/// 未登录 → 401。因此这里按状态映射,不依赖 `error.code`。
+///
+/// 四个分类各自带稳定前缀(`authentication-required:` / `permission-denied:` /
+/// `fork-source-not-found:` / `fork-source-not-available:`):渲染层按前缀区分「需要登录 /
+/// 未开放授权 / 作品不存在 / 作品不可用」并按需要去掉前缀展示,不做子串猜测。
+fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
+ crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
+ let (code, message) = parse_error_payload(body);
+ match status {
+ StatusCode::UNAUTHORIZED => {
+ "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string()
+ }
+ StatusCode::FORBIDDEN => {
+ "permission-denied: 作者没有开放这个作品的共创授权,无法开始共创".to_string()
+ }
+ StatusCode::NOT_FOUND => {
+ "fork-source-not-found: 该作品不存在或已被删除,无法开始共创".to_string()
+ }
+ StatusCode::CONFLICT => {
+ "fork-source-not-available: 该作品当前不能开始共创(未公开、已下架或没有公开版本)"
+ .to_string()
+ }
+ _ => format!(
+ "读取共创信息失败:{}",
+ server_error_detail(code, message, || format!("HTTP {}", status.as_u16()))
+ ),
+ }
+}
+
+/// 取件下载路径 → 路径段:只接受同源相对路径,拒绝绝对 URL、盘符、反斜杠与查询/片段。
+fn fork_source_download_segments(download_path: &str) -> Result, String> {
+ let trimmed = download_path.trim();
+ if !trimmed.starts_with('/') || trimmed.starts_with("//") {
+ return Err("共创内容下载路径无效".to_string());
+ }
+ if trimmed.contains(['\\', '?', '#']) || trimmed.contains("://") {
+ return Err("共创内容下载路径无效".to_string());
+ }
+ let segments = trimmed
+ .split('/')
+ .filter(|segment| !segment.is_empty())
+ .map(str::to_string)
+ .collect::>();
+ if segments.is_empty()
+ || segments
+ .iter()
+ .any(|segment| segment == "." || segment == ".." || segment.contains(':'))
+ {
+ return Err("共创内容下载路径无效".to_string());
+ }
+ Ok(segments)
+}
+
+/// 取件元数据(`GET …/fork-source`):受鉴权但不叠加发布灰度。
+async fn request_fork_source_metadata(
+ client: &reqwest::Client,
+ snapshot: &PlatformSessionSnapshot,
+ game_id: &str,
+) -> Result {
+ let current = current_scoped_session(snapshot)?;
+ let url = endpoint(
+ snapshot,
+ &["api", "game-distribution", "games", game_id, "fork-source"],
+ )?;
+ let response = client
+ .request(Method::GET, &url)
+ .bearer_auth(¤t.access_token)
+ .header(
+ HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
+ HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
+ )
+ .header(
+ HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER),
+ HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION),
+ )
+ .timeout(HTTP_TIMEOUT)
+ .send()
+ .await
+ .map_err(|error| {
+ if error.is_timeout() {
+ "读取共创信息失败:请求超时,请稍后重试".to_string()
+ } else {
+ "读取共创信息失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
+ .to_string()
+ }
+ })?;
+ let status = response.status();
+ let text = response
+ .text()
+ .await
+ .map_err(|error| format!("读取共创信息失败:读取响应失败:{error}"))?;
+ validate_session(snapshot)?;
+ if !status.is_success() {
+ return Err(map_fork_source_http_error(status, &text));
+ }
+ response_data(&text).map_err(|error| format!("读取共创信息失败:{error}"))
+}
+
+/// 取件本体(`GET {downloadPath}`):整包进内存,超过契约上限即失败关闭。
+///
+/// **必须带 Bearer**:取件包与发行网关同源受保护,模板库那条 `fetch_limited_bytes` 的签名
+/// 写死 `client.get(url)`、带不了鉴权头,绝不能复用它来取受保护内容(会静默变成匿名下载)。
+async fn request_fork_source_package(
+ client: &reqwest::Client,
+ snapshot: &PlatformSessionSnapshot,
+ segments: &[String],
+ max_bytes: u64,
+) -> Result, String> {
+ let current = current_scoped_session(snapshot)?;
+ let segment_refs = segments.iter().map(String::as_str).collect::>();
+ let url = endpoint(snapshot, &segment_refs)?;
+ let response = client
+ .request(Method::GET, &url)
+ .bearer_auth(¤t.access_token)
+ .header(
+ HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
+ HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
+ )
+ .header(
+ HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER),
+ HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION),
+ )
+ .timeout(HTTP_TIMEOUT)
+ .send()
+ .await
+ .map_err(|error| {
+ if error.is_timeout() {
+ "下载共创内容失败:请求超时,请稍后重试".to_string()
+ } else {
+ "下载共创内容失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
+ .to_string()
+ }
+ })?;
+ let status = response.status();
+ if !status.is_success() {
+ let text = response.text().await.unwrap_or_default();
+ validate_session(snapshot)?;
+ return Err(map_fork_source_http_error(status, &text));
+ }
+ if response
+ .content_length()
+ .is_some_and(|length| length > max_bytes)
+ {
+ return Err("共创内容超过客户端下载上限,无法完成".to_string());
+ }
+ let mut body = Vec::new();
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|error| format!("下载共创内容失败:读取响应失败:{error}"))?;
+ if body.len() as u64 + chunk.len() as u64 > max_bytes {
+ return Err("共创内容超过客户端下载上限,无法完成".to_string());
+ }
+ body.extend_from_slice(&chunk);
+ }
+ validate_session(snapshot)?;
+ Ok(body)
+}
+
+/// 公开目录每页条数:与服务端口径一致(缺省 20 / 上限 50)。
+pub(crate) fn clamped_catalog_limit(limit: Option) -> u32 {
+ const DEFAULT: u32 = 20;
+ const MAX: u32 = 50;
+ match limit {
+ Some(0) | None => DEFAULT,
+ Some(value) => value.min(MAX),
+ }
+}
+
+/// 作品 ID 的路径安全判据:只接受 ASCII 字母数字与 `-`/`_`。
+///
+/// 取件请求把作品 ID 当路径段拼进 URL,深链解析也用它校验链接里的取值——两处同一口径。
+pub(crate) fn is_safe_fork_game_id(value: &str) -> bool {
+ !value.is_empty()
+ && value.chars().all(|character| {
+ character.is_ascii_alphanumeric() || character == '-' || character == '_'
+ })
+}
+
+/// 取件:读 Fork 来源元数据,再按 `bytes` 上限下载整包字节。
+///
+/// 摘要与字节数**不在这里**校验:调用方(`game_fork`)必须在任何落盘之前用
+/// `verify_fork_source_bytes` 校验,保证失败关闭发生在解压之前。
+pub(crate) async fn fetch_platform_game_fork_source(
+ game_id: &str,
+) -> Result<(GameDistributionForkSource, Vec), String> {
+ let game_id = game_id.trim();
+ // 路径安全判据与发行入口一致:只接受 ASCII 字母数字与 `-`/`_`,不接受分隔符与盘符。
+ if !is_safe_fork_game_id(game_id) {
+ return Err("共创来源作品 ID 无效".to_string());
+ }
+ let snapshot = require_platform_session()?;
+ let client = build_client()?;
+ let value = request_fork_source_metadata(&client, &snapshot, game_id).await?;
+ let source: GameDistributionForkSource = serde_json::from_value(
+ value
+ .get("forkSource")
+ .cloned()
+ .ok_or_else(|| "共创信息响应缺少来源信息,请稍后重试".to_string())?,
+ )
+ .map_err(|error| format!("共创信息响应无效:{error}"))?;
+ if source.game_id.trim().is_empty() || source.version_id.trim().is_empty() {
+ return Err("共创信息响应无效:缺少作品或版本标识".to_string());
+ }
+ if source.bytes == 0 || source.bytes > FORK_SOURCE_PACKAGE_MAX_BYTES {
+ return Err(format!(
+ "共创内容大小异常({} 字节),客户端无法下载",
+ source.bytes
+ ));
+ }
+ let segments = fork_source_download_segments(&source.download_path)?;
+ let bytes = request_fork_source_package(&client, &snapshot, &segments, source.bytes).await?;
+ Ok((source, bytes))
+}
+
fn first_publish_state(message: Option) -> GameDistributionPublicationReadResult {
GameDistributionPublicationReadResult {
state: "first-publish".to_string(),
@@ -1006,6 +1246,155 @@ pub(crate) async fn read_game_publish_availability() -> Result {
.unwrap_or(false))
}
+/// 匿名读平台公开 JSON:目录与详情共用同一段(**不带 Bearer**、带客户端标识与 envelope 头)。
+///
+/// 带上 Bearer 会把「匿名可读」变成依赖登录态,因此这里刻意不加;非 2xx 折成可读错误。
+async fn get_public_platform_json(
+ client: &reqwest::Client,
+ snapshot: &PlatformSessionSnapshot,
+ url: &str,
+ failure_label: &str,
+) -> Result {
+ let response = client
+ .request(Method::GET, url)
+ .header(
+ HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
+ HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
+ )
+ .header(
+ HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER),
+ HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION),
+ )
+ .timeout(HTTP_TIMEOUT)
+ .send()
+ .await
+ .map_err(|error| {
+ if error.is_timeout() {
+ format!("{failure_label}:请求超时,请稍后重试")
+ } else {
+ format!("{failure_label}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试")
+ }
+ })?;
+ let status = response.status();
+ let text = response
+ .text()
+ .await
+ .map_err(|error| format!("{failure_label}:读取响应失败:{error}"))?;
+ if !status.is_success() {
+ let (code, message) = parse_error_payload(&text);
+ return Err(format!(
+ "{failure_label}:{}",
+ server_error_detail(code, message, || format!("HTTP {}", status.as_u16()))
+ ));
+ }
+ response_data(&text).map_err(|error| format!("{failure_label}:{error}"))
+}
+
+/// 单个公开作品详情(匿名单读):父作品确认页要的标题 / 作者 / 授权类型 / 封面对象键都在这里。
+///
+/// 复用平台**既有**的公开详情接口(不新增平台接口),同样不带 Bearer。响应体是公开投影,由
+/// 服务端逐字段手拼 JSON,因此这里原样透传 `Value`,类型在渲染层按共享契约的
+/// `GameDistributionGame` 收口(Rust 侧不重复定义一份)。
+#[tauri::command]
+pub(crate) async fn read_public_game_detail(game_id: String) -> Result {
+ let game_id = game_id.trim();
+ if !is_safe_fork_game_id(game_id) {
+ return Err("作品标识无效".to_string());
+ }
+ let snapshot = require_platform_session()?;
+ let client = build_client()?;
+ let url = endpoint(&snapshot, &["api", "game-distribution", "games", game_id])?;
+ let value = get_public_platform_json(&client, &snapshot, &url, "读取作品详情失败").await?;
+ Ok(value)
+}
+
+/// 平台公开作品目录(`GET /api/game-distribution/games?limit=&cursor=`)。
+///
+/// 这条接口**匿名可读**,因此不叠加发布灰度、按公开投影返回;这里仍取平台会话只为拿 base URL
+/// (启动器本身在登录后才渲染,所以正常路径一定有会话),**不带 Bearer**——带上反而会把
+/// 「匿名可读」变成依赖登录态。
+///
+/// 分页与服务端同口径(缺省 20 / 上限 50),游标原样透传(服务端校验非法游标并回 400)。
+/// 「共创」页拿它当数据源,在该页做「只保留 `forkAuthorization !== 'forbidden'`」的过滤。
+#[tauri::command]
+pub(crate) async fn list_platform_game_catalog(
+ limit: Option,
+ cursor: Option,
+) -> Result {
+ let snapshot = require_platform_session()?;
+ let client = build_client()?;
+ let limit = clamped_catalog_limit(limit);
+ let cursor = cursor
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ // 用 `url` 拼查询串:游标形如 `"{ts}:{id}"`,必须按 query 规则编码而不是手拼。
+ let mut url = url::Url::parse(&endpoint(
+ &snapshot,
+ &["api", "game-distribution", "games"],
+ )?)
+ .map_err(|error| format!("拼装共创目录请求失败:{error}"))?;
+ {
+ let mut query = url.query_pairs_mut();
+ query.append_pair("limit", &limit.to_string());
+ if let Some(cursor) = cursor.as_deref() {
+ query.append_pair("cursor", cursor);
+ }
+ }
+ let value =
+ get_public_platform_json(&client, &snapshot, url.as_str(), "读取共创目录失败").await?;
+ serde_json::from_value(value).map_err(|error| format!("共创目录响应无效:{error}"))
+}
+
+/// 公开作品封面预览:把公开目录里的 `coverObjectKey` 换成**有界 data URL**。
+///
+/// 为什么不让渲染层直接拿签名地址去 `
`:AGC 既有约定是「渲染层不自行拼 read-url、
+/// 不接触对象键与 bearer token、也不让 WebView 直连签名地址」(见 `commands.rs` 里同一口径的注释)。
+/// 因此这里走与发布面板封面同一套链路:`read-url` 换签 + 有界下载 + data URL。
+///
+/// 失败与「没有封面」都由调用方按同一个分支处理(回落到首字占位),所以这里把失败如实返回成
+/// `Err`,不吞成空串。
+#[tauri::command]
+pub(crate) async fn read_public_game_cover_preview(object_key: String) -> Result {
+ let object_key = object_key.trim().to_string();
+ if object_key.is_empty() || object_key.len() > 512 || object_key.contains("..") {
+ return Err("封面对象键无效".to_string());
+ }
+ let snapshot = require_platform_session()?;
+ current_scoped_session(&snapshot)?;
+ let client = build_client()?;
+ let source = json!({ "objectKey": object_key });
+ let download = crate::assets::resolve_canvas_resource_download_with_limit_route_and_fence(
+ &client,
+ snapshot.api_base_url.as_str(),
+ snapshot.access_token.as_str(),
+ &source,
+ COVER_PREVIEW_MAX_BYTES,
+ "/api/assets/read-url",
+ || Ok(()),
+ )
+ .await?
+ .ok_or_else(|| "封面内容为空".to_string())?;
+ current_scoped_session(&snapshot)?;
+ cover_preview_data_url(&download.media_type, &download.bytes)
+}
+
+fn cover_preview_data_url(media_type: &str, bytes: &[u8]) -> Result {
+ let media_type = media_type
+ .split(';')
+ .next()
+ .unwrap_or(media_type)
+ .trim()
+ .to_ascii_lowercase();
+ if !media_type.starts_with("image/") {
+ return Err("游戏封面预览不是受支持的图片类型".to_string());
+ }
+ if bytes.is_empty() || bytes.len() > COVER_PREVIEW_MAX_BYTES {
+ return Err("游戏封面预览超过安全大小限制".to_string());
+ }
+ let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
+ Ok(format!("data:{media_type};base64,{encoded}"))
+}
+
/// 发布面板打开时的绑定恢复。
///
/// 只有「本地绑定(且属于当前账号/origin)+ 线上作品」或「无本地绑定时按作者 + projectKey
@@ -1390,6 +1779,89 @@ fn emit_publish_progress(
}
}
+/// 把工程源包(当前项目源码)打成包并随版本上传。
+///
+/// 调用点在发布流程送审之前——服务端只允许 `awaiting_upload` / `upload_failed` 档位补传工程包,
+/// 送审后就是 409。失败只向上抛一句原因,由调用方转成面板提示,**不阻断发布**。
+#[allow(clippy::too_many_arguments)]
+async fn upload_project_bundle_for_version(
+ app: &AppHandle,
+ client: &reqwest::Client,
+ snapshot: &PlatformSessionSnapshot,
+ session_identity: &crate::platform_session::PlatformSessionIdentity,
+ app_data_dir: &Path,
+ project_root: &Path,
+ version_id: &str,
+ root_key: &str,
+) -> Result<(), String> {
+ let bundle = crate::project_bundle::build_project_bundle(
+ project_root,
+ crate::project_bundle::ProjectBundleScope::WholeProject,
+ )?;
+ // 与发行包同一套内容寻址暂存:同一份源码重传复用同一文件,续传才有意义。
+ let staged = stage_game_package_bytes(
+ &game_package_upload_staging_dir(app_data_dir),
+ &bundle.sha256,
+ &bundle.bytes,
+ )?;
+ let message = "正在上传工程源包(供他人源码级改编)…";
+ emit_publish_progress(
+ app,
+ root_key,
+ version_id,
+ "upload",
+ message,
+ 0,
+ staged.package_size_bytes,
+ );
+ let emit_handle = app.clone();
+ let progress_publish_id = root_key.to_string();
+ let progress_version_id = version_id.to_string();
+ let verify_handle = app.clone();
+ let verify_publish_id = root_key.to_string();
+ let verify_version_id = version_id.to_string();
+ upload_staged_version_asset(
+ client,
+ GamePackageUploadRequest {
+ asset: PackageUploadAsset::ProjectBundle,
+ staging_path: Path::new(&staged.staging_path),
+ version_id,
+ api_base_url: &snapshot.api_base_url,
+ access_token: &snapshot.access_token,
+ idempotency_key: &format!("{root_key}:project-bundle"),
+ session_identity: Some(session_identity),
+ },
+ move |received_bytes, total_bytes| {
+ emit_publish_progress(
+ &emit_handle,
+ &progress_publish_id,
+ &progress_version_id,
+ "upload",
+ message,
+ received_bytes,
+ total_bytes,
+ );
+ },
+ move || {
+ emit_publish_progress(
+ &verify_handle,
+ &verify_publish_id,
+ &verify_version_id,
+ "verify",
+ "正在校验工程源包…",
+ 0,
+ 0,
+ );
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+/// 发布:建游戏(首次)→ 建版本 → 上传发行包 → 〔授权非禁止时〕上传工程源包 → 送审。
+///
+/// `include_project_bundle`:是否随版本上传工程源包(面板勾选)。`None`/`true` = 授权非禁止时
+/// 默认上传;`false` = 作者取消(源码不外发,作品退化为产物级改编)。
#[tauri::command]
pub(crate) async fn publish_local_project_game(
app: AppHandle,
@@ -1405,8 +1877,12 @@ pub(crate) async fn publish_local_project_game(
version_number: Option,
expected_publication_revision: Option,
idempotency_key: Option,
+ include_project_bundle: Option,
// 买断价(整数泥点,`0` 表示免费)。旧前端不传时为 `None`,按免费提交,保持向后兼容。
price_mud_points: Option,
+ // 衍生作品的「本次核心改动说明」(母版可空):面板采集后原样透传;必填与长度规则只在服务端
+ // (`game_distribution_change_summary_violation`),这里不预校验,避免两套判据漂移。
+ change_summary: Option,
) -> Result {
let snapshot = require_platform_session()?;
let app_data_dir = app
@@ -1448,12 +1924,34 @@ 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,
+ });
+ }
+ }
// 买断价只允许 `0..=上限`;面板已按同一口径拦住非法输入,这里对命令边界再收一次口。
let price_mud_points = price_mud_points.unwrap_or(0);
if price_mud_points > MAX_GAME_PRICE_MUD_POINTS {
return Err(format!("买断价不能超过 {MAX_GAME_PRICE_MUD_POINTS} 泥点"));
}
let digest = metadata_digest(&metadata, price_mud_points, &media)?;
+ // 工程源包要不要一起传,只取决于**授权档位**与面板勾选,与后面把 metadata 交给
+ // 创建版本请求无关:这里先算出来,避免 metadata 被 move 之后再用它。
+ let fork_authorized = !matches!(
+ metadata.fork_authorization,
+ GameDistributionForkAuthorization::Forbidden
+ );
+ let include_project_bundle = fork_authorized && include_project_bundle.unwrap_or(true);
// 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析:
// 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上;
// 包内容、目标版本标签或资料任一变化都换键,新标签的提交不会被幂等收据吞掉。
@@ -1507,6 +2005,9 @@ pub(crate) async fn publish_local_project_game(
package_file_count: staged.package_file_count,
package_entry_path: "index.html",
game_metadata: &metadata,
+ change_summary: change_summary
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty()),
};
let publish_value = request_multipart(
&client,
@@ -1550,9 +2051,10 @@ pub(crate) async fn publish_local_project_game(
);
let upload_publish_id = root_key.clone();
let verify_publish_id = root_key.clone();
- let uploaded: GamePackageUploadOutcome = upload_staged_game_package(
+ let uploaded: GamePackageUploadOutcome = upload_staged_version_asset(
&client,
GamePackageUploadRequest {
+ asset: PackageUploadAsset::ReleasePackage,
staging_path: Path::new(&staged.staging_path),
version_id: &version.version_id,
api_base_url: &snapshot.api_base_url,
@@ -1585,6 +2087,26 @@ pub(crate) async fn publish_local_project_game(
)
.await?;
validate_session(&snapshot)?;
+ // 工程源包(M2b):授权非「禁止共创」且作者没取消时,随这次版本一起传上去。发布流程在这里
+ // 还不是终态(下面才送审),正是服务端允许传工程包的阶段(`awaiting_upload` / `upload_failed`);
+ // 传失败**不阻断发布**,只把原因交给面板提示「只能被产物级改编」。
+ let project_bundle_warning = if include_project_bundle {
+ upload_project_bundle_for_version(
+ &app,
+ &client,
+ &snapshot,
+ &session_identity,
+ &app_data_dir,
+ root,
+ version.version_id.as_str(),
+ root_key.as_str(),
+ )
+ .await
+ .err()
+ .map(|error| format!("未上传工程源码,他人开始共创时只能拿到平台已发布的成品:{error}"))
+ } else {
+ None
+ };
emit_publish_progress(
&app,
&root_key,
@@ -1665,6 +2187,7 @@ pub(crate) async fn publish_local_project_game(
screenshot_object_keys,
manifest: Some(manifest),
publication: Some(binding),
+ project_bundle_warning,
})
}
@@ -1804,6 +2327,11 @@ mod tests {
input_modes: vec![],
orientation:
shared_contracts::game_distribution::GameDistributionOrientation::Responsive,
+ // 上架时的共创授权档位;AGC 发布面板接线前先按缺省值构造(与不传该字段等价)。
+ fork_authorization:
+ shared_contracts::game_distribution::GameDistributionForkAuthorization::Forbidden,
+ // 改编来源声明:只在从平台作品改编时由发布链路并入,这里构造基线资料时为空。
+ fork: None,
};
let media = PublishMedia::default();
let first = metadata_digest(&metadata, 0, &media).expect("digest");
@@ -1859,6 +2387,117 @@ mod tests {
metadata_digest(&metadata, 0, &split_a).expect("split a"),
metadata_digest(&metadata, 0, &split_b).expect("split b")
);
+
+ // 改编声明也参与幂等摘要:同内容稳定,并入声明即变(这是预期行为,不是漂移)。
+ metadata.title = "游戏".to_string();
+ assert_eq!(
+ metadata_digest(&metadata, 0, &media).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, 0, &media).expect("digest with fork");
+ assert_ne!(with_fork, first);
+ assert_eq!(
+ metadata_digest(&metadata, 0, &media).expect("stable with fork"),
+ with_fork
+ );
+ }
+
+ #[test]
+ fn catalog_limit_clamps_to_server_range() {
+ // 缺省 20 / 上限 50,与服务端公开目录同口径;0 视作缺省(服务端同义)。
+ assert_eq!(clamped_catalog_limit(None), 20);
+ assert_eq!(clamped_catalog_limit(Some(0)), 20);
+ assert_eq!(clamped_catalog_limit(Some(7)), 7);
+ assert_eq!(clamped_catalog_limit(Some(50)), 50);
+ assert_eq!(clamped_catalog_limit(Some(500)), 50);
+ }
+
+ #[test]
+ fn fork_source_errors_keep_contract_statuses_distinguishable() {
+ assert_eq!(
+ map_fork_source_http_error(StatusCode::UNAUTHORIZED, "{}"),
+ "authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
+ );
+ assert_eq!(
+ map_fork_source_http_error(
+ StatusCode::FORBIDDEN,
+ r#"{"error":{"code":"FORK_NOT_AUTHORIZED","message":"未开放共创"}}"#
+ ),
+ "permission-denied: 作者没有开放这个作品的共创授权,无法开始共创"
+ );
+ assert_eq!(
+ map_fork_source_http_error(
+ StatusCode::NOT_FOUND,
+ r#"{"error":{"code":"FORK_SOURCE_NOT_FOUND"}}"#
+ ),
+ "fork-source-not-found: 该作品不存在或已被删除,无法开始共创"
+ );
+ assert_eq!(
+ map_fork_source_http_error(
+ StatusCode::CONFLICT,
+ r#"{"error":{"code":"FORK_SOURCE_NOT_AVAILABLE"}}"#
+ ),
+ "fork-source-not-available: 该作品当前不能开始共创(未公开、已下架或没有公开版本)"
+ );
+ // 其它失败保留原始 detail,便于排障,但仍是可读文案而不是裸 HTTP 码。
+ assert_eq!(
+ map_fork_source_http_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ r#"{"error":{"code":"INTERNAL","message":"服务异常"}}"#
+ ),
+ "读取共创信息失败:服务异常"
+ );
+ }
+
+ #[test]
+ fn fork_source_download_path_accepts_only_same_origin_relative_paths() {
+ assert_eq!(
+ fork_source_download_segments(
+ "/api/game-distribution/games/game_1/fork-source/package"
+ )
+ .expect("relative path"),
+ vec![
+ "api",
+ "game-distribution",
+ "games",
+ "game_1",
+ "fork-source",
+ "package"
+ ]
+ );
+ for unsafe_path in [
+ "",
+ "api/game-distribution/games/game_1/fork-source/package",
+ "//evil.example.com/package",
+ "https://evil.example.com/package",
+ "/api/../package",
+ "/api\\package",
+ "/api/package?token=1",
+ "/api/package#frag",
+ "/api/C:package",
+ ] {
+ assert!(
+ fork_source_download_segments(unsafe_path).is_err(),
+ "should reject {unsafe_path:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn cover_preview_data_url_accepts_images_and_rejects_invalid_payloads() {
+ assert_eq!(
+ cover_preview_data_url("image/png; charset=binary", &[0, 1, 2])
+ .expect("image data URL"),
+ "data:image/png;base64,AAEC"
+ );
+ assert!(cover_preview_data_url("text/plain", &[0, 1, 2]).is_err());
+ assert!(cover_preview_data_url("image/png", &[]).is_err());
}
#[test]
@@ -2068,6 +2707,10 @@ mod tests {
input_modes: vec![],
orientation:
shared_contracts::game_distribution::GameDistributionOrientation::Responsive,
+ // 共创授权档位与改编声明:这里构造的是「不涉及共创」的基线资料(与不传这两个字段等价)。
+ fork_authorization:
+ shared_contracts::game_distribution::GameDistributionForkAuthorization::Forbidden,
+ fork: None,
}
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs
new file mode 100644
index 000000000..c8d74da42
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs
@@ -0,0 +1,731 @@
+//! 从平台作品 Fork:受鉴权取件 → 摘要校验 → 建成一份可在本项目里继续改造、并且**立刻可运行**
+//! 的工程。
+//!
+//! 两种取件形态(服务端 `source`)落法不同,实现事实如下:
+//! - `Project`(工程源包):包本身就是作者的工程,解到项目根即可继续改造。包内**不含**构建产物
+//! (`project_bundle.rs` 排除 `game/dist`),所以本机还没有可运行的产物:建项只登记一条初始
+//! 工程版本,`code-prototype` 保持 `pending`,由运行视图如实提示「需要先构建」。
+//! - `Package`(已构建成品包):平台只有成品时,先按标准初始化生成脚手架,再把成品铺进**预览根**
+//! (`fork_playable_root`,即 `game/dist`)——运行视图只服务预览根,铺在别处的副本用户既看不到
+//! 也播不了。成品包自带可玩入口,因此建项同时把 `code-prototype` 登记为已完成。
+//!
+//! 关键顺序(顺序本身就是合同):
+//! 1. 先取件并按元数据校验字节(失败关闭,不落盘);
+//! 2. 再 `init_local_game_project_at` 生成脚手架——它的 `create_npm_scaffold` 判据是
+//! 「没有 manifest 且根/`game` 都没有 index.html、没有 package.json」,所以 `Package`
+//! 形态的成品必须等脚手架生成之后再铺,且不能铺进项目根或 `game/` 根;
+//! 3. 然后 `register_forked_project_state_at` 登记「可运行原型」与初始工程版本;
+//! 4. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明 Fork 来源。
+
+#[cfg(not(test))]
+mod desktop;
+#[cfg(not(test))]
+pub(crate) use desktop::*;
+
+use super::*;
+
+/// 取件字节校验:字节数与 SHA-256 都必须与取件元数据一致,任一不符即失败关闭。
+///
+/// 抽成纯函数是为了让「失败就绝不落盘」这条规则可被单测钉住:调用点在解压之前。
+pub(crate) fn verify_fork_source_bytes(
+ bytes: &[u8],
+ expected_sha256: &str,
+ expected_bytes: u64,
+) -> Result<(), String> {
+ if bytes.len() as u64 != expected_bytes {
+ return Err(format!(
+ "共创内容大小校验失败(期望 {expected_bytes} 字节,实际 {} 字节),已放弃落盘",
+ bytes.len()
+ ));
+ }
+ let actual = crate::project_bundle::sha256_hex(bytes);
+ if actual != expected_sha256.trim().to_ascii_lowercase() {
+ return Err("共创内容完整性校验失败,已放弃落盘".to_string());
+ }
+ Ok(())
+}
+
+/// 可玩参考的落点:项目的**预览根**。
+///
+/// 成品包 fork 把取到的发行产物铺在这里,而不是另找一个"参考目录":运行视图只服务
+/// `preview::project_game_root()` 指向的那一个根(`preview.rs` 的
+/// `start_local_game_preview_for_project` / `resolve_preview_path`),铺在根之外的副本用户
+/// 既看不到也播不了——那正是「fork 之后没法直接运行」的成因之一。
+///
+/// 预览根由 `project_game_root` 单点推导,这里不另写第二份判据:本形态必然是刚生成的 npm
+/// 脚手架(`game/package.json` 存在),预览根就是 `game/dist`(AGC 网页脚手架的 vite
+/// `outDir`)。它**不**在项目根或 `game/` 根,因此不会打断 `create_npm_scaffold` 判据;它也
+/// 被工程源包的排除清单覆盖(`project_bundle.rs` 的 `game/dist`),fork 来的成品**不会**被当成
+/// 作者自己的源码重新上传。
+pub(crate) fn fork_playable_root(project_root: &Path) -> PathBuf {
+ crate::preview::project_game_root(project_root)
+}
+
+/// Fork 建项收口:把「本项目已有可运行原型」与初始工程版本**如实**登记进 manifest。
+///
+/// 为什么必须有这一条:运行视图与发布导出都以 `manifest.tasks[code-prototype] == completed`
+/// (或存在运行中的预览)作为「项目里已有可运行原型」的**唯一**事实——前端
+/// `view/project-development/index.tsx` 的 `runAvailable`、后端 `project/export.rs` 的
+/// `project_has_runnable_prototype` 都是这一条。Fork 建项过去只生成脚手架与全新 manifest
+/// (seed 任务全 `pending`、`versions` 为空),于是 fork 出来的项目一进来就被判成「首个可运行
+/// 原型尚未完成」:运行页签点了没反应、发布也被拦——而它其实已经带了一份可玩的成品。
+///
+/// 写入顺序照既有 AI 直连回合收口(`agent/direct_runtime/mod.rs` 的同一套模式,见
+/// `sync_direct_codex_project_file_projection_at`):任务状态 → 推进 project revision →
+/// 追加 `initial-` 版本 → 宣告清单失效。这里不新造状态写入路径,也不动 manifest
+/// 契约(改编来源仍只写在 `.agent/fork-source.json`)。
+///
+/// **只在项目里确实存在可玩入口时才标完成**(判据与预览同源:预览根下的 `index.html`):
+/// - 成品包形态:发行产物已铺进预览根,`<预览根>/index.html` 存在 → 登记为已完成,运行与
+/// 发布都立刻可用;
+/// - 工程源包形态:按产品口径**不自动构建**(与「不自动安装外部工程依赖」的既有策略一致),
+/// 本机还没有可运行产物 → 只追加初始工程版本、`code-prototype` 保持 `pending`,由运行视图
+/// 如实提示「需要先构建」,绝不谎报原型已完成。
+///
+/// `emit_game_creator_manifest_invalidated` 不是可选项:资源画布「项目版本」卡与发布面板的
+/// 版本标签只读清单快照,不宣告失效就看不到刚写进去的初始版本。
+fn register_forked_project_state_at(root: &Path) -> Result {
+ if fork_playable_root(root).join("index.html").is_file() {
+ update_manifest_task_status_at(
+ root,
+ "code-prototype",
+ GameCreationAppTaskStatus::Completed,
+ )?;
+ }
+ // 顺序是合同:先推进 durable revision,再把该 revision 绑成正式版本
+ // (`append_agent_game_iteration_version_at` 明确要求 revision > 0 且由调用方先推进)。
+ let revision = advance_agent_runtime_project_revision_locked(root)?;
+ append_agent_game_iteration_version_at(root, revision)?;
+ emit_game_creator_manifest_invalidated(root, FORK_MANIFEST_INVALIDATED_SOURCE);
+ read_manifest_for_project(root)
+}
+
+/// 清单失效事件的来源标识(与 `direct-codex.version` 等既有来源并列,便于排障定位写入者)。
+const FORK_MANIFEST_INVALIDATED_SOURCE: &str = "game-fork.project";
+
+/// 用平台作品的取件内容建一个新项目。
+///
+/// 两种取件形态(`source`)落法不同:
+/// - `Package`(已构建成品包):先按标准初始化生成脚手架,再把成品铺进**预览根**
+/// (`fork_playable_root`)——平台只有成品时只有这一份内容可用,而且它必须落在预览根才播得了。
+/// - `Project`(工程源包):包本身就是作者的工程(自带 `package.json` / `vite.config.*`),
+/// 因此**先把包解到项目根**再走标准初始化:初始化会跳过 npm 脚手架(`game/index.html` 已存在),
+/// 只补 `.agent` 身份、目录与 agent.db。
+///
+/// 任一形态建项成功后都走 `register_forked_project_state_at` 收口(登记「可运行原型」与初始
+/// 工程版本),任一步失败都删掉半成品目录,不留无法解释的项目。
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn create_project_from_platform_fork_at(
+ projects_root: &Path,
+ game_id: &str,
+ version_id: &str,
+ source: ProjectForkSourceKind,
+ package_bytes: &[u8],
+ requested_name: Option<&str>,
+ planning: bool,
+ // 阶段回调:真实发生到哪一步就报哪一步(渲染层状态条据此渲染,不造百分比)。
+ on_stage: &dyn Fn(ForkSyncStage),
+) -> Result {
+ let requested_name = requested_name
+ .map(normalize_game_creation_project_name)
+ .transpose()?;
+ // 作品 ID 来自平台响应:先把形状钉住再创建任何目录,非法标识不得留下半成品。
+ if !crate::game_distribution_publish::is_safe_fork_game_id(game_id.trim()) {
+ return Err("共创来源作品 ID 无效,无法建立来源记录".to_string());
+ }
+ if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
+ return Err("自动工作区根目录必须是绝对路径".to_string());
+ }
+ ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?;
+ prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?;
+ let metadata = fs::symlink_metadata(projects_root).map_err(|error| {
+ format!(
+ "读取自动工作区根目录失败:{}: {error}",
+ projects_root.display()
+ )
+ })?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err("自动工作区根目录必须是普通文件夹".to_string());
+ }
+
+ for _ in 0..16 {
+ let workspace_id = uuid::Uuid::new_v4().simple().to_string();
+ let short_id = &workspace_id[..8];
+ let project_name = requested_name.clone().unwrap_or_else(|| {
+ let prefix = if planning {
+ "策划项目"
+ } else {
+ "共创项目"
+ };
+ format!("{prefix} {short_id}")
+ });
+ let project_root = projects_root.join(format!("gameagent-{short_id}"));
+ match fs::create_dir(&project_root) {
+ Ok(()) => {
+ let result = (|| {
+ harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
+ enforce_project_permission_policy(&project_root, "project.create")?;
+ let _lock = acquire_project_write_lock(&project_root, "project.create")?;
+ let mut project = match source {
+ // 工程源包:包本身就是可编辑工程,先解到项目根再初始化。
+ ProjectForkSourceKind::Project => {
+ on_stage(ForkSyncStage::Extract);
+ // 复用模板归档的同一套门禁:条目数上限、单文件上限、拒符号链接、
+ // 条目路径只允许项目内相对路径(ZIP 根 == 项目根)。
+ crate::template_library::extract_template_archive(
+ package_bytes,
+ &project_root,
+ )
+ .map_err(|error| format!("共创工程源包解压失败:{error}"))?;
+ on_stage(ForkSyncStage::Project);
+ let project = init_local_game_project_at(
+ &project_root,
+ &format!("gameagent-{workspace_id}"),
+ &project_name,
+ )?;
+ write_project_fork_source(
+ &project_root,
+ &ProjectForkSourceRecord::new(
+ game_id,
+ version_id,
+ ProjectForkSourceKind::Project,
+ ),
+ )?;
+ project
+ }
+ // 成品包:脚手架必须先生成,否则 `create_npm_scaffold` 判据被成品包里的
+ // `index.html` / `package.json` 打断,项目从此无法发布;铺到哪里则由
+ // `fork_playable_root` 决定(预览根),不能铺进项目根或 `game/` 根。
+ ProjectForkSourceKind::Package => {
+ on_stage(ForkSyncStage::Project);
+ let project = init_local_game_project_at(
+ &project_root,
+ &format!("gameagent-{workspace_id}"),
+ &project_name,
+ )?;
+ on_stage(ForkSyncStage::Extract);
+ let playable_root = fork_playable_root(&project_root);
+ ensure_game_creator_private_directory_tree(
+ &playable_root,
+ "可玩参考目录",
+ )?;
+ // 发行包契约:入口 `index.html` 固定在包根(服务端 `validate_release_zip`
+ // 与 api-server 的 `package_entry_path` 都强制),所以整包解到预览根后
+ // 就是 `<预览根>/index.html`。
+ crate::template_library::extract_template_archive(
+ package_bytes,
+ &playable_root,
+ )
+ .map_err(|error| format!("共创内容解压失败:{error}"))?;
+ write_project_fork_source(
+ &project_root,
+ &ProjectForkSourceRecord::new(
+ game_id,
+ version_id,
+ ProjectForkSourceKind::Package,
+ ),
+ )?;
+ project
+ }
+ };
+ // 建项收口:登记「可运行原型」与初始工程版本。返回收口**之后**重读的清单,
+ // 免得把过期快照交给进项目通道(它会据这份清单渲染首帧)。
+ project.manifest = register_forked_project_state_at(&project_root)?;
+ Ok(project)
+ })();
+ if result.is_err() {
+ let _ = fs::remove_dir_all(&project_root);
+ }
+ return result;
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(error) => {
+ return Err(format!(
+ "创建自动工作区失败:{}: {error}",
+ project_root.display()
+ ));
+ }
+ }
+ }
+ Err("自动工作区命名冲突,请重试".to_string())
+}
+
+/// Fork 工程同步的**真实阶段**:状态条只呈现这里真实发出的阶段,不造百分比、不预估进度。
+///
+/// 与需求文档「源码 / 素材 / 场景 / 配置」四分类的对应关系:文档那四类假设的是「按资产种类分别
+/// 同步」,而客户端这条链路是**整包取回**(一次下载、一次校验、一次解压、一次建项),没有按种类
+/// 分开的可测量阶段,所以这里如实降级成四个真实阶段,并在渲染层说明当前阶段在做什么。
+#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub(crate) enum ForkSyncStage {
+ /// 取回作品内容:读取件元数据 + 下载整包。
+ Download,
+ /// 校验整包字节数与 SHA-256(失败即放弃落盘)。
+ Verify,
+ /// 解压到项目:工程源包解到项目根,成品包解到 `reference//`。
+ Extract,
+ /// 生成工程与来源记录:脚手架 / `.agent` 身份 / `.agent/fork-source.json`。
+ Project,
+}
+
+impl ForkSyncStage {
+ /// 事件里用的稳定键(渲染层按它匹配阶段,不匹配文案)。
+ pub(crate) fn key(self) -> &'static str {
+ match self {
+ Self::Download => "download",
+ Self::Verify => "verify",
+ Self::Extract => "extract",
+ Self::Project => "project",
+ }
+ }
+
+ /// 状态条上给用户看的阶段名。
+ pub(crate) fn label(self) -> &'static str {
+ match self {
+ Self::Download => "取回作品内容",
+ Self::Verify => "校验摘要与字节",
+ Self::Extract => "解压到项目",
+ Self::Project => "生成工程与来源记录",
+ }
+ }
+}
+
+/// 深链协议名:与 `tauri.conf.json` 的 `plugins.deep-link.desktop.schemes` 必须一致。
+pub(crate) const FORK_DEEP_LINK_SCHEME: &str = "genarrative";
+
+/// 从深链 URL 解析要 Fork 的作品 ID。
+///
+/// 冻结约定:`genarrative://fork?gameId=`,同时接受 `genarrative://fork/`。
+/// 只做解析与形状校验,不碰网络也不建项目:非法链接返回可读原因,由桌面层聚焦窗口后把原因交给
+/// 渲染层展示(用户点错链接时得到的是提示,而不是静默无事发生)。
+pub(crate) fn fork_game_id_from_deep_link(raw_url: &str) -> Result {
+ let trimmed = raw_url.trim();
+ if trimmed.is_empty() {
+ return Err("链接为空,无法识别要共创的作品".to_string());
+ }
+ let url = url::Url::parse(trimmed).map_err(|_| format!("无法解析链接:{trimmed}"))?;
+ if !url.scheme().eq_ignore_ascii_case(FORK_DEEP_LINK_SCHEME) {
+ return Err(format!("只接受 {FORK_DEEP_LINK_SCHEME}:// 开头的共创链接"));
+ }
+ // `genarrative://fork/...` 里的 `fork` 是协议的 host(对外动作名叫「开始共创」,协议名与内部标识不改),作品 ID 走查询参数或路径段。
+ let route = url.host_str().unwrap_or_default().to_ascii_lowercase();
+ if route != "fork" {
+ return Err(format!(
+ "不支持的链接形式:需要 {FORK_DEEP_LINK_SCHEME}://fork?gameId=…"
+ ));
+ }
+ let from_query = url
+ .query_pairs()
+ .find(|(key, _)| matches!(key.as_ref(), "gameId" | "gameid" | "game_id" | "id"))
+ .map(|(_, value)| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ let from_path = url
+ .path_segments()
+ .and_then(|mut segments| segments.find(|segment| !segment.is_empty()))
+ .map(str::to_string);
+ let game_id = from_query
+ .or(from_path)
+ .ok_or_else(|| "链接里没有 gameId,无法确定要共创的作品".to_string())?;
+ if !crate::game_distribution_publish::is_safe_fork_game_id(&game_id) {
+ return Err(format!("链接里的 gameId 不合法:{game_id}"));
+ }
+ Ok(game_id)
+}
+
+/// 测试断言用:与运行视图 / 发布导出同源,「项目里已有可运行原型」的唯一事实是
+/// `manifest.tasks[code-prototype] == completed`(见 `register_forked_project_state_at` 的注释)。
+///
+/// 只服务 `game_fork` 的建项收口用例,因此留在 `cfg(test)` 内,不新增生产 API。
+#[cfg(test)]
+fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool {
+ manifest.tasks.iter().any(|task| {
+ task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::project_bundle::sha256_hex;
+ use zip::write::SimpleFileOptions;
+
+ fn test_root(label: &str) -> PathBuf {
+ let nonce = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|elapsed| elapsed.as_nanos())
+ .unwrap_or_default();
+ let root = std::env::temp_dir().join(format!(
+ "agc-game-fork-{label}-{}-{nonce}",
+ std::process::id()
+ ));
+ fs::create_dir_all(&root).expect("create temp root");
+ root
+ }
+
+ /// 最小成品包:发行包的真实形状是「运行产物 + 根 index.html」,不含 package.json。
+ fn release_package_bytes() -> Vec {
+ let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ let options = SimpleFileOptions::default();
+ writer
+ .start_file("index.html", options)
+ .expect("start entry");
+ std::io::Write::write_all(&mut writer, b"").expect("write entry");
+ writer
+ .start_file("game/main.js", options)
+ .expect("start script");
+ std::io::Write::write_all(&mut writer, b"console.log('playable')").expect("write script");
+ writer.finish().expect("finish zip").into_inner()
+ }
+
+ #[test]
+ fn fork_sync_stages_keep_stable_keys_and_readable_labels() {
+ // 事件键必须与 serde 的 kebab-case 序列化一致:渲染层按 key 匹配阶段。
+ for (stage, key) in [
+ (ForkSyncStage::Download, "download"),
+ (ForkSyncStage::Verify, "verify"),
+ (ForkSyncStage::Extract, "extract"),
+ (ForkSyncStage::Project, "project"),
+ ] {
+ assert_eq!(stage.key(), key);
+ assert_eq!(
+ serde_json::to_string(&stage).expect("serialize stage"),
+ format!("\"{key}\"")
+ );
+ assert!(!stage.label().is_empty(), "阶段必须有可读名");
+ }
+ // 真实顺序:取回 → 校验 → 解压 → 建项(状态条按这个顺序排)。
+ assert_eq!(ForkSyncStage::Download.label(), "取回作品内容");
+ assert_eq!(ForkSyncStage::Verify.label(), "校验摘要与字节");
+ assert_eq!(ForkSyncStage::Extract.label(), "解压到项目");
+ assert_eq!(ForkSyncStage::Project.label(), "生成工程与来源记录");
+ }
+
+ #[test]
+ fn deep_link_accepts_both_fork_forms_and_ignores_extra_parameters() {
+ assert_eq!(
+ fork_game_id_from_deep_link("genarrative://fork?gameId=game_parent_1").unwrap(),
+ "game_parent_1"
+ );
+ assert_eq!(
+ fork_game_id_from_deep_link("genarrative://fork/game_parent_2").unwrap(),
+ "game_parent_2"
+ );
+ // 大小写:scheme 与动作名由 url crate 归一,作品 ID 原样保留。
+ assert_eq!(
+ fork_game_id_from_deep_link("GENARRATIVE://FORK?gameId=Game_Parent_3").unwrap(),
+ "Game_Parent_3"
+ );
+ // 多余参数与其它键名(兼容 game_id / id)不影响解析。
+ assert_eq!(
+ fork_game_id_from_deep_link("genarrative://fork?from=share&game_id=game_parent_4")
+ .unwrap(),
+ "game_parent_4"
+ );
+ assert_eq!(
+ fork_game_id_from_deep_link("genarrative://fork?gameId=game_parent_5&extra=1").unwrap(),
+ "game_parent_5"
+ );
+ // 两种形式同时出现时以查询参数为准(约定形式优先)。
+ assert_eq!(
+ fork_game_id_from_deep_link("genarrative://fork/ignored?gameId=game_parent_6").unwrap(),
+ "game_parent_6"
+ );
+ }
+
+ #[test]
+ fn deep_link_rejects_empty_wrong_scheme_route_and_missing_or_illegal_game_id() {
+ for (raw, expected) in [
+ ("", "链接为空"),
+ (" ", "链接为空"),
+ (
+ "https://platform.test/games/detail?id=game_1",
+ "只接受 genarrative:// 开头的共创链接",
+ ),
+ ("genarrative://other?gameId=game_1", "不支持的链接形式"),
+ ("genarrative://fork", "链接里没有 gameId"),
+ ("genarrative://fork/", "链接里没有 gameId"),
+ ("genarrative://fork?gameId=", "链接里没有 gameId"),
+ (
+ "genarrative://fork?gameId=../escape",
+ "链接里的 gameId 不合法",
+ ),
+ (
+ "genarrative://fork?gameId=game%2Fslash",
+ "链接里的 gameId 不合法",
+ ),
+ ("not a url", "无法解析链接"),
+ ] {
+ let error = fork_game_id_from_deep_link(raw)
+ .expect_err(&format!("{raw:?} 应被拒绝(期望原因含「{expected}」)"));
+ assert!(
+ error.contains(expected),
+ "{raw:?} 的原因不是期望文案:{error}"
+ );
+ }
+ }
+
+ #[test]
+ fn fork_game_id_shape_rule_is_shared_with_the_fetch_path() {
+ assert!(crate::game_distribution_publish::is_safe_fork_game_id(
+ "game_1"
+ ));
+ assert!(crate::game_distribution_publish::is_safe_fork_game_id(
+ "a-b_c"
+ ));
+ for bad in ["", " ", "game/1", "game 1", "game\\1", "game:1"] {
+ assert!(
+ !crate::game_distribution_publish::is_safe_fork_game_id(bad),
+ "{bad:?} 不应通过"
+ );
+ }
+ }
+
+ #[test]
+ fn fork_source_bytes_must_match_declared_size_and_digest() {
+ let bytes = release_package_bytes();
+ let digest = sha256_hex(&bytes);
+ assert!(verify_fork_source_bytes(&bytes, &digest, bytes.len() as u64).is_ok());
+ // 摘要大小写不敏感(服务端回小写,客户端仍按同一口径比较)。
+ assert!(
+ verify_fork_source_bytes(&bytes, &digest.to_uppercase(), bytes.len() as u64).is_ok()
+ );
+
+ let size_error = verify_fork_source_bytes(&bytes, &digest, bytes.len() as u64 + 1)
+ .expect_err("大小不符必须失败");
+ assert!(size_error.contains("大小校验失败"), "{size_error}");
+ let digest_error = verify_fork_source_bytes(&bytes, &"a".repeat(64), bytes.len() as u64)
+ .expect_err("摘要不符必须失败");
+ assert!(digest_error.contains("完整性校验失败"), "{digest_error}");
+ assert!(verify_fork_source_bytes(&[], &digest, 0).is_err());
+ }
+
+ #[test]
+ fn playable_reference_root_is_the_project_preview_root() {
+ // 生产路径把成品包铺到「预览根」。它必须与预览服务读取的根(`project_game_root`)
+ // 是同一个答案,否则「铺进项目」与「预览服务哪里」会分叉成两份判据——fork 之后能跑
+ // 不能跑就取决于这份判据是否一致。
+ let root = test_root("playable-root");
+ fs::create_dir_all(root.join("game")).expect("create game dir");
+ fs::write(
+ root.join("game/package.json"),
+ r#"{"scripts":{"build":"vite build"}}"#,
+ )
+ .expect("write package.json");
+
+ let playable_root = fork_playable_root(&root);
+ assert_eq!(playable_root, root.join("game/dist"));
+ // 可玩参考永远落在项目内,不会拼出项目外的路径。
+ assert!(playable_root.starts_with(&root));
+ fs::remove_dir_all(&root).ok();
+ }
+
+ /// 最小工程源包:自带 `game/package.json` 与 `game/index.html`(这正是「解压即工程」的条件)。
+ fn project_bundle_bytes() -> Vec {
+ let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ let options = SimpleFileOptions::default();
+ for (name, content) in [
+ ("game/index.html", "source entry"),
+ (
+ "game/package.json",
+ "{\"dependencies\":{\"phaser\":\"4.2.1\"}}",
+ ),
+ ("game/vite.config.js", "export default {};"),
+ ("game/src/game.js", "console.log('from bundle');"),
+ ] {
+ writer.start_file(name, options).expect("start entry");
+ std::io::Write::write_all(&mut writer, content.as_bytes()).expect("write entry");
+ }
+ writer.finish().expect("finish zip").into_inner()
+ }
+
+ #[test]
+ fn platform_project_bundle_source_stays_source_only_with_an_initial_version() {
+ let projects_root = test_root("create-project-source");
+ let bytes = project_bundle_bytes();
+ let project = create_project_from_platform_fork_at(
+ &projects_root,
+ "game_upstream",
+ "gamever_upstream",
+ ProjectForkSourceKind::Project,
+ &bytes,
+ Some("源码改编测试"),
+ false,
+ &|_| {},
+ )
+ .expect("create fork project from project bundle");
+ let root = Path::new(&project.project_path);
+
+ // 1) 源包内容逐字节落地:工程源码不被脚手架覆盖(package.json 必须是包里的那份)。
+ assert_eq!(
+ fs::read_to_string(root.join("game/package.json")).unwrap(),
+ "{\"dependencies\":{\"phaser\":\"4.2.1\"}}"
+ );
+ assert_eq!(
+ fs::read_to_string(root.join("game/src/game.js")).unwrap(),
+ "console.log('from bundle');"
+ );
+ assert_eq!(
+ fs::read_to_string(root.join("game/index.html")).unwrap(),
+ "source entry"
+ );
+
+ // 2) 标准初始化仍然跑过:补上项目身份与 agent.db,工程才能被 AGC 正常打开。
+ assert!(root.join(".agent/manifest.json").is_file());
+ assert!(root.join(".agent/agent.db").is_file());
+
+ // 3) 工程源包不做参考副本,也不在 fork 时构建:包内不含构建产物(工程源包排除
+ // `game/dist`),因此本机还没有可运行的产物。
+ assert!(!root.join("reference").exists());
+ assert!(!root.join("game/dist").exists());
+
+ // 4) 收口**如实**:登记一条初始工程版本,但 `code-prototype` 保持未完成——运行视图会
+ // 提示「需要先构建」,绝不谎报原型已完成。
+ let code_prototype = project
+ .manifest
+ .tasks
+ .iter()
+ .find(|task| task.id == "code-prototype")
+ .map(|task| task.status.clone())
+ .expect("code-prototype 必须存在于 seed 任务里");
+ assert_eq!(code_prototype, GameCreationAppTaskStatus::Pending);
+ assert!(!project_has_runnable_prototype(&project.manifest));
+ assert_eq!(
+ project.manifest.versions.len(),
+ 1,
+ "fork 建项必须有初始版本"
+ );
+ let initial = &project.manifest.versions[0];
+ assert_eq!(initial.version_id, "initial-1");
+ assert_eq!(initial.project_revision, 1);
+ assert_eq!(
+ initial.created_reason,
+ GameIterationVersionCreatedReason::Initial
+ );
+ assert_eq!(initial.parent_version_id, None);
+
+ // 5) 来源记录带上取件形态(v2)。
+ let record = read_project_fork_source(root).expect("fork source record");
+ assert_eq!(record.schema_version, FORK_SOURCE_SCHEMA_VERSION);
+ assert_eq!(record.source, ProjectForkSourceKind::Project);
+ assert_eq!(record.game_id, "game_upstream");
+ assert_eq!(record.version_id, "gamever_upstream");
+ }
+
+ #[test]
+ fn platform_fork_project_gets_playable_reference_at_the_preview_root() {
+ let projects_root = test_root("create");
+ let bytes = release_package_bytes();
+ let project = create_project_from_platform_fork_at(
+ &projects_root,
+ "game_parent",
+ "gamever_parent",
+ ProjectForkSourceKind::Package,
+ &bytes,
+ Some("改编测试"),
+ false,
+ &|_| {},
+ )
+ .expect("create fork project");
+ let root = Path::new(&project.project_path);
+
+ // 1) 合规脚手架正常生成:`create_npm_scaffold` 的判据没有被参考产物打断。
+ assert!(root.join("game/package.json").is_file());
+ assert!(root.join("game/vite.config.js").is_file());
+ assert!(root.join("game/index.html").is_file());
+ assert!(root.join(".agent/manifest.json").is_file());
+
+ // 2) 成品铺在**预览根**(`game/dist`),与脚手架互不覆盖:预览根下必须有可玩入口,
+ // 否则运行视图即使放行也会报「游戏目录不存在」。不再另写一份 `reference/` 副本
+ // (第二份副本用户既看不到也播不了,还会翻倍占磁盘)。
+ assert!(!root.join("reference").exists());
+ assert_eq!(
+ fs::read_to_string(root.join("game/dist/index.html")).unwrap(),
+ ""
+ );
+ assert_eq!(
+ fs::read_to_string(root.join("game/dist/game/main.js")).unwrap(),
+ "console.log('playable')"
+ );
+ assert_ne!(
+ fs::read_to_string(root.join("game/dist/index.html")).unwrap(),
+ fs::read_to_string(root.join("game/index.html")).unwrap()
+ );
+
+ // 3) 收口把「可运行原型」登记上:成品包自带可玩入口,所以任务置完成、版本为初始版本,
+ // 运行与发布门禁同时放行(这就是「fork 之后能直接运行」的判据本身)。
+ let code_prototype = project
+ .manifest
+ .tasks
+ .iter()
+ .find(|task| task.id == "code-prototype")
+ .map(|task| task.status.clone())
+ .expect("code-prototype 必须存在于 seed 任务里");
+ assert_eq!(code_prototype, GameCreationAppTaskStatus::Completed);
+ assert!(project_has_runnable_prototype(&project.manifest));
+ assert_eq!(
+ project.manifest.versions.len(),
+ 1,
+ "fork 建项必须有初始版本"
+ );
+ assert_eq!(project.manifest.versions[0].version_id, "initial-1");
+ assert_eq!(project.manifest.versions[0].project_revision, 1);
+
+ // 4) 来源记录与项目身份一致,并标明这条是成品包取件(v2 字段)。
+ let record = read_project_fork_source(root).expect("fork source record");
+ assert_eq!(record.source, ProjectForkSourceKind::Package);
+ assert_eq!(record.game_id, "game_parent");
+ assert_eq!(record.version_id, "gamever_parent");
+ assert_eq!(project.manifest.name, "改编测试");
+ }
+
+ #[test]
+ fn failed_fork_creation_leaves_no_half_built_project() {
+ let projects_root = test_root("rollback");
+ // 不是合法 zip:解压必须在铺参考副本这一步失败,然后整个项目目录被移除。
+ let error = create_project_from_platform_fork_at(
+ &projects_root,
+ "game_parent",
+ "gamever_parent",
+ ProjectForkSourceKind::Package,
+ b"not-a-zip",
+ None,
+ false,
+ &|_| {},
+ )
+ .expect_err("invalid archive must fail");
+ assert!(error.contains("zip"), "{error}");
+ let leftovers = fs::read_dir(&projects_root)
+ .expect("read projects root")
+ .filter_map(Result::ok)
+ .filter(|entry| {
+ entry
+ .file_name()
+ .to_string_lossy()
+ .starts_with("gameagent-")
+ })
+ .count();
+ assert_eq!(leftovers, 0, "失败的项目目录必须被清理");
+ }
+
+ #[test]
+ fn unsafe_game_id_fails_before_any_directory_is_created() {
+ let projects_root = test_root("unsafe-id");
+ let bytes = release_package_bytes();
+ assert!(create_project_from_platform_fork_at(
+ &projects_root,
+ "../escape",
+ "gamever_parent",
+ ProjectForkSourceKind::Package,
+ &bytes,
+ None,
+ false,
+ &|_| {},
+ )
+ .is_err());
+ assert_eq!(
+ fs::read_dir(&projects_root).expect("read root").count(),
+ 0,
+ "标识非法时不得创建任何目录"
+ );
+ }
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs
new file mode 100644
index 000000000..4fc1120de
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs
@@ -0,0 +1,167 @@
+// 桌面 Fork 取件接入;摘要校验、参考副本落点与来源记录规则保留在父模块供现有测试验证。
+use super::*;
+use crate::game_distribution_publish::{fetch_platform_game_fork_source, require_platform_session};
+use serde_json::{json, Value};
+use shared_contracts::game_distribution::GameDistributionForkSourceKind;
+use tauri::{Emitter, Manager};
+use tauri_plugin_deep_link::DeepLinkExt;
+
+/// 深链事件名:渲染层订阅同名事件,收到后预填 Fork 入口(**不**自动开始下载)。
+pub(crate) const FORK_DEEP_LINK_EVENT: &str = "game-fork-deep-link";
+
+/// 主窗口标签,与 `tauri.conf.json` 的 `app.windows[0].label` 一致。
+const MAIN_WINDOW_LABEL: &str = "client";
+
+/// 把主窗口带到前台:深链可能来自浏览器点击,用户期望看到的正是这个窗口。
+pub(crate) fn focus_main_window_for_deep_link(app: &tauri::AppHandle) {
+ let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
+ app_log!("game.fork.deep_link.window_missing: 找不到主窗口 {MAIN_WINDOW_LABEL}");
+ return;
+ };
+ let _ = window.unminimize();
+ let _ = window.show();
+ let _ = window.set_focus();
+}
+
+/// 处理一条深链:解析出 gameId 就交给渲染层预填,解析失败就把原因交给渲染层展示。
+///
+/// 两种结果都先聚焦窗口——用户点了链接却什么都没发生是最糟的失败方式。
+fn handle_fork_deep_link_url(app: &tauri::AppHandle, raw_url: &str) {
+ focus_main_window_for_deep_link(app);
+ let payload = match fork_game_id_from_deep_link(raw_url) {
+ Ok(game_id) => json!({ "gameId": game_id, "message": Value::Null }),
+ Err(message) => {
+ app_log!("game.fork.deep_link.invalid: {message}");
+ json!({ "gameId": Value::Null, "message": message })
+ }
+ };
+ if let Err(error) = app.emit(FORK_DEEP_LINK_EVENT, payload) {
+ app_log!("game.fork.deep_link.emit_failed: {error}");
+ }
+}
+
+/// 注册并接管 Fork 深链。
+///
+/// - **冷启动**(Windows/Linux):URL 是启动参数,deep-link 插件在插件初始化阶段就解析并暂存,
+/// 而应用的 `setup` 晚于插件,所以这里先取「当前值」,再订阅后续链接。
+/// - **已运行**:Windows/Linux 会再起一个进程,由 `tauri-plugin-single-instance`(带 `deep-link`
+/// feature)把第二个实例的 argv 转交给已有实例,交给 deep-link 插件发事件;macOS 由系统把
+/// URL 交给已运行的 .app。
+/// - **协议注册**:Windows 写 `HKCU\Software\Classes\`、Linux 写用户级 `.desktop`
+/// (macOS 由打包时注入 `Info.plist` 的 `CFBundleURLTypes`)。这里运行时再注册一次,覆盖
+/// 便携/未按安装器安装的场景;失败只记日志,不影响启动。
+pub(crate) fn initialize_fork_deep_link(app: &tauri::AppHandle) {
+ match app.deep_link().get_current() {
+ Ok(Some(urls)) => {
+ for url in urls {
+ handle_fork_deep_link_url(app, url.as_str());
+ }
+ }
+ Ok(None) => {}
+ Err(error) => app_log!("game.fork.deep_link.current_failed: {error}"),
+ }
+ let handle = app.clone();
+ app.deep_link().on_open_url(move |event| {
+ for url in event.urls() {
+ handle_fork_deep_link_url(&handle, url.as_str());
+ }
+ });
+ #[cfg(any(windows, target_os = "linux"))]
+ if let Err(error) = app.deep_link().register_all() {
+ app_log!("game.fork.deep_link.register_failed: {error}");
+ }
+}
+
+/// Fork 工程同步进度事件:每进入一个**真实阶段**发一次,渲染层据此渲染状态条。
+pub(crate) const FORK_PROGRESS_EVENT: &str = "game-fork-progress";
+
+/// 把阶段透给渲染层;发事件失败只记日志,绝不影响 Fork 本身。
+fn emit_fork_stage(app: &tauri::AppHandle, stage: ForkSyncStage) {
+ if let Err(error) = app.emit(
+ FORK_PROGRESS_EVENT,
+ json!({ "stage": stage.key(), "label": stage.label() }),
+ ) {
+ app_log!("game.fork.progress.emit_failed: {error}");
+ }
+}
+
+/// 读本项目的改编来源记录(`.agent/fork-source.json`)。
+///
+/// 发布面板用它判断「这是不是衍生作品」:衍生作品的「本次核心改动说明」必填、授权档位继承父作品
+/// 且不可选(服务端口径)。文件缺失/损坏按「不是衍生作品」处理(`None`),与发布链路读它时的口径
+/// 一致——同一个可选元数据文件不该让面板报错。
+#[tauri::command]
+pub(crate) fn read_local_project_fork_source(
+ project_path: String,
+) -> Result
) : null}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/runUnavailableHint.ts b/apps/ai-game-creator-shell/src/view/project-development/runUnavailableHint.ts
new file mode 100644
index 000000000..288b4af79
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/runUnavailableHint.ts
@@ -0,0 +1,27 @@
+import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
+
+/**
+ * 「运行」页签不可用(`run-unavailable-hint`)时给用户看的文案。
+ *
+ * 同一句话对两种情况并不都成立,所以按清单里已有的事实分叉,而不是套一句万能的空话:
+ *
+ * - **全新项目**(清单里还没有任何工程内部版本):确实什么都还没产出,
+ * 「首个可运行原型尚未完成,运行视图暂不可用」是准确的,保持原样。
+ * - **已经有工程内容、但本机还没有可运行的构建产物**:典型是**从平台作品 Fork 来的工程源包
+ * 项目**——包里就是作者的完整工程,但工程源包按规则不含构建产物(`project_bundle.rs` 排除
+ * `game/dist`),AGC 也不在 Fork 时自动构建(与「不自动安装外部工程依赖」的既有策略一致)。
+ * 这时原句会让人以为「什么都没有」,必须如实说清:内容已经在本地,只是还没有构建出可运行的
+ * 原型,让智能体完成可运行原型之后即可运行。
+ *
+ * 判据只用清单里已有的字段(`versions` 非空 = 这个项目已经有工程内部版本记录)。渲染层拿不到
+ * 「预览根有没有 index.html」这种文件事实(那是原生侧的门禁判据),所以这里不新造状态、也不
+ * 额外调 IPC——它只决定**措辞**,能不能运行仍由运行门禁与原生预览决定。
+ */
+export function runUnavailableHintText(
+ manifest: Pick,
+): string {
+ const hasProjectVersions = (manifest.versions?.length ?? 0) > 0;
+ return hasProjectVersions
+ ? '已有工程内容但还没有可运行的构建产物:让智能体完成可运行原型后即可运行'
+ : '首个可运行原型尚未完成,运行视图暂不可用';
+}
diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts
index 53689b40e..92e5fc6ee 100644
--- a/apps/ai-game-creator-shell/tests/appSurface.test.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts
@@ -7,6 +7,7 @@ import { registerDesignAgentSurfaceTests } from './appSurface/design-agent.suite
import { describe } from './appSurface/harness';
import {
registerClientHomeTests,
+ registerCoCreationEntryTests,
registerHomeProjectCreationTests,
registerRecentProjectsTests,
} from './appSurface/home.suite';
@@ -41,6 +42,7 @@ describe('AI 游戏创作 App 界面边界', () => {
registerAuthTests();
registerAgentStatusDerivationTests();
registerClientHomeTests();
+ registerCoCreationEntryTests();
registerHomeProjectCreationTests();
registerRuntimeSettingsTests();
registerRecentProjectsTests();
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index c16b87c6c..ddbdd108b 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -10,6 +10,7 @@ import {
parseStyleSheet,
resolveDeclarations,
} from '../styleCascade';
+import { createTauriEventFake } from '../tauriEventFake';
import {
act,
App,
@@ -4191,3 +4192,98 @@ export function registerRecentProjectsTests() {
});
});
}
+
+/** 共创入口从首页内联面板搬到侧边栏「共创」页之后的回归防线。 */
+export function registerCoCreationEntryTests() {
+ it('首页不再内联「从平台作品 Fork」面板', async () => {
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'list_platform_game_catalog') {
+ return { games: [], nextCursor: null };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ installTauriRuntime({ core: { invoke } });
+ renderLauncherAt('/?launcher');
+
+ // 首页该有的东西还在(否则这条断言会因为整页没渲染而假绿)。
+ expect(await screen.findByLabelText('创作想法')).not.toBeNull();
+ // 内联面板整体消失:标题、按钮都不得再出现在首页。
+ expect(screen.queryByText('从平台作品 Fork')).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Fork 到我的项目' }),
+ ).toBeNull();
+ });
+
+ it('深链带 gameId 时直接落到确认页并预填父作品', async () => {
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_public_game_detail') {
+ return {
+ id: 'game_from_link',
+ title: '链接带来的作品',
+ author: { id: 'user_1', name: '作者乙' },
+ forkAuthorization: 'nonCommercial',
+ coverColor: '#c2653d',
+ };
+ }
+ if (command === 'list_platform_game_catalog') {
+ return { games: [], nextCursor: null };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ installTauriRuntime({ core: { invoke } });
+ const eventFake = createTauriEventFake({ invoke });
+ eventFake.install();
+ renderLauncherAt('/?launcher');
+ await screen.findByLabelText('创作想法');
+ eventFake.flushRegistrationEvals();
+
+ eventFake.emit('game-fork-deep-link', {
+ gameId: 'game_from_link',
+ message: null,
+ });
+
+ // 落到确认页:父作品信息卡 + 强制阅读闸门。
+ expect(await screen.findByText('开始共创')).not.toBeNull();
+ const card = await screen.findByLabelText('父作品信息');
+ await waitFor(() => expect(card.textContent).toContain('链接带来的作品'));
+ expect(card.textContent).toContain('game_from_link');
+ expect(
+ screen.getByRole('button', { name: /确认并进入编辑/ }),
+ ).toHaveProperty('disabled', true);
+ eventFake.restore();
+ });
+
+ it('侧边栏「共创」入口可切换,并落在共创页上', async () => {
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'list_platform_game_catalog') {
+ return {
+ games: [
+ {
+ id: 'game_open',
+ title: '开放共创的作品',
+ category: '动作',
+ author: { id: 'user_1', name: '作者甲' },
+ coverColor: '#c2653d',
+ forkAuthorization: 'nonCommercial',
+ createdAt: '2026-10-01T08:00:00Z',
+ },
+ ],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ installTauriRuntime({ core: { invoke } });
+ renderLauncherAt('/?launcher');
+ await screen.findByLabelText('创作想法');
+
+ fireEvent.click(screen.getByRole('button', { name: '共创' }));
+
+ expect(await screen.findByText('支持共创的作品')).not.toBeNull();
+ expect(await screen.findByText('开放共创的作品')).not.toBeNull();
+ expect(invoke).toHaveBeenCalledWith('list_platform_game_catalog', {
+ limit: 50,
+ cursor: null,
+ });
+ });
+}
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
index fc37be1bc..4caa9bb13 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
@@ -531,6 +531,134 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
});
+ /**
+ * Fork 建项之后运行视图必须直接可用。
+ *
+ * 原生侧(`game_fork.rs` 的 `register_forked_project_state_at`)在建项收口时登记「可运行
+ * 原型」:成品包形态把发行产物铺进预览根并置 `code-prototype = completed`,同时写一条
+ * `initial-1` 工程内部版本。这里钉的是渲染层的投影结果——那句「首个可运行原型尚未完成」
+ * 必须消失,运行页签可点并真的发出播放请求。
+ */
+ it('renders a forked package project as directly runnable with an initial version', () => {
+ const manifest = createGameCreationAppManifest(
+ 'workbench-fork-package',
+ '共创项目(成品包)',
+ );
+ const codePrototype = manifest.tasks.find(
+ (task) => task.id === 'code-prototype',
+ );
+ if (!codePrototype) {
+ throw new Error('fixture 必须包含 code-prototype seed 任务');
+ }
+ codePrototype.status = 'completed';
+ manifest.versions = [
+ {
+ versionId: 'initial-1',
+ parentVersionId: null,
+ projectRevision: 1,
+ resourceBindings: [],
+ createdReason: 'initial',
+ createdAt: 1_791_271_678,
+ },
+ ];
+
+ const onPlay = vi.fn();
+ render(
+ React.createElement(ProjectDevelopmentView, {
+ projectName: '共创项目(成品包)',
+ projectPath: '/tmp/workbench-fork-package',
+ manifest,
+ attachments: [],
+ recentRunStatus: null,
+ recentRunStopReason: null,
+ chat: React.createElement(
+ 'div',
+ { 'aria-label': '测试项目总控' },
+ '项目总控对话内容',
+ ),
+ onHomeOpen: vi.fn(),
+ onProjectsOpen: vi.fn(),
+ onPlay,
+ }),
+ );
+
+ // 提示必须消失,页签必须不再是「不可用」态。
+ expect(
+ screen.queryByText('首个可运行原型尚未完成,运行视图暂不可用'),
+ ).toBeNull();
+ expect(document.querySelector('#run-unavailable-hint')).toBeNull();
+ const runTab = screen.getByRole('tab', {
+ name: '运行',
+ }) as HTMLButtonElement;
+ expect(runTab.getAttribute('data-unavailable')).toBeNull();
+ expect(runTab.getAttribute('aria-describedby')).toBeNull();
+
+ // 点运行 = 切视图 + 发起播放请求(就是 fork 之后用户看到的「直接能跑」)。
+ fireEvent.click(runTab);
+ expect(runTab.getAttribute('aria-selected')).toBe('true');
+ expect(onPlay).toHaveBeenCalledTimes(1);
+ });
+
+ /**
+ * 工程源包 Fork 的如实表述:包里是作者的完整工程,但工程源包不含构建产物
+ * (`project_bundle.rs` 排除 `game/dist`),AGC 也不在 Fork 时自动构建,所以本机还没有
+ * 可运行的构建产物。此时不能再说「首个可运行原型尚未完成」(听着像什么都没有),
+ * 也不能谎称已经可运行。
+ */
+ it('tells the truth for a source-only fork that still needs a build', () => {
+ const manifest = createGameCreationAppManifest(
+ 'workbench-fork-source',
+ '共创项目(工程源包)',
+ );
+ manifest.versions = [
+ {
+ versionId: 'initial-1',
+ parentVersionId: null,
+ projectRevision: 1,
+ resourceBindings: [],
+ createdReason: 'initial',
+ createdAt: 1_791_271_678,
+ },
+ ];
+
+ const onPlay = vi.fn();
+ render(
+ React.createElement(ProjectDevelopmentView, {
+ projectName: '共创项目(工程源包)',
+ projectPath: '/tmp/workbench-fork-source',
+ manifest,
+ attachments: [],
+ recentRunStatus: null,
+ recentRunStopReason: null,
+ chat: React.createElement(
+ 'div',
+ { 'aria-label': '测试项目总控' },
+ '项目总控对话内容',
+ ),
+ onHomeOpen: vi.fn(),
+ onProjectsOpen: vi.fn(),
+ onPlay,
+ }),
+ );
+
+ expect(
+ screen.getByText(
+ '已有工程内容但还没有可运行的构建产物:让智能体完成可运行原型后即可运行',
+ ),
+ ).not.toBeNull();
+ expect(
+ screen.queryByText('首个可运行原型尚未完成,运行视图暂不可用'),
+ ).toBeNull();
+
+ const runTab = screen.getByRole('tab', {
+ name: '运行',
+ }) as HTMLButtonElement;
+ expect(runTab.getAttribute('data-unavailable')).toBe('true');
+ fireEvent.click(runTab);
+ expect(runTab.getAttribute('aria-selected')).toBe('false');
+ expect(onPlay).not.toHaveBeenCalled();
+ });
+
it('keeps the approval-mode choices reachable in their own dialog surface', () => {
// 审批模式原来是长在会话列头部按钮里的对话框;面板顶部精简之后它由设置浮层里的
// 「操作权限」行打开,但选项与「不可用项给出说明」的语义必须完整保留。
diff --git a/apps/ai-game-creator-shell/tests/coCreation.test.tsx b/apps/ai-game-creator-shell/tests/coCreation.test.tsx
new file mode 100644
index 000000000..4ac1b1519
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/coCreation.test.tsx
@@ -0,0 +1,461 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { InitLocalProjectResult } from '../src/app/types';
+import { useForkDeepLinkStore } from '../src/features/platform-fork/forkDeepLink';
+import {
+ forkableGames,
+ formatCatalogCreatedAt,
+ formatCatalogPlayCount,
+ isForkableGame,
+ loadForkableGames,
+} from '../src/features/platform-fork/platformForkCatalog';
+import CoCreationView from '../src/view/co-creation';
+
+const FORK_COMMAND = 'create_local_project_from_platform_game';
+const CATALOG_COMMAND = 'list_platform_game_catalog';
+
+const PROJECT_RESULT = {
+ projectPath: 'C:\\projects\\gameagent-fork-1',
+ manifestPath: 'C:\\projects\\gameagent-fork-1\\.agent\\manifest.json',
+ manifest: { name: '共创项目 1' },
+} as unknown as InitLocalProjectResult;
+
+type InvokeCall = { command: string; args?: Record };
+
+function installInvoke(
+ implementation: (command: string, args?: Record) => unknown,
+) {
+ const calls: InvokeCall[] = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ calls.push({ command, args });
+ return implementation(command, args);
+ },
+ );
+ (window as unknown as { __TAURI__?: unknown }).__TAURI__ = {
+ core: { invoke },
+ };
+ return calls;
+}
+
+function catalogGame(
+ id: string,
+ title: string,
+ forkAuthorization: string,
+ playCount?: number,
+) {
+ return {
+ id,
+ title,
+ summary: '',
+ category: '动作',
+ author: { id: 'user_1', name: '作者甲' },
+ coverColor: '#c2653d',
+ forkAuthorization,
+ createdAt: '2026-10-01T08:00:00Z',
+ ...(typeof playCount === 'number' ? { playCount } : {}),
+ };
+}
+
+function renderPage() {
+ const onOpenConfirm = vi.fn();
+ const rendered = render();
+ return { ...rendered, onOpenConfirm };
+}
+
+function forkCalls(calls: InvokeCall[]) {
+ return calls.filter((call) => call.command === FORK_COMMAND);
+}
+
+afterEach(() => {
+ cleanup();
+ delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
+ useForkDeepLinkStore.getState().consume();
+});
+
+describe('共创目录模型', () => {
+ it('只保留授权非 forbidden 的作品,字段缺省按 forbidden 处理', () => {
+ expect(isForkableGame(catalogGame('g1', 'A', 'forbidden') as never)).toBe(
+ false,
+ );
+ expect(
+ isForkableGame(catalogGame('g2', 'B', 'nonCommercial') as never),
+ ).toBe(true);
+ expect(isForkableGame(catalogGame('g3', 'C', 'full') as never)).toBe(true);
+ expect(isForkableGame({ id: 'g4' } as never)).toBe(false);
+ expect(
+ forkableGames([
+ catalogGame('g1', 'A', 'forbidden'),
+ catalogGame('g2', 'B', 'full'),
+ ] as never).map((game) => game.id),
+ ).toEqual(['g2']);
+ });
+
+ it('扫到足够可 Fork 的作品就停,不把页数打满', async () => {
+ const invoke = vi.fn(async () => ({
+ games: [catalogGame('g1', 'A', 'full'), catalogGame('g2', 'B', 'full')],
+ nextCursor: 'cursor-1',
+ }));
+ const result = await loadForkableGames(invoke as never, {
+ enough: 2,
+ maxPages: 3,
+ });
+ expect(result.games.map((game) => game.id)).toEqual(['g1', 'g2']);
+ expect(result.scannedPages).toBe(1);
+ expect(result.cappedByPageLimit).toBe(false);
+ });
+
+ it('一直没凑够就按页数上限停,并标记「被页数上限截断」', async () => {
+ let page = 0;
+ const invoke = vi.fn(async () => {
+ page += 1;
+ return {
+ games: [catalogGame(`g${page}`, 'A', 'forbidden')],
+ nextCursor: `cursor-${page}`,
+ };
+ });
+ const result = await loadForkableGames(invoke as never, {
+ enough: 5,
+ maxPages: 3,
+ });
+ expect(result.scannedPages).toBe(3);
+ expect(result.scannedGames).toBe(3);
+ expect(result.games).toEqual([]);
+ expect(result.cappedByPageLimit).toBe(true);
+ });
+});
+
+describe('共创页', () => {
+ it('列出可 Fork 的作品,过滤掉禁止共创的,并展示扫描范围', async () => {
+ const calls = installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [
+ catalogGame('game_open', '开放共创的作品', 'nonCommercial'),
+ catalogGame('game_locked', '禁止共创的作品', 'forbidden'),
+ ],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+
+ expect(await screen.findByText('开放共创的作品')).not.toBeNull();
+ expect(screen.queryByText('禁止共创的作品')).toBeNull();
+ expect(screen.getByText('作者甲')).not.toBeNull();
+ expect(screen.getByText('动作')).not.toBeNull();
+ expect(screen.getByText(/已检查最新 2 个作品(1 页)/)).not.toBeNull();
+ // 列表只读目录,不碰建项命令。
+ expect(forkCalls(calls)).toHaveLength(0);
+ });
+
+ it('点卡片只打开确认页,不直接建项', async () => {
+ const calls = installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [catalogGame('game_open', '开放共创的作品', 'full')],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ const { onOpenConfirm } = renderPage();
+ await screen.findByText('开放共创的作品');
+
+ const forkButton = screen.getByRole('button', {
+ name: '开始共创《开放共创的作品》',
+ });
+ fireEvent.click(forkButton);
+
+ // 卡片按钮:可见文案与无障碍名同一套词,且只剩「开始共创」一个动作名。
+ expect(forkButton.textContent).toBe('开始共创');
+ expect(document.querySelector('.fork-page')?.textContent).not.toMatch(
+ /\bFork\b/u,
+ );
+
+ // 建项与授权确认都搬到确认页;列表这一层一次 native 调用都不该发。
+ expect(forkCalls(calls)).toHaveLength(0);
+ expect(onOpenConfirm).toHaveBeenCalledTimes(1);
+ expect(onOpenConfirm.mock.calls[0]?.[0]).toMatchObject({
+ gameId: 'game_open',
+ origin: 'co-creation',
+ });
+ });
+
+ it('不再渲染「直接 Fork 指定作品」输入块(页面只留卡片入口)', async () => {
+ installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [catalogGame('game_open', '开放共创的作品', 'full')],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ const { container } = renderPage();
+ await screen.findByText('开放共创的作品');
+
+ // 整块连同它的标题、输入框、按钮一起消失。
+ expect(screen.queryByText('直接 Fork 指定作品')).toBeNull();
+ expect(screen.queryByLabelText('平台作品 ID 或链接')).toBeNull();
+ expect(screen.queryByRole('button', { name: '去确认并 Fork' })).toBeNull();
+ expect(screen.queryByPlaceholderText(/例如 game_ab12…/)).toBeNull();
+ // 卡片入口仍在(否则上面几条会因为整页没渲染而假绿)。
+ expect(
+ screen.getAllByRole('button', { name: /^开始共创《/ }).length,
+ ).toBeGreaterThan(0);
+
+ // 页面自己**不建**滚动容器:滚动落在外壳 `.launcher-main`(见 styles.css 契约测试)。
+ expect(container.querySelector('.fork-page')).not.toBeNull();
+ expect(container.querySelector('.fork-page .overflow-y-auto')).toBeNull();
+ expect(container.querySelector('.fork-page .overflow-scroll')).toBeNull();
+ // 顶部留白在内容层(页根),页头不贴顶;窄窗口仍保留留白。
+ const pageRoot = container.querySelector('.fork-page') as HTMLElement;
+ expect(pageRoot.classList.contains('pt-20')).toBe(true);
+ expect(pageRoot.getAttribute('class')).toContain('max-[760px]:pt-16');
+ // 用户可见文案不再出现「像 git fork」这类类比(口径只留在代码注释与文档里)。
+ expect(document.body.textContent ?? '').not.toMatch(/git\s*fork/iu);
+ });
+
+ it('卡片时间:公开目录只有 createdAt,就按「创建时间」渲染真实值', async () => {
+ installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [catalogGame('game_open', '开放共创的作品', 'full')],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+ await screen.findByText('开放共创的作品');
+
+ // 标签是「创建时间」而不是「更新时间」:目录 payload 没有 updatedAt,不拿 createdAt 冒充。
+ expect(screen.getByText('创建时间')).not.toBeNull();
+ expect(screen.queryByText('更新时间')).toBeNull();
+ expect(
+ screen.getByText(formatCatalogCreatedAt('2026-10-01T08:00:00Z')!),
+ ).not.toBeNull();
+ });
+
+ it('卡片时间:字段缺失或不可解析时整行不渲染,不留恒为「—」的死行', async () => {
+ installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [
+ {
+ ...catalogGame('game_open', '开放共创的作品', 'full'),
+ createdAt: '',
+ },
+ {
+ ...catalogGame('game_bad', '时间不可解析的作品', 'full'),
+ createdAt: '不是时间',
+ },
+ ],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+ await screen.findByText('开放共创的作品');
+
+ expect(screen.queryByText('创建时间')).toBeNull();
+ expect(screen.queryByText('更新时间')).toBeNull();
+ // 「作者 / 类型」两行照旧(确认不是整张卡没渲染)。
+ expect(screen.getAllByText('作者')).toHaveLength(2);
+ expect(screen.getAllByText('类型')).toHaveLength(2);
+ });
+
+ it('卡片显示作品自身的游玩次数,且不带会被读成合计的措辞', async () => {
+ installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [
+ catalogGame('game_played', '有游玩记录的作品', 'full', 7),
+ catalogGame('game_fresh', '没人玩过的作品', 'full', 0),
+ ],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+ await screen.findByText('有游玩记录的作品');
+
+ // 自身值:7 次 / 0 次都照实显示(0 是真实值,不是缺字段)。
+ expect(screen.getAllByText('游玩')).toHaveLength(2);
+ expect(screen.getByText('7 次')).not.toBeNull();
+ expect(screen.getByText('0 次')).not.toBeNull();
+ // 文案只讲自身,**不出现**任何合计/衍生口径(客户端没有树,聚合在网页端)。
+ const pageText = document.body.textContent ?? '';
+ expect(pageText).not.toMatch(/含衍生|子树|合计|衍生作品/u);
+ // 「含衍生」那条口径的反面证据:文案里就是「N 次」,前面不带别的词。
+ expect(
+ [...document.querySelectorAll('.fork-page article small span')].some(
+ (node) => node.textContent?.trim() === '游玩',
+ ),
+ ).toBe(true);
+ });
+
+ it('游玩次数字段缺失时整行不渲染(不留恒为「—」的死行)', async () => {
+ installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [catalogGame('game_no_plays', '旧响应没有该字段', 'full')],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+ await screen.findByText('旧响应没有该字段');
+
+ expect(screen.queryByText('游玩')).toBeNull();
+ // 其余信息行照旧(确认不是整张卡没渲染)。
+ expect(screen.getByText('作者')).not.toBeNull();
+ expect(screen.getByText('类型')).not.toBeNull();
+ });
+
+ it('游玩次数格式化:千分位、负数收敛到 0、非法值返回 null', () => {
+ expect(formatCatalogPlayCount(7)).toBe('7 次');
+ expect(formatCatalogPlayCount(12345)).toBe('12,345 次');
+ expect(formatCatalogPlayCount(0)).toBe('0 次');
+ expect(formatCatalogPlayCount(-3)).toBe('0 次');
+ expect(formatCatalogPlayCount(3.8)).toBe('3 次');
+ expect(formatCatalogPlayCount(undefined)).toBeNull();
+ expect(formatCatalogPlayCount(null)).toBeNull();
+ expect(formatCatalogPlayCount(Number.NaN)).toBeNull();
+ expect(formatCatalogPlayCount('7')).toBeNull();
+ });
+
+ it('时间格式化把微秒时间戳按本地日期折算', () => {
+ expect(formatCatalogCreatedAt('2026-10-01T08:00:00Z')).toMatch(
+ /^\d{4}-\d{2}-\d{2}$/,
+ );
+ // 微秒与毫秒两种写法必须落同一天:折算除数写错就会跑到几万年以外。
+ expect(formatCatalogCreatedAt(1_759_308_800_000_000)).toBe(
+ formatCatalogCreatedAt(1_759_308_800_000),
+ );
+ expect(formatCatalogCreatedAt('')).toBeNull();
+ expect(formatCatalogCreatedAt(undefined)).toBeNull();
+ expect(formatCatalogCreatedAt('不是时间')).toBeNull();
+ });
+
+ it('目录为空时给出明确空态,而不是空白', async () => {
+ installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [catalogGame('game_locked', '禁止共创', 'forbidden')],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+
+ expect(await screen.findByText(/作品里还没有开放共创的/)).not.toBeNull();
+ });
+
+ it('目录读取失败时给出失败态与重试', async () => {
+ let attempts = 0;
+ installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ attempts += 1;
+ if (attempts === 1) {
+ throw new Error('读取共创目录失败:请求超时,请稍后重试');
+ }
+ return {
+ games: [catalogGame('game_open', '开放共创的作品', 'full')],
+ nextCursor: null,
+ };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+
+ expect(
+ await screen.findByText('读取共创目录失败:请求超时,请稍后重试'),
+ ).not.toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '重试' }));
+ expect(await screen.findByText('开放共创的作品')).not.toBeNull();
+ });
+
+ it('有封面时用有界 data URL 渲染,换签失败或没有封面则保留首字占位', async () => {
+ const calls = installInvoke((command, args) => {
+ if (command === CATALOG_COMMAND) {
+ return {
+ games: [
+ {
+ ...catalogGame('game_with_cover', '有封面的作品', 'full'),
+ coverObjectKey: 'game-distribution/cover/open.png',
+ },
+ {
+ ...catalogGame('game_broken_cover', '换签失败的作品', 'full'),
+ coverObjectKey: 'game-distribution/cover/broken.png',
+ },
+ catalogGame('game_no_cover', '没有封面的作品', 'full'),
+ ],
+ nextCursor: null,
+ };
+ }
+ if (command === 'read_public_game_cover_preview') {
+ if ((args as { objectKey?: string })?.objectKey?.includes('broken')) {
+ throw new Error('换签失败');
+ }
+ return 'data:image/png;base64,AAAA';
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ const { container } = render();
+
+ // 有封面的那张走
,src 是命令返回的 data URL。
+ const image = await screen.findByRole('img', {
+ name: '有封面的作品 封面',
+ });
+ expect(image.getAttribute('src')).toBe('data:image/png;base64,AAAA');
+ // 失败与缺封面都保留首字占位:不出现破图(即没有第二张
)。
+ expect(container.querySelectorAll('img')).toHaveLength(1);
+ expect(
+ screen.queryByRole('img', { name: '换签失败的作品 封面' }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole('img', { name: '没有封面的作品 封面' }),
+ ).toBeNull();
+ // 没有对象键的那张根本不该发换签请求。
+ expect(
+ calls.filter(
+ (call) =>
+ call.command === 'read_public_game_cover_preview' &&
+ (call.args as { objectKey?: string })?.objectKey === undefined,
+ ),
+ ).toHaveLength(0);
+ expect(
+ calls.filter((call) => call.command === 'read_public_game_cover_preview'),
+ ).toHaveLength(2);
+ });
+
+ it('深链携带错误原因时如实展示,不预填也不请求', async () => {
+ const calls = installInvoke((command) => {
+ if (command === CATALOG_COMMAND) {
+ return { games: [], nextCursor: null };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+ await screen.findByText(/还没有开放共创的/);
+
+ useForkDeepLinkStore.getState().request({
+ gameId: null,
+ message: '链接里没有 gameId,无法确定要共创的作品',
+ });
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ '链接里没有 gameId,无法确定要共创的作品',
+ );
+ expect(forkCalls(calls)).toHaveLength(0);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/forkConfirm.test.tsx b/apps/ai-game-creator-shell/tests/forkConfirm.test.tsx
new file mode 100644
index 000000000..03c8a92b3
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/forkConfirm.test.tsx
@@ -0,0 +1,395 @@
+// @vitest-environment jsdom
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { InitLocalProjectResult } from '../src/app/types';
+import { useForkConfirmStore } from '../src/features/platform-fork/forkConfirmTarget';
+import { MANDATORY_READING_SECONDS } from '../src/features/platform-fork/forkNotice';
+import { FORK_PROGRESS_STAGES } from '../src/features/platform-fork/forkProgress';
+import ForkConfirmView from '../src/view/fork-confirm';
+import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
+
+const FORK_COMMAND = 'create_local_project_from_platform_game';
+
+const PROJECT_RESULT = {
+ projectPath: 'C:\\projects\\gameagent-fork-1',
+ manifestPath: 'C:\\projects\\gameagent-fork-1\\.agent\\manifest.json',
+ manifest: { name: '共创项目 1' },
+} as unknown as InitLocalProjectResult;
+
+const PARENT_GAME = {
+ id: 'game_parent_1',
+ title: '母版作品',
+ author: { id: 'user_1', name: '原作者甲' },
+ forkAuthorization: 'nonCommercial',
+ coverObjectKey: 'game-distribution/cover/parent.png',
+ coverColor: '#c2653d',
+};
+
+type InvokeCall = { command: string; args?: Record };
+
+let eventFake: TauriEventFake | null = null;
+
+function installInvoke(
+ implementation: (command: string, args?: Record) => unknown,
+) {
+ const calls: InvokeCall[] = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ calls.push({ command, args });
+ return implementation(command, args);
+ },
+ );
+ (window as unknown as { __TAURI__?: unknown }).__TAURI__ = {
+ core: { invoke },
+ };
+ return calls;
+}
+
+function renderPage() {
+ const onProjectCreated = vi.fn();
+ const onCancel = vi.fn();
+ const rendered = render(
+ ,
+ );
+ return { ...rendered, onProjectCreated, onCancel };
+}
+
+function confirmButton() {
+ return screen.getByRole('button', { name: /确认并进入编辑/ });
+}
+
+/** 跨过强制阅读窗口(真实计时器即可,3 秒内用假计时器更快)。 */
+function finishMandatoryReading() {
+ act(() => {
+ vi.advanceTimersByTime(MANDATORY_READING_SECONDS * 1000 + 300);
+ });
+}
+
+beforeEach(() => {
+ // 假计时器 + 允许真实时间推进:既能精确跨过 3 秒强制阅读窗口,又不会让
+ // `findBy*`/`waitFor` 这类依赖计时器的查询挂死。
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ useForkConfirmStore.getState().open({
+ gameId: PARENT_GAME.id,
+ game: PARENT_GAME as never,
+ origin: 'co-creation',
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ eventFake?.restore();
+ eventFake = null;
+ useForkConfirmStore.getState().close();
+ delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
+});
+
+describe('开始共创确认页', () => {
+ it('父作品信息卡展示标题 / 作者 / 授权类型 / 封面,缺失时降级不报错', async () => {
+ installInvoke((command) => {
+ if (command === 'read_public_game_cover_preview') {
+ return 'data:image/png;base64,AAAA';
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+
+ const card = await screen.findByLabelText('父作品信息');
+ expect(card.textContent).toContain('母版作品');
+ expect(card.textContent).toContain('原作者甲');
+ expect(card.textContent).toContain('允许非商用共创');
+ expect(card.textContent).toContain('game_parent_1');
+ // 封面走有界 data URL 链路。
+ const cover = await screen.findByRole('img', { name: '母版作品 封面' });
+ expect(cover.getAttribute('src')).toBe('data:image/png;base64,AAAA');
+ });
+
+ it('信息卡缺字段时逐项降级:没有标题/作者/封面也不报错', async () => {
+ useForkConfirmStore.getState().close();
+ useForkConfirmStore.getState().open({
+ gameId: 'game_bare_1',
+ game: null,
+ origin: 'deep-link',
+ });
+ installInvoke((command) => {
+ if (command === 'read_public_game_detail') {
+ return { id: 'game_bare_1' };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ const { onProjectCreated } = renderPage();
+
+ const card = await screen.findByLabelText('父作品信息');
+ await waitFor(() => expect(card.textContent).toContain('(未获取到标题)'));
+ expect(card.textContent).toContain('game_bare_1');
+ // 缺授权类型读作「禁止共创」(契约缺省),缺封面不渲染
。
+ expect(card.textContent).toContain('禁止共创');
+ expect(screen.queryByRole('img')).toBeNull();
+ // 仍然可以走完确认流程(缺信息不阻塞)。
+ finishMandatoryReading();
+ expect(confirmButton()).toHaveProperty('disabled', false);
+ expect(onProjectCreated).not.toHaveBeenCalled();
+ });
+
+ it('强制阅读:3 秒内按钮禁用且倒计时可见,3 秒后可点', async () => {
+ installInvoke(() => null);
+ renderPage();
+
+ expect(confirmButton()).toHaveProperty('disabled', true);
+ expect(confirmButton().textContent).toContain('3s');
+ const progress = screen.getByRole('progressbar', { name: '强制阅读进度' });
+ expect(progress.getAttribute('aria-valuenow')).toBe('0');
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(confirmButton()).toHaveProperty('disabled', true);
+ expect(confirmButton().textContent).toContain('2s');
+ expect(progress.getAttribute('aria-valuenow')).toBe('1');
+
+ finishMandatoryReading();
+ expect(confirmButton()).toHaveProperty('disabled', false);
+ expect(confirmButton().textContent).not.toContain('s)');
+ });
+
+ it('五类须知全文真实渲染,不是一句话折叠', async () => {
+ installInvoke(() => null);
+ renderPage();
+
+ const notices = screen.getByLabelText('授权须知全文');
+ for (const title of [
+ '可修改范围',
+ '商用权限说明',
+ '署名要求',
+ '溯源规则',
+ '违规后果说明',
+ ]) {
+ expect(notices.textContent).toContain(title);
+ }
+ // 关键口径逐句落页:Fork 定义、非商用不得盈利、署名不可删、永久记录、违规后果。
+ expect(notices.textContent).toContain(
+ '整个工程复制一份到你的项目里继续改造',
+ );
+ expect(notices.textContent).toContain('不得用于盈利');
+ expect(notices.textContent).toContain('不能删除、隐藏或改成别人的名字');
+ expect(notices.textContent).toContain('永久记录原作品 ID');
+ expect(notices.textContent).toContain('会被平台下架并要求整改');
+ });
+
+ it('须知块不再自建内层滚动,页面只靠外壳滚动容器', async () => {
+ installInvoke(() => null);
+ const { container } = renderPage();
+
+ const root = container.querySelector('.fork-page');
+ expect(root).not.toBeNull();
+ // 顶部留白在内容层(页根),页头不贴顶;与列表页同值。
+ expect(root?.classList.contains('pt-20')).toBe(true);
+ // 用户可见文案不再出现「像 git fork / 与 git fork 同理」这类类比。
+ expect(document.body.textContent ?? '').not.toMatch(/git\s*fork/iu);
+ // 页面内不得出现任何纵向滚动容器:须知全文随页面自然流动。
+ expect(container.querySelector('.fork-page .overflow-y-auto')).toBeNull();
+ expect(container.querySelector('.fork-page .overflow-auto')).toBeNull();
+ expect(container.querySelector('.fork-page .overflow-scroll')).toBeNull();
+ const notices = screen.getByLabelText('授权须知全文');
+ for (const className of Array.from(notices.classList)) {
+ expect(className.startsWith('max-h-')).toBe(false);
+ expect(className.startsWith('overflow-y-')).toBe(false);
+ }
+ // 须知仍然是**全文**:五条正文都在这一个容器里逐条渲染。
+ expect(notices.querySelectorAll('article')).toHaveLength(5);
+ });
+
+ it('底部操作条是吸附页脚:停在滚动容器下沿,不随内容滚走', async () => {
+ installInvoke(() => null);
+ const { container } = renderPage();
+
+ const footer = container.querySelector(
+ '[aria-label="开始共创操作"]',
+ ) as HTMLElement;
+ expect(footer).not.toBeNull();
+ // `sticky bottom-0` 挂在页面根下(滚动容器是外壳的 `.launcher-main`)。
+ expect(footer.classList.contains('sticky')).toBe(true);
+ expect(footer.classList.contains('bottom-0')).toBe(true);
+ const pageRoot = container.querySelector('.fork-page') as HTMLElement;
+ expect(footer.parentElement).toBe(pageRoot);
+ expect(pageRoot.lastElementChild).toBe(footer);
+ expect(footer.className).toContain('border-t');
+ // 两个动作都在页脚里,长页面下也够得到。
+ expect(footer.textContent).toContain('取消');
+ expect(footer.textContent).toContain('确认并进入编辑');
+ });
+
+ it('状态是徽章形态,与按钮可区分(倒计时同时出现在徽章与按钮上)', async () => {
+ installInvoke(() => null);
+ renderPage();
+
+ const badge = screen.getByText(/^3s 后可确认$/);
+ // 徽章:圆角胶囊 + 描边,不是按钮。
+ expect(badge.tagName).toBe('SMALL');
+ expect(badge.className).toContain('rounded-full');
+ expect(badge.className).toContain('border');
+ expect(badge.closest('button')).toBeNull();
+ expect(screen.getByText('确认后开始').className).toContain('rounded-full');
+
+ finishMandatoryReading();
+ expect(await screen.findByText('已可确认')).not.toBeNull();
+ });
+
+ it('用户可见文案统一叫「开始共创」,不出现 Fork 动作名', async () => {
+ installInvoke(() => null);
+ renderPage();
+
+ // 标题就是统一后的动作名。
+ expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(
+ '开始共创',
+ );
+ // 只检查**声明出来的用户可见节点**(不放全页 `not.toContain('Fork')`,
+ // 那样会误伤内部标识与 aria 说明里的机制描述)。
+ const visibleCopy = [
+ screen.getByRole('heading', { level: 1 }).textContent ?? '',
+ screen.getByLabelText('父作品信息').textContent ?? '',
+ screen.getByLabelText('授权须知全文').textContent ?? '',
+ screen.getByLabelText('工程同步状态').textContent ?? '',
+ screen.getByLabelText('开始共创操作').textContent ?? '',
+ ].join('\n');
+ expect(visibleCopy).not.toMatch(/Fork/u);
+ expect(visibleCopy).toContain('开始共创');
+ });
+
+ it('全开放授权时商用权限改写成可商用', async () => {
+ useForkConfirmStore.getState().close();
+ useForkConfirmStore.getState().open({
+ gameId: 'game_open_1',
+ game: {
+ ...PARENT_GAME,
+ id: 'game_open_1',
+ forkAuthorization: 'full',
+ } as never,
+ origin: 'co-creation',
+ });
+ installInvoke(() => null);
+ renderPage();
+
+ const notices = await screen.findByLabelText('授权须知全文');
+ expect(notices.textContent).toContain('允许全开放共创');
+ expect(notices.textContent).toContain('可以改编、商用、引流');
+ });
+
+ it('工程同步状态条按真实阶段渲染,事件到哪一步就点亮哪一步', async () => {
+ eventFake = createTauriEventFake();
+ eventFake.install();
+ // 让建项命令悬住:进度订阅在 forkGame 期间才在,命令立刻返回会先释放订阅。
+ // (`Promise.withResolvers` 需要 lib ES2024,本仓 AGC 走 ES2022,因此手写 deferred。)
+ let resolveFork: (value: unknown) => void = () => undefined;
+ installInvoke((command) => {
+ if (command === FORK_COMMAND) {
+ return new Promise((resolve) => {
+ resolveFork = resolve as (value: unknown) => void;
+ });
+ }
+ return null;
+ });
+ const { onProjectCreated } = renderPage();
+
+ // 未开始:四个真实阶段都在场,且没有任何一个被点亮。
+ for (const stage of FORK_PROGRESS_STAGES) {
+ expect(screen.getByText(stage.label)).not.toBeNull();
+ }
+ expect(screen.getByText('确认后开始')).not.toBeNull();
+
+ finishMandatoryReading();
+ fireEvent.click(confirmButton());
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: '正在开始共创…' }),
+ ).toHaveProperty('disabled', true),
+ );
+
+ // 原生真实事件:只按到达顺序点亮,不预估、不填百分比。
+ eventFake.flushRegistrationEvals();
+ eventFake.emit('game-fork-progress', {
+ stage: 'download',
+ label: '取回作品内容',
+ });
+ expect(await screen.findByText(/正在:取回作品内容/)).not.toBeNull();
+
+ eventFake.emit('game-fork-progress', {
+ stage: 'verify',
+ label: '校验摘要与字节',
+ });
+ expect(await screen.findByText(/正在:校验摘要与字节/)).not.toBeNull();
+ // 未知阶段整条丢弃,不会污染状态条。
+ eventFake.emit('game-fork-progress', { stage: 'whatever' });
+ expect(screen.getByText(/正在:校验摘要与字节/)).not.toBeNull();
+
+ resolveFork(PROJECT_RESULT);
+ await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
+ });
+
+ it('确认后调用原生 Fork 命令,成功后交给上层进入工作台', async () => {
+ const calls = installInvoke((command) =>
+ command === FORK_COMMAND ? PROJECT_RESULT : null,
+ );
+ const { onProjectCreated } = renderPage();
+
+ finishMandatoryReading();
+ fireEvent.click(confirmButton());
+
+ await waitFor(() =>
+ expect(
+ calls.filter((call) => call.command === FORK_COMMAND),
+ ).toHaveLength(1),
+ );
+ expect(
+ calls.find((call) => call.command === FORK_COMMAND)?.args,
+ ).toMatchObject({ gameId: 'game_parent_1', name: null, planning: false });
+ await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
+ });
+
+ it('取件失败时如实展示可读原因,且不进入工作台', async () => {
+ installInvoke((command) => {
+ if (command === FORK_COMMAND) {
+ throw new Error(
+ 'permission-denied: 作者没有开放这个作品的共创授权,无法开始共创',
+ );
+ }
+ return null;
+ });
+ const { onProjectCreated } = renderPage();
+
+ finishMandatoryReading();
+ fireEvent.click(confirmButton());
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ '作者没有开放这个作品的共创授权,无法开始共创',
+ );
+ expect(onProjectCreated).not.toHaveBeenCalled();
+ });
+
+ it('取消回到进入前的界面,且同步进行中不允许取消', async () => {
+ installInvoke(() => null);
+ const { onCancel } = renderPage();
+
+ fireEvent.click(screen.getByRole('button', { name: '取消' }));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(useForkConfirmStore.getState().target).toBeNull();
+ });
+
+ it('没有待确认目标时给出可读提示而不是空白', async () => {
+ useForkConfirmStore.getState().close();
+ const { onCancel } = renderPage();
+
+ expect(await screen.findByText(/没有待确认的共创作品/)).not.toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '返回' }));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts
index 87164c5c5..b136afa4f 100644
--- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts
+++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts
@@ -79,6 +79,8 @@ describe('Rust 发布 facade', () => {
expect(calls[0]?.command).toBe('publish_local_project_game');
expect(calls[0]?.args).toMatchObject({
projectPath: '/tmp/project',
+ // 身份字段在顶层:`gameMetadata` 不再重复携带 `projectKey`。
+ projectKey: 'local-proj-1',
idempotencyKey: 'agc-publish-test',
// 未传价格时按免费提交,保持旧调用方向后兼容。
priceMudPoints: 0,
@@ -86,7 +88,6 @@ describe('Rust 发布 facade', () => {
coverPath: '',
screenshotPaths: [],
metadata: {
- projectKey: 'local-proj-1',
coverObjectKey: 'generated/cover.png',
screenshots: ['generated/shot-1.png', 'generated/shot-2.png'],
},
@@ -138,7 +139,102 @@ describe('Rust 发布 facade', () => {
expect(calls[0]?.args).not.toHaveProperty('idempotencyKey');
expect(calls[0]?.args).toMatchObject({
projectPath: '/tmp/project',
- metadata: { projectKey: 'local-proj-1' },
+ // 身份锚在顶层:同一作者重复发布会确定性派生出同一 gameId。
+ projectKey: 'local-proj-1',
+ });
+ });
+
+ test('取消公开工程源码时显式传 includeProjectBundle=false', async () => {
+ const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
+
+ await publishLocalProjectGame({
+ invoke,
+ projectPath: '/tmp/project',
+ manifest: MANIFEST,
+ metadata: { coverObjectKey: null },
+ // 首次发布(没有 gameId):平台派发的下一个版本号就是 1;更新发布才是 max+1,
+ // 由平台回读的 `nextVersionNumber` 提供。这个文件的两个用例都是首次发布。
+ versionNumber: 1,
+ includeProjectBundle: false,
+ });
+ expect(calls[0]?.args).toMatchObject({
+ includeProjectBundle: false,
+ versionNumber: 1,
+ });
+
+ // 勾选(或未表态)时不带这个字段:默认由原生按「授权非禁止即上传」判断,
+ // 渲染层不重复实现同一套默认值。
+ await publishLocalProjectGame({
+ invoke,
+ projectPath: '/tmp/project',
+ manifest: MANIFEST,
+ metadata: { coverObjectKey: null },
+ versionNumber: 1,
+ includeProjectBundle: true,
+ });
+ 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',
+ manifest: MANIFEST,
+ metadata: { coverObjectKey: null },
+ versionNumber: 2,
+ changeSummary:
+ ' 把跳台改成三段,并重画全部背景与手感,还新增了两个关卡 ',
+ });
+ expect(calls[0]?.args).toMatchObject({
+ changeSummary: '把跳台改成三段,并重画全部背景与手感,还新增了两个关卡',
+ });
+
+ // 空串/纯空白视为没写:不带字段(母版本来也不该带),由服务端对衍生作品回必填码。
+ await publishLocalProjectGame({
+ invoke,
+ projectPath: '/tmp/project',
+ manifest: MANIFEST,
+ metadata: { coverObjectKey: null },
+ versionNumber: 2,
+ changeSummary: ' ',
+ });
+ expect(calls[1]?.args).not.toHaveProperty('changeSummary');
+ });
+
+ test('共创授权档位随创建作品请求提交,改编声明不来自渲染层', async () => {
+ const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
+
+ await publishLocalProjectGame({
+ invoke,
+ projectPath: '/tmp/project',
+ manifest: MANIFEST,
+ metadata: {
+ coverObjectKey: null,
+ forkAuthorization: 'nonCommercial',
+ },
+ // 同为首次发布:平台派发的下一个版本号是 1(见上一个用例的说明)。
+ versionNumber: 1,
+ });
+ 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',
+ manifest: MANIFEST,
+ metadata: { coverObjectKey: null },
+ versionNumber: 1,
+ });
+ expect(calls[1]?.args).toMatchObject({
+ metadata: { forkAuthorization: 'forbidden' },
});
});
diff --git a/apps/ai-game-creator-shell/tests/launcherScrollContract.test.ts b/apps/ai-game-creator-shell/tests/launcherScrollContract.test.ts
new file mode 100644
index 000000000..c6beac413
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/launcherScrollContract.test.ts
@@ -0,0 +1,153 @@
+/**
+ * 外壳滚动链的**契约测试**(纯 CSS 断言,不需要浏览器)。
+ *
+ * 背景:`.launcher-main` 曾经是 `display: grid` + `grid-template-rows: auto …` + `overflow: hidden`,
+ * 视图一旦比窗口高就被整块裁掉——「共创」列表最后一行、开始共创确认页底部操作条都够不到。
+ * 修法是把 `.launcher-main` 变成**唯一的页面滚动容器**(flex 列 + `overflow-y: auto`),
+ * 视图保持自然高度(`flex: 0 0 auto`)。
+ *
+ * jsdom 没有布局,量不出 `scrollHeight > clientHeight`;这里钉住的是**样式契约**(哪一层负责
+ * 滚动、链路上的每一层都允许收缩),真实滚动行为由浏览器冒烟核对。
+ */
+import { readFileSync } from 'node:fs';
+
+import { describe, expect, it } from 'vitest';
+
+import { repoPath } from './repoPath';
+
+const stylesPath = repoPath('apps/ai-game-creator-shell/src/styles.css');
+const styles = readFileSync(stylesPath, 'utf8');
+
+/** 取某个选择器的第一条声明块(与 `workbenchThemeContrast.test.ts` 同一套解析口径)。 */
+function getCssBlock(source: string, selector: string) {
+ const selectorIndex = source.indexOf(selector);
+ expect(selectorIndex, `${selector} should exist`).toBeGreaterThanOrEqual(0);
+ const openBraceIndex = source.indexOf('{', selectorIndex);
+ let depth = 0;
+ for (let index = openBraceIndex; index < source.length; index += 1) {
+ const char = source[index];
+ if (char === '{') {
+ depth += 1;
+ } else if (char === '}') {
+ depth -= 1;
+ if (depth === 0) {
+ return source.slice(openBraceIndex + 1, index);
+ }
+ }
+ }
+ throw new Error(`${selector} block is not closed`);
+}
+
+/** 去掉注释再断言:注释里会引用旧写法(例如「以前是 grid-template-rows…」)。 */
+function stripComments(block: string) {
+ return block.replace(/\/\*[\s\S]*?\*\//gu, '');
+}
+
+function declaration(block: string, property: string) {
+ const match = block.match(new RegExp(`(^|[;{\\s])${property}:\\s*([^;]+);`));
+ expect(match, `${property} should be declared`).not.toBeNull();
+ return (match?.[2] ?? '').replace(/\s+/gu, ' ').trim();
+}
+
+describe('launcher 滚动链', () => {
+ it('根与外壳层都给足高度,允许收缩', () => {
+ const root = getCssBlock(styles, '#root {');
+ expect(declaration(root, 'height')).toBe('100dvh');
+ expect(declaration(root, 'min-height')).toBe('0');
+
+ const chromeContent = getCssBlock(styles, '.window-chrome__content {');
+ expect(declaration(chromeContent, 'height')).toBe('100%');
+ expect(declaration(chromeContent, 'min-height')).toBe('0');
+ // 外壳内容区自己不能滚:滚动只发生在 `.launcher-main`。
+ expect(declaration(chromeContent, 'overflow')).toBe('hidden');
+
+ const launcherShell = getCssBlock(
+ styles,
+ '.window-chrome__content > .launcher-shell',
+ );
+ expect(declaration(launcherShell, 'height')).toBe('100%');
+ expect(declaration(launcherShell, 'min-height')).toBe('0');
+ });
+
+ it('`.launcher-main` 是唯一页面滚动容器(flex 列 + 纵向 auto)', () => {
+ const main = getCssBlock(styles, '\n.launcher-main {');
+ expect(declaration(main, 'display')).toBe('flex');
+ expect(declaration(main, 'flex-direction')).toBe('column');
+ // 纵向滚动 + 横向裁切:窄窗口不会被内容顶出横向滚动条。
+ expect(declaration(main, 'overflow-y')).toBe('auto');
+ expect(declaration(main, 'overflow-x')).toBe('hidden');
+ // 不允许再出现把内容整块裁掉的 `overflow: hidden`(横向那条是显式 overflow-x)。
+ expect(main).not.toMatch(/(^|[;\s])overflow:\s*hidden;/u);
+
+ const mainOverride = getCssBlock(
+ styles,
+ '.window-chrome__content > .launcher-shell .launcher-main {',
+ );
+ expect(declaration(mainOverride, 'height')).toBe('100%');
+ expect(declaration(mainOverride, 'min-height')).toBe('0');
+
+ // 视图保持自然高度:`flex-shrink: 1` 会把长页面压扁到容器高度。
+ const viewItem = getCssBlock(styles, '.launcher-main > * {');
+ expect(declaration(viewItem, 'flex')).toBe('0 0 auto');
+ });
+
+ it('旧的 grid 行高与裁切不再决定滚动', () => {
+ const main = stripComments(getCssBlock(styles, '\n.launcher-main {'));
+ expect(main).not.toMatch(/grid-template-rows/u);
+ expect(main).not.toMatch(/grid-template-columns/u);
+ expect(main).not.toMatch(/align-content/u);
+
+ // 工作台仍可特判自己占满高度,但不得再改滚动方式(滚动只有一处)。
+ const workbenchRule = getCssBlock(
+ styles,
+ '.launcher-main:has(.game-project-workbench) {',
+ );
+ expect(workbenchRule).not.toMatch(/overflow|display|height/u);
+ });
+
+ it('共创相关页面共享一套版式令牌,且正文不吃满整屏', () => {
+ const tokens = readFileSync(
+ repoPath(
+ 'apps/ai-game-creator-shell/src/features/platform-fork/forkPageStyles.ts',
+ ),
+ 'utf8',
+ );
+ const column = tokens.match(
+ /export const FORK_PAGE_COLUMN =\s*'[^']*min\((\d+)px/u,
+ );
+ expect(
+ column,
+ 'FORK_PAGE_COLUMN should cap the column width',
+ ).not.toBeNull();
+ const width = Number(column?.[1]);
+ expect(width).toBeGreaterThanOrEqual(720);
+ expect(width).toBeLessThanOrEqual(880);
+ // 正文另有 72ch 上限:长文不铺满整个内容列。
+ expect(tokens).toContain('max-w-[72ch]');
+ });
+
+ it('页头顶部留白加在内容层(≥80px),不加在滚动容器上', () => {
+ const tokens = readFileSync(
+ repoPath(
+ 'apps/ai-game-creator-shell/src/features/platform-fork/forkPageStyles.ts',
+ ),
+ 'utf8',
+ );
+ const root = tokens.match(
+ /export const FORK_PAGE_ROOT =[\s\S]*?'([^']+)'/u,
+ );
+ expect(root, 'FORK_PAGE_ROOT should be declared').not.toBeNull();
+ const rootClass = root?.[1] ?? '';
+ // 80px = Tailwind 的 pt-20,与首页首屏的顶部留白同值。
+ expect(rootClass).toContain('pt-20');
+ const home = readFileSync(
+ repoPath('apps/ai-game-creator-shell/src/view/home/index.tsx'),
+ 'utf8',
+ );
+ expect(home, '首页用同一个 pt-20 作为基准').toContain('pt-20');
+
+ // 滚动容器自己不加上内边距:留白属于内容层,滚动时页头跟着内容走。
+ const main = stripComments(getCssBlock(styles, '\n.launcher-main {'));
+ expect(main).not.toMatch(/padding-top/u);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/platformForkDeepLink.test.tsx b/apps/ai-game-creator-shell/tests/platformForkDeepLink.test.tsx
new file mode 100644
index 000000000..f1c6bd257
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/platformForkDeepLink.test.tsx
@@ -0,0 +1,97 @@
+// @vitest-environment jsdom
+import { cleanup, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ FORK_DEEP_LINK_EVENT,
+ forkDeepLinkRequestFromEvent,
+ subscribeForkDeepLink,
+ useForkDeepLinkStore,
+} from '../src/features/platform-fork/forkDeepLink';
+import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
+
+/**
+ * 深链在渲染层的落点:原生事件 → 待处理请求 store。
+ *
+ * 「预填输入框、不自动下载」这条行为在共创页上(`coCreation.test.tsx`),这里只钉住事件解析与订阅,
+ * 免得同一件事在两个文件里各断言一遍。
+ */
+let eventFake: TauriEventFake | null = null;
+
+afterEach(() => {
+ cleanup();
+ eventFake?.restore();
+ eventFake = null;
+ useForkDeepLinkStore.getState().consume();
+});
+
+describe('Fork 深链载荷解析', () => {
+ it('接受 gameId 与错误原因两种形状,并丢掉空载荷', () => {
+ expect(
+ forkDeepLinkRequestFromEvent({ gameId: 'game_from_link', message: null }),
+ ).toEqual({ gameId: 'game_from_link', message: null });
+ expect(
+ forkDeepLinkRequestFromEvent({
+ gameId: null,
+ message: '链接里没有 gameId',
+ }),
+ ).toEqual({ gameId: null, message: '链接里没有 gameId' });
+ // 两端空白由渲染层收敛,避免把带空白的 ID 填进输入框。
+ expect(
+ forkDeepLinkRequestFromEvent({ gameId: ' game_x ', message: '' }),
+ ).toEqual({ gameId: 'game_x', message: null });
+ for (const payload of [
+ null,
+ undefined,
+ 'game_x',
+ 42,
+ {},
+ { gameId: null, message: null },
+ { gameId: ' ', message: ' ' },
+ ]) {
+ expect(forkDeepLinkRequestFromEvent(payload)).toBeNull();
+ }
+ });
+
+ it('订阅原生事件后把载荷交给调用方(空载荷不产生请求)', async () => {
+ eventFake = createTauriEventFake();
+ eventFake.install();
+ const received: unknown[] = [];
+ const release = await subscribeForkDeepLink((request) => {
+ received.push(request);
+ });
+ eventFake.flushRegistrationEvals();
+
+ eventFake.emit(FORK_DEEP_LINK_EVENT, { gameId: 'game_from_link' });
+ expect(received).toEqual([{ gameId: 'game_from_link', message: null }]);
+
+ // 没有 gameId 也没有 message 的载荷不产生请求(避免用空链接清空用户输入)。
+ eventFake.emit(FORK_DEEP_LINK_EVENT, {});
+ await waitFor(() => expect(received).toHaveLength(1));
+ release();
+ });
+
+ it('待处理请求取走即清空,同一链接只生效一次', () => {
+ const store = useForkDeepLinkStore.getState();
+ store.request({ gameId: 'game_once', message: null });
+ expect(useForkDeepLinkStore.getState().consume()).toEqual({
+ gameId: 'game_once',
+ message: null,
+ });
+ expect(useForkDeepLinkStore.getState().consume()).toBeNull();
+ });
+});
+
+describe('深链订阅的清理', () => {
+ it('注销后事件不再进入回调', async () => {
+ eventFake = createTauriEventFake();
+ eventFake.install();
+ const handler = vi.fn();
+ const release = await subscribeForkDeepLink(handler);
+ eventFake.flushRegistrationEvals();
+ release();
+
+ eventFake.emit(FORK_DEEP_LINK_EVENT, { gameId: 'game_after_release' });
+ expect(handler).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/platformGameForkModel.test.ts b/apps/ai-game-creator-shell/tests/platformGameForkModel.test.ts
new file mode 100644
index 000000000..bdd4ca1d4
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/platformGameForkModel.test.ts
@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+
+import { classifyPlatformForkFailure } from '../src/features/platform-fork/platformGameForkModel';
+describe('Fork 取件失败分类', () => {
+ it('按原生前缀分类失败,并去掉机器前缀后展示', () => {
+ expect(
+ classifyPlatformForkFailure(
+ new Error(
+ 'authentication-required: 陶泥儿登录态已过期,请重新登录后重试',
+ ),
+ ),
+ ).toEqual({
+ kind: 'auth',
+ message: '陶泥儿登录态已过期,请重新登录后重试',
+ });
+ expect(
+ classifyPlatformForkFailure(
+ 'permission-denied: 作者没有开放这个作品的共创授权,无法开始共创',
+ ),
+ ).toEqual({
+ kind: 'forbidden',
+ message: '作者没有开放这个作品的共创授权,无法开始共创',
+ });
+ expect(
+ classifyPlatformForkFailure(
+ 'fork-source-not-found: 该作品不存在或已被删除,无法开始共创',
+ ),
+ ).toEqual({
+ kind: 'notFound',
+ message: '该作品不存在或已被删除,无法开始共创',
+ });
+ expect(
+ classifyPlatformForkFailure(
+ 'fork-source-not-available: 该作品当前不能开始共创(未公开、已下架或没有公开版本)',
+ ),
+ ).toEqual({
+ kind: 'unavailable',
+ message: '该作品当前不能开始共创(未公开、已下架或没有公开版本)',
+ });
+ // 未命中前缀:原样展示,不吞掉排障信息。
+ expect(classifyPlatformForkFailure(new Error('网络不可用'))).toEqual({
+ kind: 'other',
+ message: '网络不可用',
+ });
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/runUnavailableHint.test.ts b/apps/ai-game-creator-shell/tests/runUnavailableHint.test.ts
new file mode 100644
index 000000000..bd3d8714e
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/runUnavailableHint.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+
+import { runUnavailableHintText } from '../src/view/project-development/runUnavailableHint';
+
+/**
+ * 「运行」页签不可用时的文案(`run-unavailable-hint`)。
+ *
+ * 为什么要有这条用例:同一句「首个可运行原型尚未完成」对**已有工程内容、只是本机还没构建出
+ * 可运行产物**的情况(典型是从平台作品 Fork 来的工程源包项目)是误导——内容明明已经在本地。
+ * 文案因此必须按清单事实分叉,而分叉判据只能是清单里已有的字段。
+ */
+describe('runUnavailableHintText', () => {
+ it('全新项目(清单里还没有工程内部版本)沿用原有措辞', () => {
+ expect(runUnavailableHintText({ versions: [] })).toBe(
+ '首个可运行原型尚未完成,运行视图暂不可用',
+ );
+ });
+
+ it('versions 字段整体缺失时按「没有版本」处理,不抛错', () => {
+ expect(runUnavailableHintText({} as { versions: [] })).toBe(
+ '首个可运行原型尚未完成,运行视图暂不可用',
+ );
+ });
+
+ it('已有工程内容但没有可运行的构建产物时如实说明需要先完成原型', () => {
+ const text = runUnavailableHintText({
+ versions: [
+ {
+ versionId: 'initial-1',
+ parentVersionId: null,
+ projectRevision: 1,
+ resourceBindings: [],
+ createdReason: 'initial',
+ createdAt: 1_791_271_678,
+ },
+ ],
+ });
+
+ expect(text).toBe(
+ '已有工程内容但还没有可运行的构建产物:让智能体完成可运行原型后即可运行',
+ );
+ // 不能再用「首个可运行原型尚未完成」那种"什么都还没有"的说法。
+ expect(text).not.toContain('首个可运行原型尚未完成');
+ });
+});
diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf
index cfc24e607..69f1583b2 100644
--- a/deploy/container/nginx.conf
+++ b/deploy/container/nginx.conf
@@ -183,7 +183,7 @@ http {
try_files /index.html =404;
}
- location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/mine|games/play|games/publish)/?$" {
+ location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish|games/theme|games/themes)/?$" {
try_files $uri /index.html =404;
}
diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf
index 89b6ecd03..3d7593beb 100644
--- a/deploy/nginx/genarrative-dev-http.conf
+++ b/deploy/nginx/genarrative-dev-http.conf
@@ -240,7 +240,7 @@ server {
try_files /index.html =404;
}
- location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/mine|games/play|games/publish)/?$" {
+ location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish|games/theme|games/themes)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf
index 987b5f9e1..2063cb350 100644
--- a/deploy/nginx/genarrative.conf
+++ b/deploy/nginx/genarrative.conf
@@ -268,7 +268,7 @@ server {
try_files /index.html =404;
}
- location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/mine|games/play|games/publish)/?$" {
+ location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish|games/theme|games/themes)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
diff --git a/docs/README.md b/docs/README.md
index 13874f005..43b973947 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -22,13 +22,14 @@
- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价已实现并通过本地验证,待用户验收,未部署。
- [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的验收边界与本地证据。
- [创作者主页与关注粉丝合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同):前后端已实现并通过工程验证,用户已确认提交交付,未部署。第四项默认进入自己主页,他人的关注/粉丝列表统一只读并支持主页跳转。
-- [创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md):分层落点、关系表/DTO、授权、关系列表分页、组件状态、深链与验证边界;游戏列表沿用最多 48 项限制,不做额外分页改造。
+- [创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md):分层落点、关系表/DTO、授权、关系列表分页、组件状态、深链与验证边界;公开游戏目录原为最多 48 项且不分页,**2026-10-07 已补真游标分页**(缺省 48 / 上限 100 / 非法游标 400 `CATALOG_INVALID_CURSOR` / 末页 `nextCursor` 为 `null`),作者过滤沿用原排序但不再被 48 项截死。
- [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。
- [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。
- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。
- [游戏买断制泥点付费与播放鉴权合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏买断制泥点付费与播放鉴权合同)与[里程碑](./project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md):已实现,本机真实栈 E2E 由人工验收脚本 `check:game-distribution-purchase-e2e` 覆盖(最近一次人工运行 76 PASS / 0 FAIL / 1 WARN),该脚本不在 CI 自动门禁内;待用户验收,未部署;作者可选买断制泥点付费,购买后永久可玩,后台审核可见价格且审核员不限次试用;网页与 AGC 两个发布入口一致支持定价并共用 `packages/shared` 组件 `PlatformGamePricingField`,AGC 定价的前端用例与 Rust 预填单测已覆盖,AGC 真实栈发布未覆盖。
- [游戏分发统一发布接口与版本号自然幂等合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏分发统一发布接口与版本号自然幂等合同2026-10-07)、[里程碑](./project-memory/plans/【里程碑】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md)与[实施计划](./project-memory/plans/【实施计划】游戏分发统一发布接口与版本号自然幂等-2026-10-07.md):已定稿待实现;`POST /versions` 合并建作品与建首版,`versionNumber` 必填并作自然幂等键,首次发布媒体只上传一次。
- [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。
+- [游戏共创与作品 Fork](./【技术方案】游戏共创与作品Fork-2026-10-03.md):共创授权三态(只升不降)、作品级血缘与代际、成品包与工程源包两条改造路径、族谱树与溯源署名的产品设计与技术方案;同时作为该功能主规范,已评审通过,按 M1 逐里程碑实现。
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
diff --git a/docs/project-memory/plans/【实施计划】游戏共创授权与血缘-2026-10-04.md b/docs/project-memory/plans/【实施计划】游戏共创授权与血缘-2026-10-04.md
new file mode 100644
index 000000000..a196a3bea
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】游戏共创授权与血缘-2026-10-04.md
@@ -0,0 +1,98 @@
+# 【实施计划】游戏共创授权与血缘
+
+| 字段 | 值 |
+| ------------- | ------------------------------------------------------------------------ |
+| Version | 1.0 |
+| Status | implemented-local(代码与静态门禁已过;运行时端到端与浏览器验证待补) |
+| Date | 2026-10-04 |
+| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` |
+| Milestone | `docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md` |
+| 授权默认值 | `forbidden`(保守取值,与需求文档一致;如需改默认允许,改一个常量即可) |
+| 分支 / 工作树 | `feat/game-fork` / `.worktrees/feat/game-fork` |
+
+## 修改边界(逐文件)
+
+### 1. `server-rs/crates/spacetime-module/src/game_distribution.rs`
+
+- `GameDistributionGame` 表尾追加 `fork_authorization: String`,`#[default("forbidden".to_string())]`。
+- 新增表 `GameDistributionLineage`(accessor `game_distribution_lineage`):
+ - `game_id` PK;`owner_user_id` / `parent_game_id` / `root_game_id` 三个 btree 索引。
+ - 字段:`game_id, owner_user_id, parent_game_id, parent_version_id, root_game_id, generation, created_at`。
+- 新常量:`GAME_DISTRIBUTION_FORK_AUTHORIZATION_{FORBIDDEN,NON_COMMERCIAL,FULL}`、`GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION`。
+- `GameDistributionCreateGameInput` 追加 `forked_from_game_id: Option`、`forked_from_version_id: Option`。
+- 新增 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`。
+- `create_game_distribution_game_tx`:在「新建分支」写 game 之后、写收据之前校验并写血缘行(幂等回放分支与 `local_project_id` 复用分支都不写血缘;复用分支带血缘直接报错)。
+- 新增 `set_game_distribution_fork_authorization_tx` + procedure `set_game_distribution_fork_authorization_and_return`。
+- 快照:`GameDistributionGameSnapshot` 加 `fork_authorization`;公开快照加 `fork_authorization` / `fork_count` / `lineage`(父作品摘要 + 代际 + 根);后台快照加 `fork_authorization` / `generation` / `forked_from_game_id` / `derived_count`。
+- 血缘校验函数 `validate_game_distribution_fork_declaration_tx(ctx, owner, parent_game_id, parent_version_id) -> Result<(root, generation, parent_owner), String>`。
+
+### 2. `server-rs/crates/spacetime-module/src/migration.rs`
+
+- 在 `game_distribution_game` 之后追加 `game_distribution_lineage`,并补一行注释说明它是业务事实、随迁移导出。
+
+### 3. `server-rs/crates/module-game-distribution/src/{domain,errors,lib}.rs`
+
+- 授权阶梯规则(只升不降)与错误:`FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED`、`FORK_AUTHORIZATION_UNKNOWN`、`FORK_NOT_AUTHORIZED`、`FORK_SOURCE_NOT_AVAILABLE`、`FORK_SOURCE_VERSION_MISMATCH`、`FORK_DECLARATION_ON_EXISTING_GAME`(展示文案中文,错误码进 details)。
+- 代际计算:`next_generation(parent_generation) = parent_generation + 1`;根判定。
+- 配套单测。
+
+### 4. `server-rs/crates/spacetime-client/src/**`
+
+- `game_distribution.rs`:创建输入 record 加血缘字段;新增 `set_game_distribution_fork_authorization`。
+- `active/mapper/game_distribution.rs`:`GameDistributionGameRecord` 加 `fork_authorization`;公开 record 加 `fork_count`/`lineage`;admin record 加 `generation`/`forked_from_game_id`/`derived_count`;新增血缘 record 与映射。
+- `module_bindings/**` 由 `npm run spacetime:generate` 生成,禁止手改。
+
+### 5. `server-rs/crates/shared-contracts/src/game_distribution.rs`
+
+- `GameDistributionCreateGameRequest` 追加 `fork: Option`(`parentGameId` / `parentVersionId`)。
+- 新增 `GameDistributionSetForkAuthorizationRequest { expectedForkAuthorization, forkAuthorization }`。
+- 游戏响应加 `forkAuthorization`,公开响应加 `forkCount` / `lineage`。(原计划的 `forkSourceAvailable` 未实现,也不需要:改造入口的显隐依据 `forkAuthorization`,可复刻形态由 `/fork-source` 回答;若 M2b 要在详情页区分「可源码级改造 / 只能参考」,届时再加。)
+
+### 6. `server-rs/crates/api-server/src/modules/game_distribution.rs`
+
+- `protected` 路由新增 `PUT /api/game-distribution/games/{game_id}/fork-authorization`。
+- `create_game` 校验并传递血缘声明。
+- `public_game_payload` / `game_payload` / `admin_game_payload` 增量字段。
+- `map_spacetime_error` 增加血缘/授权关键字的映射分支(放在 `不匹配` 分支之前,避免子串碰撞)。
+- 定向测试(沿用 `app.rs` 里的分发测试 harness)。
+
+### 7. 网页端
+
+- `packages/shared/src/contracts/gameDistribution.ts`:`GameDistributionGame` / 创建请求 / 新授权请求类型。
+- `src/services/gameDistributionClient.ts`:`updateGameForkAuthorization`(照 `unpublishGame` 写)。
+- `src/components/game-distribution/GameDetailPage.tsx`:授权徽章、溯源卡、代际。
+- `src/components/game-distribution/MyGamesPage.tsx`:卡片内授权设置(行内两段式确认,复用现有 `PlatformActionButton` 视觉),只升不降。
+- 定向测试:`GameDistributionPages.test.tsx` / `MyGamesPage.test.tsx` 增量用例。
+
+### 8. 文档
+
+- 主规范 `§5 验收证据` 回填、里程碑验收勾选;`docs/README.md` 索引已登记。
+
+## 顺序
+
+1. schema(表 + 列 + 迁移登记)→ `npm run spacetime:generate` → `npm run check:spacetime-schema`。
+2. 领域规则与单测(`module-game-distribution`)。
+3. module tx/procedure + client facade + mapper。
+4. shared-contracts DTO + api-server handler/payload/错误映射 + 后端定向测试。
+5. 网页端类型、服务层、详情页、我的作品页 + 定向测试。
+6. 运行时 smoke:真实栈走「设置授权 → 提升 → 拒绝降级 → 声明血缘 → 详情页展示 → 父作品下架后新声明被拒」。
+
+## 验证命令
+
+```bash
+npm run spacetime:generate
+npm run check:spacetime-schema
+cargo test -p module-game-distribution
+cargo test -p api-server
+npx tsc --noEmit
+npx vitest run src/components/game-distribution
+npm run check:encoding
+git diff --check
+```
+
+## 风险与回滚点
+
+- **回滚点 1(schema)**:新列与新表只做追加,旧行默认 `forbidden`;回滚只需还原表定义并重新发布(新表直接删表定义)。
+- **风险**:`map_spacetime_error` 靠子串匹配,新错误文案若含「不匹配」「已存在」「状态」会被打成 409 —— 必须在映射表前面显式加分支。
+- **风险**:新增血缘校验若放在 `local_project_id` 复用分支之后,会放过「既有作品改判成衍生作品」;必须放在复用分支内判定。
+- **风险**:公开 payload 不得回传对象键;血缘只回传作品摘要与代际。
diff --git a/docs/project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md b/docs/project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md
new file mode 100644
index 000000000..6f5f0ded7
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md
@@ -0,0 +1,73 @@
+# 【里程碑】作品工程源包与一键改造
+
+| 字段 | 值 |
+| ----------- | ----------------------------------------------------- |
+| Version | 1.2 |
+| Status | implemented-local(**两端均已落地**:服务端三列 + 校验器 + 上行/下行路由族;AGC 侧打包器 + 上传 + 取件建项 + 来源记录 v2;端到端脚本 46/46、规则 parity 门禁 OK。未跑项见验收标准里的 ⏳) |
+| Date | 2026-10-03(2026-10-04 标注为正解路径;2026-10-05 按实现更新范围与验收) |
+| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`(口径见 §2.5、§3.2.3、§3.4、§3.5.1、§3.5.2、§3.5.4) |
+
+> **定位(2026-10-04)**:只读侦察证明成品包路径只能试玩、不能重新发布(见「成品包改造闭环」里程碑的「事实依据」),因此**本里程碑是「一键复刻完整工程」的唯一正解路径**:只有拿到作者随版本上传的工程源包,用户才可能在不重写工程的前提下改核心逻辑并重新发布。技术方案 §3.5.4 的推荐是「路线 A(参考式改编)先行验证需求,路线 B(本里程碑)作为正解排后续」。
+>
+> **产品口径(2026-10-05 已拍板,§3.2.3/§3.5.4)**:**不做**独立的「源码可见性」开关——作品授权非 `forbidden` 时,工程源包与成品包走同一道取件鉴权;作者不想给源码就是**不上传**,「上传与否」本身就是开关。`nonCommercial` 的约束力是**平台规则层面**(源码被取走后无法从技术上阻止商用)。上传与发布/审核解耦:未上传不影响发布。
+
+## 目标
+
+作者在授权允许时,把工程源码作为该发行版本的可选伴随资产一起发布;其他用户拿到的是**源码级工程**,可以改核心逻辑、重跑构建并发布,改造质量与原作者体验一致。
+
+## 范围
+
+- 工程源包作为发行版本的可选伴随资产:上传(整包或分片续传)、确认、只读回读与对象存储生命周期。
+- 工程源包的内容门禁:服务端**独立恢复**(不信任客户端)——与发行包同级的体积/条目上限 + 源码场景项(凭据与隐私文件、依赖与 VCS 目录、根级构建产物与 IDE 目录、嵌套压缩包、符号链接、加密条目)。
+- 改造内容下发入口的来源优先级:同一版本同时存在工程源包与成品包时优先下发工程源包,并如实标注来源类型。
+- 客户端:源码工程确定性打包(条目排序 + 固定时间戳)→ 上传(含中断续传);下载 → 校验摘要 → 解压 → 以源码工程形态建项。
+- 发布面板的工程包上传入口与后果说明:**上传与否是作者唯一的开关**(不做独立的「源码可见性」授权位);未上传时如实提示「只能被他人试玩与参考」。
+
+## 不在范围内
+
+- 成品包路径的「试玩 + 素材参考」闭环(见「成品包改造闭环」里程碑;本里程碑复用其内容下发入口、血缘声明与建项基座)。
+- **已公开版本的补传(backfill)**:原方案曾设想「公开后可给当前公开版本补传一次工程包」,本期**不做**——上传阶段门与发行包确认一致(只允许 `awaiting_upload` / `upload_failed`),公开后不接受任何内容写入。若产品要这条,需要单独设计(它会是「版本不可变」的例外)。
+- 工程源包的版本回溯(历史版本没有工程包时不为它补做)。
+- 网页端上传工程源码包(首期只支持客户端)。
+- 相似度比对与低改动度判定、收益分成。
+
+## 依赖与前置条件
+
+- 「游戏共创授权与血缘」里程碑已通过验收。
+- 「成品包改造闭环」里程碑已通过验收(内容下发入口、客户端建项基座、发布携带来源、授权面板均在那一里程碑落地)。
+- **前置依赖 ①「工程包下载/授权」接口——已落地(2026-10-05)**:`GET /games/{gameId}/fork-source` 在有工程包时返回 `source: "project"`,本体的独立路径 `GET /games/{gameId}/fork-source/project` 与成品包共用同一套鉴权与校验函数(不重复实现),**不下发对象键**、只服务当前公开版本、`no-store`。读取复用现役 OSS 读路径,**缓存键含资产维度**(对象键 `…/{version_id}.project.zip` 与 `…/{version_id}.zip` 不同),两份资产不串味。
+- **前置依赖 ②包内禁项与规模上限——已定稿并已落地(2026-10-05)**:服务端 `module-game-distribution/src/project_bundle.rs` 的 `validate_project_bundle_zip` 独立复核,拒绝清单与上限见技术方案 §3.5.2。**512 MiB(客户端打包上限)与 200 MiB(服务端/反代放行量)的不对齐按下述口径收敛**:服务端上限与发行包逐项相等(200 MiB / 展开 500 MiB / 单文件 64 MiB / 条目 10 000 / 压缩比 100)。**客户端预检已完成**(原先属待办):打包器在产出后即按 `limits.max_archive_bytes` 失败关闭(`apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs` 的 `write_project_bundle_zip` 内 `bytes.len() > limits.max_archive_bytes` 分支),超限包在本地就被拦下,不会「上传到一半被拒」;单文件 / 累计展开 / 条目数更是在压缩前就检查。
+- 客户端已有的「从包安装并建项」基座与包内容门禁可直接复用;本里程碑不新建第二套解压或建项路径。
+- 决策:产品已同意引入工程源包,且**不设**独立的「源码可见性」授权位(口径见文首)。
+
+## 实现进展(2026-10-05)
+
+- 服务端:块 A `c6bd2c081`(三列 + 契约 + 绑定)、块 B `9403a47c0`(工程包校验器)、块 C+D `d061b2e37`(上行 5 条路由 + 下行优先 + 缓存资产维度);文档块 E `3aa145d91`。
+- 客户端:`4d070dd1a`(规则对齐 + `check-project-bundle-policy-parity` 机器门禁)、`5dd9d1c5d`(发布时上传,失败不阻断)、`72f08c264`(取件按 `source` 分支建项 + 来源记录 v2 兼容 v1)、`da24dd5be`(取件提示按形态如实措辞);`ff763abe5`(端到端验收脚本)。
+- 证据(按冻结点实测重数):`npm run (本地夹具脚本,无 npm 条目)` **51 个 `check(` 调用点**;`npm run check-project-bundle-policy-parity` OK(服务端 51 条规则全被客户端覆盖 + 路径形状维度);`cargo test -p module-game-distribution` **73 passed**(其中 `project_bundle::tests` **19 条**);`cargo test -p api-server game_distribution` 60 passed;AGC `vitest` 1961 passed、客户端打包器 `cargo test -- project_bundle` **24 passed**(含 dev-dependency 执行级交叉测试)。
+- **未做(含原因)**:① 已公开版本的补传(backfill)——按产品口径不做,阶段门与发行包确认一致;② 真实 >200 MiB / 10 001 条目 / 极端半包续传边界——未测(成本高,靠常量单测钉住);③ AGC 真机取件→建项链路——未跑(需要真机客户端窗口);④ 结构化埋点事件——未接(待产品定事件口径)。
+- **已知留白(对抗性复核后如实登记,别当成已全覆盖)**:归档格式未纳入 `.bz2` / `.xz` / `.zst`;数据库转储 `*.db` / `*.sql` 未纳入;凭据目录未纳入 `.gcloud` / `.azure` / `.password-store`;zip 条目名的 CP437 解码与 `external_attributes == 0` 的条目未覆盖;「爆炸包峰值内存」只有机制级封顶 + 常量单测,未做真实量测;内容嗅探的误报率无实测数据(只做了「不误报」的反例:`.sshrc` / `Dockerfile` / 非私钥证书块 / `AKIA` 长度不足 / 二进制扩展名不扫)。
+
+## 验收标准
+
+> 标注:✅ = 已落地并有证据(单测 / 门禁 / 端到端脚本,本地);⏳ = 仍待补(真机或极端边界);⏭ = 明确不做。
+
+- [x] ✅ 工程包校验器拒绝清单齐备:绝对路径、`..`、盘符、反斜杠、通配符、符号链接、加密条目、**嵌套归档扩展名并集(`.zip`/`.tar`/`.gz`/`.tgz`/`.7z`/`.rar`/`.jar`/`.whl`/`.nupkg`)与 magic bytes 嗅探(改名也拦)**、完全重复/大小写折叠重复路径、任意层级 `node_modules` / `.git` / `.svn` / `.agent` / **`.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`**、根级 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`、凭据与隐私文件(`.env*`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`.npmrc`、`.netrc`、`.git-credentials`、`id_rsa*`、`id_ed25519*`、**`credentials*` / `id_ecdsa*` / `id_dsa*` / `terraform.tfstate*` / `service-account*` / `*.jks` / `*.keystore` / `*.ppk` / `*.p8` / `*.kdbx` / `*.der` / `.htpasswd` / `.pgpass` / `*.map`**)、**小体积文本条目的内容嗅探(PEM 私钥块 / `AKIA…` / `ghp_` / `github_pat_` / `xox*`)**、单文件/累计/压缩比超限;合法源码包(**不含** `index.html`)通过。证据:`module-game-distribution` **19 条校验器单测**(+ `package.rs` 6 条)与 `check-project-bundle-policy-parity`(客户端打包器与它逐项对齐)。
+- [x] ✅ 同一版本重复确认同一工程包不产生第二份对象(同内容幂等重放);换内容重传按冲突拒绝(409 `同一版本已存在不同的工程源包`)。证据:模块事务 + 本地夹具脚本(工程源包)A4。
+- [x] ✅ 阶段门:只有 `awaiting_upload` / `upload_failed` 可写;已上传、验证中、待审核、已拒绝、已公开、已撤回、已取消一律拒绝。**顺序按实现**:「已存在工程包」先判(409 `PROJECT_BUNDLE_ALREADY_EXISTS`),故「已公开且已有工程包」返回 `ALREADY_EXISTS`;「已公开但无工程包」才返回 `UPLOAD_NOT_ALLOWED`。证据:e2e A5 / A5b + api-server 单测。
+- [x] ✅ 非作者上传 → **404**(按「版本不存在」处理,与发行包上行族同口径,不泄露版本存在性),未带 Bearer → 401。证据:e2e A6 + 路由级 401 单测。
+- [x] ✅ 上行整包 PUT(`application/octet-stream`)+ 服务端独立复核 + 失败 422 `PROJECT_BUNDLE_VALIDATION_FAILED` 且清理半包对象。证据:e2e(整包/校验失败/清理)+ 单测。
+- [x] ✅ 下行优先级:同一版本同时有两种资产时 `fork-source` 返回 `source: "project"` 且 `downloadPath` 指向 `/fork-source/project`;只有成品包时回落 `package`;「字节数 > 0 但摘要为空」的半写行回落 `package`(失败关闭);两条下载路径共用同一个校验函数,请求 project 而实际没有工程包时 409 而非静默回落。
+- [x] ✅ 两种资产的读取缓存不串味(缓存键即对象键,键名分别以 `.zip` / `.project.zip` 结尾)。证据:api-server 单测(对象键互不相等 + `ReleasePackageCache` 双资产各自取回)。
+- [x] ✅ 未上传工程包不阻断发布与审核(上传是独立可选资产,不参与版本状态机);无工程包的作品仍能拿到成品包;未上传时面板如实提示「只能被他人试玩与参考」。证据:客户端发布链路改动 + 面板文案测试(真机窗口仍未看,见下条)。
+- [ ] ⏳ 上传中断后按权威偏移续传的**真实**验证:分片路由与客户端续传逻辑均已落地,但未跑真实断点/乱序场景。
+- [ ] ⏳ AGC **真机**取件 → 建项链路:客户端按 `source` 分支建项与来源记录 v2 已落地并有单测,但未在真机窗口里跑过「取件 → 解压 → 建项 → 可编辑/可试玩/可发布」整链。
+- [ ] ⏳ 通过源码路径发布的作品,其来源、代际与根与成品包路径完全一致(服务端本就用同一套血缘,真机验证待做)。
+- [ ] ⏳ 真实极端边界:>200 MiB 压缩包、>10 001 条目、极端压缩比在**真实上传链路**上的表现(当前靠常量单测与门禁钉住,未在真实栈压测)。
+- [x] ⏭ 已公开版本的**补传(backfill)**:按产品口径**不做**(阶段门与发行包确认一致,公开后不接受内容写入)。原验收项已从本节移除,改为本行显式记录。
+
+## 证据要求
+
+- 自动化(已落地的命令):`npm run (本地夹具脚本,无 npm 条目)`(**46/46**)、`npm run check-project-bundle-policy-parity`(两端规则一致)、`cargo test -p module-game-distribution`(69 passed,含校验器 16)、`cargo test -p api-server game_distribution`(60 passed)、AGC `npx vitest run apps/ai-game-creator-shell/tests`(1961 passed)与定向 `cargo test -- fork|project_bundle`(17 + 16 passed)、DTO parity 与 schema/编码/文档索引门禁。
+- 运行时:真实 api-server + 真实对象存储 + 真实客户端跑通「拿到源码 → 改核心逻辑 → 试玩 → 发布 → 溯源与代际正确」,附对比截图或录屏。**(取件 → 建项链路的真机窗口验证仍未跑,见验收标准 ⏳)**
+- 边界:越权下载、无授权下载、来源已下架、摘要不符、超限包、解压失败、上传中断续传、账号切换后的迟到响应、同版本双来源优先级。
diff --git a/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md
new file mode 100644
index 000000000..955d0b68c
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md
@@ -0,0 +1,261 @@
+# 【里程碑】共创主题与作品树
+
+| 字段 | 值 |
+| ----------- | --------------------------------------------------------------------- |
+| Version | 1.1 |
+| Status | 服务端已实现(`2fa201e0d` 模型/纯函数/契约、`742723a58` 公开读、`033e3aa79` 后台写);**公开前端(共创 Tab / 主题页 / 详情入口)与后台管理 UI 待做** |
+| Date | 2026-10-06 |
+| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`(§3.10 共创主题) |
+| 已落地提交 | `063c04a1b`(设计定稿:技术方案 §3.10 + 本文件)、`2fa201e0d`(数据模型 / 领域纯函数 / 契约 / 数据契约表 94→96)、`742723a58`(公开读路径:3 个 procedure + client + 2 条公开路由 + 作品详情 `themes` 增量)、`033e3aa79`(后台写路径:5 条 admin 路由 + 事务 + 幂等 + `THEME_*` 错误码) |
+
+## 目标
+
+共创侧出现**由平台 / 运营命名**的「共创主题」:一个主题下挂若干**根作品**,点进去能看到这些根各自的**作品树**;作品在游戏 Tab 里仍作为独立作品展示,详情页能看到自己所属的主题并跳过去。
+
+一句话判据:**主题是运营叙事,树由血缘复用,作品归属是额外维度而不是替代品。**
+
+## 范围
+
+- 两张表:`game_distribution_theme`(主题)与 `game_distribution_theme_member`(成员,**只允许根**);迁移白名单登记与生成绑定。
+- 领域纯函数:主题状态白名单、主题公开可见性、成员公开可见性、确定性成员主键、成员 / 主题的稳定排序与游标切页。
+- 公开读接口(匿名、`no-store`):主题列表(游标分页)、主题详情(成员根清单 + 每根既有公开摘要)。
+- 作品详情增量:`themes: [{ themeId, name, badge }]`(先取该作品的**根**再反查)。
+- 后台写接口:创建主题、改名 / 简介 / 角标 / 排序 / 状态、增删成员(都幂等)、后台列表(含 `draft` / `archived`);鉴权复用既有 `require_admin_auth` + `AuthenticatedAdmin`。
+- 公开前端:共创 Tab(主题列表)、主题页(成员根清单 + 逐根渲染 `lineage` 树)、详情页主题入口。
+- 契约同步:Rust / TS DTO、DTO parity 构建器登记、数据契约表随表落地补。
+
+## 不在范围内
+
+- **后台管理 UI**(本轮只做后台接口;运营页另行安排)。
+- 主题**封面图**(要接平台素材链路与公开授权口径,独立小需求;本轮用 `badge` + `name` 呈现)。
+- **slug / URL 别名**(公开 URL 直接用 `theme_id`,本轮不做 slug 与唯一性约束)。
+- **埋点 / 统计**(曝光、点击、成员转化等 UI 上线后按数据定口径)。
+- 主题内「**跳到某一代节点**」高亮(UI 层能力,族谱页已有 `from` 参数)。
+- **批量树端点**(本轮接受前端 N 次 `/games/{id}/lineage`)。
+- 主题级联下架 / 归档时的成员清理(不做:归档即「不再公开」,成员行按同一口径保留)。
+- 收益分成、流量回馈、相似度反洗稿校验、关注 / 粉丝。
+
+## 依赖与前置条件
+
+- M1(授权与血缘骨架)已落地:`game_distribution_lineage` 表与根解析(`game_distribution_lineage().game_id().find(...).map(root_game_id).unwrap_or(game_id)`)是本里程碑「成员只允许根」与「详情页按根反查」的前置。
+- M3(族谱与衍生列表)已落地:主题页的树**直接复用** `/games/{id}/lineage`,本里程碑不重建任何树查询或树整形。
+- 公开目录同一份 `public_game_payload` 投影已存在于 api-server(主题详情与成员卡片都复用它)。
+- 游标分页惯例已由 `/my-collections`(2026-10-06)落地:默认 20 / 上限 50 / 游标 `"{micros}:{id}"` / 非法游标 400 / 末页 `nextCursor: null`。
+- 设计已由技术方案 §3.10 定稿(含四条留白)。**待产品确认的只剩一条**:①「主题级排序口径」;② 主题详情 `roots` 的 **50 上限**行为**已于 2026-10-06 定口径**(`memberCount` 报真实可见成员数 + 新增 `rootsTruncated` 截断信号,见文末「待确认项」)。
+
+## 实施清单(执行结果)
+
+A–F 已落地(服务端);G 前端与后台 UI 未做。
+
+### A. 数据模型与迁移
+
+**已落地(`2fa201e0d`)。**
+
+1. 在 `server-rs/crates/spacetime-module/src/game_distribution.rs` 追加两张表(列、索引、注释按技术方案 §3.10.1 的表声明逐字落地):
+ - `game_distribution_theme`:`theme_id`(PK,`theme-*`)、`name`、`summary`、`badge`、`sort_order: i64`、`status: String`、`created_by_user_id`、`created_at`、`updated_at`;具名索引 `by_game_distribution_theme_created_at`(btree `created_at`)。
+ - `game_distribution_theme_member`:`member_id`(PK,`"{theme_id}:{root_game_id}"`)、`theme_id`、`root_game_id`、`sort_order: i64`、`created_at`;**两条具名 btree 索引** `by_game_distribution_theme_member_theme_id`、`by_game_distribution_theme_member_root_game_id`。
+2. 在 `server-rs/crates/spacetime-module/src/migration.rs` 的 `migration_tables!` 白名单登记两表,注释写明「主题与成员是运营业务事实,随迁移导出/导入;归档 / 下架不删除成员行」。
+3. 生成绑定:`npm run spacetime:generate`;**只保留**与本次 schema 相关的 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,其余 rustfmt 漂移 `git checkout --` 还原(与 §5.1 同处理)。
+4. 新增 procedure 的 Result/Input `SpacetimeType`(`SpacetimeType` 派生,字段用 `snake_case`,与既有 `GameDistributionCollection*` 系列同形),放在既有结果类型附近。
+
+### B. 领域纯函数(`server-rs/crates/module-game-distribution/src/theme.rs`,新文件,不碰 `ReducerContext`)
+
+**已落地(`2fa201e0d`;`033e3aa79` 补充后台 `status` 过滤白名单与文本上限校验)。**
+
+| 函数 / 常量 | 职责 | 关键约束 |
+| --- | --- | --- |
+| `GAME_DISTRIBUTION_THEME_STATUSES: [&str; 3]` | `["draft", "published", "archived"]` | 三处共用同一个白名单(可见性、后台列表过滤、DTO 校验),不复制字面量 |
+| `game_distribution_theme_status_valid(status: &str) -> bool` | 状态合法性 | 非法值由 api-server 映射 400,不落到 axum 422 |
+| `game_distribution_theme_public_visible(status: &str) -> bool` | 主题公开可见性 | 等价于 `status == "published"`;`draft` / `archived` 一律不可见 |
+| `game_distribution_theme_member_id(theme_id: &str, root_game_id: &str) -> String` | 确定性主键 | `format!("{theme_id}:{root_game_id}")`;两个 ID 都不含 `:`(`theme-*` / `game-*`),组合单射 |
+| `game_distribution_theme_member_visible(is_deleted: bool, is_published: bool, has_public_version: bool) -> bool` | 成员公开可见性 | **委托**既有 `game_distribution_collection_visible`(同一条「未删 + 已公开 + 有公开版本」口径),不新写第二个同义判定 |
+| `game_distribution_theme_root_acceptable(has_lineage_row: bool) -> bool` | 成员只允许根 | `has_lineage_row == true`(非根)→ 拒绝;由调用方用血缘点查给出事实 |
+| `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT = 20` / `_MAX = 50` / `game_distribution_theme_page_limit(limit: u32) -> usize` | 公开列表页大小归一化 | 与 `/my-collections` 同一数值与「超界截断而非报错」口径;归一化在模块侧定义一次,日志与切页共用同一函数 |
+| `encode_game_distribution_theme_cursor(created_at_micros: i64, theme_id: &str) -> String` / `parse_game_distribution_theme_cursor(value: &str) -> Result<(i64, String), String>` | 游标编解码 | 格式 `"{micros}:{themeId}"`,解析只切**第一个**冒号;解析失败返回 `Err`(api-server 映射 400) |
+| `sort_public_themes(...)` + `page_public_themes(items, cursor, limit) -> (page, next_cursor)` | 主题公开列表排序与切页 | 排序键 `created_at` **倒序** + `theme_id` **升序**兜底(全序,翻页不重不漏);**先过滤可见性、再排序切页** |
+| `sort_theme_members(...)` + `page_theme_members(items, limit) -> Vec<...>` | 主题内成员排序 | `sort_order` **升序** + `member_id` **升序**兜底(`sort_order` 允许重复) |
+| `game_distribution_theme_member_visibility(game_row_present, is_deleted, game_visibility) -> String` | 后台成员行的**细粒度**状态(只给后台) | `missing`(游戏行不存在)**优先于** `deleted`(软删除),否则原样透传游戏行的 `visibility`。与 `game_distribution_theme_member_visible` **刻意独立**:前者答「为什么」,后者答「此刻公开侧会不会出现」 |
+| `encode_game_distribution_theme_member_cursor(sort_order, member_id)` / `parse_game_distribution_theme_member_cursor(value)` | 后台成员名单游标编解码 | `"{sort_order}:{member_id}"`;解析**只委托** `parse_game_distribution_theme_cursor`(**共用同一个可达的 `THEME_INVALID_CURSOR`**,不复制第二份解析) |
+| `page_admin_theme_members(items, cursor, limit) -> (page, next_cursor)` | 后台成员名单切页 | 复用**同一份** `sort_theme_members`(全序,翻页不重不漏);`limit` 归一化复用 `game_distribution_theme_page_limit`;调用方**不**做可见性过滤(后台看全部行) |
+| `game_distribution_theme_roots_truncated(visible_member_count: usize, returned_root_count: usize) -> bool` | 详情 `roots` 是否被响应体积上限截断 | 仅 `returned_root_count < visible_member_count` 为 `true`;相等(不足上限,全发)与「回传多于可见」(两组数字不同源,防御性)都是 `false`。与族谱 `truncated` 同约定 |
+
+复用 `collection.rs` 里 `GameDistributionCollectionPageItem` 那种「排序键与负载绑定」的写法,避免「按 A 排序、按 B 切页」的错位。
+
+### C. 事务与 procedure(`server-rs/crates/spacetime-module/src/game_distribution.rs`)
+
+**已落地(读:`742723a58`;写:`033e3aa79`;后台成员名单读:本条)。**
+
+命名沿用既有:**写** = `*_and_return`,**读** = `list_*` / `get_*`。
+
+| procedure | 事务职责 |
+| --- | --- |
+| `create_game_distribution_theme_and_return` | 服务端生成 `theme_id`(`theme-` + 唯一后缀,不接受客户端传入);写 `created_by_user_id`(= admin 会话主体)、`created_at` = `updated_at` = now;返回主题行 + `replayed` |
+| `update_game_distribution_theme_and_return` | 按 `theme_id` 改名 / 简介 / 角标 / 排序 / 状态;`theme_id` 不存在 → `THEME_NOT_FOUND`;生效时刷新 `updated_at`;幂等收据键与既有写接口同族(绑定请求摘要,摘要不一致 → `THEME_IDEMPOTENCY_CONFLICT`) |
+| `upsert_game_distribution_theme_member_and_return` | 按确定性主键写成员(不存在则插入,存在则更新 `sort_order`);**先判主题存在**,再判作品存在,再按血缘点查判「是否根」(非根 → `THEME_MEMBER_NOT_ROOT`);重复调用不产生第二行,`created_at` 首次写入后不再变 |
+| `remove_game_distribution_theme_member_and_return` | 按确定性主键删除;不存在也算成功(无「重放 vs 新意图」差异,不需要幂等键);主题不存在 → `THEME_NOT_FOUND` |
+| `list_game_distribution_admin_themes` | 后台列表:支持 `status` 过滤(`all` / `draft` / `published` / `archived`),`limit` 缺省与上限与后台作品列表同口径(200),按 `created_at` 倒序 + `theme_id` 升序 |
+| `list_admin_game_distribution_theme_members` | 后台成员**名单**:**不套**公开可见性过滤、**不要求主题已发布**(`draft` / `archived` 照常可读),返回全部成员行(每行 `visible` / `visibility`)+ **行总数**(不受分页影响)+ 下一页游标;排序复用 `sort_theme_members`、切页走 `page_admin_theme_members`;主题不存在 → `THEME_NOT_FOUND` |
+| `list_game_distribution_public_themes` | 公开列表:只取 `published`,**先过滤再排序切页**,返回 `(themes, next_cursor)`;每条的 `member_count` = 该主题当前可见成员数(同一可见性判定) |
+| `get_game_distribution_theme_detail` | 公开详情:主题不存在 / 非 `published` → `found = false`(api-server 映射 404);命中时返回主题行 + 可见成员根(按 `sort_order` + `member_id` 排序),成员卡片信息按 `public_game_payload` 所需事实取(游戏行 + 当前公开版本 + 评分摘要) |
+| `list_game_distribution_theme_refs_for_root` | 按 `root_game_id` 走 `by_game_distribution_theme_member_root_game_id`,联主题行,只保留 `published`,按主题公开列表同一比较器排序;供作品详情 `themes` 增量使用 |
+| (内部辅助)`game_distribution_theme_root_of(ctx, game_id) -> String` | 血缘点查取根,无血缘行返回自身;**必须复用** `game_distribution_lineage_entries` 用的同一写法,不复制第二份根解析 |
+
+事务级不变量(必须有定向断言):① 同一主题内同一根只可能有一行(主键结构);② 非根作品不能成为成员;③ 同一根可以同时存在于多个主题(跨主题各有独立行);④ 成员行**不因作品下架 / 软删除而删除**;⑤ 归档主题的行仍在表里(只是不在公开投影里)。
+
+### D. api-server 路由(`server-rs/crates/api-server/src/modules/game_distribution.rs`)
+
+**已落地(公开族:`742723a58`;后台族:`033e3aa79`;后台成员名单读:本条)。**
+
+公开族(挂 `public_games` 那一支,带 `add_no_store_response_headers`):
+
+| 方法 / 路径 | handler | 响应 | 错误 |
+| --- | --- | --- | --- |
+| `GET /api/game-distribution/themes?limit=&cursor=` | `list_public_themes` | `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`(`memberCount` = 真实可见成员数,不截断) | 非法游标 → 400(平台信封) |
+| `GET /api/game-distribution/themes/{theme_id}` | `get_public_theme` | `{ themeId, name, summary, badge, memberCount, roots: [...], rootsTruncated }`(`memberCount` 不截断;`rootsTruncated` = 这份 `roots` 被 50 上限截断) | 不存在 / 未发布 → 404(不返回空壳) |
+| `GET /api/game-distribution/games/{game_id}`(**既有,响应增量**) | `get_game` | 追加 `themes: [{ themeId, name, badge }]` | 沿用既有 404 |
+
+后台族(挂 `admin` 那一支的 `route_layer(middleware::from_fn_with_state(state, require_admin_auth))`):
+
+| 方法 / 路径 | 语义 | 幂等 |
+| --- | --- | --- |
+| `POST /admin/api/game-distribution/themes` | 创建(`name` 必填非空;`status` 缺省 `draft`) | 要求 `Idempotency-Key` |
+| `PUT /admin/api/game-distribution/themes/{theme_id}` | 改名 / 简介 / 角标 / 排序 / 状态 | 要求 `Idempotency-Key` |
+| `GET /admin/api/game-distribution/themes?limit=&status=` | 后台列表(含 `draft` / `archived`) | 只读 |
+| `PUT /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 增 / 改成员(body 可带 `sortOrder`) | 确定性主键保证幂等 |
+| `DELETE /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 移除成员(不存在也算成功) | 不需要幂等键 |
+| `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=` | 读取成员**名单**:**全部成员行**(含当前不可见的)+ 每行 `visible` / `visibility`;**含 `draft` / `archived` 主题**(原缺口:借公开投影时草稿 / 归档主题列不出成员) | 只读(`limit` 缺省 20 / 上限 50;游标 `"{sortOrder}:{memberId}"`,末页 `null`) |
+
+### E. 错误码映射(api-server 集中映射,沿用 `FORK_*` 那套「前缀字符串 → 状态码」写法)
+
+**已落地(6 个 `THEME_*` 码:`742723a58`;领域错误类型:`033e3aa79`)。** 未登记的码兜底 **400 `THEME_ERROR`**(不落 axum 默认 422 纯文本)。
+
+| 领域码 | HTTP | 触发 |
+| --- | --- | --- |
+| `THEME_NOT_FOUND` | 404 | 后台按 `theme_id` 找不到主题(公开侧「不存在 / 未发布」一律 404 且不带该码) |
+| `THEME_BAD_REQUEST` | 400 | `name` 空 / 非法 `status` / 非法 `status` 过滤值 |
+| `THEME_INVALID_CURSOR` | 400 | 游标格式非法(也可直接复用既有 `bad_request` 文案,二选一并在脚本里钉住实际值) |
+| `THEME_IDEMPOTENCY_CONFLICT` | 409 | 同 `Idempotency-Key` 不同请求摘要 |
+| `THEME_MEMBER_NOT_ROOT` | 409 | 目标作品有血缘行(非根) |
+| `THEME_MEMBER_GAME_NOT_FOUND` | 404 | 目标作品不存在 |
+| (框架)未带 admin 会话 | 401 | 由 `require_admin_auth` 给出 |
+
+### F. 契约与 DTO 同步
+
+**已落地(类型与数据契约表:`2fa201e0d`;公开构建器登记:`742723a58`;后台 payload 登记:`4a3339782`;成员名单读接口的两个构建器:本条)。** DTO parity 现为 **58 组类型 / 17 个手拼响应构建器 / 15 个手拼响应类型**。
+
+- Rust DTO:`server-rs/crates/shared-contracts/src/game_distribution.rs`(主题列表 / 详情 / 后台写请求的私有类型;**不下发**任何对象键或内部计数)。
+- TS DTO:`packages/shared/src/contracts/gameDistribution.ts`——后台成员名单新增 `GameDistributionAdminThemeMemberRow` / `GameDistributionAdminThemeMemberListResponse` / `GameDistributionAdminThemeMemberVisibility`(服务端同样是逐字段手拼 JSON,因此走 `TS_ONLY_TYPES` 登记)。
+- `scripts/check-game-distribution-dto-parity.mjs`:登记新响应构建器(`public_themes_payload` / `public_theme_detail_payload` / 详情 `themes` 增量构建器 / 后台主题一族,以及本条的两个:`admin_theme_members_payload`(列表)与 `admin_theme_member_row_payload`(单条行)),证明这些路径确实会发出新键——列表与单条各登记一条,让「条目到底发哪几个键」有独立证据。
+- `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`:**已随表落地补齐**(`2fa201e0d`,两张表小节;`check:spacetime-schema` 现为 **96** tables)。
+
+### G. 前端(公开侧;后台 UI 不在本轮)
+
+**未做(待前端里程碑)。** 下表是待执行的改动清单:
+
+| 文件 | 改动 |
+| --- | --- |
+| `src/routing/activeAppPageRoutes.ts` | 新增 `['game-themes', '/games/themes']`、`['game-theme', '/games/theme']`(详情用 `?id=`,与 `/games/lineage?id=` 同款);同步 `SelectionStage` 类型 |
+| `src/components/platform-entry/PlatformEntryActiveFlowShell.tsx` | 新 stage 的渲染分支(与 `game-lineage` 同形) |
+| `src/components/game-distribution/GameGalleryPage.tsx` | 新增「共创」Tab(切到 `/games/themes`),分类 Tab 结构复用既有 `game-category-tabs` |
+| `src/components/game-distribution/GameThemesPage.tsx`(新) | 主题列表:卡片显示 `name` / `summary` / `badge` / `memberCount`;游标「加载更多」;空态与失败态复用既有平台错误组件 |
+| `src/components/game-distribution/GameThemePage.tsx`(新) | 主题详情:成员根清单 + **逐根**调 `/games/{id}/lineage` 渲染多棵树(N+1 是已接受的取舍);已发布但无可见成员 → 明确空态(**不是**错误);成员作品不可用时按族谱页既有降级展示 |
+| `src/components/game-distribution/GameDetailPage.tsx` | 用 `GameDetailDisplay` 的槽位渲染 `themes` 入口链(`/games/theme?id=`);无主题时不渲染空容器 |
+| `src/services/gameDistributionClient.ts` | 新增 `listThemes` / `getTheme`;`getGame` 类型增量 `themes` |
+
+新增 SPA 路由后必须同步 `npm run check:nginx-spa-routes` 与 `npm run check:pingora-route-parity` 的静态路由清单(`/games/themes` 是静态前缀;`/games/theme` 若走查询串则不需要动态段放行)。
+
+### H. 测试清单
+
+**已落地**:纯函数(`module-game-distribution`,含 `033e3aa79` 补的 status 过滤 / 文本校验)、事务结构断言(`spacetime-module`)、api-server 路由与错误码定向测试、契约 parity。**未做**:前端 vitest、真实 dev 栈 e2e。**2026-10-07 更新**:真实栈 e2e 已写好并跑通(71/71,运行在隔离实例上),但它是**本地验收脚本、未入库**(只在本地工作树,路径见本地 `info/exclude`)。
+
+| 层 | 用例 |
+| --- | --- |
+| 纯函数(`module-game-distribution`) | 状态白名单(合法三态 / 未知值);主题公开可见性(`draft` / `archived` false);成员可见性四组合 + 「未删已公开但无当前公开版本」必须 false;`member_id` 确定性(同输入同输出、不同主题不同行);成员只允许根(`has_lineage_row` true → 拒绝);游标编解码往返 + 非法游标(缺冒号 / 非数字 / 空 ID)+ 解析只切第一个冒号;页大小归一化(缺省 20 / 0 取默认 / 超界截断到 50);`rootsTruncated` 三态(相等 / 截断 / 防御性「回传多于可见」);**过滤后切页不重不漏**(可见性过滤位置在排序之前);排序兜底(`sort_order` 相同按 `member_id` 升序;`created_at` 相同按 `theme_id` 升序);**后台成员短名单**:细粒度状态三态(`missing` 优先于 `deleted`、否则原样透传)+ 与 `visible` 的组合(`visibility=published` 且无公开版本 ⇒ `visible=false`)、成员游标委托主题游标解析(共用可达稳定码、只切第一个冒号)、后台成员全序翻页不重不漏 / 末页游标 `None` / `limit=0` 取默认 / 超界截断、以及「切页复用 `sort_theme_members` + 同一归一化函数」的源码断言 |
+| 事务(`spacetime-module`) | 同主题同根重复 upsert 只有一行且 `created_at` 不变;跨主题同根两行并存;非根入成员被拒;成员作品下架 / 软删除后**行还在**、公开投影不含它、重新公开后自动回来;归档主题不出现在公开列表;`theme_id` 服务端生成且形如 `theme-*`;`created_by_user_id` 取自 admin 会话主体;`updated_at` 每次更新刷新;**后台成员名单**:不套公开可见性过滤 / 不要求主题已发布 / 复用共享排序切页纯函数 / 不 `.delete(`,且 `totalMembers` 在切页**之前**取(结构断言);行快照复用 `game_distribution_theme_member_visible` 并把 `visibility` 交给模块侧纯函数,作品行缺失不跳过 |
+| api-server 路由 | 公开列表 200 / 匿名可读 / 非法游标 400 / 末页 `nextCursor === null`;公开详情 404(不存在、`draft`、`archived`)与 200 空 `roots`;作品详情 `themes` 增量(只含公开主题;**第 N 代作品按其根查到所属主题**);两条公开路径都带 `Cache-Control: no-store`;后台**六条**路由未带 admin 会话一律 401、非 admin 令牌 403;后台写:空 `name` / 非法 `status` 400、未知 `theme_id` 404、作品不存在 404、非根 409、同键重放 `replayed: true`、同键不同请求 409、`DELETE` 重复调用 200;后台成员名单:非法游标 400 `THEME_INVALID_CURSOR`、主题不存在 404 `THEME_NOT_FOUND`、响应键集合(列表 4 键 / 单条行 6 键)与 `title` / `nextCursor` 的 `null` 语义 |
+| 契约 | DTO parity 含新构建器(`public_theme_detail_payload` 的 `mustEmit` 含 `rootsTruncated`;后台 `admin_theme_members_payload` / `admin_theme_member_row_payload` 分别钉住列表与单条行);TS 类型与 Rust DTO 逐键一致 |
+| 前端 | 主题列表(渲染 / 空态 / 失败态 / 加载更多);主题页多棵树(mock 两个根各自 `lineage`)与「已发布但空成员」空态;详情页 `themes` 链到主题页、无主题时不渲染;窄屏布局不撑破 |
+
+### I. 门禁命令与证据
+
+```bash
+# 文档与工作树(本里程碑设计产出)
+npm run check:doc-index && npm run check:encoding && git diff --check
+
+# Rust 侧
+cargo test -p module-game-distribution
+cargo test -p api-server game_distribution
+cargo check --all-targets --manifest-path server-rs/Cargo.toml
+npm run check:server-rs-ddd
+
+# schema 与生成物
+npm run spacetime:generate
+npm run check:generated-bindings
+npm run check:spacetime-schema
+
+# 契约
+npm run check:game-distribution-dto-parity
+
+# 前端
+npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts
+npm run typecheck && npm run lint:eslint
+npm run check:nginx-spa-routes && npm run check:pingora-route-parity
+
+# 真实栈端到端(本地夹具脚本,未入库;形状照同族的收藏夹具脚本:
+# 起本地完整隔离实例 + 管理员账号,走真实 HTTP,逐条 check(...) 断言,失败非零退出)
+E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node <本地夹具脚本路径>
+```
+
+真实栈端到端**没有新增 npm script**:夹具脚本本身未入库(只在本地工作树,路径见本地 `info/exclude`),所以 `npm run` 列表里不会出现指向未入库文件的条目。
+
+**执行状态**:已跑门禁 —— `check:spacetime-schema`(**96** tables)、`check:game-distribution-dto-parity`(**58 组 / 10 构建器**)、`check:encoding`、`check:doc-index`、`git diff --check`、`cargo test`(见下「验收标准」的勾选与「证据要求」的现状)。**未跑**:前端 vitest / typecheck / lint、`check:nginx-spa-routes`、`check:pingora-route-parity`(前端未动,且未新增 SPA 路由)、以及真实栈 e2e(当时本地夹具脚本尚未写上;后续已补并在隔离实例上跑通,脚本未入库)。
+
+## 验收标准
+
+- [x] 两张表与两条成员索引落地;schema 检查通过(96 tables);生成绑定随 `2fa201e0d` 提交。
+- [x] `theme_id` 由服务端生成、形如 `theme-*`;`member_id` 为 `"{theme_id}:{root_game_id}"`,重复添加同一 (主题, 根) 不产生第二行。
+- [x] 成员只允许根:目标作品有血缘行(非根)时写入被拒绝且不写库(纯函数 `game_distribution_theme_root_acceptable` 单测 + 事务结构断言);无血缘行的作品可入成员。
+- [x] 同一根可同时属于多个主题,各主题各有独立成员行;作品详情的 `themes` 返回**多值**(DTO 为数组 + parity 钉住)。
+- [x] 公开侧只出现 `status == published` 的主题;`draft` / `archived` 主题的详情与列表均不可见(纯函数单测 + 事务分别断言列表与详情两条路径)。
+- [x] 成员只出现「公开未删且存在当前公开版本」的根;成员作品下架 / 软删除后从投影中跳过但**行不删除**,重新公开后自动回到主题页(可见性委托收藏口径,逐格单测 + `member_row_survives_unpublish_and_returns_after_republish` + 事务「不删行」结构断言)。
+- [x] 主题不存在或未发布时详情返回 404,不返回空壳;已发布但可见成员为空时返回 200 + 空成员列表。
+- [x] 公开主题列表分页沿用既有游标惯例:默认 20、上限 50、超界截断;非法游标 400;`nextCursor` 为真实值且末页为 `null`;排序为 `created_at` 倒序 + `theme_id` 升序兜底,翻页不重不漏。
+- [x] 主题详情的 `roots` 按 `sort_order` 升序 + `member_id` 升序稳定排序,逐条为既有公开作品投影(不泄露对象键、不泄露未公开作品信息)。
+- [x] 作品详情新增 `themes`:**第 N 代作品**(非根)也能看到并跳到其所属公开主题(按根反查),且只含公开主题。
+- [x] 主题列表与详情的 `memberCount` 是**真实可见成员数**(不截断,不含草稿 / 已下架成员);详情另有 `rootsTruncated` 表示这份 `roots` 被 50 上限截断(`roots` 少于 `memberCount` 时为 `true`,与族谱 `truncated` 同约定)。**注意:`memberCount == roots.length` 不再是不变式**(可见成员 > 50 时故意不等,见「待确认项」的已定口径)。
+- [x] 两条公开主题路径匿名可读且带 `Cache-Control: no-store`。
+- [x] 后台五条路由未带 admin 会话一律 401;鉴权复用既有 `require_admin_auth` + `AuthenticatedAdmin`,不自造。
+- [x] 后台写接口错误码可区分:空名 / 非法状态 400、未知主题 404、作品不存在 404、非根 409、同键不同请求 409;同键重放如实回报幂等重放(错误码映射单测 + 领域错误类型单测;**经真实库存的 404 / 409 等 dev 栈 e2e**)。
+- [x] 后台 `DELETE` 成员重复调用结果相同且成功(幂等),成员不存在不是错误(事务结构断言:响应不含「之前存不存在」)。
+- [x] 后台列表含 `draft` / `archived` 主题;非法 `status` 过滤值返回 400。
+- [x] 后台能读到成员**名单**(`GET …/themes/{theme_id}/members`):**草稿 / 归档主题也能读**(事务不套 `game_distribution_theme_public_visible`,结构断言),且**含当前不可见的成员**(未公开 / 软删除 / 无公开版本三类都原样返回,不套公开可见性过滤)。主题不存在才是 404 `THEME_NOT_FOUND`。
+- [x] 名单每行 `visible` 与 `visibility` 的组合正确且**不许互相推导**:`visibility` 为 `deleted` / `missing` / 原样透传游戏行可见性,`visible` 复用 `game_distribution_theme_member_visible`;`visibility == "published"` 且**无**当前公开版本时 `visible == false`(刻意的组合,纯函数与事务结构两处钉住)。
+- [x] `totalMembers` 是**成员行总数**(含不可见、**不受分页影响**,事务在切页之前取);排序稳定(`sort_order` 升序 + 成员主键升序兜底,**复用公开侧同一比较器**);翻页不重不漏、**末页 `nextCursor: null`**;`limit` 缺省 20 / 上限 50 / `0` 取默认 / 超界截断;**非法游标 → 400 `THEME_INVALID_CURSOR`**(可达的稳定码,用模块真实产出的文案钉住)。
+- [x] 后台成员名单未带 admin 会话 → 401(同码同形),**非 admin 令牌 → 403**(复用既有 `require_admin_auth`,不自造);响应键集合由 DTO parity 两个构建器分别钉住(列表 4 键 / 单条行 6 键),`title` 与 `nextCursor` 的 `null` **必须发出键**。
+- [ ] 公开前端:共创 Tab 能列出主题并进入主题页;主题页按成员根渲染**多棵树**(复用 `/games/{id}/lineage`);已发布空主题显示空态;详情页 `themes` 能跳到主题页。**等前端**
+- [ ] 新增路由在桌面与窄屏可用,且 `check:nginx-spa-routes` / `check:pingora-route-parity` 通过。**等前端**
+- [x] 契约同步:DTO parity 通过(58 组 / 17 构建器 / 15 手拼类型);数据契约表文档随表落地补齐(`2fa201e0d`)。
+
+**已落地(本条补)**:后台成员**名单读**接口——原缺口「后台要成员名单只能借道**公开投影**(`GET /api/game-distribution/themes/{theme_id}`),而公开投影只服务 `published` 主题,于是**草稿 / 归档主题在后台看不到成员**」已随 `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=` 补掉:不套公开可见性过滤、含当前不可见成员、每行带 `visible` / `visibility` 两个独立口径,`draft` / `archived` 照常可读。技术方案侧同步写在 §3.4 后台表与 §3.10.8。
+
+**勾选口径**:勾选项由已落地的纯函数单测 / 事务结构断言 / api-server 定向测试 / DTO parity / schema 门禁证实;**未跑真实 dev 栈的端到端整链**(本地夹具脚本(共创主题) 未创建),所以凡依赖真实库存的运行时分支(未知主题 404、作品不存在 404、同键换请求 409、下架→重公开自动回归等)目前只到单元 / 结构 / 映射层。第 17 / 18 条(前端)**等前端**。
+
+## 证据要求
+
+**现状**:自动化已覆盖纯函数 / 事务结构 / api-server 定向 / 契约 / schema;**运行时**(真实本地栈整链、浏览器桌面与窄屏)**未跑**,本地夹具脚本(共创主题) **未创建**——「证据要求」的运行时与脚本两项仍缺。
+
+- 自动化:`module-game-distribution` 纯函数单测(可见性 / 排序 / 游标 / 根约束)、`spacetime-module` 事务断言、api-server 路由与错误码定向测试、DTO parity、schema 与生成绑定检查、前端 vitest、`check:encoding` / `check:doc-index` / `git diff --check`。
+- 运行时:真实本地栈上完成「建主题(draft)→ 加两个根成员 → 发布 → 匿名读列表与详情 → 作品详情看到主题 → 把一个成员下架(从投影消失、行仍在)→ 重新公开(自动回来)→ 归档主题(公开侧不可见、行仍在)→ 后台列表仍可见」整链;浏览器在桌面与窄屏走一遍共创 Tab 与主题页。
+- 边界:已发布但零可见成员、非根作品入成员、跨主题同根、非法游标、末页游标、未知主题 404、未带 admin 会话 401、重复成员写入、`DELETE` 不存在成员。
+- 脚本:本地夹具脚本(共创主题) 的 `check(...)` 计数与失败项;脚本头部按既有惯例列出「契约来源」与「与工单描述不一致、按实现断言」的条目。
+
+## 待确认项(需用户 / 产品拍板)
+
+- **主题级排序口径**:本方案按「沿用既有游标惯例」把公开列表排在 `created_at`(倒序)+ `theme_id`(升序兜底)上,`sort_order` 只用于**主题内成员排序**与后台列表。若产品要求共创 Tab 按运营 `sort_order` 展示,需要把游标改成 `(sort_order, theme_id)` 双键并同步前端——**这会改一次接口契约**(已按现状实现,改需另开一轮)。
+- **主题详情 `roots` 的 50 上限行为(已定口径,2026-10-06)**:原「三个备选」里采纳 ③:`roots` **保留** 50 上限(`get_game_distribution_theme_detail_tx` 里走 `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`),**但 `memberCount` 改为报真实可见成员数**(不再取截断后的 `roots.len()`,列表与详情都走 `game_distribution_theme_member_count`),并新增布尔信号 `rootsTruncated`:仅当 `roots` 少于 `memberCount` 时为 `true`(纯函数 `game_distribution_theme_roots_truncated`),与族谱响应的 `truncated` 同约定,客户端可据此提示「仅显示前 50 个」。未采纳 ①(去上限:一次性放大响应体积)与 ②(成员独立游标:契约从「一次拿全」改成翻页、须配套成员游标)。同步写在技术方案 §3.4 / §3.10.6 / §3.10.9。
+
+## 已知留白
+
+主题封面图、slug / URL 别名、埋点与统计、主题内「跳到某一代节点」高亮、批量树端点、主题级联归档时的成员清理、后台管理 UI。理由与将来接法见技术方案 §3.10.9 与本文件「不在范围内」。
+
+(原缺口「后台要成员名单只能借公开投影 ⇒ 草稿 / 归档主题看不到成员」**已落地**为后台成员名单读接口,见上文 §C / §D / §F 与「验收标准」;后台**管理 UI** 仍是留白。)
diff --git a/docs/project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md b/docs/project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md
new file mode 100644
index 000000000..ef58bccfb
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md
@@ -0,0 +1,48 @@
+# 【里程碑】创作族谱与衍生列表
+
+| 字段 | 值 |
+| ----------- | ----------------------------------------------------- |
+| Version | 1.0 |
+| Status | proposed |
+| Date | 2026-10-03 |
+| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` |
+
+## 目标
+
+任意作品的读者都能看到以母版为顶的创作族谱树,并从任一节点进入对应作品;作者能在自己的作品页看到「被改编」的直接衍生作品列表。
+
+## 范围
+
+- 族谱读取:以某个作品的根为顶,返回各代节点(标题、作者、代际、父作品、公开状态、游玩数),按代际与创建时间稳定排序,超出上限截断并如实标注。
+- 网页新增族谱页面与路由,含根节点、分支、当前作品高亮、节点跳转、空态与失败态。
+- 作品详情页的族谱入口。
+- 我的作品页的「被改编」入口与直接衍生作品列表弹层。
+- 来源作品已下架或封禁时节点的降级展示。
+
+## 不在范围内
+
+- 共创主题(平台命名的归组实体)与广场共创分区。
+- 族谱的排序算法、热度权重、推荐位。
+- 树的可视化交互增强(缩放、拖拽、导出图片)。
+- 收益或流量回馈的任何计算。
+
+## 依赖与前置条件
+
+- 「游戏共创授权与血缘」里程碑已通过验收。
+- 「成品包改造闭环」里程碑产生的真实数据用于端到端验证(仅展示层不依赖它,但真实链路验证需要)。
+
+## 验收标准
+
+- [ ] 三层链路作品打开族谱页,根节点为母版,各节点代际与父作品关系正确,点击任意节点进入对应详情页。
+- [ ] 未登录用户可以浏览族谱页,不触发登录门禁;已下架但仍有血缘的节点保留在树上并标注原作品不可用,不出现空白或断链。
+- [ ] 超出节点上限时如实标注已截断,不静默丢弃;空族谱(无任何衍生)给出明确空态而不是报错。
+- [ ] 我的作品页「被改编」列表只包含直接衍生作品,数量与族谱树中该作品的子节点一致。
+- [ ] 族谱与衍生列表的公开数据不泄露未公开作品、被隐藏内容或对象键;**锚点(URL 里点名的作品)必须公开可读**——未公开、已软删除或不存在的作品,两个接口一律返回 404,不得用「空标题占位节点」或空树代替 404(那等于确认该作品存在、其代际与血缘位置)。
+- [ ] 灰度未命中时族谱入口不渲染,直接访问路由给出与现役未知路径一致的降级行为。
+- [ ] 桌面与移动端布局可用,长标题与较大数字不撑破容器。
+
+## 证据要求
+
+- 自动化:族谱与衍生列表的排序、截断、可见性过滤定向测试;路由与前端组件定向测试;DTO 一致性与编码、文档索引检查。
+- 运行时:真实数据上打开族谱页与「被改编」列表,包含一条来源已下架的链路;桌面与窄屏浏览器验证跳转与布局。
+- 边界:无衍生作品的空族谱、超上限截断、来源下架、未公开子作品不出现在树中、未公开/已软删除锚点返回 404、未登录访问。
diff --git a/docs/project-memory/plans/【里程碑】成品包改造闭环-2026-10-03.md b/docs/project-memory/plans/【里程碑】成品包改造闭环-2026-10-03.md
new file mode 100644
index 000000000..a3fc7aceb
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】成品包改造闭环-2026-10-03.md
@@ -0,0 +1,77 @@
+# 【里程碑】成品包改造闭环
+
+| 字段 | 值 |
+| ----------- | ----------------------------------------------------- |
+| Version | 1.1 |
+| Status | proposed(**未拍板前不得实现**,见下方前置决策) |
+| Date | 2026-10-03(2026-10-04 按只读侦察的事实重写) |
+| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`(口径见 §2.5、§3.5.1、§3.5.4) |
+
+> **改名说明**:本里程碑原名「成品包改造闭环」,其「改造」隐含「改完能再发布」。2026-10-04 的只读侦察证明成品包路径**只能试玩、不能重新发布**(事实见下),因此本里程碑的对外名称为**「参考式改编(只试玩 + 素材 + 血缘,不含重新发布)」**;文件名保持不变以免断链。
+
+## 前置决策(未拍板前不得实现)
+
+1. **对外表述**:是否接受「只能试玩 + 素材复用,不能一键复刻工程」?建议接受并同步改文案(AGC 侧注释 `apps/ai-game-creator-shell/src-tauri/src/project/export.rs:407-408` 已把无 `package.json` 的静态项目排除在发布合同外)。
+2. **血缘承载位**:血缘只作为客户端本地注释(项目内独立文件),还是要平台可查询(服务端新增字段)或随工程包传输(manifest 升 v2)?建议先本地,等工程源包里程碑落地再上平台字段。
+3. **是否立刻做工程源包(M2b)**:它是「一键复刻完整工程」的唯一正解路径,但需要新增「工程包下载/授权接口」(当前不存在)。建议先做本里程碑验证需求。
+4. **唤起方式**:AGC 内手工输入 gameId / 平台取件码 / deep link(当前 `tauri.conf.json` 未注册 scheme、无 `tauri-plugin-deep-link` 与单实例插件,需动安装器与升级链路)?建议手工输入起步。
+5. **是否允许「伪工程」兼容存量**:补一个声明 phaser 4 + vite 的 `package.json` 让成品包过检——产出不可再构建,建议不允许。
+
+## 目标
+
+用户能在作品详情页把别人的已公开作品拿到本地,**对照试玩、复用其中素材**,并在**自己新建的合规工程**里完成改编与发布;发布后溯源、代际与署自动正确。整条链路**复用平台已存在的成品包**,作者侧不需要任何新增上传动作。
+
+> 目标里没有「把成品包改完直接发布」这一句——它不成立(见「事实依据」第 2、3 条)。
+
+## 事实依据(本里程碑的能力边界由这四条钉死)
+
+1. **试玩成立**:客户端 `validate_project_game_entry`(`apps/ai-game-creator-shell/src-tauri/src/project/verification.rs:49-66`)只要求入口是 HTML 文档、引用可解析(`:68-100`),与 `package.json`、构建脚本无关。
+2. **发布不成立**:导出/发布的唯一入口 `export_local_project_package`(`src-tauri/src/commands/desktop.rs:1148-1156`)→ `export_local_project_package_for_publish_at`(`export.rs:259-306`),其中「已有可玩入口」的静态入口分支(`export.rs:262-265`)**仍然无条件调用 `ensure_publish_project_stack(root)`**(实检 `export.rs:413-459`,调用点 `export.rs:463-468`):`package.json` 必须声明 phaser 主版本 4(`:440-453`)且声明 vite 依赖或存在 `vite.config.*`(`:398-410`、`:454-458`)。注释与反例测试见 `export.rs:407-408`、`:1239-1243`。该分支**不要求 build 脚本**(`resolve_publish_build_plan` 只在「无入口」分支调用,`export.rs:266-273`)。
+3. **成品包里没有工程**:`collect_project_export_package_files`(`export.rs:770-804`)只收 `dist` 树 + 根 `assets/` + `exports/README.md`,**不含 `package.json` 与源码**(发布前归一化还会剥掉 `game/` 前缀并要求包根 `index.html`,`export.rs:527-534`、`:582-584`)。
+4. **平台没有包下载通道**:作者侧路由只有 create/upload/chunk/complete/reset/submit/get_owner_version/cancel,作者侧 payload 不回对象键或下载地址(`server-rs/crates/api-server/src/modules/game_distribution.rs:3029-3041`);对外只有公开发行网关**逐文件**读取,且只服务**当前已公开版本**(`:395-421`、`:800-863`),扩展名受限(`server-rs/crates/module-game-distribution/src/release.rs:36-64`)。
+
+⇒ 三条合起来:**把成品包铺成项目只能试玩,发布必然被拦,也无法从包里补出合规工程声明**。
+
+## 范围
+
+- 受鉴权的内容下发入口(只服务成品包):只对满足授权与公开性条件的请求开放,不下发可直接匿名访问的对象地址。
+- 客户端:下载 → 摘要校验 → 解压 → 以「可玩参考」形态在本机建成副本(可试玩、可提取素材)→ 项目内记录来源并在界面展示。
+- 客户端:为用户提供**新建合规工程**的路径,并在其中带上血缘(来源声明随发布写入),使「在自建工程里改编后发布」能留下正确溯源。
+- 从平台作品唤起客户端的入口。
+- 发布链路自动携带来源声明,不要求用户手填。
+- 客户端发布面板的共创授权选择与相应提示。
+- 能力边界的如实告知:界面必须写明「成品包只能试玩与提取素材,**不能直接重新发布**」,不能让用户以为拿到了可发布工程。
+
+## 不在范围内
+
+- 把成品包补成可发布的工程(补声明 `package.json` 的「伪工程」路线已否决;见前置决策第 5 条)。
+- 工程源包的上传、下载与源码形态建项(见「作品工程源包与一键改造」里程碑)。
+- 网页端手填来源声明。
+- 创作族谱页面(见「创作族谱与衍生列表」里程碑)。
+- 相似度比对、收益分成、游玩次数上报。
+
+## 依赖与前置条件
+
+- 「游戏共创授权与血缘」里程碑已通过验收(授权与血缘的数据合同、状态机、灰度入口)。
+- **新增**「按版本读取/下发成品包」的受鉴权接口——当前不存在(事实依据第 4 条),需连同大小与内存策略一并设计(服务端读包整包进内存 + 进程内缓存上限 4 条 / 256 MiB,`api-server/src/modules/game_distribution.rs:926-958`、`:101-104`;客户端 `fetch_limited_bytes` 整包进内存、上限 512 MiB、无 Range 无续传,`src-tauri/src/template_library.rs:564-591`;分片能力只有上传侧,`src-tauri/src/game_package_upload.rs:96-105`、`game_package_upload/runtime.rs:353-363`)。
+- 客户端现有的「从包安装并建项」基座、解压路径门禁可直接复用,不新建第二套解压路径;但**建项形态必须区分**:先铺成品包文件与先建空工程在现有 `create_npm_scaffold` 判据下互斥(`src-tauri/src/project/manifest.rs:609-613`),两条都要就得由 fork 流程自己写 `package.json` / `vite.config.*`(既有做法可参照 `src-tauri/src/template_library.rs:816-905` 的「先复制模板文件、再调标准初始化」)。
+- 决策:前置决策 5 项拍板通过。
+
+## 验收标准
+
+- [ ] 授权为「禁止共创」的作品不出现改编入口;绕过界面直接请求内容入口同样被拒绝。
+- [ ] 未登录、无授权、来源作品未公开或已下架时,内容入口全部拒绝,且不泄露摘要与地址。
+- [ ] 客户端在真实环境中完成「详情页 → 唤起客户端 → 下载 → 建项」:摘要校验失败时不留半成品目录,成功时新副本**可直接试玩**。
+- [ ] 界面明确告知该副本**不能直接重新发布**(不得出现「改完即可发布」类文案),并给出「新建合规工程」的可用路径。
+- [ ] 在自建合规工程里改编后发布的作品,详情页显示正确的来源、代际与衍生关系;代际与根由服务端计算,客户端无法伪造或覆盖。
+- [ ] 未携带来源声明的发布路径(旧客户端、网页端)不产生血缘,也不因此报错。
+- [ ] 账号切换或退出后,迟到的下载与建项响应不得写入任何本地项目或项目来源信息。
+- [ ] 移动端不出现需要桌面端才能完成的改编动作,或明确给出桌面端提示。
+- [ ] 灰度未命中时改编入口不渲染,写接口返回服务不可用。
+
+## 证据要求
+
+- 自动化:内容入口鉴权与可用性拒绝用例、客户端下载/摘要校验/解压门禁/建项的定向测试、发布链路携带来源的用例、DTO 一致性与编码、文档索引检查。
+- 运行时:真实 api-server + 真实对象存储 + 真实客户端跑通「看别人的已公开作品 → 本地试玩并提取素材 → 在自建工程里改编 → 试玩 → 发布 → 溯源与代际正确」,附对比截图或录屏。
+- 文案:核对产品与技术文档、AGC 界面、网页入口三处都不出现「成品包可直接发布 / 一键复刻完整工程」的表述。
+- 边界:越权下载、无授权下载、来源已下架、摘要不符、解压失败、账号切换后的迟到响应、旧客户端发布不产生血缘。
diff --git a/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md
new file mode 100644
index 000000000..5ad60aa8c
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md
@@ -0,0 +1,63 @@
+# 【里程碑】游戏共创授权与血缘
+
+| 字段 | 值 |
+| ----------- | ----------------------------------------------------- |
+| Version | 1.0 |
+| Status | implemented-local(本地实现完成;已 rebase 到含 #565 的新 master 并收敛软删除语义;运行时端到端与浏览器验证待补) |
+| Date | 2026-10-03 |
+| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` |
+
+## 目标
+
+作者可以对自己的作品设置「共创授权」,之后只能单向提升;所有浏览者能在作品详情页看到授权状态、代际与溯源信息;公开页展示真实作者署名。
+
+## 范围
+
+- 作品级共创授权三态(禁止共创 / 允许非商用共创 / 允许全开放共创)的持久化、默认值与单向提升规则。
+- 作品级血缘关系(父作品、来源版本快照、根作品、代际)的持久化与查询,创建作品时对来源的完整校验。
+- 详情页:授权徽章、溯源卡(改编自《X》· 由 Y 制作)、代际与衍生数量。
+- 我的作品页:授权三态设置(仅允许提升,终态只读)。
+- 后台游戏管理:授权与代际的只读展示。
+- 公开页作者署名取真实账号资料,不再落到兜底文案。
+- 端到端灰度开关与前端入口联动。
+
+## 不在范围内
+
+- 改造内容的下发与客户端建项(成品包路径见「成品包改造闭环」里程碑,源码路径见「作品工程源包与一键改造」里程碑)。
+- 创作族谱页面与衍生作品列表(见「创作族谱与衍生列表」里程碑)。
+- 共创主题(平台命名的归组实体)。
+- 收益分成、流量回馈、相似度反洗稿校验。
+- 游玩次数上报。
+
+## 依赖与前置条件
+
+- 主规范 §3 的数据模型与状态机已评审通过。
+- 决策(2026-10-06 已拍板,取代原待拍板项):授权默认值——**母版**创建缺省仍为「禁止共创」;**衍生作品**在创建时**继承父作品当时的档位**(不接受客户端另选,也不做收窄)。其余合同不变,见技术方案 §2.3 / §3.9 补充。
+- 决策:是否引入共创主题实体需产品拍板;本里程碑不依赖该实体。
+
+## 验收标准
+
+- [x] 新建作品的初始授权为拍板结果:**母版**取请求值(缺省「禁止共创」)、**衍生作品**继承父作品当时的档位(创建时快照);迁移前已存在的作品在升级后仍按其行上的原值解释,无需人工回填。
+- [x] 同一次授权变更里,合法提升(含跳级)成功、任何降级被拒绝且不写库。
+- [x] 非作品作者不能变更授权;基于旧值的并发请求按期望值冲突拒绝。
+- [x] 重复提交同一授权变更请求不产生第二次副作用,如实回报为幂等重放。
+- [x] 声明血缘时:来源作品不存在、未公开、已被封禁、**已被软删除**(#565 的 `deleted_at`)、授权为禁止、来源版本不等于来源作品当前公开版本,各类情形全部失败关闭且给出可区分的原因(已删除与未公开统一返回 `FORK_SOURCE_NOT_AVAILABLE`,不用错误码区分删除事实)。
+- [x] 代际与根作品由服务端计算:三层链路(A→B→C)得到 B 为第 1 代、C 为第 2 代,且 B、C 的根作品都是 A。
+- [x] 血缘不可变:同一作品第二次声明血缘被拒绝;既有作品复用身份的场景不接受血缘声明。
+- [x] 来源作品下架、封禁或**软删除**后,既有衍生作品保持公开,新的血缘声明被拒绝;衍生数量只统计未软删除且已公开的子作品。
+- [x] 详情页展示的授权、代际、溯源与衍生数量与后端一致;来源作品不可读(含已软删除)时该节点降级为「原作品已不可用」,不空白、不回显被删作品标题。
+- [ ] 公开详情与广场展示真实作者名与头像(读时联账号表),兜底文案不再出现在可正常读取账号的场景;现役公开 DTO 未暴露陶泥号,如需展示须另加字段。(实现已落地:`useGameAuthorDisplayName` 按作者 ID 补查昵称,公开 DTO 未加陶泥号字段;暂无专用断言,故不勾选。)
+- [x] 公开响应不泄露对象键、未公开作品或未公开来源信息(含:父/根作品被软删除后不再输出其标题与作者名)。
+- [ ] 发布开关收紧时,共创授权写入返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`;开关状态读取失败按关闭处理。(M1 复用现役 `game-distribution:publish` 开关,不新增独立灰度键。)(路由已接 `ensure_publish_enabled`,含读开关失败按关闭;暂无 503 断言用例,故不勾选。)
+
+## 证据要求
+
+- 自动化:领域规则单测(授权状态机、代际与根计算、血缘校验)、发行模块与 BFF 定向测试、共享 DTO 一致性检查、生成绑定与 schema 检查、编码与文档索引检查。
+- 运行时:真实 api-server + 本地数据库上完成「设置授权 → 提升 → 声明血缘 → 详情页展示 → 父作品下架后新声明被拒」整链 smoke;浏览器在桌面与窄屏检查详情页与我的作品页。
+- 边界:未登录、非作者、期望值不匹配、重复请求、来源作品六种不可用情形(不存在 / 未公开 / 已被封禁 / 已被软删除 / 授权禁止 / 来源版本不匹配)、父作品下架与软删除前后对比。
+
+## 实施记录
+
+- 2026-10-04:rebase 到含 #565(作品管理与 Phaser4 客户端发布)的新 master,9 个冲突文件按「两侧业务逻辑都保留」解完;详情页按 #565 的新结构改用共享 `GameDetailDisplay`,共创卡改由 `infoCards` 槽位渲染;并按 #565 的软删除语义收敛三处(来源校验 / 衍生计数 / 溯源摘要)。冲突解法与具体落点见技术方案 §3.9.1–§3.9.2,门禁与验收证据见 §5.1。
+- 2026-10-06:按产品拍板收敛「授权继承」口径(取代「衍生作品落默认 `forbidden`、作者自己提升」):创建游戏事务在带血缘声明时取**父行当时的档位**写入新行(`resolve_game_distribution_fork_declaration_tx` 把父档位一并返回,新增纯函数 `resolve_game_distribution_creation_fork_authorization` 承载「衍生继承 / 母版按请求值」的裁决),客户端传值一律忽略且不报错;**收窄入口不做**;`PUT …/fork-authorization` 的只升不降语义当时**未改**(同日第二笔收紧,见下一条)。口径与落点见技术方案 §2.3 / §3.9 补充,证据见 §5.3。既有按旧语义写的两个 e2e 脚本(本地夹具脚本(族谱/血缘) 富树段、`scripts/capture-game-lineage-visual.mjs`)需要按新语义改成「创建后不再从 `forbidden` 提升」,本轮未改(不属本工作树权限),见 §5.3 的遗留项。
+- 2026-10-06(同日第二笔):**衍生作品的授权档位收敛为「终态」**。上一笔只改了创建(继承父档位),但 `PUT …/fork-authorization` 仍对任意作品开放「只升不降」,作者可把继承来的 `nonCommercial` 再提成 `full`,得到比祖先更宽的子作品,把父作品在非商用授权下公开的工程内容变成可用商用。现在:**存在血缘行(衍生作品)→ 任何 PUT 一律拒绝**(含传同值),新稳定码 `FORK_AUTHORIZATION_INHERITED` → 409;判定在 `set_game_distribution_fork_authorization_tx` 里**先于 CAS**,规则抽成纯函数 `module_game_distribution::resolve_fork_authorization_promotion`(衍生终态 → CAS → 只升不降);母版语义完全不变。验收标准里「授权状态机」一项据此理解为「母版单向提升 + 衍生作品不可改」。口径见技术方案 §2.3 / §3.4 / §3.9 第二条补充,证据见 §5.5,决策理由见 `decision-log.md` 同日条目。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 285ffe921..02da06b3c 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -15,6 +15,67 @@
- 影响范围:`server-rs/crates/api-server/src/modules/game_distribution.rs`(`game_media_object_key_belongs_to_game`、`ensure_owner_media_object_key` 及其单测);权威说明同步到后端数据契约、玩法链路与技术方案。
- 验证方式:`cargo test --manifest-path server-rs/Cargo.toml -p api-server modules::game_distribution`(58 passed,含新增 `owner_media_object_key_is_authorized_by_game_namespace_not_current_row`)、`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`。
+## 2026-10-07 共创页「被共创 N 次」指标、图谱下清单改响应式网格、总览图标、详情页两个主动作改名
+
+同一轮四小件(用户逐条口径,均已落地并真机取证):
+
+1. **共创页卡片指标位 = 「被共创 N 次」(不是游玩次数)**。用户原话:「并非游玩次数,而是共创次数」(截图圈的是卡片标题行右侧那处)。做法:共享卡 `GameCard` 新增**可选 `metric` 槽**(不传 = 原来的「N 次游玩」,默认行为一字不改),只有 `scope="forkable"` 的共创页传;**封面角标「共创 N」删除**(同一张卡只留一处计数)。文案选**被动式「被共创 N 次」**:避免「共创 N 次」读成主动语态,且平台已有「累计被共创次数」词汇;`aria-label` 同名。突出方式:`--platform-accent-strong`(实测 `rgb(199,101,61)`)+ `700` + `0.72rem`(默认指标 `0.66rem` / `--platform-text-soft`)+ `Sparkles` 12.8px 图标 + `tabular-nums`。**0 要显示**(「被共创 0 次」= 还没人接着做);只有两个字段都缺省才退回默认游玩数、不伪造 0。取值 `coCreationCount ?? forkCount`(服务端 `ffd751014` 已上线该键,实测 45/45 条带键,分布 0/1/2/3/5/7;「富树分支 B1」`forkCount=1` 而 `coCreationCount=2` ⇒ 两键口径确实不同)。
+2. **图谱下方「父代 / 子代」清单改响应式多列网格**。用户原话:「一行一个太奢侈了,你整小一点,响应式布局」。根因:两段清单用了两套容器——父代是 `grid`、子代是 `flex column + 54rem`,后者一张卡占满一行、16:9 封面被拉到 ~850×480。现在两段共用 `.lineage-relation__grid`(`repeat(auto-fill, minmax(10rem, 1fr))` + 容器 `max-width: 54rem`),实测 1440 **5 列** / 1024 **5 列** / 390 窄屏 **2 列**;卡宽 160–200px(比画布卡 212/148 窄一档,仍**同一个** `GameCard`,差异只由 `.lineage-relation__item` wrapper 表达);卡内标题单行省略、留白与字号收紧;封面仍 16:9 `object-fit: cover`。删掉随之失效的 `.lineage-relation__list` / `__row` / `__main`。
+3. **「总览」图标换成向内收拢**。用户原话:「改成向中间的箭头,因为现在这个就是起到这个作用的」。原用 lucide `Expand`(四角向外)与动作 `fitToView(true)`(缩小到装得下整棵树)**方向相反**;换成同一对反向图标里的 **`Minimize`**(箭头指向中间)。文案 / `aria-label` / `title` 保持「总览」不变(描述结果而非动作)。同栏逐个核对:缩小 `Minus` ✅、读数 `NNN%` ✅、放大 `Plus` ✅、刷新 `RefreshCcw` ✅、无搜索(`ZoomIn/ZoomOut` 已被测试钉住不得回归);测试新增 `Minimize` 必在 / `Expand` 不得回归的源码级断言。
+4. **详情页主操作行两个按钮定名**:`立即玩` → **`开始游戏`**(与游玩页启动按钮同一词表)、`一键开始共创` → **`开始共创`**(与玩家侧同一词表,两侧差别只在样式:共创者侧实心 lg / 玩家侧次要小按钮);未登录侧 `登录后开始共创` 不变。两个按钮都没有单独 `aria-label`,无障碍名 = 可见文本,因此随之自动一致。**已改(2026-10-07 用户拍板)**:共创广场 hero 的「立即试玩」→ **「浏览作品」**(它的动作是滚动到列表、不启动作品;两个 scope(游戏广场 / 共创广场)共用同一个按钮)。
+
+- 影响面:`packages/shared/src/components/GameCard/index.tsx`(新增 `metric` 槽)、`src/components/game-distribution/{GameGalleryPage,LineageRelationLists,GameLineagePage,GameDetailPage}.tsx`、`src/components/game-distribution/gameDistribution.css`、`packages/shared/src/components/GameDetailDisplay/index.tsx`(注释)、`GameDistributionPages.test.tsx`、`docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+- 验证方式:`npx vitest run src/components/game-distribution src/components/platform-entry src/components/creator src/routing src/components/creation-home`、`npm run typecheck`、全仓 `npx eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0`、`npm run check:encoding`、`git diff --check`;真机(只读 `127.0.0.1:8082` + 独立端口 Vite):共创页 5 张不同值卡(0/1/2/3/5)的文本+颜色+字重、游戏页同作品仍是「N 次游玩」、清单在 1440/1024/390 三档的列数/卡宽/封面比例/溢出、控件栏改前后截图、详情页共创者侧与玩家侧的可见文本与无障碍名。
+
+## 2026-10-07 「共创」从游戏页子页签升为侧边栏顶层入口;共创页卡片显示「共创 N」次数
+
+- 用户口径(原话):「共创页不应该是游戏页里的子标签,而应该是**侧边栏里的大标签**」;「共创页里的游戏卡片要突出一个**共创次数**(即它有多少个子项目)」。
+- 决策(入口):桌面侧边栏一级入口改为 `创作 / 项目 / 游戏 / **共创** / 创作者主页 / 我的`(「共创」紧挨「游戏」、同一套 `ActiveRailButton`),窄屏底部 dock 改为 `游戏 / **共创** / 创作者主页 / 我的`(4 列)。两侧都指向**同一个** `GameGalleryPage`(`scope="forkable"`,`/games/co-creation`,请求带 `forkable=1`),**不新建页面、不新建路由**。
+- 决策(子页签删除):`GameGalleryPage` 与 `GameThemesPage` 里那段「游戏 / 共创」子页签**整段删除**(它只为这个切换而存在);随之删掉 `GameGalleryPage` 的 `onOpenGallery` / `onOpenThemes` 两个 prop(调用点一并清理,不留兼容壳)。**主题列表页与主题详情路由保留**,入口改为「作品详情 → 所属主题」或直接 URL。
+- 决策(高亮互斥):`isGamesStage`(游戏区,含共创,决定搜索口语/面板滚动)与**导航高亮**拆开——新增 `isCoCreationStage`,导航用 `isGamesNavActive = isGamesStage && !isCoCreationStage`,保证进共创时高亮「共创」而不是「游戏」。
+- 决策(老链接):子页签从来只有 `setSelectionStage('game-co-creation', { path: '/games/co-creation' })`,没有自己的路径,因此 **`/games/co-creation` 深链语义不变**、无需重定向;路由表/stage 枚举/标题表本来就有这一项。
+- 决策(共创次数角标):在**共创页**卡片封面左上(复用共享卡 `GameCard` 的 `badges` 槽 + `pointer-events: none`)显示实心强调色角标「共创 N」,**只在共创页出现**(广场/游戏页不传 `badges`,默认表现零改动)。取值 `resolveCoCreationBadgeCount(game) = game.coCreationCount ?? game.forkCount`,`<=0` 或缺省**不渲染**(不显示「共创 0」)。
+- 口径(2026-10-07 服务端已定稿并落地):`coCreationCount` = **该作品子树里公开可见的全部后代数(不含自己)**,`forkCount` = **直接子代数量**;两者并存、互不推导(同一张卡上前者 ≥ 后者)。可见性规则与 `forkCount` 同一份(未软删除 + 已公开),已删 / 未公开的后代不进任何一层计数;血缘环 / 自引用按 `visited` 去重,自己永不计数。**公开投影(公开目录 / 公开详情 / 我的收藏 / 主题成员)恒发 `coCreationCount`(没有后代时是 `0`,不是缺键)**,`scripts/check-game-distribution-dto-parity.mjs` 已把它与 `forkCount` 一起钉成 `public_game_payload` 的 `mustEmit`;**作者侧同形响应不发**该键,因此读取方仍按 `coCreationCount ?? forkCount` 取值。计数由 spacetime 在**只读事务里现算**:一次建 `module_game_distribution::PublicDerivativeIndex`(父 → 直接子代血缘边 + 可见性集合)+ 每行一次 BFS 子树计数,不新增表 / 列 / 索引、不落物化计数,api-server 只发键不遍历。前端(`resolveCoCreationBadgeCount`)无需改动。
+- 影响面(本轮,只动 `src/**` + `packages/shared/**` 可加扩展 + `docs/**`):`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`(rail + dock + stage 标志)、`PlatformEntryActiveFlowShell.test.tsx`、`src/components/game-distribution/GameGalleryPage.tsx`(删子页签、加角标)、`GameThemesPage.tsx`(删同一行子页签)、`GameDistributionPages.test.tsx`、`src/components/game-distribution/gameDistribution.css`(角标样式,新增块)、`packages/shared/src/contracts/gameDistribution.ts`(可加字段 `coCreationCount?`)、`docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`(§3.4 / §3.6.1 / §3.10)、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(导航基线)。
+- 验证方式:`npx vitest run src/components/game-distribution src/components/platform-entry src/components/creator src/routing src/components/creation-home`、`npm run typecheck`、全仓 `npx eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0`、`npm run check:encoding`、`git diff --check`;真机(只读用户栈 `127.0.0.1:8082` + 独立端口 Vite):桌面「侧边栏点共创 → 列表(含共创次数角标)→ 点卡进详情 → 返回」、窄屏底部导航一遍、深链 `/games/co-creation` 直达、游戏页(子页签移除后)无死路。
+
+## 2026-10-07 Fork 建项收口:成品包铺进预览根、登记可运行原型与初始工程版本
+
+- 背景(真 bug,用户实测):用户在 AGC 里 Fork 了一个只发布过成品包的作品(`source: package`),项目建出来之后**运行页签不可用**——只显示「首个可运行原型尚未完成,运行视图暂不可用」,点击无反应,发布也被同一判据拦下。实物证据:`%APPDATA%/world.genarrative.ai-game-creator/projects/gameagent-a74aa1d9`(2026-10-06 15:27:58 建项)的 manifest **16 个 seed 任务全 `pending`(含 `code-prototype`)、没有 `versions[]`、没有 `preview`**,小到一个 296 B 的默认占位 `game/index.html`;父作品那套真实可玩产物(`index.html` + `assets/index-*.js` + 素材)被解到 `reference//` —— 该目录**不在预览根**,`preview::project_game_root()` 与服务预览的 `resolve_preview_path()` 都走不到,AGC 界面也没有任何入口能播它。
+- 根因:Fork 建项(`game_fork.rs` 的 `create_project_from_platform_fork_at`)只做「解压 → `init_local_game_project_at`(脚手架 + 全新 manifest)→ 写 `.agent/fork-source.json`」,**没有**像 AI 直连回合收口那样登记「本项目已有可运行原型」与工程内部版本;而运行视图与发布导出读的恰恰就是 `manifest.tasks[code-prototype] == completed`(或存在运行中的预览)这一条事实(前端 `view/project-development/index.tsx` 的 `runAvailable`、后端 `project/export.rs` 的 `project_has_runnable_prototype`)。用户自己那个正常项目 `gameagent-c3af9c7e` 恰是 `code-prototype: completed` + `versions: [initial-10]` 这一对——两者对比即根因。
+- 决策(D1 口径,产品 2026-10-07 拍板):**成品包 fork 之后允许零改动直接发布**,语义就是「fork 完即可用」——把父作品成品铺进**预览根**并在**同一次收口**里登记可运行原型,用户点运行/发布应立刻可用。同步更正 §3.5.1 / §2.5 里「成品包只能试玩、不能发布」的旧结论(那是按 M2a 落点假设写的,已过期):真正的能力边界只剩「取到的成品里没有源码,核心逻辑要在自建工程里重做」。
+- 决策(落点):成品包整包解到 **`fork_playable_root` = `preview::project_game_root(root)`(本形态即 `game/dist`)**,**不再写 `reference//` 副本**。理由:运行视图只服务预览根,铺在别处的副本用户既看不到也播不了;两份副本会让「哪份是事实源」含糊并白占磁盘;来源与形态事实由 `.agent/fork-source.json`(v2)承担。安全性:平台发行包契约保证入口固定在包根 `index.html`(服务端 `validate_release_zip` + api-server 的 `package_entry_path` + AGC 发布侧归一化三处一致),解到预览根后就是 `<预览根>/index.html`;`game/dist` 同时被工程源包排除清单覆盖(`project_bundle.rs`),**不会**把父作品产物当成作者源码重新上传。
+- 决策(收口,照既有模式、不另发明):`game_fork.rs` 新增 `register_forked_project_state_at(root)`,在 fork 已持有的项目写锁内按既有顺序写入——`update_manifest_task_status_at("code-prototype", Completed)`(**仅当预览根确有 `index.html`**)→ `advance_agent_runtime_project_revision_locked` → `append_agent_game_iteration_version_at`(首条即 `initial-1`)→ `emit_game_creator_manifest_invalidated(root, "game-fork.project")`,最后重读 manifest 返回(避免把过期快照交给进项目通道)。与 `agent/direct_runtime/mod.rs:4181-4194` 同一套模式;不使用无守卫的 `set_task_status`(`pitfalls.md` 记过它有两条竞争写入路径)。
+- 决策(D2 工程源包形态,产品 2026-10-07 拍板):**不在 fork 时自动构建**——与既有「不自动安装或覆盖导入/用户修改过的工程」策略一致(源码包本身也不含 `game/dist`)。因此该形态**如实不标记完成**:只写 `versions == [initial-1]`,`code-prototype` 保持 `pending`;**文案诚实化**:运行页签不可用时按清单事实分叉(`src/view/project-development/runUnavailableHint.ts`)——已有工程内部版本时说「已有工程内容但还没有可运行的构建产物:让智能体完成可运行原型后即可运行」,全新项目保留原句「首个可运行原型尚未完成,运行视图暂不可用」。
+- 决策(D3 既有数据,产品 2026-10-07 拍板):**不做一次性迁移**(不写补丁代码)。修复只对新 Fork 生效;既有 fork 项目(含上面那个实物)请**重新 Fork 一次**。
+- 不做:不新增 manifest 字段(manifest 是 `deny_unknown_fields` 的 v1 契约;改编来源仍只写 `.agent/fork-source.json`);不引入「本项目自己的构建已发生」这条新判据(D1 允许零改动发布,故不需要把运行门禁与发布门禁拆成两套事实);不改平台契约、`server-rs/**`、`src/**`、`scripts/**`。
+- 影响面(AGC 客户端 + 文档):`apps/ai-game-creator-shell/src-tauri/src/game_fork.rs`(落点、收口、来源校验、用例)、`src-tauri/src/project/export.rs`(`project_has_runnable_prototype` 提升为 `pub(crate)` 供断言)、`src/view/project-development/runUnavailableHint.ts`(新)+ `index.tsx`(一行接线)、`apps/ai-game-creator-shell/tests/runUnavailableHint.test.ts`(新)、`tests/appSurface/project-development.suite.ts`(两条 fork 形态投影用例)、`docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`(§2.5 / §3.5 头注 / §3.5.1 / §3.5.2 下载与建项 / §3.5.4 / §5 验收矩阵 / §5.8 / §6 M2a 行)。
+- 验证方式:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- game_fork`(成品包形态断言 `game/dist/index.html` == 包内入口、`code-prototype == Completed`、`versions == [initial-1]`、`project_has_runnable_prototype == true`、无 `reference/`;工程源包形态断言 `code-prototype == Pending`、`versions == [initial-1]`、无 `game/dist`)、`cargo check --tests`、`cargo fmt --check`、AGC 两个 tsconfig `typecheck`、`npx vitest run apps/ai-game-creator-shell/tests`、全仓 eslint、`npm run check:encoding`、`git diff --check`。真机留档(截图/录屏)待客户端重建后执行:Fork 一个无工程源包的已公开作品 → 运行看到父作品画面 → 改源码 → build → 发布 → 详情页出现溯源。
+
+## 2026-10-06 衍生作品的共创授权档位是终态:禁止 PUT 修改(新码 `FORK_AUTHORIZATION_INHERITED` → 409)
+
+- 背景(真漏洞):上一轮「创建时继承父作品档位」只堵住了创建,`PUT /api/game-distribution/games/{gameId}/fork-authorization` 仍是「任意作品、只限方向不限对象」。继承是「创建时快照等值」,于是一个继承到 `nonCommercial` 的衍生作品可以被作者再提成 `full`——得到一个**比祖先更宽**的子作品,把父作品在非商用授权下公开的工程内容变成可用商用。
+- 决策:**衍生作品的档位是终态**,只由继承决定。判定 = 「该作品**存在血缘行**」(有父即衍生,不接受客户端自称)→ 直接拒绝,稳定错误码 **`FORK_AUTHORIZATION_INHERITED`** → **409 CONFLICT**(与既有 `FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED` 同为 409,码不同),文案「衍生作品的共创授权由父作品继承,不能自行修改」。
+- 口径(照此实现):**衍生作品的任何 PUT 都拒绝**,含「传同值」这种幂等重试——客户端因此不会遇到「有时成功有时失败」的随机性。母版(无血缘行)语义**一律不变**:只升不降(可跳级)+ `expectedForkAuthorization` CAS + 幂等重放。
+- 判定落点(一个事务的一步):`server-rs/crates/spacetime-module/src/game_distribution.rs` 的 `set_game_distribution_fork_authorization_tx`——**幂等重放分支之后、CAS 之前**查 `game_distribution_lineage().game_id().find(&game_id)`,把 `has_lineage_row` 连同 `current` / `expected_fork_authorization` / `target` 交给新增纯函数 `module_game_distribution::resolve_fork_authorization_promotion` 一次裁决(顺序:**衍生终态 → CAS → 只升不降**,失败都不写库)。衍生判定放在 CAS 之前是有意的:同一个衍生作品不会因为载荷不同(传同值 / 传新值 / 期望值过期)而回不同的码。判据只有这一份实现(事务里不再出现 `can_promote_to`,也不自己比 CAS 期望值)。
+- 错误码可达性三件套:模块 `Display` 产出 `"{CODE}: 中文"`(码常量 `GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE`,Display 插值它);api-server 的 `FORK_` 映射表登记该码(409);纳入既有「FORK 码由模块真实文案可达」枚举测试(并保留「未登记码退化成 `FORK_ERROR`/409」的反证)。
+- 测试改动:**没有既有 Rust 用例断言过「衍生作品 PUT 提升会成功」**——Rust 侧此前对该路径只有「未知档位 → 400 信封」与「路由未带 Bearer → 401」两条用例。故本轮的测试是**新增**:`module-game-distribution` 的 `derivative_put_is_rejected_even_with_the_same_or_stale_value`(同值 / 再提一级 / 期望值过期都回同码)、`master_put_still_promotes_with_unchanged_cas_semantics`(母版提升照旧 + 同级 / 降级 / CAS 不符语义未变)、`errors.rs` 的前缀断言,`spacetime-module` 的结构断言 `set_fork_authorization_tx_rejects_derivatives_before_cas`(血缘行先于裁决、裁决先于写库),api-server 的可达性枚举 + 常量字面量断言。断言旧语义的是 本地夹具脚本(族谱/血缘) / `scripts/capture-game-lineage-visual.mjs`(不属本工作树权限,见「遗留」)。
+- 契约与形状:**无列 / 表 / 索引 / DTO 形状变更**,只新增一个错误码常量与错误变体;`GameDistributionSetForkAuthorizationRequest` 的 serde 形状未动(幂等摘要不变)。
+- 文档:技术方案 §2.3(授权模式 + 状态机)、§2.4(`/games/mine`、`/games/publish`、`/games/lineage`)、§2.6 时序图、§3.3 状态表、§3.4(`PUT` 行、`POST /games` 行、公开详情行、`/lineage` 行)、§3.9 新补充、§5.3 遗留、§7 第 2 条;数据契约文档的 `fork_authorization` 条目;里程碑日志。
+- 顺带写清的三条口径(无代码):① `/lineage` 的**累计世代数由前端自算**——本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),服务端不提供该字段;② 改动说明**已在版本摘要 payload 上**(公开详情 `currentVersion.changeSummary`,衍生为字符串、母版 `null`),族谱页右侧面板只对**选中节点**请求一次公开详情即可拿到——**不做 N+1、不给 `LineageNode` 贴该字段**;③ **衍生作品不会继承到 `forbidden`**:父作品为 `forbidden` 时不允许共创(`403 FORK_NOT_AUTHORIZED`),根本产生不出衍生作品。
+- 遗留(不在本次工作树权限内):两个 e2e 脚本里对衍生作品的 PUT 现在**一律**回 409 `FORK_AUTHORIZATION_INHERITED`(不再只是 CAS 不符),需改成「按新继承档位断言 / 只对母版提升」——由 `scripts/**` owner 并行改写。前端侧已接:`MyGamesPage` 对衍生作品隐藏提升入口、改为只读展示(`4f888d8c9`),AGC 发布面板同轮改为只读展示「授权继承自父作品」;`/lineage` 世代数按子树深度自算、面板读版本摘要层(`bd73acfcc`)。
+- 验证方式:`.worktrees/feat/game-fork` 上自跑 12 条门禁全部 exit 0(wasm build、`cargo check --all-targets`、`cargo test -p module-game-distribution`、`cargo test -p api-server game_distribution`、`cargo test -p spacetime-module`、`cargo test -p spacetime-client`、`check-game-distribution-dto-parity`、`check-project-bundle-policy-parity`、`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`、`npm run check:encoding`、`cargo fmt --check`、`git diff --check`),逐条 exit 见技术方案 §5.5。
+
+## 2026-10-06 衍生作品的共创授权改为「创建时继承父作品当时的档位」
+
+- 背景:需求文档(rev 1554「发布设置页 → 授权设置区」)要求「**自动继承父作品授权**」,并划掉了「可选择收窄授权,不可放宽」——连收窄也不做。改前的实现是衍生作品创建时落默认 `forbidden`、再由作者自己 PUT 提升,与文档不一致;旧客户端在创建衍生作品时惯常带自己的默认档位。
+- 决策:创建游戏事务的新建分支里,带 `forked_from_game_id` / `forked_from_version_id` 时新行的 `fork_authorization` 取**父行当时的档位**(血缘解析 `resolve_game_distribution_fork_declaration_tx` 现在把父行档位一并返回,它的既有校验「父必须允许共创」不变,因此继承到的档位只会是 `nonCommercial` / `full`);客户端在请求里传的值**一律被忽略且不报错**。母版维持按请求值(缺省 `forbidden`,未知档位失败关闭)。裁决抽成纯函数 `resolve_game_distribution_creation_fork_authorization(parent, requested)`,让口径可被单测直接钉住(事务需要 `ReducerContext`,起不了真库)。
+- 语义边界:继承是**创建时快照**——父作品之后提升档位不会回溯改写既有子作品的行;因此它与「只升不降」作用在同一行数据上不冲突(提升只改被提升的那一行)。**不做收窄入口**(产品决定 2026-10-06)。
+- 契约与形状:`GameDistributionCreateGameRequest.forkAuthorization` 字段保留、serde 形状(非 `Option` + `#[serde(default)]`)不变——改了会让创建请求的幂等摘要漂移;不新增/删除列,无 schema 迁移。
+- 未改(后被同日「档位终态」决策收紧,见上一条;「原样保留」现在只对**母版**成立):`PUT /api/game-distribution/games/{gameId}/fork-authorization` 的语义(只升不降、`expectedForkAuthorization` CAS、幂等重放)原样保留;它继续服务「提升」。
+- 影响面:`server-rs/crates/spacetime-module/src/game_distribution.rs`(血缘解析返回值、创建事务、纯函数与测试)、`server-rs/crates/api-server/src/modules/game_distribution.rs`(创建 handler 注释)、`server-rs/crates/shared-contracts/src/game_distribution.rs` 与 `server-rs/crates/spacetime-client/src/game_distribution.rs` 与 `packages/shared/src/contracts/gameDistribution.ts`(字段注释)、技术方案 §2.3 / §3.2.1 / §3.4 / §3.9 / §7、数据契约文档、里程碑文档。
+- 遗留(不在本次工作树权限内):本地夹具脚本(族谱/血缘) 与 `scripts/capture-game-lineage-visual.mjs` 仍按旧语义「创建衍生作品(落 `forbidden`)→ 再 PUT 从 `forbidden` 提升」,H 段富树还刻意让一个衍生节点保持 `forbidden`;改口径后那些 PUT 会因 CAS 不符返回 409。两脚本在工作树划分里属其它 owner,未改。
+- 验证方式:新增/改写 3 条 `spacetime-module` 用例(继承矩阵 / 母版按请求值 + 未知失败关闭 / 创建事务取父行档位的结构断言),`cargo test -p spacetime-module` 301 passed / 1 ignored;`cargo test -p module-game-distribution` 119 passed;`cargo test -p api-server game_distribution` 104 passed(既有「缺省 `forbidden` 且幂等摘要不变」用例继续通过);`cargo test -p spacetime-client` 43 passed;wasm build、`cargo check --all-targets`、`check-game-distribution-dto-parity`、`check-project-bundle-policy-parity`、`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`、`npm run check:encoding`、`cargo fmt --check`、`git diff --check` 全部 exit 0。**没有改动任何 Rust 既有断言**(Rust 侧原本没有断言「衍生作品默认 `forbidden`」的用例;断言旧语义的是上面两个 e2e 脚本)。
## 2026-10-07 AGC 命令沙箱原生支持 fnm/nvm:只读挂载窄叶安装前缀,npm 改走 node + npm-cli.js
- 背景:开发构建里 AGC 让命令沙箱执行 `npm run build` / `npm install` 时,宿主 Node 由 fnm 托管,`node` / `npm` 实际是随 shell 会话变化的 fnm multishell 目录里的 shim;bwrap `--tmpfs /run` 会抹掉该路径,而只按单文件挂载 `<前缀>/bin/npm`(它软链到 `lib/node_modules/npm/bin/npm-cli.js`)会因 `Cannot find module '../lib/cli.js'` 失败。此前把宿主 `node` / `npm` / `npx` shim 指到 `/usr/bin/*` 是错误取舍:系统 Node 26 默认启用实验性 Web Storage,会顶掉 vitest 0.34 jsdom 的 localStorage,使 AGC 测试套件在 HEAD 即失败(见 `pitfalls.md` 2026-10-03 条)。
@@ -9953,3 +10014,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 槽位三态:封面槽位是「空 = 沿用上一版本 / 线上 objectKey = 沿用该键 / 其余 = 本地图带二进制」;截图槽位是「线上 objectKey 或本地路径」。草稿被回写成键后,下一次发布按沿用处理、不重复上传;宿主与前端都按 `GAME_DISTRIBUTION_MEDIA_PREFIX` 前缀先认出键,读文件前分流(AGC `is_game_distribution_media_object_key` + `buildTaonierPublishMetadata` 封面分支)。
- 预览口径:当前槽位与「上一版本」槽位都按 OSS 同一来源 `object-contain` 显示(本地图走 `IconPreview` 的 `size-24 object-contain`),不再 `object-cover` 裁剪,作者能逐张对照比例;封面草稿是键时按同一 key 从上一版本列表取换签地址,取不到预览时显示「已上传封面」而不是伪装成没有封面。
- 验证:AGC 单测 `resolve_publish_media_treats_a_rewritten_cover_object_key_as_reuse_not_a_local_file` 与既有 `resolve_publish_media_*`;`taonierExport.test.tsx` 覆盖封面/截图回写、已为键时不重复改写、`buildTaonierPublishMetadata` 封面键分支。
+
+## 2026-10-07:合并 origin/master「游戏共创(作品 Fork)」后的发布链路冲突处理
+
+- 背景:`style/polish-taonier-publish`(统一发布接口)合并 `origin/master`(PR #622 游戏共创 / 作品 Fork,merge-base `b1c89fbb`),17 个文件两侧都改了同一批发布链路。结论是**保留两支能力**:统一 `POST /api/game-distribution/versions` 与旧两步 `POST /games` + `POST /games/{gameId}/versions` 并存。
+- 契约:`GameMetadata` 保留 master 的 `forkAuthorization` / `fork`(ts-rs 手写类型;`fork` 标为可选,渲染层不提交,改由原生链路从 `.agent/fork-source.json` 读取);`NewGameVersionRequest` 增加可选 `changeSummary`;恢复旧 `GameDistributionCreateGameRequest` / `GameDistributionCreateVersionRequest` 与 `From<&...> for GameMetadata`,并登记 DTO parity。
+- 领域 / 事务:`resolve_version_number` 恢复两参 `(max_existing, requested)` 与 `VersionNumberExhausted`;`spacetime-module` 同时保留统一 `publish_game_distribution_version_tx` 与旧 `create_game_distribution_game_tx` / `create_game_distribution_version_tx`,两条路径复用同一份 `ensure_game_distribution_change_summary`。
+- 修复的功能缺口:统一发布事务此前不处理 `changeSummary`。现按血缘行判定衍生、对衍生作品失败关闭并落库,母版忽略;网页 `gamePublishSubmission` 与 AGC 原生发布都把该字段透传到 `POST /versions`。
+- schema:`local_project_id` → `project_key` 是模块未上线前的硬切改名,继续按 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 走窗口;rollback 为回退 M1/M2 提交。
+- 验证:`cargo check --workspace --all-targets`;`cargo test -p module-game-distribution` / `-p spacetime-module game_distribution` / `-p api-server game_distribution`;AGC `cargo test -- game_fork`;`check:generated-bindings`、`check:game-distribution-dto-parity`、`check:spacetime-schema`、`check:encoding`、`git diff --check`、网页与 AGC 定向 vitest 全绿。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index c14026bc0..347e04bcf 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -6587,6 +6587,15 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:`cargo test -p api-server --bin api-server -- game_distribution`(新增 ETag 作用域、`If-None-Match` 列表/弱校验命中、`gzip;q=0` 拒绝、文本压缩与二进制/小文件不压、304 无正文、`*` 对包内缺失路径仍 404 等用例);`npx vitest run packages/shared/src/components/PlatformGameLoadingSurface.test.tsx`、`src/components/game-distribution/GameDistributionPages.test.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx`;`npm run check:game-distribution-ops-rollback-e2e` 47 项通过(含压缩/Vary/ETag/304/不接受 gzip 四条新断言)。真实栈同链路复跑:`phaser.min.js` 1,375,976 B → 353,336 B(gzip,4 Mbps 下 2724 ms → 774 ms),用户端游戏画面 3298 ms → 1543 ms、后台试玩 4587 ms → 2694 ms。
- **关联**:`packages/shared/src/components/PlatformGameLoadingSurface.tsx`、`src/components/game-distribution/GamePlayPage.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`deploy/nginx/README.md`。
+## 2026-10-06 合并 master 后 schema guard 的基线盲区:本地全绿、CI 红(表字段相对顺序)
+
+- **现象**:合并 master(merge 提交 `eeb101458`,PR base `9f4c7d76`)后本地整套门禁全绿,CI 的 **Backend tests** 与 **Repository checks** 却都在 `check:spacetime-schema` 红:`表 game_distribution_game 的第 26 个字段从 price_mud_points 变为 fork_authorization,疑似字段顺序被调整`。
+- **原因**:schema guard 对**表的字段相对顺序**是按 index 逐位比对的(`baseTable.fields[index]` vs `currentTable.fields[index]`);而**不显式给基线时它取 `git merge-base HEAD origin/master`**——merge 提交还没建时那还是**旧 merge-base**,于是「两侧各自在表尾追加」被并成「我们的列插到了 master 的列前面」这件事本地完全看不见;merge 提交一建,基线变成 PR base,CI 立刻红。
+- **处理(现行口径)**:合并 master 后**先建 merge 提交**,再用 PR base 复跑 `SPACETIME_SCHEMA_BASE_REF= npm run check:spacetime-schema`。列顺序规则:**master 先追加的列保持原 index,我们的追加排它之后**(`price_mud_points` 第 26 位、`fork_authorization` 表尾);对应的 `SpacetimeType` 快照(`GameDistributionGameSnapshot`)按同一条纪律排,避免只改表不改快照。
+- **别踩**:`migration.rs` 白名单、DTO parity 成员、nginx SPA 路由、Pingora 路由清单都是**集合/无序**判定(各脚本内部 `Set`/`difference`),只有 **SpacetimeDB 表字段顺序**(以及同步生成的 `module_bindings/*` 里的 wire 顺序)对相对顺序敏感;改列序后必须重跑 `spacetime generate` 并只回写受影响文件。
+- **判据/取证**:`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`(exit 0)、`cargo check --all-targets` 0、`cargo test -p api-server game_distribution`(101 passed)、`cargo test -p spacetime-module`(294 passed)。
+- **关联**:`server-rs/crates/spacetime-module/src/game_distribution.rs`(game 表与 game 快照的列序注释)、`server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_{type,snapshot_type}.rs`、`scripts/check-spacetime-schema-guard.mjs`、`scripts/check-repository-ci.sh`(CI 侧基线传法)。
+
## 2026-10-07 cc 会话恢复只活在进程内存里,「离开项目再进入」就看不到历史对话
- **现象**:用户反馈「离开后再进入项目,cc 那边看不到历史对话」——聊天面板里的历史还在(`.agent/conversations/project.jsonl` 是 UI 的事实源),但模型完全不知道之前说过什么。
diff --git a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md
index a10d7b7e9..bef890bf4 100644
--- a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md
+++ b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md
@@ -26,7 +26,7 @@
以上新增文件是拟定落点,实施时可按相邻模块组织拆分,但不得建立第二套身份、作品或数据访问系统。不涉及 AGC 桌面客户端、外部 OpenAPI、后台管理页或游戏运行态。
-源码已确认:公开游戏列表和“我的游戏”一次最多取 48 项,公开列表 `nextCursor` 固定为 null,前端 `listGames` 只返回数组;游戏广场的设备过滤发生在这份数组上。用户明确本次不额外改造这些现状。创作者主页只增加服务端作者过滤,沿用最多 48 项和原排序,不增加游戏分页、加载更多或新的筛选控件。
+源码已确认(2026-10-05 基线):公开游戏列表和“我的游戏”一次最多取 48 项,公开列表 `nextCursor` 固定为 null,前端 `listGames` 只返回数组;游戏广场的设备过滤发生在这份数组上。**2026-10-07 变更**:公开游戏目录 `GET /api/game-distribution/games` 已补**真游标分页**(`limit` 缺省 48——即为保持这条既有首屏语义、上限 100、超界截断;`cursor` = `"{createdAtMicros}:{gameId}"`;非法游标 400 `CATALOG_INVALID_CURSOR`;末页 `nextCursor` 为 `null`)。**前端尚未消费该游标**(`src/services/gameDistributionClient.ts` 的 `listGames` 仍只返回数组,属前端跟进的留白)。`/my-games`(“我的游戏”)仍是一次最多 48 项、不分页。创作者主页的作者过滤不变(服务端精确 owner 过滤 + 同一排序),只是过滤后的结果不再被 48 项截死,可继续翻页。
## 关系模型和授权
@@ -98,7 +98,7 @@ type CreatorRelationshipResponse = {
先排除不存在的对端,再计算 total 和分页;读取 limit+1 判断是否还有下一页。同时间多行不漏读,游标主人或类型不匹配返回 400。并发增删不保证多页构成冻结快照,按 userId 去重,刷新从首批开始;不引入跨请求数据库快照或游标持久化表。
-作者游戏只扩展现有 `GET /api/game-distribution/games` 的 `authorId`:未传保持原行为,显式空值返回 400;先按稳定 owner ID 和既有公开条件过滤,再排序、截取最多 48 项。现有条件包含 published、未删除及有效公开版本。前端 `GameListQuery` 增加可选 authorId,仍返回现有游戏数组;不修改游戏表结构或引入新的游戏列表系统。
+作者游戏只扩展现有 `GET /api/game-distribution/games` 的 `authorId`:未传保持原行为,显式空值返回 400;先按稳定 owner ID 和既有公开条件过滤,再排序切页(2026-10-07 起该端点自带游标分页,缺省 48 / 上限 100 / 末页 `nextCursor` 为 `null`),不再截死 48 项。现有条件包含 published、未删除及有效公开版本。前端 `GameListQuery` 增加可选 authorId;TS 客户端 `listGames` 目前仍只取数组(未消费 `nextCursor`,留给前端跟进);不修改游戏表结构或引入新的游戏列表系统。
## 前端状态与组件责任
diff --git a/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md b/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md
index 6cc64acbd..ddb65a81d 100644
--- a/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md
+++ b/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md
@@ -210,7 +210,7 @@ Direct 的自动登录刷新重试仍属同一个 run。原生单次调用结束
- 触发:新项目初始化完成,manifest 已持久化且稳定 project_id 已确认;覆盖自动创建与用户选目录创建。
- 公共字段:status=success;project_id 必填;source=editor;任务/run/turn ID 均 null。
-- properties:creation_source=home_game / home_design / template / selected_directory;project_template_id 可选,仅填真实模板稳定 ID。
+- properties:creation_source=home_game / home_design / template / selected_directory / platform_game;project_template_id 可选,仅填真实模板稳定 ID。
- 打开已有项目、幂等初始化不记创建;文件夹刚建立但后续失败不记成功。
- 去重事实键:project_id + 本次创建操作 ID + event_name,沿用业务操作身份,不读取路径当项目 ID。
- 只有成功事件,不能计算创建成功率;创建尝试数不在本版范围。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index 154ddab81..b887f4a66 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -501,6 +501,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 复用规则:末尾可空列 `project_key` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `project_key` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
- 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、公开媒体读取判定(`get_game_distribution_media_read_access_and_return`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。**资料编辑不得直接改公开价格**:调价必须走新版本审核。
+- 共创授权:游戏行末尾追加 `fork_authorization: String` 并设置 `#[default("forbidden")]`,取值 `forbidden` / `nonCommercial` / `full`(2026-10-04)。它表达作者对「这部作品能否被改编」的策略,与血缘正交:**母版**只能单向提升(`forbidden → nonCommercial → full`),降级与未知档位失败关闭;旧行按 `forbidden` 解释,旧客户端缺字段同样按禁止共创兜底。**创建时的档位来源分两种(2026-10-06)**:母版按请求值(缺省 `forbidden`);带 `forked_from_game_id` / `forked_from_version_id` 的**衍生作品继承父作品当时的档位**(创建时快照,父作品之后提升不会回溯改写既有子作品的行),请求里的 `forkAuthorization` 一律被忽略且不报错(旧客户端惯常带默认值),父作品为禁止共创时在血缘解析里就以 `FORK_NOT_AUTHORIZED` 失败,因此继承到的档位只会是 `nonCommercial` / `full`;「收窄授权」不实现(产品决定 2026-10-06)。**衍生作品的档位是终态(2026-10-06)**:存在血缘行时任何 PUT 都被拒——**409 `FORK_AUTHORIZATION_INHERITED`**(与降级同为 409、码不同),含「传同值」这种幂等重试;判定取血缘行且**先于 CAS**(同一衍生作品不因载荷不同而回不同的码),母版的只升不降 + CAS 语义不变。写入只发生在创建游戏(`create_game_distribution_game_and_return` 的 `GameDistributionCreateGameInput`)与提升 procedure `set_game_distribution_fork_authorization_and_return`(输入 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`)发生:只允许 owner 本人,`expected_fork_authorization` 不符返回 409,同 key 重放走 `game_distribution_idempotency_receipt`(action = `set_fork_authorization`;衍生作品永远写不了这条收据,因此重放分支只服务母版)。`api-server` 把实现值映射为 HTTP 码(`FORK_AUTHORIZATION_UNKNOWN` 400、降级与继承终态各 409、非本人 403、未登录 401),路由 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 受发布灰度开关约束(收紧时 503,读接口不受影响)。
+- 共创授权与软删除的交叉口径:已软删除的作品(`deleted_at` 非空)不再是可用的改编来源——`resolve_game_distribution_fork_declaration_tx` 先判 `deleted_at`,命中即按 `FORK_SOURCE_NOT_AVAILABLE` 失败(HTTP 409,与「未公开」同一错误码,不用错误码区分删除事实);既有子作品与血缘行不受影响,父作品的删除不会连带下线子作品。衍生计数(`game_distribution_public_fork_count`)只统计 `deleted_at` 为空且 `visibility = published` 的子作品,作者删除子作品后父作品的「已被改编 N 次」随之下降。
- 买断制定价(2026-10-05):游戏行末尾追加 `price_mud_points: u64` 并设置 `#[default(0u64)]`;`0` 表示免费,上限 `1_000_000`(复用 `module-game-distribution::normalize_game_price_mud_points` 校验)。价格是版本冻结资料的一部分:作者在 `GameDistributionCreateVersionRequest.priceMudPoints` 提交,写入版本冻结 `metadata_json.priceMudPoints`,只有 `approve_game_distribution_version_and_return` 通过审核时才随资料整体生效到本行;未通过审核或资料编辑都不会改变当前公开价格。公开投影(`get_public_game_distribution_game_and_return` 等)在游戏快照上带出 `priceMudPoints`。
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且活动版本存在、状态为 `published`(有效 `active_version_id`)的投影。
- 购买与播放鉴权 HTTP(2026-10-05):`POST /api/game-distribution/games/{gameId}/purchase`(`require_bearer_auth` + 必填 `Idempotency-Key`,请求体 `{ expectedPriceMudPoints }`)经 facade 调 `purchase_game_distribution_game_and_return`,返回 `{ purchase, walletBalance, replayed }`;余额不足 400 `INSUFFICIENT_MUD_POINTS`、价格已变化 409、免费游戏 400、作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`(作者免购买,绝不扣费)、管理员令牌 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`(购买只接受普通用户 bearer)、游戏不可见 404、缺幂等键 400、未登录 401。`POST /api/game-distribution/games/{gameId}/play-session` 对免费作品直接回既有公开入口 `/games/{gameId}/`;付费作品同时接受管理员令牌(按现有 admin 鉴权)与用户令牌,已购买 / 作者本人 / 管理员才签发绑定 `gameId + userId`、2 小时有效期的进程内会话,令牌为内存态,进程重启即失效。网关 `GET /api/game-distribution/play-sessions/{token}[/{assetPath}]` 不挂登录中间件、凭令牌读取当前公开版本包,能解析出平台刷新会话 Cookie 时 403,令牌过期 / 不存在、游戏下架 / 封禁或没有有效公开版本一律 404,全部 `no-store`。公开详情 `GET /api/game-distribution/games/{gameId}` 可选鉴权读取查看者:`purchased` 只反映真实购买记录,付费作品对未购买且非作者 / 非管理员把 `currentVersion.entryUrl` 置 `null`(资料与价格仍可见);`GET /api/game-distribution/releases/{gameId}[/{assetPath}]` 在当前公开版本 `price_mud_points > 0` 时同样 404,付费作品只能经播放会话路径播放。
@@ -510,6 +512,47 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 统一发布接口与版本号自然幂等(2026-10-07):`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`;`metadata` 增加必填 `versionNumber`,删掉 `resolve_game_distribution_version_number` 的 `None => max_existing + 1` 自增分支。无 `gameId` 时以 `projectKey` 为身份锚,`gameId` 由 `(ownerUserId, projectKey)` 确定性派生;新 procedure 在一次 `try_with_tx` 内 get-or-create 游戏行、写入版本行并落一张 `create_version` 收据(`idempotency_key = "{anchor}:v{versionNumber}"`,同键同摘要重放、不同摘要 409,不要求 `Idempotency-Key`)。删掉「同号 pending 被新提交取消替换」逻辑;`request_digest` 口径不变;媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。消费方:AGC `publish_local_project_game` 合并为一次调用;网页 `GamePublishPage` 新建固定 `versionNumber=1` 并持久化生成的 `projectKey`,更新读作者中心 max+1 后冻结。软删后同身份重发成功:模块按确定性 `gameId` 命中已软删行时,就地把它复活覆盖为全新作品(清 `deleted_at`、回到未公开、`publication_revision=0`、清空 `active_version_id` 与 `play_count`/`price_mud_points`、用本次资料覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可在新身份里重新发布而不被当作重放或摘要冲突;api-server 仅在显式 `gameId` 恰好等于该 `projectKey` 的确定性身份时进入复活路径,其余不存在 / 非本身份的 `gameId` 仍 404。沿用封面/截图按「当前行媒体 **或** `{GAME_DISTRIBUTION_MEDIA_PREFIX}{gameId}/` 命名空间内的历史媒体」放行,软删后 `current` 为空也能沿用旧 objectKey。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。
+### `game_distribution_lineage`
+
+- Rust 结构体:`GameDistributionLineage`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-04 新增。
+- 用途:作品之间的改编(Fork)血缘。`game_id` 为主键(子作品),因此「一个作品最多有一个父」由主键本身保证;`owner_user_id`(子作品作者,冗余用于「我改编过的作品」查询,不参与授权判定)、`parent_game_id`、`parent_version_id`(建立血缘时父作品的当前公开版本,不可变事实)、`root_game_id`(0 代母版;根作品自身不写行)、`generation: u32`(直接改编母版为 1)、`created_at`。
+- 写入时机:只在创建游戏时(`GameDistributionCreateGameInput` 携带 `forked_from_game_id` / `forked_from_version_id`,`api-server` 由创作入口的 `fork` 声明映射)与游戏行同事务写入,一次落定后不可变更;同一作品的第二次血缘声明失败关闭(`FORK_DECLARATION_ON_EXISTING_GAME` 409),`local_project_id` 复用既有身份的场景拒绝携带血缘。来源校验全部失败关闭:来源不存在 → `FORK_SOURCE_NOT_FOUND`(404),来源已软删除 / 未公开 / 无有效公开版本 → `FORK_SOURCE_NOT_AVAILABLE`(409),来源授权为禁止共创 → `FORK_NOT_AUTHORIZED`(403),来源版本不等于来源作品当前公开版本 → `FORK_SOURCE_VERSION_MISMATCH`(409)。
+- 索引:`by_game_distribution_lineage_parent_game_id`(衍生计数与今后的衍生列表)、`by_game_distribution_lineage_root_game_id`、`by_game_distribution_lineage_owner_user_id`。
+- 读取口径:公开详情/公开目录的投影增量是 `forkAuthorization`、`forkCount`(按 `parent_game_id` 实时统计未软删除且已公开的**直接**子作品,不维护物化计数)、`coCreationCount`(2026-10-07 新增:**公开可见的全部后代数**,不含自己——同一条可见性规则 `counts_as_public_derivative`,深度不同;在只读事务里**建一次** `module_game_distribution::PublicDerivativeIndex`(父 → 直接子代血缘边 + 可见性集合)后对本页每行做 BFS 子树计数,事务内不落表 / 不新增索引,api-server 只发键)与 `lineage` 摘要快照(`generation` / `rootGameId` / `rootTitle` / `parentGameId` / `parentTitle` / `parentAuthorName`);母版或旧数据为 `null`/缺省。父作品下架或封禁只影响新的血缘声明,既有摘要照常返回。
+- 溯源摘要与软删除:父(或根)作品被软删除时不再输出它的标题与父作者名——`game_distribution_lineage_snapshot` 把 `parent_title` / `root_title` 置为 `None`、`parent_author_name` 一并清空,`api-server` 原样发 `null`,前端降级为「原作品已不可用」;只保留不透明的 ID 与代际。理由:公开目录/详情既已下线被删作品,若还能从别人的溯源卡里读到它的标题与作者,删除语义就被血缘绕过。表结构同 `migration.rs` 的迁移导入导出白名单(血缘是业务事实,随迁移导入导出)。
+- 迁移:新表初始为空,不需要回填;根作品的「0 代」由「无血缘行」表达。`migration_tables!` 已登记该表。
+
+### `game_distribution_collection`
+
+- Rust 结构体:`GameDistributionCollection`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-06 新增。私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供用户态投影。
+- 用途:登录用户对公开作品的**收藏(收录)**事实——真实服务端投影,不是前端本地状态(既有发行合同禁止虚构收藏状态)。字段:`collection_id: String`(主键)、`user_id`、`game_id`、`created_at: Timestamp`。
+- 防重:主键是确定性构造的 `{user_id}:{game_id}`(`module_game_distribution::game_distribution_collection_id`,与 `profile_save_archive.archive_id` 同一写法),同一 (用户, 作品) 在结构上不可能出现第二行——去重由主键约束本身承担,而不是「事务里先查后写」(那种写法只在单写者假设下成立,分片 / 并发时两个事务都可能先查到「不存在」再各写一行)。因此**不需要**额外的 `(user_id, game_id)` 唯一索引。
+- 索引:`by_game_distribution_collection_user_id`(「我的收藏」按用户读取)、`by_game_distribution_collection_game_id`(按作品维度读取)。
+- 写入时机:`set_game_distribution_collection_and_return`(`PUT /api/game-distribution/games/{gameId}/collection`,要求 `Idempotency-Key`,命中同键收据回 `replayed = true`;主键已存在则跳过写入但仍算成功)与 `unset_game_distribution_collection_and_return`(`DELETE` 同路径,按确定性主键删除,不存在也算成功,天然幂等因此不要求幂等键)。收藏的前置失败关闭:作品不存在 → 404,未公开 / 已软删除 / 没有当前公开版本 → 409(文案 `作品状态不允许收藏(未公开或已软删除)`,`shared_contracts::game_distribution::GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT` 与 api-server 的 `状态` 子串映射共用同一常量)。取消**不**要求作品仍公开(否则下架后会留下用户清理不掉的脏行)。
+- 读取口径:`list_game_distribution_collections_and_return` 按 `user_id` 索引取行、按 `created_at` 倒序(同刻按 `game_id` 升序)稳定排序,逐条用**既有** `public_game_distribution_snapshot` 投影,只返回当前公开可读的作品(`module_game_distribution::game_distribution_collection_visible`)。下架 / 软删除的行在**投影时被跳过但不删除**,作品重新公开后自动回到列表。公开详情的 `collected` 由 `is_game_distribution_collected_and_return` 单独查询,不改动既有公开快照契约(`api-server` 在登录时把它并入公开详情负载,匿名不发该键)。
+- 迁移:随迁移导入导出(用户态事实),作品下架不删除行。`migration_tables!` 已登记该表。
+
+### `game_distribution_theme`
+
+- Rust 结构体:`GameDistributionTheme`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-06 新增。私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供投影。
+- 用途:平台 / 运营**命名**的「共创主题」——一棵(或一组)作品树的归组实体。主题不是作品的属性,而是运营叙事:作品在游戏 Tab 里仍作为独立作品展示,主题只是额外的归组维度。字段:`theme_id: String`(主键,形如 `theme-*`,由服务端签发、不接受客户端指定)、`name`(运营命名,不是根作品标题的派生)、`summary`、`badge`(角标短文本,本轮与 `name` 共同承担「封面」呈现职责,无封面图列)、`sort_order: i64`(成员排序权重)、`status: String`、`created_by_user_id: String`(创建者审计,不参与鉴权判定)、`created_at: Timestamp`、`updated_at: Timestamp`。
+- 状态口径:`status` 三态白名单 `draft` / `published` / `archived`(`module_game_distribution::GAME_DISTRIBUTION_THEME_STATUSES`);只有 `published` 进入公开投影(`game_distribution_theme_public_visible`),`draft` 与 `archived` 在公开列表 / 公开详情 / 作品详情 `themes` 里都不可见,后台仍可见全量。归档**不是删除**:行与成员行都保留,语义可逆。
+- 索引:`by_game_distribution_theme_created_at`。公开列表按创建时间倒序 + `theme_id` 升序兜底翻页(游标 `"{created_at_micros}:{theme_id}"`,默认 20 / 上限 50 / 超界截断 / 非法游标 400);`sort_order` 只用于主题内成员排序与后台列表展示,不参与公开列表排序。
+- 迁移:随迁移导出/导入(运营业务事实);归档只改变公开投影,不删除行。`migration_tables!` 已登记该表。
+- 读写路径(均已落地,2026-10-06):公开读列表 `list_game_distribution_themes_and_return`(事务 `list_game_distribution_themes_tx`)、公开详情 `get_game_distribution_theme_detail_and_return`(`get_game_distribution_theme_detail_tx`);后台写 `create_game_distribution_theme_and_return` / `update_game_distribution_theme_and_return`(`create_game_distribution_theme_tx` / `update_game_distribution_theme_tx`)与后台列表 `list_admin_game_distribution_themes_and_return`(`list_admin_game_distribution_themes_tx`)。私有表,只经 api-server 的受信服务身份调用,不经连接订阅 cache 或前端本地状态伪造。
+
+### `game_distribution_theme_member`
+
+- Rust 结构体:`GameDistributionThemeMember`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-06 新增。私有表。
+- 用途:「主题 ↔ 根作品」的运营归组事实。字段:`member_id: String`(主键)、`theme_id`、`root_game_id`、`sort_order: i64`、`created_at: Timestamp`。
+- 只允许**根作品**:根 = 该作品没有血缘行(第 0 代)。判定事实由调用方用血缘点查给出(`game_distribution_lineage().game_id().find(&game_id)` 命中即有行 ⇒ 非根),规则由纯函数 `game_distribution_theme_root_acceptable` 表达;非根写入被拒(`THEME_MEMBER_NOT_ROOT` 409)。理由:主题页呈现的作品树由 `/games/{id}/lineage` **按根**聚合,若允许子作品也入主题,同一棵树会在主题页里出现多次。
+- 防重:主键是确定性构造的 `{theme_id}:{root_game_id}`(`module_game_distribution::game_distribution_theme_member_id`),同一 (主题, 根) 在结构上不可能出现第二行——去重由主键约束本身承担,而不是「事务里先查后写」。因此**不需要**额外的 `(theme_id, root_game_id)` 唯一索引。跨主题的同一根会有**多行**(每主题一行),这是预期行为而不是重复数据。
+- 索引:`by_game_distribution_theme_member_theme_id`(主题页按主题取成员)、`by_game_distribution_theme_member_root_game_id`(作品详情按根反查所属公开主题,因此第 N 代作品也能看到自己的主题)。
+- 可见性:成员只出现在「作品公开未删且存在当前公开版本」时——判定**委托**收藏的同一条口径 `module_game_distribution::game_distribution_theme_member_visible`(内部即 `game_distribution_collection_visible`),不新写第二个同义判定。作品下架 / 软删除时该成员在**投影里跳过但不删除行**,作品重新公开后自动回到主题页;主题详情的 `roots` 与列表 / 详情的 `memberCount` 用**同一判定**(因此成员口径永不漂移),但**两个数在可见成员超过详情响应体积上限 50 时故意不相等**:`memberCount` 报真实可见成员数,`roots` 只回前 50 条,差额由详情响应的 `rootsTruncated` 表达(纯函数 `module_game_distribution::game_distribution_theme_roots_truncated`,与族谱 `truncated` 同约定)。
+- 排序:主题内成员按 `sort_order` 升序 + `member_id` 升序兜底(`sort_order` 允许重复,兜底键保证全序、翻页/渲染不重不漏);可见性过滤必须发生在排序切页之前。
+- 迁移:随迁移导出/导入(运营业务事实);归档主题与下架作品都不删除成员行。`migration_tables!` 已登记该表。
+- 读写路径(均已落地,2026-10-06):公开详情 `get_game_distribution_theme_detail_and_return`(成员投影在 `get_game_distribution_theme_detail_tx` 内按 `by_game_distribution_theme_member_theme_id` 取行)、作品详情 `themes` 增量 `list_game_distribution_theme_refs_for_root_and_return`(`list_game_distribution_theme_refs_for_root_tx`,按 `by_game_distribution_theme_member_root_game_id` 反查);后台写 `upsert_game_distribution_theme_member_and_return` / `remove_game_distribution_theme_member_and_return`(`upsert_game_distribution_theme_member_tx` / `remove_game_distribution_theme_member_tx`)。私有表,只经 api-server 的受信服务身份调用。
+
### `game_distribution_review`
- Rust 结构体:`GameDistributionReview`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供评价投影。
@@ -545,6 +588,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 冻结资料:版本表末尾追加可空 `metadata_json`,保存创建版本时由 api-server 校验(标题/简介/分类/标签/设备/方向/必需封面/≤6 张截图/买断制价格 `priceMudPoints`)并从创建请求确定 `coverObjectKey` / 截图 objectKey 后的资料快照;`approve_game_distribution_version_and_return` 通过审核时把该快照整体生效到游戏行,因此公开投影展示的始终是“已随版本审核通过”的资料与价格,旧版本(无快照)保持原值。**价格口径统一为「冻结资料缺 `priceMudPoints` 即免费(0)」**:待审列表、审核详情的版本价与审核通过后生效的游戏行价格都按 `0` 处理,不会回退到游戏行旧价。parse_game_distribution_frozen_metadata 会用 normalize_game_price_mud_points 校验价格上限,越界快照在审核时失败关闭。
- 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。冻结资料只含 `coverObjectKey` 与截图 objectKey 数组,作者续发时直接把这些 objectKey 放进 `metadata` 的沿用槽位,不需要为了沿用封面或截图重新上传;公开投影仍只暴露 objectKey,不含任何素材 ID。
- 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction` 由 `api-server` 按 `status` 派生,不落表。
+- 工程源包(M2b,2026-10-05):版本行末尾追加三个可空/零默认列 `project_bundle_object_key: Option`、`project_bundle_bytes: u64`(`0` = 未上传)、`project_bundle_sha256: Option`,保存作者**可选**上传的工程源码包(与发行包并列的第二份私有资产)。三列由 `confirm_game_distribution_project_bundle_and_return` 一次性写入(`upload_project_bundle` 幂等动作),只在版本尚未公开(`awaiting_upload` / `upload_failed`)时接受;同一版本已确认工程包后换内容按冲突拒绝,同内容按幂等重放。作品授权非 `forbidden` 时,工程包与发行包走同一道取件鉴权与校验,**平台不设独立的「源码可见性」开关**——作者不传即退化产物级改编。对象键只在服务端使用(投影里只回 `projectBundleBytes` / `projectBundleSha256`),且键名与发行包不同(`.project.zip`),避免同 (作品, 版本) 的两份资产互相覆盖或串缓存。
+- 核心改动说明(2026-10-06):版本行末尾再追加可空列 `change_summary: Option`(`#[default(None::)]`),保存**衍生作品**发布新版本时必填的「本次核心改动说明」(0 代母版恒为 `NULL`——传了也不校验、不落库)。它是**版本级**事实:同一作品的不同代版本各有各的说明,随版本冻结不可改。由 `create_game_distribution_version_and_return` 在**写库前**校验(失败关闭,不在库里留下没有说明的衍生版本),判据是**血缘行**(不接受客户端自称派生);长度按 trim 后的**字符**数计、区间 `20–500`(常量 `GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS` / `_MAX_CHARS`),违规走 `FORK_CHANGE_SUMMARY_REQUIRED` / `FORK_CHANGE_SUMMARY_INVALID`(均 400)。公开投影只多下发 `changeSummary`(衍生必有、母版 `null`),不含任何私有字段。
### 后台游戏管理读模型与恢复动作(2026-09-23)
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index ab4f9d5ce..af96c3dbd 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -100,12 +100,27 @@ npm run dev
npm run dev:all
```
-`npm run dev:all` 保持 `npm run dev` 的主站完整栈语义不变,使用 AGC 当前配套后端的数据库 `genarrative-game-creator-dev` 与 data dir `server-rs/.spacetimedb/ai-game-creator/data` 启动一份 SpacetimeDB、BgFilter worker、api-server、主站 Vite 和后台 Vite;待这五个服务就绪后,再启动 AGC Vite 与 Tauri。AGC 通过匹配的 `.app/dev-stack.json` 复用这份后端,且设置 `AGC_DEV_ADMIN_WEB=0`,因此一键入口只保留一份管理后台。该入口不接受覆盖 database 或 SpacetimeDB data dir 的参数;需要独立数据库时分别使用 `npm run dev` / `npm run agc`。
+`npm run dev:all` 保持 `npm run dev` 的主站完整栈语义不变,使用 AGC 当前配套后端的数据库 `genarrative-game-creator-dev` 与 data dir `server-rs/.spacetimedb/ai-game-creator/data` 启动一份 SpacetimeDB、BgFilter worker、api-server、主站 Vite 和后台 Vite;待这五个服务就绪后,再启动 AGC Vite 与 Tauri。AGC 通过匹配的 `.app/dev-stack.json` 复用这份后端,且设置 `AGC_DEV_ADMIN_WEB=0`,因此一键入口只保留一份管理后台。该入口不接受覆盖 database 或 SpacetimeDB data dir 的参数;需要独立数据库时分别使用 `npm run dev` / `npm run agc`。默认会**保留**这个本地库(等价于给根栈传 `--preserve-database`),因此当库内 schema 与源码不一致(SpacetimeDB 报 `needs manual migration` / `Reordering table …`)时 publish 会被拒、根栈随之退出——此时终端会补一段可操作提示(说明是本地 schema 冲突、打印目标库与 data dir),按提示用 `npm run dev:all -- --clear-database` 清掉该本地库重建,或先跑一次不带 `--preserve-database` 的 `npm run dev -- --database genarrative-game-creator-dev --spacetime-data-dir server-rs/.spacetimedb/ai-game-creator/data`;两种做法都会丢弃该本地库里的数据(仅本地开发数据)。
一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。AGC Vite marker 可访问后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.app/dev-stack.json` 为准。
通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。
+### 本地 e2e / 夹具必须用隔离实例(2026-10-07)
+
+**背景(实际事故)**:夹具类 e2e 脚本过去缺省跟着 `.app/dev-stack.json` 打**用户正在用的开发栈**,而「换端口」并不等于「换库」——`npm run dev` 与早期 pinned 端口那套都指向同一个 `genarrative-game-creator-dev` + 同一份 data dir。结果是跑几轮 e2e 就把夹具写进用户的库:实测盘点时该库有 **105 条作品 = 13 条演示 + 92 条夹具**(族谱 4 轮×9、主题 1 轮×54、收藏 2、工程源包 2、共创授权 2),而公开目录一次只出最新 48 条,用户的共创页几乎被夹具占满。
+
+**要求(这些是纪律,不是建议;本地夹具工具的路径不入库,团队按下面的做法自己准备工具即可)**:
+
+1. **缺省隔离**:任何会往库里写测试数据的脚本,未显式给目标地址时**必须**指向**隔离实例**(专用端口 + 专用库名 + 临时 data-dir),不得回落到 `.app/dev-stack.json` 里用户正在用的那套。
+2. **硬护栏**:脚本在启动阶段就要判定目标——解析出的 api-server 与 `.app/dev-stack.json` 记录的**同地址或同端口**即视为「用户开发栈」,**直接拒绝启动**(非零退出),除非调用方显式越权(`E2E_ALLOW_DEV_STACK=1` 之类)并在提示里写清后果。护栏只管**会写夹具**的脚本;只读工具可以照常读开发栈。
+3. **隔离实例的组成**:专用端口组(api / bgfilter / spacetime / admin-web / web 各一份)、**专用库名**(例如 `*-e2e`,与 `genarrative-game-creator-dev` 区分开)、**临时 data-dir**(跑完删除,另留 `--keep-*` 逃生口)。隔离实例还必须把自己的 dev-stack 标记写到临时目录(`GENARRATIVE_DEV_STACK_STATE_PATH`),**绝不覆盖仓库根的 `.app/dev-stack.json`**,也**不做**「按 exe 路径清旧 api-server」的兜底清扫——两者都会把用户正在跑的那套栈踢下线(2026-10-07 实测踩到并已修)。
+4. **夹具必须可辨识**:统一标题前缀(本仓口径 `E2E·`)+ 统一 tag,盘点/清理不依赖随机后缀;**演示数据**另有自己的前缀(`共创演示·`),两者不得混用。
+5. **跑批后自清理**:脚本正常结束时用本轮 owner 身份把自己造的作品逐条**软删**(owner 维度 `DELETE /api/game-distribution/my-games/{id}?expectedPublicationRevision=…`),并打印删除条数;清理失败或脚本中断时,必须打印**残留清单**(owner + gameId + 标题)便于人工补删。**跑完不留残留**是交付判据之一。
+6. **例外**:演示数据灌装工具**有意**写开发栈(它的用途就是把演示数据放进 dev 栈,且幂等、判重不绑账号),不受护栏约束;要写到别处请显式给目标地址。
+
+**软删的语义**:`DELETE …/my-games/{id}` 只是软删(撤销当前公开版本 + 写 `deleted_at`),公开目录、族谱树、`my-games` 立刻都看不到,但**行仍在库里**、可由后台恢复;已软删的行不会出现在任何列表接口里,所以「清理后列表为空」不等于「行被物理删除」。
+
单独启动主站前端:
```bash
diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md
new file mode 100644
index 000000000..a3d6fc96d
--- /dev/null
+++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md
@@ -0,0 +1,1214 @@
+# 【技术方案】游戏共创与作品 Fork
+
+更新时间:`2026-10-03`
+
+> 状态:`draft`(待评审)。评审通过前不写业务代码。
+> 本文件同时作为「游戏共创」的主规范与技术方案;行为合同部分不得绑定类名、文件名和实现算法。
+
+## 0. 结论摘要
+
+1. **Fork 关系挂在 `game`,不挂在 `version`。** `game` 是稳定作品身份,`version` 是不可变内容快照;血缘是「作品 ↔ 作品」关系。唯一留在版本级的是**被复刻的内容本身**(已存在的成品包与新增的工程源包都属于版本资产),因为内容随版本演进。
+2. **现状不存在任何 fork/remix/来源/父作品字段或表。** 旧的「作品改造 / Remix」在网页端已整体退役(`vite.config.ts:16-18,20-59,126-140,170-173` 把退役固化成构建门禁),后端只剩 `#[cfg(any())]` 死码(`module-runtime/src/domain.rs:137`、`api-server/src/state.rs:1317`、`shared-contracts/src/runtime.rs:879-880`)与 CSS 死类名。本功能是**从零建**,没有历史包袱,也不复活 Remix 命名。
+3. **必须区分两种 Fork,它们是两层能力而不是一个开关**:
+ - **成品包 Fork(零新增资产,但它只到「可玩参考」)**:每个已发布版本本来就存着构建产物 ZIP(`game_distribution_version.package_object_key`,`:797`),拿它铺成本地项目可以**试玩**、可以**提取素材**;但**不能重新发布**——包里没有 `package.json` 也没有源码,而 AGC 的导出/发布入口对「已有可玩入口」的项目同样强制 Phaser 4 + Vite 声明(`apps/ai-game-creator-shell/src-tauri/src/project/export.rs:262-265` → `:405-470`,注释与反例测试见 `:407-408`、`:1239-1243`)。详见 §3.5.1。
+ - **工程源包 Fork(真·复刻工程,也是唯一能直接发布的路径)**:成品包是 Vite 构建产物——模板工程 `vite.config.js` 只配了 `build: { outDir: 'dist' }`,**未关压缩也未开 sourcemap**,且平台发行校验明确拒收 `*.map`(`server-rs/crates/module-game-distribution/src/package.rs:171-175`)。所以 AI 在成品包上改核心逻辑不可靠;要让「改造完成后可发布」成立,必须有版本级可选资产**工程源包**。
+ - 两者共用同一套血缘模型;成品包路径是工程源包缺失时(网页端发布、作者不愿公开源码)的天然**降级路径,降级到「试玩 + 素材」,不含发布**。
+4. **工程源包有现成规范与现成客户端链路可复用**:排除规则见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`;下载→校验 SHA-256→解压→建项全链路已存在于 `apps/ai-game-creator-shell/src-tauri/src/template_library.rs`(`create_project_from_installed_template_at` `:816`)。
+5. **本期只做贡献归集与归因计算(§3.11),不含资金 / 分成结算**(口径留待产品决定;仓库无收益 / 分成 / 结算表,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算);**反洗稿(相似度校验)产品已决定搁置(2026-10-06,见 §2.2 与 §7)**;**不上链**。
+6. **顺带必修的相关缺口(已在 M1 修复)**:公开 `author.name` 长期落到兜底文案(创建游戏时 `author_name` 写死 `None`,`api-server/src/modules/game_distribution.rs:1001-1003`;公开 payload 兜底「创作者」)。修法:公开快照改为读时联 `user_account`,账号改名/换头像立即跟随,与后台游戏管理页同口径。
+
+---
+
+## 1. 现状事实(设计依据)
+
+### 1.1 现役「作品」只有一套:游戏分发
+
+| 形态 | 表 | 状态 |
+| --- | --- | --- |
+| 游戏分发(游戏广场) | `game_distribution_game` / `_version` / `_review` / `_review_moderation_log` / `_idempotency_receipt` | 现役 |
+| 编辑器精选素材 | `editor_showcase_asset` / `_like` | 现役,与作品 Fork 无关 |
+| 公开玩法作品(custom-world / puzzle / 大鱼 …) | `public_work_like` / `public_work_play_daily_stat` / `profile_played_world` + 各玩法源表 | **写入路径全部 `#[cfg(any())]` 死码**,前端路由与组件已删除 |
+
+现役路由唯一真相 `src/routing/activeAppPageRoutes.ts:7-18`,作品相关只有:
+
+```
+/games 游戏广场 src/components/game-distribution/GameGalleryPage.tsx
+/games/detail 作品详情 GameDetailPage.tsx
+/games/play 游玩(iframe) GamePlayPage.tsx
+/games/mine 我的作品 MyGamesPage.tsx
+/games/publish 发布 / 发布新版本 GamePublishPage.tsx
+```
+
+导航:桌面 rail 创作 / 项目 / 游戏 / 我的(`PlatformEntryActiveFlowShell.tsx:653-682`),移动 dock 游戏 / 我的(`:198-207`)。
+
+### 1.2 游戏 - 版本模型
+
+- `game_distribution_game`(`server-rs/crates/spacetime-module/src/game_distribution.rs:718-764`):`game_id` PK、`owner_user_id`、资料(标题/简介/分类/标签/封面/截图/设备/输入/朝向)、`publication_revision`(公开修订号 CAS)、`active_version_id`(当前公开版本指针)、`visibility`(`unpublished` / `published` / `suspended`)、`play_count`、`local_project_id`(同作者同本地项目复用身份,**不构成所有权证明**)、`cover_object_key`、`screenshots_json`。
+- `game_distribution_version`(`:766-821`):`version_id` PK、`game_id` 索引、`owner_user_id` 索引、`version_number`、包摘要(`package_sha256` / `package_bytes` / `package_file_count` / `package_entry_path`)、`status`、`package_object_key`、`package_manifest_json`、`entry_url`、审核人/阶段时间、`metadata_json`(该版本冻结的作者资料快照,审核通过时整体生效到 game)。
+- 版本状态:`awaiting_upload → uploaded → pending_review → published`,失败终态 `upload_failed / validation_failed / rejected / cancelled / revoked`(常量 `:868-877`,枚举 `module-game-distribution/src/domain.rs:42-53`)。
+- 公开性唯一口径:`visibility == published` 且 `active_version_id` 指向 `status == published` 的版本(`:3335-3346`)。
+- 公开读只有 3 条无鉴权路径:列表、详情、发行网关 `/api/game-distribution/releases/{gameId}[/{*asset}]`(`api-server/src/modules/game_distribution.rs:344-363`)。
+- 写操作全部经受信服务身份 procedure;幂等收据 30 天;所有公开切换走 `publication_revision` CAS。
+
+### 1.3 与 Fork 相关的既有能力与缺口
+
+| 项 | 现状 | 证据 |
+| --- | --- | --- |
+| 「能否被 fork」 | **不存在**(旧的 `remixEnabled` 是玩法级死配置) | `module-runtime/src/domain.rs:137`(`#[cfg(any())]`) |
+| 「fork 自谁」 | **不存在**。最近似的只有 `local_project_id` 身份复用,语义是同一作者自己的本地项目 | `game_distribution.rs:1700-1727` |
+| fork 计数 | **不存在**(`remixCount` 是 `#[cfg(any())]` 退役契约里的死字段,零消费方) | `shared-contracts/src/runtime.rs:922,950` |
+| 作者署名 | 字段存在但创建时写死 `None`,公开页兜底「创作者」 | `api-server/.../game_distribution.rs:1001-1003`、`:2390` |
+| 作者主页 / 关注 / 粉丝 | **不存在**(无任何社交关系表) | `migration.rs:149-200` 无相关表 |
+| 收藏 / 收录 | **已落地**(2026-10-06):`game_distribution_collection` 表 + `PUT/DELETE /api/game-distribution/games/{gameId}/collection`、`GET /api/game-distribution/my-collections`、公开详情 `collected` 字段,是**服务端真实投影**而非前端本地状态(`server-rs/crates/spacetime-module/src/game_distribution.rs`、`server-rs/crates/api-server/src/modules/game_distribution.rs`) | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md:74` 的「不要虚构收藏状态」条款照旧成立 |
+| 播放次数 | 本分支上 `play_count` 仍**从无递增路径**却被前端展示;PR #565 已新增游玩计数上报 procedure,合并后以 master 结果为准。**显示口径见 §3.6.3**:公开字段仍是自身值,界面上的「游玩次数」按子树合计显示(前端用已有树算,服务端零改动) | `game_distribution.rs:1759`(仅初值);`GameDetailPage.tsx:292` |
+| 收益 / 分成 | **不存在**;`PuzzleAuthorIncentiveClaim` 有枚举无写入方 | `module-runtime/src/domain.rs:1147` |
+| 从包建项(客户端) | **存在且完整**:下载→SHA-256 校验→解压(拒绝绝对路径/`..`/盘符/反斜杠/符号链接,条目 ≤4096、单文件 ≤256 MiB)→建项→失败删半成品 | `template_library.rs`、`:815-850`、`:240-342` |
+| 工程包排除规则 | **存在**:禁止 `.agent` / `.git` / `.svn` / `node_modules` 段与根 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` | `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`(后台模板上传章节)、`docs/【模板规范】AGC模板包组织指南-2026-09-21.md` |
+| 发行包下载通道(按版本取 ZIP) | **不存在**:作者侧只有 create/upload/chunk/complete/reset/submit/get_owner_version/cancel,作者侧 payload 不回对象键或下载地址;对外只有公开发行网关**逐文件**读取,且只服务**当前已公开版本**、扩展名受限 | `api-server/.../game_distribution.rs:3029-3041`、`:395-421`、`:800-863`;`module-game-distribution/src/release.rs:36-64` |
+| 导出/发布的工程栈前置 | **强制**:唯一导出入口对「已有可玩入口」的项目同样调用 `ensure_publish_project_stack`,要求 `package.json` 声明 phaser 主版本 4 且声明 vite 依赖或存在 `vite.config.*`(不要求 build 脚本);注释与反例测试均确认无 `package.json` 的静态项目不在发布合同内 | `apps/ai-game-creator-shell/src-tauri/src/project/export.rs:262-265`、`:405-470`、`:407-408`、`:1239-1243` |
+| 发行包内容 | **只含运行产物**:`dist` 树(条目改名 `game/...`)+ 根 `assets/` 兜底 + `exports/README.md`;**不含** `package.json` 与源码 | `apps/ai-game-creator-shell/src-tauri/src/project/export.rs:770-804` |
+
+---
+
+## 2. 产品设计
+
+### 2.1 目标
+
+让平台上的游戏可以被他人**合法地继续改造**,并让改造形成的世代链路可展示、可追溯、原作者可署名。
+
+### 2.2 非目标
+
+- **本期只做贡献归集与归因计算**(§3.11:递归「子代所有的都算父代的」+ 按代际 / 直接子代归因分解),**不做**资金 / 分成 / 版税结算与算力成本核算——无账本、无结算周期、无金额口径,属独立议题;资金与分成口径留待产品决定。
+- **反洗稿(相似度校验)产品已决定搁置(2026-10-06)**:不是没想到,是明确不做——无判定标准,且判定只能基于工程源包做结构化比对,属独立议题;本方案只保证**来源声明不可伪造**(声明只能来自真实取件,见 §3.5.3)。
+- 不做上链;「永久溯源」实现为**不可变的父子链 + 平台持久化事实**。
+- 不做跨作品类型 Fork(现役只有一种作品:游戏分发)。
+- 不做关注 / 粉丝。收藏(收录)原先也在这个「不做」列表里,但**已纳入并落地**(2026-10-06,见 §2.4 / §3.4):它是服务端真实用户态投影(`game_distribution_collection`),不是前端本地状态,因此仍满足「不要虚构收藏状态」这条既有发行合同。
+
+### 2.3 授权模式(作者侧,作品级)
+
+档位**只写在作品行上**,两种作品的规则不同:
+
+- **母版(0 代作品,没有血缘行)**:作者上架时可选,之后**只能单向提升开放度**(可跳级),请求必须带 `expectedForkAuthorization`(CAS);降级一律拒绝。
+- **衍生作品(有血缘行)**:档位在创建时**继承父作品当时的档位**,是**终态**——作者既不能收窄(入口整体不做),也不能提升:任何 `PUT …/fork-authorization`(含传同值这种幂等重试)一律 **409 `FORK_AUTHORIZATION_INHERITED`**。若放行,作者就能把继承来的 `nonCommercial` 提成 `full`,得到一个**比祖先更宽**的子作品,父作品在非商用授权下公开的工程内容会因此变成可用商用。
+
+| 值 | 用户可见文案 | 含义 |
+| --- | --- | --- |
+| `forbidden`(母版默认) | 禁止共创 | 仅可游玩,不可复刻改编 |
+| `nonCommercial` | 允许非商用共创 | 可二次开发,禁止盈利,仅可公开分享 |
+| `full` | 允许全开放共创 | 可改编、可商用、可引流、可发布新版本 |
+
+状态机(唯一合法迁移;**提升只发生在母版行上**,衍生作品行一旦由创建写入即不再变化):
+
+```mermaid
+stateDiagram-v2
+ [*] --> forbidden : 母版创建(请求缺省)
+ [*] --> nonCommercial : 衍生作品继承父档位(终态)
+ [*] --> full : 衍生作品继承父档位(终态)
+ forbidden --> nonCommercial : 母版作者提升
+ forbidden --> full : 母版作者跳级提升
+ nonCommercial --> full : 母版作者提升
+ full --> [*] : 终态(不可降级)
+```
+
+约束:
+- **衍生作品的档位 = 创建时快照的父作品档位**(2026-10-06 产品决定):创建请求里的 `forkAuthorization` 对衍生作品**一律被忽略且不报错**(旧客户端会惯常带自己的默认档位);父作品之后提升档位**不会回溯**改写既有子作品的行(提升只改被提升的那一行)。
+- **衍生作品之下没有第二条迁移**(2026-10-06 产品决定,取代「继承的行上还能再提升」的读法):档位是终态,`PUT` 一律 409 `FORK_AUTHORIZATION_INHERITED`;判定取**血缘行**(有父即衍生),不接受客户端自称,且**先于 CAS**——同一个衍生作品不会因为载荷不同(传同值 / 传新值 / 期望值过期)而回不同的码。母版(无血缘行)的语义完全不变。
+- **衍生作品不会继承到 `forbidden`**:父作品为 `forbidden` 时根本不允许共创(`403 FORK_NOT_AUTHORIZED`),建立不了血缘,因此**产生不出**衍生作品;继承到的档位只可能是 `nonCommercial` 或 `full`。
+- **不做收窄入口**:衍生作品不能选择比父作品更窄的授权(产品决定 2026-10-06,「可选择收窄授权」已从需求中划掉)——「收窄」这条路径整体不实现。
+- 只有 `owner_user_id` 可以变更(非作者 403);**母版**的提升请求必须带 `expectedForkAuthorization`(CAS),不匹配返回 `409`。
+- **母版**的任何降级请求一律 `409 FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED`,不写库。
+- 已按旧授权完成的 Fork **不受后续提升影响**(授权在建立血缘时已兑现)。
+- **提升与源码的时序**:工程源包只能在版本**尚未公开**时随版本上传(阶段门与发行包确认一致);作品公开后**不接受**补传(本期不做 backfill),因此「先禁止共创、后提升授权」的作品在其当前公开版本上不会被源码级改造,只能走「试玩 + 素材参考」。提升界面对此如实提示,不作为提升的前置条件。
+
+### 2.4 用户视角入口矩阵
+
+**玩家 / 未登录**
+
+| 位置 | 内容 |
+| --- | --- |
+| `/games` 广场卡片 | `forkedFrom` 存在时显示「改编」角标;不做父子聚合,卡片仍独立展示 |
+| `/games/detail` 详情页 | ① 授权徽章(禁止共创 / 允许非商用共创 / 允许全开放共创);② 有父作品时显示溯源卡「改编自《X》· 由 Y 制作」+ 可点进父作品;③ 显示「第 N 代作品」与「N 个衍生作品」;④ `forkAuthorization != forbidden` 时显示主行动作「改造这个作品」(未登录点击走既有登录门禁) |
+| `/games/detail` 族谱入口 | 「查看创作族谱」→ `/games/lineage?id=` |
+| `/games/lineage`(新页面) | 以根作品为顶的树:根节点、各代分支、每代作品卡(封面 / 标题 / 作者 / 第 N 代 / 游玩数),点击进入详情;父作品已下架时该节点显示「原作品已下架」但仍可点;空态与加载失败按现有平台错误组件。**累计世代数(「这棵树往下还有几代」)由前端自算**:本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),服务端**不提供**该字段,前端从同一次 `/lineage` 响应自算,不额外发请求。右侧面板展示选中节点的「本次核心改动说明」时,**只对选中节点请求一次公开详情**取 `currentVersion.changeSummary`(衍生作品为字符串、母版为 `null`)——不做 N+1、不给族谱节点贴该字段(详见 §3.4 的 `/lineage` 行)。两条前端口径均已落地(`bd73acfcc`:世代数按子树深度计算、面板读版本摘要层) |
+
+**作者 / 已登录**
+
+| 位置 | 内容 |
+| --- | --- |
+| `/games/mine` 每张作品卡 | 新增「共创授权」三态设置(**只有母版**有该入口:仅允许提升,终态 `full` 时只读;**衍生作品的档位由父作品继承、是终态**,不提供该入口——入口隐藏已落地(`4f888d8c9`:网页端改为只读展示「继承自父作品」),服务端即使收到 `PUT` 也一律 409 `FORK_AUTHORIZATION_INHERITED`);新增「被改编 N」入口,弹层列出直接子代(标题 / 作者 / 代际 / 状态);提升授权后若当前公开版本没有工程源包,行内提示「上传工程源码以支持源码级改造」(上传在桌面端客户端完成,网页端只做引导) |
+| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」**且档位由服务端继承父作品当时的档位**——该场景下客户端所选档位不生效(不报错,见 §2.3),且**该作品从此没有「修改授权」入口**(终态);AGC 发布面板同样只读展示「授权继承自父作品」(同轮改动) |
+| AGC 客户端 | ① 侧边栏「共创」页列出公开目录里 `forkAuthorization != forbidden` 的作品(卡片:封面 / 标题 / 作者 / 类型 / 创建时间),点卡片「开始共创」→ 确认页(标题即「开始共创」);② `genarrative://fork?gameId=` deep link **已注册**(原生解析 + 聚焦主窗口),带 `gameId` 时**直接落到确认页**、不带时回到「共创」页并如实展示链接原因;③ 手动粘贴作品 ID 的输入块已**移除**(重复入口,口径与理由见 §3.6.2) |
+| `/games/detail` 详情页 | 新增「收藏」按钮(收藏 / 已收藏两态)。初始态取自公开详情的 `collected`(登录才有该字段);点击后 `PUT` / `DELETE /api/game-distribution/games/{gameId}/collection`,按钮态改用**响应里的权威投影值**(不写本地乐观状态——前端本地状态正是本功能要消灭的东西)。未公开 / 已软删除的作品返回 409,按钮按失败态提示 |
+| `/games/mine`(或「我的」入口)| 新增「我的收藏(收录)」列表:`GET /api/game-distribution/my-collections`,形状与广场一致(封面 / 标题 / 作者 / 游玩数),点击进详情。列表只含**当前公开可读**的作品;已下架作品的收藏行保留,作品重新公开后自动回来(不需要用户重新收藏) |
+
+**后台**
+
+| 位置 | 内容 |
+| --- | --- |
+| `#game-management` 游戏管理 | 列表新增「授权」「代际」列;版本历史弹层不变;不做强制改授权(避免平台替作者背授权责任) |
+| `#game-distribution` 审核队列 | 允许共创的作品审核通过时,若作者未提供工程源包,标注「仅成品包可玩参考,不能直接发布」(不影响发布与审核;口径依据 §3.5.1) |
+
+### 2.5 两个层级的改造能力(必须让作者和用户都看懂)
+
+已公开的作品**总是**有成品包,所以「改造这个作品」按钮对所有开放授权的作品都可用;区别不只是「能改到什么程度」,还包括**改完能不能再发布**——这一点在 2026-10-04 的只读侦察里被否证过一次,必须按事实写:
+
+| 情形 | 用户拿到什么 | 能不能再发布 | 用户可见文案 |
+| --- | --- | --- | --- |
+| 父作品有**工程源包**(AGC 发布且作者选择公开源码) | **源码级复刻**:AGC 下载工程 → 解压为新项目 → 可改源码、可重跑构建、可发布 | 能(前提是源包本身是 Phaser 4 + Vite 工程) | 「改造这个作品」 |
+| 父作品只有**成品包**(网页端发布,或作者未上传工程源包) | **可玩参考 + 素材来源 + 合规工程骨架**:AGC 先把该发行版本的成品包铺进项目**预览根**(`game/dist`),fork 完即可运行,并可提取包内素材(受发行网关扩展名白名单限制,见 §3.5.1) | **能**(2026-10-07 起):AGC 建项同时生成 Phaser 4 + Vite 合规脚手架并登记「可运行原型」,因此发布入口的「已有可玩入口」分支成立;发布内容是当前预览根——**作者没改就是取到的那份成品产物**(平台仍要求衍生作品填「本次核心改动说明」)。**不能**的是「在取到的成品上重建核心逻辑」:包里没有源码(`export.rs:770-804`) | 「试玩并参考改编」;面板如实写明「本作品只提供已构建成品,**没有源码**:可直接运行与发布,但核心逻辑要在自建工程里重做」 |
+
+两条路径在**用户自建合规工程**里发布时写入**完全相同**的血缘与溯源;不设「作者必须开源才能被改造」的前置条件。
+
+**被否证与被更正的旧表述(保留以便对照)**:原方案写的是「成品包路径 = 产物级改造,项目无需构建步骤即可试玩与发布」、「这条形态 AGC 本来就支持」。2026-10-04 的只读侦察把「发布」判成不成立——依据是当时假设的落点(成品铺在项目里、项目没有 `package.json`,过不了 `ensure_publish_project_stack`)。2026-10-07 落地的 M2b 建项改成「**先建合规脚手架、再把成品铺进预览根**」,同时把「可运行原型」登记进 manifest,于是「试玩」与「发布」**都**成立(事实依据见 §3.5.1「AGC 建项」小节与 §5.8 证据)。真正的能力边界只剩一条:成品包里没有源码,核心逻辑要在自建工程里重做。
+
+### 2.6 关键流程
+
+**A. 作者发起共创**
+
+```mermaid
+sequenceDiagram
+ participant A as 作者(AGC/网页)
+ participant API as api-server
+ A->>API: 发布游戏/版本,携带 forkAuthorization
+ API->>API: 母版:校验三态合法并写入 game.fork_authorization;衍生作品:忽略该值,继承父作品当时的档位
+ A->>API: (AGC 且授权非 forbidden)上传工程源包
+ API->>API: 校验 zip(复用模板包门禁)→ OSS → version.project_bundle_*
+ Note over A,API: 审核通过后作品公开,且可被改造
+ A->>API: 后续修改授权(PUT /games/{gameId}/fork-authorization,仅母版、只升不降;衍生作品一律 409)
+```
+
+**B. 用户一键改造(目标形态;成品包分支只到「试玩 + 素材」)**
+
+```mermaid
+sequenceDiagram
+ participant U as 用户(浏览器)
+ participant AGC as AGC 客户端
+ participant API as api-server
+ U->>U: 详情页点「改造这个作品」
+ U->>AGC: 唤起客户端(方式待拍板:手工输入 / 取件码 / deep link)
+ AGC->>API: GET /api/game-distribution/games/X/fork-source (Bearer)
+ API-->>AGC: {versionId, bundleSha256, bundleBytes, downloadPath}
+ Note over API: 图中的字段名是 M2b 目标形状;M2a 已实现的形状见 §3.4「登录用户」表
+ AGC->>AGC: 下载 → 校验 SHA-256 → 解压(同模板安装门禁)→ 新建项目
+ AGC->>AGC: manifest 写入 forkedFrom{gameId, versionId, rootGameId, generation}
+ Note over AGC: 工程源包可改源码/重跑构建/发布;只有成品包则只能试玩与提取素材(§3.5.1)
+ AGC->>API: POST /games(携带 forkedFromGameId/VersionId)
+ API->>API: 校验授权/来源版本/计算 generation 与 root → 写 lineage
+```
+
+> 说明:本图是**目标形态**。唤起方式已定:**deep link `genarrative://fork?gameId=` 已注册并落地**(原生解析 + 单实例聚焦,带 `gameId` 直接落到 AGC 的「开始共创」确认页,见 §3.6.2;§7 第 10 条的其余候选不再需要);`GET …/fork-source` **M2a 已实现**(受鉴权元数据 + 取件本体,实际字段与状态码见 §3.4「登录用户」表),但本图里的响应字段名是 M2b 的目标形状,定稿一律以 §3.4 表格为准。
+
+**C. 溯源与命名**
+
+- 溯源卡出现在详情页与 AGC 项目内(项目信息区显示「改编自《X》」)。
+- 代际:根作品 = 第 0 代;直接改编根作品 = 第 1 代;`generation = parent.generation + 1`。
+- 「衍生作品数」= lineage 表按 `parent_game_id` 的计数(实时算,与现有评分实时聚合同口径;不新增物化计数)。
+
+---
+
+## 3. 技术方案
+
+### 3.1 分层与边界
+
+```text
+src/(网页) 详情页/我的作品/族谱页 ←→ /api/game-distribution/**
+apps/ai-game-creator-shell(AGC)
+ React 展示层 发布面板、改造入口
+ Rust facade 工程包打包/上传/下载/解压/建项(复用 template_library 基座)
+api-server 鉴权、灰度、OSS 编排、ZIP 校验、payload 组装
+spacetime-client typed facade / mapper
+spacetime-module 表、procedure、事务
+module-game-distribution 领域规则(授权状态机、代际计算、血缘校验)
+shared-contracts Rust DTO ←→ packages/shared TS DTO
+```
+
+不新增数据库访问通道;不新增第二套作品系统。
+
+### 3.2 数据模型
+
+#### 3.2.1 `game_distribution_game` 追加 1 列(表尾,带默认)
+
+```rust
+/// 共创授权等级:forbidden(默认)/ nonCommercial / full。只允许单向提升;不改变不写库。
+#[default("forbidden".to_string())]
+pub(crate) fork_authorization: String,
+```
+
+旧行反序列化自动补 `"forbidden"`,与「默认禁止共创」语义一致。写入有两个来源:**母版**按创建请求的档位(缺省 `forbidden`);**衍生作品**在创建时继承父作品当时的档位(创建时快照,见 §2.3)。
+
+#### 3.2.2 新增 `game_distribution_lineage`(父子血缘,1:1)
+
+```rust
+#[spacetimedb::table(
+ accessor = game_distribution_lineage,
+ index(accessor = by_game_distribution_lineage_parent, btree(columns = [parent_game_id])),
+ index(accessor = by_game_distribution_lineage_root, btree(columns = [root_game_id])),
+ index(accessor = by_game_distribution_lineage_owner, btree(columns = [owner_user_id])),
+)]
+pub struct GameDistributionLineage {
+ /// 子作品;主键即 1:1 约束,一个作品只能有一个父。
+ #[primary_key]
+ pub(crate) game_id: String,
+ /// 子作品作者,冗余用于「我改编过的作品」列表,不参与授权判定。
+ pub(crate) owner_user_id: String,
+ pub(crate) parent_game_id: String,
+ /// 建立血缘时父作品的当前公开版本(不可变溯源事实)。
+ pub(crate) parent_version_id: String,
+ /// 0 代母版;根作品自身不写行,读侧以「无行」判定为根。
+ pub(crate) root_game_id: String,
+ /// 代际;直接改编根作品为 1。
+ pub(crate) generation: u32,
+ pub(crate) created_at: Timestamp,
+}
+```
+
+为什么用独立表而不是在 game 上再加 3 列:
+- 血缘查询必须按 `parent` / `root` 建索引,而这两列在 game 上只能是 `Option`;本仓从未验证过对 `Option` 列建 btree 索引的行为,不拿 schema 赌。
+- 血缘是 1:1 关系实体,主键承载「一个作品只有一个父」这条不变量。
+- 不污染 game 主行读路径(详情页绝大多数请求不需要血缘行)。
+- 事务一致性:创建 game 与插入 lineage 必须同事务,插入失败整笔回滚。
+
+#### 3.2.3 `game_distribution_version` 追加 3 列(表尾,带默认)
+
+```rust
+/// 该版本随包上传的工程源包对象键(不含 node_modules/.git/dist 等,规则见模板包组织指南)。
+#[default(None::)]
+pub(crate) project_bundle_object_key: Option,
+#[default(0u64)]
+pub(crate) project_bundle_bytes: u64,
+#[default(None::)]
+pub(crate) project_bundle_sha256: Option,
+```
+
+工程包跟随版本,**只在版本尚未公开时可上传一次**(已实现口径,2026-10-05):
+
+- **上传时机**:只允许版本处于 `awaiting_upload` / `upload_failed`(与发行包确认同一道阶段门);`uploaded`、验证中、待审核、已拒绝、已公开、已撤回、已取消一律拒绝。工程包是**可选**资产:不上传不影响发布与审核。
+- **不可变**:同一版本一旦确认过工程包,**换内容按冲突拒绝**(`409 同一版本已存在不同的工程源包`),同内容按幂等重放(`replayed = true`);要改内容只能新建版本。
+- **不做版本回溯**:作者 v1 传了、v2 没传,那么 v2 只能走「试玩 + 素材参考」的成品包路径,历史版本不给补。
+- **已公开版本的补传(backfill)未实现**:原方案曾设想「作品公开后可给当前公开版本补传一次」。本期**不做**——上传阶段门与发行包一致,公开后不接受任何内容写入;若产品要这条,需要单独设计(它会是「版本不可变」的例外)。
+- **产品口径(已拍板)**:平台**不做**独立的「源码可见性」开关。作品授权非 `forbidden` 时,工程包与成品包走同一道取件鉴权与校验被他人取走;作者不想给源码,就是**不上传**。「上传与否」本身就是作者的开关,不传即退化为产物级改编。三态授权保留不变,但 `nonCommercial` 的约束力是**平台规则层面**(源码一旦被取走,无法从技术上阻止商用),文档与界面必须如实标注这一点。
+
+对象键:`{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.project.zip`(即 `agc/project-snapshots/v1/game-distribution/{game_id}/{version_id}.project.zip`)——与发行包同前缀族便于生命周期统一,靠 `.project.zip` 后缀区分资产,避免同一(作品, 版本)的两份资产互相覆盖或串用读取缓存。对象键只在服务端使用,投影里只回 `projectBundleBytes` / `projectBundleSha256`。
+
+- **知情同意(2026-10-05 复核后新增,必须落到面板文案)**:校验清单是**黑名单**——作者工程里的 `docs/`、`*.pdf`、`notes.txt`、截图、素材源文件等**不在任何拒绝集内,会被原样外发**。因此发布面板必须写明「**整个项目目录(除少数排除项)会原样公开**」,让作者自己决定要不要把某个文件留在工程里。这不是技术漏洞,而是知情同意要求;技术侧能保证的是「凭据类内容尽量被拦」,不能保证「除源码外什么都不外发」。
+
+### 3.3 状态与流转
+
+| 对象 | 字段 | 取值 | 合法迁移 | 触发方 | 并发控制 |
+| --- | --- | --- | --- | --- | --- |
+| game | `fork_authorization` | `forbidden` / `nonCommercial` / `full` | **母版**:只升不降(可跳级);**衍生作品(有血缘行)**:终态,任何 `PUT` 都拒(409 `FORK_AUTHORIZATION_INHERITED`,含传同值) | owner | `expectedForkAuthorization` 值 CAS(衍生作品的拒绝先于 CAS,因此码不随载荷变化) |
+| lineage | 全字段 | 创建即不可变 | 无 | 创建 game 时 | 主键冲突 → 409 |
+| version | `project_bundle_*` | 有 / 无 | 只在 `awaiting_upload` / `upload_failed` 时可写一次(未公开前);写入后不可再改,换内容 → 409 | owner | 与 `confirm_package` 同一幂等键族 |
+
+**血缘建立的服务端校验(全部失败关闭)**:
+
+1. `forkedFromGameId` 必须存在 → 否则 `409 FORK_SOURCE_NOT_FOUND`。
+2. 父游戏 `visibility == published`、`deleted_at` 为空,且存在 `status == published` 的当前版本 → 否则 `409 FORK_SOURCE_NOT_AVAILABLE`(下架 / 封禁 / 已软删除作品不可被新 fork,但不影响既有子作品)。
+3. 父游戏 `fork_authorization != forbidden` → 否则 `403 FORK_NOT_AUTHORIZED`。
+4. `forkedFromVersionId` 必须等于父游戏当前公开版本 id → 否则 `409 FORK_SOURCE_VERSION_MISMATCH`(禁止指向历史版本或伪造)。
+5. 同一 `localProjectId` 复用既有 game 的场景**不允许**携带血缘(避免把既有作品改判成衍生作品)→ `409 FORK_DECLARATION_ON_EXISTING_GAME`。
+6. `generation = parent.generation + 1`;`root_game_id = parent 有血缘行 ? parent.root_game_id : parent.game_id`。服务端计算,不接受客户端传入。
+
+### 3.4 接口契约
+
+所有新增路径沿用 `/api/game-distribution` 命名空间与平台 envelope;作者写路由叠加 Bearer + 发布灰度;读路径 `Cache-Control: no-store`。
+
+#### 公开(匿名可读)
+
+| 方法 / 路径 | 说明 |
+| --- | --- |
+| `GET /games?search=&category=&authorId=&limit=&cursor=&forkable=`(**既有,2026-10-07 补真分页与 `forkable` 过滤**) | 公开游戏目录(游戏广场)。匿名可读 + `no-store`;只含公开可玩的作品(`published` + 未软删除 + 有当前公开版本)。**真游标分页**:`limit` 缺省 **48**(保持既有首屏语义,AGC 共创页首屏就是 48 条)、上限 **100**(约束单响应体量),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{gameId}"`(与 `/my-collections`、主题列表同一套 `"{micros}:{id}"` 惯例,解析只切第一个冒号);**游标格式非法 → 400 `CATALOG_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页)。响应 `{ games: [<公开作品投影>], nextCursor }`;排序 `createdAt` 倒序 + `gameId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」。**`forkable`(2026-10-07 新增,供 AGC「共创」页)**:真值(`1` / `true`,**大小写不敏感、先 trim**)时只保留**可被共创**的作品(`forkAuthorization != forbidden`);**省略 / 空 / `0` / `false` / 非法取值(如 `abc`)一律按「不过滤」处理**——非法取值**宽容降级**而不是 400,理由:这是公开只读列表,`forkable` 只是可选的展示过滤,客户端(尤其旧版外壳)拼错一个可选参数不该让整个游戏广场 400;而「不过滤」正好等于本参数出现之前的既有行为,降级既不会多列(相对现状只会少列)也不改变任何既有调用方的语义。过滤**在事务内、切页之前**完成(与可见性过滤同一处、同一序,api-server **不**过滤返回结果),因此禁止共创的作品**不占页名额**、翻页不重不漏、末页 `nextCursor` 仍为 `null`,分页 / 游标语义与不带该参数时逐字相同;**未知档位按禁止解释**(与 `/fork-source` 的 403 判据同一口径,避免列出「点进去必然失败」的入口)。响应形状**不变**(`forkAuthorization` 本来就在公开作品投影里)。**修正的问题**:此前 `limit` 写死 48 且 `nextCursor` 恒为 `null`,库里第 49 条起的作品(含最老的母版与主干)在 HTTP 面永久不可见(客户端只能显示「已检查最新 48 个作品」)——数据一直在、详情与 `/lineage` 都读得到,缺的只是翻页通道。**网页侧已接(2026-10-07)**:`listGames()` 返回 `{ games, nextCursor }` 并支持 `limit` / `cursor` 透传(不再丢弃游标、不再假装「一次拿全」);游戏广场与创作者主页都在 `nextCursor` 非空时渲染「加载更多」,点击用服务端游标取下一页、**按 `gameId` 去重追加**、加载中禁用、**失败保留已加载内容**并给可读提示、**末页(`nextCursor = null`)自动隐藏入口**。真机实测(真实栈 51 条 published):首屏 48 条 → 加载更多 → 51 条,最老的 `共创演示·A0/A1/A11` 可见 |
+| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段。**另追加 `collected`(仅登录用户,2026-10-06)**:登录已认证时返回 `true` / `false`(真实投影,值来自 `is_game_distribution_collected_and_return`);**匿名请求不返回该字段**(也不发 `false`——`false` 会把「未登录」说成「没收藏」)。该字段随请求者变化,所以这条路径必须 `no-store`、不得有任何共享缓存。**再追加 `themes: [{ themeId, name, badge }]`(2026-10-06 增量,共创主题)**:该作品所属的**公开**主题(只含 `status == published`);**匿名与登录都发、无主题时恒发空数组**(与「仅登录才发」的 `collected` 不同——没有主题与没登录因此不会被混成同一种缺键)。口径是「先取该作品的**根**、再按根反查公开主题」,所以**第 N 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取)**。**版本摘要 `currentVersion` 再追加 `changeSummary`(2026-10-06,衍生作品发布必填项)**:该版本相对**改编来源**作品的核心改动说明——衍生作品必有(`20–500` 个字符,详见下方作者路由的 `POST /versions` 行),0 代母版为 **`null`**(发键不发值:客户端不必靠缺键猜),因此详情页与族谱溯源能逐代展示「这一版改了什么」。它不含任何私有字段(没有对象键 / 素材 id),匿名可读,也不需要按查看者变化 |
+| `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status, coverObjectKey }`(`coverObjectKey` 为该节点作品**当前生效的封面对象键**,与公开目录 / 详情**同源同口径**、都来自游戏行 `cover_object_key`,**无封面时为 `null`**;只发对象键,不带 `coverAssetId` 等私有 id),按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点。**「累计世代数」不是服务端字段**:本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),前端从同一次响应的 `generation` 自算,服务端不新增字段也不为此多查一次。**右侧面板的「本次核心改动说明」不贴到节点上**:`LineageNode` 保持现在的字段集(不加 `changeSummary`),面板只对**选中节点**请求一次公开详情取 `currentVersion.changeSummary`(衍生作品为字符串、母版为 `null`,见上方公开详情行)——即**不做 N+1**:不是每渲染一个节点就查一次详情(两条前端口径已落地:`bd73acfcc`) |
+| `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 |
+| 公开作品投影(`public_game_payload`,**2026-10-07 增量**) | **已有** `forkCount` = **被改编次数(直接子代数量)**,公开目录与公开详情都发。**新增(2026-10-07 定义定稿 + **已上线**(提交 `ffd751014`))** `coCreationCount` = **「共创次数」= 该作品子树里公开可见的全部后代数(不含自己)**——网页端「共创」页卡片指标位的「被共创 N 次」优先读它、缺省时回退 `forkCount`(见 §3.6.1 的卡片条目)。**两个计数的口径(不可互相推导)**:`forkCount` 只数**直接子代**(「被改编 N」);`coCreationCount` 数**整棵子树**的可见后代(「共创 N」),同一张卡上后者的数字只会 ≥ 前者。两者共用同一批可见性规则(`module_game_distribution::counts_as_public_derivative`:**未软删除 + 已公开**),只是深度不同;已删 / 未公开的后代**不进任何一层计数**(中间作品不可见也不断链:它可见的后代仍算进祖先的后代数,与族谱「父被排除时孩子照常出现」同一口径);血缘环 / 自引用按 `visited` 去重,**自己永远不计数**。`scripts/check-game-distribution-dto-parity.mjs` 已把 `forkCount`、`coCreationCount` **都**钉成「公开 payload 必须发」的字段(`mustEmit`),因此**可依赖**;**没有后代时发 `0` 而不是缺键**(缺键会被读成旧响应而触发回退)。**作者侧同形响应不发 `coCreationCount`**(与 `currentVersion` / `ratingSummary` 同类:它只是公开投影的字段),所以读取方仍需按 `coCreationCount ?? forkCount` 取值。**算法与成本(事务侧,2026-10-07 定稿)**:公开目录事务本来就要全表扫 `game_distribution_game` 做过滤 / 排序 / 切页,因此在该**只读事务里先建一次**「父 → 直接子代」血缘索引 + 一次可见性集合(`PublicDerivativeIndex`),再对**本页每一行**做一次 BFS 子树计数(`visited` 去重,边际成本 O(后代数)~O(作品数));索引**每个事务只建一次**(公开目录 / 公开详情 / 我的收藏 / 主题成员各自建一条),**不新增表 / 列 / 索引、不落物化计数**(避免与下架 / 封禁 / 删除状态产生第二份真相),**api-server 只发键、不遍历**。证据:`module-game-distribution` 的 `PublicDerivativeIndex` 纯函数单测(根 + 3 直接子代 + 1 孙代 ⇒ `forkCount == 3`、`coCreationCount == 4`;无子代 ⇒ 两键皆 `0` 且恒发;软删 / 未公开后代不计数;自引用与血缘环安全)+ api-server 的 payload 测试 + 本 parity 脚本。**运行栈实测(2026-10-07,`GET /games?limit=100&forkable=1`)**:45 条**全部**带该键,分布 `0×25 / 1×9 / 2×5 / 3×1 / 5×4 / 7×1`;且「富树分支 B1 258237」`forkCount=1` 而 `coCreationCount=2`(1 直接子代 + 1 孙代)⇒ 两键口径确实不同,前端指标位显示的是**后者**。 |
+| `GET /api/game-distribution/themes?limit=&cursor=`(新,2026-10-06) | 公开共创主题列表。**匿名可读 + `no-store`**,只含 `status == published` 的主题。响应 `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`;`memberCount` = 该主题**真实可见成员数**(不截断、不套响应体积上限,也不是成员行总数)。它与详情 `roots` 的长度在可见成员超过 50 时**故意不相等**——「展示上限」不该污染「这个主题有多少棵树」,判定口径仍是同一份。分页沿用 `/my-collections` 那套游标惯例:`limit` 缺省 **20**、上限 **50**、超界**截断**(客户端拿到一个完整页,而不是需要重试的错误);`cursor` 形如 `"{createdAtMicros}:{themeId}"`(解析只切第一个冒号);**非法游标 → 400 `THEME_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页);`nextCursor` 为真实值,**末页为 `null`**。排序 `created_at` 倒序 + `themeId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」 |
+| `GET /api/game-distribution/themes/{themeId}`(新,2026-10-06) | 公开主题详情。**匿名可读 + `no-store`**;顶层**扁平** `{ themeId, name, summary, badge, memberCount, roots, rootsTruncated }`(与 `GET /games/{gameId}` 同形,不引入第二套包装)。`roots` 逐条是公开目录**同一份** `public_game_payload` 投影,按 `sort_order` 升序 + `member_id` 升序稳定排序(不泄露对象键、不泄露未公开作品)。主题**不存在 / `draft` / `archived` 一律 404**(模块侧回同一句「主题不存在」,由既有映射落 404;**不返回空壳**、公开侧不发 `THEME_*` 码);**已发布但可见成员为空 = 200 + 空 `roots`**(空态而不是错误)。**成员数口径已定(2026-10-06)**:`roots` 受响应体积上限 **50** 约束(排序后取前 50 条),`memberCount` 报**真实可见成员数**(不截断),`rootsTruncated` 仅当 `roots` 少于 `memberCount` 时为 `true`——即「下面这份 `roots` 被上限截断」的显式信号,与族谱响应的 `truncated` 同约定(详见 §3.10.6 与 §3.10.9) |
+
+#### 作者(Bearer + 发布灰度)
+
+| 方法 / 路径 | 说明 |
+| --- | --- |
+| `PUT /games/{gameId}/fork-authorization`(新) | body `{ expectedForkAuthorization, forkAuthorization }` + `Idempotency-Key`;**只有母版可用**——母版只允许提升(降级与未知档位拒绝),返回最新 `forkAuthorization` 与 `replayed`;**衍生作品(存在血缘行)任何请求都 409 `FORK_AUTHORIZATION_INHERITED`**(与降级同为 409、码不同),包括传同值的幂等重试。判定取**血缘行**且**先于 CAS**,所以同一个衍生作品不会因载荷不同(同值 / 新值 / 期望值过期)而回不同的码;失败一律不写库 |
+| `PUT /versions/{versionId}/project-bundle`(**M2b 已实现**) | `application/octet-stream` 整包一次上传(≤ 200 MiB);另有分片族 `GET …/project-bundle/upload-state`、`PUT …/project-bundle/chunk`(偏移头 `x-genarrative-upload-offset`)、`POST …/project-bundle/complete`(服务端独立跑工程包 zip 门禁 + 算摘要 + 确认)、`POST …/project-bundle/reset`(丢弃未确认的暂存对象)。前置:调用者是该版本作者;版本处于 `awaiting_upload` / `upload_failed`;该版本**尚无**已确认的工程包(换内容 → 409)。对象键由服务端派生(`…/{version_id}.project.zip`),不接受客户端指定。**两个按实现为准的细节(端到端实测,A5/A5b/A6)**:① **非作者上传返回 `404` 而不是 `403`**——api-server 用 `load_owner_version_or_404` 把 owner 不匹配按「版本不存在」处理,与发行包上行族同口径,既不会泄露「这个版本存在但不属于你」,响应里也不含对象键;② **阶段门先判「已存在」再判版本档位**——`ensure_project_bundle_uploadable` 先看 `project_bundle_bytes > 0`(→ 409 `PROJECT_BUNDLE_ALREADY_EXISTS`,因为确认工程包不驱动版本状态机,已确认的版本可能仍停在 `awaiting_upload`),再看 `status`(→ 409 `PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED`);因此「已公开**且已有**工程包」返回的是 `ALREADY_EXISTS`,只有「已公开**但还没有**工程包」才落到 `UPLOAD_NOT_ALLOWED` |
+| `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId`;再追加可选 `forkAuthorization`(`forbidden` / `nonCommercial` / `full`,**母版缺省禁止共创**,非法取值整请求 400 + 平台信封),使作者**上架时**即可选择授权档位,不必事后提升。**该字段只对母版生效**:带 `fork` 声明的衍生作品在创建时**继承父作品当时的档位**(创建时快照),请求里传的值一律被忽略且不报错(旧客户端惯常带默认值);「收窄授权」不实现(产品决定 2026-10-06)。继承来的档位是**终态**:该作品之后再调 `PUT …/fork-authorization` 一律 409 `FORK_AUTHORIZATION_INHERITED`(母版才走只升不降);父作品为 `forbidden` 时建立不了血缘,因此衍生作品**不会继承到 `forbidden`**。 |
+| `POST /games/{gameId}/versions`(**既有,请求增量**,2026-10-06「衍生作品发布必填核心改动说明」) | 追加可选 `changeSummary`:「本次核心改动说明」,发布设置页的「二创信息区」随包提交。**谁必填**:**衍生作品**(该 `gameId` **有血缘行**,即带过 `fork` 声明、代际 ≥ 1)**必须**提供;**0 代母版忽略该字段**——不校验、不落库(写 `NULL`)而不是「可选」,否则一个与血缘无关的字段会在母版上分叉出第二套语义。判定取**血缘行**本身、不接受客户端自称派生(`fork` 声明只在创建作品时生效一次),后续每一版都按血缘行判。**长度口径**:先 `trim` 再按**字符**数计(`chars().count()`,不是字节数——中文一字 3 字节,按字节算会让中文作者只能写 1/3 的内容),区间 **20–500**(常量 `GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS` / `_MAX_CHARS` 单点持有)。下限 20 的理由:这一项要回答「这一版相对来源作品改了什么」,比作品标题(40)短、比主题角标(16)长,是一句话能说清的最小规模;只写「改了」这类敷衍串等于没有说明,而展示层要把它当「这一代的差异」读。上限 500 的理由:它是发布流程里的一个文本框、不是正文(作品详细介绍 2000 才是正文),500 个字符足够写清「加了什么玩法、换了什么美术、修了什么」,同时保证详情页 / 族谱里相邻代际不会被挤出首屏。**失败关闭**且两条失败各有稳定码(都 400,客户端可只补说明或改短重试,不必按 409 去刷新后重放):缺失 / trim 后为空 → **`FORK_CHANGE_SUMMARY_REQUIRED`**;越界 → **`FORK_CHANGE_SUMMARY_INVALID`**。**存储**:`game_distribution_version` 表尾追加可空列 `change_summary`——存**版本级**(同一作品的不同代各说各的改动),随版本冻结不可改。**投影**:公开版本摘要发 `changeSummary`(衍生必有、母版 `null`),见上方公开详情行。**幂等**:该字段属于请求体,因此参与请求摘要——同 `Idempotency-Key` 换说明会被按「同键不同请求」拒绝(409);`serde` 上缺省**不序列化 `null`**,省略该键的旧客户端请求与升级前逐字节一致,同键重放不会被误判成新请求 |
+
+#### 登录用户(Bearer,**不叠加**发布灰度)
+
+灰度只针对「发布」,改编不应当被发布开关挡住:任何已登录用户都能改编已授权公开的作品。改编的三个取件接口共用同一条校验(同一个函数,规则不分叉),错误码沿用 `FORK_*`。本章末尾的收藏(收录)三条路由同样只要求 Bearer,但它们是**用户态**接口,与改编取件无关,不走 `FORK_*` 错误码:
+
+| 方法 / 路径 | 说明 |
+| --- | --- |
+| `GET /games/{gameId}/fork-source`(**M2a 已实现**,M2b 增补 `source`) | 受鉴权元数据。404 `FORK_SOURCE_NOT_FOUND`(作品不存在)/ 409 `FORK_SOURCE_NOT_AVAILABLE`(已软删除、未公开、或没有当前公开版本)/ 403 `FORK_NOT_AUTHORIZED`(授权为 `forbidden` 或未知档位,未知按禁止解释);通过时返回 `{ forkSource: { gameId, versionId, source, sha256, bytes, downloadPath } }`。**`source` 现在可能是 `project`(该版本有工程源包时优先)或 `package`**;`sha256` / `bytes` 取自**所选资产**在版本行上的摘要(不重算),`downloadPath` 是同源相对路径并指向对应资产(`…/fork-source/project` 或 `…/fork-source/package`),**绝不下发 OSS 对象键** |
+| `GET /games/{gameId}/fork-source/project`(**M2b 已实现**) | 取件本体(源码级):与 `/fork-source/package` 同一套鉴权与校验(同一个函数),仅当该作品当前公开版本**确有工程包**时才服务,否则 `409 FORK_SOURCE_NOT_AVAILABLE`(**失败关闭**,绝不悄悄回落成品包)。响应头与成品包同形,文件名为 `{gameId}-{versionId}-project.zip`;不做引用归一化 / `RELEASE_STORAGE_BOOTSTRAP` 注入(下发原始源码包),读取走同一条 OSS 读路径但**缓存键含资产维度**(对象键不同,两份资产不串味) |
+| `GET /games/{gameId}/fork-source/package`(**M2a 已实现**) | 取件本体:直接回该版本发行包 ZIP 的字节,头为 `Content-Type: application/zip`、`Content-Length`、`Content-Disposition: attachment; filename="{gameId}-{versionId}.zip"`、`Cache-Control: no-store`。数据复用现役发行网关的读包路径(整包进内存 + 进程内缓存,上限 4 条 / 256 MiB),**不做**网关那套引用归一化与 `RELEASE_STORAGE_BOOTSTRAP` 注入——下发的是原始构建产物,客户端要按摘要校验后离线解压,任何改写都会让摘要对不上 |
+
+**收藏(收录)三条路由(2026-10-06 新增,Bearer,`no-store`)**
+
+收藏是被收藏作品的**服务端真实投影**(表 `game_distribution_collection`),不是前端本地状态;`PUT` / `DELETE` 共用同一个响应形状 `{ collected }`,客户端不按动词推断结果。
+
+| 方法 / 路径 | 说明 |
+| --- | --- |
+| `PUT /games/{gameId}/collection` | 收藏。要求 `Idempotency-Key`;请求摘要绑定 `(userId, gameId)`。**幂等**:同键重放返回 `replayed: true`(本次没有新事实);同键**换作品** → **409**(同一收据键、摘要不同);同键**换用户** → **200 各自成功**(收据键是 `(userId, action, idempotencyKey)`,本身含 `userId`,两个用户不会命中彼此的收据)。**防重靠结构**:主键是确定性构造的 `{userId}:{gameId}`,同一 (用户, 作品) 不可能出现第二行(重复收藏只留一行、仍算成功),不靠「事务里先查后写」,也不额外建唯一索引。错误码:作品不存在 → **404**;未公开 / 已软删除 / 没有当前公开版本 → **409**(失败关闭,文案 `作品状态不允许收藏(未公开或已软删除)`,不含「不存在」/「已被删除」,不用错误码泄露未公开作品的存在性)。响应 `{ collected: true, replayed: boolean }` |
+| `DELETE /games/{gameId}/collection` | 取消收藏。**不要求 `Idempotency-Key`**:按确定性主键删除,重复调用结果完全相同(不存在也算成功),没有「重放 vs 新意图」需要区分。**也不要求作品仍公开 / 未被删除**:下架后拒绝取消只会给用户留下清理不掉的脏行。响应 `{ collected: false }`(**不带** `replayed`) |
+| `GET /my-collections?limit=&cursor=`(2026-10-06 补真分页) | 「我的收藏(收录)」列表:逐条用公开目录同一份 `public_game_payload` 投影,形状与公开目录一致 `{ games, nextCursor }`。**真游标分页**:`limit` 缺省 **20**(网格一屏)、上限 **50**(约束单响应体积;与后台列表的 200 口径**不必相等**),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{collectionId}"`(与后台列表同一套 `"{micros}:{id}"` 惯例,`collectionId` = `{userId}:{gameId}` 自带冒号,解析只切第一个冒号);**格式非法 → 400**;`nextCursor` 为真实值,**最后一页为 `null`**。排序:**收藏时间倒序,同值用 `collectionId` 升序兜底**(`collectionId` 唯一 ⇒ 全序,翻页不重不漏)。分页顺序定义为「**先按可见性过滤、再排序切页**」——游标位置落在已过滤序列上,否则每翻一页都会漏掉自己的若干条收藏。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 |
+
+**贡献归集与归因(作者视角只读,2026-10-06 新增,Bearer,`no-store`)**
+
+口径与不变量见 §3.11;这里是路由层的合同摘要。
+
+| 方法 / 路径 | 说明 |
+| --- | --- |
+| `GET /games/{gameId}/contribution` | 作者读取自己作品的贡献归集:`{ gameId, own, inherited, total, byGeneration, directChildren, nodeCount, truncated, truncatedReason }`(指标集合当前只有 `playCount`)。**只要求 Bearer、不叠加发布灰度**;未登录 / 失效 → **401**,作品不存在或已软删除 → **404**,非作者 → **403**(沿用既有 owner-mismatch 惯例,理由与代价见 §3.11.5)。子树遍历只用血缘表既有父索引,**不新增表 / 索引**;受节点上限 **500** / 深度上限 **32** 约束,超限时 `truncated: true` + `truncatedReason` ∈ `node_limit` / `depth_limit`,已计入部分的数字仍自洽。**本期只做计算**:响应里没有资金 / 分成 / 结算字段 |
+
+#### 后台(admin)
+
+| 方法 / 路径 | 说明 |
+| --- | --- |
+| `GET /admin/api/game-distribution/games`(**既有,响应增量**) | 追加 `forkAuthorization` / `generation` / `forkedFromGameId` / `derivedCount`(只读展示) |
+| `POST /admin/api/game-distribution/themes`(新,2026-10-06) | 创建主题。**鉴权复用既有 admin 体系**:`route_layer(require_admin_auth)` + handler 取 `Extension`——未带 / 失效会话 → **401 `UNAUTHORIZED`**(与同组 games/reviews 路由同码同形),非 admin role → 403;模块侧第二层再要求受信服务身份(只有 api-server 能调 procedure)。`theme_id` 由**服务端**生成 `theme-{uuid}`(请求体里没有该字段,外部无法指定),`created_by_user_id` 取 admin 会话主体。要求 `Idempotency-Key`(缺 / 超 128 字符 → 400 `BAD_REQUEST`,沿用既有写接口口径,不为主题另开一套);**同键同请求摘要重放 → `replayed: true` 且回库里那一行(不写库)**,同键不同请求 → **409 `THEME_IDEMPOTENCY_CONFLICT`**。响应 `{ theme, replayed }`。空 `name` / 文本超长(名称 40 / 简介 200 / 角标 16)/ 未知 `status` → 400 `THEME_BAD_REQUEST`(不是 axum 默认 422 纯文本) |
+| `PUT /admin/api/game-distribution/themes/{themeId}`(新,2026-10-06) | 整体覆盖 `name` / `summary` / `badge` / `sortOrder` / `status`。鉴权与幂等口径同上(要求 `Idempotency-Key`;生效即刷新 `updated_at`,**重放不写库、不刷新**)。`themeId` 不存在 → **404 `THEME_NOT_FOUND`**;字段非法 → 400 `THEME_BAD_REQUEST`。响应 `{ theme, replayed }` |
+| `GET /admin/api/game-distribution/themes?limit=&status=`(新,2026-10-06) | 后台列表,**含 `draft` / `archived`**。`limit` 缺省与上限同为 **200**(超界截断),**无游标**(主题是运营维护的小集合)。`status` 白名单 `all` / `draft` / `published` / `archived`(缺省 = `all`),非法过滤值 → 400 `THEME_BAD_REQUEST`(失败关闭,不退化成全量)。响应 `{ themes: [{ themeId, name, summary, badge, sortOrder, status, memberCount, createdAt, updatedAt }] }`;此处 `memberCount` 是**成员行总数**(含当前对外不可见的成员),与公开侧同名键的「可见成员数」口径不同 |
+| `PUT /admin/api/game-distribution/themes/{themeId}/members/{rootGameId}`(新,2026-10-06) | 增 / 改成员(幂等 upsert,body 可带 `sortOrder`,缺省 0)。**不要求 `Idempotency-Key`**:成员身份完全由路径给出,确定性主键 `"{themeId}:{rootGameId}"` 天然幂等,重复调用只更新 `sortOrder`(`createdAt` 不变)。只允许**根作品**:非根(有血缘行)→ **409 `THEME_MEMBER_NOT_ROOT`**;作品不存在 → 404 `THEME_MEMBER_GAME_NOT_FOUND`;主题不存在 → 404 `THEME_NOT_FOUND`。**不做「作品必须已公开」的前置校验**(可先挂草稿根,作品公开后自动进入公开投影)。响应 `{ themeId, rootGameId, sortOrder, createdAt }` |
+| `DELETE /admin/api/game-distribution/themes/{themeId}/members/{rootGameId}`(新,2026-10-06) | 移除成员。**不要求 `Idempotency-Key`**:按确定性主键删除,**成员不存在也算成功(200)**;响应刻意不含「之前存不存在」,重复调用逐字节相同。主题不存在仍是 404 `THEME_NOT_FOUND`(那是路径里的主题 ID 错了)。响应 `{ themeId, rootGameId }` |
+| `GET /admin/api/game-distribution/themes/{themeId}/members?limit=&cursor=`(新,2026-10-06) | 后台读取主题成员**名单**(运营核对)。鉴权同上(`require_admin_auth` + `Extension`;未登录 / 失效会话 → **401 `UNAUTHORIZED`**,非 admin role → **403**)。**返回全部成员行、不套公开可见性过滤**,且 **`draft` / `archived` 主题照常可读**——公开投影只服务已发布主题,后台借道它时草稿 / 归档主题根本列不出成员,运营没法在发布前核对(本接口就是补这个缺口)。响应 `{ themeId, totalMembers, members: [{ rootGameId, title, sortOrder, createdAt, visible, visibility }], nextCursor }`:`totalMembers` 是**成员行总数**(含当前不可见的行、**不受分页影响**);`title` 取游戏行标题,游戏行不存在时为 `null`(键仍发出);`visible` = 此刻**公开投影**会不会包含它(未删 + 已公开 + 有当前公开版本,**复用** `game_distribution_theme_member_visible`,不另写一套判定),`visibility` = 更细的状态(软删除 → `deleted`,游戏行不存在 → `missing`,否则原样透传游戏行的 `published` / `unpublished` / `suspended`)——两者**刻意独立**:`visibility == "published"` 且**无**当前公开版本时 `visible == false`,运营据此看出「作品已公开、但没有公开版本」这种异常。分页与其它列表完全一致:`limit` 缺省 **20** / 上限 **50** / `0` 取默认 / 超界**截断**;游标形如 `"{sortOrder}:{memberId}"`(`memberId` 本身是 `"{themeId}:{rootGameId}"`,解析只切第一个冒号,与 `"{i64}:{rest}"` 惯例同构);**非法游标 → 400 `THEME_INVALID_CURSOR`**(可达的稳定码,不吞成 200 空页);`nextCursor` 为真实值、**末页为 `null`**。排序 `sortOrder` 升序 + 成员主键升序兜底(**与公开侧同一份比较器** `sort_theme_members`,翻页不重不漏)。主题不存在 → **404 `THEME_NOT_FOUND`**;`draft` / `archived` **不是** 404(后台要看得见) |
+
+#### 契约同步(强制)
+
+- Rust DTO:`server-rs/crates/shared-contracts/src/game_distribution.rs`
+- TS DTO:`packages/shared/src/contracts/gameDistribution.ts`
+- 版本状态枚举如需新增取值,同步 `GameDistributionVersionStatus` 与 `scripts/check-game-distribution-dto-parity.mjs` 的公开构建器约束。
+- 本期不动 `/api/external/v1`,因此不改 External OpenAPI;若后续对外开放 Fork 查询,再按现有门禁同步 OpenAPI。
+
+### 3.5 两条改造路径:成品包(先行)与工程源包(补强)
+
+> **术语更正(2026-10-04,2026-10-07 再更正一次)**:本节原来的标题把成品包路径称为「改造」,正文还写了「项目无需构建步骤即可试玩与发布」。2026-10-04 的只读侦察按**当时的落点假设**(成品铺进项目里、项目没有 `package.json`)把能力判成「本地试玩 + 素材来源,不含重新发布」;2026-10-07 落地的建项形态是「**先建 Phaser 4 + Vite 合规脚手架,再把成品铺进预览根**,并登记可运行原型」,于是**试玩与发布都成立**,只有「在成品上重建核心逻辑」不成立(包里没有源码)。本节下面按实现事实重写。路线取舍见 §3.5.4。
+
+#### 3.5.1 成品包路径(零新增上传资产:可玩参考 + 素材来源)
+
+- **内容来源**:父作品当前公开版本的 `package_object_key`(已存在,无需作者做任何额外动作)。服务端按现有发行网关同一套对象读取与校验复用该 ZIP,不新造存储。包内容是纯运行产物:`collect_project_export_package_files`(`src-tauri/src/project/export.rs:770-804`)只收 `dist` 树(条目统一改名成 `game/...`)+ 根 `assets/` 兜底 + `exports/README.md`,**根 `package.json` 与源码不进 ZIP**。
+- **下载通道(M2a 已实现)**:`GET /games/{gameId}/fork-source`(元数据)+ `GET /games/{gameId}/fork-source/package`(ZIP 字节)两条受鉴权路由,**要求 Bearer、不叠加发布灰度**(灰度只针对发布),校验顺序与错误码见 §3.4「登录用户」小节。服务的是**当前已公开版本**的发行包(与公开发行网关同一份对象),元数据里的 `sha256` / `bytes` 直接取该版本行,`downloadPath` 是同源相对路径、不下发对象键;本体复用发行网关的读包路径(整包进内存 + 进程内缓存,上限 4 条 / 256 MiB,`:926-958`、`:101-104`),但**不做**引用归一化与 bootstrap 注入——取件下发的是原始构建产物,客户端按摘要校验后离线解压,任何改写都会让摘要对不上。客户端侧仍是整包读取(`fetch_limited_bytes`,上限 512 MiB、无 Range 无续传;分片能力只有上传侧),大包的续传/分片下载是后续议题。**未实现**的是按版本直取任意历史版本:取件只服务当前公开版本,与发行网关同口径。
+- **AGC 建项:fork 完即可运行,也能发布(2026-10-07 起)。**
+ - **落点 = 预览根**:先 `init_local_game_project_at` 生成合规脚手架(Phaser 4 + Vite,`project/manifest.rs` 的 `DEFAULT_GAME_PACKAGE_JSON` / `DEFAULT_GAME_VITE_CONFIG`),再把发行包整包解到**预览根**(`game_fork.rs` 的 `fork_playable_root`,即 `preview::project_game_root()` 给出的 `game/dist`)。运行视图只服务预览根,铺在别处的副本用户既看不到也播不了——这正是「fork 之后没法直接运行」的成因。
+ - **平台发行包契约保证入口在包根**:服务端 `validate_release_zip` 要求 ZIP 根有 `index.html`(`module-game-distribution/src/package.rs:98,146`),api-server 强制 `package_entry_path == "index.html"`(`api-server/src/modules/game_distribution.rs:5434`),AGC 侧的归一化同样在新 ZIP 里剥掉 `game/` 前缀并强制根 `index.html`(`project/export.rs:489-590`)。因此解到预览根后就是 `<预览根>/index.html`,`validate_project_game_entry`(`verification.rs:49-52`)与 `start_local_game_preview_for_project`(`preview.rs:403-420`)都能通过。
+ - **建项收口登记「可运行原型」**:`game_fork.rs` 的 `register_forked_project_state_at` 在**同一次收口**里完成「任务状态 → 推进 project revision → 追加 `initial-` 工程内部版本 → 宣告清单失效」(与 AI 直连回合收口 `agent/direct_runtime/mod.rs:4181-4194` 同一套模式)。成品包形态因为预览根确实有 `index.html`,会把 `code-prototype` 置为已完成——运行页签与发布入口读的就是这一条事实(`view/project-development/index.tsx` 的 `runAvailable`、`project/export.rs` 的 `project_has_runnable_prototype`)。
+ - **发布也成立**:导出/发布入口「已有可玩入口」的分支(`export.rs:262-265`)只要求工程栈合规(phaser 4 + vite),脚手架已满足(`ensure_publish_project_stack`,`:413-470`),于是发包内容就是**当前预览根**——作者没改就是取到的那份成品产物(平台仍要求衍生作品填「本次核心改动说明」)。**没有**「改完再发布断裂」这一步了:源码缺失只影响「能不能在取到的成品上重建核心逻辑」,不影响「能不能运行 / 能不能发布」。
+ - **不再写第二份 `reference/` 副本**:可玩参考就落在预览根这一处。多写一份既让「哪份是事实源」含糊(两份都要同步),又白占一份磁盘;来源与形态事实由 `.agent/fork-source.json`(v2,`source: package`)承担,界面据此如实措辞。
+ - **不会被当成作者源码外发**:`game/dist` 属于工程源包排除清单(`project_bundle.rs:88` 的 `BUNDLE_EXCLUDED_GAME_BUILD_DIRS`),fork 来的成品不会被重新包进该作者的工程源包。
+- **能力边界**:可试玩(fork 完直接运行)、可发布(发布当前预览根)、可提取包内素材(受上面扩展名白名单限制)、可阅读已压缩的运行产物;**不可**在取到的成品上重建核心逻辑(包里没有源码与 `package.json`)。面板必须如实说明最后一点。
+- **价值**:零新增上传资产即可跑通「授权 → 运行/试玩 → 素材复用 → 直接发布(或改后在自建工程里发布)→ 血缘与溯源」;工程源包仍是有源码时的正解路径(§3.5.2)。
+
+#### 3.5.2 工程源包路径(源码级复刻,现为正解路径)
+
+- **打包(AGC,已落地)**:客户端 `project_bundle` 模块做**确定性打包**(条目排序 + 固定时间戳,同内容同摘要),排除 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`;客户端自身另有总量上限(512 MiB)。
+- **服务端复核(已落地、不信任客户端)**:`module-game-distribution/src/project_bundle.rs` 的 `validate_project_bundle_zip` 独立校验上传内容——上限与发行包**逐项相等**(压缩包 200 MiB / 展开 500 MiB / 单文件 64 MiB / 条目 10 000 / 压缩比 100,理由是两者共用同一条上传链路,反代与 Pingora 的放行量就是按 200 MiB 校准的);额外拦凭据与隐私文件(`.env`、`.env*`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`.npmrc`、`.netrc`、`.git-credentials`、`id_rsa*`、`id_ed25519*`、`*.map`)、嵌套 `.zip`、符号链接、加密条目与不安全路径;**不要求**根 `index.html`(源码包没有入口约定)。
+- **上传(AGC,已落地)**:发布链路在「授权非 `forbidden`」时随版本上传工程源包(失败**不阻断**发布,按可重试处理);面板明确提示「未上传工程包,你的作品只能被他人试玩与参考(不能直接重新发布)」。是否有工程包**不改变**授权是否开放(见 §3.2.3 的产品口径)。客户端规则与服务端对齐,并有机器门禁 `npm run check-project-bundle-policy-parity` 钉住两端排除清单/上限一致(含路径形状维度,见下)。
+- **凭据/依赖识别补强(2026-10-05 对抗性复核后)**:除上面那份清单,还拦任意层级的 `.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`,以及文件名前缀 `credentials` / `id_ecdsa` / `id_dsa` / `terraform.tfstate` / `service-account`、后缀 `.jks` / `.keystore` / `.ppk` / `.p8` / `.kdbx` / `.der`、全名 `.htpasswd` / `.pgpass`。另外对**小体积文本条目**做内容特征扫描(PEM 私钥块标记、AWS `AKIA…`、GitHub `ghp_` / `github_pat_`、Slack `xox*`)——这是唯一能兜住「未知凭据文件名」的形状;更宽的前缀启发(例如 `sk-`)**故意不做**,因为误报会直接阻断作者发布,收益与代价不成比例。
+- **嵌套包识别**:扩展名并集 `.zip` / `.tar` / `.gz` / `.tgz` / `.7z` / `.rar` / `.jar` / `.whl` / `.nupkg`,**并对每个条目按 magic bytes 嗅探**(`PK\x03\x04`、`7z`、`Rar!`、gzip、tar 的 `ustar`)——改名成 `.dat` 也拦。客户端打包器同款检查,命中即报错(指明路径与格式),不静默跳过。
+- **规模检查的读取层封顶**:实际读取按**该条目声明大小**封顶(只允许多读 1 字节用于判溢出,短读同样算失败),预分配同样收紧到硬上限。因此「声明说谎的 deflate 炸弹」在**读**这一层就失败关闭,不会先解完再比对;**同一处修法同步到发行包校验器**(那里原有同样的写法)。
+- **压缩比口径更正**:分母是**该条目的压缩字节**(不是整包字节),而且它**不是 zip 炸弹防线**——真正的约束是单文件 64 MiB 与累计展开 500 MiB 两条上限,压缩比只是补充。文档与代码注释都按这个语义写。
+- **路径形状与嗅探维度(一致性门禁新增)**:客户端过去只过 `template_library::safe_archive_relative_path`(不查结尾点/空格与 `<>"|?*`),于是 macOS/Linux 上 `src/a?.ts`、`x.` 这类名字**客户端能打、服务端必 422**(白传一趟)。现在客户端打包器自带一份与服务端逐字符对齐的路径形状检查,且 `check-project-bundle-policy-parity` 覆盖四类:① 目录/前缀/后缀/全名 + 5 项上限;② **路径形状**(哨兵段 / 结尾字符 / 禁止字符 + 两侧必须显式拒 `/` 开头与 `\`);③ **内容嗅探特征表**(`SECRET_CONTENT_SIGNATURES` / `AWS 前缀` / PEM 标记 / 文本扩展名白名单 / 嗅探窗口,两侧逐字相等);④ **嵌套包 magic bytes**(服务端 ⊆ 客户端)。抽不到一律报错,不做「抽不到就绿」。
+- **下载与建项(AGC,已落地;2026-10-07 更正落点)**:`fork-source` 返回 `source: 'project'` 时走 `GET /games/{gameId}/fork-source/project`(`package` 时走原路径)→ 校验 `sha256` 与 `bytes`(失败关闭、不落盘)→ 按来源分支建项:**工程源包**解到项目根(包就是工程本身,复用模板归档同一套条目门禁);**成品包**先 `init_local_game_project_at` 生成合规脚手架、再整包解到**预览根**(`fork_playable_root`,即 `game/dist`;先前文档写的「解压到 `/forks///`、成品包形态是可玩参考副本」与实现不符,已按代码更正——那一版落点不在预览根,导致副本播不了)。建项收口统一走 `register_forked_project_state_at`:登记「可运行原型」(仅当预览根确有 `index.html`)与 `initial-` 工程内部版本,并宣告清单失效。改编来源写在 `.agent/fork-source.json`(**不写 manifest**:manifest 是 `deny_unknown_fields` 的 v1 契约,加拓扑字段等于改契约)。来源记录为 **v2**(记录取件形态与对应摘要),**兼容 v1**(旧记录仍可读);取件成功提示按两种形态分别如实措辞。
+- **优先级(已落地)**:同一版本同时存在工程源包与成品包时,`fork-source` 优先返回工程源包并标 `source: 'project'`;只有工程包缺失(或行上「字节数 > 0 但摘要为空」这种半写状态)才回落 `package`,客户端据此决定建项形态。
+
+#### 3.5.3 发布时声明(两条路径共用)
+
+AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom` 并写入创建 game 的请求体。网页端**不提供**手填来源,声明只能来自真实下载过的内容,避免伪造血缘;网页端发布的作品因此只能作为父作品,不能作为子作品。
+
+#### 3.5.4 第二阶段(Fork 落地)的路线选择(决策材料,待用户拍板)
+
+> 本节是**待拍板材料**,不是定稿合同;定稿前不得据此实现。结论来自 2026-10-04 的只读侦察(材料:`local://m2a-design.md`),它否掉了原方案「成品包铺进项目即可试玩与发布」的假设(事实依据见 §2.5 与 §3.5.1)。
+
+**进展(2026-10-05)**:产品已采纳下面的推荐口径(**不做**独立的「源码可见性」开关;作者不传工程包即退化产物级改编;`nonCommercial` 的约束力是平台规则层面)。路线 **B 两端均已落地**:服务端(§3.2.3 三列 + `module-game-distribution` 的工程包校验器 + 上行/下行路由族,见 §3.4)与 AGC 侧(确定性打包器、发布时上传、按 `source` 分支的取件建项、来源记录 v2),两端规则由 `npm run check-project-bundle-policy-parity` 机器门禁钉住一致;A 作为默认路径继续服务未上传工程包的作品;C 不做。
+
+**落地补记(2026-10-07)**:A 路线的**兑现度被提高了**,但**没有**走成 C(不造「dist 实体的伪工程」):成品包仍只当可玩参考(内容原样解到预览根,不改写任何字节),合规工程骨架由既有 `init_local_game_project_at` 标准脚手架提供(**不是** fork 自己拼 `package.json`),并在建项收口把「可运行原型」登记进 manifest。因此 A 路线现在是「可玩参考 + 素材来源 + 合规骨架 + 开箱可运行/可发布」,唯一没兑现的仍是「在成品上重建核心逻辑」(包里没有源码)。产品口径同步见 §2.5 与 §5.8。
+
+三条候选路线:
+
+| 路线 | 用户拿到什么 | 能不能发布 | 对「一键复刻完整工程」的兑现度 | 工作量 |
+| --- | --- | --- | --- | --- |
+| **A. 参考式改编**:成品包只当「可玩参考 + 素材来源」,AGC 侧另建合规骨架(真 Phaser 4 + Vite) | 原作副本(**预览根可直接试玩**)+ 新工程骨架 + 从成品包提取的素材(受扩展名白名单限制) | 能(骨架是合规工程;2026-10-07 起建项同时登记可运行原型) | 低:脚本与配置要重写,对外不能说「复刻工程」 | AGC 侧中;若要把血缘做到平台可查询则跨端大 |
+| **B. 只做工程源包**(跳过成品包路线) | 作者随版本上传的可编辑工程包,fork 后即完整可开发工程 | 能(源包本身是 Phaser 4 + Vite 时) | 高(前提是作者真的提供工程包) | 大(需新增工程包下载/授权接口,见 M2b) |
+| **C. 成品包 + 自动补工程栈** | 铺产物 + 生成声明 phaser 4/vite 的 `package.json` | 能过客户端校验,但产出的是「dist 实体的伪工程」 | 表面中(能发布)、实质低(不可再构建) | 小–中 |
+
+**推荐(2026-10-05 已拍板;2026-10-07 按落地事实补记)**:第二阶段落 **A**(试玩 + 素材 + 血缘,现为「开箱可运行/可发布」),对外口径从「复刻工程」改为「参考改编 / 素材复用」;**B 作为正解同样已落地**(源码级复刻,`game/dist` 不在包内,所以源码形态 fork 后需要先构建才可运行——按产品口径**不自动构建**,界面如实提示);**C 不做**,或仅限一次性存量兼容且需产品书面接受「不可再构建」的语义。
+
+A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`create_npm_scaffold` 的触发条件是「没有 manifest **且** 根与 `game/` 都没有 `index.html`、都没有 `package.json`」(`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:609-613`),所以 fork 流程**先**走标准初始化生成脚手架、**再**把成品铺进 `game/dist`(预览根,不在判据覆盖的两个根上),既不需要自己拼 `package.json` / `vite.config.*`,也不会让脚手架与产物互相打断。成品铺在预览根而不是 `reference//`,是因为运行视图只服务预览根(`preview::project_game_root`);`game/dist` 同时被工程源包排除清单覆盖(`project_bundle.rs:88`),不会被当成作者源码外发。
+
+### 3.6 前端链路
+
+| 文件 | 改动 |
+| --- | --- |
+| `src/routing/activeAppPageRoutes.ts` | 新增 `['game-lineage', '/games/lineage']`;同步 `SelectionStage` 类型与 `PlatformEntryActiveFlowShell` 的 stage 分支 |
+| `src/components/game-distribution/GameDetailPage.tsx` | 世代/授权标签、数据概览(累计衍生作品 + 累计创作世代数)、血缘链(纵向滚动单链)、热门衍生栏、族谱入口、主题入口、「开始共创」主行动作 |
+| `src/components/game-distribution/GameLineagePage.tsx`(新) | 族谱页:桌面画布(节点-连线树)、移动端大纲模式、筛选器、主干线、信息面板 / bottom sheet |
+| `src/components/game-distribution/LineageNodeContent.tsx`(新) | 族谱**节点内容**(封面 + 代际角标 + 标题 + 作者)唯一实现,画布卡片与血缘链共用(避免第二套省略规则导致逐字换行) |
+| `src/components/game-distribution/LineageOutlineList.tsx`(新) | 移动端**大纲式缩进树**:全宽行 + 左缩进 + 肘形导线,无横向滚动 |
+| `src/components/game-distribution/LineageInfoPanel.tsx`(新) | 选中节点信息面板:桌面右侧浮层 / 移动端 bottom sheet(同一组件两种形态) |
+| `src/components/game-distribution/MyGamesPage.tsx` | 授权三态设置(只升,**仅母版**;衍生作品显示只读「继承自父作品」)、分类标签、「被改编 N」行内展开 |
+| `src/components/game-distribution/GamePublishPage.tsx` | 发布新版本的**「本次核心改动说明」**(衍生作品必填,20~500 字,前端同等校验) |
+| `src/services/gameDistributionClient.ts` | `setForkAuthorization` / `getGameLineage` / `getDerivedGames` / `setGameCollection` / `listMyCollections` / `listThemes` / `getTheme`;`getGame` 类型增量 |
+| `apps/ai-game-creator-shell` | 「共创」列表页(公开目录过滤 + 卡片入口,无粘贴输入块)、「开始共创」确认页(父作品卡 / 强制阅读 3 秒 / 真实同步阶段 / 吸附底部操作条)、发布面板(授权三态 + 衍生作品只读「继承自父作品」 + 「本次核心改动说明」)、`genarrative://fork` deep link 注册、外壳滚动链(见 §3.6.2) |
+| `apps/admin-web/src/pages/AdminGameManagementPage.tsx` | 列表新增授权 / 代际列 |
+
+#### 3.6.1 交互口径(2026-10-06 补充)
+
+- **世代与授权标签**:世代按文档三档命名 `0 代原创 / 1 代二创 / 2 代三创 / N 代迭代`(`coCreationCopy.ts`);授权类型沿用平台既有常量(禁止共创 / 允许非商用共创 / 允许全开放共创)。
+- **主操作「开始共创」始终显示**:授权为「禁止共创」时**置灰(`aria-disabled`)并给出原因**(悬停 `title` + 点击可读提示),不再静默隐藏;未登录走既有登录门禁。
+- **衍生作品的授权不可单独提升**:服务端会拒绝对衍生作品的授权 PUT,因此我的作品页对衍生作品**隐藏提升入口**,改为只读展示「共创授权继承自父作品(当前档位)」。
+- **血缘链(详情页共创卡)**:只画**祖先链**(根 → … → 当前),**纵向滚动**(`overflow-y: auto` / `overflow-x: hidden`)、**初次进入与当前作品变化时自动滚到底**;节点卡片为「横版封面缩略 + 标题 + 代际角标」的矮宽行,标题 2 行截断(`min-width: 0` + line-clamp),**不允许逐字换行**。整棵树由「查看创作族谱」入口承担——右栏只有 200 多像素,塞整棵树必然不可读。
+- **「累计创作世代数」在前端计算**:=本作品子树深度(子树最大代际 − 本作品代际),用详情页**已经请求的同一份 `/lineage` 数据**算,不新增请求、也不加服务端字段。
+- **移动端形态**(跟随仓库既有窄断点,`useIsMobileViewport`):
+ - **默认大纲式缩进树**(`LineageOutlineList`):全宽行、层级用左缩进 + 肘形导线表达、主干链导线加粗用主题色;**无横向滚动**、无需缩放平移;行高 ≥44px,点击行 = 选中(无 hover)。
+ - **大纲行版式口径(2026-10-07 追加,来自真机截图复盘)**:
+ - **内容列限宽居中**:`.lineage-outline` 用 `max-width: 54rem` + `margin: 0 auto`,宽屏不再整屏出血(之前 1300px+ 横条、文字与「打开 / Fork」之间约 700px 死空白)。操作按钮是行的**最后一个子元素**,因此固定在**内容列右端**,不会飘到屏幕最右侧。
+ - **角标成组、永不叠印**:代际 / 主干 / 当前作品 / 父代-子代 / ▶游玩数 统一放进 `.game-lineage-node__badges`(flex + gap + 可换行)。此前它们各自 `position: absolute`(代际左上、主干右上、「当前作品」在流内),在窄容器里会**互相叠印成乱码字形**(大纲行的「第 1 代」与「主干」);现在组内一律静态定位,重叠在结构上不可能发生。**2026-10-07 起两种形态都把该组放在封面下方**(画布卡片的信息区第一行),不再作为封面浮层。
+ - **缩略图统一 16:9**:大纲行的封面与画布同一比例(16:9,4.5rem 宽),不再被角标撑成窄竖条;行用 `min-height: 3.5rem` 做统一基线,标题最多 2 行 clamp → 同层行等宽、跨行等高,连线端点一致。
+ - **信息密度**:代际 + 主干 + 当前作品 + 父代/子代 由「三行」收敛成**一行角标组**,标题 2 行 clamp、作者一行 → 每行比改版前少一行。
+ - **图谱卡 = 广场作品卡的同一个组件(2026-10-07 用户口径:「不是要长得像,而是就应该是广场卡」)**:画布节点卡与图下方「父代 / 子代」清单卡**都直接渲染** `@genarrative/shared/components` 的 `GameCard`(不是仿制样式、也不是 CSS 覆盖)。为此对共享卡做了**纯加法**扩展:`game` 参数放宽为结构化子集(按「字段是否在对象上」决定是否渲染该行,广场用法行为不变)、新增可选 `badges` 角标槽与 `className`。
+ - **卡面不再有动作按钮**:「打开作品 / 开始共创」已从卡面移除(用户口径:看封面决定不了共创,必须点进去看)——**整卡就是入口**(`GameCard` 本身是 `