实现游戏共创 M1:作品级共创授权与改编血缘
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell 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 / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell 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 / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- 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 一致性脚本的共创类型
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<AppState> {
|
||||
"/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<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Extension(auth): Extension<AuthenticatedAccessToken>,
|
||||
headers: HeaderMap,
|
||||
Path(game_id): Path<String>,
|
||||
Json(payload): Json<GameDistributionSetForkAuthorizationRequest>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Self> {
|
||||
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<str>) -> Option<String> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 已存在的作品不能再声明改编来源",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -187,6 +187,58 @@ pub struct GameDistributionGameSummary {
|
||||
/// 公开列表/详情保证返回;作者侧及旧响应可省略。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rating_summary: Option<GameDistributionRatingSummary>,
|
||||
/// 作品级共创授权档位;缺省按「禁止共创」解释。
|
||||
#[serde(default)]
|
||||
pub fork_authorization: GameDistributionForkAuthorization,
|
||||
/// 直接改编该作品的已公开作品数。
|
||||
#[serde(default)]
|
||||
pub fork_count: u64,
|
||||
/// 该作品的改编来源;母版为空。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lineage: Option<GameDistributionLineage>,
|
||||
}
|
||||
|
||||
/// 作品级共创(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<String>,
|
||||
}
|
||||
|
||||
/// 创建游戏时可选携带的改编声明;只在全新作品上生效。
|
||||
#[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<GameDistributionInputMode>,
|
||||
pub orientation: GameDistributionOrientation,
|
||||
/// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。
|
||||
#[serde(default)]
|
||||
pub fork: Option<GameDistributionForkDeclaration>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -180,6 +180,8 @@ pub struct GameDistributionGameRecord {
|
||||
pub updated_at: String,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
/// 作品级共创授权档位:`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<String>,
|
||||
/// 直接改编该作品的已公开作品数。
|
||||
pub derived_count: u64,
|
||||
pub versions: Vec<GameDistributionAdminVersionRecord>,
|
||||
}
|
||||
|
||||
@@ -240,11 +250,26 @@ pub struct GameDistributionOwnerGameRecord {
|
||||
pub versions: Vec<GameDistributionVersionRecord>,
|
||||
}
|
||||
|
||||
/// 公开的改编血缘摘要;母版为 `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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GameDistributionPublicGameRecord {
|
||||
pub game: GameDistributionGameRecord,
|
||||
pub current_version: Option<GameDistributionVersionRecord>,
|
||||
pub rating_summary: GameDistributionRatingSummaryRecord,
|
||||
/// 直接改编该作品的已公开作品数。
|
||||
pub fork_count: u64,
|
||||
/// 该作品的改编来源;母版为 None。
|
||||
pub lineage: Option<GameDistributionLineageRecord>,
|
||||
}
|
||||
|
||||
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<crate::module_bindings::GameDistributionLineageSnapshot>,
|
||||
) -> Option<GameDistributionLineageRecord> {
|
||||
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<Vec<GameDistributionAdminGameRecord>, 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(
|
||||
|
||||
@@ -73,6 +73,22 @@ pub struct GameDistributionCreateGameRecordInput {
|
||||
pub now_micros: i64,
|
||||
/// 同一作者的本地项目标识;重复发布会复用既有 game_id。
|
||||
pub local_project_id: Option<String>,
|
||||
/// 改编来源作品;仅全新作品允许携带。
|
||||
pub forked_from_game_id: Option<String>,
|
||||
/// 建立血缘时来源作品的公开版本。
|
||||
pub forked_from_version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 作者提升作品共创授权档位;只升不降,并带上期望的当前档位做 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,
|
||||
|
||||
@@ -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<FeatureGateConfig>,
|
||||
game_distribution_game: __sdk::TableUpdate<GameDistributionGame>,
|
||||
game_distribution_idempotency_receipt: __sdk::TableUpdate<GameDistributionIdempotencyReceipt>,
|
||||
game_distribution_lineage: __sdk::TableUpdate<GameDistributionLineage>,
|
||||
game_distribution_review: __sdk::TableUpdate<GameDistributionReview>,
|
||||
game_distribution_review_moderation_log:
|
||||
__sdk::TableUpdate<GameDistributionReviewModerationLog>,
|
||||
@@ -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::<GameDistributionLineage>(
|
||||
"game_distribution_lineage",
|
||||
&self.game_distribution_lineage,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.game_id);
|
||||
diff.game_distribution_review = cache
|
||||
.apply_diff_to_table::<GameDistributionReview>(
|
||||
"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::<GameDistributionLineage>(
|
||||
"game_distribution_lineage",
|
||||
&self.game_distribution_lineage,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<GameDistributionReview>(
|
||||
"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",
|
||||
|
||||
+4
@@ -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<String>,
|
||||
pub derived_count: u64,
|
||||
pub versions: Vec<GameDistributionAdminVersionSnapshot>,
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -26,6 +26,8 @@ pub struct GameDistributionCreateGameInput {
|
||||
pub request_digest: String,
|
||||
pub now_micros: i64,
|
||||
pub local_project_id: Option<String>,
|
||||
pub forked_from_game_id: Option<String>,
|
||||
pub forked_from_version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionCreateGameInput {
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ pub struct GameDistributionGameSnapshot {
|
||||
pub updated_at_micros: i64,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
pub fork_authorization: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionGameSnapshot {
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct GameDistributionGame {
|
||||
pub local_project_id: Option<String>,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
pub fork_authorization: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionGame {
|
||||
@@ -65,6 +66,7 @@ pub struct GameDistributionGameCols {
|
||||
pub local_project_id: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
|
||||
pub cover_object_key: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
|
||||
pub screenshots_json: __sdk::__query_builder::Col<GameDistributionGame, Option<String>>,
|
||||
pub fork_authorization: __sdk::__query_builder::Col<GameDistributionGame, String>,
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionLineageSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+231
@@ -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<GameDistributionLineage>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
/// Lifetime-aware accessor marker for the table `game_distribution_lineage`.
|
||||
pub struct GameDistributionLineageTableAccessor;
|
||||
|
||||
impl __sdk::TableAccessor<super::RemoteTables> 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::<GameDistributionLineage>("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<Item = GameDistributionLineage> + '_ {
|
||||
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<Item = GameDistributionLineage> + '_ {
|
||||
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<GameDistributionLineage, String>,
|
||||
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::<String>("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<GameDistributionLineage> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table =
|
||||
client_cache.get_or_make_table::<GameDistributionLineage>("game_distribution_lineage");
|
||||
_table.add_unique_constraint::<String>("game_id", |row| &row.game_id);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<GameDistributionLineage>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<GameDistributionLineage>", "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<GameDistributionLineage>;
|
||||
}
|
||||
|
||||
impl game_distribution_lineageQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn game_distribution_lineage(&self) -> __sdk::__query_builder::Table<GameDistributionLineage> {
|
||||
__sdk::__query_builder::Table::new("game_distribution_lineage")
|
||||
}
|
||||
}
|
||||
+73
@@ -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<GameDistributionLineage, String>,
|
||||
pub owner_user_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
|
||||
pub parent_game_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
|
||||
pub parent_version_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
|
||||
pub root_game_id: __sdk::__query_builder::Col<GameDistributionLineage, String>,
|
||||
pub generation: __sdk::__query_builder::Col<GameDistributionLineage, u32>,
|
||||
pub created_at: __sdk::__query_builder::Col<GameDistributionLineage, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
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<GameDistributionLineage, String>,
|
||||
pub owner_user_id: __sdk::__query_builder::IxCol<GameDistributionLineage, String>,
|
||||
pub parent_game_id: __sdk::__query_builder::IxCol<GameDistributionLineage, String>,
|
||||
pub root_game_id: __sdk::__query_builder::IxCol<GameDistributionLineage, String>,
|
||||
}
|
||||
|
||||
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 {}
|
||||
+3
@@ -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<GameDistributionVersionSnapshot>,
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: u64,
|
||||
pub fork_count: u64,
|
||||
pub lineage: Option<GameDistributionLineageSnapshot>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionPublicGameSnapshot {
|
||||
|
||||
+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 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;
|
||||
}
|
||||
+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_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<GameDistributionProcedureResult, __sdk::InternalError>,
|
||||
) + 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<GameDistributionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, GameDistributionProcedureResult>(
|
||||
"set_game_distribution_fork_authorization_and_return",
|
||||
SetGameDistributionForkAuthorizationAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -761,6 +761,45 @@ pub struct GameDistributionGame {
|
||||
/// 审核通过后生效的截图对象键 JSON 数组(与 `cover_asset_id` 同源,来自版本冻结资料)。
|
||||
#[default(None::<String>)]
|
||||
pub(crate) screenshots_json: Option<String>,
|
||||
/// 作品级共创(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<String>,
|
||||
/// 改编来源作品;仅新建游戏时允许携带,复用既有身份时报错。
|
||||
pub forked_from_game_id: Option<String>,
|
||||
/// 建立血缘时来源作品的公开版本,必须等于来源作品当前公开版本。
|
||||
pub forked_from_version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 作者提升作品共创授权:只允许单向提升,并要求调用方给出期望的当前档位。
|
||||
#[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<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
/// 作品级共创授权档位;旧行由表默认值补齐为 `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<String>,
|
||||
/// 直接改编该作品的已公开作品数。
|
||||
pub derived_count: u64,
|
||||
pub versions: Vec<GameDistributionAdminVersionSnapshot>,
|
||||
}
|
||||
|
||||
@@ -1161,12 +1227,28 @@ pub struct GameDistributionAdminGameListResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 公开的改编血缘摘要:只暴露作品级信息与作者名,不含对象键、素材键或私有字段。
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct GameDistributionPublicGameSnapshot {
|
||||
pub game: GameDistributionGameSnapshot,
|
||||
pub current_version: Option<GameDistributionVersionSnapshot>,
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: u64,
|
||||
/// 直接改编该作品的已公开作品数。
|
||||
pub fork_count: u64,
|
||||
/// 该作品的改编来源;母版为 None。
|
||||
pub lineage: Option<GameDistributionLineageSnapshot>,
|
||||
}
|
||||
|
||||
/// 作者自有游戏的聚合快照:游戏身份 + 最近若干版本,供作者管理页展示状态与驳回理由。
|
||||
@@ -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<String> = 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<GameDistributionLineageSnapshot> {
|
||||
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<GameDistributionPublicGameSnapshot> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
<aside className="game-detail-side">
|
||||
<div className="game-info-card game-fork-card">
|
||||
<strong>共创</strong>
|
||||
<span
|
||||
className={`game-fork-badge game-fork-badge--${forkAuthorization}`}
|
||||
>
|
||||
{GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[forkAuthorization]}
|
||||
</span>
|
||||
{lineage ? (
|
||||
<>
|
||||
<span className="game-fork-lineage">
|
||||
第 {lineage.generation} 代作品 · 改编自《{lineage.parentTitle}》
|
||||
</span>
|
||||
{lineage.parentAuthorName ? (
|
||||
<span className="game-fork-lineage__author">
|
||||
由 {lineage.parentAuthorName} 制作
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{forkCount > 0 ? (
|
||||
<span className="game-fork-derived">已被改编 {forkCount} 次</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="game-info-card">
|
||||
<strong>游玩方式</strong>
|
||||
<span>{resolveControlsLabel(version, game.inputModes)}</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user