From 247625d61da123b00f1fdeb540d757e8fd27f6b4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 19:08:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=B8=B8=E6=88=8F=E5=85=B1?= =?UTF-8?q?=E5=88=9B=20M1=EF=BC=9A=E4=BD=9C=E5=93=81=E7=BA=A7=E5=85=B1?= =?UTF-8?q?=E5=88=9B=E6=8E=88=E6=9D=83=E4=B8=8E=E6=94=B9=E7=BC=96=E8=A1=80?= =?UTF-8?q?=E7=BC=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game_distribution_game 表尾追加 fork_authorization(默认 forbidden,只升不降,旧行自动取默认) - 新增 game_distribution_lineage 表,记录父作品、来源版本、根作品与代际,并按 parent/root/owner 建索引 - migration.rs 登记新表并注明血缘随迁移导出 - 新增 set_game_distribution_fork_authorization_and_return procedure 与幂等收据、期望值 CAS - 创建作品时校验改编声明:来源存在、已公开、授权开放、来源版本等于当前公开版本;复用既有身份时拒绝携带血缘 - 公开与后台响应增量:forkAuthorization、forkCount、lineage、generation、forkedFromGameId、derivedCount - 公开快照的作者名与头像改为读时联 user_account,不再落到「创作者」兜底文案 - api-server 新增 PUT /api/game-distribution/games/{game_id}/fork-authorization(Bearer、幂等键、期望值校验) - 错误映射新增 FORK_ 前缀分支并置于通用子串分支之前,避免被误判成 409 或 404 - module-game-distribution 增加授权阶梯、代际计算与错误码,并补单测覆盖阶梯与错误码前缀 - 网页端详情页新增共创信息卡,我的作品页新增授权提升入口,补服务层调用与定向测试 - 重新生成 SpacetimeDB 绑定,登记 DTO 一致性脚本的共创类型 --- .../shared/src/contracts/gameDistribution.ts | 91 +++++ .../check-game-distribution-dto-parity.mjs | 13 +- .../src/modules/game_distribution.rs | 173 +++++++- .../module-game-distribution/src/domain.rs | 163 ++++++++ .../module-game-distribution/src/errors.rs | 43 ++ .../module-game-distribution/src/lib.rs | 6 +- .../shared-contracts/src/game_distribution.rs | 55 +++ .../crates/spacetime-client/src/active.rs | 3 +- .../spacetime-client/src/active/mapper.rs | 3 +- .../src/active/mapper/game_distribution.rs | 76 +++- .../spacetime-client/src/game_distribution.rs | 55 +++ .../spacetime-client/src/module_bindings.rs | 34 ++ ...e_distribution_admin_game_snapshot_type.rs | 4 + ...ame_distribution_create_game_input_type.rs | 2 + .../game_distribution_game_snapshot_type.rs | 1 + .../game_distribution_game_type.rs | 3 + ...game_distribution_lineage_snapshot_type.rs | 20 + .../game_distribution_lineage_table.rs | 231 +++++++++++ .../game_distribution_lineage_type.rs | 73 ++++ ..._distribution_public_game_snapshot_type.rs | 3 + ...ution_set_fork_authorization_input_type.rs | 21 + ...fork_authorization_and_return_procedure.rs | 62 +++ .../spacetime-module/src/game_distribution.rs | 374 +++++++++++++++++- .../crates/spacetime-module/src/migration.rs | 2 + .../game-distribution/GameDetailPage.tsx | 32 ++ .../GameDistributionPages.test.tsx | 38 ++ .../game-distribution/MyGamesPage.test.tsx | 73 ++++ .../game-distribution/MyGamesPage.tsx | 185 +++++++++ .../game-distribution/gameDistribution.css | 39 ++ src/services/gameDistributionClient.test.ts | 53 +++ src/services/gameDistributionClient.ts | 40 ++ 31 files changed, 1943 insertions(+), 28 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_set_fork_authorization_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_fork_authorization_and_return_procedure.rs diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 1d0d8cd7a..539cd521d 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -111,6 +111,77 @@ export type GameDistributionVersionSummary = { controls: string[]; }; +/** + * 共创授权三态:只允许单向提升(forbidden → nonCommercial → full),不可降级。 + */ +export type GameDistributionForkAuthorization = + | 'forbidden' + | 'nonCommercial' + | 'full'; + +/** 授权阶梯顺序;`canUpgradeGameForkAuthorization` 按这个顺序判定可提升档位。 */ +export const GAME_DISTRIBUTION_FORK_AUTHORIZATIONS: readonly GameDistributionForkAuthorization[] = + ['forbidden', 'nonCommercial', 'full']; + +/** 旧数据、字段缺省或未知取值时的兜底档位:保守拒绝共创。 */ +export const GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION: GameDistributionForkAuthorization = + 'forbidden'; + +export const GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS: Record< + GameDistributionForkAuthorization, + string +> = { + forbidden: '禁止共创', + nonCommercial: '允许非商用共创', + full: '允许全开放共创', +}; + +/** 空间受限场景(卡片行内选择等)用的短标签。 */ +export const GAME_DISTRIBUTION_FORK_AUTHORIZATION_SHORT_LABELS: Record< + GameDistributionForkAuthorization, + string +> = { + forbidden: '禁止', + nonCommercial: '非商用', + full: '全开放', +}; + +/** 把任意原始值收敛到合法档位;缺省与未知一律按 `forbidden` 兜底。 */ +export function resolveGameForkAuthorization( + value: string | null | undefined, +): GameDistributionForkAuthorization { + return GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.includes( + value as GameDistributionForkAuthorization, + ) + ? (value as GameDistributionForkAuthorization) + : GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION; +} + +/** 授权阶梯比较:只有严格高于当前档位的目标才允许提交。 */ +export function canUpgradeGameForkAuthorization( + current: GameDistributionForkAuthorization, + target: GameDistributionForkAuthorization, +) { + return ( + GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.indexOf(target) > + GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.indexOf(current) + ); +} + +/** + * 作品血缘摘要:代际、父作品与根作品。 + * 根作品或旧数据为 null/缺省;父作品下架后仍按快照回传标题与作者名,用于溯源署名。 + * `parentAuthorName` 在账号信息不可得时可能缺省或为 null,展示层按「无署名」处理。 + */ +export type GameDistributionLineage = { + generation: number; + rootGameId: string; + rootTitle: string; + parentGameId: string; + parentTitle: string; + parentAuthorName?: string | null; +}; + export type GameDistributionGame = { id: string; title: string; @@ -141,6 +212,12 @@ export type GameDistributionGame = { currentVersion?: GameDistributionVersionSummary | null; /** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */ ratingSummary?: GameDistributionRatingSummary; + /** 共创授权三态;旧数据或字段缺省时读作 `forbidden`。 */ + forkAuthorization?: GameDistributionForkAuthorization; + /** 被改编次数(直接子代数量);旧数据或字段缺省时读作 0。 */ + forkCount?: number; + /** 血缘摘要;根作品或旧数据为 null/缺省。 */ + lineage?: GameDistributionLineage | null; playCount: number; createdAt: string; }; @@ -164,6 +241,8 @@ export type GameDistributionCreateGameRequest = { deviceSupport: GameDistributionDeviceSupport; inputModes: GameDistributionInputMode[]; orientation: GameDistributionOrientation; + /** 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 */ + fork?: GameDistributionForkDeclaration | null; }; export type GameDistributionCreateVersionRequest = { @@ -245,3 +324,15 @@ export type GameDistributionCancelVersionResponse = { version: GameDistributionPrivateVersion; replayed: boolean; }; + +/** 提升共创授权;`expectedForkAuthorization` 是 CAS 期望值,只允许升级。 */ +export type GameDistributionSetForkAuthorizationRequest = { + expectedForkAuthorization: GameDistributionForkAuthorization; + forkAuthorization: GameDistributionForkAuthorization; +}; + +/** 创建游戏时的改编来源声明:父作品 + 建立血缘时锁定的父版本。 */ +export type GameDistributionForkDeclaration = { + parentGameId: string; + parentVersionId: string; +}; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index c87e829dc..10b03a2e9 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -50,6 +50,17 @@ const PAIRS = [ 'GameDistributionCreateVersionRequest', ], ['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'], + // 共创(Fork)授权与血缘:档位枚举与公开血缘摘要必须与 TS 逐字对齐。 + [ + 'GameDistributionForkAuthorization', + 'GameDistributionForkAuthorization', + ], + ['GameDistributionLineage', 'GameDistributionLineage'], + ['GameDistributionForkDeclaration', 'GameDistributionForkDeclaration'], + [ + 'GameDistributionSetForkAuthorizationRequest', + 'GameDistributionSetForkAuthorizationRequest', + ], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], @@ -92,7 +103,7 @@ const RESPONSE_BUILDERS = [ fn: 'public_game_payload', ts: 'GameDistributionGame', base: 'game_payload', - mustEmit: ['currentVersion', 'ratingSummary'], + mustEmit: ['currentVersion', 'ratingSummary', 'forkCount', 'lineage'], }, { fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' }, { fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' }, diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index a892127b7..2ce689cd6 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -37,11 +37,12 @@ use shared_contracts::admin::{ }; use shared_contracts::game_distribution::{ GAME_DISTRIBUTION_CATEGORIES, GameDistributionAuthor, GameDistributionCreateGameRequest, - GameDistributionCreateVersionRequest, GameDistributionInputMode, - GameDistributionMyReviewResponse, GameDistributionPublishMetadataSuggestion, - GameDistributionPublishMetadataSuggestionRequest, GameDistributionRatingSummary, - GameDistributionReview, GameDistributionReviewsResponse, GameDistributionSaveReviewRequest, - GameDistributionSaveReviewResponse, + GameDistributionCreateVersionRequest, GameDistributionForkAuthorization, + GameDistributionInputMode, GameDistributionMyReviewResponse, + GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, + GameDistributionRatingSummary, GameDistributionReview, GameDistributionReviewsResponse, + GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, + GameDistributionSetForkAuthorizationRequest, }; use spacetime_client::{ GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord, @@ -52,7 +53,8 @@ use spacetime_client::{ GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationOperationRecord, - GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, + GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, GameDistributionUserReviewRecord, GameDistributionVersionRecord, SpacetimeClientError, }; @@ -309,6 +311,10 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/games/{game_id}/unpublish", post(unpublish_game), ) + .route( + "/api/game-distribution/games/{game_id}/fork-authorization", + put(set_fork_authorization), + ) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -1014,6 +1020,14 @@ async fn create_game( request_digest, now_micros: now, local_project_id: normalize_local_project_id(payload.local_project_id.as_deref())?, + forked_from_game_id: payload + .fork + .as_ref() + .map(|fork| fork.parent_game_id.clone()), + forked_from_version_id: payload + .fork + .as_ref() + .map(|fork| fork.parent_version_id.clone()), }) .await .map_err(map_spacetime_error)?; @@ -1846,6 +1860,49 @@ async fn unpublish_game( )) } +/// 作者提升作品的共创授权档位:只升不降,降级与未知档位由领域层拒绝。 +async fn set_fork_authorization( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(game_id): Path, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let idempotency_key = idempotency_key(&headers)?; + let request_digest = compute_request_digest( + &serde_json::to_vec(&( + game_id.as_str(), + payload.expected_fork_authorization, + payload.fork_authorization, + )) + .map_err(|error| internal(error.to_string()))?, + ); + let game = state + .spacetime_client() + .set_game_distribution_fork_authorization( + GameDistributionSetForkAuthorizationRecordInput { + game_id, + owner_user_id, + fork_authorization: fork_authorization_value(payload.fork_authorization), + expected_fork_authorization: fork_authorization_value( + payload.expected_fork_authorization, + ), + idempotency_key, + request_digest, + now_micros: now_micros(), + }, + ) + .await + .map_err(map_spacetime_error)?; + Ok(json_success_body( + Some(&ctx), + json!({ "game": game_payload(&game.0), "replayed": game.1 }), + )) +} + async fn admin_list_reviews( State(state): State, Extension(ctx): Extension, @@ -2314,6 +2371,14 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value { "ratingSummary".to_string(), json!(rating_summary_payload(game.rating_summary)), ); + object.insert("forkCount".to_string(), json!(game.fork_count)); + object.insert( + "lineage".to_string(), + game.lineage + .as_ref() + .map(lineage_payload) + .unwrap_or(Value::Null), + ); } payload } @@ -2333,6 +2398,10 @@ fn admin_game_payload(game: &GameDistributionAdminGameRecord) -> Value { "playCount": game.play_count, "activeVersionId": game.active_version_id, "publicationRevision": game.publication_revision, + "forkAuthorization": game.fork_authorization, + "generation": game.lineage_generation, + "forkedFromGameId": game.forked_from_game_id, + "derivedCount": game.derived_count, "createdAt": game.created_at, "updatedAt": game.updated_at, "versions": game @@ -2392,12 +2461,26 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value { "inputModes": input_modes, "orientation": game.orientation, "status": game.visibility, + "forkAuthorization": game.fork_authorization, "publicationRevision": game.publication_revision, "playCount": game.play_count, "createdAt": game.created_at, }) } +/// 公开血缘摘要的响应形状。独立成函数是为了让公开投影保持「只用 object.insert 追加键」 +/// 的形态,DTO 一致性检查据此逐键比对 TS 契约。 +fn lineage_payload(lineage: &spacetime_client::GameDistributionLineageRecord) -> Value { + json!({ + "generation": lineage.generation, + "rootGameId": lineage.root_game_id, + "rootTitle": lineage.root_title, + "parentGameId": lineage.parent_game_id, + "parentTitle": lineage.parent_title, + "parentAuthorName": lineage.parent_author_name, + }) +} + fn version_summary_payload(version: &GameDistributionVersionRecord) -> Value { json!({ "id": version.version_id, @@ -2664,8 +2747,63 @@ fn map_package_error(error: ReleasePackageError) -> AppError { .with_details(json!({ "provider": "game-distribution", "reason": format!("{error:?}") })) } +/// DTO 档位 → 领域档位:字符串值只在一处定义(`module-game-distribution`)。 +fn to_domain_fork_authorization( + value: GameDistributionForkAuthorization, +) -> module_game_distribution::ForkAuthorization { + match value { + GameDistributionForkAuthorization::Forbidden => { + module_game_distribution::ForkAuthorization::Forbidden + } + GameDistributionForkAuthorization::NonCommercial => { + module_game_distribution::ForkAuthorization::NonCommercial + } + GameDistributionForkAuthorization::Full => { + module_game_distribution::ForkAuthorization::Full + } + } +} + +fn fork_authorization_value(value: GameDistributionForkAuthorization) -> String { + to_domain_fork_authorization(value).as_str().to_string() +} + fn map_spacetime_error(error: SpacetimeClientError) -> AppError { match error { + // 共创相关错误以稳定错误码开头。这一段必须先于下面的「不匹配 / 不存在 / 状态」 + // 子串分支,否则血缘错误会被误映射成通用 409 或 404。 + SpacetimeClientError::Procedure(message) if message.contains("FORK_") => { + let code = message + .split(':') + .next() + .map(str::trim) + .filter(|code| code.starts_with("FORK_")) + .unwrap_or("FORK_ERROR"); + let (status, code) = match code { + "FORK_NOT_AUTHORIZED" => (StatusCode::FORBIDDEN, "FORK_NOT_AUTHORIZED"), + "FORK_SOURCE_NOT_FOUND" => (StatusCode::NOT_FOUND, "FORK_SOURCE_NOT_FOUND"), + "FORK_SOURCE_NOT_AVAILABLE" => { + (StatusCode::CONFLICT, "FORK_SOURCE_NOT_AVAILABLE") + } + "FORK_SOURCE_VERSION_MISMATCH" => { + (StatusCode::CONFLICT, "FORK_SOURCE_VERSION_MISMATCH") + } + "FORK_DECLARATION_ON_EXISTING_GAME" => { + (StatusCode::CONFLICT, "FORK_DECLARATION_ON_EXISTING_GAME") + } + "FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED" => ( + StatusCode::CONFLICT, + "FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED", + ), + "FORK_AUTHORIZATION_UNKNOWN" => { + (StatusCode::BAD_REQUEST, "FORK_AUTHORIZATION_UNKNOWN") + } + _ => (StatusCode::CONFLICT, "FORK_ERROR"), + }; + AppError::from_status(status) + .with_code(code) + .with_details(json!({ "provider": "game-distribution", "message": message })) + } SpacetimeClientError::Procedure(message) if message.contains("owner 不匹配") => { AppError::from_status(StatusCode::FORBIDDEN) .with_details(json!({ "provider": "game-distribution", "message": message })) @@ -3381,6 +3519,7 @@ mod tests { }, input_modes: vec![GameDistributionInputMode::Keyboard], orientation: GameDistributionOrientation::Landscape, + fork: None, } } @@ -3519,6 +3658,7 @@ mod tests { screenshots_json: Some( r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(), ), + fork_authorization: "forbidden".to_string(), }; let payload = game_payload(&game); assert_eq!( @@ -3540,6 +3680,8 @@ mod tests { average_score, rating_count, }, + fork_count: 0, + lineage: None, }); let summary: shared_contracts::game_distribution::GameDistributionGameSummary = serde_json::from_value(payload).expect("公开游戏响应应可解析"); @@ -3579,6 +3721,7 @@ mod tests { updated_at: "2026-09-20T00:00:00Z".to_string(), cover_object_key: Some("generated/game-cover.png".to_string()), screenshots_json: None, + fork_authorization: "forbidden".to_string(), }; let mut version = GameDistributionVersionRecord { version_id: "version_2".to_string(), @@ -3740,6 +3883,7 @@ mod tests { // 发布写入必须要求登录态,未带 Bearer 时在进入业务前就被拒绝。 let unauthenticated_create = app + .clone() .oneshot( Request::builder() .method("POST") @@ -3751,6 +3895,23 @@ mod tests { .await .expect("路由响应"); assert_eq!(unauthenticated_create.status(), StatusCode::UNAUTHORIZED); + + // 共创授权提升属于作者写入:未带 Bearer 必须在进入业务前被拒,且不能是 404/405, + // 否则说明路由没有挂进受保护区、被别的路径掩盖了。 + let unauthenticated_fork = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/game-distribution/games/game_1/fork-authorization") + .header("content-type", "application/json") + .body(Body::from( + r#"{"expectedForkAuthorization":"forbidden","forkAuthorization":"nonCommercial"}"#, + )) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!(unauthenticated_fork.status(), StatusCode::UNAUTHORIZED); } #[test] diff --git a/server-rs/crates/module-game-distribution/src/domain.rs b/server-rs/crates/module-game-distribution/src/domain.rs index 4607a2013..e0dead4c8 100644 --- a/server-rs/crates/module-game-distribution/src/domain.rs +++ b/server-rs/crates/module-game-distribution/src/domain.rs @@ -110,6 +110,169 @@ pub enum GameDistributionAction { SuspendGame, } +/// 作品级共创(Fork)授权档位:开放度只允许单向提升,不允许回退。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ForkAuthorization { + Forbidden, + NonCommercial, + Full, +} + +pub const FORK_AUTHORIZATION_FORBIDDEN: &str = "forbidden"; +pub const FORK_AUTHORIZATION_NON_COMMERCIAL: &str = "nonCommercial"; +pub const FORK_AUTHORIZATION_FULL: &str = "full"; + +impl ForkAuthorization { + pub fn parse(value: &str) -> Option { + match value.trim() { + FORK_AUTHORIZATION_FORBIDDEN => Some(Self::Forbidden), + FORK_AUTHORIZATION_NON_COMMERCIAL => Some(Self::NonCommercial), + FORK_AUTHORIZATION_FULL => Some(Self::Full), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Forbidden => FORK_AUTHORIZATION_FORBIDDEN, + Self::NonCommercial => FORK_AUTHORIZATION_NON_COMMERCIAL, + Self::Full => FORK_AUTHORIZATION_FULL, + } + } + + /// 开放度序:数值越大越开放。 + pub fn rank(self) -> u8 { + match self { + Self::Forbidden => 0, + Self::NonCommercial => 1, + Self::Full => 2, + } + } + + /// 是否允许他人基于该作品发起改编。 + pub fn allows_fork(self) -> bool { + !matches!(self, Self::Forbidden) + } + + /// 目标档位是否合法:只能提升,同级视为无变化,降级一律拒绝。 + pub fn can_promote_to(self, target: Self) -> bool { + target.rank() > self.rank() + } +} + +/// 改编代际:根作品为 0,直接改编根作品为 1,逐级递增。 +pub fn next_generation(parent_generation: u32) -> u32 { + parent_generation.saturating_add(1) +} + pub(crate) fn normalize_id(value: impl AsRef) -> Option { normalize_required_string(value) } + +#[cfg(test)] +mod fork_authorization_tests { + use super::*; + use crate::GameDistributionError; + + #[test] + fn parse_accepts_every_declared_ladder_value() { + for authorization in [ + ForkAuthorization::Forbidden, + ForkAuthorization::NonCommercial, + ForkAuthorization::Full, + ] { + assert_eq!( + ForkAuthorization::parse(authorization.as_str()), + Some(authorization) + ); + } + } + + #[test] + fn parse_rejects_unknown_and_miscased_values() { + for value in ["", "allowed", "FULL", "non_commercial", "noncommercial"] { + assert_eq!(ForkAuthorization::parse(value), None, "value={value:?}"); + } + } + + /// 持久化行可能带着历史写入的空白;解析侧容忍首尾空白,避免把可识别的档位读成未知。 + #[test] + fn parse_tolerates_surrounding_whitespace() { + assert_eq!( + ForkAuthorization::parse(" nonCommercial "), + Some(ForkAuthorization::NonCommercial) + ); + } + + #[test] + fn promotion_is_monotonic_and_never_downgrades() { + use ForkAuthorization::{Forbidden, Full, NonCommercial}; + assert!(Forbidden.can_promote_to(NonCommercial)); + assert!(Forbidden.can_promote_to(Full)); + assert!(NonCommercial.can_promote_to(Full)); + + assert!(!Full.can_promote_to(Full)); + assert!(!NonCommercial.can_promote_to(NonCommercial)); + assert!(!Full.can_promote_to(NonCommercial)); + assert!(!Full.can_promote_to(Forbidden)); + assert!(!NonCommercial.can_promote_to(Forbidden)); + } + + #[test] + fn only_forbidden_blocks_forks() { + assert!(!ForkAuthorization::Forbidden.allows_fork()); + assert!(ForkAuthorization::NonCommercial.allows_fork()); + assert!(ForkAuthorization::Full.allows_fork()); + } + + #[test] + fn generation_starts_at_one_for_direct_derivatives() { + assert_eq!(next_generation(0), 1); + assert_eq!(next_generation(1), 2); + assert_eq!(next_generation(u32::MAX), u32::MAX); + } + + /// HTTP 层按「错误码前缀」映射状态码,文案格式一旦变化就会静默退化成通用 4xx。 + #[test] + fn fork_errors_are_prefixed_with_their_machine_code() { + let cases = [ + ( + GameDistributionError::ForkAuthorizationUnknown { + value: "x".to_string(), + }, + "FORK_AUTHORIZATION_UNKNOWN", + ), + ( + GameDistributionError::ForkAuthorizationDowngradeNotAllowed { + current: FORK_AUTHORIZATION_FULL.to_string(), + requested: FORK_AUTHORIZATION_FORBIDDEN.to_string(), + }, + "FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED", + ), + (GameDistributionError::ForkNotAuthorized, "FORK_NOT_AUTHORIZED"), + ( + GameDistributionError::ForkSourceNotFound, + "FORK_SOURCE_NOT_FOUND", + ), + ( + GameDistributionError::ForkSourceNotAvailable, + "FORK_SOURCE_NOT_AVAILABLE", + ), + ( + GameDistributionError::ForkSourceVersionMismatch, + "FORK_SOURCE_VERSION_MISMATCH", + ), + ( + GameDistributionError::ForkDeclarationOnExistingGame, + "FORK_DECLARATION_ON_EXISTING_GAME", + ), + ]; + for (error, code) in cases { + assert!( + error.to_string().starts_with(code), + "expected {code} prefix, got {}", + error + ); + } + } +} diff --git a/server-rs/crates/module-game-distribution/src/errors.rs b/server-rs/crates/module-game-distribution/src/errors.rs index e3cb17a65..72d61a534 100644 --- a/server-rs/crates/module-game-distribution/src/errors.rs +++ b/server-rs/crates/module-game-distribution/src/errors.rs @@ -56,6 +56,25 @@ pub enum GameDistributionError { VersionNotPublished, VersionAlreadyPublished, ReviewActionNotAllowed, + /// 共创授权:请求了未知档位。 + ForkAuthorizationUnknown { + value: String, + }, + /// 共创授权:只允许提升开放度。 + ForkAuthorizationDowngradeNotAllowed { + current: String, + requested: String, + }, + /// 共创授权:来源作品未开放共创。 + ForkNotAuthorized, + /// 共创授权:改编来源作品不存在。 + ForkSourceNotFound, + /// 共创授权:改编来源作品当前不可改编。 + ForkSourceNotAvailable, + /// 共创授权:改编来源版本不是该作品的当前公开版本。 + ForkSourceVersionMismatch, + /// 共创授权:已存在的作品不能再声明改编来源。 + ForkDeclarationOnExistingGame, } impl fmt::Display for GameDistributionError { @@ -84,6 +103,30 @@ impl fmt::Display for GameDistributionError { Self::VersionNotPublished => formatter.write_str("游戏版本当前未公开"), Self::VersionAlreadyPublished => formatter.write_str("游戏版本已经公开"), Self::ReviewActionNotAllowed => formatter.write_str("当前版本不允许审核操作"), + // 共创相关错误的文案以错误码开头,供 HTTP 层按码映射状态码,避免子串碰撞。 + Self::ForkAuthorizationUnknown { value } => write!( + formatter, + "FORK_AUTHORIZATION_UNKNOWN: 未知的共创授权档位 {value}" + ), + Self::ForkAuthorizationDowngradeNotAllowed { current, requested } => write!( + formatter, + "FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED: 共创授权只能提升,当前 {current},请求 {requested}" + ), + Self::ForkNotAuthorized => { + formatter.write_str("FORK_NOT_AUTHORIZED: 该作品尚未开放共创授权") + } + Self::ForkSourceNotFound => { + formatter.write_str("FORK_SOURCE_NOT_FOUND: 改编来源作品不存在") + } + Self::ForkSourceNotAvailable => { + formatter.write_str("FORK_SOURCE_NOT_AVAILABLE: 改编来源作品当前不可改编") + } + Self::ForkSourceVersionMismatch => formatter.write_str( + "FORK_SOURCE_VERSION_MISMATCH: 改编来源版本不是该作品的当前公开版本", + ), + Self::ForkDeclarationOnExistingGame => formatter.write_str( + "FORK_DECLARATION_ON_EXISTING_GAME: 已存在的作品不能再声明改编来源", + ), } } } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 2fb820d17..e65b87493 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -20,8 +20,10 @@ pub use application::{ }; pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision}; pub use domain::{ - GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility, - compute_request_digest, generate_game_id, generate_game_version_id, + FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL, + ForkAuthorization, GameDistributionAction, GameSnapshot, GameVersionSnapshot, + GameVersionStatus, GameVisibility, compute_request_digest, generate_game_id, + generate_game_version_id, next_generation, }; pub use errors::{GameDistributionError, GameDistributionFieldError}; pub use events::GameDistributionEvent; diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 8142c5761..c9fd9539e 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -187,6 +187,58 @@ pub struct GameDistributionGameSummary { /// 公开列表/详情保证返回;作者侧及旧响应可省略。 #[serde(default, skip_serializing_if = "Option::is_none")] pub rating_summary: Option, + /// 作品级共创授权档位;缺省按「禁止共创」解释。 + #[serde(default)] + pub fork_authorization: GameDistributionForkAuthorization, + /// 直接改编该作品的已公开作品数。 + #[serde(default)] + pub fork_count: u64, + /// 该作品的改编来源;母版为空。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lineage: Option, +} + +/// 作品级共创(Fork)授权档位:只允许单向提升开放度。 +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum GameDistributionForkAuthorization { + /// 仅可游玩,不可复刻改编。 + #[default] + Forbidden, + /// 可二次开发,禁止盈利。 + NonCommercial, + /// 可改编、可商用、可发布新版本。 + Full, +} + +/// 公开的改编血缘摘要:只含作品级信息,不含对象键与素材键。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionLineage { + /// 代际;直接改编母版为 1。 + pub generation: u32, + pub root_game_id: String, + pub root_title: String, + pub parent_game_id: String, + pub parent_title: String, + #[serde(default)] + pub parent_author_name: Option, +} + +/// 创建游戏时可选携带的改编声明;只在全新作品上生效。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionForkDeclaration { + pub parent_game_id: String, + pub parent_version_id: String, +} + +/// 作者提升作品共创授权:只升不降,`expected` 用于并发校验。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionSetForkAuthorizationRequest { + pub expected_fork_authorization: GameDistributionForkAuthorization, + pub fork_authorization: GameDistributionForkAuthorization, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -217,6 +269,9 @@ pub struct GameDistributionCreateGameRequest { pub device_support: GameDistributionDeviceSupport, pub input_modes: Vec, pub orientation: GameDistributionOrientation, + /// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 + #[serde(default)] + pub fork: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 7a1651ae4..20b3aa492 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -29,7 +29,8 @@ pub use game_distribution::{ GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, - GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput, + GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, }; #[path = "active/external_generation.rs"] diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 07b1a9cf8..9a1bfb69b 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -97,7 +97,8 @@ pub use self::game_distribution::{ GameDistributionAdminGameRecord, GameDistributionAdminUserReviewDetailRecord, GameDistributionAdminUserReviewListRecord, GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord, GameDistributionGameRecord, - GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord, + GameDistributionLineageRecord, GameDistributionOwnerGameRecord, + GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord, GameDistributionReviewGameRecord, GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecord, GameDistributionUserReviewListRecord, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 176783bc4..0e1d4848d 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -180,6 +180,8 @@ pub struct GameDistributionGameRecord { pub updated_at: String, pub cover_object_key: Option, pub screenshots_json: Option, + /// 作品级共创授权档位:`forbidden` / `nonCommercial` / `full`。 + pub fork_authorization: String, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -231,6 +233,14 @@ pub struct GameDistributionAdminGameRecord { pub publication_revision: u64, pub created_at: String, pub updated_at: String, + /// 作品级共创授权档位。 + pub fork_authorization: String, + /// 改编代际;母版为 0。 + pub lineage_generation: u32, + /// 改编来源作品;母版为 None。 + pub forked_from_game_id: Option, + /// 直接改编该作品的已公开作品数。 + pub derived_count: u64, pub versions: Vec, } @@ -240,11 +250,26 @@ pub struct GameDistributionOwnerGameRecord { pub versions: Vec, } +/// 公开的改编血缘摘要;母版为 `None`。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionLineageRecord { + pub generation: u32, + pub root_game_id: String, + pub root_title: String, + pub parent_game_id: String, + pub parent_title: String, + pub parent_author_name: Option, +} + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct GameDistributionPublicGameRecord { pub game: GameDistributionGameRecord, pub current_version: Option, pub rating_summary: GameDistributionRatingSummaryRecord, + /// 直接改编该作品的已公开作品数。 + pub fork_count: u64, + /// 该作品的改编来源;母版为 None。 + pub lineage: Option, } fn map_game( @@ -274,6 +299,7 @@ fn map_game( updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros), cover_object_key: value.cover_object_key, screenshots_json: value.screenshots_json, + fork_authorization: value.fork_authorization, } } @@ -337,10 +363,42 @@ fn map_admin_game( publication_revision: value.publication_revision, created_at: shared_kernel::format_timestamp_micros(value.created_at_micros), updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros), + fork_authorization: value.fork_authorization, + lineage_generation: value.lineage_generation, + forked_from_game_id: value.forked_from_game_id, + derived_count: value.derived_count, versions: value.versions.into_iter().map(map_admin_version).collect(), } } +fn map_lineage( + value: Option, +) -> Option { + value.map(|value| GameDistributionLineageRecord { + generation: value.generation, + root_game_id: value.root_game_id, + root_title: value.root_title, + parent_game_id: value.parent_game_id, + parent_title: value.parent_title, + parent_author_name: value.parent_author_name, + }) +} + +fn map_public_game( + value: crate::module_bindings::GameDistributionPublicGameSnapshot, +) -> GameDistributionPublicGameRecord { + GameDistributionPublicGameRecord { + game: map_game(value.game), + current_version: value.current_version.map(map_version), + rating_summary: GameDistributionRatingSummaryRecord { + average_score: value.average_score, + rating_count: value.rating_count, + }, + fork_count: value.fork_count, + lineage: map_lineage(value.lineage), + } +} + pub(crate) fn map_game_distribution_admin_game_list_result( result: crate::module_bindings::GameDistributionAdminGameListResult, ) -> Result, SpacetimeClientError> { @@ -395,14 +453,7 @@ pub(crate) fn map_game_distribution_public_game_list_result( Ok(result .games .into_iter() - .map(|value| GameDistributionPublicGameRecord { - game: map_game(value.game), - current_version: value.current_version.map(map_version), - rating_summary: GameDistributionRatingSummaryRecord { - average_score: value.average_score, - rating_count: value.rating_count, - }, - }) + .map(map_public_game) .collect()) } @@ -421,14 +472,7 @@ pub(crate) fn map_game_distribution_public_game_result( if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - Ok(result.game.map(|value| GameDistributionPublicGameRecord { - game: map_game(value.game), - current_version: value.current_version.map(map_version), - rating_summary: GameDistributionRatingSummaryRecord { - average_score: value.average_score, - rating_count: value.rating_count, - }, - })) + Ok(result.game.map(map_public_game)) } pub(crate) fn map_game_distribution_review_list_result( diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 6e45c41a1..2b6a20cd1 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -73,6 +73,22 @@ pub struct GameDistributionCreateGameRecordInput { pub now_micros: i64, /// 同一作者的本地项目标识;重复发布会复用既有 game_id。 pub local_project_id: Option, + /// 改编来源作品;仅全新作品允许携带。 + pub forked_from_game_id: Option, + /// 建立血缘时来源作品的公开版本。 + pub forked_from_version_id: Option, +} + +/// 作者提升作品共创授权档位;只升不降,并带上期望的当前档位做 CAS。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionSetForkAuthorizationRecordInput { + pub game_id: String, + pub owner_user_id: String, + pub fork_authorization: String, + pub expected_fork_authorization: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -584,6 +600,8 @@ impl SpacetimeClient { request_digest: input.request_digest, now_micros: input.now_micros, local_project_id: input.local_project_id, + forked_from_game_id: input.forked_from_game_id, + forked_from_version_id: input.forked_from_version_id, }; self.call_after_connect( "create_game_distribution_game", @@ -606,6 +624,43 @@ impl SpacetimeClient { .await } + pub async fn set_game_distribution_fork_authorization( + &self, + input: GameDistributionSetForkAuthorizationRecordInput, + ) -> Result<(GameDistributionGameRecord, bool), SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionSetForkAuthorizationInput { + game_id: input.game_id, + owner_user_id: input.owner_user_id, + fork_authorization: input.fork_authorization, + expected_fork_authorization: input.expected_fork_authorization, + idempotency_key: input.idempotency_key, + request_digest: input.request_digest, + now_micros: input.now_micros, + }; + self.call_after_connect( + "set_game_distribution_fork_authorization", + move |connection, sender| { + connection + .procedures() + .set_game_distribution_fork_authorization_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_game_result) + .and_then(|(game, _, replayed)| { + game.map(|game| (game, replayed)).ok_or_else(|| { + SpacetimeClientError::missing_snapshot("共创授权更新结果") + }) + }); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_game_distribution_version( &self, input: GameDistributionCreateVersionRecordInput, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 805d286be..7106f0719 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -428,6 +428,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_snapshot_type; +pub mod game_distribution_lineage_table; +pub mod game_distribution_lineage_type; pub mod game_distribution_my_review_input_type; pub mod game_distribution_owner_game_list_input_type; pub mod game_distribution_owner_game_list_procedure_result_type; @@ -448,6 +451,7 @@ pub mod game_distribution_review_moderation_log_type; pub mod game_distribution_review_moderation_operation_snapshot_type; pub mod game_distribution_review_table; pub mod game_distribution_review_type; +pub mod game_distribution_set_fork_authorization_input_type; pub mod game_distribution_submit_review_input_type; pub mod game_distribution_suspend_input_type; pub mod game_distribution_unpublish_input_type; @@ -825,6 +829,7 @@ pub mod save_editor_project_layout_v_2_and_return_procedure; pub mod save_game_distribution_my_review_and_return_procedure; pub mod seed_analytics_date_dimensions_reducer; pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure; +pub mod set_game_distribution_fork_authorization_and_return_procedure; pub mod set_payment_order_provider_code_and_return_procedure; pub mod settle_llm_router_quota_and_return_procedure; pub mod start_ai_task_reducer; @@ -1282,6 +1287,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_snapshot_type::GameDistributionLineageSnapshot; +pub use game_distribution_lineage_table::*; +pub use game_distribution_lineage_type::GameDistributionLineage; pub use game_distribution_my_review_input_type::GameDistributionMyReviewInput; pub use game_distribution_owner_game_list_input_type::GameDistributionOwnerGameListInput; pub use game_distribution_owner_game_list_procedure_result_type::GameDistributionOwnerGameListProcedureResult; @@ -1302,6 +1310,7 @@ pub use game_distribution_review_moderation_log_type::GameDistributionReviewMode pub use game_distribution_review_moderation_operation_snapshot_type::GameDistributionReviewModerationOperationSnapshot; pub use game_distribution_review_table::*; pub use game_distribution_review_type::GameDistributionReview; +pub use game_distribution_set_fork_authorization_input_type::GameDistributionSetForkAuthorizationInput; pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput; pub use game_distribution_suspend_input_type::GameDistributionSuspendInput; pub use game_distribution_unpublish_input_type::GameDistributionUnpublishInput; @@ -1679,6 +1688,7 @@ pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project pub use save_game_distribution_my_review_and_return_procedure::save_game_distribution_my_review_and_return; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return; +pub use set_game_distribution_fork_authorization_and_return_procedure::set_game_distribution_fork_authorization_and_return; pub use set_payment_order_provider_code_and_return_procedure::set_payment_order_provider_code_and_return; pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return; pub use start_ai_task_reducer::start_ai_task; @@ -1868,6 +1878,7 @@ pub struct DbUpdate { feature_gate_config: __sdk::TableUpdate, game_distribution_game: __sdk::TableUpdate, game_distribution_idempotency_receipt: __sdk::TableUpdate, + game_distribution_lineage: __sdk::TableUpdate, game_distribution_review: __sdk::TableUpdate, game_distribution_review_moderation_log: __sdk::TableUpdate, @@ -2090,6 +2101,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { )?, ) } + "game_distribution_lineage" => db_update.game_distribution_lineage.append( + game_distribution_lineage_table::parse_table_update(table_update)?, + ), "game_distribution_review" => db_update.game_distribution_review.append( game_distribution_review_table::parse_table_update(table_update)?, ), @@ -2505,6 +2519,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.game_distribution_idempotency_receipt, ) .with_updates_by_pk(|row| &row.receipt_id); + diff.game_distribution_lineage = cache + .apply_diff_to_table::( + "game_distribution_lineage", + &self.game_distribution_lineage, + ) + .with_updates_by_pk(|row| &row.game_id); diff.game_distribution_review = cache .apply_diff_to_table::( "game_distribution_review", @@ -2893,6 +2913,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_distribution_idempotency_receipt" => db_update .game_distribution_idempotency_receipt .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_distribution_lineage" => db_update + .game_distribution_lineage + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_distribution_review" => db_update .game_distribution_review .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3179,6 +3202,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_distribution_idempotency_receipt" => db_update .game_distribution_idempotency_receipt .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_distribution_lineage" => db_update + .game_distribution_lineage + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_distribution_review" => db_update .game_distribution_review .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3380,6 +3406,7 @@ pub struct AppliedDiff<'r> { game_distribution_game: __sdk::TableAppliedDiff<'r, GameDistributionGame>, game_distribution_idempotency_receipt: __sdk::TableAppliedDiff<'r, GameDistributionIdempotencyReceipt>, + game_distribution_lineage: __sdk::TableAppliedDiff<'r, GameDistributionLineage>, game_distribution_review: __sdk::TableAppliedDiff<'r, GameDistributionReview>, game_distribution_review_moderation_log: __sdk::TableAppliedDiff<'r, GameDistributionReviewModerationLog>, @@ -3662,6 +3689,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_distribution_idempotency_receipt, event, ); + callbacks.invoke_table_row_callbacks::( + "game_distribution_lineage", + &self.game_distribution_lineage, + event, + ); callbacks.invoke_table_row_callbacks::( "game_distribution_review", &self.game_distribution_review, @@ -4593,6 +4625,7 @@ impl __sdk::SpacetimeModule for RemoteModule { feature_gate_config_table::register_table(client_cache); game_distribution_game_table::register_table(client_cache); game_distribution_idempotency_receipt_table::register_table(client_cache); + game_distribution_lineage_table::register_table(client_cache); game_distribution_review_table::register_table(client_cache); game_distribution_review_moderation_log_table::register_table(client_cache); game_distribution_version_table::register_table(client_cache); @@ -4686,6 +4719,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "feature_gate_config", "game_distribution_game", "game_distribution_idempotency_receipt", + "game_distribution_lineage", "game_distribution_review", "game_distribution_review_moderation_log", "game_distribution_version", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs index 8d79a32a3..edf08f24d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs @@ -21,6 +21,10 @@ pub struct GameDistributionAdminGameSnapshot { pub publication_revision: u64, pub created_at_micros: i64, pub updated_at_micros: i64, + pub fork_authorization: String, + pub lineage_generation: u32, + pub forked_from_game_id: Option, + pub derived_count: u64, pub versions: Vec, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs index c9279cef3..332e27688 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs @@ -26,6 +26,8 @@ pub struct GameDistributionCreateGameInput { pub request_digest: String, pub now_micros: i64, pub local_project_id: Option, + pub forked_from_game_id: Option, + pub forked_from_version_id: Option, } impl __sdk::InModule for GameDistributionCreateGameInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs index 555f390bf..8f0f72dfd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs @@ -30,6 +30,7 @@ pub struct GameDistributionGameSnapshot { pub updated_at_micros: i64, pub cover_object_key: Option, pub screenshots_json: Option, + pub fork_authorization: String, } impl __sdk::InModule for GameDistributionGameSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs index 7236ad64b..de395e549 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs @@ -31,6 +31,7 @@ pub struct GameDistributionGame { pub local_project_id: Option, pub cover_object_key: Option, pub screenshots_json: Option, + pub fork_authorization: String, } impl __sdk::InModule for GameDistributionGame { @@ -65,6 +66,7 @@ pub struct GameDistributionGameCols { pub local_project_id: __sdk::__query_builder::Col>, pub cover_object_key: __sdk::__query_builder::Col>, pub screenshots_json: __sdk::__query_builder::Col>, + pub fork_authorization: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for GameDistributionGame { @@ -107,6 +109,7 @@ impl __sdk::__query_builder::HasCols for GameDistributionGame { local_project_id: __sdk::__query_builder::Col::new(table_name, "local_project_id"), cover_object_key: __sdk::__query_builder::Col::new(table_name, "cover_object_key"), screenshots_json: __sdk::__query_builder::Col::new(table_name, "screenshots_json"), + fork_authorization: __sdk::__query_builder::Col::new(table_name, "fork_authorization"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_snapshot_type.rs new file mode 100644 index 000000000..b0be0ba15 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_snapshot_type.rs @@ -0,0 +1,20 @@ +// 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 GameDistributionLineageSnapshot { + pub generation: u32, + pub root_game_id: String, + pub root_title: String, + pub parent_game_id: String, + pub parent_title: String, + pub parent_author_name: Option, +} + +impl __sdk::InModule for GameDistributionLineageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_table.rs new file mode 100644 index 000000000..1a8dc339c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_table.rs @@ -0,0 +1,231 @@ +// 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 super::game_distribution_lineage_type::GameDistributionLineage; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_distribution_lineage`. +/// +/// Obtain a handle from the [`GameDistributionLineageTableAccess::game_distribution_lineage`] method on [`super::RemoteTables`], +/// like `ctx.db.game_distribution_lineage()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_lineage().on_insert(...)`. +pub struct GameDistributionLineageTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_distribution_lineage`. +pub struct GameDistributionLineageTableAccessor; + +impl __sdk::TableAccessor for GameDistributionLineageTableAccessor { + type Row = GameDistributionLineage; + type Handle<'db> = GameDistributionLineageTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_distribution_lineage() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_distribution_lineage`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameDistributionLineageTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameDistributionLineageTableHandle`], which mediates access to the table `game_distribution_lineage`. + fn game_distribution_lineage(&self) -> GameDistributionLineageTableHandle<'_>; +} + +impl GameDistributionLineageTableAccess for super::RemoteTables { + fn game_distribution_lineage(&self) -> GameDistributionLineageTableHandle<'_> { + GameDistributionLineageTableHandle { + imp: self + .imp + .get_table::("game_distribution_lineage"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameDistributionLineageInsertCallbackId(__sdk::CallbackId); +pub struct GameDistributionLineageDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameDistributionLineageTableHandle<'ctx> { + type Row = GameDistributionLineage; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameDistributionLineageTableHandle<'ctx> { + type Row = GameDistributionLineage; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameDistributionLineageInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionLineageInsertCallbackId { + GameDistributionLineageInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionLineageInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameDistributionLineageDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionLineageDeleteCallbackId { + GameDistributionLineageDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionLineageDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameDistributionLineageTableHandle<'ctx> { + type InsertCallbackId = GameDistributionLineageInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionLineageInsertCallbackId { + GameDistributionLineageInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionLineageInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameDistributionLineageTableHandle<'ctx> { + type DeleteCallbackId = GameDistributionLineageDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionLineageDeleteCallbackId { + GameDistributionLineageDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionLineageDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameDistributionLineageUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameDistributionLineageTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionLineageUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionLineageUpdateCallbackId { + GameDistributionLineageUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionLineageUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameDistributionLineageTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionLineageUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionLineageUpdateCallbackId { + GameDistributionLineageUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionLineageUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `game_id` unique index on the table `game_distribution_lineage`, +/// which allows point queries on the field of the same name +/// via the [`GameDistributionLineageGameIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_lineage().game_id().find(...)`. +pub struct GameDistributionLineageGameIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameDistributionLineageTableHandle<'ctx> { + /// Get a handle on the `game_id` unique index on the table `game_distribution_lineage`. + pub fn game_id(&self) -> GameDistributionLineageGameIdUnique<'ctx> { + GameDistributionLineageGameIdUnique { + imp: self.imp.get_unique_constraint::("game_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameDistributionLineageGameIdUnique<'ctx> { + /// Find the subscribed row whose `game_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("game_distribution_lineage"); + _table.add_unique_constraint::("game_id", |row| &row.game_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `GameDistributionLineage`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_distribution_lineageQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `GameDistributionLineage`. + fn game_distribution_lineage(&self) -> __sdk::__query_builder::Table; +} + +impl game_distribution_lineageQueryTableAccess for __sdk::QueryTableAccessor { + fn game_distribution_lineage(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_distribution_lineage") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_type.rs new file mode 100644 index 000000000..b41ae6b6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_type.rs @@ -0,0 +1,73 @@ +// 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 GameDistributionLineage { + pub game_id: String, + pub owner_user_id: String, + pub parent_game_id: String, + pub parent_version_id: String, + pub root_game_id: String, + pub generation: u32, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for GameDistributionLineage { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `GameDistributionLineage`. +/// +/// Provides typed access to columns for query building. +pub struct GameDistributionLineageCols { + pub game_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub parent_game_id: __sdk::__query_builder::Col, + pub parent_version_id: __sdk::__query_builder::Col, + pub root_game_id: __sdk::__query_builder::Col, + pub generation: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for GameDistributionLineage { + type Cols = GameDistributionLineageCols; + fn cols(table_name: &'static str) -> Self::Cols { + GameDistributionLineageCols { + game_id: __sdk::__query_builder::Col::new(table_name, "game_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + parent_game_id: __sdk::__query_builder::Col::new(table_name, "parent_game_id"), + parent_version_id: __sdk::__query_builder::Col::new(table_name, "parent_version_id"), + root_game_id: __sdk::__query_builder::Col::new(table_name, "root_game_id"), + generation: __sdk::__query_builder::Col::new(table_name, "generation"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `GameDistributionLineage`. +/// +/// Provides typed access to indexed columns for query building. +pub struct GameDistributionLineageIxCols { + pub game_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub parent_game_id: __sdk::__query_builder::IxCol, + pub root_game_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for GameDistributionLineage { + type IxCols = GameDistributionLineageIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + GameDistributionLineageIxCols { + game_id: __sdk::__query_builder::IxCol::new(table_name, "game_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + parent_game_id: __sdk::__query_builder::IxCol::new(table_name, "parent_game_id"), + root_game_id: __sdk::__query_builder::IxCol::new(table_name, "root_game_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for GameDistributionLineage {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs index b9cd4b0aa..aafc6cf36 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::game_distribution_game_snapshot_type::GameDistributionGameSnapshot; +use super::game_distribution_lineage_snapshot_type::GameDistributionLineageSnapshot; use super::game_distribution_version_snapshot_type::GameDistributionVersionSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,8 @@ pub struct GameDistributionPublicGameSnapshot { pub current_version: Option, pub average_score: Option, pub rating_count: u64, + pub fork_count: u64, + pub lineage: Option, } impl __sdk::InModule for GameDistributionPublicGameSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_set_fork_authorization_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_set_fork_authorization_input_type.rs new file mode 100644 index 000000000..3776a1550 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_set_fork_authorization_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionSetForkAuthorizationInput { + pub game_id: String, + pub owner_user_id: String, + pub fork_authorization: String, + pub expected_fork_authorization: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +impl __sdk::InModule for GameDistributionSetForkAuthorizationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_fork_authorization_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_fork_authorization_and_return_procedure.rs new file mode 100644 index 000000000..4bfbec693 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/set_game_distribution_fork_authorization_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_procedure_result_type::GameDistributionProcedureResult; +use super::game_distribution_set_fork_authorization_input_type::GameDistributionSetForkAuthorizationInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SetGameDistributionForkAuthorizationAndReturnArgs { + pub input: GameDistributionSetForkAuthorizationInput, +} + +impl __sdk::InModule for SetGameDistributionForkAuthorizationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `set_game_distribution_fork_authorization_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait set_game_distribution_fork_authorization_and_return { + fn set_game_distribution_fork_authorization_and_return( + &self, + input: GameDistributionSetForkAuthorizationInput, + ) { + self.set_game_distribution_fork_authorization_and_return_then(input, |_, _| {}); + } + + fn set_game_distribution_fork_authorization_and_return_then( + &self, + input: GameDistributionSetForkAuthorizationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl set_game_distribution_fork_authorization_and_return for super::RemoteProcedures { + fn set_game_distribution_fork_authorization_and_return_then( + &self, + input: GameDistributionSetForkAuthorizationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionProcedureResult>( + "set_game_distribution_fork_authorization_and_return", + SetGameDistributionForkAuthorizationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 1d4a59cde..b1f3959f0 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -761,6 +761,45 @@ pub struct GameDistributionGame { /// 审核通过后生效的截图对象键 JSON 数组(与 `cover_asset_id` 同源,来自版本冻结资料)。 #[default(None::)] pub(crate) screenshots_json: Option, + /// 作品级共创(Fork)授权档位:`forbidden` / `nonCommercial` / `full`。 + /// 只允许单向提升开放度;旧行按默认「禁止共创」解释。 + #[default("forbidden")] + pub(crate) fork_authorization: String, +} + +/// 作品之间的改编(Fork)血缘:一个作品最多有一个父,主键即子作品 ID。 +/// +/// 血缘在创建子作品时一次性写入,之后不可变更;父作品下架或封禁都不影响既有行。 +#[spacetimedb::table( + accessor = game_distribution_lineage, + index( + accessor = by_game_distribution_lineage_parent_game_id, + btree(columns = [parent_game_id]) + ), + index( + accessor = by_game_distribution_lineage_root_game_id, + btree(columns = [root_game_id]) + ), + index( + accessor = by_game_distribution_lineage_owner_user_id, + btree(columns = [owner_user_id]) + ) +)] +#[derive(Clone)] +pub struct GameDistributionLineage { + /// 子作品;主键承载「一个作品只能有一个父」这条不变量。 + #[primary_key] + pub(crate) game_id: String, + /// 子作品作者,冗余用于「我改编过的作品」查询,不参与授权判定。 + pub(crate) owner_user_id: String, + pub(crate) parent_game_id: String, + /// 建立血缘时父作品的当前公开版本,不可变溯源事实。 + pub(crate) parent_version_id: String, + /// 0 代母版;根作品自身不写行。 + pub(crate) root_game_id: String, + /// 代际;直接改编根作品为 1。 + pub(crate) generation: u32, + pub(crate) created_at: Timestamp, } /// 游戏发行版本。包摘要、确认字节数和送审资料在创建后作为不可变快照保留;状态和 @@ -886,6 +925,7 @@ const GAME_DISTRIBUTION_ACTION_REJECT: &str = "reject"; const GAME_DISTRIBUTION_ACTION_UNPUBLISH: &str = "unpublish"; const GAME_DISTRIBUTION_ACTION_SUSPEND: &str = "suspend"; const GAME_DISTRIBUTION_ACTION_RESTORE: &str = "restore"; +const GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION: &str = "set_fork_authorization"; /// 后台游戏管理页每个游戏最多回传多少个版本历史;版本数仍按全量统计。 const GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT: usize = 20; /// 后台游戏管理页一次最多回传多少个游戏;全量视图按上限截断,不做游标分页。 @@ -916,6 +956,22 @@ pub struct GameDistributionCreateGameInput { pub now_micros: i64, /// 同一作者的本地项目标识;重复发布会复用既有 game_id 而不是新建游戏。 pub local_project_id: Option, + /// 改编来源作品;仅新建游戏时允许携带,复用既有身份时报错。 + pub forked_from_game_id: Option, + /// 建立血缘时来源作品的公开版本,必须等于来源作品当前公开版本。 + pub forked_from_version_id: Option, +} + +/// 作者提升作品共创授权:只允许单向提升,并要求调用方给出期望的当前档位。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionSetForkAuthorizationInput { + pub game_id: String, + pub owner_user_id: String, + pub fork_authorization: String, + pub expected_fork_authorization: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1098,6 +1154,8 @@ pub struct GameDistributionGameSnapshot { pub updated_at_micros: i64, pub cover_object_key: Option, pub screenshots_json: Option, + /// 作品级共创授权档位;旧行由表默认值补齐为 `forbidden`。 + pub fork_authorization: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1151,6 +1209,14 @@ pub struct GameDistributionAdminGameSnapshot { pub publication_revision: u64, pub created_at_micros: i64, pub updated_at_micros: i64, + /// 作品级共创授权档位。 + pub fork_authorization: String, + /// 改编代际;母版为 0。 + pub lineage_generation: u32, + /// 改编来源作品;母版为 None。 + pub forked_from_game_id: Option, + /// 直接改编该作品的已公开作品数。 + pub derived_count: u64, pub versions: Vec, } @@ -1161,12 +1227,28 @@ pub struct GameDistributionAdminGameListResult { pub error_message: Option, } +/// 公开的改编血缘摘要:只暴露作品级信息与作者名,不含对象键、素材键或私有字段。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionLineageSnapshot { + /// 代际;直接改编母版为 1。 + pub generation: u32, + pub root_game_id: String, + pub root_title: String, + pub parent_game_id: String, + pub parent_title: String, + pub parent_author_name: Option, +} + #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionPublicGameSnapshot { pub game: GameDistributionGameSnapshot, pub current_version: Option, pub average_score: Option, pub rating_count: u64, + /// 直接改编该作品的已公开作品数。 + pub fork_count: u64, + /// 该作品的改编来源;母版为 None。 + pub lineage: Option, } /// 作者自有游戏的聚合快照:游戏身份 + 最近若干版本,供作者管理页展示状态与驳回理由。 @@ -1301,6 +1383,22 @@ pub fn create_game_distribution_version_and_return( } } +/// 提升作品的共创(Fork)授权档位。幂等、只升不降;降级与未知档位一律拒绝且不写库。 +#[spacetimedb::procedure] +pub fn set_game_distribution_fork_authorization_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionSetForkAuthorizationInput, +) -> GameDistributionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + set_game_distribution_fork_authorization_tx(tx, input.clone()) + }) { + Ok((game, replayed)) => game_distribution_game_result(game, replayed), + Err(error) => game_distribution_result_error(error), + } +} + /// 确认对象存储中的包摘要和校验清单。procedure 不读取、不执行上传内容。 #[spacetimedb::procedure] pub fn confirm_game_distribution_package_and_return( @@ -1652,6 +1750,67 @@ pub fn get_owner_game_distribution_version_and_return( } } +/// 校验改编(Fork)声明并返回(根作品 ID、子作品代际)。 +/// +/// 失败关闭:来源必须存在且已公开、授权档位非「禁止共创」、来源版本必须恰好是 +/// 该作品当前公开版本(禁止指向历史版本或伪造)。 +fn resolve_game_distribution_fork_declaration_tx( + ctx: &ReducerContext, + forked_from_game_id: &str, + forked_from_version_id: &str, +) -> Result<(String, u32), String> { + let source = ctx + .db + .game_distribution_game() + .game_id() + .find(&forked_from_game_id.to_string()) + .ok_or_else(|| { + module_game_distribution::GameDistributionError::ForkSourceNotFound.to_string() + })?; + if source.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED { + return Err( + module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string(), + ); + } + let authorization = + module_game_distribution::ForkAuthorization::parse(source.fork_authorization.as_str()) + .unwrap_or(module_game_distribution::ForkAuthorization::Forbidden); + if !authorization.allows_fork() { + return Err(module_game_distribution::GameDistributionError::ForkNotAuthorized.to_string()); + } + let current_version_id = source + .active_version_id + .as_ref() + .filter(|version_id| { + ctx.db + .game_distribution_version() + .version_id() + .find(*version_id) + .map(|version| version.status == GAME_DISTRIBUTION_VERSION_PUBLISHED) + .unwrap_or(false) + }) + .ok_or_else(|| { + module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string() + })?; + if current_version_id.as_str() != forked_from_version_id { + return Err(module_game_distribution::GameDistributionError::ForkSourceVersionMismatch + .to_string()); + } + let (root_game_id, parent_generation) = match ctx + .db + .game_distribution_lineage() + .game_id() + .find(&forked_from_game_id.to_string()) + { + Some(lineage) => (lineage.root_game_id, lineage.generation), + None => (source.game_id.clone(), 0), + }; + Ok(( + root_game_id, + module_game_distribution::next_generation(parent_generation), + )) +} + fn create_game_distribution_game_tx( ctx: &ReducerContext, input: GameDistributionCreateGameInput, @@ -1676,6 +1835,22 @@ fn create_game_distribution_game_tx( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + let forked_from_game_id = + normalize_game_distribution_optional(input.forked_from_game_id.unwrap_or_default()); + let forked_from_version_id = + normalize_game_distribution_optional(input.forked_from_version_id.unwrap_or_default()); + if forked_from_game_id.is_some() != forked_from_version_id.is_some() { + return Err("改编声明必须同时给出来源作品与来源版本".to_string()); + } + let fork_resolution = match ( + forked_from_game_id.as_deref(), + forked_from_version_id.as_deref(), + ) { + (Some(parent_game_id), Some(parent_version_id)) => Some( + resolve_game_distribution_fork_declaration_tx(ctx, parent_game_id, parent_version_id)?, + ), + _ => None, + }; if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; @@ -1703,6 +1878,11 @@ fn create_game_distribution_game_tx( .filter(&owner_user_id) .find(|game| game.local_project_id.as_deref() == Some(local_project_id)); if let Some(game) = existing { + // 复用既有作品身份时不允许再声明改编来源:否则会把别人的原创作改判成衍生作品。 + if fork_resolution.is_some() { + return Err(module_game_distribution::GameDistributionError::ForkDeclarationOnExistingGame + .to_string()); + } insert_game_distribution_receipt( ctx, GameDistributionReceiptInput { @@ -1762,7 +1942,26 @@ fn create_game_distribution_game_tx( local_project_id, cover_object_key: None, screenshots_json: None, + fork_authorization: module_game_distribution::FORK_AUTHORIZATION_FORBIDDEN.to_string(), }); + // 血缘与游戏行同事务写入:只有全新作品才会走到这里,复用身份已在上面被拒绝。 + if let (Some(parent_game_id), Some(parent_version_id), Some((root_game_id, generation))) = ( + forked_from_game_id.as_deref(), + forked_from_version_id.as_deref(), + fork_resolution, + ) { + ctx.db + .game_distribution_lineage() + .insert(GameDistributionLineage { + game_id: game_id.clone(), + owner_user_id: owner_user_id.clone(), + parent_game_id: parent_game_id.to_string(), + parent_version_id: parent_version_id.to_string(), + root_game_id, + generation, + created_at: now, + }); + } insert_game_distribution_receipt( ctx, GameDistributionReceiptInput { @@ -2584,6 +2783,93 @@ fn unpublish_game_distribution_game_tx( Ok((game_distribution_game_snapshot(&game), false)) } +/// 提升作品的共创授权档位,要求调用方给出期望的当前档位。 +/// +/// 三重失败关闭:期望值不匹配(调用方基于旧状态)→ 冲突;目标档位不高于当前 → 拒绝降级; +/// 档位字符串不在白名单 → 拒绝未知档位。三者都不写库。 +fn set_game_distribution_fork_authorization_tx( + ctx: &ReducerContext, + input: GameDistributionSetForkAuthorizationInput, +) -> Result<(GameDistributionGameSnapshot, bool), String> { + let owner_user_id = required_game_distribution_text(input.owner_user_id, "owner_user_id")?; + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let idempotency_key = + required_game_distribution_text(input.idempotency_key, "idempotency_key")?; + let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?; + let receipt_id = game_distribution_receipt_id( + owner_user_id.as_str(), + GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION, + idempotency_key.as_str(), + ); + if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { + ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "幂等收据对应的游戏已不存在".to_string())?; + return Ok((game_distribution_game_snapshot(&game), true)); + } + let target = + module_game_distribution::ForkAuthorization::parse(input.fork_authorization.as_str()) + .ok_or_else(|| { + module_game_distribution::GameDistributionError::ForkAuthorizationUnknown { + value: input.fork_authorization.clone(), + } + .to_string() + })?; + let mut game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "游戏不存在".to_string())?; + if game.owner_user_id != owner_user_id { + return Err("游戏 owner 不匹配".to_string()); + } + let current = + module_game_distribution::ForkAuthorization::parse(game.fork_authorization.as_str()) + .unwrap_or(module_game_distribution::ForkAuthorization::Forbidden); + if current.as_str() != input.expected_fork_authorization.trim() { + return Err("共创授权期望值与当前状态不匹配".to_string()); + } + if !current.can_promote_to(target) { + return Err( + module_game_distribution::GameDistributionError::ForkAuthorizationDowngradeNotAllowed { + current: current.as_str().to_string(), + requested: target.as_str().to_string(), + } + .to_string(), + ); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + game.fork_authorization = target.as_str().to_string(); + game.updated_at = now; + ctx.db + .game_distribution_game() + .game_id() + .update(game.clone()); + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id, + action: GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION.to_string(), + idempotency_key, + request_digest, + game_id: Some(game_id), + version_id: None, + outcome_kind: "game".to_string(), + outcome_game_id: Some(game.game_id.clone()), + outcome_version_id: None, + outcome_json: None, + now_micros: input.now_micros, + }, + )?; + Ok((game_distribution_game_snapshot(&game), false)) +} + fn suspend_game_distribution_game_tx( ctx: &ReducerContext, input: GameDistributionSuspendInput, @@ -2825,6 +3111,15 @@ fn admin_game_distribution_game_snapshot( let author_avatar_url = account .and_then(|user| user.avatar_url) .or_else(|| game.author_avatar_url.clone()); + let lineage = ctx + .db + .game_distribution_lineage() + .game_id() + .find(&game.game_id); + let (lineage_generation, forked_from_game_id) = lineage + .map(|lineage| (lineage.generation, Some(lineage.parent_game_id))) + .unwrap_or((0, None)); + let derived_count = game_distribution_public_fork_count(ctx, game.game_id.as_str()); GameDistributionAdminGameSnapshot { game_id: game.game_id.clone(), owner_user_id: game.owner_user_id.clone(), @@ -2838,6 +3133,10 @@ fn admin_game_distribution_game_snapshot( publication_revision: game.publication_revision, created_at_micros: game.created_at.to_micros_since_unix_epoch(), updated_at_micros: game.updated_at.to_micros_since_unix_epoch(), + fork_authorization: game.fork_authorization.clone(), + lineage_generation, + forked_from_game_id, + derived_count, versions: versions .iter() .map(admin_game_distribution_version_snapshot) @@ -3204,6 +3503,7 @@ fn game_distribution_game_snapshot(game: &GameDistributionGame) -> GameDistribut updated_at_micros: game.updated_at.to_micros_since_unix_epoch(), cover_object_key: game.cover_object_key.clone(), screenshots_json: game.screenshots_json.clone(), + fork_authorization: game.fork_authorization.clone(), } } @@ -3346,16 +3646,88 @@ fn public_game_distribution_version( }) } +/// 直接改编该作品的**已公开**作品数。按血缘索引实时统计,不维护物化计数, +/// 避免与下架/封禁状态产生第二份真相。 +fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u64 { + let derived_game_ids: Vec = ctx + .db + .game_distribution_lineage() + .by_game_distribution_lineage_parent_game_id() + .filter(&game_id.to_string()) + .map(|lineage| lineage.game_id) + .collect(); + derived_game_ids + .into_iter() + .filter(|derived_game_id| { + ctx.db + .game_distribution_game() + .game_id() + .find(derived_game_id) + .map(|game| game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED) + .unwrap_or(false) + }) + .count() as u64 +} + +/// 公开血缘摘要。父作品被下架或封禁时仍然返回(作品行不会被删除), +/// 只有父行真的缺失时才整体降级为 None。 +fn game_distribution_lineage_snapshot( + ctx: &ReducerContext, + game_id: &str, +) -> Option { + let lineage = ctx + .db + .game_distribution_lineage() + .game_id() + .find(&game_id.to_string())?; + let parent = ctx + .db + .game_distribution_game() + .game_id() + .find(&lineage.parent_game_id)?; + let root_title = ctx + .db + .game_distribution_game() + .game_id() + .find(&lineage.root_game_id) + .map(|root| root.title) + .unwrap_or_else(|| parent.title.clone()); + Some(GameDistributionLineageSnapshot { + generation: lineage.generation, + root_game_id: lineage.root_game_id, + root_title, + parent_game_id: lineage.parent_game_id, + parent_title: parent.title, + parent_author_name: parent.author_name, + }) +} + fn public_game_distribution_snapshot( ctx: &ReducerContext, game: &GameDistributionGame, ) -> Option { let version = public_game_distribution_version(ctx, game)?; let (average_score, rating_count) = game_distribution_user_rating_summary(ctx, &game.game_id); + let fork_count = game_distribution_public_fork_count(ctx, game.game_id.as_str()); + let lineage = game_distribution_lineage_snapshot(ctx, game.game_id.as_str()); + let mut game_snapshot = game_distribution_game_snapshot(game); + // 作者名/头像读时联账号表:发布时冻结的资料可能是空的(历史数据),而公开页展示的是 + // 作者身份,账号改名或换头像后应当立即跟随,与后台游戏管理页保持同一口径。 + 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() { + game_snapshot.author_name = Some(display_name); + } + if account.avatar_url.is_some() { + game_snapshot.author_avatar_url = account.avatar_url; + } + } Some(GameDistributionPublicGameSnapshot { - game: game_distribution_game_snapshot(game), + game: game_snapshot, current_version: Some(game_distribution_version_snapshot_for_game(ctx, &version)), average_score, rating_count, + fork_count, + lineage, }) } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f942967bb..a36b95461 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -217,6 +217,8 @@ macro_rules! migration_tables { game_distribution_review_moderation_log, game_distribution_version, game_distribution_idempotency_receipt, + // 作品之间的改编血缘:业务事实,随迁移导出/导入;父作品下架不删除历史行。 + game_distribution_lineage, llm_router_account, llm_router_billing_checkpoint, agc_model_catalog, diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 0820d2b3d..be082fd19 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -10,6 +10,10 @@ import { } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, + resolveGameForkAuthorization, +} from '../../../packages/shared/src/contracts/gameDistribution'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { type GameDistributionGame, @@ -243,6 +247,11 @@ function GameDetailContent({ }) { const reviews = useGameReviews(game.id, canReview); const version = game.currentVersion; + const forkAuthorization = resolveGameForkAuthorization( + game.forkAuthorization, + ); + const lineage = game.lineage ?? null; + const forkCount = game.forkCount ?? 0; const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile; const playbackBlocked = !version || mobilePlaybackBlocked; return ( @@ -305,6 +314,29 @@ function GameDetailContent({