From 540d1ed6353ae65d213f0a54fbe263330c7f52a4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 01:02:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=B8=B8=E6=88=8F=E5=85=B1?= =?UTF-8?q?=E5=88=9B=20M3=EF=BC=9A=E5=88=9B=E4=BD=9C=E6=97=8F=E8=B0=B1?= =?UTF-8?q?=E6=A0=91=E4=B8=8E=E8=A1=8D=E7=94=9F=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 module-game-distribution/src/lineage.rs:锚点可读性、可见性过滤、稳定排序、上限截断的纯函数,附 15 个单测(复用 M1 的软删除判定,不重写第二套规则) - 新增公开只读 procedure get_game_distribution_lineage / list_game_distribution_derived_games;锚点必须公开可读(未公开/已软删除/不存在一律按不存在处理),树内只出现未软删除且已公开的节点,不补 null 占位节点 - api-server 新增 GET /games/{id}/lineage 与 /games/{id}/derived(匿名可读、no-store),响应走结构化 DTO,节点键集合由测试钉死,杜绝下发对象键/素材键 - 作者自有游戏聚合追加 forkCount(与公开详情 forkCount 同口径),供「被改编 N」入口使用 - shared-contracts 与 TS 契约新增 LineageNode / LineageResponse / DerivedResponse 三型并登记 DTO 一致性检查 - 前端新增 /games/lineage 族谱页,登记 stage 类型、路由表、页面标题与壳层渲染分支;详情页共创卡新增「查看创作族谱」入口(仅已公开作品) - 我的作品页新增「被改编 N」行内展开,列出直接子代的标题/作者/代际/状态,含空态与失败重试(仅已公开作品) - 补前端组件/路由/标题/客户端用例与 api-server 路由、负载映射用例;同步里程碑验收标准与技术方案 §3.4 的锚点可读性口径 --- ...【里程碑】创作族谱与衍生列表-2026-10-03.md | 4 +- ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 4 +- .../shared/src/contracts/gameDistribution.ts | 40 ++ .../check-game-distribution-dto-parity.mjs | 3 + .../src/modules/game_distribution.rs | 286 +++++++++++++- .../module-game-distribution/src/lib.rs | 5 + .../module-game-distribution/src/lineage.rs | 372 ++++++++++++++++++ .../shared-contracts/src/game_distribution.rs | 48 +++ .../spacetime-client/src/active/mapper.rs | 10 +- .../src/active/mapper/game_distribution.rs | 143 +++++++ .../spacetime-client/src/game_distribution.rs | 44 +++ .../spacetime-client/src/module_bindings.rs | 26 +- ...e_distribution_derived_games_input_type.rs | 15 + ..._distribution_derived_games_result_type.rs | 22 ++ .../game_distribution_lineage_input_type.rs | 15 + ...distribution_lineage_node_snapshot_type.rs | 21 + .../game_distribution_lineage_result_type.rs | 23 ++ ...e_distribution_owner_game_snapshot_type.rs | 1 + ...stribution_lineage_and_return_procedure.rs | 59 +++ ...tion_derived_games_and_return_procedure.rs | 62 +++ .../spacetime-module/src/game_distribution.rs | 357 +++++++++++++++++ .../game-distribution/GameDetailPage.tsx | 25 ++ .../GameDistributionPages.test.tsx | 263 +++++++++++++ .../game-distribution/GameLineagePage.tsx | 265 +++++++++++++ .../game-distribution/MyGamesPage.test.tsx | 104 +++++ .../game-distribution/MyGamesPage.tsx | 136 +++++++ .../game-distribution/gameDistribution.css | 213 ++++++++++ .../PlatformEntryActiveFlowShell.test.tsx | 115 ++++++ .../PlatformEntryActiveFlowShell.tsx | 41 ++ .../platformEntryActiveTypes.ts | 1 + src/routing/activeAppPageRoutes.test.ts | 6 + src/routing/activeAppPageRoutes.ts | 1 + src/services/activeAppTitle.test.ts | 3 + src/services/activeAppTitle.ts | 1 + src/services/gameDistributionClient.test.ts | 38 ++ src/services/gameDistributionClient.ts | 35 ++ 36 files changed, 2780 insertions(+), 27 deletions(-) create mode 100644 server-rs/crates/module-game-distribution/src/lineage.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_lineage_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_derived_games_and_return_procedure.rs create mode 100644 src/components/game-distribution/GameLineagePage.tsx diff --git a/docs/project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md b/docs/project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md index a96bf9aa2..ef58bccfb 100644 --- a/docs/project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md +++ b/docs/project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md @@ -37,7 +37,7 @@ - [ ] 未登录用户可以浏览族谱页,不触发登录门禁;已下架但仍有血缘的节点保留在树上并标注原作品不可用,不出现空白或断链。 - [ ] 超出节点上限时如实标注已截断,不静默丢弃;空族谱(无任何衍生)给出明确空态而不是报错。 - [ ] 我的作品页「被改编」列表只包含直接衍生作品,数量与族谱树中该作品的子节点一致。 -- [ ] 族谱与衍生列表的公开数据不泄露未公开作品、被隐藏内容或对象键。 +- [ ] 族谱与衍生列表的公开数据不泄露未公开作品、被隐藏内容或对象键;**锚点(URL 里点名的作品)必须公开可读**——未公开、已软删除或不存在的作品,两个接口一律返回 404,不得用「空标题占位节点」或空树代替 404(那等于确认该作品存在、其代际与血缘位置)。 - [ ] 灰度未命中时族谱入口不渲染,直接访问路由给出与现役未知路径一致的降级行为。 - [ ] 桌面与移动端布局可用,长标题与较大数字不撑破容器。 @@ -45,4 +45,4 @@ - 自动化:族谱与衍生列表的排序、截断、可见性过滤定向测试;路由与前端组件定向测试;DTO 一致性与编码、文档索引检查。 - 运行时:真实数据上打开族谱页与「被改编」列表,包含一条来源已下架的链路;桌面与窄屏浏览器验证跳转与布局。 -- 边界:无衍生作品的空族谱、超上限截断、来源下架、未公开子作品不出现在树中、未登录访问。 +- 边界:无衍生作品的空族谱、超上限截断、来源下架、未公开子作品不出现在树中、未公开/已软删除锚点返回 404、未登录访问。 diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 996aa162f..8a30fcf6e 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -303,8 +303,8 @@ pub(crate) project_bundle_sha256: Option, | 方法 / 路径 | 说明 | | --- | --- | | `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)、`forkSourceAvailable`(布尔,仅表达"能否真复刻",不泄露对象键) | -| `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ root: GameSummary, nodes: [{ gameId, title, author, generation, parentGameId, status, playCount }] }`,按代际与创建时间稳定排序;节点上限 200,超出返回 `truncated: true` | -| `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表 | +| `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status }`,按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点 | +| `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 | #### 作者(Bearer + 发布灰度) diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 10e530625..fec2488e2 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -185,6 +185,46 @@ export type GameDistributionLineage = { parentAuthorName?: string | null; }; +/** + * 族谱树上的单个作品节点。 + * + * 只有公开且未软删除的作品会成为节点(锚点不可读时接口直接 404),因此 `title` 必然存在; + * `authorName` 为 null 表示作者信息不可得。`parentGameId` 指向的作品可能不在树里 + * (父作品已删/未公开),展示层据此把该节点标注为「原作品不可用」,不猜测父作品内容。 + * 节点只带作品级公开信息,不含对象键、素材键或 owner user id 之外的私有字段。 + */ +export type GameDistributionLineageNode = { + gameId: string; + title: string; + authorName?: string | null; + /** 代际;母版为 0。 */ + generation: number; + /** 父作品 ID;母版为 null。 */ + parentGameId?: string | null; + playCount: number; + status: GameDistributionGameVisibility; +}; + +/** + * 族谱读接口响应:`nodes` 按代际升序、同代按创建时间升序稳定排序,含根(若根可见); + * 超过节点上限时截断尾部并置 `truncated`,不静默丢弃。 + */ +export type GameDistributionLineageResponse = { + /** 请求作品所属的根作品 ID;根不可用时仍返回,展示层据此标注树顶不可用。 */ + rootGameId: string; + /** 根节点在可见集合中的投影;根已删或未公开时为 null。 */ + root?: GameDistributionLineageNode | null; + nodes: GameDistributionLineageNode[]; + truncated: boolean; +}; + +/** 直接衍生作品(「被改编」)列表响应;只含对外可见的直接子代。 */ +export type GameDistributionDerivedResponse = { + gameId: string; + nodes: GameDistributionLineageNode[]; + truncated: boolean; +}; + export type GameDistributionGame = { id: string; title: string; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 28d376dfd..ddb761b3b 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -60,6 +60,9 @@ const PAIRS = [ 'GameDistributionForkAuthorization', ], ['GameDistributionLineage', 'GameDistributionLineage'], + ['GameDistributionLineageNode', 'GameDistributionLineageNode'], + ['GameDistributionLineageResponse', 'GameDistributionLineageResponse'], + ['GameDistributionDerivedResponse', 'GameDistributionDerivedResponse'], ['GameDistributionForkDeclaration', 'GameDistributionForkDeclaration'], [ 'GameDistributionSetForkAuthorizationRequest', diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index e287b4b78..e6d0cfcd4 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -39,25 +39,29 @@ use shared_contracts::admin::{ use shared_contracts::game_distribution::{ GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, GameDistributionAuthor, GameDistributionCreateGameRequest, - GameDistributionCreateVersionRequest, GameDistributionForkAuthorization, - GameDistributionInputMode, GameDistributionMyReviewResponse, GameDistributionOrientation, + GameDistributionCreateVersionRequest, GameDistributionDerivedResponse, + GameDistributionForkAuthorization, GameDistributionInputMode, + GameDistributionLineageNode, GameDistributionLineageResponse, + GameDistributionMyReviewResponse, GameDistributionOrientation, GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, GameDistributionRatingSummary, GameDistributionReview, GameDistributionReviewsResponse, GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, GameDistributionSetForkAuthorizationRequest, GameDistributionUpdateGameMetadataRequest, + GameDistributionVisibility, }; use spacetime_client::{ GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord, GameDistributionAdminUserReviewListRecordInput, GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, GameDistributionDeleteGameRecordInput, - GameDistributionGameRecord, GameDistributionGetGameRecordInput, - GameDistributionOwnerGameRecord, GameDistributionPublicGameListRecordInput, - GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, - GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, - GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationOperationRecord, - GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, - GameDistributionSubmitReviewRecordInput, + GameDistributionDerivedGamesRecord, GameDistributionGameRecord, + GameDistributionGetGameRecordInput, GameDistributionLineageNodeRecord, + GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord, + GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord, + GameDistributionRatingSummaryRecord, GameDistributionRejectRecordInput, + GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, + GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecordInput, + GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, GameDistributionUserReviewRecord, GameDistributionVersionRecord, SpacetimeClientError, @@ -391,6 +395,14 @@ pub fn router(state: AppState) -> Router { let public_games = Router::new() .route("/api/game-distribution/games", get(list_games)) .route("/api/game-distribution/games/{game_id}", get(get_game)) + .route( + "/api/game-distribution/games/{game_id}/lineage", + get(get_game_lineage), + ) + .route( + "/api/game-distribution/games/{game_id}/derived", + get(get_game_derived_games), + ) .route( "/api/game-distribution/games/{game_id}/plays", post(record_game_play), @@ -1047,6 +1059,96 @@ async fn get_game( Ok(json_success_body(Some(&ctx), public_game_payload(game))) } +/// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。 +/// +/// 抽成函数是为了让这条映射可被单测钉住(不可读 → 404),而不是散落在两个 handler 里。 +fn lineage_read_or_not_found(value: Option) -> Result { + value.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND)) +} + +/// 公开创作族谱:以该作品的母版为顶返回整棵树。公开只读、匿名可读。 +/// +/// 锚点必须公开可读:不存在、未公开或已软删除一律 404,与公开详情同一口径。这里**不** +/// 用「空标题节点」代替 404——那等于对外确认该 gameId 存在、它是第几代、它在血缘里的位置。 +async fn get_game_lineage( + State(state): State, + Extension(ctx): Extension, + Path(game_id): Path, +) -> Result, AppError> { + let tree = lineage_read_or_not_found( + state + .spacetime_client() + .get_game_distribution_lineage(game_id) + .await + .map_err(map_spacetime_error)?, + )?; + Ok(json_success_body(Some(&ctx), lineage_tree_payload(tree))) +} + +/// 公开「被改编」列表:该作品的直接衍生作品(只含未软删除且已公开的直接子代)。 +/// +/// 与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致;锚点不可公开读取时 404。 +async fn get_game_derived_games( + State(state): State, + Extension(ctx): Extension, + Path(game_id): Path, +) -> Result, AppError> { + let derived = lineage_read_or_not_found( + state + .spacetime_client() + .list_game_distribution_derived_games(game_id) + .await + .map_err(map_spacetime_error)?, + )?; + Ok(json_success_body(Some(&ctx), derived_games_payload(derived))) +} + +/// 线上可见性字符串 → DTO 枚举;未知取值按「未公开」保守解释,不会因此多暴露任何信息。 +fn game_distribution_visibility(value: &str) -> GameDistributionVisibility { + match value { + "published" => GameDistributionVisibility::Published, + "suspended" => GameDistributionVisibility::Suspended, + _ => GameDistributionVisibility::Unpublished, + } +} + +fn lineage_node_payload(node: GameDistributionLineageNodeRecord) -> GameDistributionLineageNode { + GameDistributionLineageNode { + game_id: node.game_id, + title: node.title, + author_name: node.author_name, + generation: node.generation, + parent_game_id: node.parent_game_id, + play_count: node.play_count, + status: game_distribution_visibility(node.status.as_str()), + } +} + +/// 族谱 / 衍生列表走结构化 DTO 而不是手拼 JSON:节点字段本来就少且固定, +/// 用 DTO 才能让「不发对象键、不发素材键」由类型保证,而不是靠人肉回忆。 +fn lineage_tree_payload(tree: GameDistributionLineageTreeRecord) -> GameDistributionLineageResponse { + GameDistributionLineageResponse { + root_game_id: tree.root_game_id, + root: tree.root.map(lineage_node_payload), + nodes: tree.nodes.into_iter().map(lineage_node_payload).collect(), + truncated: tree.truncated, + } +} + +fn derived_games_payload( + derived: GameDistributionDerivedGamesRecord, +) -> GameDistributionDerivedResponse { + GameDistributionDerivedResponse { + game_id: derived.game_id, + nodes: derived + .nodes + .into_iter() + .map(lineage_node_payload) + .collect(), + truncated: derived.truncated, + } +} + /// 一次游玩上报的请求体;只有匿名身份需要 `clientId`,登录身份由 bearer 决定。 #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1197,10 +1299,16 @@ async fn get_owner_game( .await .map_err(map_spacetime_error)? .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; - let versions = load_owner_game_versions(&state, owner_user_id, &game_id).await?; + let (versions, fork_count) = load_owner_game_versions(&state, owner_user_id, &game_id).await?; Ok(json_success_body( Some(&ctx), - json!({ "game": owner_game_entry_payload(GameDistributionOwnerGameRecord { game, versions }) }), + json!({ + "game": owner_game_entry_payload(GameDistributionOwnerGameRecord { + game, + versions, + fork_count, + }), + }), )) } @@ -1213,7 +1321,7 @@ async fn load_owner_game_versions( state: &AppState, owner_user_id: String, game_id: &str, -) -> Result, AppError> { +) -> Result<(Vec, u64), AppError> { let games = state .spacetime_client() .list_owner_game_distribution_games( @@ -1227,7 +1335,8 @@ async fn load_owner_game_versions( Ok(games .into_iter() .find(|entry| entry.game.game_id == game_id) - .map(|entry| entry.versions) + // 「被改编 N」与版本列表来自同一份 owner 聚合投影,详情页与列表页因此不会各算一套。 + .map(|entry| (entry.versions, entry.fork_count)) .unwrap_or_default()) } @@ -3093,6 +3202,9 @@ fn owner_game_entry_payload(entry: GameDistributionOwnerGameRecord) -> Value { versions.first().cloned().unwrap_or(Value::Null), ); object.insert("versions".to_string(), Value::Array(versions)); + // 「被改编 N」:与公开详情 `forkCount` 同口径(只算未软删除且已公开的直接子代), + // 作者页入口据此展示;未公开作品的行内入口不会渲染,因为衍生列表要求锚点公开可读。 + object.insert("forkCount".to_string(), json!(entry.fork_count)); payload } @@ -4457,8 +4569,10 @@ mod tests { let entry = owner_game_entry_payload(GameDistributionOwnerGameRecord { game, versions: vec![version], + fork_count: 2, }); assert_eq!(entry["status"], Value::String("unpublished".to_string())); + assert_eq!(entry["forkCount"], Value::Number(2.into())); assert_eq!( entry["versions"][0]["status"], Value::String("rejected".to_string()) @@ -4773,6 +4887,152 @@ mod tests { } } + /// 族谱与衍生列表是公开只读路由:必须挂载、匿名可读、不缓存。 + /// + /// 测试态没有可用数据库,所以证明点是「已挂载并走到 SpacetimeDB」(502), + /// 而不是 404 / 401 / 405;同时路由层必须带 `no-store`。 + #[tokio::test] + async fn lineage_and_derived_routes_are_public_and_no_store() { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + let app = crate::app::build_router( + crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("测试状态应可构建"), + ); + + for uri in [ + "/api/game-distribution/games/game_1/lineage", + "/api/game-distribution/games/game_1/derived", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + response.status(), + StatusCode::BAD_GATEWAY, + "{uri} 必须挂载并走到 SpacetimeDB" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{uri} 是公开读接口,必须不缓存" + ); + } + } + + /// 锚点不可读(未公开 / 已软删除 / 不存在)必须映射成 404,而不是 200 空树。 + /// + /// 模块侧由 `lineage_anchor_readable` 判成 `found = false`,facade 折成 `None`, + /// 这一层把它折成 404;三段串起来才构成「未公开作品不泄露」的回归网。 + #[test] + fn lineage_reads_map_unreadable_anchor_to_not_found() { + assert_eq!( + lineage_read_or_not_found::(None) + .expect_err("不可读锚点必须 404") + .status_code(), + StatusCode::NOT_FOUND + ); + assert_eq!(lineage_read_or_not_found(Some(7)).expect("可读锚点透传"), 7); + } + + /// 族谱 / 衍生列表的负载只发公开节点字段,且未知可见性按「未公开」保守解释。 + #[test] + fn lineage_payloads_expose_only_public_node_fields() { + let root = GameDistributionLineageNodeRecord { + game_id: "game-root".to_string(), + title: "母版".to_string(), + author_name: Some("原作者".to_string()), + generation: 0, + parent_game_id: None, + play_count: 12, + status: "published".to_string(), + }; + let child = GameDistributionLineageNodeRecord { + game_id: "game-child".to_string(), + title: "衍生作品".to_string(), + author_name: None, + generation: 1, + // 父作品不在可见集合里:ID 原样回传,展示层据此降级,不猜测父作品内容。 + parent_game_id: Some("game-removed".to_string()), + play_count: 3, + status: "mystery".to_string(), + }; + let tree = serde_json::to_value(lineage_tree_payload( + GameDistributionLineageTreeRecord { + root_game_id: "game-root".to_string(), + root: Some(root.clone()), + nodes: vec![root, child], + truncated: true, + }, + )) + .expect("族谱负载应可序列化"); + + assert_eq!(tree["rootGameId"], Value::String("game-root".to_string())); + assert_eq!(tree["truncated"], Value::Bool(true)); + assert_eq!(tree["root"]["title"], Value::String("母版".to_string())); + assert_eq!( + tree["nodes"][1]["parentGameId"], + Value::String("game-removed".to_string()) + ); + assert_eq!(tree["nodes"][1]["authorName"], Value::Null); + assert_eq!( + tree["nodes"][1]["status"], + Value::String("unpublished".to_string()) + ); + // 节点键集合必须恰好是契约里的那七个:多一个键就意味着多泄露一类信息。 + // serde_json 的 Map 按字母序输出,所以这里比较排序后的键集合而不是固定顺序。 + let mut node_keys = tree["nodes"][0] + .as_object() + .expect("节点必须是对象") + .keys() + .cloned() + .collect::>(); + node_keys.sort(); + let mut expected_keys = vec![ + "gameId", + "title", + "authorName", + "generation", + "parentGameId", + "playCount", + "status", + ]; + expected_keys.sort(); + assert_eq!(node_keys, expected_keys); + + let derived = serde_json::to_value(derived_games_payload( + GameDistributionDerivedGamesRecord { + game_id: "game-root".to_string(), + nodes: Vec::new(), + truncated: false, + }, + )) + .expect("衍生列表负载应可序列化"); + assert_eq!(derived["gameId"], Value::String("game-root".to_string())); + assert_eq!(derived["nodes"], Value::Array(Vec::new())); + assert_eq!(derived["truncated"], Value::Bool(false)); + + assert_eq!( + game_distribution_visibility("mystery"), + GameDistributionVisibility::Unpublished + ); + assert_eq!( + game_distribution_visibility("suspended"), + GameDistributionVisibility::Suspended + ); + } + #[test] fn recovery_action_covers_every_version_status() { for (status, expected) in [ diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 28ecd0f59..c5c2d1f0b 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -3,6 +3,7 @@ mod commands; mod domain; mod errors; mod events; +mod lineage; mod package; mod release; mod reviews; @@ -28,6 +29,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, +}; pub use package::{ MAX_COMPRESSION_RATIO, MAX_EXPANDED_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_PACKAGE_BYTES, ReleaseFileManifest, ReleasePackageError, ReleasePackageManifest, validate_release_zip, diff --git a/server-rs/crates/module-game-distribution/src/lineage.rs b/server-rs/crates/module-game-distribution/src/lineage.rs new file mode 100644 index 000000000..7870afe50 --- /dev/null +++ b/server-rs/crates/module-game-distribution/src/lineage.rs @@ -0,0 +1,372 @@ +//! 创作族谱(Lineage)树的纯构建规则:锚点可读性、可见性过滤、稳定排序、上限截断。 +//! +//! 这里不碰 `ReducerContext`:调用方(`spacetime-module` 的事务函数)负责把「游戏行 + 血缘行」 +//! 折叠成 [`LineageTreeEntry`],本模块只按纯函数规则产出对外节点。这样可以脱离 SpacetimeDB +//! 直接单测可见性与截断,也避免读路径把「哪些节点能出现」的规则写成第二套。 +//! +//! 三条不可越过的口径: +//! - **锚点必须公开可读**:URL 里点名的作品若不存在、已软删除或未公开,一律按「不存在」处理 +//! (api-server 映射 404)。不允许返回一个「标题降级为空的节点」——那等于对外确认 +//! 「这个 gameId 存在、它是第几代、它在血缘里的位置」,本身就是未公开作品的信息泄露。 +//! - **树里只出现公开且未删除的作品节点**:已软删除或未公开的作品既不是节点,也不外发标题/作者名。 +//! - **不补占位节点**:父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parent_game_id`, +//! 展示层遇到「父不在可见集合里」就按 M1 已确立的口径标注「原作品已不可用」, +//! 不猜测、不回显父作品内容。 + +use crate::domain::{can_serve_as_fork_source, counts_as_public_derivative}; + +/// 单次响应最多返回的树节点数;超出时如实标注 `truncated`,不静默丢弃。 +/// +/// 上限只拦「数量」,不改变排序:先按代际与创建时间稳定排序,再截前缀, +/// 因此被截掉的总是最深的代际,而不是随机节点。 +pub const GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT: usize = 200; + +/// 锚点(URL 里点名的作品)是否可读:必须是未软删除且已公开的作品,否则一律按不存在处理。 +/// +/// 复用 `can_serve_as_fork_source`,与「能否作为新的改编来源」共用同一条「未删 + 已公开」口径, +/// 不再写第二套判定;单测把四种组合钉死,防泄露回归。 +pub fn lineage_anchor_readable(is_deleted: bool, is_published: bool) -> bool { + can_serve_as_fork_source(is_deleted, is_published) +} + +/// 树构建的输入行:调用方把游戏行与血缘行折叠成这些事实。 +/// +/// `is_published` 由调用方按表的可见性常量折叠,本模块不复制可见性字面量; +/// `status` 原样回传给客户端。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageTreeEntry { + pub game_id: String, + pub title: String, + pub author_name: Option, + pub generation: u32, + pub parent_game_id: Option, + pub play_count: u64, + pub is_deleted: bool, + pub is_published: bool, + pub status: String, + pub created_at_micros: i64, +} + +/// 对外节点;`author_name` 为 `None` 表示作者信息不可得(账号与冻结资料都没有名字)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageTreeNode { + pub game_id: String, + pub title: String, + pub author_name: Option, + pub generation: u32, + pub parent_game_id: Option, + pub play_count: u64, + pub status: String, +} + +/// 族谱树:`root` 为根节点在可见集合中的投影,根不可读(已删/未公开)时为 `None`。 +/// +/// `nodes` 是整棵树(含根,若根可见)的稳定排序结果;`root` 只是取其中的根节点, +/// 便于展示层直接标注「树顶」,不需要再按 ID 找一次。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageTree { + pub root: Option, + pub nodes: Vec, + pub truncated: bool, +} + +/// 衍生作品列表:直接子代的稳定排序结果。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageNodeList { + pub nodes: Vec, + pub truncated: bool, +} + +/// 构建以 `root_game_id` 为顶的整棵族谱树。 +/// +/// - `root_game_id`:该作品所属的母版,用于挑出 `root`; +/// - `limit`:节点上限;`0` 视为「不要任何节点」而不回退到默认值,调用方负责传常量。 +pub fn build_lineage_tree( + entries: &[LineageTreeEntry], + root_game_id: &str, + limit: usize, +) -> LineageTree { + let mut visible: Vec<&LineageTreeEntry> = entries + .iter() + .filter(|entry| can_serve_as_fork_source(entry.is_deleted, entry.is_published)) + .collect(); + stable_node_order(&mut visible); + let truncated = visible.len() > limit; + let nodes = visible + .into_iter() + .take(limit) + .map(lineage_tree_node) + .collect::>(); + let root = nodes + .iter() + .find(|node| node.game_id == root_game_id) + .cloned(); + LineageTree { + root, + nodes, + truncated, + } +} + +/// 构建「被改编」直接子代列表:只纳入对外可见的衍生作品,父作品自身不出现在列表里。 +/// +/// 用 `counts_as_public_derivative`(与公开详情 `forkCount` 同一条规则), +/// 保证「被改编 N」的数字与列表条数一致。 +pub fn build_derived_node_list(entries: &[LineageTreeEntry], limit: usize) -> LineageNodeList { + let mut visible: Vec<&LineageTreeEntry> = entries + .iter() + .filter(|entry| counts_as_public_derivative(entry.is_deleted, entry.is_published)) + .collect(); + stable_node_order(&mut visible); + let truncated = visible.len() > limit; + let nodes = visible + .into_iter() + .take(limit) + .map(lineage_tree_node) + .collect::>(); + LineageNodeList { nodes, truncated } +} + +/// 稳定排序:代际升序 → 创建时间升序 → gameId 升序。 +/// +/// 最后一级是按 ID 的确定性兜底,避免同代同刻的作品在两次读之间换序(截断点会随之漂移)。 +fn stable_node_order(entries: &mut Vec<&LineageTreeEntry>) { + entries.sort_by(|left, right| { + left.generation + .cmp(&right.generation) + .then_with(|| left.created_at_micros.cmp(&right.created_at_micros)) + .then_with(|| left.game_id.cmp(&right.game_id)) + }); + // 同一作品可能被调用方重复折叠进来(例如根行既在血缘索引外又被打点补进来), + // 排序后相同 ID 必然相邻;去重保证树上不出现重复节点,也不会把截断额度浪费在副本上。 + entries.dedup_by(|left, right| left.game_id == right.game_id); +} + +/// 折叠成对外节点;进入这里的前提是调用方已按可见性过滤过。 +fn lineage_tree_node(entry: &LineageTreeEntry) -> LineageTreeNode { + LineageTreeNode { + game_id: entry.game_id.clone(), + title: entry.title.clone(), + author_name: entry.author_name.clone(), + generation: entry.generation, + parent_game_id: entry.parent_game_id.clone(), + play_count: entry.play_count, + status: entry.status.clone(), + } +} + +#[cfg(test)] +mod lineage_tree_tests { + use super::*; + + fn entry( + game_id: &str, + generation: u32, + created_at_micros: i64, + is_deleted: bool, + is_published: bool, + ) -> LineageTreeEntry { + LineageTreeEntry { + game_id: game_id.to_string(), + title: format!("作品 {game_id}"), + author_name: Some(format!("作者 {game_id}")), + generation, + parent_game_id: (generation > 0).then(|| format!("game_gen{}", generation - 1)), + play_count: generation as u64 * 10, + is_deleted, + is_published, + status: if is_published { "published" } else { "unpublished" }.to_string(), + created_at_micros, + } + } + + fn published(game_id: &str, generation: u32, created_at_micros: i64) -> LineageTreeEntry { + entry(game_id, generation, created_at_micros, false, true) + } + + fn node_ids(tree: &LineageTree) -> Vec<&str> { + tree.nodes.iter().map(|node| node.game_id.as_str()).collect() + } + + /// 锚点可读性:四种组合逐一钉死。「已软删除 + 可见性仍是 published」必须为 false—— + /// 删除与可见性复位之间没有原子保证,读路径完全可能看到这个组合。 + #[test] + fn anchor_requires_unremoved_and_published() { + let cases = [ + (false, true, true, "未删 + 已公开"), + (true, true, false, "已软删除 + 可见性仍是 published"), + (false, false, false, "未删 + 未公开"), + (true, false, false, "已软删除 + 未公开"), + ]; + for (is_deleted, is_published, expected, label) in cases { + assert_eq!( + lineage_anchor_readable(is_deleted, is_published), + expected, + "{label}(is_deleted={is_deleted}, is_published={is_published})" + ); + } + } + + /// 软删除节点必须整体消失:既不是节点,也不把标题/作者名泄给任何人。 + #[test] + fn soft_deleted_nodes_are_removed_without_leaking_identity() { + let entries = [ + published("root", 0, 1), + entry("removed", 1, 2, true, true), + published("alive", 1, 3), + ]; + let tree = build_lineage_tree(&entries, "root", 200); + assert_eq!(node_ids(&tree), vec!["root", "alive"]); + assert!( + tree.nodes + .iter() + .all(|node| node.title != "作品 removed" + && node.author_name.as_deref() != Some("作者 removed")), + "被删作品的标题与作者名都不得出现在响应里" + ); + } + + /// 未公开的节点同样不出现,也不泄露身份;锚点自己未公开时整棵树不含它 + /// (路由层已按 `lineage_anchor_readable` 判成 404,这里再钉住纯函数一侧)。 + #[test] + fn unpublished_nodes_are_removed_including_the_anchor() { + let entries = [ + published("root", 0, 1), + entry("hidden", 1, 2, false, false), + published("alive", 1, 3), + ]; + let tree = build_lineage_tree(&entries, "root", 200); + 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); + assert_eq!(node_ids(&tree), vec!["root"]); + assert!(tree.nodes.iter().all(|node| node.game_id != "anchor")); + } + + /// 树里不补占位节点:根已删/未公开时按 ID 与代际回退,孩子的父引用原样保留指向不可用的根。 + #[test] + fn unavailable_root_falls_back_to_id_and_generation_tree() { + let entries = [ + entry("root", 0, 1, true, true), + published("mid", 1, 2), + published("leaf", 2, 3), + ]; + let tree = build_lineage_tree(&entries, "root", 200); + assert_eq!(node_ids(&tree), vec!["mid", "leaf"]); + assert!(tree.root.is_none(), "根不可用时 root 降级为空"); + let mid = tree + .nodes + .iter() + .find(|node| node.game_id == "mid") + .expect("中间节点保留"); + // 父引用原样保留指向不可用的根:展示层据此标注「原作品已不可用」,而不是断链。 + assert_eq!(mid.parent_game_id.as_deref(), Some("game_gen0")); + assert_eq!(mid.generation, 1); + let leaf = tree + .nodes + .iter() + .find(|node| node.game_id == "leaf") + .expect("后代保留"); + assert_eq!(leaf.generation, 2); + } + + /// 根可见时 `root` 指回根节点,且节点集合含根(树顶就是根)。 + #[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 root = tree.root.expect("根应可见"); + assert_eq!(root.game_id, "root"); + assert_eq!(root.title, "作品 root"); + assert_eq!( + tree.nodes.first().map(|node| node.game_id.as_str()), + Some("root") + ); + } + + /// 超出上限时如实标注,并只截掉最深代际(排序先于截断)。 + #[test] + fn limit_truncates_deepest_generations_and_flags_it() { + let entries = [ + published("g0", 0, 1), + published("g1", 1, 2), + published("g2", 2, 3), + published("g3", 3, 4), + ]; + let tree = build_lineage_tree(&entries, "g0", 2); + assert!(tree.truncated); + assert_eq!(node_ids(&tree), vec!["g0", "g1"]); + + let full = build_lineage_tree(&entries, "g0", 200); + assert!(!full.truncated); + assert_eq!(node_ids(&full).len(), 4); + } + + /// 排序稳定:代际升序,同代按创建时间升序,同代同刻按 ID 升序。 + #[test] + fn ordering_is_generation_then_creation_then_id() { + let entries = [ + published("g1-late", 1, 50), + published("g0", 0, 40), + published("g1-b", 1, 10), + published("g1-a", 1, 10), + ]; + let tree = build_lineage_tree(&entries, "g0", 200); + assert_eq!( + node_ids(&tree), + vec!["g0", "g1-a", "g1-b", "g1-late"], + "同代同刻必须按 ID 兜底,避免截断点漂移" + ); + } + + /// 重复折叠进来的同一作品只出现一次(例如调用方既按根补了根行、又重复打点)。 + #[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); + assert_eq!(node_ids(&tree), vec!["root"]); + } + + /// 衍生列表只含对外可见的直接子代:软删除与未公开的直接子代都不在列表里。 + /// + /// 调用方只会把「直接子代」折叠进来(父作品自身不在其中),所以这里验证的是两条可见性规则。 + #[test] + fn derived_list_contains_only_visible_children() { + let entries = [ + published("child-a", 3, 2), + entry("child-removed", 3, 3, true, true), + entry("child-hidden", 3, 4, false, false), + ]; + let list = build_derived_node_list(&entries, 200); + let ids = list + .nodes + .iter() + .map(|node| node.game_id.as_str()) + .collect::>(); + assert_eq!(ids, vec!["child-a"]); + assert!(!list.truncated); + } + + /// 衍生列表同样如实标注截断,并按创建时间稳定排序。 + #[test] + fn derived_list_sorts_and_flags_truncation() { + let entries = [ + published("late", 3, 30), + published("early", 3, 10), + published("mid", 3, 20), + ]; + let list = build_derived_node_list(&entries, 2); + assert!(list.truncated); + let ids = list + .nodes + .iter() + .map(|node| node.game_id.as_str()) + .collect::>(); + assert_eq!(ids, vec!["early", "mid"]); + } +} diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index b76030fb1..6421f9dec 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -252,6 +252,54 @@ pub struct GameDistributionSetForkAuthorizationRequest { pub fork_authorization: GameDistributionForkAuthorization, } +/// 族谱树上对外可见的单个作品节点。 +/// +/// 只带作品级公开信息:不含对象键、素材键或 owner user id 之外的私有字段。 +/// 只有公开且未软删除的作品会成为节点(锚点可读性由接口在读出前判定,不可读即 404), +/// 因此标题必然存在;`author_name` 为 `None` 表示作者信息不可得。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionLineageNode { + pub game_id: String, + pub title: String, + #[serde(default)] + pub author_name: Option, + /// 代际;母版为 0。 + pub generation: u32, + /// 父作品 ID;母版为空。父作品不可用时仍回传 ID,展示层据此降级为「原作品不可用」。 + #[serde(default)] + pub parent_game_id: Option, + pub play_count: u64, + /// 可见性;未公开的锚点节点原样回传 `unpublished` / `suspended`。 + pub status: GameDistributionVisibility, +} + +/// 族谱读接口的响应:以根作品为顶的整棵树。 +/// +/// `nodes` 按代际升序、同代按创建时间升序稳定排序,含根(若根可见);超过节点上限时 +/// 截断尾部并置 `truncated`,不静默丢弃。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionLineageResponse { + /// 请求作品所属的根作品 ID;根已删/未公开时仍回传,展示层据此标注树顶不可用。 + pub root_game_id: String, + /// 根节点在可见集合中的投影;根已软删除或未公开时为空。 + #[serde(default)] + pub root: Option, + pub nodes: Vec, + pub truncated: bool, +} + +/// 直接衍生作品(「被改编」)列表的响应;只含对外可见的直接子代。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionDerivedResponse { + /// 被改编的父作品 ID。 + pub game_id: String, + pub nodes: Vec, + pub truncated: bool, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionListResponse { diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 9a1bfb69b..00c143b74 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -96,9 +96,10 @@ pub use self::external_generation::{ pub use self::game_distribution::{ GameDistributionAdminGameRecord, GameDistributionAdminUserReviewDetailRecord, GameDistributionAdminUserReviewListRecord, GameDistributionAdminUserReviewRecord, - GameDistributionAdminVersionRecord, GameDistributionGameRecord, - GameDistributionLineageRecord, GameDistributionOwnerGameRecord, - GameDistributionPublicGameRecord, + GameDistributionAdminVersionRecord, GameDistributionDerivedGamesRecord, + GameDistributionGameRecord, GameDistributionLineageNodeRecord, + GameDistributionLineageRecord, GameDistributionLineageTreeRecord, + GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord, GameDistributionReviewGameRecord, GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecord, GameDistributionUserReviewListRecord, @@ -168,7 +169,8 @@ pub(crate) use self::external_generation::{ }; pub(crate) use self::game_distribution::{ ensure_admin_user_review_result, ensure_user_review_result, map_admin_user_review, - map_game_distribution_admin_game_list_result, map_game_distribution_game_result, + map_game_distribution_admin_game_list_result, map_game_distribution_derived_games_result, + map_game_distribution_game_result, map_game_distribution_lineage_result, map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result, map_game_distribution_public_game_result, map_game_distribution_review_list_result, map_game_distribution_version_result, map_review_moderation_operation, map_user_review, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 72f62a832..936d139b7 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -251,6 +251,8 @@ pub struct GameDistributionAdminGameRecord { pub struct GameDistributionOwnerGameRecord { pub game: GameDistributionGameRecord, pub versions: Vec, + /// 直接改编该作品的已公开作品数(作者页「被改编 N」入口)。 + pub fork_count: u64, } /// 公开的改编血缘摘要;母版为 `None`。 @@ -278,6 +280,38 @@ pub struct GameDistributionPublicGameRecord { pub lineage: Option, } +/// 族谱 / 衍生列表的单个对外节点。 +/// +/// 已软删除或未公开的作品不会成为节点(`module_game_distribution::lineage` 的纯函数负责过滤), +/// 所以标题必然存在;`author_name` 为 `None` 表示作者信息不可得。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionLineageNodeRecord { + pub game_id: String, + pub title: String, + pub author_name: Option, + pub generation: u32, + pub parent_game_id: Option, + pub play_count: u64, + pub status: String, +} + +/// 以某作品根为顶的整棵族谱树。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionLineageTreeRecord { + pub root_game_id: String, + pub root: Option, + pub nodes: Vec, + pub truncated: bool, +} + +/// 某作品的直接衍生作品(「被改编」)列表。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionDerivedGamesRecord { + pub game_id: String, + pub nodes: Vec, + pub truncated: bool, +} + fn map_game( value: crate::module_bindings::GameDistributionGameSnapshot, ) -> GameDistributionGameRecord { @@ -394,6 +428,56 @@ fn map_lineage( }) } +fn map_lineage_node( + value: crate::module_bindings::GameDistributionLineageNodeSnapshot, +) -> GameDistributionLineageNodeRecord { + GameDistributionLineageNodeRecord { + game_id: value.game_id, + title: value.title, + author_name: value.author_name, + generation: value.generation, + parent_game_id: value.parent_game_id, + play_count: value.play_count, + status: value.status, + } +} + +/// 族谱树:`found == false` 表示锚点不可公开读取(不存在 / 未公开 / 已软删除), +/// 折成 `None` 由 api-server 映射成 404。 +pub(crate) fn map_game_distribution_lineage_result( + result: crate::module_bindings::GameDistributionLineageResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + if !result.found { + return Ok(None); + } + Ok(Some(GameDistributionLineageTreeRecord { + root_game_id: result.root_game_id, + root: result.root.map(map_lineage_node), + nodes: result.nodes.into_iter().map(map_lineage_node).collect(), + truncated: result.truncated, + })) +} + +/// 直接衍生作品列表:`found == false` 的语义与族谱一致。 +pub(crate) fn map_game_distribution_derived_games_result( + result: crate::module_bindings::GameDistributionDerivedGamesResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + if !result.found { + return Ok(None); + } + Ok(Some(GameDistributionDerivedGamesRecord { + game_id: result.game_id, + nodes: result.nodes.into_iter().map(map_lineage_node).collect(), + truncated: result.truncated, + })) +} + fn map_public_game( value: crate::module_bindings::GameDistributionPublicGameSnapshot, ) -> GameDistributionPublicGameRecord { @@ -453,6 +537,7 @@ pub(crate) fn map_game_distribution_owner_game_list_result( .map(|value| GameDistributionOwnerGameRecord { game: map_game(value.game), versions: value.versions.into_iter().map(map_version).collect(), + fork_count: value.fork_count, }) .collect()) } @@ -496,3 +581,61 @@ pub(crate) fn map_game_distribution_review_list_result( } Ok(result.versions.into_iter().map(map_version).collect()) } + +#[cfg(test)] +mod lineage_result_tests { + use super::*; + + fn lineage_result(found: bool) -> crate::module_bindings::GameDistributionLineageResult { + crate::module_bindings::GameDistributionLineageResult { + ok: true, + found, + root_game_id: "game-root".to_string(), + root: None, + nodes: Vec::new(), + truncated: false, + error_message: None, + } + } + + fn derived_result(found: bool) -> crate::module_bindings::GameDistributionDerivedGamesResult { + crate::module_bindings::GameDistributionDerivedGamesResult { + ok: true, + found, + game_id: "game-root".to_string(), + nodes: Vec::new(), + truncated: true, + error_message: None, + } + } + + /// 锚点不可读(`found = false`)必须折成 `None` 交给 api-server 映射 404, + /// 而不是返回一棵空树——空树会被误读成「这部作品存在但没有衍生」。 + #[test] + fn unreadable_anchor_maps_to_none_for_lineage() { + assert!( + map_game_distribution_lineage_result(lineage_result(false)) + .expect("found=false 不是错误") + .is_none() + ); + let tree = map_game_distribution_lineage_result(lineage_result(true)) + .expect("可读锚点") + .expect("可读锚点应有树"); + assert_eq!(tree.root_game_id, "game-root"); + assert!(!tree.truncated); + } + + #[test] + fn unreadable_anchor_maps_to_none_for_derived() { + assert!( + map_game_distribution_derived_games_result(derived_result(false)) + .expect("found=false 不是错误") + .is_none() + ); + let derived = map_game_distribution_derived_games_result(derived_result(true)) + .expect("可读锚点") + .expect("可读锚点应有列表"); + assert_eq!(derived.game_id, "game-root"); + assert!(derived.truncated); + } +} diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 8fe6d7559..9dc016a16 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -629,6 +629,50 @@ impl SpacetimeClient { .await } + /// 读取以某作品根为顶的创作族谱树;锚点不可公开读取时返回 `None`,由 api-server 映射 404。 + pub async fn get_game_distribution_lineage( + &self, + game_id: String, + ) -> Result, SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionLineageInput { game_id }; + self.call_after_connect( + "get_game_distribution_lineage", + move |connection, sender| { + connection + .procedures() + .get_game_distribution_lineage_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_lineage_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 读取某作品的直接衍生作品(「被改编」列表);锚点不可公开读取时返回 `None`。 + pub async fn list_game_distribution_derived_games( + &self, + game_id: String, + ) -> Result, SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionDerivedGamesInput { game_id }; + self.call_after_connect( + "list_game_distribution_derived_games", + move |connection, sender| { + connection + .procedures() + .list_game_distribution_derived_games_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_derived_games_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + /// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。 pub async fn increment_game_distribution_game_play_counts( &self, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 77cd93411..17f17c092 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -421,6 +421,8 @@ pub mod game_distribution_confirm_package_input_type; pub mod game_distribution_create_game_input_type; pub mod game_distribution_create_version_input_type; pub mod game_distribution_delete_game_input_type; +pub mod game_distribution_derived_games_input_type; +pub mod game_distribution_derived_games_result_type; pub mod game_distribution_fail_upload_input_type; pub mod game_distribution_game_list_procedure_result_type; pub mod game_distribution_game_procedure_result_type; @@ -430,6 +432,9 @@ pub mod game_distribution_game_type; pub mod game_distribution_get_game_input_type; pub mod game_distribution_idempotency_receipt_table; pub mod game_distribution_idempotency_receipt_type; +pub mod game_distribution_lineage_input_type; +pub mod game_distribution_lineage_node_snapshot_type; +pub mod game_distribution_lineage_result_type; pub mod game_distribution_lineage_snapshot_type; pub mod game_distribution_lineage_table; pub mod game_distribution_lineage_type; @@ -489,6 +494,7 @@ pub mod get_external_generation_job_summary_and_return_procedure; pub mod get_external_generation_queue_stats_and_return_procedure; pub mod get_feature_gate_config_procedure; pub mod get_game_distribution_game_and_return_procedure; +pub mod get_game_distribution_lineage_and_return_procedure; pub mod get_game_distribution_my_review_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; @@ -523,6 +529,7 @@ pub mod list_error_reports_and_return_procedure; pub mod list_external_api_keys_and_return_procedure; pub mod list_external_generation_job_summaries_and_return_procedure; pub mod list_external_generation_jobs_and_return_procedure; +pub mod list_game_distribution_derived_games_and_return_procedure; pub mod list_game_distribution_games_and_return_procedure; pub mod list_game_distribution_review_games_and_return_procedure; pub mod list_game_distribution_reviews_and_return_procedure; @@ -1287,6 +1294,8 @@ pub use game_distribution_confirm_package_input_type::GameDistributionConfirmPac pub use game_distribution_create_game_input_type::GameDistributionCreateGameInput; pub use game_distribution_create_version_input_type::GameDistributionCreateVersionInput; pub use game_distribution_delete_game_input_type::GameDistributionDeleteGameInput; +pub use game_distribution_derived_games_input_type::GameDistributionDerivedGamesInput; +pub use game_distribution_derived_games_result_type::GameDistributionDerivedGamesResult; pub use game_distribution_fail_upload_input_type::GameDistributionFailUploadInput; pub use game_distribution_game_list_procedure_result_type::GameDistributionGameListProcedureResult; pub use game_distribution_game_procedure_result_type::GameDistributionGameProcedureResult; @@ -1296,6 +1305,9 @@ pub use game_distribution_game_type::GameDistributionGame; pub use game_distribution_get_game_input_type::GameDistributionGetGameInput; pub use game_distribution_idempotency_receipt_table::*; pub use game_distribution_idempotency_receipt_type::GameDistributionIdempotencyReceipt; +pub use game_distribution_lineage_input_type::GameDistributionLineageInput; +pub use game_distribution_lineage_node_snapshot_type::GameDistributionLineageNodeSnapshot; +pub use game_distribution_lineage_result_type::GameDistributionLineageResult; pub use game_distribution_lineage_snapshot_type::GameDistributionLineageSnapshot; pub use game_distribution_lineage_table::*; pub use game_distribution_lineage_type::GameDistributionLineage; @@ -1355,6 +1367,7 @@ pub use get_external_generation_job_summary_and_return_procedure::get_external_g pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return; pub use get_feature_gate_config_procedure::get_feature_gate_config; pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return; +pub use get_game_distribution_lineage_and_return_procedure::get_game_distribution_lineage_and_return; pub use get_game_distribution_my_review_and_return_procedure::get_game_distribution_my_review_and_return; pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; @@ -1389,6 +1402,7 @@ pub use list_error_reports_and_return_procedure::list_error_reports_and_return; pub use list_external_api_keys_and_return_procedure::list_external_api_keys_and_return; pub use list_external_generation_job_summaries_and_return_procedure::list_external_generation_job_summaries_and_return; pub use list_external_generation_jobs_and_return_procedure::list_external_generation_jobs_and_return; +pub use list_game_distribution_derived_games_and_return_procedure::list_game_distribution_derived_games_and_return; pub use list_game_distribution_games_and_return_procedure::list_game_distribution_games_and_return; pub use list_game_distribution_review_games_and_return_procedure::list_game_distribution_review_games_and_return; pub use list_game_distribution_reviews_and_return_procedure::list_game_distribution_reviews_and_return; @@ -4189,19 +4203,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, -> + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + > { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, - > RemoteDbContext for Ctx +> RemoteDbContext for Ctx { } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_input_type.rs new file mode 100644 index 000000000..ceb822408 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_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 GameDistributionDerivedGamesInput { + pub game_id: String, +} + +impl __sdk::InModule for GameDistributionDerivedGamesInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_result_type.rs new file mode 100644 index 000000000..fcd9c98fa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_derived_games_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}; + +use super::game_distribution_lineage_node_snapshot_type::GameDistributionLineageNodeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionDerivedGamesResult { + pub ok: bool, + pub found: bool, + pub game_id: String, + pub nodes: Vec, + pub truncated: bool, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionDerivedGamesResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_input_type.rs new file mode 100644 index 000000000..53263e05c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_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 GameDistributionLineageInput { + pub game_id: String, +} + +impl __sdk::InModule for GameDistributionLineageInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs new file mode 100644 index 000000000..7bdc3ab33 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs @@ -0,0 +1,21 @@ +// 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 GameDistributionLineageNodeSnapshot { + pub game_id: String, + pub title: String, + pub author_name: Option, + pub generation: u32, + pub parent_game_id: Option, + pub play_count: u64, + pub status: String, +} + +impl __sdk::InModule for GameDistributionLineageNodeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_result_type.rs new file mode 100644 index 000000000..b83513525 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_result_type.rs @@ -0,0 +1,23 @@ +// 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_lineage_node_snapshot_type::GameDistributionLineageNodeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionLineageResult { + pub ok: bool, + pub found: bool, + pub root_game_id: String, + pub root: Option, + pub nodes: Vec, + pub truncated: bool, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionLineageResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_game_snapshot_type.rs index 67a5946db..9e5e217a3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_game_snapshot_type.rs @@ -12,6 +12,7 @@ use super::game_distribution_version_snapshot_type::GameDistributionVersionSnaps pub struct GameDistributionOwnerGameSnapshot { pub game: GameDistributionGameSnapshot, pub versions: Vec, + pub fork_count: u64, } impl __sdk::InModule for GameDistributionOwnerGameSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_lineage_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_lineage_and_return_procedure.rs new file mode 100644 index 000000000..8f76b2f0b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_lineage_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_lineage_input_type::GameDistributionLineageInput; +use super::game_distribution_lineage_result_type::GameDistributionLineageResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetGameDistributionLineageAndReturnArgs { + pub input: GameDistributionLineageInput, +} + +impl __sdk::InModule for GetGameDistributionLineageAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_game_distribution_lineage_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_game_distribution_lineage_and_return { + fn get_game_distribution_lineage_and_return(&self, input: GameDistributionLineageInput) { + self.get_game_distribution_lineage_and_return_then(input, |_, _| {}); + } + + fn get_game_distribution_lineage_and_return_then( + &self, + input: GameDistributionLineageInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_game_distribution_lineage_and_return for super::RemoteProcedures { + fn get_game_distribution_lineage_and_return_then( + &self, + input: GameDistributionLineageInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionLineageResult>( + "get_game_distribution_lineage_and_return", + GetGameDistributionLineageAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_derived_games_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_derived_games_and_return_procedure.rs new file mode 100644 index 000000000..1a1f66553 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_derived_games_and_return_procedure.rs @@ -0,0 +1,62 @@ +// 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_derived_games_input_type::GameDistributionDerivedGamesInput; +use super::game_distribution_derived_games_result_type::GameDistributionDerivedGamesResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListGameDistributionDerivedGamesAndReturnArgs { + pub input: GameDistributionDerivedGamesInput, +} + +impl __sdk::InModule for ListGameDistributionDerivedGamesAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_game_distribution_derived_games_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_game_distribution_derived_games_and_return { + fn list_game_distribution_derived_games_and_return( + &self, + input: GameDistributionDerivedGamesInput, + ) { + self.list_game_distribution_derived_games_and_return_then(input, |_, _| {}); + } + + fn list_game_distribution_derived_games_and_return_then( + &self, + input: GameDistributionDerivedGamesInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_game_distribution_derived_games_and_return for super::RemoteProcedures { + fn list_game_distribution_derived_games_and_return_then( + &self, + input: GameDistributionDerivedGamesInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionDerivedGamesResult>( + "list_game_distribution_derived_games_and_return", + ListGameDistributionDerivedGamesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 5b0bcf9a6..e11488b09 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1176,6 +1176,18 @@ pub struct GameDistributionPublicGameInput { pub game_id: String, } +/// 读取以某作品的根为顶的整棵创作族谱树。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionLineageInput { + pub game_id: String, +} + +/// 读取某作品的直接衍生作品(「被改编」列表)。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionDerivedGamesInput { + pub game_id: String, +} + /// 单次游玩计数增量;api-server 已按 `game_id` 聚合,同一输入内不重复。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionPlayCountIncrement { @@ -1331,11 +1343,105 @@ pub struct GameDistributionPublicGameSnapshot { pub lineage: Option, } +/// 族谱 / 衍生列表的对外节点。 +/// +/// 只有公开且未软删除的作品会成为节点(锚点可读性另由 `lineage_anchor_readable` 判定), +/// 因此标题必然存在;`author_name` 为 `None` 表示作者信息不可得(账号与冻结资料都没有名字)。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionLineageNodeSnapshot { + pub game_id: String, + pub title: String, + pub author_name: Option, + /// 代际;母版为 0。 + pub generation: u32, + /// 父作品 ID;母版为 None。父作品不可用时仍回传 ID,展示层据此降级。 + pub parent_game_id: Option, + pub play_count: u64, + /// 可见性;未公开的锚点节点原样回传 `unpublished` / `suspended`。 + pub status: String, +} + +/// 族谱树读取结果;`found == false` 表示目标作品不存在或已软删除(api-server 映射 404)。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionLineageResult { + pub ok: bool, + pub found: bool, + pub root_game_id: String, + pub root: Option, + pub nodes: Vec, + pub truncated: bool, + pub error_message: Option, +} + +/// 直接衍生作品列表结果;`found == false` 表示父作品不存在或已软删除。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionDerivedGamesResult { + pub ok: bool, + pub found: bool, + pub game_id: String, + pub nodes: Vec, + pub truncated: bool, + pub error_message: Option, +} + +impl GameDistributionLineageResult { + /// 目标作品不存在或已软删除:`ok` 仍为 true,由 api-server 映射成 404。 + fn not_found() -> Self { + Self { + ok: true, + found: false, + root_game_id: String::new(), + root: None, + nodes: Vec::new(), + truncated: false, + error_message: None, + } + } + + fn failed(error: String) -> Self { + Self { + ok: false, + found: false, + root_game_id: String::new(), + root: None, + nodes: Vec::new(), + truncated: false, + error_message: Some(error), + } + } +} + +impl GameDistributionDerivedGamesResult { + fn not_found() -> Self { + Self { + ok: true, + found: false, + game_id: String::new(), + nodes: Vec::new(), + truncated: false, + error_message: None, + } + } + + fn failed(error: String) -> Self { + Self { + ok: false, + found: false, + game_id: String::new(), + nodes: Vec::new(), + truncated: false, + error_message: Some(error), + } + } +} + /// 作者自有游戏的聚合快照:游戏身份 + 最近若干版本,供作者管理页展示状态与驳回理由。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionOwnerGameSnapshot { pub game: GameDistributionGameSnapshot, pub versions: Vec, + /// 直接改编该作品的已公开作品数(作者页「被改编 N」入口)。 + pub fork_count: u64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1844,6 +1950,61 @@ pub fn get_public_game_distribution_game_and_return( } } +/// 返回以该作品根为顶的整棵创作族谱树;公开只读,匿名可读。 +/// +/// 目标作品不存在或已软删除时 `found = false`(api-server 映射 404);未公开但未删除的作品 +/// 仍可读(作者页入口),自身作为锚点保留、身份字段按可见性降级。 +#[spacetimedb::procedure] +pub fn get_game_distribution_lineage_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionLineageInput, +) -> GameDistributionLineageResult { + match ctx.try_with_tx(|tx| game_distribution_lineage_tx(tx, input.clone())) { + Ok(Some(read)) => GameDistributionLineageResult { + ok: true, + found: true, + root_game_id: read.root_game_id, + root: read.root.map(game_distribution_lineage_node_snapshot), + nodes: read + .nodes + .into_iter() + .map(game_distribution_lineage_node_snapshot) + .collect(), + truncated: read.truncated, + error_message: None, + }, + Ok(None) => GameDistributionLineageResult::not_found(), + Err(error) => GameDistributionLineageResult::failed(error), + } +} + +/// 返回某作品的直接衍生作品(「被改编」列表);公开只读,匿名可读。 +/// +/// 与公开详情 `forkCount` 同口径:只列未软删除且已公开的直接子代。父作品被软删除时按 +/// 「不存在」处理(`found = false`)。 +#[spacetimedb::procedure] +pub fn list_game_distribution_derived_games_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionDerivedGamesInput, +) -> GameDistributionDerivedGamesResult { + match ctx.try_with_tx(|tx| game_distribution_derived_games_tx(tx, input.clone())) { + Ok(Some(read)) => GameDistributionDerivedGamesResult { + ok: true, + found: true, + game_id: read.game_id, + nodes: read + .nodes + .into_iter() + .map(game_distribution_lineage_node_snapshot) + .collect(), + truncated: read.truncated, + error_message: None, + }, + Ok(None) => GameDistributionDerivedGamesResult::not_found(), + Err(error) => GameDistributionDerivedGamesResult::failed(error), + } +} + /// 管理员读取任意版本状态;admin 权限由 api-server 在调用前校验,procedure 只接受受信服务身份。 #[spacetimedb::procedure] pub fn get_game_distribution_version_and_return( @@ -3760,6 +3921,9 @@ fn list_owner_game_distribution_games_tx( }) .collect(), game: game_distribution_game_snapshot(&game), + // 与公开详情的 `forkCount` 同一口径(只算未软删除且已公开的直接子代), + // 作者页「被改编 N」入口据此展示,避免两处各算一套。 + fork_count: game_distribution_public_fork_count(ctx, game.game_id.as_str()), } }) .collect()) @@ -3831,6 +3995,199 @@ fn get_public_game_distribution_game_tx( Ok(public_game_distribution_snapshot(ctx, &game)) } +/// 族谱树读取的领域结果(procedure 只做 ok/error 包装)。 +struct GameDistributionLineageRead { + root_game_id: String, + root: Option, + nodes: Vec, + truncated: bool, +} + +/// 直接衍生作品列表的领域结果。 +struct GameDistributionDerivedGamesRead { + game_id: String, + nodes: Vec, + truncated: bool, +} + +/// 读取以目标作品根为顶的整棵族谱树。 +/// +/// 锚点必须公开可读:不存在、已软删除或未公开时返回 `Ok(None)`(api-server 映射 404)。 +/// 不允许「降级返回一个空标题节点」——那等于对外确认该 gameId 存在、代际与血缘位置,属于泄露。 +fn game_distribution_lineage_tx( + ctx: &ReducerContext, + input: GameDistributionLineageInput, +) -> Result, String> { + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let Some(game) = ctx.db.game_distribution_game().game_id().find(&game_id) else { + return Ok(None); + }; + if !module_game_distribution::lineage_anchor_readable( + game.deleted_at.is_some(), + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + ) { + return Ok(None); + } + // 有血缘行就按行的根走;没有血缘行说明目标自身就是母版(0 代)。 + let root_game_id = ctx + .db + .game_distribution_lineage() + .game_id() + .find(&game_id) + .map(|lineage| lineage.root_game_id) + .unwrap_or_else(|| game_id.clone()); + let entries = game_distribution_lineage_entries(ctx, root_game_id.as_str()); + let tree = module_game_distribution::build_lineage_tree( + &entries, + root_game_id.as_str(), + module_game_distribution::GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, + ); + Ok(Some(GameDistributionLineageRead { + root_game_id, + root: tree.root, + nodes: tree.nodes, + truncated: tree.truncated, + })) +} + +/// 读取某作品的直接衍生作品(「被改编」列表)。 +/// +/// 只折叠直接子代的行事实;可见性(未软删除 + 已公开)由纯函数判定,与公开详情 `forkCount` +/// 用同一批规则,因此「被改编 N」与列表条数必然一致。锚点必须公开可读(同族谱口径), +/// 否则返回 `Ok(None)`。 +fn game_distribution_derived_games_tx( + ctx: &ReducerContext, + input: GameDistributionDerivedGamesInput, +) -> Result, String> { + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let Some(game) = ctx.db.game_distribution_game().game_id().find(&game_id) else { + return Ok(None); + }; + if !module_game_distribution::lineage_anchor_readable( + game.deleted_at.is_some(), + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + ) { + return Ok(None); + } + let entries = ctx + .db + .game_distribution_lineage() + .by_game_distribution_lineage_parent_game_id() + .filter(&game_id) + .filter_map(|lineage| { + let child = ctx + .db + .game_distribution_game() + .game_id() + .find(&lineage.game_id)?; + Some(game_distribution_lineage_entry( + ctx, + &child, + lineage.generation, + Some(lineage.parent_game_id.clone()), + )) + }) + .collect::>(); + let list = module_game_distribution::build_derived_node_list( + &entries, + module_game_distribution::GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, + ); + Ok(Some(GameDistributionDerivedGamesRead { + game_id, + nodes: list.nodes, + truncated: list.truncated, + })) +} + +/// 折叠族谱树的输入行:根作品自身 + 所有以该根为祖先的血缘行。 +/// +/// 只读行事实,不做任何可见性判断——「哪些节点能出现、身份字段要不要降级」全部由 +/// `module_game_distribution::lineage` 的纯函数决定,避免同一口径长出第二套实现。 +fn game_distribution_lineage_entries( + ctx: &ReducerContext, + root_game_id: &str, +) -> Vec { + let mut entries = Vec::new(); + if let Some(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 + .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 +} + +fn game_distribution_lineage_entry( + ctx: &ReducerContext, + game: &GameDistributionGame, + generation: u32, + parent_game_id: Option, +) -> module_game_distribution::LineageTreeEntry { + module_game_distribution::LineageTreeEntry { + 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, + status: game.visibility.clone(), + created_at_micros: game.created_at.to_micros_since_unix_epoch(), + } +} + +/// 族谱节点的作者名:与公开详情同样读时联账号表(账号改名/换头像立即跟随), +/// 账号信息不可得时退回发布时冻结的作者名。 +/// +/// 与公开投影的差别只有一处:空串与纯空白归一成 `None`(节点上没有可渲染的名字就不占位)。 +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()) +} + +fn game_distribution_lineage_node_snapshot( + node: module_game_distribution::LineageTreeNode, +) -> GameDistributionLineageNodeSnapshot { + GameDistributionLineageNodeSnapshot { + game_id: node.game_id, + title: node.title, + author_name: node.author_name, + generation: node.generation, + parent_game_id: node.parent_game_id, + play_count: node.play_count, + status: node.status, + } +} + fn get_game_distribution_game_for_owner_or_public_tx( ctx: &ReducerContext, input: GameDistributionGetGameInput, diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 1f94a32da..3ea86f6fd 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -47,6 +47,11 @@ type GameDetailPageProps = { gameId: string | null; onBack: () => void; onPlay: (gameId: string) => void; + /** + * 打开创作族谱页;参数是根作品 ID 与「当前作品」ID(用于族谱页高亮)。 + * 回调缺失时不渲染族谱入口(测试或没有导航能力的宿主)。 + */ + onOpenLineage?: (rootGameId: string, fromGameId?: string) => void; /** * 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。 * @@ -107,6 +112,7 @@ export function GameDetailPage({ gameId, onBack, onPlay, + onOpenLineage, ownerView = false, }: GameDetailPageProps) { const authUi = useAuthUi(); @@ -264,6 +270,7 @@ export function GameDetailPage({ onLogin={() => authUi?.openLoginModal()} onBack={onBack} onPlay={onPlay} + onOpenLineage={onOpenLineage} /> ); } @@ -278,6 +285,7 @@ function GameDetailContent({ onLogin, onBack, onPlay, + onOpenLineage, }: { game: GameDistributionGame; ownerView: boolean; @@ -288,6 +296,7 @@ function GameDetailContent({ onLogin: () => void; onBack: () => void; onPlay: (gameId: string) => void; + onOpenLineage?: (rootGameId: string, fromGameId?: string) => void; }) { /** * 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架 @@ -384,6 +393,22 @@ function GameDetailContent({ 已被改编 {forkCount} 次 ) : null} + {onOpenLineage && publiclyVisible ? ( + /* + 族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事, + 点进去是以母版为顶的整棵族谱树;`from` 带上当前作品供页面高亮。 + 只有已公开作品才渲染:族谱接口要求锚点公开可读,未公开作品点进去必然 404。 + */ + + ) : null} ), }, diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index f6411fe45..b86f6b36b 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -14,16 +14,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution'; import type { GameDistributionGame, + GameDistributionLineageNode, GameDistributionMyGame, GameDistributionVersionStatusEntry, } from '../../services/gameDistributionClient'; import { GameDetailPage } from './GameDetailPage'; import { GameGalleryPage } from './GameGalleryPage'; +import { GameLineagePage } from './GameLineagePage'; import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage'; import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel'; const listGamesMock = vi.hoisted(() => vi.fn()); const getGameMock = vi.hoisted(() => vi.fn()); +const getGameLineageMock = vi.hoisted(() => vi.fn()); const getMyGameMock = vi.hoisted(() => vi.fn()); const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn()); const recordGamePlayMock = vi.hoisted(() => vi.fn()); @@ -34,6 +37,7 @@ const getMyGameReviewMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, getGame: getGameMock, + getGameLineage: getGameLineageMock, getMyGame: getMyGameMock, loadVersionRecoveryActions: loadVersionRecoveryActionsMock, recordGamePlay: recordGamePlayMock, @@ -131,6 +135,7 @@ beforeEach(() => { setViewportWidth(1024); listGamesMock.mockResolvedValue([]); getGameMock.mockResolvedValue(createGame()); + getGameLineageMock.mockResolvedValue(null); getMyGameMock.mockResolvedValue(null); loadVersionRecoveryActionsMock.mockResolvedValue({}); listGameReviewsMock.mockResolvedValue({ @@ -913,3 +918,261 @@ describe('游戏封面与截图展示', () => { ); }); }); + +function createLineageNode( + overrides: Partial = {}, +): GameDistributionLineageNode { + return { + gameId: 'game-1', + title: '作品', + authorName: '作者', + generation: 1, + parentGameId: null, + playCount: 0, + status: 'published', + ...overrides, + }; +} + +describe('GameLineagePage', () => { + 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, + playCount: 30, + }), + createLineageNode({ + gameId: 'game-mid', + title: '中间代', + generation: 1, + parentGameId: 'game-root', + }), + createLineageNode({ + gameId: 'game-leaf', + title: '叶节点', + generation: 2, + parentGameId: 'game-removed', + }), + ], + truncated: false, + }); + const onOpenDetail = vi.fn(); + render( + , + ); + + expect(await screen.findByText('叶节点')).toBeTruthy(); + expect(screen.getByText('中间代')).toBeTruthy(); + // 当前作品高亮在它自己的节点卡片上。 + expect( + screen.getByText('叶节点').closest('.game-lineage-node')?.className, + ).toContain('game-lineage-node--anchor'); + // 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。 + expect(screen.getByText('原作品已不可用')).toBeTruthy(); + // 任一公开节点都能进入对应详情页。 + fireEvent.click(screen.getByRole('button', { name: '母版' })); + expect(onOpenDetail).toHaveBeenCalledWith('game-root'); + }); + + 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.findByText('这部作品还没有衍生作品,欢迎从它开始共创。'), + ).toBeTruthy(); + }); + + it('超出节点上限时如实标注截断,不静默丢弃', async () => { + getGameLineageMock.mockResolvedValueOnce({ + rootGameId: 'game-root', + root: createLineageNode({ + gameId: 'game-root', + title: '母版', + generation: 0, + }), + nodes: [ + createLineageNode({ gameId: 'game-root', title: '母版', generation: 0 }), + createLineageNode({ gameId: 'game-2', title: '第二部', generation: 1 }), + ], + truncated: true, + }); + render( + , + ); + + expect( + await screen.findByText('这部作品衍生较多,这里只展示前 2 个节点。'), + ).toBeTruthy(); + }); + + it('根作品不可用时按代际回退展示,不空白也不报错', async () => { + getGameLineageMock.mockResolvedValueOnce({ + rootGameId: 'game-root', + root: null, + nodes: [ + createLineageNode({ + gameId: 'game-leaf', + title: '叶节点', + generation: 1, + parentGameId: 'game-root', + }), + ], + truncated: false, + }); + render( + , + ); + + expect( + await screen.findByText( + '母版已不可用,下面按代际关系展示仍可阅读的衍生作品。', + ), + ).toBeTruthy(); + expect(screen.getByText('叶节点')).toBeTruthy(); + expect(screen.getByText('原作品已不可用')).toBeTruthy(); + }); + + it('未公开作品的详情页不渲染族谱入口', async () => { + getMyGameMock.mockResolvedValueOnce({ + ...createGame({ status: 'unpublished' }), + latestVersion: null, + versions: [], + } as GameDistributionMyGame); + render( + , + ); + + await screen.findByText('作品状态'); + expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull(); + }); + + it('加载失败走平台错误态并可重试', async () => { + getGameLineageMock + .mockRejectedValueOnce(new Error('网络错误')) + .mockResolvedValueOnce({ + rootGameId: 'game-root', + root: createLineageNode({ gameId: 'game-root', title: '母版' }), + nodes: [ + createLineageNode({ gameId: 'game-root', title: '母版' }), + createLineageNode({ gameId: 'game-2', title: '第二部' }), + ], + truncated: false, + }); + render( + , + ); + + expect(await screen.findByText('网络错误')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '重试' })); + expect(await screen.findByText('第二部')).toBeTruthy(); + }); + + it('缺少根作品编号时不发请求并提示编号无效', async () => { + render( + , + ); + + expect(await screen.findByText('游戏编号无效')).toBeTruthy(); + expect(getGameLineageMock).not.toHaveBeenCalled(); + }); + + it('详情页共创卡里的族谱入口带上根作品与当前作品', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + lineage: { + generation: 1, + rootGameId: 'game-root', + rootTitle: '母版', + parentGameId: 'game-root', + parentTitle: '母版', + parentAuthorName: '原作者', + }, + }), + ); + const onOpenLineage = vi.fn(); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: '查看创作族谱' }), + ); + expect(onOpenLineage).toHaveBeenCalledWith('game-root', 'game-1'); + }); + + it('没有族谱导航回调时不渲染族谱入口', async () => { + render( + , + ); + + await screen.findByRole('heading', { name: '星轨防线' }); + expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull(); + }); +}); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx new file mode 100644 index 000000000..43893bbe5 --- /dev/null +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -0,0 +1,265 @@ +import './gameDistribution.css'; + +import { ArrowLeft, RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + type GameDistributionLineageNode, + type GameDistributionLineageResponse, + getGameLineage, +} from '../../services/gameDistributionClient'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformEmptyState } from '../common/PlatformEmptyState'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; + +type GameLineagePageProps = { + /** 路由 `?id=` 里的根作品 ID;为空时给出明确的无效编号提示,不发请求。 */ + rootGameId: string | null; + /** + * 用户是从作品详情页的哪个节点进来的,用来高亮「当前作品」。 + * 缺失时退回高亮根节点(直接打开族谱链接的场景)。 + */ + highlightGameId?: string | null; + onBack: () => void; + onOpenDetail: (gameId: string) => void; +}; + +/** + * 按代际给节点分组;节点在服务端已按「代际升序、同代创建时间升序」排好, + * 这里只做分组,不重新排序。 + */ +function groupNodesByGeneration( + nodes: readonly GameDistributionLineageNode[], +): [number, GameDistributionLineageNode[]][] { + const groups = new Map(); + for (const node of nodes) { + const bucket = groups.get(node.generation); + if (bucket) { + bucket.push(node); + } else { + groups.set(node.generation, [node]); + } + } + return [...groups.entries()].sort(([left], [right]) => left - right); +} + +function generationLabel(generation: number) { + return generation === 0 ? '母版' : `第 ${generation} 代`; +} + +function LineageNodeCard({ + node, + isAnchor, + hasVisibleParent, + onOpenDetail, +}: { + node: GameDistributionLineageNode; + isAnchor: boolean; + hasVisibleParent: boolean; + onOpenDetail: (gameId: string) => void; +}) { + const title = node.title.trim() || '未命名作品'; + // 父作品不在可见集合里(已软删除或未公开)时不得猜测它的内容,只标注「原作品已不可用」; + // 节点自身照常留在树上,不出现空白或断链。 + const parentUnavailable = Boolean(node.parentGameId) && !hasVisibleParent; + const authorName = node.authorName?.trim() ?? ''; + + return ( +
+
+ + {generationLabel(node.generation)} + + {isAnchor ? ( + 当前作品 + ) : null} +
+ +

