拆分大文件

This commit is contained in:
2026-04-23 23:38:00 +08:00
parent 53a9cdd791
commit 8df502b2a7
506 changed files with 11312 additions and 13069 deletions

View File

@@ -1 +1,806 @@
// Profile dashboard、wallet 与 played world 投影落位点。
use crate::*;
#[spacetimedb::table(accessor = profile_dashboard_state)]
pub struct ProfileDashboardState {
#[primary_key]
pub(crate) user_id: String,
pub(crate) wallet_balance: u64,
pub(crate) total_play_time_ms: u64,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_wallet_ledger,
index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_wallet_ledger_user_created_at,
btree(columns = [user_id, created_at])
)
)]
pub struct ProfileWalletLedger {
#[primary_key]
pub(crate) wallet_ledger_id: String,
pub(crate) user_id: String,
pub(crate) amount_delta: i64,
pub(crate) balance_after: u64,
pub(crate) source_type: RuntimeProfileWalletLedgerSourceType,
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_played_world,
index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_played_world_user_world_key,
btree(columns = [user_id, world_key])
),
index(
accessor = by_profile_played_world_user_last_played_at,
btree(columns = [user_id, last_played_at])
)
)]
pub struct ProfilePlayedWorld {
#[primary_key]
pub(crate) played_world_id: String,
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) first_played_at: Timestamp,
pub(crate) last_played_at: Timestamp,
pub(crate) last_observed_play_time_ms: u64,
}
#[spacetimedb::table(
accessor = profile_save_archive,
index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_save_archive_user_world_key,
btree(columns = [user_id, world_key])
),
index(
accessor = by_profile_save_archive_user_saved_at,
btree(columns = [user_id, saved_at])
)
)]
pub struct ProfileSaveArchive {
#[primary_key]
pub(crate) archive_id: String,
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_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) saved_at: Timestamp,
pub(crate) bottom_tab: String,
pub(crate) game_state_json: String,
pub(crate) current_story_json: Option<String>,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
// save archive 列表是按世界聚合后的最近一次快照视图,读取时只做排序,不再拼装默认值。
#[spacetimedb::procedure]
pub fn list_profile_save_archives(
ctx: &mut ProcedureContext,
input: RuntimeProfileSaveArchiveListInput,
) -> RuntimeProfileSaveArchiveProcedureResult {
match ctx.try_with_tx(|tx| list_profile_save_archive_rows(tx, input.clone())) {
Ok(entries) => RuntimeProfileSaveArchiveProcedureResult {
ok: true,
entries,
record: None,
current_snapshot: None,
error_message: None,
},
Err(message) => RuntimeProfileSaveArchiveProcedureResult {
ok: false,
entries: Vec::new(),
record: None,
current_snapshot: None,
error_message: Some(message),
},
}
}
// resume 会把指定 archive 回填到当前 snapshot并同步返回 entry + 当前 snapshot。
#[spacetimedb::procedure]
pub fn resume_profile_save_archive_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileSaveArchiveResumeInput,
) -> RuntimeProfileSaveArchiveProcedureResult {
match ctx.try_with_tx(|tx| resume_profile_save_archive_record(tx, input.clone())) {
Ok((record, current_snapshot)) => RuntimeProfileSaveArchiveProcedureResult {
ok: true,
entries: Vec::new(),
record: Some(record),
current_snapshot: Some(current_snapshot),
error_message: None,
},
Err(message) => RuntimeProfileSaveArchiveProcedureResult {
ok: false,
entries: Vec::new(),
record: None,
current_snapshot: None,
error_message: Some(message),
},
}
}
// profile dashboard 当前先作为 projection 读入口返回默认零值,等待 runtime_snapshot 写链补齐刷新。
#[spacetimedb::procedure]
pub fn get_profile_dashboard(
ctx: &mut ProcedureContext,
input: RuntimeProfileDashboardGetInput,
) -> RuntimeProfileDashboardProcedureResult {
match ctx.try_with_tx(|tx| get_profile_dashboard_snapshot(tx, input.clone())) {
Ok(record) => RuntimeProfileDashboardProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileDashboardProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 钱包流水当前只暴露最近 50 条只读视图,排序与截断逻辑在 procedure 内统一收口。
#[spacetimedb::procedure]
pub fn list_profile_wallet_ledger(
ctx: &mut ProcedureContext,
input: RuntimeProfileWalletLedgerListInput,
) -> RuntimeProfileWalletLedgerProcedureResult {
match ctx.try_with_tx(|tx| list_profile_wallet_ledger_entries(tx, input.clone())) {
Ok(entries) => RuntimeProfileWalletLedgerProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileWalletLedgerProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at避免 Axum 侧拼装。
#[spacetimedb::procedure]
pub fn get_profile_play_stats(
ctx: &mut ProcedureContext,
input: RuntimeProfilePlayStatsGetInput,
) -> RuntimeProfilePlayStatsProcedureResult {
match ctx.try_with_tx(|tx| get_profile_play_stats_snapshot(tx, input.clone())) {
Ok(record) => RuntimeProfilePlayStatsProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfilePlayStatsProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput,
) -> Result<Vec<RuntimeProfileSaveArchiveSnapshot>, String> {
let validated_input = build_runtime_profile_save_archive_list_input(input.user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_save_archive()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.map(|row| build_profile_save_archive_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.saved_at_micros
.cmp(&left.saved_at_micros)
.then_with(|| left.archive_id.cmp(&right.archive_id))
});
Ok(entries)
}
pub(crate) fn resume_profile_save_archive_record(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveResumeInput,
) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> {
let validated_input = build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key)
.map_err(|error| error.to_string())?;
let archive = ctx
.db
.profile_save_archive()
.iter()
.find(|row| {
row.user_id == validated_input.user_id && row.world_key == validated_input.world_key
})
.ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?;
let existing_snapshot = ctx
.db
.runtime_snapshot()
.user_id()
.find(&validated_input.user_id);
let created_at = existing_snapshot
.as_ref()
.map(|row| row.created_at)
.unwrap_or(archive.saved_at);
if let Some(existing) = existing_snapshot {
ctx.db
.runtime_snapshot()
.user_id()
.delete(&existing.user_id);
}
ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow {
user_id: archive.user_id.clone(),
version: SAVE_SNAPSHOT_VERSION,
saved_at: archive.saved_at,
bottom_tab: archive.bottom_tab.clone(),
game_state_json: archive.game_state_json.clone(),
current_story_json: archive.current_story_json.clone(),
created_at,
updated_at: archive.saved_at,
});
Ok((
build_profile_save_archive_snapshot_from_row(&archive),
RuntimeSnapshot {
user_id: archive.user_id.clone(),
version: SAVE_SNAPSHOT_VERSION,
saved_at_micros: archive.saved_at.to_micros_since_unix_epoch(),
bottom_tab: archive.bottom_tab.clone(),
game_state_json: archive.game_state_json.clone(),
current_story_json: archive.current_story_json.clone(),
created_at_micros: created_at.to_micros_since_unix_epoch(),
updated_at_micros: archive.saved_at.to_micros_since_unix_epoch(),
},
))
}
pub(crate) fn sync_profile_projections_from_snapshot(
ctx: &ReducerContext,
snapshot: &RuntimeSnapshot,
) -> Result<(), String> {
let game_state = parse_json_str(&snapshot.game_state_json)?;
let game_state_object = game_state.as_object();
let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros);
sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at);
sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?;
Ok(())
}
fn sync_profile_dashboard_from_snapshot(
ctx: &ReducerContext,
snapshot: &RuntimeSnapshot,
game_state: Option<&serde_json::Map<String, JsonValue>>,
saved_at: Timestamp,
) {
let current_state = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&snapshot.user_id);
let previous_wallet_balance = current_state
.as_ref()
.map(|row| row.wallet_balance)
.unwrap_or(0);
let previous_total_play_time_ms = current_state
.as_ref()
.map(|row| row.total_play_time_ms)
.unwrap_or(0);
let next_wallet_balance =
read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency")));
let mut next_total_play_time_ms = previous_total_play_time_ms;
if next_wallet_balance != previous_wallet_balance {
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: format!(
"{}:{}:{}",
snapshot.user_id, snapshot.saved_at_micros, next_wallet_balance
),
user_id: snapshot.user_id.clone(),
amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64,
balance_after: next_wallet_balance,
source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync,
created_at: saved_at,
});
}
if let Some(world_meta) = resolve_profile_world_snapshot_meta(game_state) {
let current_play_time_ms = read_non_negative_u64(
game_state
.and_then(|state| state.get("runtimeStats"))
.and_then(JsonValue::as_object)
.and_then(|stats| stats.get("playTimeMs")),
);
let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key);
let existing = ctx
.db
.profile_played_world()
.played_world_id()
.find(&played_world_id);
let previous_observed_play_time_ms = existing
.as_ref()
.map(|row| row.last_observed_play_time_ms)
.unwrap_or(0);
let incremental_play_time_ms =
current_play_time_ms.saturating_sub(previous_observed_play_time_ms);
next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms);
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: snapshot.user_id.clone(),
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_title: world_meta.world_title,
world_subtitle: world_meta.world_subtitle,
first_played_at: existing.first_played_at,
last_played_at: saved_at,
last_observed_play_time_ms: current_play_time_ms
.max(existing.last_observed_play_time_ms),
});
} else {
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
played_world_id,
user_id: snapshot.user_id.clone(),
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_title: world_meta.world_title,
world_subtitle: world_meta.world_subtitle,
first_played_at: saved_at,
last_played_at: saved_at,
last_observed_play_time_ms: current_play_time_ms,
});
}
}
if let Some(existing) = current_state {
ctx.db
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: existing.created_at,
updated_at: saved_at,
});
} else {
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: saved_at,
updated_at: saved_at,
});
}
}
fn sync_profile_save_archive_from_snapshot(
ctx: &ReducerContext,
snapshot: &RuntimeSnapshot,
game_state: &JsonValue,
saved_at: Timestamp,
) -> Result<(), String> {
let Some(archive_meta) =
resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref())
else {
return Ok(());
};
let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key);
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
let created_at = existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(saved_at);
if let Some(existing) = existing {
ctx.db
.profile_save_archive()
.archive_id()
.delete(&existing.archive_id);
}
ctx.db.profile_save_archive().insert(ProfileSaveArchive {
archive_id,
user_id: snapshot.user_id.clone(),
world_key: archive_meta.world_key,
owner_user_id: archive_meta.owner_user_id,
profile_id: archive_meta.profile_id,
world_type: archive_meta.world_type,
world_name: archive_meta.world_name,
subtitle: archive_meta.subtitle,
summary_text: archive_meta.summary_text,
cover_image_src: archive_meta.cover_image_src,
saved_at,
bottom_tab: snapshot.bottom_tab.clone(),
game_state_json: snapshot.game_state_json.clone(),
current_story_json: snapshot.current_story_json.clone(),
created_at,
updated_at: saved_at,
});
Ok(())
}
#[derive(Clone, Debug)]
struct ProfileWorldSnapshotMeta {
world_key: String,
owner_user_id: Option<String>,
profile_id: Option<String>,
world_type: Option<String>,
world_title: String,
world_subtitle: String,
}
#[derive(Clone, Debug)]
struct ProfileSaveArchiveMeta {
world_key: String,
owner_user_id: Option<String>,
profile_id: Option<String>,
world_type: Option<String>,
world_name: String,
subtitle: String,
summary_text: String,
cover_image_src: Option<String>,
}
pub(crate) fn build_profile_save_archive_snapshot_from_row(
row: &ProfileSaveArchive,
) -> RuntimeProfileSaveArchiveSnapshot {
RuntimeProfileSaveArchiveSnapshot {
archive_id: row.archive_id.clone(),
user_id: row.user_id.clone(),
world_key: row.world_key.clone(),
owner_user_id: row.owner_user_id.clone(),
profile_id: row.profile_id.clone(),
world_type: row.world_type.clone(),
world_name: row.world_name.clone(),
subtitle: row.subtitle.clone(),
summary_text: row.summary_text.clone(),
cover_image_src: row.cover_image_src.clone(),
saved_at_micros: row.saved_at.to_micros_since_unix_epoch(),
bottom_tab: row.bottom_tab.clone(),
game_state_json: row.game_state_json.clone(),
current_story_json: row.current_story_json.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn read_non_negative_u64(value: Option<&JsonValue>) -> u64 {
match value {
Some(JsonValue::Number(number)) => {
if let Some(raw) = number.as_u64() {
raw
} else if let Some(raw) = number.as_i64() {
raw.max(0) as u64
} else if let Some(raw) = number.as_f64() {
if raw.is_finite() && raw > 0.0 {
raw.floor() as u64
} else {
0
}
} else {
0
}
}
Some(JsonValue::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
_ => 0,
}
}
fn read_string_from_json(value: Option<&JsonValue>) -> Option<String> {
value
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn resolve_profile_world_snapshot_meta(
game_state: Option<&serde_json::Map<String, JsonValue>>,
) -> Option<ProfileWorldSnapshotMeta> {
let game_state = game_state?;
let custom_world_profile = game_state
.get("customWorldProfile")
.and_then(JsonValue::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let profile_id = read_string_from_json(custom_world_profile.get("id"));
let world_title = read_string_from_json(custom_world_profile.get("name"))
.or_else(|| read_string_from_json(custom_world_profile.get("title")));
if profile_id.is_some() || world_title.is_some() {
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
return Some(ProfileWorldSnapshotMeta {
world_key: profile_id
.as_ref()
.map(|profile_id| format!("custom:{profile_id}"))
.unwrap_or_else(|| format!("custom:{world_title}")),
owner_user_id: None,
profile_id,
world_type: Some("CUSTOM".to_string()),
world_title,
world_subtitle: read_string_from_json(custom_world_profile.get("summary"))
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
.unwrap_or_default(),
});
}
}
let world_type = read_string_from_json(game_state.get("worldType"))?;
let current_scene_preset = game_state
.get("currentScenePreset")
.and_then(JsonValue::as_object);
Some(ProfileWorldSnapshotMeta {
world_key: format!("builtin:{world_type}"),
owner_user_id: None,
profile_id: None,
world_type: Some(world_type.clone()),
world_title: current_scene_preset
.and_then(|preset| read_string_from_json(preset.get("name")))
.unwrap_or_else(|| build_builtin_world_title(&world_type)),
world_subtitle: current_scene_preset
.and_then(|preset| {
read_string_from_json(preset.get("summary"))
.or_else(|| read_string_from_json(preset.get("description")))
})
.unwrap_or_default(),
})
}
fn resolve_profile_save_archive_meta(
game_state: &JsonValue,
current_story_json: Option<&str>,
) -> Option<ProfileSaveArchiveMeta> {
let game_state_object = game_state.as_object();
let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?;
let story_engine_memory = game_state_object
.and_then(|state| state.get("storyEngineMemory"))
.and_then(JsonValue::as_object);
let continue_game_digest = story_engine_memory
.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
let current_story_text = parse_optional_json_str(current_story_json)
.ok()
.flatten()
.and_then(|story| story.as_object().cloned())
.and_then(|story| read_string_from_json(story.get("text")));
let custom_world_profile = game_state_object
.and_then(|state| state.get("customWorldProfile"))
.and_then(JsonValue::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let world_name = read_string_from_json(custom_world_profile.get("name"))
.or_else(|| read_string_from_json(custom_world_profile.get("title")))
.unwrap_or_else(|| world_meta.world_title.clone());
let subtitle = read_string_from_json(custom_world_profile.get("summary"))
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
.unwrap_or_else(|| world_meta.world_subtitle.clone());
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if subtitle.is_empty() {
None
} else {
Some(subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
return Some(ProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name,
subtitle,
summary_text,
cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")),
});
}
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if world_meta.world_subtitle.is_empty() {
None
} else {
Some(world_meta.world_subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
let current_scene_preset = game_state_object
.and_then(|state| state.get("currentScenePreset"))
.and_then(JsonValue::as_object);
Some(ProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name: world_meta.world_title,
subtitle: world_meta.world_subtitle.clone(),
summary_text,
cover_image_src: current_scene_preset
.and_then(|preset| read_string_from_json(preset.get("imageSrc"))),
})
}
fn build_builtin_world_title(world_type: &str) -> String {
match world_type {
"WUXIA" => "武侠世界".to_string(),
"XIANXIA" => "仙侠世界".to_string(),
_ => "叙事世界".to_string(),
}
}
fn get_profile_dashboard_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileDashboardGetInput,
) -> Result<RuntimeProfileDashboardSnapshot, String> {
let validated_input = build_runtime_profile_dashboard_get_input(input.user_id)
.map_err(|error| error.to_string())?;
let state = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&validated_input.user_id);
let played_world_count = ctx
.db
.profile_played_world()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.count() as u32;
Ok(match state {
Some(existing) => RuntimeProfileDashboardSnapshot {
user_id: existing.user_id,
wallet_balance: existing.wallet_balance,
total_play_time_ms: existing.total_play_time_ms,
played_world_count,
updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()),
},
None => RuntimeProfileDashboardSnapshot {
user_id: validated_input.user_id,
wallet_balance: 0,
total_play_time_ms: 0,
played_world_count,
updated_at_micros: None,
},
})
}
fn list_profile_wallet_ledger_entries(
ctx: &ReducerContext,
input: RuntimeProfileWalletLedgerListInput,
) -> Result<Vec<RuntimeProfileWalletLedgerEntrySnapshot>, String> {
let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_wallet_ledger()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.map(|row| build_profile_wallet_ledger_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.created_at_micros
.cmp(&left.created_at_micros)
.then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id))
});
entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT);
Ok(entries)
}
fn get_profile_play_stats_snapshot(
ctx: &ReducerContext,
input: RuntimeProfilePlayStatsGetInput,
) -> Result<RuntimeProfilePlayStatsSnapshot, String> {
let validated_input = build_runtime_profile_play_stats_get_input(input.user_id)
.map_err(|error| error.to_string())?;
let dashboard_state = ctx
.db
.profile_dashboard_state()
.user_id()
.find(&validated_input.user_id);
let mut played_works = ctx
.db
.profile_played_world()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.map(|row| build_profile_played_world_snapshot_from_row(&row))
.collect::<Vec<_>>();
played_works.sort_by(|left, right| {
right
.last_played_at_micros
.cmp(&left.last_played_at_micros)
.then_with(|| left.played_world_id.cmp(&right.played_world_id))
});
Ok(RuntimeProfilePlayStatsSnapshot {
user_id: validated_input.user_id,
total_play_time_ms: dashboard_state
.as_ref()
.map(|row| row.total_play_time_ms)
.unwrap_or(0),
played_works,
updated_at_micros: dashboard_state
.as_ref()
.map(|row| row.updated_at.to_micros_since_unix_epoch()),
})
}
fn build_profile_wallet_ledger_snapshot_from_row(
row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot {
RuntimeProfileWalletLedgerEntrySnapshot {
wallet_ledger_id: row.wallet_ledger_id.clone(),
user_id: row.user_id.clone(),
amount_delta: row.amount_delta,
balance_after: row.balance_after,
source_type: row.source_type,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_played_world_snapshot_from_row(
row: &ProfilePlayedWorld,
) -> RuntimeProfilePlayedWorldSnapshot {
RuntimeProfilePlayedWorldSnapshot {
played_world_id: row.played_world_id.clone(),
user_id: row.user_id.clone(),
world_key: row.world_key.clone(),
owner_user_id: row.owner_user_id.clone(),
profile_id: row.profile_id.clone(),
world_type: row.world_type.clone(),
world_title: row.world_title.clone(),
world_subtitle: row.world_subtitle.clone(),
first_played_at_micros: row.first_played_at.to_micros_since_unix_epoch(),
last_played_at_micros: row.last_played_at.to_micros_since_unix_epoch(),
last_observed_play_time_ms: row.last_observed_play_time_ms,
}
}