diff --git a/server-rs/crates/module-game-distribution/src/contribution.rs b/server-rs/crates/module-game-distribution/src/contribution.rs new file mode 100644 index 000000000..55344468e --- /dev/null +++ b/server-rs/crates/module-game-distribution/src/contribution.rs @@ -0,0 +1,698 @@ +//! 贡献归集(rollup)与归因(attribution)的纯计算规则。 +//! +//! 产品口径原话是「子代所有的都算父代的」,并要能回答「能溯源多少是子代给的、多少是自己的」。 +//! 递归定义(本文件是唯一实现,调用方只负责把行折叠成 [`ContributionNode`]): +//! +//! ```text +//! inherited(W) = Σ_{c ∈ 直接子代(W)} total(c) +//! total(W) = own(W) + inherited(W) +//! ``` +//! +//! 三条不可越过的口径: +//! - **每个后代在它的每个祖先里只计一次**:同一棵子树上不存在重复计数(重复行按 `game_id` +//! 去重),也不漏(只走「父在集合里」的边,见下条)。 +//! - **缺失 / 悬空行安全跳过**:父不在集合里的行(历史遗留、脏数据、自环)既不是根的后代, +//! 也就不会被计进任何数字——不 panic,也不发「只有 ID 的半残节点」。 +//! - **截断如实标注且已计入部分仍自洽**:受节点上限与深度上限约束时返回 +//! `truncated = true` 与 `truncated_reason`,且三条不变量在**截断后的子树**上依然成立 +//! (见 [`ContributionBreakdown`])。不静默给半个数。 +//! +//! 指标集合刻意做成结构体 [`ContributionTotals`](当前只有 `play_count`):将来加点赞 / 收藏 / +//! 收入只需加字段并在那一个合并函数里加一行,递归与归因的形状不用动。 +//! +//! **本期只做计算**:本模块不含任何资金 / 分成 / 结算语义,输出里也没有它们的位置。 + +use std::{ + collections::{BTreeMap, HashMap, HashSet, VecDeque}, + ops::{Add, AddAssign}, +}; + +/// 单次归集最多计入多少个**后代**节点(根不计入);超出时如实标注 `node_limit`。 +/// +/// 上限只拦「数量」:遍历按相对深度逐层推进、同层按 `gameId` 升序,因此被截掉的总是同一批 +/// 后缀节点,两次读之间不会漂移。 +pub const GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT: usize = 500; + +/// 相对深度上限(根为 0):比这更深的节点不再计入并标注 `depth_limit`。 +/// +/// 血缘表的主键约束保证一个作品只有一个父,但整数「代际」字段本身不能阻止一条被脏数据写坏的 +/// 超长链;深度上限是遍历的硬边界,避免一次读取退化成无界递归。 +pub const GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT: u32 = 32; + +/// 截断原因:`truncated == false` 时为 `None`,不返回空串占位。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContributionTruncationReason { + /// 后代节点数达到 [`GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT`]。 + NodeLimit, + /// 有节点的相对深度超过 [`GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT`]。 + DepthLimit, +} + +impl ContributionTruncationReason { + /// 对外字符串;与族谱接口 `truncated` 的既有约定一致(客户端按它分支提示)。 + pub fn as_str(self) -> &'static str { + match self { + Self::NodeLimit => "node_limit", + Self::DepthLimit => "depth_limit", + } + } +} + +/// 可扩展的指标集合。 +/// +/// 当前只有 `play_count`;**将来加指标只需加字段**并在 [`ContributionTotals::merge`] 里加一行, +/// 递归定义、归因分解与上限 / 截断语义都不需要改。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ContributionTotals { + pub play_count: u64, +} + +impl ContributionTotals { + /// 由作品行的游玩数构造。 + pub fn from_play_count(play_count: u64) -> Self { + Self { play_count } + } + + /// 唯一的合并方式:饱和相加,避免极端数据下溢出 panic(debug 构建下 `+` 会 panic)。 + fn merge(&mut self, other: &Self) { + self.play_count = self.play_count.saturating_add(other.play_count); + } +} + +impl AddAssign for ContributionTotals { + fn add_assign(&mut self, other: Self) { + self.merge(&other); + } +} + +impl Add for ContributionTotals { + type Output = Self; + + fn add(mut self, other: Self) -> Self { + self.merge(&other); + self + } +} + +/// 归集输入:调用方把游戏行(指标 + 代际)与血缘行(父引用)折叠成这些事实。 +/// +/// `generation` 是**绝对代际**(与族谱一致,直接改编母版为 1),根自身用 0 占位即可—— +/// 根不参与任何按代际分解。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContributionNode { + pub game_id: String, + pub parent_game_id: Option, + pub generation: u32, + pub totals: ContributionTotals, +} + +/// 按代际分解的一条:`byGeneration` **只含后代**(不含根自身),`generation` 是绝对代际。 +/// +/// 语义(与 [`ContributionBreakdown`] 的不变量绑定):本条目是根节点 `inherited` 里 +/// **由这一代自己产生**的那一份,因此 `total` 即该代贡献、`inherited` 恒为 `0`—— +/// 该代从更深代际继承到的量已计入更深代际的那一条,再计一次会让分解之和大于根的 `inherited` +/// (同一个后代会在多层分解里被重复计入,正是本功能要避免的事)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContributionGenerationBreakdown { + pub generation: u32, + /// 这一代计入分解的后代节点数。 + pub game_count: u64, + pub own: ContributionTotals, + pub inherited: ContributionTotals, + pub total: ContributionTotals, +} + +/// 直接子代明细的一条:`total` 是该直接子代**含它自己整棵子树**的值。 +/// +/// 这是「子代所有的都算父代的」最直白的证据面:`Σ directChildren.total == inherited(根)`。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContributionChildBreakdown { + pub game_id: String, + pub generation: u32, + pub own: ContributionTotals, + pub inherited: ContributionTotals, + pub total: ContributionTotals, +} + +/// 单个作品的归集与归因结果。 +/// +/// 不变量(截断后依然成立,单测逐条钉住): +/// - `inherited == Σ by_generation.total == Σ direct_children.total`; +/// - `inherited == Σ over 参与计算的后代 own`(递归定义的等价形式); +/// - `total == own + inherited`; +/// - `node_count == Σ by_generation.game_count`(= 参与计算的**后代**节点数,不含根)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContributionBreakdown { + pub game_id: String, + pub own: ContributionTotals, + pub inherited: ContributionTotals, + pub total: ContributionTotals, + /// 只含后代、按代际升序。 + pub by_generation: Vec, + /// 直接子代的整棵子树值,按 `gameId` 升序。 + pub direct_children: Vec, + /// 参与计算的后代节点数(不含根)。 + pub node_count: u64, + pub truncated: bool, + pub truncated_reason: Option, +} + +/// 计算以 `root_game_id` 为根的贡献归集与归因。 +/// +/// - `nodes`:子树候选节点集合(含根自身;多收的后代由本函数按上限截断); +/// - `node_limit`:后代节点上限;`0` 视为「不要任何后代」,不回退到默认值; +/// - `depth_limit`:相对深度上限(根为 0)。 +/// +/// 根不在集合里时返回 `None`(调用方映射 404);这里**不做**任何可见性过滤——入参没有可见性 +/// 字段,作者视角的归集口径就是「血缘上属于我的子树」,过滤规则要变必须显式加字段。 +pub fn build_contribution_breakdown( + nodes: &[ContributionNode], + root_game_id: &str, + node_limit: usize, + depth_limit: u32, +) -> Option { + // 重复行(同一 `game_id` 出现多次)只认第一次出现,后续行既不算节点也不算父边。 + let mut canonical_index: HashMap<&str, usize> = HashMap::new(); + for (index, node) in nodes.iter().enumerate() { + canonical_index + .entry(node.game_id.as_str()) + .or_insert(index); + } + let is_canonical = + |index: usize| canonical_index.get(nodes[index].game_id.as_str()) == Some(&index); + let root_index = *canonical_index.get(root_game_id)?; + + // 只保留「父仍在集合里」的边:悬空行(父被删 / 脏数据)、自环、根自身都不进子表, + // 因此它们永远不会被遍历到,也就不会被计进任何数字。 + let mut children: HashMap> = HashMap::new(); + for (index, node) in nodes.iter().enumerate() { + if index == root_index || !is_canonical(index) { + continue; + } + let Some(parent_game_id) = node.parent_game_id.as_deref() else { + continue; + }; + if parent_game_id == node.game_id.as_str() { + continue; + } + let Some(&parent_index) = canonical_index.get(parent_game_id) else { + continue; + }; + children.entry(parent_index).or_default().push(index); + } + // 同层按 `gameId` 升序:截断点因此确定,两次读之间不会换一批节点。 + for child_indexes in children.values_mut() { + child_indexes.sort_by(|left, right| nodes[*left].game_id.cmp(&nodes[*right].game_id)); + } + + let mut visited: HashSet = HashSet::new(); + let mut order: Vec = vec![root_index]; + visited.insert(root_index); + let mut queue: VecDeque<(usize, u32)> = VecDeque::new(); + queue.push_back((root_index, 0)); + let mut descendants: usize = 0; + let mut node_limited = false; + let mut depth_limited = false; + while let Some((index, depth)) = queue.pop_front() { + let Some(child_indexes) = children.get(&index) else { + continue; + }; + for &child in child_indexes { + if visited.contains(&child) { + continue; + } + // 节点上限优先于深度上限:两种上限同时拦住同一批节点时,报资源上限更有指导性 + // (调用方该去分页 / 收敛范围,而不是去怀疑血缘有 32 层)。 + if descendants >= node_limit { + node_limited = true; + continue; + } + let child_depth = depth + 1; + if child_depth > depth_limit { + depth_limited = true; + continue; + } + visited.insert(child); + order.push(child); + descendants += 1; + queue.push_back((child, child_depth)); + } + } + + // 自底向上(逆 BFS 序保证后代先算)折叠;只累加**参与计算**的边, + // 因此截断后的数字与 `node_count` 是同一份子树的自洽结果。 + let mut totals: Vec = nodes.iter().map(|node| node.totals).collect(); + let mut inherited: Vec = vec![ContributionTotals::default(); nodes.len()]; + for &index in order.iter().rev() { + let mut sum = ContributionTotals::default(); + if let Some(child_indexes) = children.get(&index) { + for &child in child_indexes { + if visited.contains(&child) { + sum += totals[child]; + } + } + } + inherited[index] = sum; + totals[index] = nodes[index].totals + sum; + } + + let mut generations: BTreeMap = BTreeMap::new(); + for &index in order.iter().skip(1) { + let entry = generations.entry(nodes[index].generation).or_default(); + entry.0 += 1; + entry.1 += nodes[index].totals; + } + let by_generation = generations + .into_iter() + .map( + |(generation, (game_count, own))| ContributionGenerationBreakdown { + generation, + game_count, + own, + // 见 [`ContributionGenerationBreakdown`] 的说明:分解只计本代自身, + // 更深代际的量归它们自己那一条,避免重复计数。 + inherited: ContributionTotals::default(), + total: own, + }, + ) + .collect(); + + let direct_children = children + .get(&root_index) + .map(|child_indexes| { + child_indexes + .iter() + .filter(|child| visited.contains(child)) + .map(|&index| ContributionChildBreakdown { + game_id: nodes[index].game_id.clone(), + generation: nodes[index].generation, + own: nodes[index].totals, + inherited: inherited[index], + total: totals[index], + }) + .collect::>() + }) + .unwrap_or_default(); + + let truncated_reason = if node_limited { + Some(ContributionTruncationReason::NodeLimit) + } else if depth_limited { + Some(ContributionTruncationReason::DepthLimit) + } else { + None + }; + + Some(ContributionBreakdown { + game_id: root_game_id.to_string(), + own: nodes[root_index].totals, + inherited: inherited[root_index], + total: totals[root_index], + by_generation, + direct_children, + node_count: descendants as u64, + truncated: truncated_reason.is_some(), + truncated_reason, + }) +} + +#[cfg(test)] +mod contribution_tests { + use super::*; + + fn node( + game_id: &str, + parent_game_id: Option<&str>, + generation: u32, + play_count: u64, + ) -> ContributionNode { + ContributionNode { + game_id: game_id.to_string(), + parent_game_id: parent_game_id.map(str::to_string), + generation, + totals: ContributionTotals::from_play_count(play_count), + } + } + + fn build(nodes: &[ContributionNode], root: &str) -> ContributionBreakdown { + build_contribution_breakdown( + nodes, + root, + GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT, + GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT, + ) + .expect("根必须在节点集合里") + } + + fn breakdown_play_counts(breakdown: &ContributionBreakdown) -> (u64, u64, u64) { + ( + breakdown.own.play_count, + breakdown.inherited.play_count, + breakdown.total.play_count, + ) + } + + fn by_generation_total(breakdown: &ContributionBreakdown) -> u64 { + breakdown + .by_generation + .iter() + .map(|entry| entry.total.play_count) + .sum() + } + + fn direct_children_total(breakdown: &ContributionBreakdown) -> u64 { + breakdown + .direct_children + .iter() + .map(|entry| entry.total.play_count) + .sum() + } + + fn generation_game_count(breakdown: &ContributionBreakdown) -> u64 { + breakdown + .by_generation + .iter() + .map(|entry| entry.game_count) + .sum() + } + + /// 技术方案里的三层链路 A → B → C 必须逐条对上,且三条不变量同时成立。 + #[test] + fn three_generation_chain_matches_the_documented_invariants() { + let nodes = [ + node("A", None, 0, 100), + node("B", Some("A"), 1, 30), + node("C", Some("B"), 2, 7), + ]; + let breakdown = build(&nodes, "A"); + + assert_eq!(breakdown_play_counts(&breakdown), (100, 37, 137)); + // A.inherited = B.own + C.own;B.inherited = C.own;C.inherited = 0。 + assert_eq!(breakdown.inherited.play_count, 30 + 7); + assert_eq!(generation_game_count(&breakdown), 2); + assert_eq!(breakdown.node_count, 2); + assert!(!breakdown.truncated); + assert_eq!(breakdown.truncated_reason, None); + + assert_eq!( + by_generation_total(&breakdown), + breakdown.inherited.play_count + ); + assert_eq!( + direct_children_total(&breakdown), + breakdown.inherited.play_count + ); + assert_eq!(direct_children_total(&breakdown), 37); + assert_eq!(breakdown.direct_children.len(), 1); + assert_eq!(breakdown.direct_children[0].game_id, "B"); + assert_eq!(breakdown.direct_children[0].own.play_count, 30); + assert_eq!(breakdown.direct_children[0].inherited.play_count, 7); + assert_eq!(breakdown.direct_children[0].total.play_count, 37); + } + + /// C 自身的归集:无子代 → `inherited = 0`,`total == own`。 + #[test] + fn leaf_has_empty_inheritance() { + let nodes = [ + node("A", None, 0, 100), + node("B", Some("A"), 1, 30), + node("C", Some("B"), 2, 7), + ]; + let breakdown = build(&nodes, "C"); + assert_eq!(breakdown_play_counts(&breakdown), (7, 0, 7)); + assert_eq!(breakdown.node_count, 0); + assert!(breakdown.by_generation.is_empty()); + assert!(breakdown.direct_children.is_empty()); + } + + /// 多分支:根带 3 个直接子代、其中一个再带孙代(每个后代只计一次)。 + #[test] + fn multi_branch_tree_rolls_up_once_per_descendant() { + let nodes = [ + node("root", None, 0, 10), + node("c1", Some("root"), 1, 1), + node("c2", Some("root"), 1, 2), + node("c3", Some("root"), 1, 3), + node("g1", Some("c2"), 2, 4), + ]; + let breakdown = build(&nodes, "root"); + + assert_eq!(breakdown_play_counts(&breakdown), (10, 10, 20)); + assert_eq!(breakdown.node_count, 4); + assert_eq!(generation_game_count(&breakdown), 4); + // 直接子代明细按 gameId 升序,且每项是「含整棵子树」的值。 + assert_eq!( + breakdown + .direct_children + .iter() + .map(|entry| (entry.game_id.as_str(), entry.total.play_count)) + .collect::>(), + vec![("c1", 1), ("c2", 6), ("c3", 3)] + ); + assert_eq!(direct_children_total(&breakdown), 10); + // 按代际分解:只含后代、绝对代际升序,逐条是「这一代自己产生的量」。 + assert_eq!( + breakdown + .by_generation + .iter() + .map(|entry| ( + entry.generation, + entry.game_count, + entry.own.play_count, + entry.inherited.play_count, + entry.total.play_count + )) + .collect::>(), + vec![(1, 3, 6, 0, 6), (2, 1, 4, 0, 4)] + ); + assert_eq!(by_generation_total(&breakdown), 10); + } + + /// 无子代:三个数字为 0 / 0 / own,分解为空数组(不是缺键、也不是占位行)。 + #[test] + fn childless_root_has_empty_attribution() { + let breakdown = build(&[node("solo", None, 0, 42)], "solo"); + assert_eq!(breakdown_play_counts(&breakdown), (42, 0, 42)); + assert_eq!(breakdown.node_count, 0); + assert!(breakdown.by_generation.is_empty()); + assert!(breakdown.direct_children.is_empty()); + assert!(!breakdown.truncated); + } + + /// 缺失 / 悬空行被安全跳过:父不在集合里、无父、自环、重复行都不参与任何数字。 + #[test] + fn dangling_missing_and_duplicate_rows_are_skipped_safely() { + let nodes = [ + node("root", None, 0, 5), + node("child", Some("root"), 1, 2), + // 父不在集合里(历史遗留脏数据)→ 不是 root 的后代。 + node("orphan", Some("ghost"), 1, 99), + // 没有父但不是根 → 同样不可达。 + node("floating", None, 1, 50), + // 自环 → 按悬空行处理,不得进入遍历。 + node("selfloop", Some("selfloop"), 1, 77), + // 重复行(同 gameId)→ 只认第一次出现,不重复计数。 + node("child", Some("root"), 1, 2), + // 连到被跳过节点上的孙子:其父虽在集合里,但从根不可达,也不得计入。 + node("grandchild", Some("orphan"), 2, 11), + ]; + let breakdown = build(&nodes, "root"); + + assert_eq!(breakdown_play_counts(&breakdown), (5, 2, 7)); + assert_eq!(breakdown.node_count, 1); + assert_eq!(generation_game_count(&breakdown), 1); + assert_eq!(direct_children_total(&breakdown), 2); + assert_eq!(by_generation_total(&breakdown), 2); + } + + /// 根不在节点集合里:返回 `None`(调用方映射 404),不返回空壳结果。 + #[test] + fn missing_root_returns_none() { + assert!( + build_contribution_breakdown( + &[node("other", None, 0, 1)], + "root", + GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT, + GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT, + ) + .is_none() + ); + } + + /// 代际取节点自带的**绝对**代际(与族谱一致),不是相对深度。 + #[test] + fn generation_breakdown_uses_absolute_generations() { + let nodes = [ + node("mid", None, 4, 1), + node("child", Some("mid"), 5, 2), + node("grandchild", Some("child"), 6, 3), + ]; + let breakdown = build(&nodes, "mid"); + assert_eq!( + breakdown + .by_generation + .iter() + .map(|entry| entry.generation) + .collect::>(), + vec![5, 6] + ); + assert_eq!( + breakdown + .direct_children + .iter() + .map(|entry| entry.generation) + .collect::>(), + vec![5] + ); + } + + /// 节点上限:如实标注 `node_limit`,且三条不变量在截断后的子树上仍然成立。 + #[test] + fn node_limit_truncates_and_keeps_invariants() { + let mut nodes = vec![node("root", None, 0, 1)]; + for index in 0..10 { + nodes.push(node( + &format!("child-{index:02}"), + Some("root"), + 1, + (index + 1) as u64, + )); + } + let breakdown = build_contribution_breakdown(&nodes, "root", 3, 32).expect("根必须存在"); + + assert!(breakdown.truncated); + assert_eq!( + breakdown.truncated_reason, + Some(ContributionTruncationReason::NodeLimit) + ); + assert_eq!(breakdown.node_count, 3); + assert_eq!(generation_game_count(&breakdown), 3); + assert_eq!(breakdown.direct_children.len(), 3); + // 截掉的是排序后缀(child-00 / 01 / 02 留下),截断点确定。 + assert_eq!( + breakdown + .direct_children + .iter() + .map(|entry| entry.game_id.as_str()) + .collect::>(), + vec!["child-00", "child-01", "child-02"] + ); + assert_eq!(breakdown_play_counts(&breakdown), (1, 6, 7)); + assert_eq!( + by_generation_total(&breakdown), + breakdown.inherited.play_count + ); + assert_eq!( + direct_children_total(&breakdown), + breakdown.inherited.play_count + ); + } + + /// 深度上限:它自己就是截断原因;节点上限优先于深度上限。 + #[test] + fn depth_limit_truncates_and_yields_to_node_limit() { + let mut nodes = vec![node("g0", None, 0, 1)]; + for generation in 1..=5u32 { + nodes.push(node( + &format!("g{generation}"), + Some(&format!("g{}", generation - 1)), + generation, + generation as u64, + )); + } + + let truncated = build_contribution_breakdown(&nodes, "g0", 500, 2).expect("根必须存在"); + assert!(truncated.truncated); + assert_eq!( + truncated.truncated_reason, + Some(ContributionTruncationReason::DepthLimit) + ); + assert_eq!(truncated.node_count, 2); + assert_eq!( + by_generation_total(&truncated), + truncated.inherited.play_count + ); + assert_eq!( + direct_children_total(&truncated), + truncated.inherited.play_count + ); + + // 两种上限同时拦住时按节点上限报(资源上限更有指导性)。 + let both = build_contribution_breakdown(&nodes, "g0", 1, 2).expect("根必须存在"); + assert_eq!( + both.truncated_reason, + Some(ContributionTruncationReason::NodeLimit) + ); + assert_eq!(both.node_count, 1); + + // 不截断时原样返回 `None`,不发空串。 + let full = build_contribution_breakdown(&nodes, "g0", 500, 32).expect("根必须存在"); + assert!(!full.truncated); + assert_eq!(full.truncated_reason, None); + assert_eq!(full.node_count, 5); + } + + /// 上限常量为**合同值**:技术方案 §3.11 与里程碑验收都引用 500 / 32,改动需同步契约与文档。 + #[test] + fn contribution_limit_constants_are_pinned() { + assert_eq!(GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT, 500); + assert_eq!(GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT, 32); + assert_eq!( + ContributionTruncationReason::NodeLimit.as_str(), + "node_limit" + ); + assert_eq!( + ContributionTruncationReason::DepthLimit.as_str(), + "depth_limit" + ); + } + + /// 递归定义的等价形式:`inherited == Σ over 参与计算的后代 own`。 + /// + /// 这条恒等式是「按代际分解之和 == inherited」与「直接子代明细之和 == inherited」的公共原因, + /// 钉住它就能同时解释两个分解面为什么自洽。 + #[test] + fn inherited_equals_sum_of_descendant_own() { + let nodes = [ + node("root", None, 0, 10), + node("c1", Some("root"), 1, 1), + node("c2", Some("root"), 1, 2), + node("g1", Some("c1"), 2, 3), + node("g2", Some("g1"), 3, 4), + node("g3", Some("c2"), 2, 5), + ]; + for limit in [0usize, 1, 3, 500] { + let breakdown = + build_contribution_breakdown(&nodes, "root", limit, 32).expect("根必须存在"); + let descendant_own: u64 = breakdown + .by_generation + .iter() + .map(|entry| entry.own.play_count) + .sum(); + assert_eq!( + breakdown.inherited.play_count, descendant_own, + "limit={limit} 时 inherited 必须等于参与计算的后代 own 之和" + ); + assert_eq!(by_generation_total(&breakdown), descendant_own); + assert_eq!(direct_children_total(&breakdown), descendant_own); + assert_eq!( + breakdown.total.play_count, + breakdown.own.play_count + breakdown.inherited.play_count + ); + assert_eq!(generation_game_count(&breakdown), breakdown.node_count); + } + } + + /// 指标集合按结构体合并:改一个字段不会波及递归与归因的形状。 + #[test] + fn totals_merge_is_the_single_extension_point() { + let mut left = ContributionTotals::from_play_count(2); + left += ContributionTotals::from_play_count(3); + assert_eq!(left.play_count, 5); + assert_eq!( + (ContributionTotals::from_play_count(u64::MAX) + + ContributionTotals::from_play_count(1)) + .play_count, + u64::MAX + ); + } +} diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index f23ea0a31..c895bba8f 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -1,6 +1,7 @@ mod application; mod collection; mod commands; +mod contribution; mod domain; mod errors; mod events; @@ -32,6 +33,12 @@ pub use collection::{ pub use commands::{ CreateGameInput, CreateVersionInput, IdempotencyRequest, PurchaseGameInput, ReviewDecision, }; +pub use contribution::{ + ContributionBreakdown, ContributionChildBreakdown, ContributionGenerationBreakdown, + ContributionNode, ContributionTotals, ContributionTruncationReason, + GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT, GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT, + build_contribution_breakdown, +}; pub use domain::{ FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL, ForkAuthorization, GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS,