Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -1,3 +1,798 @@
|
||||
//! 运行时应用编排过渡落位。
|
||||
//!
|
||||
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
|
||||
use crate::domain::*;
|
||||
use crate::errors::RuntimeProfileFieldError;
|
||||
use crate::format_utc_micros;
|
||||
|
||||
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
|
||||
RuntimeSettingsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
music_volume: snapshot.music_volume,
|
||||
platform_theme: snapshot.platform_theme,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_record(
|
||||
snapshot: RuntimeBrowseHistorySnapshot,
|
||||
) -> RuntimeBrowseHistoryRecord {
|
||||
RuntimeBrowseHistoryRecord {
|
||||
browse_history_id: snapshot.browse_history_id,
|
||||
user_id: snapshot.user_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_name: snapshot.world_name,
|
||||
subtitle: snapshot.subtitle,
|
||||
summary_text: snapshot.summary_text,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
theme_mode: snapshot.theme_mode,
|
||||
author_display_name: snapshot.author_display_name,
|
||||
visited_at: format_utc_micros(snapshot.visited_at_micros),
|
||||
visited_at_micros: snapshot.visited_at_micros,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_dashboard_record(
|
||||
snapshot: RuntimeProfileDashboardSnapshot,
|
||||
) -> RuntimeProfileDashboardRecord {
|
||||
RuntimeProfileDashboardRecord {
|
||||
user_id: snapshot.user_id,
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_world_count: snapshot.played_world_count,
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_ledger_entry_record(
|
||||
snapshot: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
) -> RuntimeProfileWalletLedgerEntryRecord {
|
||||
RuntimeProfileWalletLedgerEntryRecord {
|
||||
wallet_ledger_id: snapshot.wallet_ledger_id,
|
||||
user_id: snapshot.user_id,
|
||||
amount_delta: snapshot.amount_delta,
|
||||
balance_after: snapshot.balance_after,
|
||||
source_type: snapshot.source_type,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_center_record(
|
||||
snapshot: RuntimeProfileRechargeCenterSnapshot,
|
||||
) -> RuntimeProfileRechargeCenterRecord {
|
||||
RuntimeProfileRechargeCenterRecord {
|
||||
user_id: snapshot.user_id,
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
membership: build_runtime_profile_membership_record(snapshot.membership),
|
||||
point_products: snapshot
|
||||
.point_products
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_recharge_product_record)
|
||||
.collect(),
|
||||
membership_products: snapshot
|
||||
.membership_products
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_recharge_product_record)
|
||||
.collect(),
|
||||
benefits: snapshot
|
||||
.benefits
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_membership_benefit_record)
|
||||
.collect(),
|
||||
latest_order: snapshot
|
||||
.latest_order
|
||||
.map(build_runtime_profile_recharge_order_record),
|
||||
has_points_recharged: snapshot.has_points_recharged,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_product_record(
|
||||
snapshot: RuntimeProfileRechargeProductSnapshot,
|
||||
) -> RuntimeProfileRechargeProductRecord {
|
||||
RuntimeProfileRechargeProductRecord {
|
||||
product_id: snapshot.product_id,
|
||||
title: snapshot.title,
|
||||
price_cents: snapshot.price_cents,
|
||||
kind: snapshot.kind,
|
||||
points_amount: snapshot.points_amount,
|
||||
bonus_points: snapshot.bonus_points,
|
||||
duration_days: snapshot.duration_days,
|
||||
badge_label: snapshot.badge_label,
|
||||
description: snapshot.description,
|
||||
tier: snapshot.tier,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_membership_benefit_record(
|
||||
snapshot: RuntimeProfileMembershipBenefitSnapshot,
|
||||
) -> RuntimeProfileMembershipBenefitRecord {
|
||||
RuntimeProfileMembershipBenefitRecord {
|
||||
benefit_name: snapshot.benefit_name,
|
||||
normal_value: snapshot.normal_value,
|
||||
month_value: snapshot.month_value,
|
||||
season_value: snapshot.season_value,
|
||||
year_value: snapshot.year_value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_membership_record(
|
||||
snapshot: RuntimeProfileMembershipSnapshot,
|
||||
) -> RuntimeProfileMembershipRecord {
|
||||
RuntimeProfileMembershipRecord {
|
||||
user_id: snapshot.user_id,
|
||||
status: snapshot.status,
|
||||
tier: snapshot.tier,
|
||||
started_at: snapshot.started_at_micros.map(format_utc_micros),
|
||||
started_at_micros: snapshot.started_at_micros,
|
||||
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
|
||||
expires_at_micros: snapshot.expires_at_micros,
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_order_record(
|
||||
snapshot: RuntimeProfileRechargeOrderSnapshot,
|
||||
) -> RuntimeProfileRechargeOrderRecord {
|
||||
RuntimeProfileRechargeOrderRecord {
|
||||
order_id: snapshot.order_id,
|
||||
user_id: snapshot.user_id,
|
||||
product_id: snapshot.product_id,
|
||||
product_title: snapshot.product_title,
|
||||
kind: snapshot.kind,
|
||||
amount_cents: snapshot.amount_cents,
|
||||
status: snapshot.status,
|
||||
payment_channel: snapshot.payment_channel,
|
||||
paid_at: format_utc_micros(snapshot.paid_at_micros),
|
||||
paid_at_micros: snapshot.paid_at_micros,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
points_delta: snapshot.points_delta,
|
||||
membership_expires_at: snapshot.membership_expires_at_micros.map(format_utc_micros),
|
||||
membership_expires_at_micros: snapshot.membership_expires_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_referral_invite_center_record(
|
||||
snapshot: RuntimeReferralInviteCenterSnapshot,
|
||||
) -> RuntimeReferralInviteCenterRecord {
|
||||
RuntimeReferralInviteCenterRecord {
|
||||
user_id: snapshot.user_id,
|
||||
invite_code: snapshot.invite_code,
|
||||
invite_link_path: snapshot.invite_link_path,
|
||||
invited_count: snapshot.invited_count,
|
||||
rewarded_invite_count: snapshot.rewarded_invite_count,
|
||||
today_inviter_reward_count: snapshot.today_inviter_reward_count,
|
||||
today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining,
|
||||
reward_points: snapshot.reward_points,
|
||||
has_redeemed_code: snapshot.has_redeemed_code,
|
||||
bound_inviter_user_id: snapshot.bound_inviter_user_id,
|
||||
bound_at: snapshot.bound_at_micros.map(format_utc_micros),
|
||||
bound_at_micros: snapshot.bound_at_micros,
|
||||
updated_at: format_utc_micros(snapshot.updated_at_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_referral_redeem_record(
|
||||
snapshot: RuntimeReferralRedeemSnapshot,
|
||||
) -> RuntimeReferralRedeemRecord {
|
||||
RuntimeReferralRedeemRecord {
|
||||
center: build_runtime_referral_invite_center_record(snapshot.center),
|
||||
invitee_reward_granted: snapshot.invitee_reward_granted,
|
||||
inviter_reward_granted: snapshot.inviter_reward_granted,
|
||||
invitee_balance_after: snapshot.invitee_balance_after,
|
||||
inviter_balance_after: snapshot.inviter_balance_after,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_reward_code_redeem_record(
|
||||
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
|
||||
) -> RuntimeProfileRewardCodeRedeemRecord {
|
||||
RuntimeProfileRewardCodeRedeemRecord {
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
amount_granted: snapshot.amount_granted,
|
||||
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_record(
|
||||
snapshot: RuntimeProfileRedeemCodeSnapshot,
|
||||
) -> RuntimeProfileRedeemCodeRecord {
|
||||
RuntimeProfileRedeemCodeRecord {
|
||||
code: snapshot.code,
|
||||
mode: snapshot.mode,
|
||||
reward_points: snapshot.reward_points,
|
||||
max_uses: snapshot.max_uses,
|
||||
global_used_count: snapshot.global_used_count,
|
||||
enabled: snapshot.enabled,
|
||||
allowed_user_ids: snapshot.allowed_user_ids,
|
||||
created_by: snapshot.created_by,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at: format_utc_micros(snapshot.updated_at_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_record(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> RuntimeProfilePlayedWorldRecord {
|
||||
RuntimeProfilePlayedWorldRecord {
|
||||
played_world_id: snapshot.played_world_id,
|
||||
user_id: snapshot.user_id,
|
||||
world_key: snapshot.world_key,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_type: snapshot.world_type,
|
||||
world_title: snapshot.world_title,
|
||||
world_subtitle: snapshot.world_subtitle,
|
||||
first_played_at: format_utc_micros(snapshot.first_played_at_micros),
|
||||
first_played_at_micros: snapshot.first_played_at_micros,
|
||||
last_played_at: format_utc_micros(snapshot.last_played_at_micros),
|
||||
last_played_at_micros: snapshot.last_played_at_micros,
|
||||
last_observed_play_time_ms: snapshot.last_observed_play_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_record(
|
||||
snapshot: RuntimeProfilePlayStatsSnapshot,
|
||||
) -> RuntimeProfilePlayStatsRecord {
|
||||
RuntimeProfilePlayStatsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_works: snapshot
|
||||
.played_works
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_played_world_record)
|
||||
.collect(),
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_snapshot_record(
|
||||
snapshot: RuntimeSnapshot,
|
||||
) -> Result<RuntimeSnapshotRecord, RuntimeProfileFieldError> {
|
||||
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
|
||||
let current_story = parse_optional_json_value(
|
||||
snapshot.current_story_json.as_deref(),
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)?;
|
||||
|
||||
Ok(RuntimeSnapshotRecord {
|
||||
user_id: snapshot.user_id,
|
||||
version: snapshot.version,
|
||||
saved_at: format_utc_micros(snapshot.saved_at_micros),
|
||||
saved_at_micros: snapshot.saved_at_micros,
|
||||
bottom_tab: snapshot.bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json: snapshot.game_state_json,
|
||||
current_story_json: snapshot.current_story_json,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_save_archive_record(
|
||||
snapshot: RuntimeProfileSaveArchiveSnapshot,
|
||||
) -> Result<RuntimeProfileSaveArchiveRecord, RuntimeProfileFieldError> {
|
||||
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
|
||||
let current_story = parse_optional_json_value(
|
||||
snapshot.current_story_json.as_deref(),
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)?;
|
||||
|
||||
Ok(RuntimeProfileSaveArchiveRecord {
|
||||
archive_id: snapshot.archive_id,
|
||||
user_id: snapshot.user_id,
|
||||
world_key: snapshot.world_key,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_type: snapshot.world_type,
|
||||
world_name: snapshot.world_name,
|
||||
subtitle: snapshot.subtitle,
|
||||
summary_text: snapshot.summary_text,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
saved_at: format_utc_micros(snapshot.saved_at_micros),
|
||||
saved_at_micros: snapshot.saved_at_micros,
|
||||
bottom_tab: snapshot.bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json: snapshot.game_state_json,
|
||||
current_story_json: snapshot.current_story_json,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_save_checkpoint_update(
|
||||
input: RuntimeSaveCheckpointInput,
|
||||
existing: RuntimeSnapshotRecord,
|
||||
) -> Result<RuntimeSaveCheckpointSnapshotUpdate, RuntimeProfileFieldError> {
|
||||
if is_non_persistent_runtime_snapshot(&existing.game_state) {
|
||||
return Err(RuntimeProfileFieldError::NonPersistentRuntimeSnapshot);
|
||||
}
|
||||
|
||||
let persisted_session_id =
|
||||
read_runtime_json_string_field(&existing.game_state, "runtimeSessionId")
|
||||
.ok_or(RuntimeProfileFieldError::MissingRuntimeSessionId)?;
|
||||
if persisted_session_id != input.session_id {
|
||||
return Err(RuntimeProfileFieldError::RuntimeSessionMismatch {
|
||||
expected_session_id: persisted_session_id,
|
||||
actual_session_id: input.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RuntimeSaveCheckpointSnapshotUpdate {
|
||||
saved_at_micros: input.saved_at_micros,
|
||||
bottom_tab: input.bottom_tab,
|
||||
game_state: refresh_runtime_snapshot_play_time(
|
||||
existing.game_state,
|
||||
input.updated_at_micros,
|
||||
),
|
||||
current_story: existing.current_story,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_id(user_id: &str, world_key: &str) -> String {
|
||||
format!("{}:{}", user_id.trim(), world_key.trim())
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_snapshot_wallet_ledger_id(
|
||||
user_id: &str,
|
||||
saved_at_micros: i64,
|
||||
next_wallet_balance: u64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
user_id.trim(),
|
||||
saved_at_micros,
|
||||
next_wallet_balance
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_save_archive_id(user_id: &str, world_key: &str) -> String {
|
||||
format!("{}:{}", user_id.trim(), world_key.trim())
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_wallet_ledger_id(
|
||||
user_id: &str,
|
||||
created_at_micros: i64,
|
||||
product_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
user_id.trim(),
|
||||
created_at_micros,
|
||||
product_id.trim()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_order_id(
|
||||
user_id: &str,
|
||||
created_at_micros: i64,
|
||||
product_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"recharge:{}",
|
||||
build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_points_recharge_delta(
|
||||
product: &RuntimeProfileRechargeProductSnapshot,
|
||||
has_points_recharged: bool,
|
||||
) -> u64 {
|
||||
let bonus_points = if has_points_recharged {
|
||||
0
|
||||
} else {
|
||||
product.bonus_points
|
||||
};
|
||||
product.points_amount.saturating_add(bonus_points)
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_membership_purchase_update(
|
||||
current_started_at_micros: Option<i64>,
|
||||
current_expires_at_micros: Option<i64>,
|
||||
purchased_at_micros: i64,
|
||||
duration_days: u32,
|
||||
) -> RuntimeProfileMembershipPurchaseUpdate {
|
||||
let start_at_micros = current_expires_at_micros
|
||||
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
|
||||
.unwrap_or(purchased_at_micros);
|
||||
let expires_at_micros = start_at_micros
|
||||
.saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS));
|
||||
|
||||
RuntimeProfileMembershipPurchaseUpdate {
|
||||
started_at_micros: current_started_at_micros.unwrap_or(purchased_at_micros),
|
||||
expires_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_invite_code(user_id: &str, salt: u32) -> String {
|
||||
let mut hash = 14_695_981_039_346_656_037u64;
|
||||
for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(1_099_511_628_211);
|
||||
}
|
||||
format!("SY{:08X}", hash as u32)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_invite_link_path(invite_code: &str) -> String {
|
||||
format!("/?inviteCode={}", invite_code.trim())
|
||||
}
|
||||
|
||||
pub fn runtime_profile_day_start_micros(now_micros: i64) -> i64 {
|
||||
now_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS) * PROFILE_RUNTIME_DAY_MICROS
|
||||
}
|
||||
|
||||
pub fn should_grant_runtime_profile_inviter_reward(today_inviter_reward_count: u32) -> bool {
|
||||
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_referral_invitee_ledger_id(
|
||||
invitee_user_id: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> String {
|
||||
format!("invitee:{}:{}", invitee_user_id.trim(), updated_at_micros)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_referral_inviter_ledger_id(
|
||||
inviter_user_id: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> String {
|
||||
format!("inviter:{}:{}", inviter_user_id.trim(), updated_at_micros)
|
||||
}
|
||||
|
||||
pub fn validate_runtime_profile_redeem_code_usage(
|
||||
code: &RuntimeProfileRedeemCodeSnapshot,
|
||||
user_id: &str,
|
||||
user_used_count: u32,
|
||||
) -> Result<(), RuntimeProfileFieldError> {
|
||||
if !code.enabled {
|
||||
return Err(RuntimeProfileFieldError::RedeemCodeDisabled);
|
||||
}
|
||||
if code.reward_points == 0 {
|
||||
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
|
||||
}
|
||||
|
||||
match code.mode {
|
||||
RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => {
|
||||
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Unique if code.global_used_count >= code.max_uses => {
|
||||
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Private => {
|
||||
if !code.allowed_user_ids.iter().any(|item| item == user_id) {
|
||||
return Err(RuntimeProfileFieldError::RedeemCodeNotAllowedForUser);
|
||||
}
|
||||
if code.global_used_count >= code.max_uses {
|
||||
return Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_usage_id(
|
||||
code: &str,
|
||||
user_id: &str,
|
||||
redeemed_at_micros: i64,
|
||||
sequence: u32,
|
||||
) -> String {
|
||||
format!(
|
||||
"redeem:{}:{}:{}:{}",
|
||||
code.trim(),
|
||||
user_id.trim(),
|
||||
redeemed_at_micros,
|
||||
sequence
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_ledger_id(usage_id: &str) -> String {
|
||||
format!("{}:ledger", usage_id.trim())
|
||||
}
|
||||
|
||||
pub fn convert_runtime_profile_wallet_unsigned_delta(
|
||||
amount_delta: u64,
|
||||
) -> Result<i64, RuntimeProfileFieldError> {
|
||||
i64::try_from(amount_delta).map_err(|_| RuntimeProfileFieldError::WalletAmountOverflow)
|
||||
}
|
||||
|
||||
pub fn calculate_runtime_profile_wallet_balance(
|
||||
previous_balance: u64,
|
||||
amount_delta: i64,
|
||||
) -> Result<u64, RuntimeProfileFieldError> {
|
||||
if amount_delta >= 0 {
|
||||
previous_balance
|
||||
.checked_add(amount_delta as u64)
|
||||
.ok_or(RuntimeProfileFieldError::WalletBalanceOverflow)
|
||||
} else {
|
||||
previous_balance
|
||||
.checked_sub(amount_delta.unsigned_abs())
|
||||
.ok_or(RuntimeProfileFieldError::InsufficientWalletBalance)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
|
||||
let Some(game_state_object) = game_state.as_object_mut() else {
|
||||
return game_state;
|
||||
};
|
||||
let now_text = format_utc_micros(now_micros);
|
||||
let Some(runtime_stats) = game_state_object
|
||||
.get_mut("runtimeStats")
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
game_state_object.insert(
|
||||
"runtimeStats".to_string(),
|
||||
serde_json::json!({
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": now_text,
|
||||
"hostileNpcsDefeated": 0,
|
||||
"questsAccepted": 0,
|
||||
"itemsUsed": 0,
|
||||
"scenesTraveled": 0,
|
||||
}),
|
||||
);
|
||||
return game_state;
|
||||
};
|
||||
|
||||
let current_play_time = runtime_stats
|
||||
.get("playTimeMs")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let elapsed_ms = runtime_stats
|
||||
.get("lastPlayTickAt")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
|
||||
|
||||
// checkpoint 只刷新服务端已有 runtimeStats 的时间水位,不接收浏览器上传的剧情、背包或战斗真相。
|
||||
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
|
||||
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
|
||||
game_state
|
||||
}
|
||||
|
||||
pub fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
read_runtime_json_string_field_from_map(game_state, "runtimeMode").as_deref(),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_world_snapshot_meta(
|
||||
game_state: Option<&serde_json::Map<String, Value>>,
|
||||
) -> Option<RuntimeProfileWorldSnapshotMeta> {
|
||||
let game_state = game_state?;
|
||||
let custom_world_profile = game_state
|
||||
.get("customWorldProfile")
|
||||
.and_then(Value::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let profile_id = read_runtime_json_string_field_from_map(custom_world_profile, "id");
|
||||
let world_title = read_runtime_json_string_field_from_map(custom_world_profile, "name")
|
||||
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"));
|
||||
if profile_id.is_some() || world_title.is_some() {
|
||||
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
|
||||
return Some(RuntimeProfileWorldSnapshotMeta {
|
||||
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_runtime_json_string_field_from_map(
|
||||
custom_world_profile,
|
||||
"summary",
|
||||
)
|
||||
.or_else(|| {
|
||||
read_runtime_json_string_field_from_map(custom_world_profile, "settingText")
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let world_type = read_runtime_json_string_field_from_map(game_state, "worldType")?;
|
||||
let current_scene_preset = game_state
|
||||
.get("currentScenePreset")
|
||||
.and_then(Value::as_object);
|
||||
|
||||
Some(RuntimeProfileWorldSnapshotMeta {
|
||||
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_runtime_json_string_field_from_map(preset, "name"))
|
||||
.unwrap_or_else(|| build_runtime_builtin_world_title(&world_type)),
|
||||
world_subtitle: current_scene_preset
|
||||
.and_then(|preset| {
|
||||
read_runtime_json_string_field_from_map(preset, "summary")
|
||||
.or_else(|| read_runtime_json_string_field_from_map(preset, "description"))
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_save_archive_meta(
|
||||
game_state: &Value,
|
||||
current_story_json: Option<&str>,
|
||||
) -> Option<RuntimeProfileSaveArchiveMeta> {
|
||||
if is_non_persistent_runtime_snapshot(game_state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let game_state_object = game_state.as_object();
|
||||
let world_meta = resolve_runtime_profile_world_snapshot_meta(game_state_object)?;
|
||||
let story_engine_memory = game_state_object
|
||||
.and_then(|state| state.get("storyEngineMemory"))
|
||||
.and_then(Value::as_object);
|
||||
let continue_game_digest = story_engine_memory
|
||||
.and_then(|memory| read_runtime_json_string_field_from_map(memory, "continueGameDigest"));
|
||||
let current_story_text = parse_optional_json_value(
|
||||
current_story_json,
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|story| story.as_object().cloned())
|
||||
.and_then(|story| read_runtime_json_string_field_from_map(&story, "text"));
|
||||
let custom_world_profile = game_state_object
|
||||
.and_then(|state| state.get("customWorldProfile"))
|
||||
.and_then(Value::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let world_name = read_runtime_json_string_field_from_map(custom_world_profile, "name")
|
||||
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"))
|
||||
.unwrap_or_else(|| world_meta.world_title.clone());
|
||||
let subtitle = read_runtime_json_string_field_from_map(custom_world_profile, "summary")
|
||||
.or_else(|| {
|
||||
read_runtime_json_string_field_from_map(custom_world_profile, "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(RuntimeProfileSaveArchiveMeta {
|
||||
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_runtime_json_string_field_from_map(
|
||||
custom_world_profile,
|
||||
"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(Value::as_object);
|
||||
|
||||
Some(RuntimeProfileSaveArchiveMeta {
|
||||
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,
|
||||
summary_text,
|
||||
cover_image_src: current_scene_preset
|
||||
.and_then(|preset| read_runtime_json_string_field_from_map(preset, "imageSrc")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_runtime_json_non_negative_u64(value: Option<&Value>) -> u64 {
|
||||
match value {
|
||||
Some(Value::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(Value::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_runtime_json_string_field(value: &Value, field: &str) -> Option<String> {
|
||||
read_runtime_json_string_field_from_map(value.as_object()?, field)
|
||||
}
|
||||
|
||||
pub fn read_runtime_json_string_field_from_map(
|
||||
value: &serde_json::Map<String, Value>,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
value
|
||||
.get(field)?
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn build_runtime_builtin_world_title(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "武侠世界".to_string(),
|
||||
"XIANXIA" => "仙侠世界".to_string(),
|
||||
_ => "叙事世界".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_json_value(
|
||||
raw: Option<&str>,
|
||||
error: RuntimeProfileFieldError,
|
||||
) -> Result<Option<Value>, RuntimeProfileFieldError> {
|
||||
match raw.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(value) => serde_json::from_str::<Value>(value)
|
||||
.map(Some)
|
||||
.map_err(|_| error),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user