实现游戏共创 M3:创作族谱树与衍生列表

- 新增 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 的锚点可读性口径
This commit is contained in:
2026-10-05 01:02:03 +08:00
parent 3d51d1309a
commit 39669763fe
36 changed files with 2780 additions and 27 deletions
@@ -37,7 +37,7 @@
- [ ] 未登录用户可以浏览族谱页,不触发登录门禁;已下架但仍有血缘的节点保留在树上并标注原作品不可用,不出现空白或断链。
- [ ] 超出节点上限时如实标注已截断,不静默丢弃;空族谱(无任何衍生)给出明确空态而不是报错。
- [ ] 我的作品页「被改编」列表只包含直接衍生作品,数量与族谱树中该作品的子节点一致。
- [ ] 族谱与衍生列表的公开数据不泄露未公开作品、被隐藏内容或对象键。
- [ ] 族谱与衍生列表的公开数据不泄露未公开作品、被隐藏内容或对象键;**锚点(URL 里点名的作品)必须公开可读**——未公开、已软删除或不存在的作品,两个接口一律返回 404,不得用「空标题占位节点」或空树代替 404(那等于确认该作品存在、其代际与血缘位置)。
- [ ] 灰度未命中时族谱入口不渲染,直接访问路由给出与现役未知路径一致的降级行为。
- [ ] 桌面与移动端布局可用,长标题与较大数字不撑破容器。
@@ -45,4 +45,4 @@
- 自动化:族谱与衍生列表的排序、截断、可见性过滤定向测试;路由与前端组件定向测试;DTO 一致性与编码、文档索引检查。
- 运行时:真实数据上打开族谱页与「被改编」列表,包含一条来源已下架的链路;桌面与窄屏浏览器验证跳转与布局。
- 边界:无衍生作品的空族谱、超上限截断、来源下架、未公开子作品不出现在树中、未登录访问。
- 边界:无衍生作品的空族谱、超上限截断、来源下架、未公开子作品不出现在树中、未公开/已软删除锚点返回 404、未登录访问。
@@ -303,8 +303,8 @@ pub(crate) project_bundle_sha256: Option<String>,
| 方法 / 路径 | 说明 |
| --- | --- |
| `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 + 发布灰度)
@@ -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;
@@ -60,6 +60,9 @@ const PAIRS = [
'GameDistributionForkAuthorization',
],
['GameDistributionLineage', 'GameDistributionLineage'],
['GameDistributionLineageNode', 'GameDistributionLineageNode'],
['GameDistributionLineageResponse', 'GameDistributionLineageResponse'],
['GameDistributionDerivedResponse', 'GameDistributionDerivedResponse'],
['GameDistributionForkDeclaration', 'GameDistributionForkDeclaration'],
[
'GameDistributionSetForkAuthorizationRequest',
@@ -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<AppState> {
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<T>(value: Option<T>) -> Result<T, AppError> {
value.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))
}
/// 公开创作族谱:以该作品的母版为顶返回整棵树。公开只读、匿名可读。
///
/// 锚点必须公开可读:不存在、未公开或已软删除一律 404,与公开详情同一口径。这里**不**
/// 用「空标题节点」代替 404——那等于对外确认该 gameId 存在、它是第几代、它在血缘里的位置。
async fn get_game_lineage(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Path(game_id): Path<String>,
) -> Result<Json<Value>, 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<AppState>,
Extension(ctx): Extension<RequestContext>,
Path(game_id): Path<String>,
) -> Result<Json<Value>, 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<Vec<GameDistributionVersionRecord>, AppError> {
) -> Result<(Vec<GameDistributionVersionRecord>, 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::<u8>(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::<Vec<_>>();
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 [
@@ -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,
@@ -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<String>,
pub generation: u32,
pub parent_game_id: Option<String>,
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<String>,
pub generation: u32,
pub parent_game_id: Option<String>,
pub play_count: u64,
pub status: String,
}
/// 族谱树:`root` 为根节点在可见集合中的投影,根不可读(已删/未公开)时为 `None`。
///
/// `nodes` 是整棵树(含根,若根可见)的稳定排序结果;`root` 只是取其中的根节点,
/// 便于展示层直接标注「树顶」,不需要再按 ID 找一次。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineageTree {
pub root: Option<LineageTreeNode>,
pub nodes: Vec<LineageTreeNode>,
pub truncated: bool,
}
/// 衍生作品列表:直接子代的稳定排序结果。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineageNodeList {
pub nodes: Vec<LineageTreeNode>,
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(ids, vec!["early", "mid"]);
}
}
@@ -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<String>,
/// 代际;母版为 0。
pub generation: u32,
/// 父作品 ID;母版为空。父作品不可用时仍回传 ID,展示层据此降级为「原作品不可用」。
#[serde(default)]
pub parent_game_id: Option<String>,
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<GameDistributionLineageNode>,
pub nodes: Vec<GameDistributionLineageNode>,
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<GameDistributionLineageNode>,
pub truncated: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionListResponse {
@@ -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,
@@ -251,6 +251,8 @@ pub struct GameDistributionAdminGameRecord {
pub struct GameDistributionOwnerGameRecord {
pub game: GameDistributionGameRecord,
pub versions: Vec<GameDistributionVersionRecord>,
/// 直接改编该作品的已公开作品数(作者页「被改编 N」入口)。
pub fork_count: u64,
}
/// 公开的改编血缘摘要;母版为 `None`。
@@ -278,6 +280,38 @@ pub struct GameDistributionPublicGameRecord {
pub lineage: Option<GameDistributionLineageRecord>,
}
/// 族谱 / 衍生列表的单个对外节点。
///
/// 已软删除或未公开的作品不会成为节点(`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<String>,
pub generation: u32,
pub parent_game_id: Option<String>,
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<GameDistributionLineageNodeRecord>,
pub nodes: Vec<GameDistributionLineageNodeRecord>,
pub truncated: bool,
}
/// 某作品的直接衍生作品(「被改编」)列表。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionDerivedGamesRecord {
pub game_id: String,
pub nodes: Vec<GameDistributionLineageNodeRecord>,
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<Option<GameDistributionLineageTreeRecord>, 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<Option<GameDistributionDerivedGamesRecord>, 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);
}
}
@@ -629,6 +629,50 @@ impl SpacetimeClient {
.await
}
/// 读取以某作品根为顶的创作族谱树;锚点不可公开读取时返回 `None`,由 api-server 映射 404。
pub async fn get_game_distribution_lineage(
&self,
game_id: String,
) -> Result<Option<GameDistributionLineageTreeRecord>, 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<Option<GameDistributionDerivedGamesRecord>, 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,
@@ -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<RemoteModule>,
>
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>
{
}
impl<
Ctx: __sdk::DbContext<
Ctx: __sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>,
> RemoteDbContext for Ctx
> RemoteDbContext for Ctx
{
}
@@ -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;
}
@@ -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<GameDistributionLineageNodeSnapshot>,
pub truncated: bool,
pub error_message: Option<String>,
}
impl __sdk::InModule for GameDistributionDerivedGamesResult {
type Module = super::RemoteModule;
}
@@ -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;
}
@@ -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<String>,
pub generation: u32,
pub parent_game_id: Option<String>,
pub play_count: u64,
pub status: String,
}
impl __sdk::InModule for GameDistributionLineageNodeSnapshot {
type Module = super::RemoteModule;
}
@@ -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<GameDistributionLineageNodeSnapshot>,
pub nodes: Vec<GameDistributionLineageNodeSnapshot>,
pub truncated: bool,
pub error_message: Option<String>,
}
impl __sdk::InModule for GameDistributionLineageResult {
type Module = super::RemoteModule;
}
@@ -12,6 +12,7 @@ use super::game_distribution_version_snapshot_type::GameDistributionVersionSnaps
pub struct GameDistributionOwnerGameSnapshot {
pub game: GameDistributionGameSnapshot,
pub versions: Vec<GameDistributionVersionSnapshot>,
pub fork_count: u64,
}
impl __sdk::InModule for GameDistributionOwnerGameSnapshot {
@@ -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<GameDistributionLineageResult, __sdk::InternalError>,
) + 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<GameDistributionLineageResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionLineageResult>(
"get_game_distribution_lineage_and_return",
GetGameDistributionLineageAndReturnArgs { input },
__callback,
);
}
}
@@ -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<GameDistributionDerivedGamesResult, __sdk::InternalError>,
) + 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<GameDistributionDerivedGamesResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionDerivedGamesResult>(
"list_game_distribution_derived_games_and_return",
ListGameDistributionDerivedGamesAndReturnArgs { input },
__callback,
);
}
}
@@ -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<GameDistributionLineageSnapshot>,
}
/// 族谱 / 衍生列表的对外节点。
///
/// 只有公开且未软删除的作品会成为节点(锚点可读性另由 `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<String>,
/// 代际;母版为 0。
pub generation: u32,
/// 父作品 ID;母版为 None。父作品不可用时仍回传 ID,展示层据此降级。
pub parent_game_id: Option<String>,
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<GameDistributionLineageNodeSnapshot>,
pub nodes: Vec<GameDistributionLineageNodeSnapshot>,
pub truncated: bool,
pub error_message: Option<String>,
}
/// 直接衍生作品列表结果;`found == false` 表示父作品不存在或已软删除。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionDerivedGamesResult {
pub ok: bool,
pub found: bool,
pub game_id: String,
pub nodes: Vec<GameDistributionLineageNodeSnapshot>,
pub truncated: bool,
pub error_message: Option<String>,
}
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<GameDistributionVersionSnapshot>,
/// 直接改编该作品的已公开作品数(作者页「被改编 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<module_game_distribution::LineageTreeNode>,
nodes: Vec<module_game_distribution::LineageTreeNode>,
truncated: bool,
}
/// 直接衍生作品列表的领域结果。
struct GameDistributionDerivedGamesRead {
game_id: String,
nodes: Vec<module_game_distribution::LineageTreeNode>,
truncated: bool,
}
/// 读取以目标作品根为顶的整棵族谱树。
///
/// 锚点必须公开可读:不存在、已软删除或未公开时返回 `Ok(None)`(api-server 映射 404)。
/// 不允许「降级返回一个空标题节点」——那等于对外确认该 gameId 存在、代际与血缘位置,属于泄露。
fn game_distribution_lineage_tx(
ctx: &ReducerContext,
input: GameDistributionLineageInput,
) -> Result<Option<GameDistributionLineageRead>, 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<Option<GameDistributionDerivedGamesRead>, 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::<Vec<_>>();
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<module_game_distribution::LineageTreeEntry> {
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<String>,
) -> 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<String> {
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,
@@ -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} 次
</span>
) : null}
{onOpenLineage && publiclyVisible ? (
/*
族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事,
点进去是以母版为顶的整棵族谱树;`from` 带上当前作品供页面高亮。
只有已公开作品才渲染:族谱接口要求锚点公开可读,未公开作品点进去必然 404。
*/
<button
type="button"
className="game-fork-lineage-link"
onClick={() =>
onOpenLineage(lineage?.rootGameId ?? game.id, game.id)
}
>
查看创作族谱
</button>
) : null}
</span>
),
},
@@ -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> = {},
): 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(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-leaf"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameDetailPage
gameId="game-1"
ownerView
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenLineage={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('网络错误')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(await screen.findByText('第二部')).toBeTruthy();
});
it('缺少根作品编号时不发请求并提示编号无效', async () => {
render(
<GameLineagePage
rootGameId=""
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenLineage={onOpenLineage}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: '查看创作族谱' }),
);
expect(onOpenLineage).toHaveBeenCalledWith('game-root', 'game-1');
});
it('没有族谱导航回调时不渲染族谱入口', async () => {
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull();
});
});
@@ -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<number, GameDistributionLineageNode[]>();
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 (
<article
className={`game-lineage-node${isAnchor ? ' game-lineage-node--anchor' : ''}`}
aria-current={isAnchor ? 'true' : undefined}
>
<div className="game-lineage-node__head">
<span className="game-lineage-node__generation">
{generationLabel(node.generation)}
</span>
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
</div>
<button
type="button"
className="game-lineage-node__title"
onClick={() => onOpenDetail(node.gameId)}
>
{title}
</button>
<p className="game-lineage-node__meta">
<span>{authorName || '未署名作者'}</span>
<span aria-hidden="true">·</span>
<span>{node.playCount.toLocaleString()} 次游玩</span>
</p>
{parentUnavailable ? (
<p className="game-lineage-node__parent-unavailable">原作品已不可用</p>
) : null}
</article>
);
}
/**
* 创作族谱页:以根作品为顶展示整棵改编树,任一公开节点都能点进对应作品详情。
*
* 公开只读页面,未登录也能浏览;加载失败走平台既有错误态,无衍生作品给明确空态。
* 父作品已删或未公开时该节点只降级为「原作品已不可用」,不回显父作品内容、不产生断链。
*/
export function GameLineagePage({
rootGameId,
highlightGameId = null,
onBack,
onOpenDetail,
}: GameLineagePageProps) {
const normalizedRootId = rootGameId?.trim() ?? '';
const [lineage, setLineage] = useState<GameDistributionLineageResponse | null>(
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 (
<div className="game-page game-lineage-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回
</button>
<div className="game-toolbar">
<div className="game-toolbar__title">
<span className="game-eyebrow">创作族谱</span>
<h2>{rootTitle || '创作族谱'}</h2>
</div>
{normalizedRootId ? (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => void loadLineage()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" /> 刷新
</PlatformActionButton>
) : null}
</div>
{!normalizedRootId ? (
<PlatformStatusMessage tone="error" surface="platform">
游戏编号无效
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="game-loading-panel">正在加载创作族谱…</div>
) : null}
{!isLoading && error ? (
<PlatformStatusMessage tone="error" surface="platform">
<span>{error}</span>
<button
type="button"
className="game-lineage-retry"
onClick={() => void loadLineage()}
>
重试
</button>
</PlatformStatusMessage>
) : null}
{!isLoading && !error && lineage ? (
<>
{lineage.truncated ? (
<p className="game-lineage-notice" role="status">
这部作品衍生较多,这里只展示前 {nodes.length} 个节点。
</p>
) : null}
{lineage.root ? null : (
<p className="game-lineage-notice game-lineage-notice--muted" role="status">
母版已不可用,下面按代际关系展示仍可阅读的衍生作品。
</p>
)}
{groups.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>这部作品还没有可展示的族谱节点。</span>
</PlatformEmptyState>
) : (
<div className="game-lineage-tree">
{groups.map(([generation, groupNodes]) => (
<section
key={generation}
className="game-lineage-generation"
aria-label={generationLabel(generation)}
>
<h3 className="game-lineage-generation__label">
{generationLabel(generation)}
<span className="game-lineage-generation__count">
{groupNodes.length}
</span>
</h3>
<div className="game-lineage-generation__nodes">
{groupNodes.map((node) => (
<LineageNodeCard
key={node.gameId}
node={node}
isAnchor={node.gameId === anchorId}
hasVisibleParent={
!node.parentGameId ||
visibleIds.has(node.parentGameId)
}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
))}
</div>
)}
{derivedCount === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>这部作品还没有衍生作品,欢迎从它开始共创。</span>
</PlatformEmptyState>
) : null}
</>
) : null}
<p className="game-detail-footnote">
族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。
</p>
</div>
);
}
export default GameLineagePage;
@@ -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(
<AuthUiContext.Provider value={createAuthValue()}>
<MyGamesPage onBack={vi.fn()} onOpenDetail={onOpenDetail} />
</AuthUiContext.Provider>,
);
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();
});

Some files were not shown because too many files have changed in this diff Show More