diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 796c3830a..2c100540a 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -60,7 +60,7 @@ | 作者署名 | 字段存在但创建时写死 `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 结果为准 | `game_distribution.rs:1759`(仅初值);`GameDetailPage.tsx:292` | +| 播放次数 | 本分支上 `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` | @@ -494,6 +494,24 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr - **改动前的断点就在这一层**:`.launcher-main` 原是 `display:grid` + `grid-template-rows: auto auto auto` + `overflow:hidden`——行高被内容撑到 1494px、容器只有 570px,`overflow:hidden` 又让用户滚不动(实测:`overflow-y: hidden`、`userScrollable=false`、最后一行 `bottom=1512 > viewport 620`)。因此「共创」列表最后一行与确认页底部按钮都被裁掉、够不到。 - **版式令牌**:两页共用 `src/features/platform-fork/forkPageStyles.ts`(内容列 `min(860px, 100vw - 124px)`、正文 `72ch` 上限、4/8 步进间距、状态用**徽章**而非灰字、只用 `packages/shared/src/theme.css` 既有 `--platform-*` 变量、`:focus-visible` 统一 `3px solid var(--platform-warm-text)`)。 +#### 3.6.3 游玩次数的三种口径(2026-10-07 追加,纯前端显示层) + +需求方口径:「游玩次数的**显示**必须是**子代叠加到父代**」——父代展示的是**自己 + 全部后代的合计**。 +于是同一份 `playCount` 有三种口径,**必须分清、不要合并**: + +| 口径 | 值 | 落点 | +| --- | --- | --- | +| 公开接口字段 `playCount` | **自身值**(服务端**不做**子树遍历) | `/lineage` 节点、`/derived` 节点、公开详情 DTO、我的作品列表 | +| **显示** | **子树合计** = 自身 + 全部后代,并给出分解(自身 M + 衍生 K) | 详情页 hero 与「共创」卡、主题页母版行、血缘图节点(若显示游玩数)、热门衍生栏 | +| **主干线判据 / 热度排序** | **自身值** | 族谱「每代热度最高」的主干线判据(裁定:主干线表达「**这一版自己**有多热」,用合计会让深层子树压制真正热的叶子)、热门衍生栏排序 | + +- **不在服务端加字段、也不让公开接口做子树遍历**(列表页会因此 N 次遍历,正是要省掉的算力成本): + 客户端用**已经拿到的那棵树**一次自底向上遍历即可,纯函数 `collectLineagePlayCountTotals` + 满足与 §3.11 贡献归集**同一条不变量** `total(W) = own(W) + Σ 直接子代 total(C)`,**零额外请求**。 +- 主题页母版行复用内嵌族谱页**同一次** `/lineage`(`GameLineagePage.onPlayCountTotals` 回传),不额外请求。 +- **拿不到树的界面继续显示自身值**(我的作品列表、广场卡片等),并且**不暗示含衍生**;详情页在树回来前 + 也是自身值,「合计 + 分解」只在拿到树后出现(截断/失败时同样回退)。 + ### 3.7 灰度与可观测 - **M1 实现口径**:共创写入复用现役 `game-distribution:publish` 开关(`ensure_publish_enabled`),不新增独立灰度键——本功能与发布能力同批上线,先少一个开关减少漂移面。独立键 `game-distribution:fork`(含 `/api/runtime/frontend-config` 的 `gameDistributionForkEnabled` 与前端入口门禁)作为后续增强保留,接入前需要同时改 `module-runtime`、`api-server` 与前端配置读取。 @@ -774,6 +792,11 @@ pub struct GameDistributionThemeMember { #### 3.11.1 递归定义 +> **前端显示也复用这一条**(2026-10-07,§3.6.3):公开接口的 `playCount` 仍是**自身值**, +> 但界面上显示的「游玩次数」按**同样的不变量**在客户端用已有的那棵树算(`collectLineagePlayCountTotals`), +> 所以展示口径与本节的服务端归集**永远一致**,且不为显示新增任何服务端字段或子树遍历。 +> 唯一例外:**主干线与热度排序的判据**用自身值(裁定见 §3.6.3)。 + ```text inherited(W) = Σ_{c ∈ 直接子代(W)} total(c) total(W) = own(W) + inherited(W) diff --git a/src/components/game-distribution/CoCreationHotDerived.tsx b/src/components/game-distribution/CoCreationHotDerived.tsx index 3d10430da..4ae64f71b 100644 --- a/src/components/game-distribution/CoCreationHotDerived.tsx +++ b/src/components/game-distribution/CoCreationHotDerived.tsx @@ -1,6 +1,7 @@ import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; import { coCreationGenerationShortLabel } from './coCreationCopy'; import { GameCover } from './GameCover'; +import type { LineagePlayCountBreakdown } from './lineageTreeLayout'; /** * 热门衍生栏:当前作品下热度最高的 5 个分支作品(横向卡片)。 @@ -8,18 +9,34 @@ import { GameCover } from './GameCover'; * 热度口径:**`playCount` 倒序**,同热度按创建顺序(服务端顺序)稳定兜底。 * `/derived` 目前不保证服务端排序,所以排序在客户端做;后端若补了排序, * 这段逻辑仍然成立(只是排序变成幂等)。 + * + * 显示口径(2026-10-06):「游玩次数」的**显示**一律是**子树合计**(父代展示自己 + 全部后代)。 + * 传了 `playCountTotals` 时排序与显示都用合计(这样列表顺序与卡片上的数字一致); + * 传不进来(拿不到树)时回退服务端的**自身值**,不暗示含衍生。 */ export const CO_CREATION_HOT_DERIVED_LIMIT = 5; export function CoCreationHotDerived({ nodes, + playCountTotals, onOpenGameDetail, }: { nodes: readonly GameDistributionLineageNode[]; + /** + * 每个节点的游玩次数分解(详情页用同一棵 `/lineage` 在前端算好的,零额外请求)。 + * 缺失时显示自身值。 + */ + playCountTotals?: ReadonlyMap | null; onOpenGameDetail: (gameId: string) => void; }) { + const shownPlayCount = (gameId: string, own: number) => + playCountTotals?.get(gameId)?.total ?? own; const hottest = [...nodes] - .sort((left, right) => right.playCount - left.playCount) + .sort( + (left, right) => + shownPlayCount(right.gameId, right.playCount) - + shownPlayCount(left.gameId, left.playCount), + ) .slice(0, CO_CREATION_HOT_DERIVED_LIMIT); if (hottest.length === 0) return null; @@ -34,12 +51,16 @@ export function CoCreationHotDerived({ onClick={() => onOpenGameDetail(node.gameId)} > - + {node.title} {coCreationGenerationShortLabel(node.generation)} ·{' '} - {node.authorName?.trim() || '未署名作者'} · {node.playCount} 次游玩 + {node.authorName?.trim() || '未署名作者'} ·{' '} + {shownPlayCount(node.gameId, node.playCount)} 次游玩 ))} diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 15e06e281..1a167edd3 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -60,7 +60,9 @@ import { } from './gameVersionHistoryModel'; import { collectLineageAncestorChain, + collectLineagePlayCountTotals, collectLineageSubtreeDepth, + type LineagePlayCountBreakdown, } from './lineageTreeLayout'; import { useGameAuthorDisplayName } from './useGameAuthorName'; import { useGameReviews } from './useGameReviews'; @@ -544,9 +546,23 @@ function GameDetailContent({ * (见 useGameAuthorName);查不到时不显示昵称,仍保留头像、主页入口和关注操作。 */ const authorName = useGameAuthorDisplayName(game.author); + /** + * 游玩次数的**显示**口径(2026-10-06):父代展示的是「自己 + 全部后代的合计」。 + * 用详情页本来就拿到的这棵 `/lineage` 树**在前端算**(零额外请求、服务端零改动), + * 整棵树的分解都留着,因为「热门衍生栏」也要显示各自子树的合计。 + * 树还没回来 / 拿不到时为 `null`,各处回退成服务端的**自身值**,并且不说「含衍生」。 + */ + const [lineageTotals, setLineageTotals] = useState | null>(null); + const playCountBreakdown = lineageTotals?.get(game.id) ?? null; const displayGame = { ...game, author: { ...game.author, name: authorName ?? '' }, + // hero 的「N 次游玩」按同口径显示**子树合计**;树还没回来时回退服务端自身值 + // (公开接口的 `playCount` 字段本身仍是自身值,这里只改展示)。 + playCount: playCountBreakdown?.total ?? game.playCount, }; /** 作者视角的条目没有公开投影 `currentVersion`,可玩版本取版本历史里的已发布版本。 */ const publishedVersion = @@ -597,6 +613,7 @@ function GameDetailContent({ if (!publiclyVisible) { setLineageChain([]); setDerivedNodes([]); + setLineageTotals(null); return; } // 每个调用各自兜底(含同步抛错):附加数据失败不能影响作品主信息。 @@ -623,6 +640,9 @@ function GameDetailContent({ tree ? collectLineageSubtreeDepth(tree.nodes, game.id) : null, ); setDerivedNodes(derived?.nodes ?? []); + // 游玩次数显示口径(2026-10-06):父代展示「自己 + 全部后代的合计」; + // 用同一棵 /lineage 在前端算,零额外请求、服务端零改动。 + setLineageTotals(tree ? collectLineagePlayCountTotals(tree.nodes) : null); }, [game.id, publiclyVisible]); useEffect(() => { void loadCoCreationData(); @@ -883,6 +903,21 @@ function GameDetailContent({ ) : null} {/* 数据概览:累计衍生作品数 + 累计创作世代数(子树深度,前端用同一份 lineage 数据算)。 */} + {/* + 游玩次数按「子代叠加到父代」显示:合计 = 自身 + 全部后代,并**给出分解**, + 避免只给一个会让人误解的数字(数据来自同一棵 /lineage,零额外请求)。 + 树还没回来 / 拿不到时不显示这一项(不伪造、也不暗示含衍生)。 + */} + {playCountBreakdown ? ( + + 游玩 {playCountBreakdown.total} 次(自身{' '} + {playCountBreakdown.own} + 衍生{' '} + {playCountBreakdown.inherited}) + + ) : null} 累计衍生作品 {forkCount} @@ -1061,6 +1096,7 @@ function GameDetailContent({ {derivedNodes.length > 0 && onOpenGameDetail ? ( ) : null} diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 38880b860..fac14941b 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -2191,6 +2191,68 @@ describe('GameThemePage', () => { }, }); + it('母版行的「N 次游玩」用子树合计(复用内嵌树那一次 /lineage,不多发请求)', async () => { + getThemeMock.mockResolvedValue({ + themeId: 'theme-1', + name: '精选共创', + badge: null, + summary: null, + memberCount: 1, + roots: [ + createGame({ + id: 'game-root-1', + title: '根一', + playCount: 3, + lineage: { generation: 0 }, + }), + ], + }); + getGameLineageMock.mockResolvedValue({ + rootGameId: 'game-root-1', + root: createLineageNode({ + gameId: 'game-root-1', + title: '根一', + generation: 0, + parentGameId: null, + playCount: 3, + }), + nodes: [ + createLineageNode({ + gameId: 'game-root-1', + title: '根一', + generation: 0, + parentGameId: null, + playCount: 3, + }), + createLineageNode({ + gameId: 'game-child-1', + title: '衍生一', + generation: 1, + parentGameId: 'game-root-1', + playCount: 4, + }), + ], + truncated: false, + }); + render( + , + ); + + // 合计 = 3 + 4 = 7(不是服务端的自身值 3),并给出分解。 + const total = await screen.findByLabelText('游玩次数合计'); + await waitFor(() => + expect(total.textContent).toBe('7 次游玩(自身 3 + 衍生 4)'), + ); + expect(screen.queryByText('3 次游玩')).toBeNull(); + // 合计来自内嵌树那一次请求:主题页自己没有额外再拉一份 lineage。 + expect(getGameLineageMock).toHaveBeenCalledTimes(1); + expect(getGameLineageMock).toHaveBeenCalledWith('game-root-1'); + }); + it('渲染主题头与逐根的内嵌作品树(复用族谱页)', async () => { getThemeMock.mockResolvedValue({ themeId: 'theme-1', @@ -3700,6 +3762,212 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { ).toBeGreaterThan(0); }); + /** + * 游玩次数合计的数据集:root(10) → mid(20) → current(7) → child(5) → grandchild(3)。 + * 「当前作品」的合计 = 7 + 5 + 3 = 15(自身 7 + 衍生 8)。 + */ + const playCountTree = { + rootGameId: 'game-root', + root: null, + nodes: [ + createLineageNode({ + gameId: 'game-root', + title: '根原创', + generation: 0, + parentGameId: null, + playCount: 10, + }), + createLineageNode({ + gameId: 'game-mid', + title: '中间代', + generation: 1, + parentGameId: 'game-root', + playCount: 20, + }), + createLineageNode({ + gameId: 'game-current', + title: '当前作品', + generation: 2, + parentGameId: 'game-mid', + playCount: 7, + }), + createLineageNode({ + gameId: 'game-child', + title: '子代', + generation: 3, + parentGameId: 'game-current', + playCount: 5, + }), + createLineageNode({ + gameId: 'game-grandchild', + title: '孙代', + generation: 4, + parentGameId: 'game-child', + playCount: 3, + }), + ], + truncated: false, + }; + + it('游玩次数按「子代叠加到父代」显示:卡里给合计 + 分解,hero 同口径', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + id: 'game-current', + playCount: 7, + forkCount: 3, + lineage: { + rootGameId: 'game-root', + parentGameId: 'game-mid', + parentTitle: '中间代', + parentAuthorName: '原作者', + generation: 2, + }, + }), + ); + getGameLineageMock.mockResolvedValue(playCountTree); + render( + , + ); + + // 卡里的概览:合计 + 分解(不是只给一个会让人误解的数字)。 + const breakdown = await screen.findByLabelText('游玩次数合计'); + expect(breakdown.textContent).toBe('游玩 15 次(自身 7 + 衍生 8)'); + // 自身值只出现在分解里,不再单独显示成「7 次游玩」。 + expect(screen.queryByText('7 次游玩')).toBeNull(); + // hero 也是同一口径(子树合计),与卡里一致。 + expect(await screen.findByText('15 次游玩')).toBeTruthy(); + expect(screen.getByText('累计衍生作品 3')).toBeTruthy(); + }); + + it('热门衍生栏按「子树合计」显示与排序;拿不到树时回退自身值', async () => { + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' })); + getDerivedGamesMock.mockResolvedValueOnce({ + gameId: 'game-1', + nodes: [ + createLineageNode({ + gameId: 'child-low', + title: '低热度', + playCount: 1, + generation: 1, + parentGameId: 'game-1', + }), + createLineageNode({ + gameId: 'child-hot', + title: '高热度', + playCount: 99, + generation: 1, + parentGameId: 'game-1', + }), + createLineageNode({ + gameId: 'child-mid', + title: '中热度', + playCount: 50, + generation: 1, + parentGameId: 'game-1', + }), + ], + truncated: false, + }); + // 树里「低热度」有一个 200 播放的孙代 → 子树合计 201,超过「高热度」的 99。 + getGameLineageMock.mockResolvedValue({ + rootGameId: 'game-1', + root: null, + nodes: [ + createLineageNode({ + gameId: 'game-1', + title: '母版', + generation: 0, + parentGameId: null, + playCount: 10, + }), + createLineageNode({ + gameId: 'child-low', + title: '低热度', + playCount: 1, + generation: 1, + parentGameId: 'game-1', + }), + createLineageNode({ + gameId: 'child-grand', + title: '孙代爆款', + playCount: 200, + generation: 2, + parentGameId: 'child-low', + }), + ], + truncated: false, + }); + const onOpenGameDetail = vi.fn(); + const { unmount } = render( + , + ); + + const hot = await screen.findByLabelText('热门衍生作品'); + // 显示与排序都按合计:低热度 201 → 高热度 99 → 中热度 50。 + await waitFor(() => + expect( + [...hot.querySelectorAll('.co-creation-hot__title')].map( + (element) => element.textContent, + ), + ).toEqual(['低热度', '高热度', '中热度']), + ); + expect(hot.textContent).toContain('201 次游玩'); + unmount(); + + // 拿不到树 → 回退服务端自身值(不暗示含衍生),排序也回退到自身值。 + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' })); + getDerivedGamesMock.mockResolvedValueOnce({ + gameId: 'game-1', + nodes: [ + createLineageNode({ + gameId: 'child-low', + title: '低热度', + playCount: 1, + generation: 1, + parentGameId: 'game-1', + }), + createLineageNode({ + gameId: 'child-hot', + title: '高热度', + playCount: 99, + generation: 1, + parentGameId: 'game-1', + }), + ], + truncated: false, + }); + getGameLineageMock.mockRejectedValue(new Error('拿不到树')); + render( + , + ); + + const hotFallback = await screen.findByLabelText('热门衍生作品'); + expect(hotFallback.textContent).toContain('99 次游玩'); + expect( + [...hotFallback.querySelectorAll('.co-creation-hot__title')].map( + (element) => element.textContent, + ), + ).toEqual(['高热度', '低热度']); + // 没有树就没有「游玩次数合计」这一项,也不出现「含衍生」的说法。 + expect(screen.queryByLabelText('游玩次数合计')).toBeNull(); + expect(document.body.textContent).not.toContain('含衍生'); + }); + it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => { getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' })); getDerivedGamesMock.mockResolvedValueOnce({ diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 5d8c6c727..5d8a70890 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -33,9 +33,11 @@ import { buildLineageTreeLayout, collectLineageHighlight, collectLineageImmediateScope, + collectLineagePlayCountTotals, collectLineageRelationRoles, generationAxisLabel, type LineageLayoutNode, + type LineagePlayCountBreakdown, type LineageRelationRole, } from './lineageTreeLayout'; @@ -54,6 +56,14 @@ type GameLineagePageProps = { * 供主题页逐根渲染多棵树复用同一份树查询与渲染(不新建第二套树逻辑)。 */ embedded?: boolean; + /** + * 树加载完成后把「每个节点的游玩次数分解(子树合计)」回传给宿主,供宿主页面显示合计 + * ——宿主用**同一次** `/lineage` 请求的结果算,不需要自己再发一次(主题页的母版行就用它)。 + * 回调在内部用 ref 保存,宿主不必包 `useCallback`。 + */ + onPlayCountTotals?: ( + totals: ReadonlyMap, + ) => void; }; const MIN_SCALE = 0.35; @@ -233,6 +243,7 @@ export function GameLineagePage({ onBack, onOpenDetail, embedded = false, + onPlayCountTotals, }: GameLineagePageProps) { const normalizedRootId = rootGameId?.trim() ?? ''; const [lineage, setLineage] = @@ -305,6 +316,17 @@ export function GameLineagePage({ }, [loadLineage]); const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]); + /** + * 树一变就把「每个节点的游玩次数分解」交给宿主(主题页的母版行要显示子树合计)。 + * 回调存在 ref 里,所以宿主不必包 `useCallback`,也不会因为回调换身份而反复触发。 + */ + const playCountTotalsCallbackRef = useRef(onPlayCountTotals); + playCountTotalsCallbackRef.current = onPlayCountTotals; + useEffect(() => { + const callback = playCountTotalsCallbackRef.current; + if (!callback || !lineage) return; + callback(collectLineagePlayCountTotals(lineage.nodes)); + }, [lineage]); // 高亮锚点(= 「当前作品」,也是「父代 / 子代」的基准)必须早于筛选链路声明。 const anchorId = highlightGameId?.trim() || normalizedRootId; /** diff --git a/src/components/game-distribution/GameThemePage.tsx b/src/components/game-distribution/GameThemePage.tsx index 1aac0873b..245b6c75c 100644 --- a/src/components/game-distribution/GameThemePage.tsx +++ b/src/components/game-distribution/GameThemePage.tsx @@ -11,6 +11,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { GameLineagePage } from './GameLineagePage'; +import type { LineagePlayCountBreakdown } from './lineageTreeLayout'; import { useGameAuthorDisplayName } from './useGameAuthorName'; type GameThemePageProps = { @@ -24,6 +25,7 @@ type GameThemePageProps = { /** 成员根卡片:作品摘要 + 「查看作品 / 查看作品族谱」两个入口。 */ function ThemeRootCard({ root, + playCountBreakdown, onOpenDetail, onOpenLineage, }: { @@ -34,6 +36,11 @@ function ThemeRootCard({ playCount: number; lineage?: { generation: number } | null; }; + /** + * 游玩次数分解(子树合计)。**显示口径 2026-10-06**:父代展示「自己 + 全部后代的合计」。 + * 拿不到树时为 `null` → 回退服务端的**自身值**,且不说「含衍生」。 + */ + playCountBreakdown?: LineagePlayCountBreakdown | null; onOpenDetail: (gameId: string) => void; onOpenLineage?: (rootGameId: string, fromGameId?: string) => void; }) { @@ -48,7 +55,13 @@ function ThemeRootCard({

{authorName?.trim() || '未署名作者'} - {root.playCount.toLocaleString()} 次游玩 + + {(playCountBreakdown?.total ?? root.playCount).toLocaleString()}{' '} + 次游玩 + {playCountBreakdown && playCountBreakdown.inherited > 0 + ? `(自身 ${playCountBreakdown.own} + 衍生 ${playCountBreakdown.inherited})` + : ''} +

| null>(null); + /** + * 各根树的「游玩次数分解」由内嵌的族谱页用**它自己那次** `/lineage` 请求回传(不额外请求)。 + * 节点 id 全局唯一,所以多棵树的结果直接合并。 + */ + const handlePlayCountTotals = useCallback( + (totals: ReadonlyMap) => { + setRootPlayCounts((current) => new Map([...(current ?? []), ...totals])); + }, + [], + ); + return (