diff --git a/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md b/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md index 19b48c12..880e7206 100644 --- a/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md +++ b/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md @@ -17,6 +17,19 @@ 4. 入口必须在移动端单手可点,不遮挡舞台主体。 5. 规则内容只做说明,不参与任何前端裁决;真实规则仍以后端运行快照为准。 +## 游玩统计规则 + +所有作品都需要对自身以及用户做游玩统计。 + +大鱼吃小鱼正式运行时必须遵守: + +1. 正式开始游玩已发布作品时,更新作品自身播放统计。 +2. 已登录用户写入 `profile_played_world`,`world_key = big-fish:{session_id}`。 +3. `profile_id` 保存大鱼作品号/会话号,`world_type = BIG_FISH`。 +4. `world_title` 使用玩法草稿标题,`world_subtitle` 优先使用副标题,其次使用核心乐趣。 +5. `owner_user_id` 使用大鱼作品归属用户 ID。 +6. 退出或结算上报 `elapsedMs` 后,后端按增量刷新 `profile_dashboard_state.total_play_time_ms` 和明细中的 `last_observed_play_time_ms`。 + ## 落地范围 1. `src/components/big-fish-runtime/BigFishRuntimeShell.tsx` diff --git a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md index b64db9ba..152641f1 100644 --- a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md +++ b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md @@ -169,6 +169,24 @@ Node 侧入口位于: ## 4. 本轮边界决议 +### 4.0 统一游玩统计规则 + +所有作品都需要对自身以及用户做游玩统计。 + +正式游玩开始时,玩法自己的作品真相表必须先更新自身统计;已登录用户还必须同步 upsert `profile_played_world` 明细。用户侧明细不是单纯计数,必须保留可跳转的稳定作品标识: + +1. `world_key` +2. `world_type` +3. `profile_id` +4. `world_title` +5. `world_subtitle` +6. `owner_user_id` +7. `first_played_at` +8. `last_played_at` +9. `last_observed_play_time_ms` + +当玩法有可观测时长时,后端按增量刷新 `profile_dashboard_state.total_play_time_ms`,并同步推进对应 `profile_played_world.last_observed_play_time_ms`。 + ### 4.1 先做 projection 读链 本轮 profile 三接口只做: diff --git a/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md index fb842857..da371b46 100644 --- a/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md +++ b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md @@ -39,6 +39,15 @@ 新增拼图成绩表,按“关卡作品 + 网格规格 + 用户”维护最佳成绩。 +正式开始拼图关卡时还必须同步用户玩过作品明细: + +1. 作品自身统计继续更新 `puzzle_work_profile.play_count`。 +2. 已登录用户写入 `profile_played_world`,`world_key = puzzle:{profile_id}`。 +3. `profile_id` 保存拼图作品号,`world_type = PUZZLE`。 +4. `world_title` 使用关卡名,`world_subtitle` 使用作品摘要,`owner_user_id` 使用拼图作者用户 ID。 +5. 下一关切换到新 `profile_id` 时按同一规则再次写入。 +6. 排行榜提交携带的 `elapsedMs` 是本关可观测时长,后端按增量累计到 `profile_dashboard_state.total_play_time_ms`。 + 建议字段: 1. `entry_id` diff --git a/packages/shared/src/contracts/bigFish.ts b/packages/shared/src/contracts/bigFish.ts index 4bedd2d1..5223f38e 100644 --- a/packages/shared/src/contracts/bigFish.ts +++ b/packages/shared/src/contracts/bigFish.ts @@ -25,6 +25,10 @@ export type ExecuteBigFishActionRequest = { motionKey?: 'idle_float' | 'move_swim' | string; }; +export type RecordBigFishPlayRequest = { + elapsedMs?: number; +}; + export type SubmitBigFishInputRequest = { x: number; y: number; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 9a37e1da..de3302a6 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -34,8 +34,8 @@ use crate::{ auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, - get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message, - record_big_fish_play, submit_big_fish_message, + get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play, + stream_big_fish_message, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -589,9 +589,19 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/sessions/{session_id}/play", + post(record_big_fish_play).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/big-fish/works/{session_id}/play", - post(record_big_fish_play), + post(record_big_fish_play).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/puzzle/agent/sessions", diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 93d774dd..65077496 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -24,7 +24,8 @@ use shared_contracts::big_fish::{ BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse, BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse, BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse, - CreateBigFishSessionRequest, ExecuteBigFishActionRequest, SendBigFishMessageRequest, + CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest, + SendBigFishMessageRequest, }; use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse}; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; @@ -32,8 +33,9 @@ use spacetime_client::{ BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, BigFishLevelBlueprintRecord, - BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, - BigFishSessionRecord, BigFishWorkSummaryRecord, SpacetimeClientError, + BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord, + SpacetimeClientError, }; use tokio::time::sleep; @@ -195,12 +197,28 @@ pub async fn record_big_fish_play( State(state): State, Path(session_id): Path, Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, ) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + big_fish_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "big-fish", + "message": error.body_text(), + })), + ) + })?; ensure_non_empty(&request_context, &session_id, "sessionId")?; let items = state .spacetime_client() - .record_big_fish_play(session_id, current_utc_micros()) + .record_big_fish_play(BigFishPlayReportRecordInput { + session_id, + user_id: authenticated.claims().user_id().to_string(), + elapsed_ms: payload.elapsed_ms.unwrap_or(0), + reported_at_micros: current_utc_micros(), + }) .await .map_err(|error| { big_fish_error_response(&request_context, map_big_fish_client_error(error)) diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index 778d937b..fcd98ba7 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -321,6 +321,8 @@ pub struct BigFishPublishInput { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishPlayRecordInput { pub session_id: String, + pub user_id: String, + pub elapsed_ms: u64, pub played_at_micros: i64, } @@ -666,6 +668,9 @@ pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), if normalize_required_string(&input.session_id).is_none() { return Err(BigFishFieldError::MissingSessionId); } + if normalize_required_string(&input.user_id).is_none() { + return Err(BigFishFieldError::MissingOwnerUserId); + } Ok(()) } diff --git a/server-rs/crates/shared-contracts/src/big_fish.rs b/server-rs/crates/shared-contracts/src/big_fish.rs index 49a69dca..bd25a93d 100644 --- a/server-rs/crates/shared-contracts/src/big_fish.rs +++ b/server-rs/crates/shared-contracts/src/big_fish.rs @@ -26,6 +26,13 @@ pub struct ExecuteBigFishActionRequest { pub motion_key: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecordBigFishPlayRequest { + #[serde(default)] + pub elapsed_ms: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BigFishAnchorItemResponse { @@ -189,4 +196,14 @@ mod tests { assert_eq!(payload["motionKey"], json!("move_swim")); assert_eq!(payload["level"], json!(3)); } + + #[test] + fn record_big_fish_play_request_uses_camel_case() { + let payload = serde_json::to_value(RecordBigFishPlayRequest { + elapsed_ms: Some(12_345), + }) + .expect("payload should serialize"); + + assert_eq!(payload, json!({ "elapsedMs": 12_345 })); + } } diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index d09229f3..1ff4eb7d 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -132,29 +132,6 @@ impl SpacetimeClient { .await } - pub async fn record_big_fish_play( - &self, - session_id: String, - played_at_micros: i64, - ) -> Result, SpacetimeClientError> { - let procedure_input = BigFishPlayRecordInput { - session_id, - played_at_micros, - }; - - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .record_big_fish_play_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(|result| map_big_fish_works_procedure_result(result, None)); - send_once(&sender, mapped); - }); - }) - .await - } - pub async fn submit_big_fish_message( &self, input: BigFishMessageSubmitRecordInput, @@ -290,4 +267,28 @@ impl SpacetimeClient { }) .await } + + pub async fn record_big_fish_play( + &self, + input: BigFishPlayReportRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = BigFishPlayRecordInput { + session_id: input.session_id, + user_id: input.user_id, + elapsed_ms: input.elapsed_ms, + played_at_micros: input.reported_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_big_fish_play_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(|result| map_big_fish_works_procedure_result(result, None)); + send_once(&sender, mapped); + }); + }) + .await + } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index cfbe290e..e5306ade 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -10,13 +10,14 @@ pub use mapper::{ BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, - BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, - BigFishSessionRecord, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, - CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, - CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, - CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, - CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, - CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, + BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord, + CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, + CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, + CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, + CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, + CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index e28270ac..9412d8c6 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4364,6 +4364,14 @@ pub struct PuzzleRunNextLevelRecordInput { pub advanced_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishPlayReportRecordInput { + pub session_id: String, + pub user_id: String, + pub elapsed_ms: u64, + pub reported_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAnchorItemRecord { pub key: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs index c254d1f6..bbdaab4f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs @@ -17,6 +17,10 @@ impl __sdk::InModule for AdminDisableProfileRedeemCodeArgs { type Module = super::RemoteModule; } +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_disable_profile_redeem_code`. +/// +/// Implemented for [`super::RemoteProcedures`]. pub trait admin_disable_profile_redeem_code { fn admin_disable_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminDisableInput) { self.admin_disable_profile_redeem_code_then(input, |_, _| {}); @@ -25,11 +29,12 @@ pub trait admin_disable_profile_redeem_code { fn admin_disable_profile_redeem_code_then( &self, input: RuntimeProfileRedeemCodeAdminDisableInput, - callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -37,17 +42,18 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures { fn admin_disable_profile_redeem_code_then( &self, input: RuntimeProfileRedeemCodeAdminDisableInput, - callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( "admin_disable_profile_redeem_code", AdminDisableProfileRedeemCodeArgs { input }, - callback, + __callback, ); } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs index cafe2382..7e918220 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs @@ -17,6 +17,10 @@ impl __sdk::InModule for AdminUpsertProfileRedeemCodeArgs { type Module = super::RemoteModule; } +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_upsert_profile_redeem_code`. +/// +/// Implemented for [`super::RemoteProcedures`]. pub trait admin_upsert_profile_redeem_code { fn admin_upsert_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminUpsertInput) { self.admin_upsert_profile_redeem_code_then(input, |_, _| {}); @@ -25,11 +29,12 @@ pub trait admin_upsert_profile_redeem_code { fn admin_upsert_profile_redeem_code_then( &self, input: RuntimeProfileRedeemCodeAdminUpsertInput, - callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -37,17 +42,18 @@ impl admin_upsert_profile_redeem_code for super::RemoteProcedures { fn admin_upsert_profile_redeem_code_then( &self, input: RuntimeProfileRedeemCodeAdminUpsertInput, - callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( "admin_upsert_profile_redeem_code", AdminUpsertProfileRedeemCodeArgs { input }, - callback, + __callback, ); } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs index dc9ec79b..ac5e3715 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs @@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct BigFishPlayRecordInput { pub session_id: String, + pub user_id: String, + pub elapsed_ms: u64, pub played_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 1a028014..6edd0e7c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub mod accept_quest_reducer; pub mod acknowledge_quest_completion_reducer; +pub mod admin_disable_profile_redeem_code_procedure; +pub mod admin_upsert_profile_redeem_code_procedure; pub mod advance_puzzle_next_level_procedure; pub mod ai_result_reference_input_type; pub mod ai_result_reference_kind_type; @@ -251,9 +253,6 @@ pub mod list_custom_world_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; -pub mod redeem_profile_reward_code_procedure; -pub mod admin_upsert_profile_redeem_code_procedure; -pub mod admin_disable_profile_redeem_code_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; pub mod npc_battle_interaction_procedure_result_type; @@ -281,6 +280,8 @@ pub mod profile_invite_code_type; pub mod profile_membership_type; pub mod profile_played_world_type; pub mod profile_recharge_order_type; +pub mod profile_redeem_code_type; +pub mod profile_redeem_code_usage_type; pub mod profile_referral_relation_type; pub mod profile_save_archive_type; pub mod profile_wallet_ledger_type; @@ -335,7 +336,6 @@ pub mod quest_objective_snapshot_type; pub mod quest_progress_signal_type; pub mod quest_record_input_type; pub mod quest_record_type; -pub mod record_big_fish_play_procedure; pub mod quest_reward_equipment_slot_type; pub mod quest_reward_intel_type; pub mod quest_reward_item_rarity_type; @@ -348,9 +348,11 @@ pub mod quest_status_type; pub mod quest_step_snapshot_type; pub mod quest_treasure_inspected_signal_type; pub mod quest_turn_in_input_type; +pub mod record_big_fish_play_procedure; pub mod redeem_profile_referral_invite_code_procedure; -pub mod refund_profile_wallet_points_and_return_procedure; +pub mod redeem_profile_reward_code_procedure; pub mod refresh_session_type; +pub mod refund_profile_wallet_points_and_return_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -408,6 +410,14 @@ pub mod runtime_profile_recharge_order_snapshot_type; pub mod runtime_profile_recharge_order_status_type; pub mod runtime_profile_recharge_product_kind_type; pub mod runtime_profile_recharge_product_snapshot_type; +pub mod runtime_profile_redeem_code_admin_disable_input_type; +pub mod runtime_profile_redeem_code_admin_procedure_result_type; +pub mod runtime_profile_redeem_code_admin_upsert_input_type; +pub mod runtime_profile_redeem_code_mode_type; +pub mod runtime_profile_redeem_code_snapshot_type; +pub mod runtime_profile_reward_code_redeem_input_type; +pub mod runtime_profile_reward_code_redeem_procedure_result_type; +pub mod runtime_profile_reward_code_redeem_snapshot_type; pub mod runtime_profile_save_archive_list_input_type; pub mod runtime_profile_save_archive_procedure_result_type; pub mod runtime_profile_save_archive_resume_input_type; @@ -418,14 +428,6 @@ pub mod runtime_profile_wallet_ledger_entry_snapshot_type; pub mod runtime_profile_wallet_ledger_list_input_type; pub mod runtime_profile_wallet_ledger_procedure_result_type; pub mod runtime_profile_wallet_ledger_source_type_type; -pub mod runtime_profile_redeem_code_mode_type; -pub mod runtime_profile_reward_code_redeem_input_type; -pub mod runtime_profile_reward_code_redeem_snapshot_type; -pub mod runtime_profile_reward_code_redeem_procedure_result_type; -pub mod runtime_profile_redeem_code_admin_upsert_input_type; -pub mod runtime_profile_redeem_code_admin_disable_input_type; -pub mod runtime_profile_redeem_code_snapshot_type; -pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; @@ -490,6 +492,8 @@ pub mod user_browse_history_type; pub use accept_quest_reducer::accept_quest; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; +pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; +pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use ai_result_reference_input_type::AiResultReferenceInput; pub use ai_result_reference_kind_type::AiResultReferenceKind; @@ -733,9 +737,6 @@ pub use list_custom_world_works_procedure::list_custom_world_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; -pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; -pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; -pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; @@ -763,6 +764,8 @@ pub use profile_invite_code_type::ProfileInviteCode; pub use profile_membership_type::ProfileMembership; pub use profile_played_world_type::ProfilePlayedWorld; pub use profile_recharge_order_type::ProfileRechargeOrder; +pub use profile_redeem_code_type::ProfileRedeemCode; +pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage; pub use profile_referral_relation_type::ProfileReferralRelation; pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_wallet_ledger_type::ProfileWalletLedger; @@ -817,7 +820,6 @@ pub use quest_objective_snapshot_type::QuestObjectiveSnapshot; pub use quest_progress_signal_type::QuestProgressSignal; pub use quest_record_input_type::QuestRecordInput; pub use quest_record_type::QuestRecord; -pub use record_big_fish_play_procedure::record_big_fish_play; pub use quest_reward_equipment_slot_type::QuestRewardEquipmentSlot; pub use quest_reward_intel_type::QuestRewardIntel; pub use quest_reward_item_rarity_type::QuestRewardItemRarity; @@ -830,9 +832,11 @@ pub use quest_status_type::QuestStatus; pub use quest_step_snapshot_type::QuestStepSnapshot; pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; pub use quest_turn_in_input_type::QuestTurnInInput; +pub use record_big_fish_play_procedure::record_big_fish_play; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; -pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; +pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refresh_session_type::RefreshSession; +pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; @@ -890,6 +894,14 @@ pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrde pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; +pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; +pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; +pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; +pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; +pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; +pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; +pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveListInput; pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult; pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput; @@ -900,14 +912,6 @@ pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletL pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput; pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult; pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; -pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; -pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; -pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; -pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; -pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; -pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; -pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; -pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs new file mode 100644 index 00000000..9be48e6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs @@ -0,0 +1,78 @@ +// 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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileRedeemCode { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileRedeemCode { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRedeemCode`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRedeemCodeCols { + pub code: __sdk::__query_builder::Col, + pub mode: __sdk::__query_builder::Col, + pub reward_points: __sdk::__query_builder::Col, + pub max_uses: __sdk::__query_builder::Col, + pub global_used_count: __sdk::__query_builder::Col, + pub enabled: __sdk::__query_builder::Col, + pub allowed_user_ids: __sdk::__query_builder::Col>, + pub created_by: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileRedeemCode { + type Cols = ProfileRedeemCodeCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRedeemCodeCols { + code: __sdk::__query_builder::Col::new(table_name, "code"), + mode: __sdk::__query_builder::Col::new(table_name, "mode"), + reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"), + max_uses: __sdk::__query_builder::Col::new(table_name, "max_uses"), + global_used_count: __sdk::__query_builder::Col::new(table_name, "global_used_count"), + enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), + allowed_user_ids: __sdk::__query_builder::Col::new(table_name, "allowed_user_ids"), + created_by: __sdk::__query_builder::Col::new(table_name, "created_by"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRedeemCode`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRedeemCodeIxCols { + pub code: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRedeemCode { + type IxCols = ProfileRedeemCodeIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRedeemCodeIxCols { + code: __sdk::__query_builder::IxCol::new(table_name, "code"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCode {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_usage_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_usage_type.rs new file mode 100644 index 00000000..cbbd93ae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_usage_type.rs @@ -0,0 +1,65 @@ +// 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 ProfileRedeemCodeUsage { + pub usage_id: String, + pub code: String, + pub user_id: String, + pub amount_granted: u64, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileRedeemCodeUsage { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRedeemCodeUsage`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRedeemCodeUsageCols { + pub usage_id: __sdk::__query_builder::Col, + pub code: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub amount_granted: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileRedeemCodeUsage { + type Cols = ProfileRedeemCodeUsageCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRedeemCodeUsageCols { + usage_id: __sdk::__query_builder::Col::new(table_name, "usage_id"), + code: __sdk::__query_builder::Col::new(table_name, "code"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + amount_granted: __sdk::__query_builder::Col::new(table_name, "amount_granted"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRedeemCodeUsage`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRedeemCodeUsageIxCols { + pub code: __sdk::__query_builder::IxCol, + pub usage_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRedeemCodeUsage { + type IxCols = ProfileRedeemCodeUsageIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRedeemCodeUsageIxCols { + code: __sdk::__query_builder::IxCol::new(table_name, "code"), + usage_id: __sdk::__query_builder::IxCol::new(table_name, "usage_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCodeUsage {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs index 5f5e7400..4d048a49 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs @@ -17,6 +17,10 @@ impl __sdk::InModule for RedeemProfileRewardCodeArgs { type Module = super::RemoteModule; } +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `redeem_profile_reward_code`. +/// +/// Implemented for [`super::RemoteProcedures`]. pub trait redeem_profile_reward_code { fn redeem_profile_reward_code(&self, input: RuntimeProfileRewardCodeRedeemInput) { self.redeem_profile_reward_code_then(input, |_, _| {}); @@ -25,11 +29,12 @@ pub trait redeem_profile_reward_code { fn redeem_profile_reward_code_then( &self, input: RuntimeProfileRewardCodeRedeemInput, - callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -37,17 +42,18 @@ impl redeem_profile_reward_code for super::RemoteProcedures { fn redeem_profile_reward_code_then( &self, input: RuntimeProfileRewardCodeRedeemInput, - callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>( "redeem_profile_reward_code", RedeemProfileRewardCodeArgs { input }, - callback, + __callback, ); } } diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 0c0faa92..769d126b 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -1,4 +1,7 @@ use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session}; +use crate::runtime::{ + ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, +}; use crate::*; const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0; @@ -575,6 +578,92 @@ pub(crate) fn compile_big_fish_draft_tx( ) } +pub(crate) fn record_big_fish_play_tx( + ctx: &ReducerContext, + input: BigFishPlayRecordInput, +) -> Result, String> { + validate_play_record_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish 已发布作品不存在".to_string())?; + let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + let draft = session + .draft_json + .as_deref() + .map(deserialize_draft) + .transpose() + .map_err(|error| format!("big_fish.draft_json 非法: {error}"))?; + let title = draft + .as_ref() + .map(|value| value.title.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "大鱼吃小鱼".to_string()); + let subtitle = draft + .as_ref() + .and_then(|value| { + let subtitle = value.subtitle.trim(); + if subtitle.is_empty() { + let core_fun = value.core_fun.trim(); + (!core_fun.is_empty()).then(|| core_fun.to_string()) + } else { + Some(subtitle.to_string()) + } + }) + .unwrap_or_default(); + let world_key = format!("big-fish:{}", session.session_id); + + upsert_profile_played_work( + ctx, + ProfilePlayedWorkUpsertInput { + user_id: input.user_id.clone(), + world_key: world_key.clone(), + owner_user_id: Some(session.owner_user_id.clone()), + profile_id: Some(session.session_id.clone()), + world_type: Some("BIG_FISH".to_string()), + world_title: title, + world_subtitle: subtitle, + played_at_micros: input.played_at_micros, + }, + )?; + add_profile_observed_play_time( + ctx, + &input.user_id, + &world_key, + input.elapsed_ms, + input.played_at_micros, + )?; + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: session.progress_percent, + stage: session.stage, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: session.draft_json.clone(), + asset_coverage_json: session.asset_coverage_json.clone(), + last_assistant_reply: session.last_assistant_reply.clone(), + publish_ready: session.publish_ready, + // 中文注释:正式进入已发布作品时同时累加作品播放数,用户侧去重由 profile_played_world 保证。 + play_count: session.play_count.saturating_add(1), + created_at: session.created_at, + updated_at: played_at, + }; + replace_big_fish_session(ctx, &session, next_session); + + list_big_fish_works_tx( + ctx, + BigFishWorksListInput { + owner_user_id: String::new(), + published_only: true, + }, + ) +} + pub(crate) fn build_big_fish_session_snapshot( ctx: &ReducerContext, row: &BigFishCreationSession, @@ -692,47 +781,6 @@ pub(crate) fn build_big_fish_work_summary( }) } -pub(crate) fn record_big_fish_play_tx( - ctx: &ReducerContext, - input: BigFishPlayRecordInput, -) -> Result, String> { - validate_play_record_input(&input).map_err(|error| error.to_string())?; - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.stage == BigFishCreationStage::Published) - .ok_or_else(|| "big_fish 已发布作品不存在".to_string())?; - let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); - let next_session = BigFishCreationSession { - session_id: session.session_id.clone(), - owner_user_id: session.owner_user_id.clone(), - seed_text: session.seed_text.clone(), - current_turn: session.current_turn, - progress_percent: session.progress_percent, - stage: session.stage, - anchor_pack_json: session.anchor_pack_json.clone(), - draft_json: session.draft_json.clone(), - asset_coverage_json: session.asset_coverage_json.clone(), - last_assistant_reply: session.last_assistant_reply.clone(), - publish_ready: session.publish_ready, - // 中文注释:这里只记录正式发布作品的进入次数,创作结果页测试运行不走这个 procedure。 - play_count: session.play_count.saturating_add(1), - created_at: session.created_at, - updated_at: played_at, - }; - replace_big_fish_session(ctx, &session, next_session); - - list_big_fish_works_tx( - ctx, - BigFishWorksListInput { - owner_user_id: String::new(), - published_only: true, - }, - ) -} - pub(crate) fn replace_big_fish_session( ctx: &ReducerContext, current: &BigFishCreationSession, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 7c27aba7..d3b34a62 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,3 +1,6 @@ +use crate::runtime::{ + ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, +}; use module_puzzle::{ PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, @@ -1072,6 +1075,12 @@ fn start_puzzle_run_tx( .map(|value| value.profile_id.clone()); increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros); + upsert_puzzle_profile_played_work( + ctx, + &input.owner_user_id, + &entry_profile_row, + input.started_at_micros, + )?; insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?; Ok(run) } @@ -1179,6 +1188,12 @@ fn advance_puzzle_next_level_tx( .find(&next_profile.profile_id) { increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros); + upsert_puzzle_profile_played_work( + ctx, + &input.owner_user_id, + &next_profile_row, + input.advanced_at_micros, + )?; } replace_puzzle_runtime_run(ctx, &row, &next_run, input.advanced_at_micros); Ok(next_run) @@ -1219,6 +1234,13 @@ fn submit_puzzle_leaderboard_entry_tx( &input.run_id, input.submitted_at_micros, ); + add_profile_observed_play_time( + ctx, + &input.owner_user_id, + &format!("puzzle:{}", input.profile_id), + input.elapsed_ms.max(1_000), + input.submitted_at_micros, + )?; let leaderboard_entries = list_puzzle_leaderboard_entries( ctx, @@ -1607,6 +1629,28 @@ fn increment_puzzle_profile_play_count( ); } +fn upsert_puzzle_profile_played_work( + ctx: &TxContext, + user_id: &str, + row: &PuzzleWorkProfileRow, + played_at_micros: i64, +) -> Result<(), String> { + // 拼图正式游玩以作品 profile_id 作为公开作品号,用户侧明细按 world_key 去重。 + upsert_profile_played_work( + ctx, + ProfilePlayedWorkUpsertInput { + user_id: user_id.to_string(), + world_key: format!("puzzle:{}", row.profile_id), + owner_user_id: Some(row.owner_user_id.clone()), + profile_id: Some(row.profile_id.clone()), + world_type: Some("PUZZLE".to_string()), + world_title: row.level_name.clone(), + world_subtitle: row.summary.clone(), + played_at_micros, + }, + ) +} + fn replace_generated_candidate( draft: &mut PuzzleResultDraft, candidates: Vec, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 9c4cda1f..4c81d551 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -116,6 +116,17 @@ pub struct ProfilePlayedWorld { pub(crate) last_observed_play_time_ms: u64, } +pub(crate) struct ProfilePlayedWorkUpsertInput { + pub(crate) user_id: String, + pub(crate) world_key: String, + pub(crate) owner_user_id: Option, + pub(crate) profile_id: Option, + pub(crate) world_type: Option, + pub(crate) world_title: String, + pub(crate) world_subtitle: String, + pub(crate) played_at_micros: i64, +} + #[spacetimedb::table(accessor = profile_membership)] pub struct ProfileMembership { #[primary_key] @@ -589,6 +600,172 @@ pub(crate) fn sync_profile_projections_from_snapshot( Ok(()) } +pub(crate) fn upsert_profile_played_work( + ctx: &ReducerContext, + input: ProfilePlayedWorkUpsertInput, +) -> Result<(), String> { + let user_id = input.user_id.trim(); + let world_key = input.world_key.trim(); + if user_id.is_empty() { + return Err("profile_played_world.user_id 不能为空".to_string()); + } + if world_key.is_empty() { + return Err("profile_played_world.world_key 不能为空".to_string()); + } + + let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + let played_world_id = format!("{user_id}:{world_key}"); + let existing = ctx + .db + .profile_played_world() + .played_world_id() + .find(&played_world_id); + + if let Some(existing) = existing { + ctx.db + .profile_played_world() + .played_world_id() + .delete(&existing.played_world_id); + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: user_id.to_string(), + world_key: world_key.to_string(), + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_type: input.world_type, + world_title: input.world_title, + world_subtitle: input.world_subtitle, + first_played_at: existing.first_played_at, + last_played_at: played_at, + last_observed_play_time_ms: existing.last_observed_play_time_ms, + }); + } else { + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: user_id.to_string(), + world_key: world_key.to_string(), + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_type: input.world_type, + world_title: input.world_title, + world_subtitle: input.world_subtitle, + first_played_at: played_at, + last_played_at: played_at, + last_observed_play_time_ms: 0, + }); + } + + ensure_profile_dashboard_state(ctx, user_id, played_at); + Ok(()) +} + +pub(crate) fn add_profile_observed_play_time( + ctx: &ReducerContext, + user_id: &str, + world_key: &str, + elapsed_ms: u64, + observed_at_micros: i64, +) -> Result<(), String> { + let user_id = user_id.trim(); + let world_key = world_key.trim(); + if user_id.is_empty() || world_key.is_empty() || elapsed_ms == 0 { + return Ok(()); + } + + let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros); + let played_world_id = format!("{user_id}:{world_key}"); + if let Some(existing) = ctx + .db + .profile_played_world() + .played_world_id() + .find(&played_world_id) + { + ctx.db + .profile_played_world() + .played_world_id() + .delete(&existing.played_world_id); + ctx.db.profile_played_world().insert(ProfilePlayedWorld { + played_world_id, + user_id: existing.user_id, + world_key: existing.world_key, + owner_user_id: existing.owner_user_id, + profile_id: existing.profile_id, + world_type: existing.world_type, + world_title: existing.world_title, + world_subtitle: existing.world_subtitle, + first_played_at: existing.first_played_at, + last_played_at: observed_at, + last_observed_play_time_ms: existing + .last_observed_play_time_ms + .saturating_add(elapsed_ms), + }); + } + + add_profile_dashboard_play_time(ctx, user_id, elapsed_ms, observed_at); + Ok(()) +} + +fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { + if ctx + .db + .profile_dashboard_state() + .user_id() + .find(&user_id.to_string()) + .is_some() + { + return; + } + + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: user_id.to_string(), + wallet_balance: 0, + total_play_time_ms: 0, + created_at: updated_at, + updated_at, + }); +} + +fn add_profile_dashboard_play_time( + ctx: &ReducerContext, + user_id: &str, + elapsed_ms: u64, + updated_at: Timestamp, +) { + let current = ctx + .db + .profile_dashboard_state() + .user_id() + .find(&user_id.to_string()); + + if let Some(existing) = current { + ctx.db + .profile_dashboard_state() + .user_id() + .delete(&existing.user_id); + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: user_id.to_string(), + wallet_balance: existing.wallet_balance, + total_play_time_ms: existing.total_play_time_ms.saturating_add(elapsed_ms), + created_at: existing.created_at, + updated_at, + }); + } else { + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: user_id.to_string(), + wallet_balance: 0, + total_play_time_ms: elapsed_ms, + created_at: updated_at, + updated_at, + }); + } +} + fn sync_profile_dashboard_from_snapshot( ctx: &ReducerContext, snapshot: &RuntimeSnapshot, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4af6f0f1..e96c43cb 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -36,6 +36,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, + ProfilePlayedWorkSummary, + ProfilePlayStatsResponse, } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { @@ -55,12 +57,12 @@ import { import { listBigFishGallery } from '../../services/big-fish-gallery'; import { advanceLocalBigFishRuntimeRun, + recordBigFishPlay, startLocalBigFishRuntimeRun, } from '../../services/big-fish-runtime'; import { deleteBigFishWork, listBigFishWorks, - recordBigFishWorkPlay, } from '../../services/big-fish-works'; import { readCustomWorldAgentUiState, @@ -107,6 +109,7 @@ import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetailByCode, } from '../../services/rpg-entry/rpgEntryLibraryClient'; +import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { @@ -154,6 +157,8 @@ type AgentResultBlockerView = { message: string; }; +type BigFishRuntimeSessionSource = 'draft' | 'work' | null; + const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ 'publish_missing_world_hook', 'publish_missing_player_premise', @@ -442,6 +447,13 @@ export function PlatformEntryFlowShellImpl({ title: string; publicWorkCode: string; } | null>(null); + const [bigFishRuntimeWork, setBigFishRuntimeWork] = + useState(null); + const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState< + number | null + >(null); + const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] = + useState(null); const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); const [bigFishGenerationState, setBigFishGenerationState] = useState(null); @@ -474,6 +486,14 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); + const [profilePlayStats, setProfilePlayStats] = + useState(null); + const [profilePlayStatsError, setProfilePlayStatsError] = useState< + string | null + >(null); + const [isProfilePlayStatsLoading, setIsProfilePlayStatsLoading] = + useState(false); + const [isProfilePlayStatsOpen, setIsProfilePlayStatsOpen] = useState(false); const hadReadableProtectedDataRef = useRef(false); const hasInitialAgentSession = Boolean( readCustomWorldAgentUiState().activeSessionId && @@ -986,7 +1006,6 @@ export function PlatformEntryFlowShellImpl({ const bigFishError = bigFishFlow.error; const setBigFishError = bigFishFlow.setError; const isBigFishBusy = bigFishFlow.isBusy; - const setIsBigFishBusy = bigFishFlow.setIsBusy; const streamingBigFishReplyText = bigFishFlow.streamingReplyText; const isStreamingBigFishReply = bigFishFlow.isStreamingReply; @@ -1034,6 +1053,9 @@ export function PlatformEntryFlowShellImpl({ setBigFishWorks([]); setBigFishRun(null); setBigFishRuntimeShare(null); + setBigFishRuntimeWork(null); + setBigFishRuntimeStartedAt(null); + setBigFishRuntimeSessionSource(null); setBigFishGenerationState(null); setBigFishError(null); setPuzzleOperation(null); @@ -1045,6 +1067,9 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleNextLevelGenerating(false); setPuzzleError(null); setDeletingCreationWorkId(null); + setProfilePlayStats(null); + setProfilePlayStatsError(null); + setIsProfilePlayStatsOpen(false); resetRpgSessionViewState(); setRpgGeneratedCustomWorldProfile(null); setRpgCustomWorldError(null); @@ -1113,6 +1138,9 @@ export function PlatformEntryFlowShellImpl({ const leaveBigFishFlow = useCallback(() => { setBigFishRun(null); + setBigFishRuntimeWork(null); + setBigFishRuntimeStartedAt(null); + setBigFishRuntimeSessionSource(null); setBigFishGenerationState(null); bigFishFlow.leaveFlow(); }, [bigFishFlow]); @@ -1149,32 +1177,62 @@ export function PlatformEntryFlowShellImpl({ return; } - const run = async () => { - setBigFishError(null); - setBigFishRuntimeShare(null); - if (bigFishSession.stage === 'published') { - await recordBigFishWorkPlay(bigFishSession.sessionId); - await refreshBigFishShelf(); - } - setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); - setSelectionStage('big-fish-runtime'); - }; - - void run().catch((error) => { - setBigFishError(resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。')); + const sessionId = bigFishSession.sessionId; + setBigFishError(null); + setBigFishRuntimeShare(null); + setBigFishRuntimeWork(null); + setBigFishRuntimeStartedAt(Date.now()); + setBigFishRuntimeSessionSource('draft'); + setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); + setSelectionStage('big-fish-runtime'); + void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'), + ); }); - }, [bigFishSession, refreshBigFishShelf, resolveBigFishErrorMessage, setSelectionStage]); + void refreshBigFishShelf(); + }, [ + bigFishSession, + refreshBigFishShelf, + resolveBigFishErrorMessage, + setSelectionStage, + ]); const restartBigFishRun = useCallback(() => { if (!bigFishSession && !bigFishRun) { return; } + const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId; + if (!sessionId) { + return; + } + setBigFishError(null); - setBigFishRuntimeShare(null); - setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); + if (bigFishSession) { + setBigFishRuntimeShare(null); + } + setBigFishRuntimeStartedAt(Date.now()); + setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work'); + setBigFishRun( + startLocalBigFishRuntimeRun({ + session: bigFishSession, + work: bigFishRuntimeWork, + }), + ); setSelectionStage('big-fish-runtime'); - }, [bigFishRun, bigFishSession, setSelectionStage]); + void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'), + ); + }); + }, [ + bigFishRun, + bigFishRuntimeWork, + bigFishSession, + resolveBigFishErrorMessage, + setSelectionStage, + ]); const startPuzzleRunFromProfile = useCallback( async (profileId: string) => { @@ -1265,12 +1323,33 @@ export function PlatformEntryFlowShellImpl({ } setBigFishRun((currentRun) => - currentRun ? advanceLocalBigFishRuntimeRun(currentRun, payload) : currentRun, + currentRun + ? advanceLocalBigFishRuntimeRun(currentRun, payload) + : currentRun, ); }, [bigFishRun], ); + const reportBigFishObservedPlayTime = useCallback(() => { + const sessionId = bigFishRun?.sessionId?.trim(); + if (!sessionId || !bigFishRuntimeStartedAt) { + return; + } + + const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt); + setBigFishRuntimeStartedAt(null); + void recordBigFishPlay(sessionId, { elapsedMs }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'), + ); + }); + }, [ + bigFishRun?.sessionId, + bigFishRuntimeStartedAt, + resolveBigFishErrorMessage, + ]); + const swapPuzzlePiecesInRun = useCallback( (payload: { firstPieceId: string; secondPieceId: string }) => { if (!puzzleRun || isPuzzleBusy) { @@ -1327,7 +1406,9 @@ export function PlatformEntryFlowShellImpl({ }) .catch((error) => { submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); - setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。')); + setPuzzleError( + resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'), + ); }) .finally(() => { setIsPuzzleLeaderboardBusy(false); @@ -1697,26 +1778,34 @@ export function PlatformEntryFlowShellImpl({ const startBigFishRunFromWork = useCallback( (item: BigFishWorkSummary) => { - const sessionId = item.sourceSessionId?.trim(); - if (!sessionId) { - setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); - return; - } + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); + return; + } - const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId); - setBigFishError(null); - bigFishFlow.setSession(null); - setBigFishRuntimeShare({ - title: item.title, - publicWorkCode, - }); - setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); - setSelectionStage('big-fish-runtime'); - pushAppHistoryPath( - buildPublicWorkStagePath('big-fish-runtime', publicWorkCode), - ); - }, - [bigFishFlow, setSelectionStage], + const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId); + setBigFishError(null); + bigFishFlow.setSession(null); + setBigFishRuntimeWork(item); + setBigFishRuntimeShare({ + title: item.title, + publicWorkCode, + }); + setBigFishRuntimeStartedAt(Date.now()); + setBigFishRuntimeSessionSource('work'); + setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); + setSelectionStage('big-fish-runtime'); + pushAppHistoryPath( + buildPublicWorkStagePath('big-fish-runtime', publicWorkCode), + ); + void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'), + ); + }); + }, + [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage], ); const handlePublicCodeSearch = useCallback( @@ -1865,6 +1954,118 @@ export function PlatformEntryFlowShellImpl({ ], ); + const openProfilePlayedWorks = useCallback(() => { + setIsProfilePlayStatsOpen(true); + setIsProfilePlayStatsLoading(true); + setProfilePlayStatsError(null); + + void getRpgProfilePlayStats() + .then(setProfilePlayStats) + .catch((error) => { + setProfilePlayStats(null); + setProfilePlayStatsError( + resolveRpgCreationErrorMessage(error, '读取玩过作品失败。'), + ); + }) + .finally(() => { + setIsProfilePlayStatsLoading(false); + }); + }, []); + + const openPlayedWork = useCallback( + (work: ProfilePlayedWorkSummary) => { + const worldType = (work.worldType ?? '').toLowerCase(); + setIsProfilePlayStatsOpen(false); + + if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) { + const profileId = + work.profileId ?? work.worldKey.replace(/^puzzle:/u, ''); + if (profileId) { + void openPuzzleDetail(profileId, { tab: 'profile' }); + } + return; + } + + if ( + worldType === 'big_fish' || + worldType === 'big-fish' || + work.worldKey.startsWith('big-fish:') + ) { + const sessionId = + work.profileId ?? work.worldKey.replace(/^big-fish:/u, ''); + if (!sessionId) { + return; + } + void refreshBigFishGallery() + .then((entries) => { + const matchedEntry = entries.find( + (entry) => entry.sourceSessionId === sessionId, + ); + if (matchedEntry) { + startBigFishRunFromWork(matchedEntry); + return; + } + startBigFishRunFromWork({ + workId: `big-fish:${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: work.ownerUserId ?? '', + title: work.worldTitle, + subtitle: work.worldSubtitle, + summary: work.worldSubtitle, + coverImageSrc: null, + status: 'published', + updatedAt: work.lastPlayedAt, + publishReady: true, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }); + }) + .catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'), + ); + }); + return; + } + + const profileId = work.profileId ?? work.worldKey; + const ownerUserId = work.ownerUserId; + if (!ownerUserId || !profileId) { + return; + } + + runProtectedAction(() => { + void detailNavigation.openGalleryDetail({ + ownerUserId, + profileId, + publicWorkCode: null, + authorPublicUserCode: null, + visibility: 'published', + publishedAt: work.firstPlayedAt, + updatedAt: work.lastPlayedAt, + authorDisplayName: work.worldSubtitle, + worldName: work.worldTitle, + subtitle: work.worldSubtitle, + summaryText: '', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + }); + }); + }, + [ + detailNavigation, + openPuzzleDetail, + refreshBigFishGallery, + resolveBigFishErrorMessage, + runProtectedAction, + startBigFishRunFromWork, + ], + ); + useEffect(() => { const publicWorkCode = initialPublicWorkCode?.trim(); if ( @@ -2120,7 +2321,19 @@ export function PlatformEntryFlowShellImpl({ void handlePublicCodeSearch(keyword); }} isSearchingPublicCode={isSearchingPublicCode} - onOpenProfileDashboardCard={() => { + profilePlayStats={profilePlayStats} + isProfilePlayStatsOpen={isProfilePlayStatsOpen} + isProfilePlayStatsLoading={isProfilePlayStatsLoading} + profilePlayStatsError={profilePlayStatsError} + onCloseProfilePlayStats={() => { + setIsProfilePlayStatsOpen(false); + }} + onOpenPlayedWork={openPlayedWork} + onOpenProfileDashboardCard={(cardKey) => { + if (cardKey === 'playedWorks') { + openProfilePlayedWorks(); + return; + } if (platformBootstrap.dashboardError) { void platformBootstrap.refreshProfileDashboard(); } @@ -2373,11 +2586,15 @@ export function PlatformEntryFlowShellImpl({ isBusy={isBigFishBusy} error={bigFishError} onBack={() => { + reportBigFishObservedPlayTime(); setSelectionStage( - bigFishSession ? 'big-fish-result' : 'platform', + bigFishRuntimeSessionSource === 'draft' + ? 'big-fish-result' + : 'platform', ); }} onRestart={() => { + reportBigFishObservedPlayTime(); void restartBigFishRun(); }} onSubmitInput={submitBigFishInput} @@ -2545,17 +2762,17 @@ export function PlatformEntryFlowShellImpl({ } > - { - setSelectionStage(puzzleRuntimeReturnStage); - }} + { + setSelectionStage(puzzleRuntimeReturnStage); + }} onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); }} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index a76e4672..a3077037 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -33,9 +33,11 @@ import type { PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, - ProfileWalletLedgerResponse, + ProfilePlayedWorkSummary, + ProfilePlayStatsResponse, ProfileReferralInviteCenterResponse, ProfileSaveArchiveSummary, + ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeResponse, } from '../../../packages/shared/src/contracts/runtime'; @@ -101,6 +103,12 @@ export interface RpgEntryHomeViewProps { onSearchPublicCode?: (keyword: string) => void | Promise; isSearchingPublicCode?: boolean; onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void; + profilePlayStats?: ProfilePlayStatsResponse | null; + isProfilePlayStatsOpen?: boolean; + isProfilePlayStatsLoading?: boolean; + profilePlayStatsError?: string | null; + onCloseProfilePlayStats?: () => void; + onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void; onRechargeSuccess?: () => void | Promise; createTabContent?: ReactNode; } @@ -814,6 +822,21 @@ function formatDashboardUpdatedAt(value: string | null | undefined) { }); } +function formatPlayedWorkType(value: string | null | undefined) { + const normalizedValue = (value ?? '').toLowerCase(); + if (normalizedValue === 'puzzle') { + return '拼图'; + } + if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') { + return '大鱼'; + } + return 'RPG'; +} + +function formatPlayedWorkId(work: ProfilePlayedWorkSummary) { + return work.profileId?.trim() || work.worldKey; +} + function buildPublicUserCode(user: AuthUser | null | undefined) { if (user?.publicUserCode?.trim()) { return user.publicUserCode.trim(); @@ -1248,6 +1271,108 @@ function ProfileReferralModal({ ); } +function ProfilePlayedWorksModal({ + stats, + isLoading, + error, + onClose, + onOpenWork, +}: { + stats: ProfilePlayStatsResponse | null; + isLoading: boolean; + error: string | null; + onClose: () => void; + onOpenWork?: (work: ProfilePlayedWorkSummary) => void; +}) { + const playedWorks = stats?.playedWorks ?? []; + + return ( +
+
+ +
+
+
+ PLAYED +
+
玩过作品
+
+ + {formatCompactPlayTime(stats?.totalPlayTimeMs ?? 0)} +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : playedWorks.length > 0 ? ( +
+ {playedWorks.map((work) => ( + + ))} +
+ ) : ( +
+ 暂无玩过作品 +
+ )} +
+
+
+ ); +} + export function RpgEntryHomeView({ activeTab, onTabChange, @@ -1272,6 +1397,12 @@ export function RpgEntryHomeView({ onSearchPublicCode, isSearchingPublicCode = false, onOpenProfileDashboardCard, + profilePlayStats = null, + isProfilePlayStatsOpen = false, + isProfilePlayStatsLoading = false, + profilePlayStatsError = null, + onCloseProfilePlayStats, + onOpenPlayedWork, onRechargeSuccess, createTabContent, }: RpgEntryHomeViewProps) { @@ -2301,6 +2432,15 @@ export function RpgEntryHomeView({ onSubmitRedeem={submitReferralInviteCode} /> ) : null} + {isProfilePlayStatsOpen ? ( + undefined)} + onOpenWork={onOpenPlayedWork} + /> + ) : null} {isWalletLedgerOpen ? ( ) : null} + {isProfilePlayStatsOpen ? ( + undefined)} + onOpenWork={onOpenPlayedWork} + /> + ) : null} {isWalletLedgerOpen ? ( ( + `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + '记录大鱼吃小鱼游玩失败', + { + retry: BIG_FISH_RUNTIME_WRITE_RETRY, + }, + ); +} diff --git a/src/services/big-fish-runtime/index.ts b/src/services/big-fish-runtime/index.ts index bf43b28c..30187a9e 100644 --- a/src/services/big-fish-runtime/index.ts +++ b/src/services/big-fish-runtime/index.ts @@ -2,3 +2,4 @@ export { advanceLocalBigFishRuntimeRun, startLocalBigFishRuntimeRun, } from './bigFishLocalRuntime'; +export { recordBigFishPlay } from './bigFishRuntimeClient';