# Conflicts: # docs/technical/README.md # docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md # docs/technical/SPACETIMEDB_TABLE_CATALOG.md # scripts/generate-spacetime-bindings.mjs # server-rs/crates/api-server/src/app.rs # server-rs/crates/api-server/src/assets.rs # server-rs/crates/api-server/src/big_fish.rs # server-rs/crates/api-server/src/custom_world_ai.rs # server-rs/crates/api-server/src/llm.rs # server-rs/crates/api-server/src/main.rs # server-rs/crates/api-server/src/puzzle.rs # server-rs/crates/api-server/src/runtime_profile.rs # server-rs/crates/api-server/src/runtime_story/compat/ai.rs # server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs # server-rs/crates/api-server/src/runtime_story/compat/presentation.rs # server-rs/crates/api-server/src/runtime_story/compat/tests.rs # server-rs/crates/api-server/src/state.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/module-big-fish/src/lib.rs # server-rs/crates/module-custom-world/src/lib.rs # server-rs/crates/module-puzzle/src/lib.rs # server-rs/crates/module-runtime/src/lib.rs # server-rs/crates/spacetime-client/src/big_fish.rs # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs # server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/mod.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # server-rs/crates/spacetime-module/src/custom_world/mod.rs # server-rs/crates/spacetime-module/src/lib.rs # server-rs/crates/spacetime-module/src/migration.rs # server-rs/crates/spacetime-module/src/puzzle.rs # server-rs/crates/spacetime-module/src/runtime/profile.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx # src/services/aiService.ts # src/services/puzzle-runtime/puzzleRuntimeClient.ts
651 lines
22 KiB
Rust
651 lines
22 KiB
Rust
mod application;
|
|
mod commands;
|
|
mod domain;
|
|
mod errors;
|
|
mod events;
|
|
|
|
pub use application::*;
|
|
pub use commands::*;
|
|
pub use domain::*;
|
|
pub use errors::*;
|
|
|
|
use shared_kernel::format_rfc3339 as format_shared_rfc3339;
|
|
use time::OffsetDateTime;
|
|
|
|
pub fn format_utc_micros(micros: i64) -> String {
|
|
let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000)
|
|
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
|
format_shared_rfc3339(timestamp).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
|
}
|
|
|
|
pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargeProductSnapshot> {
|
|
vec![
|
|
build_points_recharge_product(
|
|
"points_60",
|
|
"60叙世币",
|
|
600,
|
|
60,
|
|
60,
|
|
"首充双倍",
|
|
"首充送60叙世币",
|
|
),
|
|
build_points_recharge_product(
|
|
"points_180",
|
|
"180叙世币",
|
|
1800,
|
|
180,
|
|
180,
|
|
"首充双倍",
|
|
"首充送180叙世币",
|
|
),
|
|
build_points_recharge_product(
|
|
"points_300",
|
|
"300叙世币",
|
|
3000,
|
|
300,
|
|
300,
|
|
"首充双倍",
|
|
"首充送300叙世币",
|
|
),
|
|
build_points_recharge_product(
|
|
"points_680",
|
|
"680叙世币",
|
|
6800,
|
|
680,
|
|
680,
|
|
"首充双倍",
|
|
"首充送680叙世币",
|
|
),
|
|
build_points_recharge_product(
|
|
"points_1280",
|
|
"1280叙世币",
|
|
12800,
|
|
1280,
|
|
1280,
|
|
"首充双倍",
|
|
"首充送1280叙世币",
|
|
),
|
|
build_points_recharge_product(
|
|
"points_3280",
|
|
"3280叙世币",
|
|
32800,
|
|
3280,
|
|
3280,
|
|
"首充双倍",
|
|
"首充送3280叙世币",
|
|
),
|
|
]
|
|
}
|
|
|
|
pub fn runtime_profile_recharge_membership_products() -> Vec<RuntimeProfileRechargeProductSnapshot>
|
|
{
|
|
vec![
|
|
build_membership_recharge_product(
|
|
"member_month",
|
|
"月卡",
|
|
2800,
|
|
30,
|
|
RuntimeProfileMembershipTier::Month,
|
|
),
|
|
build_membership_recharge_product(
|
|
"member_season",
|
|
"季卡",
|
|
7800,
|
|
90,
|
|
RuntimeProfileMembershipTier::Season,
|
|
),
|
|
build_membership_recharge_product(
|
|
"member_year",
|
|
"年卡",
|
|
24800,
|
|
365,
|
|
RuntimeProfileMembershipTier::Year,
|
|
),
|
|
]
|
|
}
|
|
|
|
pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBenefitSnapshot> {
|
|
vec![
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "特权名称".to_string(),
|
|
normal_value: "普通".to_string(),
|
|
month_value: "月卡".to_string(),
|
|
season_value: "季卡".to_string(),
|
|
year_value: "年卡".to_string(),
|
|
},
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "免费".to_string(),
|
|
normal_value: "免费".to_string(),
|
|
month_value: "¥28".to_string(),
|
|
season_value: "¥78".to_string(),
|
|
year_value: "¥248".to_string(),
|
|
},
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "免叙世币回合数".to_string(),
|
|
normal_value: "30".to_string(),
|
|
month_value: "100".to_string(),
|
|
season_value: "100".to_string(),
|
|
year_value: "100".to_string(),
|
|
},
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "每日签到加成".to_string(),
|
|
normal_value: "0%".to_string(),
|
|
month_value: "0%".to_string(),
|
|
season_value: "+100%".to_string(),
|
|
year_value: "+210%".to_string(),
|
|
},
|
|
]
|
|
}
|
|
|
|
pub fn runtime_profile_recharge_product_by_id(
|
|
product_id: &str,
|
|
) -> Option<RuntimeProfileRechargeProductSnapshot> {
|
|
runtime_profile_recharge_point_products()
|
|
.into_iter()
|
|
.chain(runtime_profile_recharge_membership_products())
|
|
.find(|product| product.product_id == product_id)
|
|
}
|
|
|
|
fn build_points_recharge_product(
|
|
product_id: &str,
|
|
title: &str,
|
|
price_cents: u64,
|
|
points_amount: u64,
|
|
bonus_points: u64,
|
|
badge_label: &str,
|
|
description: &str,
|
|
) -> RuntimeProfileRechargeProductSnapshot {
|
|
RuntimeProfileRechargeProductSnapshot {
|
|
product_id: product_id.to_string(),
|
|
title: title.to_string(),
|
|
price_cents,
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
points_amount,
|
|
bonus_points,
|
|
duration_days: 0,
|
|
badge_label: badge_label.to_string(),
|
|
description: description.to_string(),
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
}
|
|
}
|
|
|
|
fn build_membership_recharge_product(
|
|
product_id: &str,
|
|
title: &str,
|
|
price_cents: u64,
|
|
duration_days: u32,
|
|
tier: RuntimeProfileMembershipTier,
|
|
) -> RuntimeProfileRechargeProductSnapshot {
|
|
RuntimeProfileRechargeProductSnapshot {
|
|
product_id: product_id.to_string(),
|
|
title: title.to_string(),
|
|
price_cents,
|
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
|
points_amount: 0,
|
|
bonus_points: 0,
|
|
duration_days,
|
|
badge_label: String::new(),
|
|
description: format!("{}天会员", duration_days),
|
|
tier,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn defaults_match_shared_contract_baseline() {
|
|
let settings = RuntimeSettings::defaults();
|
|
|
|
assert!((settings.music_volume - DEFAULT_MUSIC_VOLUME).abs() < f32::EPSILON);
|
|
assert_eq!(settings.platform_theme, RuntimePlatformTheme::Light);
|
|
}
|
|
|
|
#[test]
|
|
fn normalized_clamps_music_volume_into_valid_range() {
|
|
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
|
let high = RuntimeSettings::normalized(3.5, RuntimePlatformTheme::Dark);
|
|
|
|
assert_eq!(low.music_volume, 0.0);
|
|
assert_eq!(high.music_volume, 1.0);
|
|
assert_eq!(high.platform_theme, RuntimePlatformTheme::Dark);
|
|
}
|
|
|
|
#[test]
|
|
fn theme_from_client_string_falls_back_to_light() {
|
|
assert_eq!(
|
|
RuntimePlatformTheme::from_client_str("dark"),
|
|
RuntimePlatformTheme::Dark
|
|
);
|
|
assert_eq!(
|
|
RuntimePlatformTheme::from_client_str("LIGHT"),
|
|
RuntimePlatformTheme::Light
|
|
);
|
|
assert_eq!(
|
|
RuntimePlatformTheme::from_client_str("mythic"),
|
|
RuntimePlatformTheme::Light
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_upsert_input_rejects_blank_user_id() {
|
|
let error = build_runtime_setting_upsert_input(
|
|
" ".to_string(),
|
|
DEFAULT_MUSIC_VOLUME,
|
|
RuntimePlatformTheme::Light,
|
|
1,
|
|
)
|
|
.expect_err("blank user id should fail");
|
|
|
|
assert_eq!(error, RuntimeSettingsFieldError::MissingUserId);
|
|
}
|
|
|
|
#[test]
|
|
fn browse_history_theme_from_client_string_falls_back_to_mythic() {
|
|
assert_eq!(
|
|
RuntimeBrowseHistoryThemeMode::from_client_str("martial"),
|
|
RuntimeBrowseHistoryThemeMode::Martial
|
|
);
|
|
assert_eq!(
|
|
RuntimeBrowseHistoryThemeMode::from_client_str("RIFT"),
|
|
RuntimeBrowseHistoryThemeMode::Rift
|
|
);
|
|
assert_eq!(
|
|
RuntimeBrowseHistoryThemeMode::from_client_str("unknown"),
|
|
RuntimeBrowseHistoryThemeMode::Mythic
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_browse_history_sync_input_normalizes_optionals_and_visited_at() {
|
|
let input = build_runtime_browse_history_sync_input(
|
|
" user-1 ".to_string(),
|
|
vec![RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: " owner-a ".to_string(),
|
|
profile_id: " profile-a ".to_string(),
|
|
world_name: " 世界A ".to_string(),
|
|
subtitle: Some(" ".to_string()),
|
|
summary_text: Some(" 简介 ".to_string()),
|
|
cover_image_src: Some(" /cover.png ".to_string()),
|
|
theme_mode: Some(" arcane ".to_string()),
|
|
author_display_name: Some(" ".to_string()),
|
|
visited_at: None,
|
|
}],
|
|
1_713_680_000_000_000,
|
|
)
|
|
.expect("sync input should build");
|
|
|
|
assert_eq!(input.user_id, "user-1");
|
|
assert_eq!(input.entries.len(), 1);
|
|
assert_eq!(input.entries[0].owner_user_id, "owner-a");
|
|
assert_eq!(input.entries[0].profile_id, "profile-a");
|
|
assert_eq!(input.entries[0].world_name, "世界A");
|
|
assert_eq!(input.entries[0].subtitle, None);
|
|
assert_eq!(input.entries[0].summary_text, Some("简介".to_string()));
|
|
assert_eq!(
|
|
input.entries[0].cover_image_src,
|
|
Some("/cover.png".to_string())
|
|
);
|
|
assert_eq!(input.entries[0].theme_mode, Some("arcane".to_string()));
|
|
assert_eq!(input.entries[0].author_display_name, None);
|
|
assert_eq!(
|
|
input.entries[0].visited_at,
|
|
Some("2024-04-21T06:13:20Z".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_browse_history_entries_sorts_desc_and_dedups_by_owner_profile() {
|
|
let entries = prepare_runtime_browse_history_entries(RuntimeBrowseHistorySyncInput {
|
|
user_id: "user-1".to_string(),
|
|
entries: vec![
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-a".to_string(),
|
|
profile_id: "profile-a".to_string(),
|
|
world_name: "世界旧".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: Some("martial".to_string()),
|
|
author_display_name: None,
|
|
visited_at: Some("2026-04-20T10:00:00Z".to_string()),
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-b".to_string(),
|
|
profile_id: "profile-b".to_string(),
|
|
world_name: "世界B".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: Some("rift".to_string()),
|
|
author_display_name: Some("作者B".to_string()),
|
|
visited_at: Some("2026-04-21T10:00:00Z".to_string()),
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-a".to_string(),
|
|
profile_id: "profile-a".to_string(),
|
|
world_name: "世界新".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: Some("unknown".to_string()),
|
|
author_display_name: Some("".to_string()),
|
|
visited_at: Some("2026-04-21T11:00:00Z".to_string()),
|
|
},
|
|
],
|
|
updated_at_micros: 1_776_000_000_000_000,
|
|
})
|
|
.expect("entries should prepare");
|
|
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(entries[0].world_name, "世界新");
|
|
assert_eq!(entries[0].theme_mode, RuntimeBrowseHistoryThemeMode::Mythic);
|
|
assert_eq!(
|
|
entries[0].author_display_name,
|
|
DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME
|
|
);
|
|
assert_eq!(entries[1].world_name, "世界B");
|
|
assert!(entries[0].visited_at_micros > entries[1].visited_at_micros);
|
|
}
|
|
|
|
#[test]
|
|
fn build_browse_history_sync_input_silently_filters_invalid_entries() {
|
|
let input = build_runtime_browse_history_sync_input(
|
|
"user-1".to_string(),
|
|
vec![
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: " ".to_string(),
|
|
profile_id: "profile-a".to_string(),
|
|
world_name: "世界A".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: None,
|
|
author_display_name: None,
|
|
visited_at: None,
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-b".to_string(),
|
|
profile_id: "profile-b".to_string(),
|
|
world_name: " 世界B ".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: None,
|
|
author_display_name: None,
|
|
visited_at: None,
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-c".to_string(),
|
|
profile_id: "".to_string(),
|
|
world_name: "世界C".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: None,
|
|
author_display_name: None,
|
|
visited_at: None,
|
|
},
|
|
],
|
|
1_776_000_000_000_000,
|
|
)
|
|
.expect("sync input should build");
|
|
|
|
assert_eq!(input.entries.len(), 1);
|
|
assert_eq!(input.entries[0].owner_user_id, "owner-b");
|
|
assert_eq!(input.entries[0].profile_id, "profile-b");
|
|
assert_eq!(input.entries[0].world_name, "世界B");
|
|
}
|
|
|
|
#[test]
|
|
fn build_profile_inputs_reject_blank_user_id() {
|
|
assert_eq!(
|
|
build_runtime_profile_dashboard_get_input(" ".to_string())
|
|
.expect_err("dashboard input should fail"),
|
|
RuntimeProfileFieldError::MissingUserId
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_wallet_ledger_list_input(" ".to_string())
|
|
.expect_err("wallet ledger input should fail"),
|
|
RuntimeProfileFieldError::MissingUserId
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_play_stats_get_input(" ".to_string())
|
|
.expect_err("play stats input should fail"),
|
|
RuntimeProfileFieldError::MissingUserId
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_dashboard_record_formats_optional_timestamp() {
|
|
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {
|
|
user_id: "user-1".to_string(),
|
|
wallet_balance: 8,
|
|
total_play_time_ms: 12,
|
|
played_world_count: 2,
|
|
updated_at_micros: Some(1_713_680_000_000_000),
|
|
});
|
|
|
|
assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn profile_wallet_ledger_source_type_formats_to_snapshot_sync() {
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
|
|
"snapshot_sync"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
|
|
"points_recharge"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(),
|
|
"asset_operation_consume"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
|
|
"asset_operation_refund"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_product_catalog_matches_reference_prices() {
|
|
let point_products = runtime_profile_recharge_point_products();
|
|
let membership_products = runtime_profile_recharge_membership_products();
|
|
|
|
assert_eq!(point_products.len(), 6);
|
|
assert_eq!(point_products[0].product_id, "points_60");
|
|
assert_eq!(point_products[0].title, "60叙世币");
|
|
assert_eq!(point_products[0].price_cents, 600);
|
|
assert_eq!(point_products[0].bonus_points, 60);
|
|
assert_eq!(point_products[0].description, "首充送60叙世币");
|
|
assert_eq!(point_products[5].product_id, "points_3280");
|
|
assert_eq!(point_products[5].price_cents, 32800);
|
|
assert_eq!(point_products[5].bonus_points, 3280);
|
|
assert_eq!(point_products[5].description, "首充送3280叙世币");
|
|
assert_eq!(membership_products.len(), 3);
|
|
assert_eq!(membership_products[0].title, "月卡");
|
|
assert_eq!(membership_products[0].price_cents, 2800);
|
|
assert_eq!(membership_products[2].duration_days, 365);
|
|
|
|
let benefits = runtime_profile_membership_benefits();
|
|
assert!(
|
|
benefits
|
|
.iter()
|
|
.any(|benefit| benefit.benefit_name == "免叙世币回合数")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_recharge_order_input_rejects_unknown_product() {
|
|
let error = build_runtime_profile_recharge_order_create_input(
|
|
"user-1".to_string(),
|
|
"bad-product".to_string(),
|
|
"mock".to_string(),
|
|
1,
|
|
)
|
|
.expect_err("unknown product should fail");
|
|
|
|
assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_identity_helpers_keep_existing_key_shape() {
|
|
assert_eq!(
|
|
build_runtime_profile_played_world_id(" user-1 ", " custom:world "),
|
|
"user-1:custom:world"
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_snapshot_wallet_ledger_id(" user-1 ", 100, 30),
|
|
"user-1:100:30"
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_recharge_wallet_ledger_id("user-1", 200, "points_60"),
|
|
"user-1:200:points_60"
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_recharge_order_id("user-1", 200, "points_60"),
|
|
"recharge:user-1:200:points_60"
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_redeem_code_usage_id("GIFT", "user-1", 300, 2),
|
|
"redeem:GIFT:user-1:300:2"
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_redeem_code_ledger_id("redeem:GIFT:user-1:300:2"),
|
|
"redeem:GIFT:user-1:300:2:ledger"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_membership_purchase_extends_from_active_expiry() {
|
|
let update =
|
|
resolve_runtime_profile_membership_purchase_update(Some(10), Some(200), 100, 30);
|
|
|
|
assert_eq!(update.started_at_micros, 10);
|
|
assert_eq!(
|
|
update.expires_at_micros,
|
|
200 + 30 * PROFILE_RUNTIME_DAY_MICROS
|
|
);
|
|
|
|
let expired_update =
|
|
resolve_runtime_profile_membership_purchase_update(Some(10), Some(80), 100, 1);
|
|
assert_eq!(expired_update.started_at_micros, 10);
|
|
assert_eq!(
|
|
expired_update.expires_at_micros,
|
|
100 + PROFILE_RUNTIME_DAY_MICROS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_wallet_balance_calculation_guards_edges() {
|
|
assert_eq!(
|
|
convert_runtime_profile_wallet_unsigned_delta(8).expect("small amount should convert"),
|
|
8
|
|
);
|
|
assert_eq!(
|
|
convert_runtime_profile_wallet_unsigned_delta(i64::MAX as u64 + 1)
|
|
.expect_err("oversized amount should fail"),
|
|
RuntimeProfileFieldError::WalletAmountOverflow
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_wallet_balance(10, 5).expect("positive delta should add"),
|
|
15
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_wallet_balance(10, -4)
|
|
.expect("negative delta should subtract"),
|
|
6
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_wallet_balance(3, -4).expect_err("overspend should fail"),
|
|
RuntimeProfileFieldError::InsufficientWalletBalance
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_redeem_code_usage_validation_matches_modes() {
|
|
let base = RuntimeProfileRedeemCodeSnapshot {
|
|
code: "GIFT".to_string(),
|
|
mode: RuntimeProfileRedeemCodeMode::Public,
|
|
reward_points: 30,
|
|
max_uses: 2,
|
|
global_used_count: 0,
|
|
enabled: true,
|
|
allowed_user_ids: Vec::new(),
|
|
created_by: "admin".to_string(),
|
|
created_at_micros: 1,
|
|
updated_at_micros: 1,
|
|
};
|
|
|
|
validate_runtime_profile_redeem_code_usage(&base, "user-1", 1)
|
|
.expect("public code under per-user limit should pass");
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&base, "user-1", 2)
|
|
.expect_err("public code over per-user limit should fail"),
|
|
RuntimeProfileFieldError::RedeemCodeUsesExhausted
|
|
);
|
|
|
|
let private = RuntimeProfileRedeemCodeSnapshot {
|
|
mode: RuntimeProfileRedeemCodeMode::Private,
|
|
allowed_user_ids: vec!["user-2".to_string()],
|
|
..base.clone()
|
|
};
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&private, "user-1", 0)
|
|
.expect_err("private code should check allow list"),
|
|
RuntimeProfileFieldError::RedeemCodeNotAllowedForUser
|
|
);
|
|
|
|
let disabled = RuntimeProfileRedeemCodeSnapshot {
|
|
enabled: false,
|
|
..base
|
|
};
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&disabled, "user-1", 0)
|
|
.expect_err("disabled code should fail"),
|
|
RuntimeProfileFieldError::RedeemCodeDisabled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_save_checkpoint_update_rejects_session_mismatch() {
|
|
let existing = RuntimeSnapshotRecord {
|
|
user_id: "user-1".to_string(),
|
|
version: SAVE_SNAPSHOT_VERSION,
|
|
saved_at: "2026-04-29T00:00:00Z".to_string(),
|
|
saved_at_micros: 1,
|
|
bottom_tab: "story".to_string(),
|
|
game_state: serde_json::json!({
|
|
"runtimeSessionId": "session-old",
|
|
"runtimeStats": {
|
|
"playTimeMs": 10,
|
|
"lastPlayTickAt": "2026-04-29T00:00:00Z"
|
|
}
|
|
}),
|
|
current_story: None,
|
|
game_state_json: "{}".to_string(),
|
|
current_story_json: None,
|
|
created_at_micros: 1,
|
|
updated_at_micros: 1,
|
|
};
|
|
let input = RuntimeSaveCheckpointInput {
|
|
session_id: "session-new".to_string(),
|
|
bottom_tab: "story".to_string(),
|
|
saved_at_micros: 2,
|
|
updated_at_micros: 3,
|
|
};
|
|
|
|
assert_eq!(
|
|
build_runtime_save_checkpoint_update(input, existing)
|
|
.expect_err("mismatched session should fail"),
|
|
RuntimeProfileFieldError::RuntimeSessionMismatch {
|
|
expected_session_id: "session-old".to_string(),
|
|
actual_session_id: "session-new".to_string(),
|
|
}
|
|
);
|
|
}
|
|
}
|