+ {authorName || '未署名作者'} + + {node.playCount.toLocaleString()} 次游玩 +

+ {parentUnavailable ? ( +

原作品已不可用

+ ) : null} +
+ ); +} + +/** + * 创作族谱页:以根作品为顶展示整棵改编树,任一公开节点都能点进对应作品详情。 + * + * 公开只读页面,未登录也能浏览;加载失败走平台既有错误态,无衍生作品给明确空态。 + * 父作品已删或未公开时该节点只降级为「原作品已不可用」,不回显父作品内容、不产生断链。 + */ +export function GameLineagePage({ + rootGameId, + highlightGameId = null, + onBack, + onOpenDetail, +}: GameLineagePageProps) { + const normalizedRootId = rootGameId?.trim() ?? ''; + const [lineage, setLineage] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const loadLineage = useCallback(async () => { + if (!normalizedRootId) { + setLineage(null); + setError(''); + return; + } + setIsLoading(true); + setError(''); + try { + const result = await getGameLineage(normalizedRootId); + if (!result) { + setError('游戏编号无效'); + return; + } + setLineage(result); + } catch (loadError) { + setError( + loadError instanceof Error ? loadError.message : '读取创作族谱失败', + ); + } finally { + setIsLoading(false); + } + }, [normalizedRootId]); + + useEffect(() => { + void loadLineage(); + }, [loadLineage]); + + const nodes = lineage?.nodes ?? []; + const groups = useMemo(() => groupNodesByGeneration(nodes), [nodes]); + const visibleIds = useMemo( + () => new Set(nodes.map((node) => node.gameId)), + [nodes], + ); + const anchorId = highlightGameId?.trim() || normalizedRootId; + const derivedCount = nodes.filter((node) => node.generation > 0).length; + const rootTitle = lineage?.root?.title?.trim() ?? ''; + + return ( +
+ +
+
+ 创作族谱 +

{rootTitle || '创作族谱'}

+
+ {normalizedRootId ? ( + void loadLineage()} + disabled={isLoading} + > + + ) : null} +
+ + {!normalizedRootId ? ( + + 游戏编号无效 + + ) : null} + + {isLoading ? ( +
正在加载创作族谱…
+ ) : null} + + {!isLoading && error ? ( + + {error} + + + ) : null} + + {!isLoading && !error && lineage ? ( + <> + {lineage.truncated ? ( +

+ 这部作品衍生较多,这里只展示前 {nodes.length} 个节点。 +

+ ) : null} + {lineage.root ? null : ( +

+ 母版已不可用,下面按代际关系展示仍可阅读的衍生作品。 +

+ )} + {groups.length === 0 ? ( + + 这部作品还没有可展示的族谱节点。 + + ) : ( +
+ {groups.map(([generation, groupNodes]) => ( +
+

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

+
+ {groupNodes.map((node) => ( + + ))} +
+
+ ))} +
+ )} + {derivedCount === 0 ? ( + + 这部作品还没有衍生作品,欢迎从它开始共创。 + + ) : null} + + ) : null} + +

+ 族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。 +

+
+ ); +} + +export default GameLineagePage; diff --git a/src/components/game-distribution/MyGamesPage.test.tsx b/src/components/game-distribution/MyGamesPage.test.tsx index fb851e2fb..e935820bb 100644 --- a/src/components/game-distribution/MyGamesPage.test.tsx +++ b/src/components/game-distribution/MyGamesPage.test.tsx @@ -8,6 +8,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { cancelGameVersion, deleteMyGame, + getDerivedGames, type GameDistributionMyGame, getGameVersion, listMyGames, @@ -23,6 +24,7 @@ import { MyGamesPage } from './MyGamesPage'; vi.mock('../../services/gameDistributionClient', () => ({ cancelGameVersion: vi.fn(), deleteMyGame: vi.fn(), + getDerivedGames: vi.fn(), getGameVersion: vi.fn(), listMyGames: vi.fn(), loadVersionRecoveryActions: vi.fn(), @@ -111,6 +113,7 @@ function renderPage( beforeEach(() => { vi.mocked(cancelGameVersion).mockReset(); vi.mocked(deleteMyGame).mockReset(); + vi.mocked(getDerivedGames).mockReset(); vi.mocked(getGameVersion).mockReset(); vi.mocked(listMyGames).mockReset(); vi.mocked(loadVersionRecoveryActions).mockReset(); @@ -663,3 +666,104 @@ test('共创授权已达全开放时入口变为只读展示', async () => { expect(await screen.findByText('共创授权已达上限')).toBeTruthy(); expect(screen.queryByRole('button', { name: '共创授权' })).toBeNull(); }); + +test('「被改编 N」入口按需拉取并列出直接子代,点击可进入详情', async () => { + vi.mocked(listMyGames).mockResolvedValue([ + createGame({ status: 'published', forkCount: 1 }), + ]); + vi.mocked(getDerivedGames).mockResolvedValue({ + gameId: 'game-1', + nodes: [ + { + gameId: 'game-child', + title: '衍生作品', + authorName: '改编者', + generation: 1, + parentGameId: 'game-1', + playCount: 5, + status: 'published', + }, + ], + truncated: false, + }); + const onOpenDetail = vi.fn(); + + render( + + + , + ); + + const toggle = await screen.findByRole('button', { name: /被改编 1/ }); + // 展开前不请求衍生列表。 + expect(getDerivedGames).not.toHaveBeenCalled(); + fireEvent.click(toggle); + + await waitFor(() => expect(getDerivedGames).toHaveBeenCalledWith('game-1')); + expect(await screen.findByText('衍生作品')).toBeTruthy(); + expect(screen.getByText(/改编者 · 第 1 代 · 已公开/)).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '衍生作品' })); + expect(onOpenDetail).toHaveBeenCalledWith('game-child'); +}); + +test('没有衍生作品时展开给出空态', async () => { + vi.mocked(listMyGames).mockResolvedValue([ + createGame({ status: 'published', forkCount: 0 }), + ]); + vi.mocked(getDerivedGames).mockResolvedValue({ + gameId: 'game-1', + nodes: [], + truncated: false, + }); + + renderPage(createAuthValue()); + + fireEvent.click(await screen.findByRole('button', { name: /被改编 0/ })); + expect(await screen.findByText('还没有衍生作品。')).toBeTruthy(); +}); + +test('衍生列表读取失败时行内给出错误与重试', async () => { + vi.mocked(listMyGames).mockResolvedValue([ + createGame({ status: 'published', forkCount: 2 }), + ]); + vi.mocked(getDerivedGames) + .mockRejectedValueOnce(new Error('网络错误')) + .mockResolvedValueOnce({ + gameId: 'game-1', + nodes: [ + { + gameId: 'game-child', + title: '衍生作品', + authorName: '改编者', + generation: 1, + parentGameId: 'game-1', + playCount: 5, + status: 'published', + }, + ], + truncated: true, + }); + + renderPage(createAuthValue()); + + fireEvent.click(await screen.findByRole('button', { name: /被改编 2/ })); + expect(await screen.findByText('网络错误')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '重试' })); + expect(await screen.findByText('衍生作品')).toBeTruthy(); + expect( + screen.getByText('衍生作品较多,仅展示前 1 部。'), + ).toBeTruthy(); +}); + +test('未公开作品不渲染「被改编 N」入口', async () => { + vi.mocked(listMyGames).mockResolvedValue([ + createGame({ status: 'unpublished', forkCount: 0 }), + ]); + + renderPage(createAuthValue()); + + expect(await screen.findByText('我的游戏')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /被改编/ })).toBeNull(); + expect(getDerivedGames).not.toHaveBeenCalled(); +}); diff --git a/src/components/game-distribution/MyGamesPage.tsx b/src/components/game-distribution/MyGamesPage.tsx index e3d902853..c64ba2dac 100644 --- a/src/components/game-distribution/MyGamesPage.tsx +++ b/src/components/game-distribution/MyGamesPage.tsx @@ -15,6 +15,8 @@ import { import { cancelGameVersion, deleteMyGame, + getDerivedGames, + type GameDistributionLineageNode, type GameDistributionMyGame, type GameDistributionUpdateMyGameResponse, type GameDistributionVersionStatusEntry, @@ -196,6 +198,15 @@ export function MyGamesPage({ useState(null); const [savingForkAuthorizationGameId, setSavingForkAuthorizationGameId] = useState(''); + /** 「被改编 N」行内展开:展开的是哪个作品,以及按作品缓存的直接子代。 */ + const [expandedDerivedGameId, setExpandedDerivedGameId] = useState(''); + const [derivedByGameId, setDerivedByGameId] = useState< + Record + >({}); + const [loadingDerivedGameId, setLoadingDerivedGameId] = useState(''); + const [derivedErrorByGameId, setDerivedErrorByGameId] = useState< + Record + >({}); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); const canLoad = Boolean(authUi?.user && authUi.canAccessProtectedData); @@ -222,6 +233,10 @@ export function MyGamesPage({ try { const entries = await listMyGames(); setGames(entries); + // 列表刷新后「被改编 N」计数也刷新了,行内展开的旧子代列表一并作废,避免展示陈旧数据。 + setExpandedDerivedGameId(''); + setDerivedByGameId({}); + setDerivedErrorByGameId({}); // 列表投影不带 recoveryAction,只为还需要作者动作的最新版本补一次版本回读; // 单个回读失败只丢失该版本的下一步提示,不影响列表。 const pendingVersionIds = entries @@ -291,6 +306,41 @@ export function MyGamesPage({ ); } + async function loadDerivedNodes(game: GameDistributionMyGame) { + setLoadingDerivedGameId(game.id); + setDerivedErrorByGameId((current) => ({ ...current, [game.id]: '' })); + try { + const result = await getDerivedGames(game.id); + if (!result) { + setDerivedErrorByGameId((current) => ({ + ...current, + [game.id]: '读取衍生作品失败', + })); + return; + } + setDerivedByGameId((current) => ({ + ...current, + [game.id]: { nodes: result.nodes, truncated: result.truncated }, + })); + } catch (loadError: unknown) { + setDerivedErrorByGameId((current) => ({ + ...current, + [game.id]: + loadError instanceof Error ? loadError.message : '读取衍生作品失败', + })); + } finally { + setLoadingDerivedGameId(''); + } + } + + function handleToggleDerived(game: GameDistributionMyGame) { + const isOpen = expandedDerivedGameId === game.id; + setExpandedDerivedGameId(isOpen ? '' : game.id); + if (!isOpen && !derivedByGameId[game.id]) { + void loadDerivedNodes(game); + } + } + async function handleCancelVersion( game: GameDistributionMyGame, version: GameDistributionVersionStatusEntry, @@ -554,6 +604,11 @@ export function MyGamesPage({ const forkAuthorization = resolveGameForkAuthorization( game.forkAuthorization, ); + const forkCount = game.forkCount ?? 0; + const isDerivedOpen = expandedDerivedGameId === game.id; + const derivedEntry = derivedByGameId[game.id] ?? null; + const derivedError = derivedErrorByGameId[game.id] ?? ''; + const isDerivedLoading = loadingDerivedGameId === game.id; const forkAuthorizationLabel = GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ forkAuthorization @@ -641,6 +696,87 @@ export function MyGamesPage({ {isHistoryOpen ? '收起' : '展开'} ) : null} + {/* + 「被改编 N」与版本历史同属作品关系的展开信息,放在同一节奏里, + 不占用下方动作行的位置;点开后才按需拉取直接子代。 + 只在已公开作品上渲染:衍生接口要求锚点公开可读,未公开/草稿作品点进去必然 404。 + */} + {game.status === 'published' ? ( + <> + + {isDerivedOpen ? ( +
+ {isDerivedLoading ? ( +

+ 正在加载衍生作品… +

+ ) : null} + {!isDerivedLoading && derivedError ? ( +

+ {derivedError} + +

+ ) : null} + {!isDerivedLoading && !derivedError && derivedEntry + ? derivedEntry.nodes.map((node) => ( +
+ + + {node.authorName?.trim() || '未署名作者'} · + 第 {node.generation} 代 ·{' '} + {GAME_STATUS_LABEL[node.status] ?? + node.status} + +
+ )) + : null} + {!isDerivedLoading && + !derivedError && + derivedEntry && + derivedEntry.nodes.length === 0 ? ( +

+ 还没有衍生作品。 +

+ ) : null} + {!isDerivedLoading && + !derivedError && + derivedEntry?.truncated ? ( +

+ 衍生作品较多,仅展示前{' '} + {derivedEntry.nodes.length} 部。 +

+ ) : null} +
+ ) : null} + + ) : null} {isHistoryOpen ? ( ({ createGame: vi.fn(), createGameVersion: vi.fn(), deleteMyGame: vi.fn(), + getDerivedGames: vi.fn(), getGame: vi.fn(), + getGameLineage: vi.fn(), getGameVersion: vi.fn(), getMyGame: vi.fn(), listGames: vi.fn(), @@ -1172,3 +1174,116 @@ describe('作品管理 owner 作用域', () => { expect(gameDistributionMock.getMyGame).not.toHaveBeenCalled(); }); }); + +describe('创作族谱', () => { + const lineageTree = { + rootGameId: 'game-root', + root: { + gameId: 'game-root', + title: '母版', + authorName: '原作者', + generation: 0, + parentGameId: null, + playCount: 3, + status: 'published', + }, + nodes: [ + { + gameId: 'game-root', + title: '母版', + authorName: '原作者', + generation: 0, + parentGameId: null, + playCount: 3, + status: 'published', + }, + { + gameId: 'game-leaf', + title: '叶节点', + authorName: '改编者', + generation: 1, + parentGameId: 'game-root', + playCount: 1, + status: 'published', + }, + ], + truncated: false, + }; + + it('族谱页按根作品渲染树,未登录也能浏览并跳转节点', async () => { + window.history.replaceState( + null, + '', + '/games/lineage?id=game-root&from=game-leaf', + ); + gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree); + const setSelectionStage = vi.fn(); + + render( + , + ); + + expect(await screen.findByText('叶节点')).toBeTruthy(); + expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith( + 'game-root', + ); + fireEvent.click(screen.getByRole('button', { name: '母版' })); + expect(setSelectionStage).toHaveBeenCalledWith('game-detail', { + path: '/games/detail?id=game-root', + }); + }); + + it('详情页的族谱入口带上根作品与当前作品', async () => { + window.history.replaceState(null, '', '/games/detail?id=game-1'); + gameDistributionMock.getGame.mockResolvedValue({ + id: 'game-1', + title: '衍生作品', + summary: '已发布', + description: '', + category: '动作', + tags: [], + coverColor: '#d77a51', + icon: '✦', + author: { id: 'author-1', name: '测试作者' }, + deviceSupport: { desktop: true, mobile: true, touch: true }, + status: 'published', + currentVersion: { + id: 'version-1', + version: '1.0.0', + entryUrl: 'https://play.example.test/releases/version-1/index.html', + sha256: 'sha256', + publishedAt: '2026-09-18T00:00:00.000Z', + controls: ['键盘'], + }, + playCount: 1, + createdAt: '2026-09-18T00:00:00.000Z', + forkCount: 0, + lineage: { + generation: 1, + rootGameId: 'game-root', + rootTitle: '母版', + parentGameId: 'game-root', + parentTitle: '母版', + parentAuthorName: '原作者', + }, + }); + const setSelectionStage = vi.fn(); + + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: '查看创作族谱' }), + ); + expect(setSelectionStage).toHaveBeenCalledWith('game-lineage', { + path: '/games/lineage?id=game-root&from=game-1', + }); + }); +}); diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 128133c6d..68decde2d 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -100,6 +100,11 @@ const GamePlayPage = lazy(async () => { return { default: module.GamePlayPage }; }); +const GameLineagePage = lazy(async () => { + const module = await import('../game-distribution/GameLineagePage'); + return { default: module.GameLineagePage }; +}); + type ActiveRailButtonProps = { active: boolean; emphasized?: boolean; @@ -473,6 +478,32 @@ export function PlatformEntryFlowShellImpl({ [setSelectionStage], ); + /** + * 打开创作族谱页。路由按规范只要求 `?id=`;这里额外带上 `from=<当前作品>`, + * 让页面对「用户点进来时所在的作品」做高亮,直接打开链接时缺省高亮根节点。 + */ + const openGameLineage = useCallback( + (rootGameId: string, fromGameId?: string) => { + const params = new URLSearchParams({ id: rootGameId }); + if (fromGameId && fromGameId !== rootGameId) { + params.set('from', fromGameId); + } + const path = `/games/lineage?${params.toString()}`; + setSelectionStage('game-lineage', { path }); + }, + [setSelectionStage], + ); + + /** 族谱页从详情页进来,返回优先走浏览器原生返回,兜底回广场。 */ + const backFromGameLineage = useCallback(() => { + if (hasAppHistoryBackEntry()) { + window.history.back(); + return; + } + replaceAppHistoryPath('/games'); + setSelectionStage('games', { path: '/games' }); + }, [setSelectionStage]); + /** * 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到 * 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。 @@ -604,6 +635,7 @@ export function PlatformEntryFlowShellImpl({ selectionStage === 'game-detail' || selectionStage === 'game-play' || selectionStage === 'game-mine' || + selectionStage === 'game-lineage' || selectionStage === 'game-publish'; /** * 游玩页自己撑满剩余高度,面板不能再滚动,否则移动端会出现整页可滚的 @@ -614,6 +646,7 @@ export function PlatformEntryFlowShellImpl({ typeof window === 'undefined' ? '' : window.location.search, ); const gameId = gameSearchParams.get('id'); + const lineageHighlightGameId = gameSearchParams.get('from'); const publishGameId = gameSearchParams.get('game'); const avatarUrl = authUi?.user?.avatarUrl?.trim() || null; const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user); @@ -870,6 +903,14 @@ export function PlatformEntryFlowShellImpl({ ownerView={gameSearchParams.get('owner') === '1'} onBack={backFromGameDetail} onPlay={openGamePlay} + onOpenLineage={openGameLineage} + /> + ) : selectionStage === 'game-lineage' ? ( + ) : ( diff --git a/src/components/platform-entry/platformEntryActiveTypes.ts b/src/components/platform-entry/platformEntryActiveTypes.ts index 46af48ad2..1670e8d30 100644 --- a/src/components/platform-entry/platformEntryActiveTypes.ts +++ b/src/components/platform-entry/platformEntryActiveTypes.ts @@ -10,6 +10,7 @@ export type SelectionStage = | 'game-detail' | 'game-play' | 'game-mine' + | 'game-lineage' | 'game-publish'; export type PlatformEntryFlowShellProps = { diff --git a/src/routing/activeAppPageRoutes.test.ts b/src/routing/activeAppPageRoutes.test.ts index 94b300a3e..4b2918158 100644 --- a/src/routing/activeAppPageRoutes.test.ts +++ b/src/routing/activeAppPageRoutes.test.ts @@ -27,6 +27,9 @@ describe('appPageRoutes', () => { expect(resolveSelectionStageFromPath('/games/detail/')).toBe('game-detail'); expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play'); expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine'); + expect(resolveSelectionStageFromPath('/games/lineage')).toBe( + 'game-lineage', + ); expect(resolveSelectionStageFromPath('/games/publish')).toBe( 'game-publish', ); @@ -44,6 +47,9 @@ describe('appPageRoutes', () => { expect(resolvePathForSelectionStage('profile')).toBe('/profile'); expect(resolvePathForSelectionStage('games')).toBe('/games'); expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine'); + expect(resolvePathForSelectionStage('game-lineage')).toBe( + '/games/lineage', + ); expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish'); expect(resolvePathForSelectionStage('profile-payment')).toBe( '/profile/payment', diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts index 0c32ec9e9..2aa0acdd0 100644 --- a/src/routing/activeAppPageRoutes.ts +++ b/src/routing/activeAppPageRoutes.ts @@ -15,6 +15,7 @@ const STAGE_ROUTE_ENTRIES = [ ['game-detail', '/games/detail'], ['game-play', '/games/play'], ['game-mine', '/games/mine'], + ['game-lineage', '/games/lineage'], ['game-publish', '/games/publish'], ] as const satisfies readonly (readonly [SelectionStage, string])[]; diff --git a/src/services/activeAppTitle.test.ts b/src/services/activeAppTitle.test.ts index b99dc1cff..d838a54e3 100644 --- a/src/services/activeAppTitle.test.ts +++ b/src/services/activeAppTitle.test.ts @@ -36,6 +36,9 @@ describe('activeAppTitle', () => { expect(resolveAppTitleForSelectionStage('game-mine')).toBe( '我的游戏 - 陶泥儿', ); + expect(resolveAppTitleForSelectionStage('game-lineage')).toBe( + '创作族谱 - 陶泥儿', + ); expect(resolveAppTitleForSelectionStage('game-publish')).toBe( '发布游戏 - 陶泥儿', ); diff --git a/src/services/activeAppTitle.ts b/src/services/activeAppTitle.ts index d3a82f17d..cc5c172ec 100644 --- a/src/services/activeAppTitle.ts +++ b/src/services/activeAppTitle.ts @@ -16,6 +16,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Record = { 'game-detail': '游戏详情 - 陶泥儿', 'game-play': '正在游玩 - 陶泥儿', 'game-mine': '我的游戏 - 陶泥儿', + 'game-lineage': '创作族谱 - 陶泥儿', 'game-publish': '发布游戏 - 陶泥儿', }; diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 95750dff7..587b1fa34 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -5,7 +5,9 @@ import { clearStoredAccessToken, setStoredAccessToken } from './apiClient'; import { cancelGameVersion, createGameVersion, + getDerivedGames, getGame, + getGameLineage, getGameVersion, getMyGameReview, listGameReviews, @@ -85,6 +87,42 @@ describe('gameDistributionClient', () => { expect(await getGame('')).toBeNull(); }); + it('族谱与衍生列表走公开只读路径、不缓存,并拒绝空 id', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + rootGameId: 'game-root', + root: null, + nodes: [], + truncated: false, + gameId: 'game-1', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await getGameLineage('game-1'); + await getDerivedGames('game-1'); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/game-distribution/games/game-1/lineage', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + '/api/game-distribution/games/game-1/derived', + ); + for (const [, init] of fetchMock.mock.calls) { + expect(init).toMatchObject({ method: 'GET', cache: 'no-store' }); + expect(init?.headers?.Authorization).toBeUndefined(); + } + + expect(await getGameLineage('')).toBeNull(); + expect(await getDerivedGames(' ')).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index 317df0c76..2f3dd84ab 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -4,9 +4,11 @@ import type { GameDistributionCategory, GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, + GameDistributionDerivedResponse, GameDistributionDeviceSupport, GameDistributionForkAuthorization, GameDistributionGame, + GameDistributionLineageResponse, GameDistributionMyReviewResponse, GameDistributionRecoveryAction, GameDistributionReviewsResponse, @@ -27,11 +29,14 @@ import { getGamePlayClientId } from './gamePlayClientId'; export type { GameDistributionAuthor, + GameDistributionDerivedResponse, GameDistributionDeviceSupport, GameDistributionForkAuthorization, GameDistributionGame, GameDistributionInputMode, GameDistributionLineage, + GameDistributionLineageNode, + GameDistributionLineageResponse, GameDistributionRatingSummary, GameDistributionRecoveryAction, GameDistributionReview, @@ -357,6 +362,36 @@ export async function getGame(gameId: string) { ); } +/** + * 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读; + * 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。 + */ +export async function getGameLineage(gameId: string) { + const normalizedId = gameId.trim(); + if (!normalizedId) return null; + return requestJson( + `/games/${encodeURIComponent(normalizedId)}/lineage`, + { method: 'GET', cache: 'no-store' }, + '读取创作族谱失败', + PUBLIC_GAME_REQUEST_OPTIONS, + ); +} + +/** + * 读取某作品的直接衍生作品(「被改编」列表)。只含对外可见的直接子代, + * 与公开详情里 `forkCount` 的口径一致。 + */ +export async function getDerivedGames(gameId: string) { + const normalizedId = gameId.trim(); + if (!normalizedId) return null; + return requestJson( + `/games/${encodeURIComponent(normalizedId)}/derived`, + { method: 'GET', cache: 'no-store' }, + '读取衍生作品失败', + PUBLIC_GAME_REQUEST_OPTIONS, + ); +} + /** * 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。 */