族谱:新增游玩次数子树合计纯函数(与服务端归集不变量同口径)并写明主干线判据=自身
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 11:18:18 +08:00
parent fa5264259e
commit 7dbe2a9b5e
2 changed files with 216 additions and 0 deletions
@@ -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),
@@ -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<number, LineageLayoutNode>();
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<string, LineagePlayCountBreakdown> {
const ownById = new Map<string, number>();
const childrenByParent = new Map<string, string[]>();
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<string, number>();
const inProgress = new Set<string>();
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<string, LineagePlayCountBreakdown>();
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` 的角色:
*