Add user played work stats for puzzle and big fish
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-04-28 12:58:31 +08:00
parent bb4100fca4
commit 377d7d0412
21 changed files with 1028 additions and 82 deletions

View File

@@ -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`

View File

@@ -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 三接口只做:

View File

@@ -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`

View File

@@ -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;

View File

@@ -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,
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,
@@ -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,13 @@ 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/puzzle/agent/sessions",
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(

View File

@@ -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;
@@ -191,6 +193,45 @@ pub async fn delete_big_fish_work(
))
}
pub async fn record_big_fish_play(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<RecordBigFishPlayRequest>, JsonRejection>,
) -> Result<Json<Value>, 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 session = state
.spacetime_client()
.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))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishSessionResponse {
session: map_big_fish_session_response(session),
},
))
}
pub async fn submit_big_fish_message(
State(state): State<AppState>,
Path(session_id): Path<String>,

View File

@@ -316,6 +316,15 @@ pub struct BigFishPublishInput {
pub published_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPlayReportInput {
pub session_id: String,
pub user_id: String,
pub elapsed_ms: u64,
pub reported_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError {
MissingSessionId,
@@ -654,6 +663,16 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_play_report_input(input: &BigFishPlayReportInput) -> Result<(), BigFishFieldError> {
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(())
}
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
serde_json::to_string(anchor_pack)
}
@@ -861,5 +880,4 @@ mod tests {
);
assert!(coverage.blockers.iter().any(|item| item.contains("背景图")));
}
}

View File

@@ -26,6 +26,13 @@ pub struct ExecuteBigFishActionRequest {
pub motion_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RecordBigFishPlayRequest {
#[serde(default)]
pub elapsed_ms: Option<u64>,
}
#[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 }));
}
}

View File

@@ -250,4 +250,28 @@ impl SpacetimeClient {
})
.await
}
pub async fn record_big_fish_play(
&self,
input: BigFishPlayReportRecordInput,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let procedure_input = BigFishPlayReportInput {
session_id: input.session_id,
user_id: input.user_id,
elapsed_ms: input.elapsed_ms,
reported_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(map_big_fish_session_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
}

View File

@@ -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,
@@ -30,10 +31,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,
};

View File

@@ -4211,6 +4211,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,

View File

@@ -0,0 +1,18 @@
// 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 BigFishPlayReportInput {
pub session_id: String,
pub user_id: String,
pub elapsed_ms: u64,
pub reported_at_micros: i64,
}
impl __sdk::InModule for BigFishPlayReportInput {
type Module = super::RemoteModule;
}

View File

@@ -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_report_input_type;
pub mod big_fish_publish_input_type;
pub mod big_fish_runtime_params_type;
pub mod big_fish_session_create_input_type;
@@ -343,9 +344,10 @@ 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 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;
@@ -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_report_input_type::BigFishPlayReportInput;
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;
@@ -812,9 +815,10 @@ 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 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;

View File

@@ -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_report_input_type::BigFishPlayReportInput;
use super::big_fish_session_procedure_result_type::BigFishSessionProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RecordBigFishPlayArgs {
pub input: BigFishPlayReportInput,
}
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: BigFishPlayReportInput) {
self.record_big_fish_play_then(input, |_, _| {});
}
fn record_big_fish_play_then(
&self,
input: BigFishPlayReportInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl record_big_fish_play for super::RemoteProcedures {
fn record_big_fish_play_then(
&self,
input: BigFishPlayReportInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>(
"record_big_fish_play",
RecordBigFishPlayArgs { input },
__callback,
);
}
}

View File

@@ -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;
@@ -150,6 +153,25 @@ pub fn compile_big_fish_draft(
}
}
#[spacetimedb::procedure]
pub fn record_big_fish_play(
ctx: &mut ProcedureContext,
input: BigFishPlayReportInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
pub(crate) fn create_big_fish_session_tx(
ctx: &ReducerContext,
input: BigFishSessionCreateInput,
@@ -544,6 +566,67 @@ pub(crate) fn compile_big_fish_draft_tx(
)
}
pub(crate) fn record_big_fish_play_tx(
ctx: &ReducerContext,
input: BigFishPlayReportInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_play_report_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_creation_session 不存在或尚未发布".to_string())?;
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.reported_at_micros,
},
)?;
add_profile_observed_play_time(
ctx,
&input.user_id,
&world_key,
input.elapsed_ms,
input.reported_at_micros,
)?;
build_big_fish_session_snapshot(ctx, &session)
}
pub(crate) fn build_big_fish_session_snapshot(
ctx: &ReducerContext,
row: &BigFishCreationSession,

View File

@@ -1,12 +1,15 @@
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,
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,
@@ -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<PuzzleGeneratedImageCandidate>,
@@ -1689,12 +1733,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 +1764,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 +1840,8 @@ fn deserialize_run(value: &str) -> Result<PuzzleRunSnapshot, String> {
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]

View File

@@ -83,6 +83,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<String>,
pub(crate) profile_id: Option<String>,
pub(crate) world_type: Option<String>,
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]
@@ -498,6 +509,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,

