diff --git a/server-rs/crates/module-game-distribution/src/lineage.rs b/server-rs/crates/module-game-distribution/src/lineage.rs index 7870afe50..1325d6c1f 100644 --- a/server-rs/crates/module-game-distribution/src/lineage.rs +++ b/server-rs/crates/module-game-distribution/src/lineage.rs @@ -48,6 +48,11 @@ pub struct LineageTreeEntry { } /// 对外节点;`author_name` 为 `None` 表示作者信息不可得(账号与冻结资料都没有名字)。 +/// +/// 作者名的空值语义:调用方(`spacetime-module`)把空串与纯空白归一成 `None`——节点上没有 +/// 可渲染的名字就不占位。这与 M1 的 `fork_lineage_visible_identity` **刻意保留三态** +/// (`Some("")` / `Some(" ")` 原样透传)不同,是有意为之:溯源卡由展示层按 falsy 判断是否 +/// 渲染「由 X 制作」,两种取值在两端渲染结果等价,但节点侧不该把空串当成一个名字发出去。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct LineageTreeNode { pub game_id: String, @@ -175,7 +180,12 @@ mod lineage_tree_tests { play_count: generation as u64 * 10, is_deleted, is_published, - status: if is_published { "published" } else { "unpublished" }.to_string(), + status: if is_published { + "published" + } else { + "unpublished" + } + .to_string(), created_at_micros, } } @@ -185,7 +195,10 @@ mod lineage_tree_tests { } fn node_ids(tree: &LineageTree) -> Vec<&str> { - tree.nodes.iter().map(|node| node.game_id.as_str()).collect() + tree.nodes + .iter() + .map(|node| node.game_id.as_str()) + .collect() } /// 锚点可读性:四种组合逐一钉死。「已软删除 + 可见性仍是 published」必须为 false—— @@ -215,13 +228,11 @@ mod lineage_tree_tests { entry("removed", 1, 2, true, true), published("alive", 1, 3), ]; - let tree = build_lineage_tree(&entries, "root", 200); + let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); assert_eq!(node_ids(&tree), vec!["root", "alive"]); assert!( - tree.nodes - .iter() - .all(|node| node.title != "作品 removed" - && node.author_name.as_deref() != Some("作者 removed")), + tree.nodes.iter().all(|node| node.title != "作品 removed" + && node.author_name.as_deref() != Some("作者 removed")), "被删作品的标题与作者名都不得出现在响应里" ); } @@ -235,15 +246,16 @@ mod lineage_tree_tests { entry("hidden", 1, 2, false, false), published("alive", 1, 3), ]; - let tree = build_lineage_tree(&entries, "root", 200); + let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); assert_eq!(node_ids(&tree), vec!["root", "alive"]); // 被点名的锚点未公开时也不得出现(没有「自身豁免」)。 - let unpublished_anchor = [ - published("root", 0, 1), - entry("anchor", 1, 2, false, false), - ]; - let tree = build_lineage_tree(&unpublished_anchor, "root", 200); + let unpublished_anchor = [published("root", 0, 1), entry("anchor", 1, 2, false, false)]; + let tree = build_lineage_tree( + &unpublished_anchor, + "root", + GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, + ); assert_eq!(node_ids(&tree), vec!["root"]); assert!(tree.nodes.iter().all(|node| node.game_id != "anchor")); } @@ -256,7 +268,7 @@ mod lineage_tree_tests { published("mid", 1, 2), published("leaf", 2, 3), ]; - let tree = build_lineage_tree(&entries, "root", 200); + let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); assert_eq!(node_ids(&tree), vec!["mid", "leaf"]); assert!(tree.root.is_none(), "根不可用时 root 降级为空"); let mid = tree @@ -279,7 +291,7 @@ mod lineage_tree_tests { #[test] fn visible_root_is_exposed_as_tree_top() { let entries = [published("root", 0, 5), published("child", 1, 6)]; - let tree = build_lineage_tree(&entries, "root", 200); + let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); let root = tree.root.expect("根应可见"); assert_eq!(root.game_id, "root"); assert_eq!(root.title, "作品 root"); @@ -302,7 +314,7 @@ mod lineage_tree_tests { assert!(tree.truncated); assert_eq!(node_ids(&tree), vec!["g0", "g1"]); - let full = build_lineage_tree(&entries, "g0", 200); + let full = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); assert!(!full.truncated); assert_eq!(node_ids(&full).len(), 4); } @@ -316,7 +328,7 @@ mod lineage_tree_tests { published("g1-b", 1, 10), published("g1-a", 1, 10), ]; - let tree = build_lineage_tree(&entries, "g0", 200); + let tree = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); assert_eq!( node_ids(&tree), vec!["g0", "g1-a", "g1-b", "g1-late"], @@ -328,7 +340,7 @@ mod lineage_tree_tests { #[test] fn duplicate_entries_collapse_to_one_node() { let entries = [published("root", 0, 1), published("root", 0, 1)]; - let tree = build_lineage_tree(&entries, "root", 200); + let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); assert_eq!(node_ids(&tree), vec!["root"]); } @@ -342,7 +354,7 @@ mod lineage_tree_tests { entry("child-removed", 3, 3, true, true), entry("child-hidden", 3, 4, false, false), ]; - let list = build_derived_node_list(&entries, 200); + let list = build_derived_node_list(&entries, GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); let ids = list .nodes .iter() @@ -369,4 +381,45 @@ mod lineage_tree_tests { .collect::>(); assert_eq!(ids, vec!["early", "mid"]); } + + /// 节点上限是**合同值**:一条断言同时钉住「常量本身」与「动作行为」。 + /// + /// 只按常量符号构造输入不够——改常量时输入会一起变,测试仍绿。所以这里既断言 + /// `200` 这个对外承诺值(技术方案 §3.4 与里程碑验收都写它),又用常量本身构造 + /// 满量 / 超量输入,保证「改常量」和「改截断实现」都必须过这道门。 + #[test] + fn node_limit_constant_is_pinned_and_governs_truncation() { + assert_eq!( + GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, 200, + "节点上限是合同值(技术方案 §3.4 与里程碑验收都引用 200);改动需同步契约与文档" + ); + + let mut entries = (0..GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT) + .map(|index| published(&format!("g{index}"), 0, index as i64)) + .collect::>(); + + // 恰好等于上限:不算截断。 + let exact = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); + assert!(!exact.truncated); + assert_eq!(exact.nodes.len(), GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); + + // 多一个:截断到常量值,并如实标注。 + entries.push(published("overflow", 0, 10_000)); + let overflowed = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); + assert!(overflowed.truncated); + assert_eq!( + overflowed.nodes.len(), + GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, + "截断后节点数必须等于常量值" + ); + + // 衍生列表共用同一个上限常量,同样在超量时标注截断。 + let derived_overflow = + build_derived_node_list(&entries, GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); + assert!(derived_overflow.truncated); + assert_eq!( + derived_overflow.nodes.len(), + GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT + ); + } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_input_type.rs new file mode 100644 index 000000000..5575203b6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionForkSourceInput { + pub game_id: String, +} + +impl __sdk::InModule for GameDistributionForkSourceInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_result_type.rs new file mode 100644 index 000000000..7d4f39408 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fork_source_result_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionForkSourceResult { + pub ok: bool, + pub found: bool, + pub available: bool, + pub fork_authorization: String, + pub version_id: Option, + pub package_sha_256: Option, + pub package_bytes: Option, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionForkSourceResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_fork_source_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_fork_source_and_return_procedure.rs new file mode 100644 index 000000000..6ab77ba98 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_fork_source_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_fork_source_input_type::GameDistributionForkSourceInput; +use super::game_distribution_fork_source_result_type::GameDistributionForkSourceResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetGameDistributionForkSourceAndReturnArgs { + pub input: GameDistributionForkSourceInput, +} + +impl __sdk::InModule for GetGameDistributionForkSourceAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_game_distribution_fork_source_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_game_distribution_fork_source_and_return { + fn get_game_distribution_fork_source_and_return(&self, input: GameDistributionForkSourceInput) { + self.get_game_distribution_fork_source_and_return_then(input, |_, _| {}); + } + + fn get_game_distribution_fork_source_and_return_then( + &self, + input: GameDistributionForkSourceInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_game_distribution_fork_source_and_return for super::RemoteProcedures { + fn get_game_distribution_fork_source_and_return_then( + &self, + input: GameDistributionForkSourceInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionForkSourceResult>( + "get_game_distribution_fork_source_and_return", + GetGameDistributionForkSourceAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index b86f6b36b..d583cf32b 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -1022,6 +1022,67 @@ describe('GameLineagePage', () => { ).toBeTruthy(); }); + it('没有任何可见节点时只渲染一条整体空态,不叠加衍生空态', async () => { + getGameLineageMock.mockResolvedValueOnce({ + rootGameId: 'game-root', + root: null, + nodes: [], + truncated: false, + }); + render( + , + ); + + expect( + await screen.findByText('这部作品还没有可展示的族谱节点。'), + ).toBeTruthy(); + // 双重空态回归:整体空态已覆盖「没有节点」,不能再叠一条衍生空态。 + expect( + screen.queryByText('这部作品还没有衍生作品,欢迎从它开始共创。'), + ).toBeNull(); + expect(document.querySelectorAll('.game-lineage-tree')).toHaveLength(0); + }); + + it('只有母版时渲染母版节点并只给衍生空态,不给整体空态', async () => { + getGameLineageMock.mockResolvedValueOnce({ + rootGameId: 'game-root', + root: createLineageNode({ + gameId: 'game-root', + title: '母版', + generation: 0, + parentGameId: null, + }), + nodes: [ + createLineageNode({ + gameId: 'game-root', + title: '母版', + generation: 0, + parentGameId: null, + }), + ], + truncated: false, + }); + render( + , + ); + + expect(await screen.findByRole('button', { name: '母版' })).toBeTruthy(); + expect( + screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'), + ).toBeTruthy(); + expect( + screen.queryByText('这部作品还没有可展示的族谱节点。'), + ).toBeNull(); + }); + it('超出节点上限时如实标注截断,不静默丢弃', async () => { getGameLineageMock.mockResolvedValueOnce({ rootGameId: 'game-root', diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 43893bbe5..ee23259bb 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -211,47 +211,54 @@ export function GameLineagePage({ 母版已不可用,下面按代际关系展示仍可阅读的衍生作品。

)} - {groups.length === 0 ? ( + {/* + 空态只有一条,避免「双重空态」:`nodes` 为空说明整棵树没有任何可见节点 + (连母版都不可读),此时只给整体空态;只有「有节点但没有任何衍生」时才给衍生空态。 + 两者互斥,不会同时出现。 + */} + {nodes.length === 0 ? ( 这部作品还没有可展示的族谱节点。 ) : ( -
- {groups.map(([generation, groupNodes]) => ( -
-

- {generationLabel(generation)} - - {groupNodes.length} - -

-
- {groupNodes.map((node) => ( - - ))} -
-
- ))} -
+ <> +
+ {groups.map(([generation, groupNodes]) => ( +
+

+ {generationLabel(generation)} + + {groupNodes.length} + +

+
+ {groupNodes.map((node) => ( + + ))} +
+
+ ))} +
+ {derivedCount === 0 ? ( + + 这部作品还没有衍生作品,欢迎从它开始共创。 + + ) : null} + )} - {derivedCount === 0 ? ( - - 这部作品还没有衍生作品,欢迎从它开始共创。 - - ) : null} ) : null} diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 2b499a917..f11efce32 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -1258,7 +1258,9 @@ } .game-lineage-generation__nodes { display: grid; - grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); + /* `min(13rem, 100%)`:桌面端列宽不变,视口窄于 13rem 时下限跟着收缩, + 避免固定下限把内容撑出容器产生横向溢出。 */ + grid-template-columns: repeat(auto-fill, minmax(min(13rem, 100%), 1fr)); gap: 0.6rem; } .game-lineage-node {