From dd605a030550cdfd7b6cb4fda03e10bd1c90f48f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 01:47:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20M3=20=E6=97=8F=E8=B0=B1?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E6=B3=A8=E9=87=8A=E5=B9=B6=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E5=9B=BE=E8=A3=85=E9=85=8D=E7=BA=AF=E5=87=BD=E6=95=B0=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 spacetime-module 里 get_game_distribution_lineage_and_return 的文档注释:锚点不存在/已软删除/未公开一律 found=false(api-server 映射 404),并写明理由——用空标题占位或空树代替 404 等于确认该 gameId 的存在、代际与血缘位置 - 修正 shared-contracts 里节点 status 的注释:当前只有已公开作品会成为节点(未公开节点被过滤、未公开锚点直接 404),线上取值实际恒为 published;保留该字段是为将来允许非公开节点进入树时不必再改契约 - 新增纯函数 assemble_lineage_tree_entries:家族归属校验(只保留 root_game_id 命中的血缘行)、根行以 0 代入集合、游戏行缺失的行安全跳过、重复行去重(根行优先);module 侧只负责取行与按根查索引 - 新增纯函数 resolve_lineage_author_name:账号昵称优先(trim 后非空)→ 退回发布时冻结的作者名 → 两者都空白则 None(不占位) - 新增纯函数 lineage_entry_from_game_facts:族谱装配与衍生列表共用同一份字段映射 - module 删除 game_distribution_lineage_entry,衍生列表 tx 改用共用构造函数并复用同一份行事实折叠 - 新增 6 条单测:整族装配、旁支排除、缺行跳过、无根行回退、重复行去重、作者名归一三态 - 本批未改前端;package.json 与 lineage e2e 脚本属第三个 session,未触碰 --- .../module-game-distribution/src/lib.rs | 6 +- .../module-game-distribution/src/lineage.rs | 251 ++++++++++++++++++ .../shared-contracts/src/game_distribution.rs | 12 +- .../spacetime-module/src/game_distribution.rs | 94 +++---- 4 files changed, 313 insertions(+), 50 deletions(-) diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index c5c2d1f0b..bd622e73b 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -30,8 +30,10 @@ pub use domain::{ pub use errors::{GameDistributionError, GameDistributionFieldError}; pub use events::GameDistributionEvent; pub use lineage::{ - GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, LineageNodeList, LineageTree, LineageTreeEntry, - LineageTreeNode, build_derived_node_list, build_lineage_tree, lineage_anchor_readable, + GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, LineageGameFacts, LineageLink, LineageNodeList, + LineageResolvedLink, LineageTree, LineageTreeEntry, LineageTreeNode, + assemble_lineage_tree_entries, build_derived_node_list, build_lineage_tree, + lineage_anchor_readable, lineage_entry_from_game_facts, resolve_lineage_author_name, }; pub use package::{ MAX_COMPRESSION_RATIO, MAX_EXPANDED_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_PACKAGE_BYTES, diff --git a/server-rs/crates/module-game-distribution/src/lineage.rs b/server-rs/crates/module-game-distribution/src/lineage.rs index 1325d6c1f..b07fee4d9 100644 --- a/server-rs/crates/module-game-distribution/src/lineage.rs +++ b/server-rs/crates/module-game-distribution/src/lineage.rs @@ -13,6 +13,8 @@ //! 展示层遇到「父不在可见集合里」就按 M1 已确立的口径标注「原作品已不可用」, //! 不猜测、不回显父作品内容。 +use std::collections::HashSet; + use crate::domain::{can_serve_as_fork_source, counts_as_public_derivative}; /// 单次响应最多返回的树节点数;超出时如实标注 `truncated`,不静默丢弃。 @@ -132,6 +134,116 @@ pub fn build_derived_node_list(entries: &[LineageTreeEntry], limit: usize) -> Li LineageNodeList { nodes, truncated } } +/// 游戏行的事实:调用方从游戏表取出后折叠成这些字段,不携带任何 `ReducerContext` 依赖。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageGameFacts { + pub game_id: String, + pub title: String, + pub author_name: Option, + pub play_count: u64, + pub is_deleted: bool, + pub is_published: bool, + pub status: String, + pub created_at_micros: i64, +} + +/// 血缘行的事实:谁改编自谁、属于哪个根、第几代。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageLink { + /// 子作品。 + pub game_id: String, + pub parent_game_id: String, + pub root_game_id: String, + pub generation: u32, +} + +/// 血缘行 + 它指向的游戏行事实;游戏行缺失(脏数据 / 历史遗留)时 `game` 为 `None`。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageResolvedLink { + pub link: LineageLink, + pub game: Option, +} + +/// 把「根作品行 + 以该根为祖先的血缘行」装配成族谱树输入行。 +/// +/// 这一层是树形状正确性的根,规则全部收在这里并脱离 SpacetimeDB 可单测: +/// - **家族归属**:只保留 `root_game_id` 命中的血缘行(调用方已按根索引取过一次,这里是显式 +/// 不变量,避免将来换查询写法时把旁支混进树里); +/// - **根自身也在集合里**:根行以 0 代、无父进入;根行缺失时不占位(由展示层判「母版不可用」); +/// - **游戏行缺失就跳过**:不发只有 ID 的半残节点,也不 panic; +/// - **重复行去重**:同一作品只保留第一次出现,根行优先于血缘行。 +pub fn assemble_lineage_tree_entries( + root_game_id: &str, + root: Option, + links: &[LineageResolvedLink], +) -> Vec { + let mut entries = Vec::new(); + if let Some(root) = root { + entries.push(lineage_entry_from_game_facts(root, 0, None)); + } + for resolved in links { + if resolved.link.root_game_id != root_game_id { + continue; + } + let Some(game) = resolved.game.clone() else { + continue; + }; + entries.push(lineage_entry_from_game_facts( + game, + resolved.link.generation, + Some(resolved.link.parent_game_id.clone()), + )); + } + let mut seen = HashSet::new(); + entries.retain(|entry| seen.insert(entry.game_id.clone())); + entries +} + +/// 族谱节点的作者名:账号昵称优先(非空才用),否则退回发布时冻结的作者名; +/// 两者都是空串或纯空白时返回 `None`——节点上没有可渲染的名字就不占位。 +/// +/// 注意与 M1 的 `fork_lineage_visible_identity` 的差异:那个函数刻意保留作者名三态 +/// (`Some("")` / `Some(" ")` 原样透传),因为溯源卡由展示层按 falsy 判断是否渲染 +/// 「由 X 制作」,两种取值渲染结果等价;节点侧则不该把空串当成一个名字发出去。 +pub fn resolve_lineage_author_name( + account_display_name: Option<&str>, + frozen_author_name: Option<&str>, +) -> Option { + if let Some(display_name) = account_display_name { + let trimmed = display_name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + frozen_author_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) +} + +/// 由行事实 + 血缘关系构造一条树输入行。 +/// +/// 公开给调用方复用:族谱树走 [`assemble_lineage_tree_entries`],衍生列表则由调用方按 +/// `parent_game_id` 取直接子代后逐条调用本函数——两处的字段映射因此只有一份。 +pub fn lineage_entry_from_game_facts( + facts: LineageGameFacts, + generation: u32, + parent_game_id: Option, +) -> LineageTreeEntry { + LineageTreeEntry { + game_id: facts.game_id, + title: facts.title, + author_name: facts.author_name, + generation, + parent_game_id, + play_count: facts.play_count, + is_deleted: facts.is_deleted, + is_published: facts.is_published, + status: facts.status, + created_at_micros: facts.created_at_micros, + } +} + /// 稳定排序:代际升序 → 创建时间升序 → gameId 升序。 /// /// 最后一级是按 ID 的确定性兜底,避免同代同刻的作品在两次读之间换序(截断点会随之漂移)。 @@ -422,4 +534,143 @@ mod lineage_tree_tests { GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT ); } + + fn facts(game_id: &str, is_deleted: bool, is_published: bool) -> LineageGameFacts { + LineageGameFacts { + game_id: game_id.to_string(), + title: format!("作品 {game_id}"), + author_name: Some(format!("作者 {game_id}")), + play_count: 1, + is_deleted, + is_published, + status: if is_published { + "published" + } else { + "unpublished" + } + .to_string(), + created_at_micros: 10, + } + } + + fn link(game_id: &str, parent: &str, root: &str, generation: u32) -> LineageResolvedLink { + LineageResolvedLink { + link: LineageLink { + game_id: game_id.to_string(), + parent_game_id: parent.to_string(), + root_game_id: root.to_string(), + generation, + }, + game: Some(facts(game_id, false, true)), + } + } + + fn assembled_ids(entries: &[LineageTreeEntry]) -> Vec<&str> { + entries.iter().map(|entry| entry.game_id.as_str()).collect() + } + + /// 装配是树形状正确性的根:根行进集合(0 代、无父),同根的整族(各分支 + 深层后代) + /// 都进去,且后代保留自己的代际与父引用。 + #[test] + fn assembly_keeps_root_and_whole_family_of_its_root() { + let entries = assemble_lineage_tree_entries( + "root", + Some(facts("root", false, true)), + &[ + link("child-a", "root", "root", 1), + link("child-b", "root", "root", 1), + link("grandchild", "child-a", "root", 2), + ], + ); + assert_eq!( + assembled_ids(&entries), + vec!["root", "child-a", "child-b", "grandchild"] + ); + assert_eq!(entries[0].generation, 0, "根自身是 0 代"); + assert_eq!(entries[0].parent_game_id, None, "根自身没有父"); + let grandchild = entries + .iter() + .find(|entry| entry.game_id == "grandchild") + .expect("深层后代应进入集合"); + assert_eq!(grandchild.generation, 2); + assert_eq!(grandchild.parent_game_id.as_deref(), Some("child-a")); + } + + /// 不属于该根的血缘行一律排除,避免把旁支混进树里。 + #[test] + fn assembly_excludes_links_from_other_roots() { + let entries = assemble_lineage_tree_entries( + "root", + Some(facts("root", false, true)), + &[ + link("child-a", "root", "root", 1), + link("outsider", "other-root", "other-root", 1), + ], + ); + assert_eq!(assembled_ids(&entries), vec!["root", "child-a"]); + } + + /// 游戏行缺失(脏数据 / 历史遗留)时跳过该行:不 panic,也不产生只有 ID 的半残节点。 + #[test] + fn assembly_skips_links_whose_game_row_is_missing() { + let mut broken = link("ghost", "root", "root", 1); + broken.game = None; + let entries = assemble_lineage_tree_entries( + "root", + Some(facts("root", false, true)), + &[broken, link("child-a", "root", "root", 1)], + ); + assert_eq!(assembled_ids(&entries), vec!["root", "child-a"]); + assert!(entries.iter().all(|entry| entry.game_id != "ghost")); + } + + /// 根行缺失(历史数据)时仍装配出后代:根不占位,由展示层按 `rootGameId` 判「母版不可用」。 + #[test] + fn assembly_works_without_the_root_row() { + let entries = + assemble_lineage_tree_entries("root", None, &[link("child-a", "root", "root", 1)]); + assert_eq!(assembled_ids(&entries), vec!["child-a"]); + } + + /// 重复行去重且保留第一次出现:同一作品既有根行又有血缘行时,根的身份(0 代、无父)优先。 + #[test] + fn assembly_dedupes_keeping_the_first_occurrence() { + let entries = assemble_lineage_tree_entries( + "root", + Some(facts("root", false, true)), + &[ + link("root", "someone", "root", 3), + link("root", "someone", "root", 3), + ], + ); + assert_eq!(assembled_ids(&entries), vec!["root"]); + assert_eq!(entries[0].generation, 0, "根行优先,不被血缘行改写"); + assert_eq!(entries[0].parent_game_id, None); + } + + /// 作者名归一:账号昵称优先(并 trim)→ 退回冻结名 → 两者都空白则 `None`(节点不占位)。 + #[test] + fn author_name_prefers_account_then_frozen_then_none() { + assert_eq!( + resolve_lineage_author_name(Some("账号昵称"), Some("冻结名")), + Some("账号昵称".to_string()) + ); + assert_eq!( + resolve_lineage_author_name(Some(" 账号昵称 "), Some("冻结名")), + Some("账号昵称".to_string()), + "账号昵称去首尾空白后透传" + ); + // 账号信息不可得(或昵称为空白)时退回发布时冻结的作者名。 + assert_eq!( + resolve_lineage_author_name(None, Some("冻结名")), + Some("冻结名".to_string()) + ); + assert_eq!( + resolve_lineage_author_name(Some(" "), Some("冻结名")), + Some("冻结名".to_string()) + ); + // 两者都没有可渲染的名字 → 不占位。 + assert_eq!(resolve_lineage_author_name(None, None), None); + assert_eq!(resolve_lineage_author_name(Some(""), Some(" ")), None); + } } diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index ff2cf0a71..64803eb9d 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -270,7 +270,9 @@ pub struct GameDistributionLineageNode { #[serde(default)] pub parent_game_id: Option, pub play_count: u64, - /// 可见性;未公开的锚点节点原样回传 `unpublished` / `suspended`。 + /// 可见性。当前**只有已公开作品会成为节点**(未公开节点被过滤、未公开锚点直接 404), + /// 所以线上取值实际恒为 `published`;保留该字段是为了将来允许非公开节点进入树时 + /// 不必再改契约,届时展示层按此渲染状态。 pub status: GameDistributionVisibility, } @@ -363,6 +365,14 @@ pub struct GameDistributionCreateGameRequest { pub device_support: GameDistributionDeviceSupport, pub input_modes: Vec, pub orientation: GameDistributionOrientation, + /// 上架时选择的共创授权档位;缺省按「禁止共创」解释(与表列默认一致,旧客户端不受影响)。 + /// + /// 写成非 `Option` + `#[serde(default)]` 而不是 `Option<..>`:省略与显式传 `forbidden` + /// 得到同一个值,因此 `create_game` 的幂等摘要(对整个请求体取摘要)也相同——省略与显式 + /// 传默认不会变成两个不同的请求;未知取值由反序列化直接拒绝(api-server 映射 400), + /// 不会静默落成 `forbidden`。 + #[serde(default)] + pub fork_authorization: GameDistributionForkAuthorization, /// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 #[serde(default)] pub fork: Option, diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 7c4d8ce32..6ae385821 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -2013,8 +2013,11 @@ pub fn get_public_game_distribution_game_and_return( /// 返回以该作品根为顶的整棵创作族谱树;公开只读,匿名可读。 /// -/// 目标作品不存在或已软删除时 `found = false`(api-server 映射 404);未公开但未删除的作品 -/// 仍可读(作者页入口),自身作为锚点保留、身份字段按可见性降级。 +/// 锚点必须公开可读:不存在、已软删除或未公开(`visibility != published`)一律 `found = false` +/// (api-server 映射 404)。**不允许**用「标题降级为空的锚点节点」或空树代替 404——那等于对外 +/// 确认该 gameId 存在、它是第几代、它在血缘里的位置,本身就是未公开作品的信息泄露。 +/// 树内也只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 +/// `parent_game_id`,不补占位节点。 #[spacetimedb::procedure] pub fn get_game_distribution_lineage_and_return( ctx: &mut ProcedureContext, @@ -4173,9 +4176,9 @@ fn game_distribution_derived_games_tx( .game_distribution_game() .game_id() .find(&lineage.game_id)?; - Some(game_distribution_lineage_entry( - ctx, - &child, + // 字段映射只有一份:复用与族谱装配同一个构造函数,避免两条路径各写一套。 + Some(module_game_distribution::lineage_entry_from_game_facts( + game_distribution_lineage_game_facts(ctx, &child), lineage.generation, Some(lineage.parent_game_id.clone()), )) @@ -4234,57 +4237,52 @@ fn get_game_distribution_fork_source_tx( /// 折叠族谱树的输入行:根作品自身 + 所有以该根为祖先的血缘行。 /// -/// 只读行事实,不做任何可见性判断——「哪些节点能出现、身份字段要不要降级」全部由 -/// `module_game_distribution::lineage` 的纯函数决定,避免同一口径长出第二套实现。 +/// 这里只负责取行(根游戏行、按根索引命中的血缘行、以及每行引用的游戏行), +/// 「家族归属校验、根自身入集合、游戏行缺失跳过、重复行去重」全部交给 +/// `module_game_distribution::assemble_lineage_tree_entries` 这个纯函数, +/// 既避免同一口径长出第二套实现,也让树形状能在不起 `ReducerContext` 的前提下被单测钉住。 fn game_distribution_lineage_entries( ctx: &ReducerContext, root_game_id: &str, ) -> Vec { - let mut entries = Vec::new(); - if let Some(root) = ctx + let root = ctx .db .game_distribution_game() .game_id() .find(&root_game_id.to_string()) - { - entries.push(game_distribution_lineage_entry(ctx, &root, 0, None)); - } - for lineage in ctx + .map(|game| game_distribution_lineage_game_facts(ctx, &game)); + let resolved_links = ctx .db .game_distribution_lineage() .by_game_distribution_lineage_root_game_id() .filter(&root_game_id.to_string()) - { - let Some(game) = ctx - .db - .game_distribution_game() - .game_id() - .find(&lineage.game_id) - else { - continue; - }; - entries.push(game_distribution_lineage_entry( - ctx, - &game, - lineage.generation, - Some(lineage.parent_game_id.clone()), - )); - } - entries + .map(|lineage| module_game_distribution::LineageResolvedLink { + link: module_game_distribution::LineageLink { + game_id: lineage.game_id.clone(), + parent_game_id: lineage.parent_game_id.clone(), + root_game_id: lineage.root_game_id.clone(), + generation: lineage.generation, + }, + game: ctx + .db + .game_distribution_game() + .game_id() + .find(&lineage.game_id) + .map(|game| game_distribution_lineage_game_facts(ctx, &game)), + }) + .collect::>(); + module_game_distribution::assemble_lineage_tree_entries(root_game_id, root, &resolved_links) } -fn game_distribution_lineage_entry( +/// 把一个游戏行折叠成族谱装配用的行事实(含作者名的读时联账号)。 +fn game_distribution_lineage_game_facts( ctx: &ReducerContext, game: &GameDistributionGame, - generation: u32, - parent_game_id: Option, -) -> module_game_distribution::LineageTreeEntry { - module_game_distribution::LineageTreeEntry { +) -> module_game_distribution::LineageGameFacts { + module_game_distribution::LineageGameFacts { game_id: game.game_id.clone(), title: game.title.clone(), author_name: game_distribution_lineage_author_name(ctx, game), - generation, - parent_game_id, play_count: game.play_count, is_deleted: game.deleted_at.is_some(), is_published: game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, @@ -4296,20 +4294,22 @@ fn game_distribution_lineage_entry( /// 族谱节点的作者名:与公开详情同样读时联账号表(账号改名/换头像立即跟随), /// 账号信息不可得时退回发布时冻结的作者名。 /// -/// 与公开投影的差别只有一处:空串与纯空白归一成 `None`(节点上没有可渲染的名字就不占位)。 +/// 归一规则(账号昵称优先、两者都空白则 `None`)放在 +/// `module_game_distribution::resolve_lineage_author_name`,这里只做账号查询。 fn game_distribution_lineage_author_name( ctx: &ReducerContext, game: &GameDistributionGame, ) -> Option { - if let Some(account) = ctx.db.user_account().user_id().find(&game.owner_user_id) { - let display_name = account.display_name.trim().to_string(); - if !display_name.is_empty() { - return Some(display_name); - } - } - game.author_name - .clone() - .filter(|name| !name.trim().is_empty()) + let account_display_name = ctx + .db + .user_account() + .user_id() + .find(&game.owner_user_id) + .map(|account| account.display_name); + module_game_distribution::resolve_lineage_author_name( + account_display_name.as_deref(), + game.author_name.as_deref(), + ) } fn game_distribution_lineage_node_snapshot(