实现游戏共创 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

- 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:
2026-10-04 19:08:42 +08:00
parent 5e789f480b
commit 247625d61d
31 changed files with 1943 additions and 28 deletions
@@ -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;
};
+12 -1
View File
@@ -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",
@@ -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>,
}
@@ -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 {
@@ -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"),
}
}
}
@@ -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;
}
@@ -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")
}
}
@@ -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 {}
@@ -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 {
@@ -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;
}
@@ -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