拆分大文件
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user