View File

@@ -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,6 +57,7 @@ import {
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
advanceLocalBigFishRuntimeRun,
recordBigFishPlay,
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import {
@@ -105,6 +108,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 {
@@ -152,6 +156,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',
@@ -429,6 +435,13 @@ export function PlatformEntryFlowShellImpl({
title: string;
publicWorkCode: string;
} | null>(null);
const [bigFishRuntimeWork, setBigFishRuntimeWork] =
useState<BigFishWorkSummary | null>(null);
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
number | null
>(null);
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
useState<BigFishRuntimeSessionSource>(null);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
@@ -461,6 +474,14 @@ export function PlatformEntryFlowShellImpl({
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null
>(null);
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(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 &&
@@ -973,7 +994,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;
@@ -1021,6 +1041,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishWorks([]);
setBigFishRun(null);
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
@@ -1032,6 +1055,9 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleNextLevelGenerating(false);
setPuzzleError(null);
setDeletingCreationWorkId(null);
setProfilePlayStats(null);
setProfilePlayStatsError(null);
setIsProfilePlayStatsOpen(false);
resetRpgSessionViewState();
setRpgGeneratedCustomWorldProfile(null);
setRpgCustomWorldError(null);
@@ -1100,6 +1126,9 @@ export function PlatformEntryFlowShellImpl({
const leaveBigFishFlow = useCallback(() => {
setBigFishRun(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishGenerationState(null);
bigFishFlow.leaveFlow();
}, [bigFishFlow]);
@@ -1136,22 +1165,56 @@ export function PlatformEntryFlowShellImpl({
return;
}
const sessionId = bigFishSession.sessionId;
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('draft');
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishSession, setSelectionStage]);
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
});
}, [bigFishSession, 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) => {
@@ -1241,12 +1304,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) {
@@ -1303,7 +1387,9 @@ export function PlatformEntryFlowShellImpl({
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'));
setPuzzleError(
resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'),
);
})
.finally(() => {
setIsPuzzleLeaderboardBusy(false);
@@ -1673,26 +1759,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(
@@ -1841,6 +1935,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 (
@@ -2096,7 +2302,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();
}
@@ -2349,11 +2567,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}
@@ -2517,17 +2739,17 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
>
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={
isPuzzleBusy ||
isPuzzleNextLevelGenerating ||
isPuzzleLeaderboardBusy
}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={
isPuzzleBusy ||
isPuzzleNextLevelGenerating ||
isPuzzleLeaderboardBusy
}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
onSwapPieces={(payload) => {
void swapPuzzlePiecesInRun(payload);
}}

View File

@@ -34,6 +34,8 @@ import type {
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
@@ -102,6 +104,12 @@ export interface RpgEntryHomeViewProps {
onSearchPublicCode?: (keyword: string) => void | Promise<void>;
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<void>;
createTabContent?: ReactNode;
}
@@ -815,6 +823,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();
@@ -1264,6 +1287,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 (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[34rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭玩过作品"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
PLAYED
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{formatCompactPlayTime(stats?.totalPlayTimeMs ?? 0)}</span>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
{isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : playedWorks.length > 0 ? (
<div className="mt-5 space-y-3">
{playedWorks.map((work) => (
<button
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
{formatPlayedWorkType(work.worldType)}
</span>
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate">
{formatPlayedWorkId(work)}
</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</button>
))}
</div>
) : (
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
</div>
)}
</div>
</div>
</div>
);
}
export function RpgEntryHomeView({
activeTab,
onTabChange,
@@ -1288,6 +1413,12 @@ export function RpgEntryHomeView({
onSearchPublicCode,
isSearchingPublicCode = false,
onOpenProfileDashboardCard,
profilePlayStats = null,
isProfilePlayStatsOpen = false,
isProfilePlayStatsLoading = false,
profilePlayStatsError = null,
onCloseProfilePlayStats,
onOpenPlayedWork,
onRechargeSuccess,
createTabContent,
}: RpgEntryHomeViewProps) {
@@ -2318,6 +2449,15 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
/>
) : null}
</div>
);
}
@@ -2422,6 +2562,15 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type {
BigFishSessionResponse,
RecordBigFishPlayRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
*/
export function recordBigFishPlay(
sessionId: string,
payload: RecordBigFishPlayRequest,
) {
return requestJson<BigFishSessionResponse>(
`/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,
},
);
}

View File

@@ -2,3 +2,4 @@ export {
advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun,
} from './bigFishLocalRuntime';
export { recordBigFishPlay } from './bigFishRuntimeClient';