From 7dbe2a9b5e254529246e8621d77ce09b34f08718 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 11:18:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=8F=E8=B0=B1=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B8=B8=E7=8E=A9=E6=AC=A1=E6=95=B0=E5=AD=90=E6=A0=91=E5=90=88?= =?UTF-8?q?=E8=AE=A1=E7=BA=AF=E5=87=BD=E6=95=B0=EF=BC=88=E4=B8=8E=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E5=BD=92=E9=9B=86=E4=B8=8D=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=90=8C=E5=8F=A3=E5=BE=84=EF=BC=89=E5=B9=B6=E5=86=99=E6=98=8E?= =?UTF-8?q?=E4=B8=BB=E5=B9=B2=E7=BA=BF=E5=88=A4=E6=8D=AE=3D=E8=87=AA?= =?UTF-8?q?=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lineageTreeLayout.test.ts | 117 ++++++++++++++++++ .../game-distribution/lineageTreeLayout.ts | 99 +++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index fa15c806e..80e4fc154 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -8,6 +8,7 @@ import { collectLineageAncestorChain, collectLineageHighlight, collectLineageImmediateScope, + collectLineagePlayCountTotals, collectLineageRelationRoles, collectLineageSubtreeDepth, generationAxisLabel, @@ -400,6 +401,122 @@ describe('collectLineageAncestorChain', () => { }); }); +describe('collectLineagePlayCountTotals(显示=子树合计)', () => { + /** 链:root(1) → a(2) → a1(3) → a1x(4)。 */ + const chain = [ + { ...node('root', 0, null), playCount: 1 }, + { ...node('a', 1, 'root'), playCount: 2 }, + { ...node('a1', 2, 'a'), playCount: 3 }, + { ...node('a1x', 3, 'a1'), playCount: 4 }, + ]; + + it('链式数据:每个节点的合计 = 自己 + 全部后代', () => { + const totals = collectLineagePlayCountTotals(chain); + expect(totals.get('a1x')).toEqual({ own: 4, inherited: 0, total: 4 }); + expect(totals.get('a1')).toEqual({ own: 3, inherited: 4, total: 7 }); + expect(totals.get('a')).toEqual({ own: 2, inherited: 7, total: 9 }); + expect(totals.get('root')).toEqual({ own: 1, inherited: 9, total: 10 }); + }); + + it('分支数据:兄弟各自累加后再汇总给父代', () => { + const branch = [ + { ...node('root', 0, null), playCount: 1 }, + { ...node('a', 1, 'root'), playCount: 10 }, + { ...node('a1', 2, 'a'), playCount: 5 }, + { ...node('b', 1, 'root'), playCount: 20 }, + ]; + const totals = collectLineagePlayCountTotals(branch); + expect(totals.get('root')).toEqual({ own: 1, inherited: 35, total: 36 }); + expect(totals.get('a')).toEqual({ own: 10, inherited: 5, total: 15 }); + expect(totals.get('b')).toEqual({ own: 20, inherited: 0, total: 20 }); + }); + + it('每个节点都满足服务端不变量 total = own + Σ 直接子代 total', () => { + const branch = [ + { ...node('root', 0, null), playCount: 1 }, + { ...node('a', 1, 'root'), playCount: 10 }, + { ...node('a1', 2, 'a'), playCount: 5 }, + { ...node('a2', 2, 'a'), playCount: 7 }, + { ...node('b', 1, 'root'), playCount: 20 }, + ]; + const totals = collectLineagePlayCountTotals(branch); + for (const item of branch) { + const own = totals.get(item.gameId)!.own; + const directChildren = branch.filter( + (candidate) => candidate.parentGameId === item.gameId, + ); + const sum = directChildren.reduce( + (acc, child) => acc + (totals.get(child.gameId)?.total ?? 0), + 0, + ); + expect(totals.get(item.gameId)!.total).toBe(own + sum); + } + }); + + it('单节点树:合计 = 自身', () => { + const totals = collectLineagePlayCountTotals([ + { ...node('solo', 0, null), playCount: 12 }, + ]); + expect(totals.get('solo')).toEqual({ own: 12, inherited: 0, total: 12 }); + }); + + it('缺字段 / 非有限 / 负数一律按 0 计(不臆造)', () => { + const totals = collectLineagePlayCountTotals([ + { gameId: 'root', parentGameId: null, playCount: undefined }, + { gameId: 'a', parentGameId: 'root', playCount: Number.NaN }, + { gameId: 'b', parentGameId: 'root', playCount: -5 }, + { gameId: 'c', parentGameId: 'root', playCount: 4 }, + ]); + expect(totals.get('a')).toEqual({ own: 0, inherited: 0, total: 0 }); + expect(totals.get('b')).toEqual({ own: 0, inherited: 0, total: 0 }); + expect(totals.get('root')).toEqual({ own: 0, inherited: 4, total: 4 }); + }); + + it('父节点不在可见集合里时只统计可见子树,不向上虚构', () => { + const totals = collectLineagePlayCountTotals([ + { ...node('child', 1, 'game-removed'), playCount: 5 }, + { ...node('grandchild', 2, 'child'), playCount: 7 }, + ]); + expect(totals.get('child')).toEqual({ own: 5, inherited: 7, total: 12 }); + // 不可见的父作品不会被凭空造出来。 + expect(totals.has('game-removed')).toBe(false); + }); + + it('脏数据里的环不会死循环,且每个节点只累加一次', () => { + const totals = collectLineagePlayCountTotals([ + { gameId: 'x', parentGameId: 'y', playCount: 1 }, + { gameId: 'y', parentGameId: 'x', playCount: 2 }, + ]); + expect(totals.get('x')!.total).toBe(3); + expect(totals.get('y')!.total).toBeGreaterThanOrEqual(2); + expect(totals.get('x')!.total).toBeLessThanOrEqual(3); + }); +}); + +describe('主干线判据 = 自身 playCount(不是子树合计)', () => { + it('自身高但合计低 与 自身低但合计高 的兄弟,主干线选**自身高**的那个', () => { + // A:自己 100、没有子代(合计 100)。B:自己 5、但有一个 200 播放的子代(合计 205)。 + const layout = buildLineageTreeLayout([ + { ...node('root', 0, null), playCount: 1 }, + { ...node('a', 1, 'root'), playCount: 100 }, + { ...node('b', 1, 'root'), playCount: 5 }, + { ...node('b1', 2, 'b'), playCount: 200 }, + ]); + const totals = collectLineagePlayCountTotals([ + { ...node('root', 0, null), playCount: 1 }, + { ...node('a', 1, 'root'), playCount: 100 }, + { ...node('b', 1, 'root'), playCount: 5 }, + { ...node('b1', 2, 'b'), playCount: 200 }, + ]); + // 合计上 B 赢(205 > 100)…… + expect(totals.get('b')!.total).toBeGreaterThan(totals.get('a')!.total); + const byId = new Map(layout.nodes.map((item) => [item.gameId, item])); + // ……但主干线判据是自身,所以主干是 A。 + expect(byId.get('a')!.isTrunk).toBe(true); + expect(byId.get('b')!.isTrunk).toBe(false); + }); +}); + describe('collectLineageHighlight', () => { const tree = buildLineageTreeLayout([ node('root', 0, null), diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index ce0aab374..cd1c9b2ce 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -72,6 +72,7 @@ export type LineageLayoutNode = { /** * 主干线节点:**该代 `playCount` 最高**的那个(并列取先出现的;占位节点不参与)。 * 文档要求「每代热度最高的版本标为主干线,用粗实线连接」。 + * 判据是**自身** `playCount`,不是子树合计(裁定见 `buildLineageTreeLayout` 里的注释)。 */ isTrunk: boolean; }; @@ -286,6 +287,11 @@ export function buildLineageTreeLayout( }); // 主干线:每代取 `playCount` 最高的真实节点(占位节点不参与、并列取先出现的)。 + // + // **判据裁定(2026-10-06)**:这里用的是**自身** `playCount`,**不是**子树合计 + // (`collectLineagePlayCountTotals` 的 `total`)。主干线要表达「**这一版自己**有多热」; + // 换成合计会让「后代多但自己很冷」的节点压制真正热的叶子节点,整条主干线失去意义。 + // 「显示=子树合计、主干线判据=自身」这两个口径同时成立,别把它们合并。 const trunkByGeneration = new Map(); for (const node of layoutNodes) { if (node.isStub) continue; @@ -460,6 +466,99 @@ export function collectLineageAncestorChain< /** 节点相对「当前作品」的角色(文档口径的**父代 / 子代**)。 */ export type LineageRelationRole = 'self' | 'ancestor' | 'descendant' | 'side'; +/** 游玩次数的显示分解:自身值 + 全部后代的合计(= 显示值)。 */ +export type LineagePlayCountBreakdown = { + /** **自身**游玩次数:服务端公开字段 `playCount` 的原值(不做任何叠加)。 */ + own: number; + /** 全部**后代**的合计(父代展示的「衍生」部分)。 */ + inherited: number; + /** **显示值** = own + 全部后代合计,满足 `total(W) = own(W) + Σ 直接子代 total(C)`。 */ + total: number; +}; + +/** + * 一次性自底向上遍历,算出每个节点的**子树游玩次数合计**(口径 2026-10-06): + * + * > 游玩次数的**显示**必须是「子代叠加到父代」——父代展示的是自己 + 全部后代的合计。 + * + * 与服务端贡献归集不变量完全一致(`total(W) = own(W) + Σ 直接子代 total(C)`), + * 但是**纯前端用已有的那棵树算**:详情页与族谱页本来就拿到了 `/lineage` 整棵树, + * 所以零额外请求、服务端零改动(公开接口的 `playCount` 字段仍是自身值,不要拿它去遍历子树)。 + * + * 边界: + * - 缺 `playCount` / 非有限数 / 负数 → 按 0 计(占位与未知都不臆造); + * - 父节点不在可见集合里(未公开 / 已删 / 被截断)→ 合计**只统计可见子树**, + * 不向上虚构(与「未公开不出现在树里」的既有可见性口径一致); + * - 数据里出现环时每个节点只累加一次,不会死循环。 + * + * 注意:**主干线判据不是它**。主干线(每代热度最高)用**自身** `playCount`,见 + * `buildLineageTreeLayout` 里的说明。 + */ +export function collectLineagePlayCountTotals< + T extends { + gameId: string; + parentGameId?: string | null; + playCount?: number | null; + }, +>(nodes: readonly T[]): Map { + const ownById = new Map(); + const childrenByParent = new Map(); + for (const node of nodes) { + const raw = node.playCount; + ownById.set( + node.gameId, + typeof raw === 'number' && Number.isFinite(raw) ? Math.max(0, raw) : 0, + ); + } + for (const node of nodes) { + const parentId = node.parentGameId ?? null; + // 父不在可见集合里 → 该节点是可见森林的根,不向外累加。 + if (!parentId || !ownById.has(parentId)) continue; + const bucket = childrenByParent.get(parentId); + if (bucket) bucket.push(node.gameId); + else childrenByParent.set(parentId, [node.gameId]); + } + + const totalById = new Map(); + const inProgress = new Set(); + const compute = (rootId: string) => { + // 迭代式后序遍历:链路可能很深,避免递归栈。 + const stack: { id: string; expanded: boolean }[] = [ + { id: rootId, expanded: false }, + ]; + while (stack.length > 0) { + const frame = stack.pop() as { id: string; expanded: boolean }; + if (!frame.expanded) { + // 已算过,或正在这条链上(数据里出现环)→ 不再展开:环上每个节点只累加一次。 + if (totalById.has(frame.id) || inProgress.has(frame.id)) continue; + inProgress.add(frame.id); + stack.push({ id: frame.id, expanded: true }); + for (const childId of childrenByParent.get(frame.id) ?? []) { + stack.push({ id: childId, expanded: false }); + } + continue; + } + let total = ownById.get(frame.id) ?? 0; + for (const childId of childrenByParent.get(frame.id) ?? []) { + total += totalById.get(childId) ?? 0; + } + totalById.set(frame.id, total); + inProgress.delete(frame.id); + } + }; + for (const node of nodes) { + if (!totalById.has(node.gameId)) compute(node.gameId); + } + + const breakdowns = new Map(); + for (const node of nodes) { + const own = ownById.get(node.gameId) ?? 0; + const total = totalById.get(node.gameId) ?? own; + breakdowns.set(node.gameId, { own, inherited: total - own, total }); + } + return breakdowns; +} + /** * 每个节点相对 `gameId` 的角色: *