use super::*; impl From for CreationEntryTypeAdminUpsertInput { fn from(input: module_runtime::CreationEntryTypeAdminUpsertInput) -> Self { Self { id: input.id, title: input.title, subtitle: input.subtitle, badge: input.badge, image_src: input.image_src, visible: input.visible, open: input.open, sort_order: input.sort_order, category_id: input.category_id, category_label: input.category_label, category_sort_order: input.category_sort_order, unified_creation_spec_json: input.unified_creation_spec_json, } } } /// 将业务层 banner JSON 保存输入转换为 SpacetimeDB 生成绑定类型。 impl From for CreationEntryEventBannersAdminUpsertInput { fn from(input: module_runtime::CreationEntryEventBannersAdminUpsertInput) -> Self { Self { event_banners_json: input.event_banners_json, } } } impl From for AdminWorkVisibilityListInput { fn from(input: module_runtime::AdminWorkVisibilityListInput) -> Self { Self { admin_user_id: input.admin_user_id, } } } impl From for AdminWorkVisibilityUpdateInput { fn from(input: module_runtime::AdminWorkVisibilityUpdateInput) -> Self { Self { admin_user_id: input.admin_user_id, source_type: input.source_type, profile_id: input.profile_id, visible: input.visible, } } } pub(crate) fn build_admin_work_visibility_list_input( admin_user_id: String, ) -> Result { let admin_user_id = admin_user_id.trim().to_string(); if admin_user_id.is_empty() { return Err("adminUserId 不能为空".to_string()); } Ok(module_runtime::AdminWorkVisibilityListInput { admin_user_id }) } pub(crate) fn build_admin_work_visibility_update_input( admin_user_id: String, source_type: String, profile_id: String, visible: bool, ) -> Result { let admin_user_id = admin_user_id.trim().to_string(); if admin_user_id.is_empty() { return Err("adminUserId 不能为空".to_string()); } let source_type = source_type.trim().to_string(); if source_type.is_empty() { return Err("sourceType 不能为空".to_string()); } let profile_id = profile_id.trim().to_string(); if profile_id.is_empty() { return Err("profileId 不能为空".to_string()); } Ok(module_runtime::AdminWorkVisibilityUpdateInput { admin_user_id, source_type, profile_id, visible, }) } impl From for RuntimeSettingGetInput { fn from(input: module_runtime::RuntimeSettingGetInput) -> Self { Self { user_id: input.user_id, } } } impl From for RuntimeSettingUpsertInput { fn from(input: module_runtime::RuntimeSettingUpsertInput) -> Self { Self { user_id: input.user_id, music_volume: input.music_volume, platform_theme: map_runtime_platform_theme(input.platform_theme), updated_at_micros: input.updated_at_micros, } } } impl From for RuntimeBrowseHistoryListInput { fn from(input: module_runtime::RuntimeBrowseHistoryListInput) -> Self { Self { user_id: input.user_id, } } } impl From for RuntimeBrowseHistoryClearInput { fn from(input: module_runtime::RuntimeBrowseHistoryClearInput) -> Self { Self { user_id: input.user_id, } } } impl From for RuntimeBrowseHistorySyncInput { fn from(input: module_runtime::RuntimeBrowseHistorySyncInput) -> Self { Self { user_id: input.user_id, entries: input.entries.into_iter().map(Into::into).collect(), updated_at_micros: input.updated_at_micros, } } } impl From for RuntimeBrowseHistoryWriteInput { fn from(input: module_runtime::RuntimeBrowseHistoryWriteInput) -> Self { Self { owner_user_id: input.owner_user_id, profile_id: input.profile_id, world_name: input.world_name, subtitle: input.subtitle, summary_text: input.summary_text, cover_image_src: input.cover_image_src, theme_mode: input.theme_mode, author_display_name: input.author_display_name, visited_at: input.visited_at, } } } impl From for RuntimeSnapshotGetInput { fn from(input: module_runtime::RuntimeSnapshotGetInput) -> Self { Self { user_id: input.user_id, } } } impl From for RuntimeSnapshotDeleteInput { fn from(input: module_runtime::RuntimeSnapshotDeleteInput) -> Self { Self { user_id: input.user_id, } } } impl From for RuntimeTrackingEventInput { fn from(input: module_runtime::RuntimeTrackingEventInput) -> Self { Self { event_id: input.event_id, event_key: input.event_key, scope_kind: map_runtime_tracking_scope_kind(input.scope_kind), scope_id: input.scope_id, user_id: input.user_id, owner_user_id: input.owner_user_id, profile_id: input.profile_id, module_key: input.module_key, metadata_json: input.metadata_json, occurred_at_micros: input.occurred_at_micros, } } } pub type CreationEntryConfigRecord = shared_contracts::creation_entry_config::CreationEntryConfigResponse; pub type AdminWorkVisibilityRecord = shared_contracts::admin::AdminWorkVisibilityEntryPayload; pub(crate) fn map_creation_entry_config_procedure_result( result: CreationEntryConfigProcedureResult, ) -> Result { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } let snapshot = result .record .ok_or_else(|| SpacetimeClientError::missing_snapshot("创作入口配置快照"))?; Ok(module_runtime::build_creation_entry_config_response( map_creation_entry_config_snapshot(snapshot), )) } pub(crate) fn map_admin_work_visibility_list_procedure_result( result: AdminWorkVisibilityListProcedureResult, ) -> Result, SpacetimeClientError> { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } Ok(result .entries .into_iter() .map(map_admin_work_visibility_snapshot) .collect()) } pub(crate) fn map_admin_work_visibility_procedure_result( result: AdminWorkVisibilityProcedureResult, ) -> Result { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } result .record .map(map_admin_work_visibility_snapshot) .ok_or_else(|| SpacetimeClientError::missing_snapshot("后台作品可见性快照")) } fn map_admin_work_visibility_snapshot( snapshot: AdminWorkVisibilitySnapshot, ) -> AdminWorkVisibilityRecord { AdminWorkVisibilityRecord { source_type: snapshot.source_type, work_id: snapshot.work_id, profile_id: snapshot.profile_id, source_session_id: snapshot.source_session_id, public_work_code: snapshot.public_work_code, owner_user_id: snapshot.owner_user_id, author_display_name: snapshot.author_display_name, title: snapshot.title, subtitle: snapshot.subtitle, cover_image_src: snapshot.cover_image_src, visible: snapshot.visible, published_at_micros: snapshot.published_at_micros, updated_at_micros: snapshot.updated_at_micros, } } /// 从本地订阅表行组装创作入口配置响应,兼容旧单条 banner 字段。 pub(crate) fn build_creation_entry_config_record_from_rows( header: CreationEntryConfig, mut creation_types: Vec, ) -> CreationEntryConfigRecord { creation_types.sort_by(|left, right| { left.sort_order .cmp(&right.sort_order) .then_with(|| left.id.cmp(&right.id)) }); module_runtime::build_creation_entry_config_response( module_runtime::CreationEntryConfigSnapshot { config_id: header.config_id, start_card: module_runtime::CreationEntryStartCardSnapshot { title: header.start_title, description: header.start_description, idle_badge: header.start_idle_badge, busy_badge: header.start_busy_badge, }, type_modal: module_runtime::CreationEntryTypeModalSnapshot { title: header.modal_title, description: header.modal_description, }, event_banner: module_runtime::CreationEntryEventBannerSnapshot { title: creation_entry_text_or_default( header.event_title, module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE, ), description: creation_entry_text_or_default( header.event_description, module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION, ), cover_image_src: creation_entry_text_or_default( header.event_cover_image_src, module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC, ), prize_pool_mud_points: header.event_prize_pool_mud_points, starts_at_text: creation_entry_text_or_default( header.event_starts_at_text, module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT, ), ends_at_text: creation_entry_text_or_default( header.event_ends_at_text, module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT, ), render_mode: "structured".to_string(), html_code: None, }, event_banners_json: header.event_banners_json, creation_types: creation_types .into_iter() .map(|item| { normalize_creation_entry_type_snapshot( module_runtime::CreationEntryTypeSnapshot { id: item.id, title: item.title, subtitle: item.subtitle, badge: item.badge, image_src: item.image_src, visible: item.visible, open: item.open, sort_order: item.sort_order, category_id: creation_entry_text_or_default( item.category_id, module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID, ), category_label: creation_entry_text_or_default( item.category_label, module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL, ), category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), unified_creation_spec_json: item.unified_creation_spec_json, }, ) }) .collect(), updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), }, ) } /// 将 SpacetimeDB procedure 快照映射为 module-runtime 领域快照。 fn map_creation_entry_config_snapshot( snapshot: CreationEntryConfigSnapshot, ) -> module_runtime::CreationEntryConfigSnapshot { module_runtime::CreationEntryConfigSnapshot { config_id: snapshot.config_id, start_card: module_runtime::CreationEntryStartCardSnapshot { title: snapshot.start_card.title, description: snapshot.start_card.description, idle_badge: snapshot.start_card.idle_badge, busy_badge: snapshot.start_card.busy_badge, }, type_modal: module_runtime::CreationEntryTypeModalSnapshot { title: snapshot.type_modal.title, description: snapshot.type_modal.description, }, event_banner: module_runtime::CreationEntryEventBannerSnapshot { title: snapshot.event_banner.title, description: snapshot.event_banner.description, cover_image_src: snapshot.event_banner.cover_image_src, prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points, starts_at_text: snapshot.event_banner.starts_at_text, ends_at_text: snapshot.event_banner.ends_at_text, render_mode: snapshot.event_banner.render_mode, html_code: snapshot.event_banner.html_code, }, event_banners_json: snapshot.event_banners_json, creation_types: snapshot .creation_types .into_iter() .map(|item| { normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot { id: item.id, title: item.title, subtitle: item.subtitle, badge: item.badge, image_src: item.image_src, visible: item.visible, open: item.open, sort_order: item.sort_order, category_id: item.category_id, category_label: item.category_label, category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at_micros, unified_creation_spec_json: item.unified_creation_spec_json, }) }) .collect(), updated_at_micros: snapshot.updated_at_micros, } } fn creation_entry_text_or_default(value: Option, default_value: &str) -> String { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| default_value.to_string()) } fn normalize_creation_entry_type_snapshot( item: module_runtime::CreationEntryTypeSnapshot, ) -> module_runtime::CreationEntryTypeSnapshot { // 中文注释:旧库里残留的跳一跳系统默认入口行仍会从订阅缓存命中,这里统一做读模型纠偏, // 这样无论走订阅缓存还是 procedure 回退,创作页都只会看到新的跳一跳入口口径。 if item.id == "jump-hop" && item.title == "跳一跳" && item.subtitle == "俯视角跳跃闯关" && item.badge == "可创建" && item.image_src == "/creation-type-references/puzzle.webp" && item.visible && item.open && item.sort_order == 45 { return module_runtime::CreationEntryTypeSnapshot { subtitle: "主题驱动平台跳跃".to_string(), image_src: "/creation-type-references/jump-hop.webp".to_string(), ..item }; } item } #[cfg(test)] mod tests { use super::*; use spacetimedb_sdk::Timestamp; fn build_creation_entry_header() -> CreationEntryConfig { CreationEntryConfig { config_id: "creation-entry-config".to_string(), start_title: "新建作品".to_string(), start_description: "选择模板后进入对应的创作表单。".to_string(), start_idle_badge: "模板 Tab".to_string(), start_busy_badge: "正在开启".to_string(), modal_title: "选择创作类型".to_string(), modal_description: "先选玩法类型,再进入对应创作工作台。".to_string(), updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000), event_title: None, event_description: None, event_cover_image_src: None, event_prize_pool_mud_points: 0, event_starts_at_text: None, event_ends_at_text: None, event_banners_json: None, } } fn build_old_jump_hop_row() -> CreationEntryTypeConfig { CreationEntryTypeConfig { id: "jump-hop".to_string(), title: "跳一跳".to_string(), subtitle: "俯视角跳跃闯关".to_string(), badge: "可创建".to_string(), image_src: "/creation-type-references/puzzle.webp".to_string(), visible: true, open: true, sort_order: 45, updated_at: Timestamp::from_micros_since_unix_epoch(2_000_000), category_id: Some("recommended".to_string()), category_label: Some("热门推荐".to_string()), category_sort_order: 20, unified_creation_spec_json: None, } } #[test] fn build_creation_entry_config_record_from_rows_normalizes_old_jump_hop_row() { let record = build_creation_entry_config_record_from_rows( build_creation_entry_header(), vec![build_old_jump_hop_row()], ); let jump_hop = record .creation_types .iter() .find(|item| item.id == "jump-hop") .expect("should contain jump-hop"); assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); assert_eq!( jump_hop.image_src, "/creation-type-references/jump-hop.webp" ); } #[test] fn map_creation_entry_config_snapshot_normalizes_old_jump_hop_snapshot() { let record = map_creation_entry_config_snapshot(CreationEntryConfigSnapshot { config_id: "creation-entry-config".to_string(), start_card: CreationEntryStartCardSnapshot { title: "新建作品".to_string(), description: "选择模板后进入对应的创作表单。".to_string(), idle_badge: "模板 Tab".to_string(), busy_badge: "正在开启".to_string(), }, type_modal: CreationEntryTypeModalSnapshot { title: "选择创作类型".to_string(), description: "先选玩法类型,再进入对应创作工作台。".to_string(), }, event_banner: CreationEntryEventBannerSnapshot { title: "主题创作赛".to_string(), description: "用温暖的色彩,捏出秋天的故事。".to_string(), cover_image_src: "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png".to_string(), prize_pool_mud_points: 58_000, starts_at_text: "2024.10.20 10:00".to_string(), ends_at_text: "2024.11.20 23:59".to_string(), render_mode: "structured".to_string(), html_code: None, }, event_banners_json: None, creation_types: vec![CreationEntryTypeSnapshot { id: "jump-hop".to_string(), title: "跳一跳".to_string(), subtitle: "俯视角跳跃闯关".to_string(), badge: "可创建".to_string(), image_src: "/creation-type-references/puzzle.webp".to_string(), visible: true, open: true, sort_order: 45, category_id: "recommended".to_string(), category_label: "热门推荐".to_string(), category_sort_order: 20, updated_at_micros: 2_000_000, unified_creation_spec_json: None, }], updated_at_micros: 1_000_000, }); let jump_hop = record .creation_types .iter() .find(|item| item.id == "jump-hop") .expect("should contain jump-hop"); assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); assert_eq!( jump_hop.image_src, "/creation-type-references/jump-hop.webp" ); } } pub(crate) fn map_runtime_setting_procedure_result( result: RuntimeSettingProcedureResult, ) -> Result { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } let snapshot = result .record .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime settings 快照"))?; Ok(build_runtime_setting_record(map_runtime_setting_snapshot( snapshot, ))) } pub(crate) fn map_runtime_tracking_event_procedure_result( result: RuntimeTrackingEventProcedureResult, ) -> Result<(), SpacetimeClientError> { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } Ok(()) } pub(crate) fn map_runtime_tracking_event_batch_procedure_result( result: RuntimeTrackingEventBatchProcedureResult, ) -> Result { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } Ok(result.accepted_count) } pub(crate) fn map_runtime_snapshot_procedure_result( result: RuntimeSnapshotProcedureResult, ) -> Result, SpacetimeClientError> { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } result .record .map(|snapshot| { build_runtime_snapshot_record(map_runtime_snapshot_snapshot(snapshot)) .map_err(|error| SpacetimeClientError::Runtime(error.to_string())) }) .transpose() } pub(crate) fn map_runtime_snapshot_required_procedure_result( result: RuntimeSnapshotProcedureResult, ) -> Result { map_runtime_snapshot_procedure_result(result)? .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime snapshot 快照")) } pub(crate) fn map_runtime_snapshot_delete_procedure_result( result: RuntimeSnapshotProcedureResult, ) -> Result { map_runtime_snapshot_procedure_result(result).map(|record| record.is_some()) } pub(crate) fn map_runtime_setting_snapshot( snapshot: RuntimeSettingSnapshot, ) -> module_runtime::RuntimeSettingSnapshot { module_runtime::RuntimeSettingSnapshot { user_id: snapshot.user_id, music_volume: snapshot.music_volume, platform_theme: map_runtime_platform_theme_back(snapshot.platform_theme), created_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, } } pub(crate) fn map_runtime_platform_theme( value: DomainRuntimePlatformTheme, ) -> crate::module_bindings::RuntimePlatformTheme { match value { DomainRuntimePlatformTheme::Light => crate::module_bindings::RuntimePlatformTheme::Light, DomainRuntimePlatformTheme::Dark => crate::module_bindings::RuntimePlatformTheme::Dark, } } pub(crate) fn map_runtime_platform_theme_back( value: crate::module_bindings::RuntimePlatformTheme, ) -> DomainRuntimePlatformTheme { match value { crate::module_bindings::RuntimePlatformTheme::Light => DomainRuntimePlatformTheme::Light, crate::module_bindings::RuntimePlatformTheme::Dark => DomainRuntimePlatformTheme::Dark, } } pub(crate) fn map_runtime_tracking_scope_kind( value: DomainRuntimeTrackingScopeKind, ) -> crate::module_bindings::RuntimeTrackingScopeKind { match value { DomainRuntimeTrackingScopeKind::Site => { crate::module_bindings::RuntimeTrackingScopeKind::Site } DomainRuntimeTrackingScopeKind::Work => { crate::module_bindings::RuntimeTrackingScopeKind::Work } DomainRuntimeTrackingScopeKind::Module => { crate::module_bindings::RuntimeTrackingScopeKind::Module } DomainRuntimeTrackingScopeKind::User => { crate::module_bindings::RuntimeTrackingScopeKind::User } } } pub(crate) fn map_runtime_tracking_scope_kind_back( value: crate::module_bindings::RuntimeTrackingScopeKind, ) -> DomainRuntimeTrackingScopeKind { match value { crate::module_bindings::RuntimeTrackingScopeKind::Site => { DomainRuntimeTrackingScopeKind::Site } crate::module_bindings::RuntimeTrackingScopeKind::Work => { DomainRuntimeTrackingScopeKind::Work } crate::module_bindings::RuntimeTrackingScopeKind::Module => { DomainRuntimeTrackingScopeKind::Module } crate::module_bindings::RuntimeTrackingScopeKind::User => { DomainRuntimeTrackingScopeKind::User } } } pub(crate) fn parse_json_value( value: &str, label: &str, ) -> Result { serde_json::from_str::(value) .map_err(|error| SpacetimeClientError::Runtime(format!("{label} 非法: {error}"))) } pub(crate) fn parse_json_array( value: &str, label: &str, ) -> Result, SpacetimeClientError> { match parse_json_value(value, label)? { serde_json::Value::Array(entries) => Ok(entries), _ => Err(SpacetimeClientError::Runtime(format!( "{label} 必须是 JSON array" ))), } } pub(crate) fn parse_json_string_array( value: &str, label: &str, ) -> Result, SpacetimeClientError> { parse_json_array(value, label)? .into_iter() .map(|entry| match entry { serde_json::Value::String(value) => Ok(value), _ => Err(SpacetimeClientError::Runtime(format!( "{label} 必须是 string array" ))), }) .collect() } pub(crate) fn parse_supported_actions_json( value: &str, ) -> Result, SpacetimeClientError> { parse_json_array(value, "custom world agent supported_actions_json")? .into_iter() .map(|entry| { let object = entry.as_object().ok_or_else(|| { SpacetimeClientError::Runtime( "custom world supported action 必须是 JSON object".to_string(), ) })?; let action = object .get("action") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { SpacetimeClientError::Runtime( "custom world supported action.action 缺失".to_string(), ) })?; let enabled = object .get("enabled") .and_then(serde_json::Value::as_bool) .ok_or_else(|| { SpacetimeClientError::Runtime( "custom world supported action.enabled 缺失".to_string(), ) })?; Ok(CustomWorldSupportedActionRecord { action: action.to_string(), enabled, reason: object .get("reason") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), }) }) .collect() } #[derive(Clone, Debug, PartialEq)] pub struct BigFishRuntimeParamsRecord { pub level_count: u32, pub merge_count_per_upgrade: u32, pub spawn_target_count: u32, pub leader_move_speed: f32, pub follower_catch_up_speed: f32, pub offscreen_cull_seconds: f32, pub prey_spawn_delta_levels: Vec, pub threat_spawn_delta_levels: Vec, pub win_level: u32, } #[derive(Clone, Debug, PartialEq)] pub struct BigFishGameDraftRecord { pub title: String, pub subtitle: String, pub core_fun: String, pub ecology_theme: String, pub levels: Vec, pub background: BigFishBackgroundBlueprintRecord, pub runtime_params: BigFishRuntimeParamsRecord, } #[derive(Clone, Debug, PartialEq)] pub struct BigFishRuntimeEntityRecord { pub entity_id: String, pub level: u32, pub position: BigFishVector2Record, pub radius: f32, pub offscreen_seconds: f32, } #[derive(Clone, Debug, PartialEq)] pub struct BigFishRuntimeRunRecord { pub run_id: String, pub session_id: String, pub status: String, pub tick: u64, pub player_level: u32, pub win_level: u32, pub leader_entity_id: Option, pub owned_entities: Vec, pub wild_entities: Vec, pub camera_center: BigFishVector2Record, pub last_input: BigFishVector2Record, pub event_log: Vec, pub updated_at: String, }