实现游戏共创 M3:创作族谱树与衍生列表
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 3m49s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m41s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 3m49s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m41s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
- 新增 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:
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+1
@@ -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 {
|
||||
|
||||
+59
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+62
@@ -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
Reference in New Issue
Block a user