实现游戏共创 M3:创作族谱树与衍生列表

- 新增 module-game-distribution/src/lineage.rs:锚点可读性、可见性过滤、稳定排序、上限截断的纯函数,附 15 个单测(复用 M1 的软删除判定,不重写第二套规则)
- 新增公开只读 procedure get_game_distribution_lineage / list_game_distribution_derived_games;锚点必须公开可读(未公开/已软删除/不存在一律按不存在处理),树内只出现未软删除且已公开的节点,不补 null 占位节点
- api-server 新增 GET /games/{id}/lineage 与 /games/{id}/derived(匿名可读、no-store),响应走结构化 DTO,节点键集合由测试钉死,杜绝下发对象键/素材键
- 作者自有游戏聚合追加 forkCount(与公开详情 forkCount 同口径),供「被改编 N」入口使用
- shared-contracts 与 TS 契约新增 LineageNode / LineageResponse / DerivedResponse 三型并登记 DTO 一致性检查
- 前端新增 /games/lineage 族谱页,登记 stage 类型、路由表、页面标题与壳层渲染分支;详情页共创卡新增「查看创作族谱」入口(仅已公开作品)
- 我的作品页新增「被改编 N」行内展开,列出直接子代的标题/作者/代际/状态,含空态与失败重试(仅已公开作品)
- 补前端组件/路由/标题/客户端用例与 api-server 路由、负载映射用例;同步里程碑验收标准与技术方案 §3.4 的锚点可读性口径
This commit is contained in:
2026-10-05 01:02:03 +08:00
parent 3d51d1309a
commit 39669763fe
36 changed files with 2780 additions and 27 deletions
@@ -49,7 +49,9 @@ const gameDistributionMock = vi.hoisted(() => ({
createGame: vi.fn(),
createGameVersion: vi.fn(),
deleteMyGame: vi.fn(),
getDerivedGames: vi.fn(),
getGame: vi.fn(),
getGameLineage: vi.fn(),
getGameVersion: vi.fn(),
getMyGame: vi.fn(),
listGames: vi.fn(),
@@ -1172,3 +1174,116 @@ describe('作品管理 owner 作用域', () => {
expect(gameDistributionMock.getMyGame).not.toHaveBeenCalled();
});
});
describe('创作族谱', () => {
const lineageTree = {
rootGameId: 'game-root',
root: {
gameId: 'game-root',
title: '母版',
authorName: '原作者',
generation: 0,
parentGameId: null,
playCount: 3,
status: 'published',
},
nodes: [
{
gameId: 'game-root',
title: '母版',
authorName: '原作者',
generation: 0,
parentGameId: null,
playCount: 3,
status: 'published',
},
{
gameId: 'game-leaf',
title: '叶节点',
authorName: '改编者',
generation: 1,
parentGameId: 'game-root',
playCount: 1,
status: 'published',
},
],
truncated: false,
};
it('族谱页按根作品渲染树,未登录也能浏览并跳转节点', async () => {
window.history.replaceState(
null,
'',
'/games/lineage?id=game-root&from=game-leaf',
);
gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree);
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-lineage"
setSelectionStage={setSelectionStage}
/>,
);
expect(await screen.findByText('叶节点')).toBeTruthy();
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
'game-root',
);
fireEvent.click(screen.getByRole('button', { name: '母版' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-root',
});
});
it('详情页的族谱入口带上根作品与当前作品', async () => {
window.history.replaceState(null, '', '/games/detail?id=game-1');
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',
title: '衍生作品',
summary: '已发布',
description: '',
category: '动作',
tags: [],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: {
id: 'version-1',
version: '1.0.0',
entryUrl: 'https://play.example.test/releases/version-1/index.html',
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 1,
createdAt: '2026-09-18T00:00:00.000Z',
forkCount: 0,
lineage: {
generation: 1,
rootGameId: 'game-root',
rootTitle: '母版',
parentGameId: 'game-root',
parentTitle: '母版',
parentAuthorName: '原作者',
},
});
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: '查看创作族谱' }),
);
expect(setSelectionStage).toHaveBeenCalledWith('game-lineage', {
path: '/games/lineage?id=game-root&from=game-1',
});
});
});