族谱:新增游玩次数子树合计纯函数(与服务端归集不变量同口径)并写明主干线判据=自身
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
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:
@@ -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` 的角色:
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user