diff --git a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index 00377a5b..9d6e3d24 100644 --- a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -16,6 +16,16 @@ 6. 启动测试运行态 7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决 +### 1.1 2026-04-27 公开游玩次数补充 + +正式发布的大鱼吃小鱼作品需要记录公开游玩次数,落地口径如下: + +1. `big_fish_creation_session.play_count` 保存该作品被正式启动的次数,默认值为 `0`。 +2. 只有平台作品详情、作品架等正式入口启动已发布作品时递增;创作结果页内的测试运行不计入。 +3. 前端作品摘要 contract 暴露 `playCount`,作品架展示与拼图一致使用该后端值。 +4. 本轮仅记录“进入玩法”次数,不记录大鱼吃小鱼总时长;个人 profile 的 RPG 时长统计仍由 runtime snapshot 负责。 +5. schema 变更需要同步 `migration.rs` 已纳入的 `big_fish_creation_session` 导入导出结构。 + ## 2. 本轮明确不做 1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。 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 ee919e86..b64db9ba 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 @@ -377,6 +377,17 @@ Node 侧入口位于: 这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。 +## 10.1 2026-04-27 统计写链修正 + +`runtime_snapshot / save archive` 主链已接入后,profile projection 的写入语义补充冻结如下: + +1. 正式 RPG 游玩只通过 `PUT /api/runtime/save/snapshot` 刷新 `profile_dashboard_state` 与 `profile_played_world`。 +2. `runtimeMode = "preview"`、`runtimeMode = "test"` 或 `runtimePersistenceDisabled = true` 的快照不刷新 profile projection。 +3. 前端发起自动保存与手动保存前,必须先把 `runtimeStats.lastPlayTickAt` 到当前时间的 live 时长同步进 `runtimeStats.playTimeMs`,避免 15 秒内进入又退出时保存 0。 +4. `profile_played_world` 的一行表示“当前用户玩过这个世界”,不是全站作品热度计数;`playedWorldCount` 读取当前用户的去重世界数。 +5. `profile_dashboard_state.total_play_time_ms` 通过同一用户同一世界的 `runtimeStats.playTimeMs - last_observed_play_time_ms` 增量累积,后端使用 `saturating_sub` 防止旧快照回退导致负增量。 +6. 作品卡上的公开热度计数如果需要覆盖 RPG 作品,应另立公开作品统计方案;不能把个人 `profile_played_world` 误当成全站作品 `playCount`。 + ## 11. 测试策略 ### 11.1 必跑 diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index 30ec15f1..21b7f2a3 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -15,6 +15,7 @@ export interface BigFishWorkSummary { levelMainImageReadyCount: number; levelMotionReadyCount: number; backgroundReady: boolean; + playCount?: number; } export interface BigFishWorksResponse { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d732ca65..28008d2f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -35,7 +35,7 @@ use crate::{ 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, - submit_big_fish_message, + record_big_fish_play, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -83,8 +83,7 @@ use crate::{ get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, - submit_puzzle_leaderboard, - swap_puzzle_pieces, + submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -575,6 +574,10 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/works/{session_id}/play", + post(record_big_fish_play), + ) .route( "/api/runtime/puzzle/agent/sessions", post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index b5f804e3..3fa14974 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -191,6 +191,32 @@ pub async fn delete_big_fish_work( )) } +pub async fn record_big_fish_play( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let items = state + .spacetime_client() + .record_big_fish_play(session_id, current_utc_micros()) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(map_big_fish_work_summary_response) + .collect(), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, @@ -924,6 +950,7 @@ fn map_big_fish_work_summary_response( level_main_image_ready_count: item.level_main_image_ready_count, level_motion_ready_count: item.level_motion_ready_count, background_ready: item.background_ready, + play_count: item.play_count, } } diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index adeec338..778d937b 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -221,6 +221,7 @@ pub struct BigFishWorkSummarySnapshot { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + pub play_count: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -316,6 +317,13 @@ pub struct BigFishPublishInput { pub published_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishPlayRecordInput { + pub session_id: String, + pub played_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum BigFishFieldError { MissingSessionId, @@ -654,6 +662,13 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish validate_session_owner(&input.session_id, &input.owner_user_id) } +pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> { + if normalize_required_string(&input.session_id).is_none() { + return Err(BigFishFieldError::MissingSessionId); + } + Ok(()) +} + pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result { serde_json::to_string(anchor_pack) } @@ -861,5 +876,4 @@ mod tests { ); assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); } - } diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 448db2c6..28c7ddf7 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -1964,14 +1964,18 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { let cleared_at_ms = current_unix_ms(); current_level.cleared_at_ms = Some(cleared_at_ms); - current_level.elapsed_ms = - Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000)); + current_level.elapsed_ms = Some( + cleared_at_ms + .saturating_sub(current_level.started_at_ms) + .max(1_000), + ); } current_level.status = next_level_status; } - if is_cleared && run.current_level.as_ref().map(|level| level.status) - != Some(PuzzleRuntimeLevelStatus::Cleared) + if is_cleared + && run.current_level.as_ref().map(|level| level.status) + != Some(PuzzleRuntimeLevelStatus::Cleared) { next_run.cleared_level_count += 1; } diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index 1f876bf7..b44cd94a 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + #[serde(default)] + pub play_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 758f18f3..5544f606 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -1,6 +1,7 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; +use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -131,6 +132,30 @@ 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(map_big_fish_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_big_fish_message( &self, input: BigFishMessageSubmitRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f990651d..fea844c4 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,10 +30,10 @@ pub use mapper::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 3e32f9fe..92e78bbb 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4606,6 +4606,7 @@ pub struct BigFishWorkSummaryRecord { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + pub play_count: u32, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs index 353343fd..a760fea0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs @@ -20,6 +20,7 @@ pub struct BigFishCreationSession { pub asset_coverage_json: String, pub last_assistant_reply: Option, pub publish_ready: bool, + pub play_count: u32, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -43,6 +44,7 @@ pub struct BigFishCreationSessionCols { pub asset_coverage_json: __sdk::__query_builder::Col, pub last_assistant_reply: __sdk::__query_builder::Col>, pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession { "last_assistant_reply", ), publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), } 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 new file mode 100644 index 00000000..dc9ec79b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs @@ -0,0 +1,16 @@ +// 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 BigFishPlayRecordInput { + pub session_id: String, + pub played_at_micros: i64, +} + +impl __sdk::InModule for BigFishPlayRecordInput { + type Module = super::RemoteModule; +} 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 76bf4e34..386e98f4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -89,6 +89,7 @@ pub mod big_fish_game_draft_type; pub mod big_fish_level_blueprint_type; pub mod big_fish_message_finalize_input_type; pub mod big_fish_message_submit_input_type; +pub mod big_fish_play_record_input_type; pub mod big_fish_publish_input_type; pub mod big_fish_runtime_params_type; pub mod big_fish_session_create_input_type; @@ -331,6 +332,7 @@ 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; @@ -558,6 +560,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft; pub use big_fish_level_blueprint_type::BigFishLevelBlueprint; pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput; pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput; +pub use big_fish_play_record_input_type::BigFishPlayRecordInput; pub use big_fish_publish_input_type::BigFishPublishInput; pub use big_fish_runtime_params_type::BigFishRuntimeParams; pub use big_fish_session_create_input_type::BigFishSessionCreateInput; @@ -800,6 +803,7 @@ 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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs new file mode 100644 index 00000000..f4cfaa6b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs @@ -0,0 +1,59 @@ +// 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::big_fish_play_record_input_type::BigFishPlayRecordInput; +use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordBigFishPlayArgs { + pub input: BigFishPlayRecordInput, +} + +impl __sdk::InModule for RecordBigFishPlayArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_big_fish_play`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_big_fish_play { + fn record_big_fish_play(&self, input: BigFishPlayRecordInput) { + self.record_big_fish_play_then(input, |_, _| {}); + } + + fn record_big_fish_play_then( + &self, + input: BigFishPlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_big_fish_play for super::RemoteProcedures { + fn record_big_fish_play_then( + &self, + input: BigFishPlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>( + "record_big_fish_play", + RecordBigFishPlayArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index c3c09287..9636ed13 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -478,15 +478,14 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection.procedures().submit_puzzle_leaderboard_entry_then( - procedure_input, - move |_, result| { + connection + .procedures() + .submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_puzzle_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index 3e68e92f..ed97fd62 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), publish_ready: true, + play_count: session.play_count, created_at: session.created_at, updated_at: published_at, }; 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 01459d39..0c0faa92 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -93,6 +93,32 @@ pub fn delete_big_fish_work( } } +#[spacetimedb::procedure] +pub fn record_big_fish_play( + ctx: &mut ProcedureContext, + input: BigFishPlayRecordInput, +) -> BigFishWorksProcedureResult { + match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) { + Ok(items) => match serde_json::to_string(&items) { + Ok(items_json) => BigFishWorksProcedureResult { + ok: true, + items_json: Some(items_json), + error_message: None, + }, + Err(error) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(error.to_string()), + }, + }, + Err(message) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_big_fish_message( ctx: &mut ProcedureContext, @@ -194,6 +220,7 @@ pub(crate) fn create_big_fish_session_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(input.welcome_message_text.clone()), publish_ready: false, + play_count: 0, created_at, updated_at: created_at, }); @@ -383,6 +410,7 @@ pub(crate) fn submit_big_fish_message_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at: submitted_at, }; @@ -429,6 +457,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -483,6 +512,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: Some(assistant_reply_text), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -530,6 +560,7 @@ pub(crate) fn compile_big_fish_draft_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at: compiled_at, }; @@ -657,9 +688,51 @@ pub(crate) fn build_big_fish_work_summary( level_main_image_ready_count: coverage.level_main_image_ready_count, level_motion_ready_count: coverage.level_motion_ready_count, background_ready: coverage.background_ready, + play_count: row.play_count, }) } +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, @@ -693,6 +766,7 @@ mod tests { asset_coverage_json: "{}".to_string(), last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), publish_ready: false, + play_count: 0, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index 1e280ef6..7e82cd91 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -17,6 +17,7 @@ pub struct BigFishCreationSession { pub(crate) asset_coverage_json: String, pub(crate) last_assistant_reply: Option, pub(crate) publish_ready: bool, + pub(crate) play_count: u32, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 37a52649..f2f6be5b 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -659,6 +659,19 @@ where Ok(wrapped.0) } +fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { + let mut next_value = value.clone(); + if table_name == "big_fish_creation_session" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 + object + .entry("play_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + } + } + next_value +} + fn insert_migration_table_rows( ctx: &ReducerContext, table: &MigrationTable, @@ -672,7 +685,8 @@ fn insert_migration_table_rows( let mut imported = 0u64; let mut skipped = 0u64; for value in &table.rows { - let row = row_from_json(value) + let normalized_value = normalize_migration_row(stringify!($table), value); + let row = row_from_json(&normalized_value) .map_err(|error| format!("{}: {error}", stringify!($table)))?; let insert_result = ctx.db .$table() diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a1087aa0..7c27aba7 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -3,10 +3,10 @@ use module_puzzle::{ PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, - PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, - PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, + PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, + PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, + PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, @@ -1689,12 +1689,7 @@ fn upsert_puzzle_leaderboard_entry( ) { let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size); let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); - if let Some(existing) = ctx - .db - .puzzle_leaderboard_entry() - .entry_id() - .find(&entry_id) - { + if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) { let should_replace = elapsed_ms < existing.best_elapsed_ms || (elapsed_ms == existing.best_elapsed_ms && updated_at.to_micros_since_unix_epoch() @@ -1725,16 +1720,18 @@ fn upsert_puzzle_leaderboard_entry( return; } - ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow { - entry_id, - profile_id: profile_id.to_string(), - grid_size, - user_id: user_id.to_string(), - nickname: nickname.to_string(), - best_elapsed_ms: elapsed_ms, - last_run_id: run_id.to_string(), - updated_at, - }); + ctx.db + .puzzle_leaderboard_entry() + .insert(PuzzleLeaderboardEntryRow { + entry_id, + profile_id: profile_id.to_string(), + grid_size, + user_id: user_id.to_string(), + nickname: nickname.to_string(), + best_elapsed_ms: elapsed_ms, + last_run_id: run_id.to_string(), + updated_at, + }); } fn list_puzzle_leaderboard_entries( @@ -1799,8 +1796,8 @@ fn deserialize_run(value: &str) -> Result { mod tests { use super::*; use module_puzzle::{ - build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score, - PuzzleLeaderboardEntry, + PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack, + recommendation_score, tag_similarity_score, }; #[test] diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 6a0c2c3b..1fa56e98 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem( id: 'level-motion-ready-count', label: `动作 ${item.levelMotionReadyCount}`, }, + { id: 'play-count', label: `游玩 ${item.playCount ?? 0}` }, ...(item.backgroundReady ? [ { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0e321392..4af6f0f1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -60,6 +60,7 @@ import { import { deleteBigFishWork, listBigFishWorks, + recordBigFishWorkPlay, } from '../../services/big-fish-works'; import { readCustomWorldAgentUiState, @@ -91,6 +92,7 @@ import { } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, + startPuzzleRun, submitPuzzleLeaderboard, } from '../../services/puzzle-runtime'; import { @@ -1147,11 +1149,21 @@ export function PlatformEntryFlowShellImpl({ return; } - setBigFishError(null); - setBigFishRuntimeShare(null); - setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); - setSelectionStage('big-fish-runtime'); - }, [bigFishSession, setSelectionStage]); + 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, '启动大鱼吃小鱼玩法失败。')); + }); + }, [bigFishSession, refreshBigFishShelf, resolveBigFishErrorMessage, setSelectionStage]); const restartBigFishRun = useCallback(() => { if (!bigFishSession && !bigFishRun) { @@ -1175,8 +1187,9 @@ export function PlatformEntryFlowShellImpl({ try { const { item } = await getPuzzleGalleryDetail(profileId); + const { run } = await startPuzzleRun({ profileId: item.profileId }); setSelectedPuzzleDetail(item); - setPuzzleRun(startLocalPuzzleRun(item)); + setPuzzleRun(run); setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setSelectionStage('puzzle-runtime'); pushAppHistoryPath( diff --git a/src/hooks/rpg-session/useRpgSessionPersistence.ts b/src/hooks/rpg-session/useRpgSessionPersistence.ts index 00ccfe2b..3836af74 100644 --- a/src/hooks/rpg-session/useRpgSessionPersistence.ts +++ b/src/hooks/rpg-session/useRpgSessionPersistence.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { syncGameStatePlayTime } from '../../data/runtimeStats'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { isAbortError } from '../../services/apiClient'; import { rpgSnapshotClient } from '../../services/rpg-runtime'; @@ -37,6 +38,10 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) { }; } +function buildPersistedGameState(gameState: GameState) { + return syncGameStatePlayTime(gameState); +} + export type UseRpgSessionPersistenceParams = { authenticatedUserId: string | null; gameState: GameState; @@ -208,9 +213,10 @@ export function useRpgSessionPersistence({ if (!canPersist) return; const timeoutId = window.setTimeout(() => { + const persistedGameState = buildPersistedGameState(gameState); void persistSnapshot({ payload: { - gameState, + gameState: persistedGameState, bottomTab, currentStory, }, @@ -235,9 +241,10 @@ export function useRpgSessionPersistence({ return false; } + const persistedGameState = buildPersistedGameState(nextGameState); const snapshot = await persistSnapshot({ payload: { - gameState: nextGameState, + gameState: persistedGameState, bottomTab: nextBottomTab, currentStory: nextStory, }, diff --git a/src/hooks/runtimeAuthGuards.test.tsx b/src/hooks/runtimeAuthGuards.test.tsx index 42100d03..5b8872d4 100644 --- a/src/hooks/runtimeAuthGuards.test.tsx +++ b/src/hooks/runtimeAuthGuards.test.tsx @@ -161,3 +161,68 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => { expect(screen.getByTestId('saved-game').textContent).toBe('no'); expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled(); }); + +test('authenticated runtime autosave syncs live play time before remote snapshot upload', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-27T10:00:02.000Z')); + storageMocks.putSaveSnapshot.mockResolvedValue({ + gameState: {}, + bottomTab: 'adventure', + currentStory: null, + }); + + const gameState = { + runtimePersistenceDisabled: false, + runtimeMode: 'play', + currentScene: 'Story', + worldType: 'CUSTOM', + playerCharacter: { id: 'hero-1' }, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: '2026-04-27T10:00:00.000Z', + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + } as GameState; + + function AutosaveHarness() { + useRpgSessionPersistence({ + authenticatedUserId: 'user-1', + gameState, + bottomTab: 'adventure' as BottomTab, + currentStory: { streaming: false } as StoryMoment, + isLoading: false, + setGameState: () => {}, + setBottomTab: () => {}, + hydrateStoryState: () => {}, + resetStoryState: () => {}, + }); + + return null; + } + + render(); + + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(storageMocks.putSaveSnapshot).toHaveBeenCalledTimes(1); + expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: expect.objectContaining({ + runtimeStats: expect.objectContaining({ + playTimeMs: 2400, + lastPlayTickAt: '2026-04-27T10:00:02.400Z', + }), + }), + }), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); +}); diff --git a/src/services/big-fish-works/bigFishWorksClient.ts b/src/services/big-fish-works/bigFishWorksClient.ts index 54392319..66cadc82 100644 --- a/src/services/big-fish-works/bigFishWorksClient.ts +++ b/src/services/big-fish-works/bigFishWorksClient.ts @@ -46,7 +46,24 @@ export async function deleteBigFishWork(sessionId: string) { ); } +/** + * 记录已发布大鱼吃小鱼作品的一次正式进入。 + */ +export async function recordBigFishWorkPlay(sessionId: string) { + return requestJson( + `${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}/play`, + { + method: 'POST', + }, + '记录大鱼吃小鱼游玩次数失败', + { + retry: BIG_FISH_WORKS_WRITE_RETRY, + }, + ); +} + export const bigFishWorksClient = { delete: deleteBigFishWork, list: listBigFishWorks, + recordPlay: recordBigFishWorkPlay, }; diff --git a/src/services/big-fish-works/index.ts b/src/services/big-fish-works/index.ts index 4b1b6798..2ab2252a 100644 --- a/src/services/big-fish-works/index.ts +++ b/src/services/big-fish-works/index.ts @@ -2,4 +2,5 @@ export { bigFishWorksClient, deleteBigFishWork, listBigFishWorks, + recordBigFishWorkPlay, } from './bigFishWorksClient';