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 {