Add user played work stats for puzzle and big fish
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user