use module_ai::{ AI_RESULT_REF_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX, AiResultReferenceInput, AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCancelInput, AiTaskCreateInput, AiTaskFailureInput, AiTaskFinishInput, AiTaskKind, AiTaskProcedureResult, AiTaskSnapshot, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStartInput, AiTaskStageStatus, AiTaskStartInput, AiTaskStatus, AiTextChunkAppendInput, AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_stage_id, generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list, validate_task_create_input, }; use module_assets::{ ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput, AssetEntityBindingProcedureResult, AssetEntityBindingSnapshot, AssetObjectAccessPolicy, AssetObjectProcedureResult, AssetObjectUpsertInput, AssetObjectUpsertSnapshot, INITIAL_ASSET_OBJECT_VERSION, validate_asset_entity_binding_fields, validate_asset_object_fields, }; use module_big_fish::{ BigFishAgentMessageKind, BigFishAgentMessageRole, BigFishAgentMessageSnapshot, BigFishAssetGenerateInput, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus, BigFishCreationStage, BigFishDraftCompileInput, BigFishMessageSubmitInput, BigFishPublishInput, BigFishRunGetInput, BigFishRunInputSubmitInput, BigFishRunProcedureResult, BigFishRunStartInput, BigFishRunStatus, BigFishRuntimeSnapshot, BigFishSessionCreateInput, BigFishSessionGetInput, BigFishSessionProcedureResult, BigFishSessionSnapshot, advance_runtime_snapshot, build_asset_coverage, build_generated_asset_slot, build_initial_runtime_snapshot, compile_default_draft, deserialize_anchor_pack, deserialize_draft, deserialize_runtime_snapshot, empty_anchor_pack, infer_anchor_pack, serialize_anchor_pack, serialize_asset_coverage, serialize_draft, serialize_runtime_snapshot, validate_asset_generate_input, validate_draft_compile_input, validate_message_submit_input, validate_publish_input, validate_run_get_input, validate_run_input_submit_input, validate_run_start_input, validate_session_create_input, validate_session_get_input, }; use module_combat::{ BATTLE_STATE_ID_PREFIX, BattleMode, BattleStateInput, BattleStateProcedureResult, BattleStateQueryInput, BattleStateSnapshot, BattleStatus, CombatOutcome, INITIAL_BATTLE_VERSION, ResolveCombatActionInput, ResolveCombatActionProcedureResult, build_battle_state_snapshot, generate_battle_state_id, resolve_combat_action as resolve_battle_state_action, validate_battle_state_input, validate_battle_state_query_input, }; use module_custom_world::{ CustomWorldAgentActionExecuteInput, CustomWorldAgentActionExecuteResult, CustomWorldAgentCardDetailGetInput, CustomWorldAgentMessageSnapshot, CustomWorldAgentMessageSubmitInput, CustomWorldAgentOperationGetInput, CustomWorldAgentOperationProcedureResult, CustomWorldAgentOperationSnapshot, CustomWorldAgentSessionCreateInput, CustomWorldAgentSessionGetInput, CustomWorldAgentSessionProcedureResult, CustomWorldAgentSessionSnapshot, CustomWorldDraftCardDetailResult, CustomWorldDraftCardDetailSectionSnapshot, CustomWorldDraftCardDetailSnapshot, CustomWorldDraftCardSnapshot, CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot, CustomWorldGalleryListResult, CustomWorldGenerationMode, CustomWorldLibraryDetailInput, CustomWorldLibraryMutationResult, CustomWorldProfileListInput, CustomWorldProfileListResult, CustomWorldProfilePublishInput, CustomWorldProfileSnapshot, CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput, CustomWorldPublicationStatus, CustomWorldPublishBlockerSnapshot, CustomWorldPublishGateSnapshot, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult, CustomWorldPublishedProfileCompileInput, CustomWorldPublishedProfileCompileResult, CustomWorldRoleAssetStatus, CustomWorldSessionStatus, CustomWorldThemeMode, CustomWorldWorkSummarySnapshot, CustomWorldWorksListInput, CustomWorldWorksListResult, RpgAgentDraftCardKind, RpgAgentDraftCardStatus, RpgAgentMessageKind, RpgAgentMessageRole, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage, build_custom_world_published_profile_compile_snapshot, validate_custom_world_agent_action_execute_input, validate_custom_world_agent_card_detail_get_input, validate_custom_world_agent_message_submit_input, validate_custom_world_agent_operation_get_input, validate_custom_world_agent_session_create_input, validate_custom_world_agent_session_get_input, validate_custom_world_gallery_detail_input, validate_custom_world_library_detail_input, validate_custom_world_profile_delete_input, validate_custom_world_profile_list_input, validate_custom_world_profile_publish_input, validate_custom_world_profile_unpublish_input, validate_custom_world_profile_upsert_input, validate_custom_world_publish_world_input, validate_custom_world_works_list_input, }; use module_inventory::{ GrantInventoryItemInput, INVENTORY_MUTATION_ID_PREFIX, INVENTORY_SLOT_ID_PREFIX, InventoryContainerKind, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind, InventoryMutation, InventoryMutationInput, InventorySlotSnapshot, RuntimeInventoryStateProcedureResult, RuntimeInventoryStateQueryInput, RuntimeInventoryStateSnapshot, apply_inventory_mutation as apply_inventory_slot_mutation, build_runtime_inventory_state_query_input, build_runtime_inventory_state_snapshot, generate_inventory_mutation_id, generate_inventory_slot_id, }; use module_npc::{ NPC_FIGHT_FUNCTION_ID, NPC_RECRUIT_AFFINITY_THRESHOLD, NPC_SPAR_FUNCTION_ID, NPC_STATE_ID_PREFIX, NpcInteractionBattleMode, NpcInteractionProcedureResult, NpcRelationState, NpcStanceProfile, NpcStateProcedureResult, NpcStateSnapshot, NpcStateUpsertInput, ResolveNpcInteractionInput, ResolveNpcSocialActionInput, apply_npc_social_action, generate_npc_state_id, normalize_npc_state_snapshot, resolve_npc_interaction as resolve_npc_interaction_domain, }; use module_progression::{ ChapterPaceBand, ChapterProgressionGetInput, ChapterProgressionInput, ChapterProgressionLedgerInput, ChapterProgressionProcedureResult, ChapterProgressionSnapshot, PlayerProgressionGetInput, PlayerProgressionGrantInput, PlayerProgressionGrantSource, PlayerProgressionProcedureResult, PlayerProgressionSnapshot, apply_chapter_progression_ledger, build_chapter_progression_snapshot, create_initial_player_progression, grant_player_experience, }; use module_quest::{ QUEST_LOG_ID_PREFIX, QuestCompletionAckInput, QuestLogEventKind, QuestNarrativeBindingSnapshot, QuestObjectiveSnapshot, QuestProgressSignal, QuestRecordInput, QuestRecordSnapshot, QuestRewardEquipmentSlot, QuestRewardItem, QuestRewardItemRarity, QuestRewardSnapshot, QuestSignalApplyInput, QuestSignalKind, QuestStatus, QuestStepSnapshot, QuestTurnInInput, acknowledge_quest_completion as acknowledge_quest_record_completion, apply_quest_signal as apply_quest_record_signal, build_quest_record_snapshot, generate_quest_log_id, turn_in_quest_record, }; use module_runtime::{ DEFAULT_MUSIC_VOLUME, DEFAULT_PLATFORM_THEME, DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT, PROFILE_WALLET_LEDGER_LIST_LIMIT, RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryProcedureResult, RuntimeBrowseHistorySnapshot, RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode, RuntimePlatformTheme, RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult, RuntimeProfileDashboardSnapshot, RuntimeProfilePlayStatsGetInput, RuntimeProfilePlayStatsProcedureResult, RuntimeProfilePlayStatsSnapshot, RuntimeProfilePlayedWorldSnapshot, RuntimeProfileSaveArchiveListInput, RuntimeProfileSaveArchiveProcedureResult, RuntimeProfileSaveArchiveResumeInput, RuntimeProfileSaveArchiveSnapshot, RuntimeProfileWalletLedgerEntrySnapshot, RuntimeProfileWalletLedgerListInput, RuntimeProfileWalletLedgerProcedureResult, RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput, RuntimeSettingProcedureResult, RuntimeSettingSnapshot, RuntimeSettingUpsertInput, RuntimeSnapshot, RuntimeSnapshotDeleteInput, RuntimeSnapshotGetInput, RuntimeSnapshotProcedureResult, RuntimeSnapshotUpsertInput, SAVE_SNAPSHOT_VERSION, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_profile_dashboard_get_input, build_runtime_profile_play_stats_get_input, build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_ledger_list_input, build_runtime_setting_get_input, build_runtime_setting_upsert_input, build_runtime_snapshot_delete_input, build_runtime_snapshot_get_input, build_runtime_snapshot_upsert_input, prepare_runtime_browse_history_entries, }; use module_runtime_item::{ RuntimeItemRewardItemSnapshot, TREASURE_RECORD_ID_PREFIX, TreasureInteractionAction, TreasureRecordProcedureResult, TreasureRecordSnapshot, TreasureResolveInput, build_inventory_item_snapshot_from_reward_item, build_treasure_record_snapshot, }; use module_story::{ INITIAL_STORY_SESSION_VERSION, STORY_EVENT_ID_PREFIX, STORY_SESSION_ID_PREFIX, StoryContinueInput, StoryEventKind, StoryEventSnapshot, StorySessionInput, StorySessionProcedureResult, StorySessionSnapshot, StorySessionStateInput, StorySessionStateProcedureResult, StorySessionStatus, apply_story_continue, build_story_session_snapshot, build_story_started_event, validate_story_continue_input, validate_story_session_input, validate_story_session_state_input, }; use serde_json::{Map as JsonMap, Value as JsonValue, json}; use shared_kernel::format_timestamp_micros; use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; mod puzzle; // 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ResolveNpcBattleInteractionInput { pub npc_interaction: ResolveNpcInteractionInput, pub story_session_id: String, pub actor_user_id: String, pub battle_state_id: Option, pub player_hp: i32, pub player_max_hp: i32, pub player_mana: i32, pub player_max_mana: i32, pub target_hp: i32, pub target_max_hp: i32, pub experience_reward: u32, pub reward_items: Vec, } // 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct NpcBattleInteractionResult { pub interaction: module_npc::NpcInteractionResult, pub battle_state: BattleStateSnapshot, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct NpcBattleInteractionProcedureResult { pub ok: bool, pub result: Option, pub error_message: Option, } #[spacetimedb::table( accessor = big_fish_creation_session, index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])) )] pub struct BigFishCreationSession { #[primary_key] session_id: String, owner_user_id: String, seed_text: String, current_turn: u32, progress_percent: u32, stage: BigFishCreationStage, anchor_pack_json: String, draft_json: Option, asset_coverage_json: String, last_assistant_reply: Option, publish_ready: bool, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = big_fish_agent_message, index(accessor = by_big_fish_message_session_id, btree(columns = [session_id])) )] pub struct BigFishAgentMessage { #[primary_key] message_id: String, session_id: String, role: BigFishAgentMessageRole, kind: BigFishAgentMessageKind, text: String, created_at: Timestamp, } #[spacetimedb::table( accessor = big_fish_asset_slot, index(accessor = by_big_fish_asset_session_id, btree(columns = [session_id])) )] pub struct BigFishAssetSlot { #[primary_key] slot_id: String, session_id: String, asset_kind: BigFishAssetKind, level: Option, motion_key: Option, status: BigFishAssetStatus, asset_url: Option, prompt_snapshot: String, updated_at: Timestamp, } #[spacetimedb::table( accessor = big_fish_runtime_run, index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_big_fish_run_session_id, btree(columns = [session_id])) )] pub struct BigFishRuntimeRun { #[primary_key] run_id: String, session_id: String, owner_user_id: String, status: BigFishRunStatus, snapshot_json: String, last_input_x: f32, last_input_y: f32, tick: u64, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = asset_object, index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) )] pub struct AssetObject { #[primary_key] asset_object_id: String, // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 bucket: String, object_key: String, access_policy: AssetObjectAccessPolicy, content_type: Option, content_length: u64, content_hash: Option, version: u32, source_job_id: Option, owner_user_id: Option, profile_id: Option, entity_id: Option, #[index(btree)] asset_kind: String, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = asset_entity_binding, index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) )] pub struct AssetEntityBinding { #[primary_key] binding_id: String, asset_object_id: String, entity_kind: String, entity_id: String, slot: String, asset_kind: String, owner_user_id: Option, profile_id: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table(accessor = runtime_setting)] pub struct RuntimeSetting { #[primary_key] user_id: String, music_volume: f32, platform_theme: RuntimePlatformTheme, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table(accessor = runtime_snapshot)] pub struct RuntimeSnapshotRow { #[primary_key] user_id: String, version: u32, saved_at: Timestamp, bottom_tab: String, game_state_json: String, current_story_json: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = user_browse_history, index(accessor = by_browse_history_user_id, btree(columns = [user_id])), index( accessor = by_browse_history_user_owner_profile, btree(columns = [user_id, owner_user_id, profile_id]) ) )] pub struct UserBrowseHistory { #[primary_key] browse_history_id: String, user_id: String, owner_user_id: String, profile_id: String, world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, theme_mode: RuntimeBrowseHistoryThemeMode, author_display_name: String, visited_at: Timestamp, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] user_id: String, wallet_balance: u64, total_play_time_ms: u64, created_at: Timestamp, 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] wallet_ledger_id: String, user_id: String, amount_delta: i64, balance_after: u64, source_type: RuntimeProfileWalletLedgerSourceType, 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] played_world_id: String, user_id: String, world_key: String, owner_user_id: Option, profile_id: Option, world_type: Option, world_title: String, world_subtitle: String, first_played_at: Timestamp, last_played_at: Timestamp, 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] archive_id: String, user_id: String, world_key: String, owner_user_id: Option, profile_id: Option, world_type: Option, world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, saved_at: Timestamp, bottom_tab: String, game_state_json: String, current_story_json: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table(accessor = player_progression)] pub struct PlayerProgression { #[primary_key] user_id: String, level: u32, current_level_xp: u32, total_xp: u32, xp_to_next_level: u32, pending_level_ups: u32, last_granted_source: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = chapter_progression, index(accessor = by_chapter_progression_user_id, btree(columns = [user_id])), index(accessor = by_chapter_progression_chapter_id, btree(columns = [chapter_id])), index(accessor = by_chapter_progression_user_chapter, btree(columns = [user_id, chapter_id])) )] pub struct ChapterProgression { #[primary_key] chapter_progression_id: String, user_id: String, chapter_id: String, chapter_index: u32, total_chapters: u32, entry_pseudo_level_millis: u32, exit_pseudo_level_millis: u32, entry_level: u32, exit_level: u32, planned_total_xp: u32, planned_quest_xp: u32, planned_hostile_xp: u32, actual_quest_xp: u32, actual_hostile_xp: u32, expected_hostile_defeat_count: u32, actual_hostile_defeat_count: u32, level_at_entry: u32, level_at_exit: Option, pace_band: ChapterPaceBand, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = npc_state, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_npc_id, btree(columns = [npc_id])), index(accessor = by_runtime_session_npc, btree(columns = [runtime_session_id, npc_id])) )] pub struct NpcState { #[primary_key] npc_state_id: String, runtime_session_id: String, npc_id: String, npc_name: String, affinity: i32, relation_state: NpcRelationState, help_used: bool, chatted_count: u32, gifts_given: u32, recruited: bool, trade_stock_signature: Option, revealed_facts: Vec, known_attribute_rumors: Vec, first_meaningful_contact_resolved: bool, seen_backstory_chapter_ids: Vec, stance_profile: NpcStanceProfile, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = story_session, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) )] pub struct StorySession { #[primary_key] story_session_id: String, runtime_session_id: String, actor_user_id: String, world_profile_id: String, initial_prompt: String, opening_summary: Option, latest_narrative_text: String, latest_choice_function_id: Option, status: StorySessionStatus, version: u32, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = story_event, index(accessor = by_story_session_id, btree(columns = [story_session_id])) )] pub struct StoryEvent { #[primary_key] event_id: String, story_session_id: String, event_kind: StoryEventKind, narrative_text: String, choice_function_id: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = ai_task, index(accessor = by_ai_task_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_ai_task_status, btree(columns = [status])), index(accessor = by_ai_task_kind, btree(columns = [task_kind])) )] pub struct AiTask { #[primary_key] task_id: String, task_kind: AiTaskKind, owner_user_id: String, request_label: String, source_module: String, source_entity_id: Option, request_payload_json: Option, status: AiTaskStatus, failure_message: Option, latest_text_output: Option, latest_structured_payload_json: Option, version: u32, created_at: Timestamp, started_at: Option, completed_at: Option, updated_at: Timestamp, } #[spacetimedb::table( accessor = ai_task_stage, index(accessor = by_ai_task_stage_task_id, btree(columns = [task_id])), index(accessor = by_ai_task_stage_task_order, btree(columns = [task_id, stage_order])) )] pub struct AiTaskStage { #[primary_key] task_stage_id: String, task_id: String, stage_kind: AiTaskStageKind, label: String, detail: String, stage_order: u32, status: AiTaskStageStatus, text_output: Option, structured_payload_json: Option, warning_messages: Vec, started_at: Option, completed_at: Option, } #[spacetimedb::table( accessor = ai_text_chunk, index(accessor = by_ai_text_chunk_task_id, btree(columns = [task_id])), index( accessor = by_ai_text_chunk_task_stage_sequence, btree(columns = [task_id, stage_kind, sequence]) ) )] pub struct AiTextChunk { #[primary_key] text_chunk_row_id: String, chunk_id: String, task_id: String, stage_kind: AiTaskStageKind, sequence: u32, delta_text: String, created_at: Timestamp, } #[spacetimedb::table( accessor = ai_result_reference, index(accessor = by_ai_result_reference_task_id, btree(columns = [task_id])) )] pub struct AiResultReference { #[primary_key] result_reference_row_id: String, result_ref_id: String, task_id: String, reference_kind: AiResultReferenceKind, reference_id: String, label: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = inventory_slot, index(accessor = by_inventory_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_inventory_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_inventory_container_slot, btree(columns = [container_kind, slot_key])), index(accessor = by_inventory_item_id, btree(columns = [item_id])) )] pub struct InventorySlot { #[primary_key] slot_id: String, runtime_session_id: String, story_session_id: Option, actor_user_id: String, container_kind: InventoryContainerKind, slot_key: String, item_id: String, category: String, name: String, description: Option, quantity: u32, rarity: InventoryItemRarity, tags: Vec, stackable: bool, stack_key: String, equipment_slot_id: Option, source_kind: InventoryItemSourceKind, source_reference_id: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = battle_state, index(accessor = by_battle_story_session_id, btree(columns = [story_session_id])), index(accessor = by_battle_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_battle_actor_user_id, btree(columns = [actor_user_id])) )] pub struct BattleState { #[primary_key] battle_state_id: String, story_session_id: String, runtime_session_id: String, actor_user_id: String, chapter_id: Option, target_npc_id: String, target_name: String, battle_mode: BattleMode, status: BattleStatus, player_hp: i32, player_max_hp: i32, player_mana: i32, player_max_mana: i32, target_hp: i32, target_max_hp: i32, experience_reward: u32, reward_items: Vec, turn_index: u32, last_action_function_id: Option, last_action_text: Option, last_result_text: Option, last_damage_dealt: i32, last_damage_taken: i32, last_outcome: CombatOutcome, version: u32, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = treasure_record, index(accessor = by_treasure_story_session_id, btree(columns = [story_session_id])), index(accessor = by_treasure_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_treasure_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_treasure_encounter_id, btree(columns = [encounter_id])) )] pub struct TreasureRecord { #[primary_key] treasure_record_id: String, runtime_session_id: String, story_session_id: String, actor_user_id: String, encounter_id: String, encounter_name: String, scene_id: Option, scene_name: Option, action: TreasureInteractionAction, reward_items: Vec, reward_hp: u32, reward_mana: u32, reward_currency: u32, story_hint: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = quest_record, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_issuer_npc_id, btree(columns = [issuer_npc_id])) )] pub struct QuestRecord { #[primary_key] quest_id: String, runtime_session_id: String, story_session_id: Option, actor_user_id: String, issuer_npc_id: String, issuer_npc_name: String, scene_id: Option, chapter_id: Option, act_id: Option, thread_id: Option, contract_id: Option, title: String, description: String, summary: String, objective: QuestObjectiveSnapshot, progress: u32, status: QuestStatus, completion_notified: bool, reward: QuestRewardSnapshot, reward_text: String, narrative_binding: QuestNarrativeBindingSnapshot, steps: Vec, active_step_id: Option, visible_stage: u32, hidden_flags: Vec, discovered_fact_ids: Vec, related_carrier_ids: Vec, consequence_ids: Vec, created_at: Timestamp, updated_at: Timestamp, completed_at: Option, turned_in_at: Option, } #[spacetimedb::table( accessor = quest_log, index(accessor = by_quest_id, btree(columns = [quest_id])), index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) )] pub struct QuestLog { #[primary_key] log_id: String, quest_id: String, runtime_session_id: String, actor_user_id: String, event_kind: QuestLogEventKind, status_after: QuestStatus, signal_kind: Option, signal: Option, step_id: Option, step_progress: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_profile, index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), index( accessor = by_custom_world_profile_publication_status, btree(columns = [publication_status]) ) )] pub struct CustomWorldProfile { #[primary_key] profile_id: String, // 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。 owner_user_id: String, source_agent_session_id: Option, publication_status: CustomWorldPublicationStatus, world_name: String, subtitle: String, summary_text: String, theme_mode: CustomWorldThemeMode, cover_image_src: Option, profile_payload_json: String, playable_npc_count: u32, landmark_count: u32, author_display_name: String, published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 deleted_at: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_session, index(accessor = by_custom_world_session_owner_user_id, btree(columns = [owner_user_id])) )] pub struct CustomWorldSession { #[primary_key] session_id: String, // 这张表只承接旧 custom-world/sessions 传统问答流,不和 agent 会话混存。 owner_user_id: String, generation_mode: CustomWorldGenerationMode, status: CustomWorldSessionStatus, setting_text: String, creator_intent_json: Option, question_snapshot_json: String, result_payload_json: Option, last_error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_session, index( accessor = by_custom_world_agent_session_owner_user_id, btree(columns = [owner_user_id]) ), index(accessor = by_custom_world_agent_session_stage, btree(columns = [stage])) )] pub struct CustomWorldAgentSession { #[primary_key] session_id: String, // Agent 会话只保留会话级聚合字段,消息、操作、卡片都拆到独立表。 owner_user_id: String, seed_text: String, current_turn: u32, progress_percent: u32, stage: RpgAgentStage, focus_card_id: Option, anchor_content_json: String, creator_intent_json: Option, creator_intent_readiness_json: String, anchor_pack_json: Option, lock_state_json: Option, draft_profile_json: Option, last_assistant_reply: Option, publish_gate_json: Option, result_preview_json: Option, pending_clarifications_json: String, quality_findings_json: String, suggested_actions_json: String, recommended_replies_json: String, asset_coverage_json: String, checkpoints_json: String, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_message, index(accessor = by_custom_world_agent_message_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentMessage { #[primary_key] message_id: String, // 消息流水单独成表,避免继续塞回 session 大 JSON。 session_id: String, role: RpgAgentMessageRole, kind: RpgAgentMessageKind, text: String, related_operation_id: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_operation, index(accessor = by_custom_world_agent_operation_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentOperation { #[primary_key] operation_id: String, // 异步操作单独建表,为 message stream / operation query 提供真相源。 session_id: String, operation_type: RpgAgentOperationType, status: RpgAgentOperationStatus, phase_label: String, phase_detail: String, progress: u32, error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_draft_card, index(accessor = by_custom_world_draft_card_session_id, btree(columns = [session_id])), index(accessor = by_custom_world_draft_card_kind, btree(columns = [kind])) )] pub struct CustomWorldDraftCard { #[primary_key] card_id: String, // 卡片实体从 agent session 拆出,后续 detail / update 都直接对这张表操作。 session_id: String, kind: RpgAgentDraftCardKind, status: RpgAgentDraftCardStatus, title: String, subtitle: String, summary: String, linked_ids_json: String, warning_count: u32, asset_status: Option, asset_status_label: Option, detail_payload_json: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_gallery_entry, public, index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])) )] pub struct CustomWorldGalleryEntry { #[primary_key] profile_id: String, // 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。 owner_user_id: String, author_display_name: String, world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, published_at: Timestamp, updated_at: Timestamp, } // 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。 #[spacetimedb::reducer(init)] pub fn init(_ctx: &ReducerContext) { log::info!( "spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,runtime_setting 已固定默认音量={} 和默认主题={},battle_state 前缀={},战斗初始版本={},npc_state 前缀={},npc 招募阈值={},story_session 前缀={},story_event 前缀={},inventory_slot 前缀={},inventory_mutation 前缀={},quest_log 前缀={},treasure_record 前缀={},player_progression 与 chapter_progression 已接入成长真相表,M5 custom_world_profile/session/agent/gallery 首批表骨架已接入,默认对象 ID 前缀={},默认绑定 ID 前缀={},资产初始版本={},故事会话初始版本={}", DEFAULT_MUSIC_VOLUME, DEFAULT_PLATFORM_THEME.as_str(), BATTLE_STATE_ID_PREFIX, INITIAL_BATTLE_VERSION, NPC_STATE_ID_PREFIX, NPC_RECRUIT_AFFINITY_THRESHOLD, STORY_SESSION_ID_PREFIX, STORY_EVENT_ID_PREFIX, INVENTORY_SLOT_ID_PREFIX, INVENTORY_MUTATION_ID_PREFIX, QUEST_LOG_ID_PREFIX, TREASURE_RECORD_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, ASSET_BINDING_ID_PREFIX, INITIAL_ASSET_OBJECT_VERSION, INITIAL_STORY_SESSION_VERSION ); } // 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。 #[spacetimedb::procedure] pub fn get_player_progression_or_default( ctx: &mut ProcedureContext, input: PlayerProgressionGetInput, ) -> PlayerProgressionProcedureResult { match ctx.try_with_tx(|tx| get_player_progression_snapshot_tx(tx, input.clone())) { Ok(record) => PlayerProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => PlayerProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 经验发放统一走 progression reducer,避免任务和战斗各自直接写等级字段。 #[spacetimedb::reducer] pub fn grant_player_progression_experience( ctx: &ReducerContext, input: PlayerProgressionGrantInput, ) -> Result<(), String> { upsert_player_progression_after_grant_tx(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn grant_player_progression_experience_and_return( ctx: &mut ProcedureContext, input: PlayerProgressionGrantInput, ) -> PlayerProgressionProcedureResult { match ctx.try_with_tx(|tx| upsert_player_progression_after_grant_tx(tx, input.clone())) { Ok(record) => PlayerProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => PlayerProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 章节计划在进入章节或编译章节预算时写入;当前先用单表同时承接计划值与实际记账值。 #[spacetimedb::reducer] pub fn upsert_chapter_progression( ctx: &ReducerContext, input: ChapterProgressionInput, ) -> Result<(), String> { upsert_chapter_progression_snapshot_tx(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn upsert_chapter_progression_and_return( ctx: &mut ProcedureContext, input: ChapterProgressionInput, ) -> ChapterProgressionProcedureResult { match ctx.try_with_tx(|tx| upsert_chapter_progression_snapshot_tx(tx, input.clone())) { Ok(record) => ChapterProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => ChapterProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 章节实际经验与击杀记账后续由 quest/combat 联动调用,这一轮先把真相写入口固定下来。 #[spacetimedb::reducer] pub fn apply_chapter_progression_ledger_entry( ctx: &ReducerContext, input: ChapterProgressionLedgerInput, ) -> Result<(), String> { update_chapter_progression_ledger_tx(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn apply_chapter_progression_ledger_entry_and_return( ctx: &mut ProcedureContext, input: ChapterProgressionLedgerInput, ) -> ChapterProgressionProcedureResult { match ctx.try_with_tx(|tx| update_chapter_progression_ledger_tx(tx, input.clone())) { Ok(record) => ChapterProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => ChapterProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_chapter_progression( ctx: &mut ProcedureContext, input: ChapterProgressionGetInput, ) -> ChapterProgressionProcedureResult { match ctx.try_with_tx(|tx| get_chapter_progression_snapshot_tx(tx, input.clone())) { Ok(record) => ChapterProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => ChapterProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 当前阶段先把 inventory_slot 立成显式背包真相表,避免继续由多个 service 各自改 runtime snapshot JSON。 #[spacetimedb::reducer] pub fn apply_inventory_mutation( ctx: &ReducerContext, input: InventoryMutationInput, ) -> Result<(), String> { apply_inventory_mutation_tx(ctx, input) } fn apply_inventory_mutation_tx( ctx: &ReducerContext, input: InventoryMutationInput, ) -> Result<(), String> { let current_slots = ctx .db .inventory_slot() .iter() .filter(|slot| { slot.runtime_session_id == input.runtime_session_id && slot.actor_user_id == input.actor_user_id }) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); let outcome = apply_inventory_slot_mutation(current_slots, input).map_err(|error| error.to_string())?; for removed_slot_id in outcome.removed_slot_ids { ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); } for slot in outcome.next_slots { ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); ctx.db .inventory_slot() .insert(build_inventory_slot_row(slot)); } Ok(()) } // procedure 面向 Axum 同步读取当前 runtime_session 下某个玩家的背包真相态。 #[spacetimedb::procedure] pub fn get_runtime_inventory_state( ctx: &mut ProcedureContext, input: RuntimeInventoryStateQueryInput, ) -> RuntimeInventoryStateProcedureResult { match ctx.try_with_tx(|tx| get_runtime_inventory_state_tx(tx, input.clone())) { Ok(snapshot) => RuntimeInventoryStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => RuntimeInventoryStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // M4 首轮先把 battle_state 作为战斗真相源落到 SpacetimeDB,避免继续把战斗状态埋在 runtime JSON 里。 #[spacetimedb::reducer] pub fn create_battle_state(ctx: &ReducerContext, input: BattleStateInput) -> Result<(), String> { create_battle_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步创建 battle_state,返回当前最新战斗快照,避免 HTTP 层再次读取 private table。 #[spacetimedb::procedure] pub fn create_battle_state_and_return( ctx: &mut ProcedureContext, input: BattleStateInput, ) -> BattleStateProcedureResult { match ctx.try_with_tx(|tx| create_battle_state_record(tx, input.clone())) { Ok(snapshot) => BattleStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => BattleStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // procedure 面向 Axum 读取单个 battle_state 真相态,当前只返回最新战斗快照。 #[spacetimedb::procedure] pub fn get_battle_state( ctx: &mut ProcedureContext, input: BattleStateQueryInput, ) -> BattleStateProcedureResult { match ctx.try_with_tx(|tx| get_battle_state_record(tx, input.clone())) { Ok(snapshot) => BattleStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => BattleStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // M4 首轮只承接单行为战斗推进,不提前把 inventory / progression / story AI 续写耦进 reducer。 #[spacetimedb::reducer] pub fn resolve_combat_action( ctx: &ReducerContext, input: ResolveCombatActionInput, ) -> Result<(), String> { resolve_battle_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步推进单次战斗动作,返回本次结算结果与 battle_state 最新快照。 #[spacetimedb::procedure] pub fn resolve_combat_action_and_return( ctx: &mut ProcedureContext, input: ResolveCombatActionInput, ) -> ResolveCombatActionProcedureResult { match ctx.try_with_tx(|tx| resolve_battle_state_record(tx, input.clone())) { Ok(result) => ResolveCombatActionProcedureResult { ok: true, result: Some(result), error_message: None, }, Err(message) => ResolveCombatActionProcedureResult { ok: false, result: None, error_message: Some(message), }, } } fn create_battle_state_record( ctx: &ReducerContext, input: BattleStateInput, ) -> Result { validate_battle_state_input(&input).map_err(|error| error.to_string())?; if ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .is_some() { return Err("battle_state.battle_state_id 已存在".to_string()); } let snapshot = build_battle_state_snapshot(input); ctx.db .battle_state() .insert(build_battle_state_row(snapshot.clone())); Ok(snapshot) } fn get_battle_state_record( ctx: &ReducerContext, input: BattleStateQueryInput, ) -> Result { validate_battle_state_query_input(&input).map_err(|error| error.to_string())?; let row = ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .ok_or_else(|| "battle_state 不存在".to_string())?; Ok(build_battle_state_snapshot_from_row(&row)) } fn get_runtime_inventory_state_tx( ctx: &ReducerContext, input: RuntimeInventoryStateQueryInput, ) -> Result { let validated_input = build_runtime_inventory_state_query_input(input.runtime_session_id, input.actor_user_id) .map_err(|error| error.to_string())?; // 这层只返回 inventory_slot 真相表的最小切片,不混入 story、quest、battle 的额外投影。 let slots = ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == validated_input.runtime_session_id && row.actor_user_id == validated_input.actor_user_id }) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); Ok(build_runtime_inventory_state_snapshot( validated_input, slots, )) } fn resolve_battle_state_record( ctx: &ReducerContext, input: ResolveCombatActionInput, ) -> Result { let current = ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .ok_or_else(|| "battle_state 不存在,无法执行战斗动作".to_string())?; let result = resolve_battle_state_action(build_battle_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .battle_state() .battle_state_id() .delete(¤t.battle_state_id); ctx.db .battle_state() .insert(build_battle_state_row(result.snapshot.clone())); if result.outcome == CombatOutcome::Victory { grant_battle_reward_items(ctx, &result.snapshot)?; if result.snapshot.experience_reward > 0 { let updated_player = upsert_player_progression_after_grant_tx( ctx, PlayerProgressionGrantInput { user_id: result.snapshot.actor_user_id.clone(), amount: result.snapshot.experience_reward, source: PlayerProgressionGrantSource::HostileNpc, updated_at_micros: result.snapshot.updated_at_micros, }, )?; // 章节计划可能尚未初始化;此时不能阻断战斗胜利结算,只跳过章节账本写入。 try_update_chapter_progression_ledger_tx( ctx, result.snapshot.actor_user_id.clone(), result.snapshot.chapter_id.clone(), ChapterProgressionLedgerInput { user_id: result.snapshot.actor_user_id.clone(), chapter_id: result.snapshot.chapter_id.clone().unwrap_or_default(), granted_quest_xp: 0, granted_hostile_xp: result.snapshot.experience_reward, hostile_defeat_increment: 1, level_at_exit: Some(updated_player.level), updated_at_micros: result.snapshot.updated_at_micros, }, )?; } } Ok(result) } // 当前阶段先把 npc_state 立成显式真相表,避免继续把关系状态藏在运行时 JSON 快照里。 #[spacetimedb::reducer] pub fn upsert_npc_state(ctx: &ReducerContext, input: NpcStateUpsertInput) -> Result<(), String> { upsert_npc_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步 upsert 接口,返回最新 NPC 状态快照。 #[spacetimedb::procedure] pub fn upsert_npc_state_and_return( ctx: &mut ProcedureContext, input: NpcStateUpsertInput, ) -> NpcStateProcedureResult { match ctx.try_with_tx(|tx| upsert_npc_state_record(tx, input.clone())) { Ok(record) => NpcStateProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => NpcStateProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 当前阶段只承接 NPC 关系状态的最小社交动作,不提前把背包、战斗和队伍副作用也塞进来。 #[spacetimedb::reducer] pub fn resolve_npc_social_action( ctx: &ReducerContext, input: ResolveNpcSocialActionInput, ) -> Result<(), String> { resolve_npc_social_action_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步社交动作接口,返回动作后的 NPC 状态快照。 #[spacetimedb::procedure] pub fn resolve_npc_social_action_and_return( ctx: &mut ProcedureContext, input: ResolveNpcSocialActionInput, ) -> NpcStateProcedureResult { match ctx.try_with_tx(|tx| resolve_npc_social_action_record(tx, input.clone())) { Ok(record) => NpcStateProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => NpcStateProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 当前阶段先冻结 NPC 正式交互统一入口,不直接在这里扩出队伍、战斗、背包等跨子域副作用。 #[spacetimedb::reducer] pub fn resolve_npc_interaction( ctx: &ReducerContext, input: ResolveNpcInteractionInput, ) -> Result<(), String> { resolve_npc_interaction_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn resolve_npc_interaction_and_return( ctx: &mut ProcedureContext, input: ResolveNpcInteractionInput, ) -> NpcInteractionProcedureResult { match ctx.try_with_tx(|tx| resolve_npc_interaction_record(tx, input.clone())) { Ok(result) => NpcInteractionProcedureResult { ok: true, result: Some(result), error_message: None, }, Err(message) => NpcInteractionProcedureResult { ok: false, result: None, error_message: Some(message), }, } } // fight / spar 的 battle_state 初始化属于聚合层编排,不回灌到 module-npc 纯领域 crate。 #[spacetimedb::procedure] pub fn resolve_npc_battle_interaction_and_return( ctx: &mut ProcedureContext, input: ResolveNpcBattleInteractionInput, ) -> NpcBattleInteractionProcedureResult { match ctx.try_with_tx(|tx| resolve_npc_battle_interaction_tx(tx, input.clone())) { Ok(result) => NpcBattleInteractionProcedureResult { ok: true, result: Some(result), error_message: None, }, Err(message) => NpcBattleInteractionProcedureResult { ok: false, result: None, error_message: Some(message), }, } } // M4 首轮先把 story_session / story_event 作为显式会话真相源落到 SpacetimeDB,避免后续继续依赖大 JSON 覆盖式写法。 #[spacetimedb::reducer] pub fn begin_story_session(ctx: &ReducerContext, input: StorySessionInput) -> Result<(), String> { begin_story_session_tx(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步创建故事会话,返回最新会话快照与开场事件,避免 HTTP 层再读 private table。 #[spacetimedb::procedure] pub fn begin_story_session_and_return( ctx: &mut ProcedureContext, input: StorySessionInput, ) -> StorySessionProcedureResult { match ctx.try_with_tx(|tx| begin_story_session_tx(tx, input.clone())) { Ok((session, event)) => StorySessionProcedureResult { ok: true, session: Some(session), event: Some(event), error_message: None, }, Err(message) => StorySessionProcedureResult { ok: false, session: None, event: None, error_message: Some(message), }, } } fn begin_story_session_tx( ctx: &ReducerContext, input: StorySessionInput, ) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { validate_story_session_input(&input).map_err(|error| error.to_string())?; if ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .is_some() { return Err("story_session.story_session_id 已存在".to_string()); } let snapshot = build_story_session_snapshot(input); let started_event = build_story_started_event(&snapshot); let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); ctx.db.story_session().insert(StorySession { story_session_id: snapshot.story_session_id.clone(), runtime_session_id: snapshot.runtime_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), world_profile_id: snapshot.world_profile_id.clone(), initial_prompt: snapshot.initial_prompt.clone(), opening_summary: snapshot.opening_summary.clone(), latest_narrative_text: snapshot.latest_narrative_text.clone(), latest_choice_function_id: snapshot.latest_choice_function_id.clone(), status: snapshot.status, version: snapshot.version, created_at, updated_at, }); ctx.db.story_event().insert(StoryEvent { event_id: started_event.event_id.clone(), story_session_id: started_event.story_session_id.clone(), event_kind: started_event.event_kind, narrative_text: started_event.narrative_text.clone(), choice_function_id: started_event.choice_function_id.clone(), created_at, }); Ok((snapshot, started_event)) } // M4 首轮继续把“故事推进”固定为事件追加 + 会话版本递增,为后续 resolve_story_action 接线提供最小基座。 #[spacetimedb::reducer] pub fn continue_story(ctx: &ReducerContext, input: StoryContinueInput) -> Result<(), String> { continue_story_tx(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步推进故事会话,返回最新会话快照与本次事件,避免 HTTP 层再读 private table。 #[spacetimedb::procedure] pub fn continue_story_and_return( ctx: &mut ProcedureContext, input: StoryContinueInput, ) -> StorySessionProcedureResult { match ctx.try_with_tx(|tx| continue_story_tx(tx, input.clone())) { Ok((session, event)) => StorySessionProcedureResult { ok: true, session: Some(session), event: Some(event), error_message: None, }, Err(message) => StorySessionProcedureResult { ok: false, session: None, event: None, error_message: Some(message), }, } } // procedure 面向 Axum 读取指定 story session 的最小真实状态,当前只返回 session + event 列表。 #[spacetimedb::procedure] pub fn get_story_session_state( ctx: &mut ProcedureContext, input: StorySessionStateInput, ) -> StorySessionStateProcedureResult { match ctx.try_with_tx(|tx| get_story_session_state_tx(tx, input.clone())) { Ok((session, events)) => StorySessionStateProcedureResult { ok: true, session: Some(session), events, error_message: None, }, Err(message) => StorySessionStateProcedureResult { ok: false, session: None, events: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn create_big_fish_session( ctx: &mut ProcedureContext, input: BigFishSessionCreateInput, ) -> BigFishSessionProcedureResult { match ctx.try_with_tx(|tx| create_big_fish_session_tx(tx, input.clone())) { Ok(session) => BigFishSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => BigFishSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_big_fish_session( ctx: &mut ProcedureContext, input: BigFishSessionGetInput, ) -> BigFishSessionProcedureResult { match ctx.try_with_tx(|tx| get_big_fish_session_tx(tx, input.clone())) { Ok(session) => BigFishSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => BigFishSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_big_fish_message( ctx: &mut ProcedureContext, input: BigFishMessageSubmitInput, ) -> BigFishSessionProcedureResult { match ctx.try_with_tx(|tx| submit_big_fish_message_tx(tx, input.clone())) { Ok(session) => BigFishSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => BigFishSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn compile_big_fish_draft( ctx: &mut ProcedureContext, input: BigFishDraftCompileInput, ) -> BigFishSessionProcedureResult { match ctx.try_with_tx(|tx| compile_big_fish_draft_tx(tx, input.clone())) { Ok(session) => BigFishSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => BigFishSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn generate_big_fish_asset( ctx: &mut ProcedureContext, input: BigFishAssetGenerateInput, ) -> BigFishSessionProcedureResult { match ctx.try_with_tx(|tx| generate_big_fish_asset_tx(tx, input.clone())) { Ok(session) => BigFishSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => BigFishSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn publish_big_fish_game( ctx: &mut ProcedureContext, input: BigFishPublishInput, ) -> BigFishSessionProcedureResult { match ctx.try_with_tx(|tx| publish_big_fish_game_tx(tx, input.clone())) { Ok(session) => BigFishSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => BigFishSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn start_big_fish_run( ctx: &mut ProcedureContext, input: BigFishRunStartInput, ) -> BigFishRunProcedureResult { match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) { Ok(run) => BigFishRunProcedureResult { ok: true, run: Some(run), error_message: None, }, Err(message) => BigFishRunProcedureResult { ok: false, run: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_big_fish_input( ctx: &mut ProcedureContext, input: BigFishRunInputSubmitInput, ) -> BigFishRunProcedureResult { match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) { Ok(run) => BigFishRunProcedureResult { ok: true, run: Some(run), error_message: None, }, Err(message) => BigFishRunProcedureResult { ok: false, run: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_big_fish_run( ctx: &mut ProcedureContext, input: BigFishRunGetInput, ) -> BigFishRunProcedureResult { match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) { Ok(run) => BigFishRunProcedureResult { ok: true, run: Some(run), error_message: None, }, Err(message) => BigFishRunProcedureResult { ok: false, run: None, error_message: Some(message), }, } } // Stage 6 先把 Agent 会话骨架写入 SpacetimeDB,LLM 采集与卡片生成后续再接入。 #[spacetimedb::procedure] pub fn create_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionCreateInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } // Stage 6 读取拆表后的最小 Agent session snapshot,供 Axum 兼容旧前端 contract。 #[spacetimedb::procedure] pub fn get_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionGetInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_custom_world_agent_message( ctx: &mut ProcedureContext, input: CustomWorldAgentMessageSubmitInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| submit_custom_world_agent_message_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_operation( ctx: &mut ProcedureContext, input: CustomWorldAgentOperationGetInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_operation_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } fn continue_story_tx( ctx: &ReducerContext, input: StoryContinueInput, ) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { validate_story_continue_input(&input).map_err(|error| error.to_string())?; let current = ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?; let current_snapshot = StorySessionSnapshot { story_session_id: current.story_session_id.clone(), runtime_session_id: current.runtime_session_id.clone(), actor_user_id: current.actor_user_id.clone(), world_profile_id: current.world_profile_id.clone(), initial_prompt: current.initial_prompt.clone(), opening_summary: current.opening_summary.clone(), latest_narrative_text: current.latest_narrative_text.clone(), latest_choice_function_id: current.latest_choice_function_id.clone(), status: current.status, version: current.version, created_at_micros: current.created_at.to_micros_since_unix_epoch(), updated_at_micros: current.updated_at.to_micros_since_unix_epoch(), }; let (next_snapshot, event_snapshot) = apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?; ctx.db .story_session() .story_session_id() .delete(¤t.story_session_id); ctx.db.story_session().insert(StorySession { story_session_id: next_snapshot.story_session_id.clone(), runtime_session_id: next_snapshot.runtime_session_id.clone(), actor_user_id: next_snapshot.actor_user_id.clone(), world_profile_id: next_snapshot.world_profile_id.clone(), initial_prompt: next_snapshot.initial_prompt.clone(), opening_summary: next_snapshot.opening_summary.clone(), latest_narrative_text: next_snapshot.latest_narrative_text.clone(), latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(), status: next_snapshot.status, version: next_snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros), }); ctx.db.story_event().insert(StoryEvent { event_id: event_snapshot.event_id.clone(), story_session_id: event_snapshot.story_session_id.clone(), event_kind: event_snapshot.event_kind, narrative_text: event_snapshot.narrative_text.clone(), choice_function_id: event_snapshot.choice_function_id.clone(), created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros), }); Ok((next_snapshot, event_snapshot)) } fn get_story_session_state_tx( ctx: &ReducerContext, input: StorySessionStateInput, ) -> Result<(StorySessionSnapshot, Vec), String> { validate_story_session_state_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .ok_or_else(|| "story_session 不存在".to_string())?; let session_snapshot = build_story_session_snapshot_from_row(&session); let mut events = ctx .db .story_event() .iter() .filter(|row| row.story_session_id == input.story_session_id) .map(|row| build_story_event_snapshot_from_row(&row)) .collect::>(); events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone())); Ok((session_snapshot, events)) } fn create_big_fish_session_tx( ctx: &ReducerContext, input: BigFishSessionCreateInput, ) -> Result { validate_session_create_input(&input).map_err(|error| error.to_string())?; if ctx .db .big_fish_creation_session() .session_id() .find(&input.session_id) .is_some() { return Err("big_fish_creation_session.session_id 已存在".to_string()); } if ctx .db .big_fish_agent_message() .message_id() .find(&input.welcome_message_id) .is_some() { return Err("big_fish_agent_message.message_id 已存在".to_string()); } let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let anchor_pack = infer_anchor_pack(&input.seed_text, None); let asset_coverage = build_asset_coverage(None, &[]); ctx.db .big_fish_creation_session() .insert(BigFishCreationSession { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, progress_percent: 20, stage: BigFishCreationStage::CollectingAnchors, anchor_pack_json: serialize_anchor_pack(&anchor_pack) .map_err(|error| error.to_string())?, draft_json: None, asset_coverage_json: serialize_asset_coverage(&asset_coverage) .map_err(|error| error.to_string())?, last_assistant_reply: Some(input.welcome_message_text.clone()), publish_ready: false, created_at, updated_at: created_at, }); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: BigFishAgentMessageRole::Assistant, kind: BigFishAgentMessageKind::Chat, text: input.welcome_message_text, created_at, }); get_big_fish_session_tx( ctx, BigFishSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_big_fish_session_tx( ctx: &ReducerContext, input: BigFishSessionGetInput, ) -> Result { validate_session_get_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .big_fish_creation_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; build_big_fish_session_snapshot(ctx, &session) } fn submit_big_fish_message_tx( ctx: &ReducerContext, input: BigFishMessageSubmitInput, ) -> Result { validate_message_submit_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .big_fish_creation_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; if ctx .db .big_fish_agent_message() .message_id() .find(&input.user_message_id) .is_some() { return Err("big_fish_agent_message.user_message_id 已存在".to_string()); } if ctx .db .big_fish_agent_message() .message_id() .find(&input.assistant_message_id) .is_some() { return Err("big_fish_agent_message.assistant_message_id 已存在".to_string()); } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { message_id: input.user_message_id, session_id: input.session_id.clone(), role: BigFishAgentMessageRole::User, kind: BigFishAgentMessageKind::Chat, text: input.user_message_text.trim().to_string(), created_at: submitted_at, }); let anchor_pack = infer_anchor_pack(&session.seed_text, Some(&input.user_message_text)); let assistant_text = "我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。" .to_string(); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { message_id: input.assistant_message_id, session_id: input.session_id.clone(), role: BigFishAgentMessageRole::Assistant, kind: BigFishAgentMessageKind::Summary, text: assistant_text.clone(), created_at: submitted_at, }); let next_session = BigFishCreationSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), seed_text: session.seed_text.clone(), current_turn: session.current_turn.saturating_add(1), progress_percent: 60, stage: BigFishCreationStage::CollectingAnchors, anchor_pack_json: serialize_anchor_pack(&anchor_pack).map_err(|error| error.to_string())?, draft_json: session.draft_json.clone(), asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: Some(assistant_text), publish_ready: session.publish_ready, created_at: session.created_at, updated_at: submitted_at, }; replace_big_fish_session(ctx, &session, next_session); get_big_fish_session_tx( ctx, BigFishSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn compile_big_fish_draft_tx( ctx: &ReducerContext, input: BigFishDraftCompileInput, ) -> Result { validate_draft_compile_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .big_fish_creation_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; let anchor_pack = deserialize_anchor_pack(&session.anchor_pack_json).map_err(|error| error.to_string())?; let draft = compile_default_draft(&anchor_pack); let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); let coverage = build_asset_coverage(Some(&draft), &asset_slots); let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string(); let next_session = BigFishCreationSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), seed_text: session.seed_text.clone(), current_turn: session.current_turn, progress_percent: 80, stage: BigFishCreationStage::DraftReady, anchor_pack_json: session.anchor_pack_json.clone(), draft_json: Some(serialize_draft(&draft).map_err(|error| error.to_string())?), asset_coverage_json: serialize_asset_coverage(&coverage) .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, created_at: session.created_at, updated_at: compiled_at, }; replace_big_fish_session(ctx, &session, next_session); append_big_fish_system_message( ctx, &input.session_id, format!("big-fish-message-compile-{}", input.compiled_at_micros), reply, input.compiled_at_micros, ); get_big_fish_session_tx( ctx, BigFishSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn generate_big_fish_asset_tx( ctx: &ReducerContext, input: BigFishAssetGenerateInput, ) -> Result { let session = ctx .db .big_fish_creation_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; let draft = session .draft_json .as_deref() .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; validate_asset_generate_input(&input, &draft).map_err(|error| error.to_string())?; let slot = build_generated_asset_slot( &input.session_id, &draft, input.asset_kind, input.level, input.motion_key.clone(), input.generated_at_micros, ) .map_err(|error| error.to_string())?; upsert_big_fish_asset_slot(ctx, slot); let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); let coverage = build_asset_coverage(Some(&draft), &asset_slots); let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros); let reply = match input.asset_kind { BigFishAssetKind::LevelMainImage => "本级主图已生成并设为正式资产。", BigFishAssetKind::LevelMotion => "本级动作已生成并设为正式资产。", BigFishAssetKind::StageBackground => "活动区域背景已生成并设为正式资产。", } .to_string(); let next_stage = if coverage.publish_ready { BigFishCreationStage::ReadyToPublish } else { BigFishCreationStage::AssetRefining }; let next_session = BigFishCreationSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), seed_text: session.seed_text.clone(), current_turn: session.current_turn, progress_percent: if coverage.publish_ready { 96 } else { 88 }, stage: next_stage, anchor_pack_json: session.anchor_pack_json.clone(), draft_json: session.draft_json.clone(), asset_coverage_json: serialize_asset_coverage(&coverage) .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, created_at: session.created_at, updated_at, }; replace_big_fish_session(ctx, &session, next_session); append_big_fish_system_message( ctx, &input.session_id, format!("big-fish-message-asset-{}", input.generated_at_micros), reply, input.generated_at_micros, ); get_big_fish_session_tx( ctx, BigFishSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn publish_big_fish_game_tx( ctx: &ReducerContext, input: BigFishPublishInput, ) -> Result { validate_publish_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .big_fish_creation_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; let draft = session .draft_json .as_deref() .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; let coverage = build_asset_coverage( Some(&draft), &list_big_fish_asset_slots(ctx, &session.session_id), ); if !coverage.publish_ready { return Err(format!( "big_fish 发布校验未通过:{}", coverage.blockers.join(";") )); } let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); let next_session = BigFishCreationSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), seed_text: session.seed_text.clone(), current_turn: session.current_turn, progress_percent: 100, stage: BigFishCreationStage::Published, anchor_pack_json: session.anchor_pack_json.clone(), draft_json: session.draft_json.clone(), asset_coverage_json: serialize_asset_coverage(&coverage) .map_err(|error| error.to_string())?, last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), publish_ready: true, created_at: session.created_at, updated_at: published_at, }; replace_big_fish_session(ctx, &session, next_session); get_big_fish_session_tx( ctx, BigFishSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn start_big_fish_run_tx( ctx: &ReducerContext, input: BigFishRunStartInput, ) -> Result { validate_run_start_input(&input).map_err(|error| error.to_string())?; if ctx .db .big_fish_runtime_run() .run_id() .find(&input.run_id) .is_some() { return Err("big_fish_runtime_run.run_id 已存在".to_string()); } let session = ctx .db .big_fish_creation_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; let draft = session .draft_json .as_deref() .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; let snapshot = build_initial_runtime_snapshot( input.run_id.clone(), input.session_id.clone(), &draft, input.started_at_micros, ); let now = Timestamp::from_micros_since_unix_epoch(input.started_at_micros); ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun { run_id: input.run_id, session_id: input.session_id, owner_user_id: input.owner_user_id, status: snapshot.status, snapshot_json: serialize_runtime_snapshot(&snapshot).map_err(|error| error.to_string())?, last_input_x: 0.0, last_input_y: 0.0, tick: snapshot.tick, created_at: now, updated_at: now, }); Ok(snapshot) } fn submit_big_fish_input_tx( ctx: &ReducerContext, input: BigFishRunInputSubmitInput, ) -> Result { validate_run_input_submit_input(&input).map_err(|error| error.to_string())?; let run = ctx .db .big_fish_runtime_run() .run_id() .find(&input.run_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?; let session = ctx .db .big_fish_creation_session() .session_id() .find(&run.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; let draft = session .draft_json .as_deref() .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; let current_snapshot = deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())?; let next_snapshot = advance_runtime_snapshot( current_snapshot, &draft.runtime_params, input.input_x, input.input_y, input.submitted_at_micros, ); replace_big_fish_run( ctx, &run, BigFishRuntimeRun { run_id: run.run_id.clone(), session_id: run.session_id.clone(), owner_user_id: run.owner_user_id.clone(), status: next_snapshot.status, snapshot_json: serialize_runtime_snapshot(&next_snapshot) .map_err(|error| error.to_string())?, last_input_x: input.input_x, last_input_y: input.input_y, tick: next_snapshot.tick, created_at: run.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), }, ); Ok(next_snapshot) } fn get_big_fish_run_tx( ctx: &ReducerContext, input: BigFishRunGetInput, ) -> Result { validate_run_get_input(&input).map_err(|error| error.to_string())?; let run = ctx .db .big_fish_runtime_run() .run_id() .find(&input.run_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?; deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string()) } fn create_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionCreateInput, ) -> Result { validate_custom_world_agent_session_create_input(&input).map_err(|error| error.to_string())?; if ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("custom_world_agent_session.session_id 已存在".to_string()); } if ctx .db .custom_world_agent_message() .message_id() .find(&input.welcome_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); ctx.db .custom_world_agent_session() .insert(CustomWorldAgentSession { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, progress_percent: 0, stage: RpgAgentStage::CollectingIntent, focus_card_id: None, anchor_content_json: input.anchor_content_json.clone(), creator_intent_json: input.creator_intent_json.clone(), creator_intent_readiness_json: input.creator_intent_readiness_json.clone(), anchor_pack_json: input.anchor_pack_json.clone(), lock_state_json: input.lock_state_json.clone(), draft_profile_json: input.draft_profile_json.clone(), last_assistant_reply: Some(input.welcome_message_text.trim().to_string()), publish_gate_json: None, result_preview_json: None, pending_clarifications_json: input.pending_clarifications_json.clone(), quality_findings_json: input.quality_findings_json.clone(), suggested_actions_json: input.suggested_actions_json.clone(), recommended_replies_json: input.recommended_replies_json.clone(), asset_coverage_json: input.asset_coverage_json.clone(), checkpoints_json: input.checkpoints_json.clone(), created_at, updated_at: created_at, }); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: input.welcome_message_text.trim().to_string(), related_operation_id: None, created_at, }); get_custom_world_agent_session_tx( ctx, CustomWorldAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, ) -> Result { validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; Ok(build_custom_world_agent_session_snapshot(ctx, &session)) } fn submit_custom_world_agent_message_tx( ctx: &ReducerContext, input: CustomWorldAgentMessageSubmitInput, ) -> Result { validate_custom_world_agent_message_submit_input(&input).map_err(|error| error.to_string())?; if input.user_message_text.contains("__phase1_force_fail__") { return Err("forced failure".to_string()); } let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if ctx .db .custom_world_agent_message() .message_id() .find(&input.user_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } if ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .is_some() { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); let user_message_text = input.user_message_text.trim().to_string(); let assistant_message_id = format!("assistant-{}", input.operation_id); if ctx .db .custom_world_agent_message() .message_id() .find(&assistant_message_id) .is_some() { return Err("custom_world_agent_message.assistant_message_id 已存在".to_string()); } ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.user_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::User, kind: RpgAgentMessageKind::Chat, text: user_message_text, related_operation_id: Some(input.operation_id.clone()), created_at: submitted_at, }); let user_message_count = ctx .db .custom_world_agent_message() .iter() .filter(|row| { row.session_id == input.session_id && matches!(row.role, RpgAgentMessageRole::User) }) .count() as u32; let next_turn = session.current_turn.saturating_add(1); let (next_stage, next_progress_percent, next_readiness_json, next_pending_clarifications_json) = if user_message_count >= 2 { ( RpgAgentStage::FoundationReview, 100, r#"{"isReady":true,"completedKeys":["seed_input"],"missingKeys":[]}"#.to_string(), "[]".to_string(), ) } else { ( RpgAgentStage::Clarifying, session.progress_percent.max(20), session.creator_intent_readiness_json.clone(), format!( r#"[{{"id":"clarify-{next_turn}","label":"补充核心设定","question":"请继续补充这个世界的玩家身份、主题氛围或核心冲突。","targetKey":"core_conflict","priority":1}}]"# ), ) }; let assistant_reply = "已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。".to_string(); ctx.db .custom_world_agent_operation() .insert(CustomWorldAgentOperation { operation_id: input.operation_id.clone(), session_id: input.session_id.clone(), operation_type: RpgAgentOperationType::ProcessMessage, status: RpgAgentOperationStatus::Completed, phase_label: "消息已处理".to_string(), phase_detail: if next_stage == RpgAgentStage::FoundationReview { "当前上下文已达到最小 foundation_review 门槛。".to_string() } else { "当前上下文已记录,继续收集世界关键锚点。".to_string() }, progress: 100, error_message: None, created_at: submitted_at, updated_at: submitted_at, }); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: assistant_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: assistant_reply.clone(), related_operation_id: Some(input.operation_id.clone()), created_at: submitted_at, }); ctx.db .custom_world_agent_session() .session_id() .update(CustomWorldAgentSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), seed_text: session.seed_text.clone(), current_turn: next_turn, progress_percent: next_progress_percent, stage: next_stage, focus_card_id: session.focus_card_id.clone(), anchor_content_json: session.anchor_content_json.clone(), creator_intent_json: session.creator_intent_json.clone(), creator_intent_readiness_json: next_readiness_json, anchor_pack_json: session.anchor_pack_json.clone(), lock_state_json: session.lock_state_json.clone(), draft_profile_json: session.draft_profile_json.clone(), last_assistant_reply: Some(assistant_reply), publish_gate_json: session.publish_gate_json.clone(), result_preview_json: session.result_preview_json.clone(), pending_clarifications_json: next_pending_clarifications_json, quality_findings_json: session.quality_findings_json.clone(), suggested_actions_json: session.suggested_actions_json.clone(), recommended_replies_json: session.recommended_replies_json.clone(), asset_coverage_json: session.asset_coverage_json.clone(), checkpoints_json: session.checkpoints_json.clone(), created_at: session.created_at, updated_at: submitted_at, }); get_custom_world_agent_operation_tx( ctx, CustomWorldAgentOperationGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, operation_id: input.operation_id, }, ) } fn get_custom_world_agent_operation_tx( ctx: &ReducerContext, input: CustomWorldAgentOperationGetInput, ) -> Result { validate_custom_world_agent_operation_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let operation = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } // AI 任务当前先固定成 private 真相表,后续由 Axum / platform-llm 再往外包一层 HTTP 与 SSE 协议。 #[spacetimedb::reducer] pub fn create_ai_task(ctx: &ReducerContext, input: AiTaskCreateInput) -> Result<(), String> { create_ai_task_tx(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn create_ai_task_and_return( ctx: &mut ProcedureContext, input: AiTaskCreateInput, ) -> AiTaskProcedureResult { match ctx.try_with_tx(|tx| create_ai_task_tx(tx, input.clone())) { Ok(task) => AiTaskProcedureResult { ok: true, task: Some(task), text_chunk: None, error_message: None, }, Err(message) => AiTaskProcedureResult { ok: false, task: None, text_chunk: None, error_message: Some(message), }, } } #[spacetimedb::reducer] pub fn start_ai_task(ctx: &ReducerContext, input: AiTaskStartInput) -> Result<(), String> { start_ai_task_tx(ctx, input).map(|_| ()) } #[spacetimedb::reducer] pub fn start_ai_task_stage( ctx: &ReducerContext, input: AiTaskStageStartInput, ) -> Result<(), String> { start_ai_task_stage_tx(ctx, input).map(|_| ()) } // 流式增量写入需要同步返回 chunk 与聚合后的任务快照,方便后续 Axum facade 直接复用。 #[spacetimedb::procedure] pub fn append_ai_text_chunk_and_return( ctx: &mut ProcedureContext, input: AiTextChunkAppendInput, ) -> AiTaskProcedureResult { match ctx.try_with_tx(|tx| append_ai_text_chunk_tx(tx, input.clone())) { Ok((task, text_chunk)) => AiTaskProcedureResult { ok: true, task: Some(task), text_chunk: Some(text_chunk), error_message: None, }, Err(message) => AiTaskProcedureResult { ok: false, task: None, text_chunk: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn complete_ai_stage_and_return( ctx: &mut ProcedureContext, input: AiStageCompletionInput, ) -> AiTaskProcedureResult { match ctx.try_with_tx(|tx| complete_ai_stage_tx(tx, input.clone())) { Ok(task) => AiTaskProcedureResult { ok: true, task: Some(task), text_chunk: None, error_message: None, }, Err(message) => AiTaskProcedureResult { ok: false, task: None, text_chunk: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn attach_ai_result_reference_and_return( ctx: &mut ProcedureContext, input: AiResultReferenceInput, ) -> AiTaskProcedureResult { match ctx.try_with_tx(|tx| attach_ai_result_reference_tx(tx, input.clone())) { Ok(task) => AiTaskProcedureResult { ok: true, task: Some(task), text_chunk: None, error_message: None, }, Err(message) => AiTaskProcedureResult { ok: false, task: None, text_chunk: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn complete_ai_task_and_return( ctx: &mut ProcedureContext, input: AiTaskFinishInput, ) -> AiTaskProcedureResult { match ctx.try_with_tx(|tx| complete_ai_task_tx(tx, input.clone())) { Ok(task) => AiTaskProcedureResult { ok: true, task: Some(task), text_chunk: None, error_message: None, }, Err(message) => AiTaskProcedureResult { ok: false, task: None, text_chunk: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn fail_ai_task_and_return( ctx: &mut ProcedureContext, input: AiTaskFailureInput, ) -> AiTaskProcedureResult { match ctx.try_with_tx(|tx| fail_ai_task_tx(tx, input.clone())) { Ok(task) => AiTaskProcedureResult { ok: true, task: Some(task), text_chunk: None, error_message: None, }, Err(message) => AiTaskProcedureResult { ok: false, task: None, text_chunk: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn cancel_ai_task_and_return( ctx: &mut ProcedureContext, input: AiTaskCancelInput, ) -> AiTaskProcedureResult { match ctx.try_with_tx(|tx| cancel_ai_task_tx(tx, input.clone())) { Ok(task) => AiTaskProcedureResult { ok: true, task: Some(task), text_chunk: None, error_message: None, }, Err(message) => AiTaskProcedureResult { ok: false, task: None, text_chunk: None, error_message: Some(message), }, } } // 当前阶段先把 quest_record / quest_log 立成最小任务真相源,后续再把奖励结算和 story action 总分发接进来。 #[spacetimedb::reducer] pub fn accept_quest(ctx: &ReducerContext, input: QuestRecordInput) -> Result<(), String> { let snapshot = build_quest_record_snapshot(input).map_err(|error| error.to_string())?; if ctx .db .quest_record() .quest_id() .find(&snapshot.quest_id) .is_some() { return Err("quest_record.quest_id 已存在".to_string()); } ctx.db .quest_record() .insert(build_quest_record_row(snapshot.clone())); append_quest_log( ctx, &snapshot, QuestLogEventKind::Accepted, None, None, None, None, snapshot.created_at_micros, ); Ok(()) } // 任务推进 reducer 只认 QuestProgressSignal,不直接掺入背包、成长和关系奖励发放。 #[spacetimedb::reducer] pub fn apply_quest_signal( ctx: &ReducerContext, input: QuestSignalApplyInput, ) -> Result<(), String> { let signal = input.signal.clone(); let current = ctx .db .quest_record() .quest_id() .find(&input.quest_id) .ok_or_else(|| "quest_record 不存在,无法应用任务信号".to_string())?; let outcome = apply_quest_record_signal(build_quest_record_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; if !outcome.changed { return Ok(()); } ctx.db.quest_record().quest_id().delete(¤t.quest_id); ctx.db .quest_record() .insert(build_quest_record_row(outcome.next_record.clone())); append_quest_log( ctx, &outcome.next_record, if outcome.completed_now { QuestLogEventKind::Completed } else { QuestLogEventKind::Progressed }, Some(outcome.signal_kind), Some(signal), outcome.changed_step_id, outcome.changed_step_progress, outcome.next_record.updated_at_micros, ); Ok(()) } #[spacetimedb::reducer] pub fn acknowledge_quest_completion( ctx: &ReducerContext, input: QuestCompletionAckInput, ) -> Result<(), String> { let current = ctx .db .quest_record() .quest_id() .find(&input.quest_id) .ok_or_else(|| "quest_record 不存在,无法确认完成提示".to_string())?; let outcome = acknowledge_quest_record_completion(build_quest_record_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; if !outcome.changed { return Ok(()); } ctx.db.quest_record().quest_id().delete(¤t.quest_id); ctx.db .quest_record() .insert(build_quest_record_row(outcome.next_record.clone())); append_quest_log( ctx, &outcome.next_record, QuestLogEventKind::CompletionAcknowledged, None, None, None, None, outcome.next_record.updated_at_micros, ); Ok(()) } #[spacetimedb::reducer] pub fn turn_in_quest(ctx: &ReducerContext, input: QuestTurnInInput) -> Result<(), String> { let current = ctx .db .quest_record() .quest_id() .find(&input.quest_id) .ok_or_else(|| "quest_record 不存在,无法交付任务".to_string())?; let next = turn_in_quest_record(build_quest_record_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db.quest_record().quest_id().delete(¤t.quest_id); ctx.db .quest_record() .insert(build_quest_record_row(next.clone())); append_quest_log( ctx, &next, QuestLogEventKind::TurnedIn, None, None, None, None, next.updated_at_micros, ); let reward_experience = next.reward.experience.unwrap_or(0); grant_quest_reward_items(ctx, &next)?; if reward_experience > 0 { let updated_player = upsert_player_progression_after_grant_tx( ctx, PlayerProgressionGrantInput { user_id: next.actor_user_id.clone(), amount: reward_experience, source: PlayerProgressionGrantSource::Quest, updated_at_micros: next.updated_at_micros, }, )?; // 章节计划缺失时先保持任务交付成功,避免成长联动反向阻断 quest 主链。 try_update_chapter_progression_ledger_tx( ctx, next.actor_user_id.clone(), next.chapter_id.clone(), ChapterProgressionLedgerInput { user_id: next.actor_user_id.clone(), chapter_id: next.chapter_id.clone().unwrap_or_default(), granted_quest_xp: reward_experience, granted_hostile_xp: 0, hostile_defeat_increment: 0, level_at_exit: Some(updated_player.level), updated_at_micros: next.updated_at_micros, }, )?; } Ok(()) } // reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 #[spacetimedb::reducer] pub fn confirm_asset_object( ctx: &ReducerContext, input: AssetObjectUpsertInput, ) -> Result<(), String> { upsert_asset_object(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 #[spacetimedb::procedure] pub fn confirm_asset_object_and_return( ctx: &mut ProcedureContext, input: AssetObjectUpsertInput, ) -> AssetObjectProcedureResult { match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { Ok(record) => AssetObjectProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => AssetObjectProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 #[spacetimedb::reducer] pub fn bind_asset_object_to_entity( ctx: &ReducerContext, input: AssetEntityBindingInput, ) -> Result<(), String> { upsert_asset_entity_binding(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 #[spacetimedb::procedure] pub fn bind_asset_object_to_entity_and_return( ctx: &mut ProcedureContext, input: AssetEntityBindingInput, ) -> AssetEntityBindingProcedureResult { match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { Ok(record) => AssetEntityBindingProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => AssetEntityBindingProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // procedure 面向 Axum 同步读取设置;若没有持久化记录则返回默认值快照,但不产生额外写入。 #[spacetimedb::procedure] pub fn get_runtime_setting_or_default( ctx: &mut ProcedureContext, input: RuntimeSettingGetInput, ) -> RuntimeSettingProcedureResult { match ctx.try_with_tx(|tx| get_runtime_setting_snapshot(tx, input.clone())) { Ok(record) => RuntimeSettingProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => RuntimeSettingProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 当前快照读取保持旧 Node 语义:无快照时返回 ok=true + record=None,而不是默认空对象。 #[spacetimedb::procedure] pub fn get_runtime_snapshot( ctx: &mut ProcedureContext, input: RuntimeSnapshotGetInput, ) -> RuntimeSnapshotProcedureResult { match ctx.try_with_tx(|tx| get_runtime_snapshot_record(tx, input.clone())) { Ok(record) => RuntimeSnapshotProcedureResult { ok: true, record, error_message: None, }, Err(message) => RuntimeSnapshotProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // PUT snapshot 主链会同步刷新 dashboard / wallet / played_world / save_archive 四类 projection。 #[spacetimedb::procedure] pub fn upsert_runtime_snapshot_and_return( ctx: &mut ProcedureContext, input: RuntimeSnapshotUpsertInput, ) -> RuntimeSnapshotProcedureResult { match ctx.try_with_tx(|tx| upsert_runtime_snapshot_record(tx, input.clone())) { Ok(record) => RuntimeSnapshotProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => RuntimeSnapshotProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 删除当前快照只影响 runtime_snapshot 主表,不联动清理 profile projection。 #[spacetimedb::procedure] pub fn delete_runtime_snapshot_and_return( ctx: &mut ProcedureContext, input: RuntimeSnapshotDeleteInput, ) -> RuntimeSnapshotProcedureResult { match ctx.try_with_tx(|tx| delete_runtime_snapshot_record(tx, input.clone())) { Ok(record) => RuntimeSnapshotProcedureResult { ok: true, record, error_message: None, }, Err(message) => RuntimeSnapshotProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // procedure 面向 Axum 同步写入设置,并返回最终归一化后的持久化结果。 #[spacetimedb::procedure] pub fn upsert_runtime_setting_and_return( ctx: &mut ProcedureContext, input: RuntimeSettingUpsertInput, ) -> RuntimeSettingProcedureResult { match ctx.try_with_tx(|tx| upsert_runtime_setting(tx, input.clone())) { Ok(record) => RuntimeSettingProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => RuntimeSettingProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 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), }, } } // M5 Stage 2 先把 library profile upsert 固定成最小正式写入口;已发布作品在这里同步刷新 gallery 投影。 #[spacetimedb::reducer] pub fn upsert_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result<(), String> { upsert_custom_world_profile_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 返回 profile 与可能同步出的 gallery 投影,避免 HTTP 层再二次查询私有表。 #[spacetimedb::procedure] pub fn upsert_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUpsertInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| upsert_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // publish 负责同时推进 profile 发布态与 gallery 公开投影,避免公开列表继续运行时拼装。 #[spacetimedb::reducer] pub fn publish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result<(), String> { publish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn publish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfilePublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| publish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // unpublish 负责撤掉 gallery 投影,并把 profile 恢复为 draft。 #[spacetimedb::reducer] pub fn unpublish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result<(), String> { unpublish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn unpublish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUnpublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| unpublish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // 删除入口继续走 owner-only 软删除,不直接物理删除 profile 真相。 #[spacetimedb::procedure] pub fn delete_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfileDeleteInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| { delete_custom_world_profile_record(tx, input.clone())?; list_custom_world_profile_snapshots( tx, CustomWorldProfileListInput { owner_user_id: input.owner_user_id.clone(), }, ) }) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_profiles( ctx: &mut ProcedureContext, input: CustomWorldProfileListInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| list_custom_world_profile_snapshots(tx, input.clone())) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_gallery_entries( ctx: &mut ProcedureContext, ) -> CustomWorldGalleryListResult { match ctx.try_with_tx(|tx| Ok::<_, String>(list_custom_world_gallery_snapshots(tx))) { Ok(entries) => CustomWorldGalleryListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldGalleryListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_library_detail( ctx: &mut ProcedureContext, input: CustomWorldLibraryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_library_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_gallery_detail( ctx: &mut ProcedureContext, input: CustomWorldGalleryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, input: CustomWorldWorksListInput, ) -> CustomWorldWorksListResult { match ctx.try_with_tx(|tx| list_custom_world_work_snapshots(tx, input.clone())) { Ok(items) => CustomWorldWorksListResult { ok: true, items, error_message: None, }, Err(message) => CustomWorldWorksListResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_card_detail( ctx: &mut ProcedureContext, input: CustomWorldAgentCardDetailGetInput, ) -> CustomWorldDraftCardDetailResult { match ctx.try_with_tx(|tx| get_custom_world_agent_card_detail_tx(tx, input.clone())) { Ok(card) => CustomWorldDraftCardDetailResult { ok: true, card: Some(card), error_message: None, }, Err(message) => CustomWorldDraftCardDetailResult { ok: false, card: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn execute_custom_world_agent_action( ctx: &mut ProcedureContext, input: CustomWorldAgentActionExecuteInput, ) -> CustomWorldAgentActionExecuteResult { match ctx.try_with_tx(|tx| execute_custom_world_agent_action_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentActionExecuteResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentActionExecuteResult { ok: false, operation: None, error_message: Some(message), }, } } // procedure 面向 Axum 同步拉取浏览历史,继续沿用旧 Node 的 visitedAt 倒序输出语义。 #[spacetimedb::procedure] pub fn list_platform_browse_history( ctx: &mut ProcedureContext, input: RuntimeBrowseHistoryListInput, ) -> RuntimeBrowseHistoryProcedureResult { match ctx.try_with_tx(|tx| list_platform_browse_history_rows(tx, input.clone())) { Ok(entries) => RuntimeBrowseHistoryProcedureResult { ok: true, entries, error_message: None, }, Err(message) => RuntimeBrowseHistoryProcedureResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } // procedure 面向 Axum 承接 browse history 的单条/批量 POST,同步返回当前用户的完整列表。 #[spacetimedb::procedure] pub fn upsert_platform_browse_history_and_return( ctx: &mut ProcedureContext, input: RuntimeBrowseHistorySyncInput, ) -> RuntimeBrowseHistoryProcedureResult { match ctx.try_with_tx(|tx| upsert_platform_browse_history_rows(tx, input.clone())) { Ok(entries) => RuntimeBrowseHistoryProcedureResult { ok: true, entries, error_message: None, }, Err(message) => RuntimeBrowseHistoryProcedureResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } // procedure 面向 Axum 清空当前用户浏览历史,并直接返回空列表响应。 #[spacetimedb::procedure] pub fn clear_platform_browse_history_and_return( ctx: &mut ProcedureContext, input: RuntimeBrowseHistoryClearInput, ) -> RuntimeBrowseHistoryProcedureResult { match ctx.try_with_tx(|tx| clear_platform_browse_history_rows(tx, input.clone())) { Ok(entries) => RuntimeBrowseHistoryProcedureResult { ok: true, entries, error_message: None, }, Err(message) => RuntimeBrowseHistoryProcedureResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } // Stage 3 先把 published profile compile 作为独立 procedure 暴露,避免把编译逻辑和表写入、发布动作强耦合。 #[spacetimedb::procedure] pub fn compile_custom_world_published_profile( _ctx: &mut ProcedureContext, input: CustomWorldPublishedProfileCompileInput, ) -> CustomWorldPublishedProfileCompileResult { match build_custom_world_published_profile_compile_snapshot(input) { Ok(record) => CustomWorldPublishedProfileCompileResult { ok: true, record: Some(record), error_message: None, }, Err(error) => CustomWorldPublishedProfileCompileResult { ok: false, record: None, error_message: Some(error.to_string()), }, } } // Stage 4 把 publish_world 串成单事务主链:compile -> profile upsert -> profile publish -> session.stage 推进。 #[spacetimedb::procedure] pub fn publish_custom_world_world( ctx: &mut ProcedureContext, input: CustomWorldPublishWorldInput, ) -> CustomWorldPublishWorldResult { match ctx.try_with_tx(|tx| publish_custom_world_world_record(tx, input.clone())) { Ok((compiled_record, entry, gallery_entry, session_stage)) => { CustomWorldPublishWorldResult { ok: true, compiled_record: Some(compiled_record), entry: Some(entry), gallery_entry, session_stage: Some(session_stage), error_message: None, } } Err(message) => CustomWorldPublishWorldResult { ok: false, compiled_record: None, entry: None, gallery_entry: None, session_stage: None, error_message: Some(message), }, } } // M4 首轮先把 treasure_record 固定成可审计的宝藏结算真相表,奖励写入与 story 归属关系由 reducer 显式校验。 #[spacetimedb::reducer] pub fn resolve_treasure_interaction( ctx: &ReducerContext, input: TreasureResolveInput, ) -> Result<(), String> { upsert_treasure_record(ctx, input).map(|_| ()) } // procedure 面向后续 Axum facade,同步返回最终 treasure_record 快照,避免 HTTP 层再额外读取私有表。 #[spacetimedb::procedure] pub fn resolve_treasure_interaction_and_return( ctx: &mut ProcedureContext, input: TreasureResolveInput, ) -> TreasureRecordProcedureResult { match ctx.try_with_tx(|tx| upsert_treasure_record(tx, input.clone())) { Ok(record) => TreasureRecordProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => TreasureRecordProcedureResult { ok: false, record: None, error_message: Some(message), }, } } fn upsert_treasure_record( ctx: &ReducerContext, input: TreasureResolveInput, ) -> Result { let snapshot = build_treasure_record_snapshot(input).map_err(|error| error.to_string())?; let story_session = ctx .db .story_session() .story_session_id() .find(&snapshot.story_session_id) .ok_or_else(|| { "treasure_record.story_session_id 对应的 story_session 不存在".to_string() })?; if story_session.runtime_session_id != snapshot.runtime_session_id { return Err( "treasure_record.runtime_session_id 必须与 story_session.runtime_session_id 一致" .to_string(), ); } if story_session.actor_user_id != snapshot.actor_user_id { return Err( "treasure_record.actor_user_id 必须与 story_session.actor_user_id 一致".to_string(), ); } // treasure_record 首版按单次结算真相处理:同 id 重放直接返回已落库快照,避免记录更新和重复发奖脱节。 if let Some(existing) = ctx .db .treasure_record() .treasure_record_id() .find(&snapshot.treasure_record_id) { return Ok(build_treasure_record_snapshot_from_row(&existing)); } let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); ctx.db .treasure_record() .insert(build_treasure_record_row(&snapshot, created_at, updated_at)); grant_treasure_reward_items_to_inventory(ctx, &snapshot)?; Ok(snapshot) } fn grant_treasure_reward_items_to_inventory( ctx: &ReducerContext, snapshot: &TreasureRecordSnapshot, ) -> Result<(), String> { for (index, reward_item) in snapshot.reward_items.iter().cloned().enumerate() { let inventory_item = build_inventory_item_snapshot_from_reward_item( &snapshot.treasure_record_id, reward_item, ) .map_err(|error| error.to_string())?; let slot_id = build_treasure_inventory_slot_id(&snapshot.treasure_record_id, index); let mutation_id = build_treasure_inventory_mutation_id(&snapshot.treasure_record_id, index); apply_inventory_mutation_tx( ctx, InventoryMutationInput { mutation_id, runtime_session_id: snapshot.runtime_session_id.clone(), story_session_id: Some(snapshot.story_session_id.clone()), actor_user_id: snapshot.actor_user_id.clone(), mutation: InventoryMutation::GrantItem(module_inventory::GrantInventoryItemInput { slot_id, item: inventory_item, }), updated_at_micros: snapshot.updated_at_micros, }, )?; } Ok(()) } fn build_treasure_inventory_slot_id(treasure_record_id: &str, reward_index: usize) -> String { format!( "{}{}_{}", INVENTORY_SLOT_ID_PREFIX, treasure_record_id, reward_index ) } fn build_treasure_inventory_mutation_id(treasure_record_id: &str, reward_index: usize) -> String { format!( "{}{}_{}", INVENTORY_MUTATION_ID_PREFIX, treasure_record_id, reward_index ) } fn build_treasure_record_row( snapshot: &TreasureRecordSnapshot, created_at: Timestamp, updated_at: Timestamp, ) -> TreasureRecord { TreasureRecord { treasure_record_id: snapshot.treasure_record_id.clone(), runtime_session_id: snapshot.runtime_session_id.clone(), story_session_id: snapshot.story_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), encounter_id: snapshot.encounter_id.clone(), encounter_name: snapshot.encounter_name.clone(), scene_id: snapshot.scene_id.clone(), scene_name: snapshot.scene_name.clone(), action: snapshot.action, reward_items: snapshot.reward_items.clone(), reward_hp: snapshot.reward_hp, reward_mana: snapshot.reward_mana, reward_currency: snapshot.reward_currency, story_hint: snapshot.story_hint.clone(), created_at, updated_at, } } fn upsert_asset_object( ctx: &ReducerContext, input: AssetObjectUpsertInput, ) -> Result { validate_asset_object_fields( &input.bucket, &input.object_key, &input.asset_kind, input.version, ) .map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 let current = ctx .db .asset_object() .iter() .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); let snapshot = match current { Some(existing) => { ctx.db .asset_object() .asset_object_id() .delete(&existing.asset_object_id); let row = AssetObject { asset_object_id: existing.asset_object_id.clone(), bucket: input.bucket.clone(), object_key: input.object_key.clone(), access_policy: input.access_policy, content_type: input.content_type.clone(), content_length: input.content_length, content_hash: input.content_hash.clone(), version: input.version, source_job_id: input.source_job_id.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), entity_id: input.entity_id.clone(), asset_kind: input.asset_kind.clone(), created_at: existing.created_at, updated_at, }; ctx.db.asset_object().insert(row); AssetObjectUpsertSnapshot { asset_object_id: existing.asset_object_id, bucket: input.bucket, object_key: input.object_key, access_policy: input.access_policy, content_type: input.content_type, content_length: input.content_length, content_hash: input.content_hash, version: input.version, source_job_id: input.source_job_id, owner_user_id: input.owner_user_id, profile_id: input.profile_id, entity_id: input.entity_id, asset_kind: input.asset_kind, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: input.updated_at_micros, } } None => { let created_at = updated_at; let row = AssetObject { asset_object_id: input.asset_object_id.clone(), bucket: input.bucket.clone(), object_key: input.object_key.clone(), access_policy: input.access_policy, content_type: input.content_type.clone(), content_length: input.content_length, content_hash: input.content_hash.clone(), version: input.version, source_job_id: input.source_job_id.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), entity_id: input.entity_id.clone(), asset_kind: input.asset_kind.clone(), created_at, updated_at, }; ctx.db.asset_object().insert(row); AssetObjectUpsertSnapshot { asset_object_id: input.asset_object_id, bucket: input.bucket, object_key: input.object_key, access_policy: input.access_policy, content_type: input.content_type, content_length: input.content_length, content_hash: input.content_hash, version: input.version, source_job_id: input.source_job_id, owner_user_id: input.owner_user_id, profile_id: input.profile_id, entity_id: input.entity_id, asset_kind: input.asset_kind, created_at_micros: input.updated_at_micros, updated_at_micros: input.updated_at_micros, } } }; Ok(snapshot) } fn upsert_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_upsert_input(&input).map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let current = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id); let next_row = match current { Some(existing) => { ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, author_display_name: input.author_display_name.clone(), published_at: existing.published_at, deleted_at: None, created_at: existing.created_at, updated_at, } } None => CustomWorldProfile { profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: updated_at, updated_at, }, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = if inserted.publication_status == CustomWorldPublicationStatus::Published { Some(sync_custom_world_gallery_entry_from_profile( ctx, &inserted, )?) } else { ctx.db .custom_world_gallery_entry() .profile_id() .delete(&inserted.profile_id); None }; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn publish_custom_world_world_record( ctx: &ReducerContext, input: CustomWorldPublishWorldInput, ) -> Result< ( module_custom_world::CustomWorldPublishedProfileCompileSnapshot, CustomWorldProfileSnapshot, Option, RpgAgentStage, ), String, > { validate_custom_world_publish_world_input(&input).map_err(|error| error.to_string())?; let compiled_record = build_custom_world_published_profile_compile_snapshot( CustomWorldPublishedProfileCompileInput { session_id: input.session_id.clone(), profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), draft_profile_json: input.draft_profile_json.clone(), legacy_result_profile_json: input.legacy_result_profile_json.clone(), setting_text: input.setting_text.clone(), author_display_name: input.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, ) .map_err(|error| error.to_string())?; let _ = upsert_custom_world_profile_record( ctx, CustomWorldProfileUpsertInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), source_agent_session_id: Some(input.session_id.clone()), world_name: compiled_record.world_name.clone(), subtitle: compiled_record.subtitle.clone(), summary_text: compiled_record.summary_text.clone(), theme_mode: compiled_record.theme_mode, cover_image_src: compiled_record.cover_image_src.clone(), profile_payload_json: compiled_record.compiled_profile_payload_json.clone(), playable_npc_count: compiled_record.playable_npc_count, landmark_count: compiled_record.landmark_count, author_display_name: compiled_record.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, )?; let (entry, gallery_entry) = publish_custom_world_profile_record( ctx, CustomWorldProfilePublishInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), author_display_name: compiled_record.author_display_name.clone(), published_at_micros: input.published_at_micros, }, )?; let session_stage = mark_custom_world_agent_session_published( ctx, &input.session_id, &input.owner_user_id, input.published_at_micros, )?; Ok((compiled_record, entry, gallery_entry, session_stage)) } fn publish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_publish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Published, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, author_display_name: input.author_display_name.clone(), published_at: Some(published_at), deleted_at: None, created_at: existing.created_at, updated_at: published_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), Some(gallery_entry), )) } fn unpublish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_unpublish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法取消发布".to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: existing.created_at, updated_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); Ok((build_custom_world_profile_snapshot(&inserted), None)) } fn delete_custom_world_profile_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfileDeleteInput, ) -> Result<(), String> { validate_custom_world_profile_delete_input(&input).map_err(|error| error.to_string())?; let Some(existing) = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) else { return Ok(()); }; if existing.deleted_at.is_some() { return Ok(()); } let deleted_at = Timestamp::from_micros_since_unix_epoch(input.deleted_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, author_display_name: existing.author_display_name.clone(), published_at: None, deleted_at: Some(deleted_at), created_at: existing.created_at, updated_at: deleted_at, }; let _ = ctx.db.custom_world_profile().insert(next_row); Ok(()) } fn list_custom_world_profile_snapshots( ctx: &ReducerContext, input: CustomWorldProfileListInput, ) -> Result, String> { validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?; let mut entries = ctx .db .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) .map(|row| build_custom_world_profile_snapshot(&row)) .collect::>(); entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(entries) } fn list_custom_world_gallery_snapshots( ctx: &ReducerContext, ) -> Vec { let mut entries = ctx .db .custom_world_gallery_entry() .iter() .map(|row| build_custom_world_gallery_entry_snapshot(&row)) .collect::>(); entries.sort_by(|left, right| { right .published_at_micros .cmp(&left.published_at_micros) .then(right.updated_at_micros.cmp(&left.updated_at_micros)) }); entries } fn get_custom_world_library_detail_record( ctx: &ReducerContext, input: CustomWorldLibraryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_library_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()); let gallery_entry = profile .as_ref() .filter(|row| row.publication_status == CustomWorldPublicationStatus::Published) .and_then(|row| { ctx.db .custom_world_gallery_entry() .profile_id() .find(&row.profile_id) .filter(|gallery_row| gallery_row.owner_user_id == row.owner_user_id) }); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(build_custom_world_gallery_entry_snapshot), )) } fn get_custom_world_gallery_detail_record( ctx: &ReducerContext, input: CustomWorldGalleryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_gallery_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id && row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() }); let gallery_entry = ctx .db .custom_world_gallery_entry() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(build_custom_world_gallery_entry_snapshot), )) } fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, ) -> Result, String> { validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; let mut items = Vec::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published }) { let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string()); let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); let (playable_npc_count, landmark_count) = resolve_session_work_counts(ctx, &session, draft_profile.as_ref()); items.push(CustomWorldWorkSummarySnapshot { work_id: format!("draft:{}", session.session_id), source_type: "agent_session".to_string(), status: "draft".to_string(), title, subtitle, summary, cover_image_src: resolve_session_work_cover_image_src(draft_profile.as_ref()), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: session.updated_at.to_micros_since_unix_epoch(), published_at_micros: None, stage: Some(session.stage), stage_label, playable_npc_count, landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: Some(session.session_id.clone()), profile_id: None, can_resume: true, can_enter_world: gate.can_enter_world, blocker_count: gate.blocker_count, publish_ready: gate.publish_ready, }); } for profile in ctx .db .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) { items.push(CustomWorldWorkSummarySnapshot { work_id: format!("published:{}", profile.profile_id), source_type: "published_profile".to_string(), status: profile.publication_status.as_str().to_string(), title: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: profile.updated_at.to_micros_since_unix_epoch(), published_at_micros: profile .published_at .map(|value| value.to_micros_since_unix_epoch()), stage: None, stage_label: None, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: profile.source_agent_session_id.clone(), profile_id: Some(profile.profile_id.clone()), can_resume: false, can_enter_world: profile.publication_status == CustomWorldPublicationStatus::Published, blocker_count: 0, publish_ready: true, }); } items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| { let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; left_rank.cmp(&right_rank) }) .then(left.work_id.cmp(&right.work_id)) }); Ok(items) } fn get_custom_world_agent_card_detail_tx( ctx: &ReducerContext, input: CustomWorldAgentCardDetailGetInput, ) -> Result { validate_custom_world_agent_card_detail_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&input.card_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_draft_card 不存在".to_string())?; build_custom_world_draft_card_detail_snapshot(&card) } fn execute_custom_world_agent_action_tx( ctx: &ReducerContext, input: CustomWorldAgentActionExecuteInput, ) -> Result { validate_custom_world_agent_action_execute_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .is_some() { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default(); match input.action.trim() { "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), "sync_result_profile" => { execute_sync_result_profile_action(ctx, &session, &input, &payload) } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "generate_characters" | "generate_landmarks" | "generate_role_assets" | "sync_role_assets" | "generate_scene_assets" | "sync_scene_assets" | "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), other => Err(format!("custom world action `{other}` 当前尚未支持")), } } fn execute_draft_foundation_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { if session.progress_percent < 100 { return Err("draft_foundation requires progressPercent >= 100".to_string()); } let updated_at = input.submitted_at_micros; let draft_profile = if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) { profile.clone() } else if let Some(existing) = parse_optional_session_object(session.draft_profile_json.as_deref()) { ensure_minimal_draft_profile(existing, &session.seed_text) } else { build_minimal_draft_profile_from_seed(&session.seed_text) }; let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone())) .map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::ObjectRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(100), stage: Some(RpgAgentStage::ObjectRefining), draft_profile_json: Some(Some(draft_profile_json.clone())), last_assistant_reply: Some(Some( "世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(), )), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("foundation-ready", "底稿整理完成", session), )?), updated_at_micros: Some(updated_at), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); upsert_world_foundation_card(ctx, &session.session_id, &draft_profile, updated_at)?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已整理出第一版世界底稿,并同步生成世界基础卡片。", updated_at, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::DraftFoundation, "底稿已整理", "第一版 foundation draft 已写入会话与世界卡。", updated_at, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_update_draft_card_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "update_draft_card")?; let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session.session_id) .ok_or_else(|| "update_draft_card target card does not exist".to_string())?; let sections = payload .get("sections") .and_then(JsonValue::as_array) .ok_or_else(|| "update_draft_card requires sections".to_string())?; if sections.is_empty() { return Err("update_draft_card requires sections".to_string()); } let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); let mut detail_sections = detail_object .get("sections") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(|| build_fallback_card_sections_json(&card)); for patch in sections { let patch_object = patch .as_object() .ok_or_else(|| "update_draft_card.sections 必须是 object 数组".to_string())?; let section_id = read_required_payload_text( patch_object, "sectionId", "update_draft_card section.sectionId is required", )?; let value = patch_object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .trim() .to_string(); let mut updated = false; for existing in &mut detail_sections { if existing.get("id").and_then(JsonValue::as_str) == Some(section_id.as_str()) { if let Some(object) = existing.as_object_mut() { object.insert("value".to_string(), JsonValue::String(value.clone())); } updated = true; break; } } if !updated { detail_sections.push(json!({ "id": section_id, "label": section_id, "value": value, })); } } detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); detail_object.insert( "kind".to_string(), JsonValue::String(card.kind.as_str().to_string()), ); detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); detail_object.insert( "sections".to_string(), JsonValue::Array(detail_sections.clone()), ); detail_object.insert( "linkedIds".to_string(), serde_json::from_str::(&card.linked_ids_json) .unwrap_or_else(|_| JsonValue::Array(Vec::new())), ); detail_object.insert("locked".to_string(), JsonValue::Bool(false)); detail_object.insert("editable".to_string(), JsonValue::Bool(false)); detail_object.insert( "editableSectionIds".to_string(), JsonValue::Array(Vec::new()), ); detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); let updated_title = extract_detail_section_value(&detail_sections, "title") .unwrap_or_else(|| card.title.clone()); let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle") .unwrap_or_else(|| card.subtitle.clone()); let updated_summary = extract_detail_section_value(&detail_sections, "summary") .unwrap_or_else(|| card.summary.clone()); let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object)) .map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?; replace_custom_world_draft_card( ctx, &card, CustomWorldDraftCard { card_id: card.card_id.clone(), session_id: card.session_id.clone(), kind: card.kind, status: card.status, title: updated_title.clone(), subtitle: updated_subtitle.clone(), summary: updated_summary.clone(), linked_ids_json: card.linked_ids_json.clone(), warning_count: card.warning_count, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), detail_payload_json: Some(detail_payload_json), created_at: card.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), }, ); let next_session = sync_session_draft_profile_from_card_update( session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已更新卡片《{}》的草稿内容。", updated_title), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::UpdateDraftCard, "卡片已更新", &format!("卡片 {} 的 detail 与摘要字段已同步更新。", card_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_result_profile_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "sync_result_profile")?; let profile = payload .get("profile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| "sync_result_profile requires profile".to_string())?; let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("sync-result-profile", "同步结果页草稿", session), )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "结果页 profile 已回写当前会话,并重建预览。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncResultProfile, "结果页已同步", "draft_profile_json 与 result_preview 已更新。", input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_publish_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_publishable_stage(session.stage, "publish_world")?; let draft_profile = if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { explicit.clone() } else { parse_optional_session_object(session.draft_profile_json.as_deref()) .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? }; let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); if !gate.publish_ready { return Err(format!( "当前世界仍有 {} 个 blocker,暂时不能发布", gate.blocker_count )); } let profile_id = payload .get("profileId") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| gate.profile_id.clone()); let setting_text = payload .get("settingText") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| session.seed_text.clone()); let legacy_result_profile_json = payload .get("legacyResultProfile") .map(serialize_json_value) .transpose()?; let publish_result = publish_custom_world_world_record( ctx, CustomWorldPublishWorldInput { session_id: session.session_id.clone(), profile_id, owner_user_id: session.owner_user_id.clone(), draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, legacy_result_profile_json, setting_text, author_display_name: "创作者".to_string(), published_at_micros: input.submitted_at_micros, }, )?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("正式世界档案已发布:{}。", publish_result.1.profile_id), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::PublishWorld, "世界已发布", &format!( "正式世界档案已写入作品库:{}。", publish_result.1.profile_id ), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_revert_checkpoint_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_long_tail_stage(session.stage, "revert_checkpoint")?; let checkpoint_id = read_required_payload_text( payload, "checkpointId", "revert_checkpoint requires checkpointId", )?; let checkpoint = parse_json_array_or_empty(&session.checkpoints_json) .into_iter() .find(|entry| { entry .get("checkpointId") .and_then(JsonValue::as_str) .map(str::trim) == Some(checkpoint_id.as_str()) }) .ok_or_else(|| "revert_checkpoint target checkpoint does not exist".to_string())?; let snapshot = checkpoint .get("snapshot") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "revert_checkpoint target checkpoint does not contain a restorable snapshot".to_string() })?; let restored_stage = snapshot .get("stage") .and_then(JsonValue::as_str) .and_then(parse_rpg_agent_stage) .unwrap_or(session.stage); let restored_progress = snapshot .get("progressPercent") .and_then(JsonValue::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(session.progress_percent); let restored_draft_profile = snapshot .get("draftProfile") .and_then(JsonValue::as_object) .cloned(); let restored_quality_findings = snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new); let gate = summarize_publish_gate_from_json( &session.session_id, restored_stage, restored_draft_profile.as_ref(), &restored_quality_findings, ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(restored_progress), stage: Some(restored_stage), draft_profile_json: Some( restored_draft_profile .as_ref() .map(|value| serialize_json_value(&JsonValue::Object(value.clone()))) .transpose()?, ), last_assistant_reply: Some(Some( "已恢复到所选 checkpoint 的世界草稿状态。".to_string(), )), quality_findings_json: Some(serialize_json_value(&JsonValue::Array( restored_quality_findings, ))?), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( restored_draft_profile.as_ref(), &gate, &parse_json_array_or_empty(&serialize_json_value(&JsonValue::Array( snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new), ))?), input.submitted_at_micros, )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已恢复到所选 checkpoint。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::RevertCheckpoint, "已回滚 checkpoint", &format!("会话已恢复到 checkpoint {}。", checkpoint_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_placeholder_custom_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, ) -> Result { let operation_type = map_action_name_to_operation_type(input.action.as_str()) .ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!( "动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action ), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, operation_type, "动作已完成", &format!("{} 当前已走最小兼容闭环。", input.action), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } #[derive(Clone, Debug, Default)] struct CustomWorldAgentSessionPatch { progress_percent: Option, stage: Option, focus_card_id: Option>, anchor_content_json: Option, creator_intent_json: Option>, creator_intent_readiness_json: Option, anchor_pack_json: Option>, lock_state_json: Option>, draft_profile_json: Option>, last_assistant_reply: Option>, publish_gate_json: Option>, result_preview_json: Option>, pending_clarifications_json: Option, quality_findings_json: Option, suggested_actions_json: Option, recommended_replies_json: Option, asset_coverage_json: Option, checkpoints_json: Option, updated_at_micros: Option, } fn build_custom_world_publish_gate_from_session( session: &CustomWorldAgentSession, ) -> CustomWorldPublishGateSnapshot { let quality_findings = parse_json_array_or_empty(&session.quality_findings_json); summarize_publish_gate_from_json( &session.session_id, session.stage, parse_optional_session_object(session.draft_profile_json.as_deref()).as_ref(), &quality_findings, ) } fn summarize_publish_gate_from_json( session_id: &str, stage: RpgAgentStage, draft_profile: Option<&JsonMap>, quality_findings: &[JsonValue], ) -> CustomWorldPublishGateSnapshot { let profile_id = draft_profile .and_then(|profile| read_optional_text_field(profile, &["legacyResultProfile.id", "id"])) .unwrap_or_else(|| format!("agent-draft-{session_id}")); let mut blockers = Vec::new(); if draft_profile.is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_empty_draft".to_string(), code: "publish_empty_draft".to_string(), message: "当前世界草稿为空,无法发布。".to_string(), }); } if let Some(profile) = draft_profile { if read_optional_text_field(profile, &["worldHook"]).is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_world_hook".to_string(), code: "publish_missing_world_hook".to_string(), message: "当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。".to_string(), }); } if read_optional_text_field(profile, &["playerPremise"]).is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。" .to_string(), }); } if !json_array_has_non_empty_text(profile.get("coreConflicts")) { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_core_conflict".to_string(), code: "publish_missing_core_conflict".to_string(), message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(), }); } if profile .get("chapters") .and_then(JsonValue::as_array) .map(|value| value.is_empty()) .unwrap_or(true) { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_main_chapter".to_string(), code: "publish_missing_main_chapter".to_string(), message: "当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。".to_string(), }); } let has_scene_act = profile .get("sceneChapters") .and_then(JsonValue::as_array) .map(|chapters| { chapters.iter().any(|chapter| { chapter .get("acts") .and_then(JsonValue::as_array) .map(|acts| !acts.is_empty()) .unwrap_or(false) }) }) .unwrap_or(false); if !has_scene_act { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_first_act".to_string(), code: "publish_missing_first_act".to_string(), message: "当前世界还没有主线第一幕,发布前至少要保留一个场景幕。".to_string(), }); } } for finding in quality_findings { if finding.get("severity").and_then(JsonValue::as_str) == Some("blocker") { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: finding .get("id") .and_then(JsonValue::as_str) .unwrap_or("publish-quality-blocker") .to_string(), code: finding .get("code") .and_then(JsonValue::as_str) .unwrap_or("publish_quality_blocker") .to_string(), message: finding .get("message") .and_then(JsonValue::as_str) .unwrap_or("当前世界仍存在 blocker。") .to_string(), }); } } let blocker_count = blockers.len() as u32; let publish_ready = blocker_count == 0; CustomWorldPublishGateSnapshot { profile_id, blockers, blocker_count, publish_ready, can_enter_world: stage == RpgAgentStage::Published && publish_ready, } } fn publish_gate_to_json_value(gate: &CustomWorldPublishGateSnapshot) -> JsonValue { json!({ "profileId": gate.profile_id, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "blockerCount": gate.blocker_count, "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, }) } fn build_result_preview_json( draft_profile: Option<&JsonMap>, gate: &CustomWorldPublishGateSnapshot, quality_findings: &[JsonValue], generated_at_micros: i64, ) -> Result, String> { let Some(profile) = draft_profile else { return Ok(None); }; serialize_json_value(&json!({ "preview": JsonValue::Object(profile.clone()), "source": "session_preview", "generatedAt": format_timestamp_micros(generated_at_micros), "qualityFindings": quality_findings, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, })) .map(Some) } fn build_supported_actions_json( stage: RpgAgentStage, progress_percent: u32, gate: &CustomWorldPublishGateSnapshot, checkpoints: &[JsonValue], ) -> Vec { let has_checkpoint = checkpoints .iter() .any(|entry| entry.get("snapshot").is_some()); let draft_refining_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ); let long_tail_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ); vec![ build_supported_action_json( "draft_foundation", progress_percent >= 100, (progress_percent < 100).then(|| "draft_foundation requires progressPercent >= 100".to_string()), ), build_supported_action_json( "update_draft_card", draft_refining_enabled, (!draft_refining_enabled).then(|| { "update_draft_card is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_result_profile", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_result_profile is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_characters", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_characters is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_landmarks", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_landmarks is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "expand_long_tail", long_tail_enabled, (!long_tail_enabled).then(|| { "expand_long_tail is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }), ), build_supported_action_json( "publish_world", long_tail_enabled && gate.publish_ready, (!long_tail_enabled) .then(|| { "publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!gate.publish_ready).then(|| "publish_world requires publish gate without blockers".to_string())), ), build_supported_action_json( "revert_checkpoint", long_tail_enabled && has_checkpoint, (!long_tail_enabled) .then(|| { "revert_checkpoint is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!has_checkpoint).then(|| "revert_checkpoint requires at least one restorable checkpoint snapshot".to_string())), ), ] } fn build_supported_action_json(action: &str, enabled: bool, reason: Option) -> JsonValue { json!({ "action": action, "enabled": enabled, "reason": reason, }) } fn build_custom_world_draft_card_detail_snapshot( card: &CustomWorldDraftCard, ) -> Result { if let Some(detail_payload_json) = card.detail_payload_json.as_deref() { let detail_value = serde_json::from_str::(detail_payload_json).map_err(|error| { format!("custom_world_draft_card.detail_payload_json 非法: {error}") })?; if let Some(object) = detail_value.as_object() { let sections = object .get("sections") .and_then(JsonValue::as_array) .map(|entries| { entries .iter() .filter_map(|entry| { let object = entry.as_object()?; Some(CustomWorldDraftCardDetailSectionSnapshot { section_id: object.get("id")?.as_str()?.to_string(), label: object .get("label") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), value: object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), }) }) .collect::>() }) .unwrap_or_else(|| build_fallback_card_sections(&card)); return Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: object .get("title") .and_then(JsonValue::as_str) .unwrap_or(card.title.as_str()) .to_string(), sections, linked_ids_json: card.linked_ids_json.clone(), locked: object .get("locked") .and_then(JsonValue::as_bool) .unwrap_or(false), editable: object .get("editable") .and_then(JsonValue::as_bool) .unwrap_or(false), editable_section_ids_json: serialize_json_value( object .get("editableSectionIds") .unwrap_or(&JsonValue::Array(Vec::new())), )?, warning_messages_json: serialize_json_value( object .get("warningMessages") .unwrap_or(&JsonValue::Array(Vec::new())), )?, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }); } } Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: card.title.clone(), sections: build_fallback_card_sections(card), linked_ids_json: card.linked_ids_json.clone(), locked: false, editable: false, editable_section_ids_json: "[]".to_string(), warning_messages_json: "[]".to_string(), asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }) } fn build_fallback_card_sections( card: &CustomWorldDraftCard, ) -> Vec { vec![ CustomWorldDraftCardDetailSectionSnapshot { section_id: "title".to_string(), label: "标题".to_string(), value: card.title.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "subtitle".to_string(), label: "副标题".to_string(), value: card.subtitle.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "summary".to_string(), label: "摘要".to_string(), value: card.summary.clone(), }, ] } fn build_fallback_card_sections_json(card: &CustomWorldDraftCard) -> Vec { build_fallback_card_sections(card) .into_iter() .map(|section| { json!({ "id": section.section_id, "label": section.label, "value": section.value, }) }) .collect() } fn rebuild_custom_world_agent_session_row( current: &CustomWorldAgentSession, patch: CustomWorldAgentSessionPatch, ) -> Result { Ok(CustomWorldAgentSession { session_id: current.session_id.clone(), owner_user_id: current.owner_user_id.clone(), seed_text: current.seed_text.clone(), current_turn: current.current_turn, progress_percent: patch.progress_percent.unwrap_or(current.progress_percent), stage: patch.stage.unwrap_or(current.stage), focus_card_id: patch .focus_card_id .unwrap_or_else(|| current.focus_card_id.clone()), anchor_content_json: patch .anchor_content_json .unwrap_or_else(|| current.anchor_content_json.clone()), creator_intent_json: patch .creator_intent_json .unwrap_or_else(|| current.creator_intent_json.clone()), creator_intent_readiness_json: patch .creator_intent_readiness_json .unwrap_or_else(|| current.creator_intent_readiness_json.clone()), anchor_pack_json: patch .anchor_pack_json .unwrap_or_else(|| current.anchor_pack_json.clone()), lock_state_json: patch .lock_state_json .unwrap_or_else(|| current.lock_state_json.clone()), draft_profile_json: patch .draft_profile_json .unwrap_or_else(|| current.draft_profile_json.clone()), last_assistant_reply: patch .last_assistant_reply .unwrap_or_else(|| current.last_assistant_reply.clone()), publish_gate_json: patch .publish_gate_json .unwrap_or_else(|| current.publish_gate_json.clone()), result_preview_json: patch .result_preview_json .unwrap_or_else(|| current.result_preview_json.clone()), pending_clarifications_json: patch .pending_clarifications_json .unwrap_or_else(|| current.pending_clarifications_json.clone()), quality_findings_json: patch .quality_findings_json .unwrap_or_else(|| current.quality_findings_json.clone()), suggested_actions_json: patch .suggested_actions_json .unwrap_or_else(|| current.suggested_actions_json.clone()), recommended_replies_json: patch .recommended_replies_json .unwrap_or_else(|| current.recommended_replies_json.clone()), asset_coverage_json: patch .asset_coverage_json .unwrap_or_else(|| current.asset_coverage_json.clone()), checkpoints_json: patch .checkpoints_json .unwrap_or_else(|| current.checkpoints_json.clone()), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch( patch .updated_at_micros .unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()), ), }) } fn replace_custom_world_agent_session( ctx: &ReducerContext, current: &CustomWorldAgentSession, next: CustomWorldAgentSession, ) { ctx.db .custom_world_agent_session() .session_id() .delete(¤t.session_id); ctx.db.custom_world_agent_session().insert(next); } fn replace_custom_world_draft_card( ctx: &ReducerContext, current: &CustomWorldDraftCard, next: CustomWorldDraftCard, ) { ctx.db .custom_world_draft_card() .card_id() .delete(¤t.card_id); ctx.db.custom_world_draft_card().insert(next); } fn build_and_insert_custom_world_operation( ctx: &ReducerContext, operation_id: &str, session_id: &str, operation_type: RpgAgentOperationType, phase_label: &str, phase_detail: &str, timestamp_micros: i64, ) -> CustomWorldAgentOperation { let row = CustomWorldAgentOperation { operation_id: operation_id.to_string(), session_id: session_id.to_string(), operation_type, status: RpgAgentOperationStatus::Completed, phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), progress: 100, error_message: None, created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), updated_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_operation().insert(row) } fn append_custom_world_action_result_message( ctx: &ReducerContext, session_id: &str, operation_id: &str, text: &str, timestamp_micros: i64, ) { let row = CustomWorldAgentMessage { message_id: format!("message-action-{}-{}", operation_id, timestamp_micros), session_id: session_id.to_string(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::ActionResult, text: text.to_string(), related_operation_id: Some(operation_id.to_string()), created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_message().insert(row); } fn upsert_world_foundation_card( ctx: &ReducerContext, session_id: &str, draft_profile: &JsonMap, updated_at_micros: i64, ) -> Result<(), String> { let card_id = "world-foundation".to_string(); let title = read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()); let subtitle = read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(); let summary = read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()); let detail_payload_json = serialize_json_value(&json!({ "id": card_id, "kind": "world", "title": title, "sections": [ { "id": "title", "label": "标题", "value": read_optional_text_field(draft_profile, &["name", "title"]).unwrap_or_else(|| "世界底稿".to_string()) }, { "id": "subtitle", "label": "副标题", "value": subtitle }, { "id": "summary", "label": "摘要", "value": summary }, ], "linkedIds": [], "locked": false, "editable": false, "editableSectionIds": [], "warningMessages": [], }))?; if let Some(existing) = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session_id) { replace_custom_world_draft_card( ctx, &existing, CustomWorldDraftCard { card_id: existing.card_id.clone(), session_id: existing.session_id.clone(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }, ); } else { ctx.db .custom_world_draft_card() .insert(CustomWorldDraftCard { card_id, session_id: session_id.to_string(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); } Ok(()) } fn sync_session_draft_profile_from_card_update( session: &CustomWorldAgentSession, card: &CustomWorldDraftCard, updated_title: &str, updated_subtitle: &str, updated_summary: &str, updated_at_micros: i64, ) -> Result { let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text)); if card.kind == RpgAgentDraftCardKind::World { draft_profile.insert( "name".to_string(), JsonValue::String(updated_title.to_string()), ); draft_profile.insert( "subtitle".to_string(), JsonValue::String(updated_subtitle.to_string()), ); draft_profile.insert( "summary".to_string(), JsonValue::String(updated_summary.to_string()), ); } let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at_micros, )?), last_assistant_reply: Some(Some(format!("卡片《{}》已更新。", updated_title))), updated_at_micros: Some(updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, ) } fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining or visual_refining" )) } } fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" )) } } fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { ensure_long_tail_stage(stage, action) } fn map_action_name_to_operation_type(action: &str) -> Option { match action { "draft_foundation" => Some(RpgAgentOperationType::DraftFoundation), "update_draft_card" => Some(RpgAgentOperationType::UpdateDraftCard), "sync_result_profile" => Some(RpgAgentOperationType::SyncResultProfile), "generate_characters" => Some(RpgAgentOperationType::GenerateCharacters), "generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks), "generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets), "sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets), "generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets), "sync_scene_assets" => Some(RpgAgentOperationType::SyncSceneAssets), "expand_long_tail" => Some(RpgAgentOperationType::ExpandLongTail), "publish_world" => Some(RpgAgentOperationType::PublishWorld), "revert_checkpoint" => Some(RpgAgentOperationType::RevertCheckpoint), _ => None, } } fn parse_rpg_agent_stage(value: &str) -> Option { match value.trim() { "collecting_intent" => Some(RpgAgentStage::CollectingIntent), "clarifying" => Some(RpgAgentStage::Clarifying), "foundation_review" => Some(RpgAgentStage::FoundationReview), "object_refining" => Some(RpgAgentStage::ObjectRefining), "visual_refining" => Some(RpgAgentStage::VisualRefining), "long_tail_review" => Some(RpgAgentStage::LongTailReview), "ready_to_publish" => Some(RpgAgentStage::ReadyToPublish), "published" => Some(RpgAgentStage::Published), "error" => Some(RpgAgentStage::Error), _ => None, } } fn resolve_rpg_agent_stage_label(stage: RpgAgentStage) -> &'static str { match stage { RpgAgentStage::CollectingIntent => "收集世界锚点", RpgAgentStage::Clarifying => "补齐关键锚点", RpgAgentStage::FoundationReview => "准备整理底稿", RpgAgentStage::ObjectRefining => "待完善草稿", RpgAgentStage::VisualRefining => "视觉工坊", RpgAgentStage::LongTailReview => "扩展长尾", RpgAgentStage::ReadyToPublish => "准备发布", RpgAgentStage::Published => "已发布", RpgAgentStage::Error => "发生错误", } } fn parse_optional_session_object(value: Option<&str>) -> Option> { value .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| serde_json::from_str::(value).ok()) .and_then(|value| value.as_object().cloned()) } fn parse_json_array_or_empty(raw: &str) -> Vec { serde_json::from_str::(raw) .ok() .and_then(|value| value.as_array().cloned()) .unwrap_or_default() } fn serialize_json_value(value: &JsonValue) -> Result { serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) } fn read_required_payload_text( payload: &JsonMap, key: &str, error_message: &str, ) -> Result { payload .get(key) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| error_message.to_string()) } fn read_optional_text_field(object: &JsonMap, keys: &[&str]) -> Option { for key in keys { let mut current = JsonValue::Object(object.clone()); let mut found = true; for segment in key.split('.') { if let Some(next) = current.get(segment) { current = next.clone(); } else { found = false; break; } } if found { if let Some(value) = current .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { return Some(value.to_string()); } } } None } fn resolve_session_work_title( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["name", "title"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "未命名草稿".to_string()) } fn resolve_session_work_summary( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["summary"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()) } fn resolve_session_work_subtitle( draft_profile: Option<&JsonMap>, stage_label: Option<&str>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["subtitle"])) .or_else(|| stage_label.map(ToOwned::to_owned)) .unwrap_or_default() } fn resolve_session_work_cover_image_src( draft_profile: Option<&JsonMap>, ) -> Option { let profile = draft_profile?; if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) { if let Some(image_src) = read_optional_text_field(camp, &["imageSrc"]) { return Some(image_src); } } if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) { for landmark in landmarks { if let Some(object) = landmark.as_object() { if let Some(image_src) = read_optional_text_field(object, &["imageSrc"]) { return Some(image_src); } } } } None } fn resolve_session_work_counts( ctx: &ReducerContext, session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> (u32, u32) { if let Some(profile) = draft_profile { let role_count = profile .get("playableNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0) + profile .get("storyNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); let landmark_count = profile .get("landmarks") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); return (role_count, landmark_count); } let mut role_count = 0u32; let mut landmark_count = 0u32; for card in ctx .db .custom_world_draft_card() .iter() .filter(|row| row.session_id == session.session_id) { match card.kind { RpgAgentDraftCardKind::Character => { role_count = role_count.saturating_add(1); } RpgAgentDraftCardKind::Landmark => { landmark_count = landmark_count.saturating_add(1); } _ => {} } } (role_count, landmark_count) } fn ensure_minimal_draft_profile( mut profile: JsonMap, seed_text: &str, ) -> JsonMap { if read_optional_text_field(&profile, &["name", "title"]).is_none() { profile.insert( "name".to_string(), JsonValue::String(seed_text.trim().to_string().if_empty("未命名草稿")), ); } if read_optional_text_field(&profile, &["summary"]).is_none() { profile.insert( "summary".to_string(), JsonValue::String( (!seed_text.trim().is_empty()) .then(|| seed_text.trim().to_string()) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()), ), ); } profile .entry("subtitle".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("worldHook".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("playerPremise".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("coreConflicts".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("playableNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("storyNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("landmarks".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("chapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("sceneChapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile } fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap { ensure_minimal_draft_profile(JsonMap::new(), seed_text) } fn build_session_checkpoint_value( checkpoint_id_suffix: &str, label: &str, session: &CustomWorldAgentSession, ) -> JsonValue { json!({ "checkpointId": format!("checkpoint-{}-{}", session.session_id, checkpoint_id_suffix), "createdAt": format_timestamp_micros(session.updated_at.to_micros_since_unix_epoch()), "label": label, "snapshot": { "stage": session.stage.as_str(), "progressPercent": session.progress_percent, "draftProfile": parse_optional_session_object(session.draft_profile_json.as_deref()).map(JsonValue::Object), "qualityFindings": parse_json_array_or_empty(&session.quality_findings_json), } }) } fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result { let mut checkpoints = parse_json_array_or_empty(current); checkpoints.push(checkpoint.clone()); serialize_json_value(&JsonValue::Array(checkpoints)) } fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option { sections.iter().find_map(|entry| { let object = entry.as_object()?; (object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| { object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string() }) }) } fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool { value .and_then(JsonValue::as_array) .map(|entries| { entries.iter().any(|entry| { entry .as_str() .map(str::trim) .filter(|text| !text.is_empty()) .is_some() }) }) .unwrap_or(false) } trait IfEmptyString { fn if_empty(self, fallback: &str) -> String; } impl IfEmptyString for String { fn if_empty(self, fallback: &str) -> String { if self.trim().is_empty() { fallback.to_string() } else { self } } } fn mark_custom_world_agent_session_published( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, updated_at_micros: i64, ) -> Result { let existing = ctx .db .custom_world_agent_session() .session_id() .find(&session_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在,无法推进到 published".to_string())?; ctx.db .custom_world_agent_session() .session_id() .delete(&existing.session_id); let next_row = CustomWorldAgentSession { session_id: existing.session_id.clone(), owner_user_id: existing.owner_user_id.clone(), seed_text: existing.seed_text.clone(), current_turn: existing.current_turn, progress_percent: existing.progress_percent, stage: RpgAgentStage::Published, focus_card_id: existing.focus_card_id.clone(), anchor_content_json: existing.anchor_content_json.clone(), creator_intent_json: existing.creator_intent_json.clone(), creator_intent_readiness_json: existing.creator_intent_readiness_json.clone(), anchor_pack_json: existing.anchor_pack_json.clone(), lock_state_json: existing.lock_state_json.clone(), draft_profile_json: existing.draft_profile_json.clone(), last_assistant_reply: existing.last_assistant_reply.clone(), publish_gate_json: existing.publish_gate_json.clone(), result_preview_json: existing.result_preview_json.clone(), pending_clarifications_json: existing.pending_clarifications_json.clone(), quality_findings_json: existing.quality_findings_json.clone(), suggested_actions_json: existing.suggested_actions_json.clone(), recommended_replies_json: existing.recommended_replies_json.clone(), asset_coverage_json: existing.asset_coverage_json.clone(), checkpoints_json: existing.checkpoints_json.clone(), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }; ctx.db.custom_world_agent_session().insert(next_row); Ok(RpgAgentStage::Published) } fn sync_custom_world_gallery_entry_from_profile( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> Result { let published_at = profile .published_at .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; ctx.db .custom_world_gallery_entry() .profile_id() .delete(&profile.profile_id); let row = CustomWorldGalleryEntry { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), author_display_name: profile.author_display_name.clone(), world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), theme_mode: profile.theme_mode, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, published_at, updated_at: profile.updated_at, }; let inserted = ctx.db.custom_world_gallery_entry().insert(row); Ok(build_custom_world_gallery_entry_snapshot(&inserted)) } fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { CustomWorldProfileSnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_agent_session_id: row.source_agent_session_id.clone(), publication_status: row.publication_status, world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), summary_text: row.summary_text.clone(), theme_mode: row.theme_mode, cover_image_src: row.cover_image_src.clone(), profile_payload_json: row.profile_payload_json.clone(), playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, author_display_name: row.author_display_name.clone(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), deleted_at_micros: row .deleted_at .map(|value| value.to_micros_since_unix_epoch()), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_session_snapshot( ctx: &ReducerContext, row: &CustomWorldAgentSession, ) -> CustomWorldAgentSessionSnapshot { let mut messages = ctx .db .custom_world_agent_message() .iter() .filter(|message| message.session_id == row.session_id) .map(|message| build_custom_world_agent_message_snapshot(&message)) .collect::>(); messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); let mut draft_cards = ctx .db .custom_world_draft_card() .iter() .filter(|card| card.session_id == row.session_id) .map(|card| build_custom_world_draft_card_snapshot(&card)) .collect::>(); draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone())); let mut operations = ctx .db .custom_world_agent_operation() .iter() .filter(|operation| operation.session_id == row.session_id) .map(|operation| build_custom_world_agent_operation_snapshot(&operation)) .collect::>(); operations .sort_by_key(|operation| (operation.created_at_micros, operation.operation_id.clone())); CustomWorldAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage, focus_card_id: row.focus_card_id.clone(), anchor_content_json: row.anchor_content_json.clone(), creator_intent_json: row.creator_intent_json.clone(), creator_intent_readiness_json: row.creator_intent_readiness_json.clone(), anchor_pack_json: row.anchor_pack_json.clone(), lock_state_json: row.lock_state_json.clone(), draft_profile_json: row.draft_profile_json.clone(), last_assistant_reply: row.last_assistant_reply.clone(), publish_gate_json: row.publish_gate_json.clone(), result_preview_json: row.result_preview_json.clone(), pending_clarifications_json: row.pending_clarifications_json.clone(), quality_findings_json: row.quality_findings_json.clone(), suggested_actions_json: row.suggested_actions_json.clone(), recommended_replies_json: row.recommended_replies_json.clone(), asset_coverage_json: row.asset_coverage_json.clone(), checkpoints_json: row.checkpoints_json.clone(), supported_actions_json: serialize_json_value(&JsonValue::Array( build_supported_actions_json( row.stage, row.progress_percent, &build_custom_world_publish_gate_from_session(row), &parse_json_array_or_empty(&row.checkpoints_json), ), )) .unwrap_or_else(|_| "[]".to_string()), messages, draft_cards, operations, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_message_snapshot( row: &CustomWorldAgentMessage, ) -> CustomWorldAgentMessageSnapshot { CustomWorldAgentMessageSnapshot { message_id: row.message_id.clone(), session_id: row.session_id.clone(), role: row.role, kind: row.kind, text: row.text.clone(), related_operation_id: row.related_operation_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_operation_snapshot( row: &CustomWorldAgentOperation, ) -> CustomWorldAgentOperationSnapshot { CustomWorldAgentOperationSnapshot { operation_id: row.operation_id.clone(), session_id: row.session_id.clone(), operation_type: row.operation_type, status: row.status, phase_label: row.phase_label.clone(), phase_detail: row.phase_detail.clone(), progress: row.progress, error_message: row.error_message.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_draft_card_snapshot( row: &CustomWorldDraftCard, ) -> CustomWorldDraftCardSnapshot { CustomWorldDraftCardSnapshot { card_id: row.card_id.clone(), session_id: row.session_id.clone(), kind: row.kind, status: row.status, title: row.title.clone(), subtitle: row.subtitle.clone(), summary: row.summary.clone(), linked_ids_json: row.linked_ids_json.clone(), warning_count: row.warning_count, asset_status: row.asset_status, asset_status_label: row.asset_status_label.clone(), detail_payload_json: row.detail_payload_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 build_custom_world_gallery_entry_snapshot( row: &CustomWorldGalleryEntry, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.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(), theme_mode: row.theme_mode, playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn create_ai_task_tx( ctx: &ReducerContext, input: AiTaskCreateInput, ) -> Result { validate_task_create_input(&input).map_err(|error| error.to_string())?; if ctx.db.ai_task().task_id().find(&input.task_id).is_some() { return Err("ai_task.task_id 已存在".to_string()); } let task_snapshot = build_ai_task_snapshot_from_create_input(&input); ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot)); replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages); get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id) } fn start_ai_task_tx( ctx: &ReducerContext, input: AiTaskStartInput, ) -> Result { let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; snapshot.status = AiTaskStatus::Running; if snapshot.started_at_micros.is_none() { snapshot.started_at_micros = Some(input.started_at_micros); } snapshot.updated_at_micros = input.started_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn start_ai_task_stage_tx( ctx: &ReducerContext, input: AiTaskStageStartInput, ) -> Result { let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; let stage = snapshot .stages .iter_mut() .find(|stage| stage.stage_kind == input.stage_kind) .ok_or_else(|| "ai_task.stage 不存在".to_string())?; snapshot.status = AiTaskStatus::Running; if snapshot.started_at_micros.is_none() { snapshot.started_at_micros = Some(input.started_at_micros); } stage.status = AiTaskStageStatus::Running; if stage.started_at_micros.is_none() { stage.started_at_micros = Some(input.started_at_micros); } snapshot.updated_at_micros = input.started_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn append_ai_text_chunk_tx( ctx: &ReducerContext, input: AiTextChunkAppendInput, ) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), String> { if input.delta_text.trim().is_empty() { return Err("ai_text_chunk.delta_text 不能为空".to_string()); } if input.sequence == 0 { return Err("ai_text_chunk.sequence 必须大于 0".to_string()); } let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; let stage = snapshot .stages .iter_mut() .find(|stage| stage.stage_kind == input.stage_kind) .ok_or_else(|| "ai_task.stage 不存在".to_string())?; let chunk = AiTextChunkSnapshot { chunk_id: generate_ai_text_chunk_id(input.created_at_micros, input.sequence), task_id: input.task_id.trim().to_string(), stage_kind: input.stage_kind, sequence: input.sequence, delta_text: input.delta_text.trim().to_string(), created_at_micros: input.created_at_micros, }; ctx.db .ai_text_chunk() .insert(build_ai_text_chunk_row(&chunk)); let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind); snapshot.status = AiTaskStatus::Running; if snapshot.started_at_micros.is_none() { snapshot.started_at_micros = Some(input.created_at_micros); } stage.status = AiTaskStageStatus::Running; if stage.started_at_micros.is_none() { stage.started_at_micros = Some(input.created_at_micros); } stage.text_output = aggregated_text.clone(); snapshot.latest_text_output = aggregated_text; snapshot.updated_at_micros = input.created_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok((snapshot, chunk)) } fn complete_ai_stage_tx( ctx: &ReducerContext, input: AiStageCompletionInput, ) -> Result { let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; let stage = snapshot .stages .iter_mut() .find(|stage| stage.stage_kind == input.stage_kind) .ok_or_else(|| "ai_task.stage 不存在".to_string())?; stage.status = AiTaskStageStatus::Completed; stage.completed_at_micros = Some(input.completed_at_micros); stage.text_output = normalize_optional_text(input.text_output.clone()); stage.structured_payload_json = normalize_optional_text(input.structured_payload_json.clone()); stage.warning_messages = normalize_string_list(input.warning_messages.clone()); snapshot.latest_text_output = stage.text_output.clone(); snapshot.latest_structured_payload_json = stage.structured_payload_json.clone(); snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn attach_ai_result_reference_tx( ctx: &ReducerContext, input: AiResultReferenceInput, ) -> Result { let reference_id = input.reference_id.trim().to_string(); if reference_id.is_empty() { return Err("ai_result_reference.reference_id 不能为空".to_string()); } let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; let reference = AiResultReferenceSnapshot { result_ref_id: generate_ai_result_ref_id(input.created_at_micros), task_id: input.task_id.trim().to_string(), reference_kind: input.reference_kind, reference_id, label: normalize_optional_text(input.label), created_at_micros: input.created_at_micros, }; ctx.db .ai_result_reference() .insert(build_ai_result_reference_row(&reference)); snapshot.result_references.push(reference); snapshot.updated_at_micros = input.created_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn complete_ai_task_tx( ctx: &ReducerContext, input: AiTaskFinishInput, ) -> Result { let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; snapshot.status = AiTaskStatus::Completed; snapshot.completed_at_micros = Some(input.completed_at_micros); snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn fail_ai_task_tx( ctx: &ReducerContext, input: AiTaskFailureInput, ) -> Result { let failure_message = input.failure_message.trim().to_string(); if failure_message.is_empty() { return Err("ai_task.failure_message 不能为空".to_string()); } let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; snapshot.status = AiTaskStatus::Failed; snapshot.failure_message = Some(failure_message); snapshot.completed_at_micros = Some(input.completed_at_micros); snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn cancel_ai_task_tx( ctx: &ReducerContext, input: AiTaskCancelInput, ) -> Result { let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; snapshot.status = AiTaskStatus::Cancelled; snapshot.completed_at_micros = Some(input.completed_at_micros); snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; persist_ai_task_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn get_ai_task_snapshot_tx(ctx: &ReducerContext, task_id: &str) -> Result { let row = ctx .db .ai_task() .task_id() .find(&task_id.trim().to_string()) .ok_or_else(|| "ai_task 不存在".to_string())?; Ok(build_ai_task_snapshot_from_row(ctx, &row)) } fn persist_ai_task_snapshot(ctx: &ReducerContext, snapshot: &AiTaskSnapshot) -> Result<(), String> { ctx.db.ai_task().task_id().delete(&snapshot.task_id); ctx.db.ai_task().insert(build_ai_task_row(snapshot)); replace_ai_task_stages(ctx, &snapshot.task_id, &snapshot.stages); Ok(()) } fn replace_ai_task_stages(ctx: &ReducerContext, task_id: &str, stages: &[AiTaskStageSnapshot]) { let stage_ids = ctx .db .ai_task_stage() .iter() .filter(|row| row.task_id == task_id) .map(|row| row.task_stage_id.clone()) .collect::>(); for stage_id in stage_ids { ctx.db.ai_task_stage().task_stage_id().delete(&stage_id); } for stage in stages { ctx.db .ai_task_stage() .insert(build_ai_task_stage_row(task_id, stage)); } } fn collect_ai_stage_text_output( ctx: &ReducerContext, task_id: &str, stage_kind: AiTaskStageKind, ) -> Option { let mut chunks = ctx .db .ai_text_chunk() .iter() .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind) .map(|row| build_ai_text_chunk_snapshot_from_row(&row)) .collect::>(); chunks.sort_by_key(|chunk| chunk.sequence); let aggregated = chunks .into_iter() .map(|chunk| chunk.delta_text) .collect::>() .join(""); if aggregated.trim().is_empty() { None } else { Some(aggregated) } } fn ensure_ai_task_can_transition(status: AiTaskStatus) -> Result<(), String> { if matches!( status, AiTaskStatus::Completed | AiTaskStatus::Failed | AiTaskStatus::Cancelled ) { Err("当前 ai_task 状态不允许执行该操作".to_string()) } else { Ok(()) } } fn build_ai_task_snapshot_from_create_input(input: &AiTaskCreateInput) -> AiTaskSnapshot { AiTaskSnapshot { task_id: input.task_id.trim().to_string(), task_kind: input.task_kind, owner_user_id: input.owner_user_id.trim().to_string(), request_label: input.request_label.trim().to_string(), source_module: input.source_module.trim().to_string(), source_entity_id: normalize_optional_text(input.source_entity_id.clone()), request_payload_json: normalize_optional_text(input.request_payload_json.clone()), status: AiTaskStatus::Pending, failure_message: None, stages: input .stages .iter() .map(|stage| AiTaskStageSnapshot { stage_kind: stage.stage_kind, label: stage.label.trim().to_string(), detail: stage.detail.trim().to_string(), order: stage.order, status: AiTaskStageStatus::Pending, text_output: None, structured_payload_json: None, warning_messages: Vec::new(), started_at_micros: None, completed_at_micros: None, }) .collect(), result_references: Vec::new(), latest_text_output: None, latest_structured_payload_json: None, version: INITIAL_AI_TASK_VERSION, created_at_micros: input.created_at_micros, started_at_micros: None, completed_at_micros: None, updated_at_micros: input.created_at_micros, } } fn build_ai_task_row(snapshot: &AiTaskSnapshot) -> AiTask { AiTask { task_id: snapshot.task_id.clone(), task_kind: snapshot.task_kind, owner_user_id: snapshot.owner_user_id.clone(), request_label: snapshot.request_label.clone(), source_module: snapshot.source_module.clone(), source_entity_id: snapshot.source_entity_id.clone(), request_payload_json: snapshot.request_payload_json.clone(), status: snapshot.status, failure_message: snapshot.failure_message.clone(), latest_text_output: snapshot.latest_text_output.clone(), latest_structured_payload_json: snapshot.latest_structured_payload_json.clone(), version: snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), started_at: snapshot .started_at_micros .map(Timestamp::from_micros_since_unix_epoch), completed_at: snapshot .completed_at_micros .map(Timestamp::from_micros_since_unix_epoch), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_ai_task_snapshot_from_row(ctx: &ReducerContext, row: &AiTask) -> AiTaskSnapshot { let mut stages = ctx .db .ai_task_stage() .iter() .filter(|stage| stage.task_id == row.task_id) .map(|stage| build_ai_task_stage_snapshot_from_row(&stage)) .collect::>(); stages.sort_by_key(|stage| stage.order); let mut result_references = ctx .db .ai_result_reference() .iter() .filter(|reference| reference.task_id == row.task_id) .map(|reference| build_ai_result_reference_snapshot_from_row(&reference)) .collect::>(); result_references.sort_by_key(|reference| reference.created_at_micros); AiTaskSnapshot { task_id: row.task_id.clone(), task_kind: row.task_kind, owner_user_id: row.owner_user_id.clone(), request_label: row.request_label.clone(), source_module: row.source_module.clone(), source_entity_id: row.source_entity_id.clone(), request_payload_json: row.request_payload_json.clone(), status: row.status, failure_message: row.failure_message.clone(), stages, result_references, latest_text_output: row.latest_text_output.clone(), latest_structured_payload_json: row.latest_structured_payload_json.clone(), version: row.version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), started_at_micros: row .started_at .map(|value| value.to_micros_since_unix_epoch()), completed_at_micros: row .completed_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_ai_task_stage_row(task_id: &str, snapshot: &AiTaskStageSnapshot) -> AiTaskStage { AiTaskStage { task_stage_id: generate_ai_task_stage_id(task_id, snapshot.stage_kind), task_id: task_id.to_string(), stage_kind: snapshot.stage_kind, label: snapshot.label.clone(), detail: snapshot.detail.clone(), stage_order: snapshot.order, status: snapshot.status, text_output: snapshot.text_output.clone(), structured_payload_json: snapshot.structured_payload_json.clone(), warning_messages: snapshot.warning_messages.clone(), started_at: snapshot .started_at_micros .map(Timestamp::from_micros_since_unix_epoch), completed_at: snapshot .completed_at_micros .map(Timestamp::from_micros_since_unix_epoch), } } fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTaskStageSnapshot { AiTaskStageSnapshot { stage_kind: row.stage_kind, label: row.label.clone(), detail: row.detail.clone(), order: row.stage_order, status: row.status, text_output: row.text_output.clone(), structured_payload_json: row.structured_payload_json.clone(), warning_messages: row.warning_messages.clone(), started_at_micros: row .started_at .map(|value| value.to_micros_since_unix_epoch()), completed_at_micros: row .completed_at .map(|value| value.to_micros_since_unix_epoch()), } } fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextChunk { AiTextChunk { text_chunk_row_id: format!( "{}{}_{}_{}", AI_TEXT_CHUNK_ID_PREFIX, snapshot.task_id, snapshot.stage_kind.as_str(), snapshot.sequence ), chunk_id: snapshot.chunk_id.clone(), task_id: snapshot.task_id.clone(), stage_kind: snapshot.stage_kind, sequence: snapshot.sequence, delta_text: snapshot.delta_text.clone(), created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), } } fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot { AiTextChunkSnapshot { chunk_id: row.chunk_id.clone(), task_id: row.task_id.clone(), stage_kind: row.stage_kind, sequence: row.sequence, delta_text: row.delta_text.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_ai_result_reference_row(snapshot: &AiResultReferenceSnapshot) -> AiResultReference { AiResultReference { result_reference_row_id: format!( "{}{}_{}", AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id ), result_ref_id: snapshot.result_ref_id.clone(), task_id: snapshot.task_id.clone(), reference_kind: snapshot.reference_kind, reference_id: snapshot.reference_id.clone(), label: snapshot.label.clone(), created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), } } fn build_ai_result_reference_snapshot_from_row( row: &AiResultReference, ) -> AiResultReferenceSnapshot { AiResultReferenceSnapshot { result_ref_id: row.result_ref_id.clone(), task_id: row.task_id.clone(), reference_kind: row.reference_kind, reference_id: row.reference_id.clone(), label: row.label.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_quest_record_row(snapshot: QuestRecordSnapshot) -> QuestRecord { QuestRecord { quest_id: snapshot.quest_id, runtime_session_id: snapshot.runtime_session_id, story_session_id: snapshot.story_session_id, actor_user_id: snapshot.actor_user_id, issuer_npc_id: snapshot.issuer_npc_id, issuer_npc_name: snapshot.issuer_npc_name, scene_id: snapshot.scene_id, chapter_id: snapshot.chapter_id, act_id: snapshot.act_id, thread_id: snapshot.thread_id, contract_id: snapshot.contract_id, title: snapshot.title, description: snapshot.description, summary: snapshot.summary, objective: snapshot.objective, progress: snapshot.progress, status: snapshot.status, completion_notified: snapshot.completion_notified, reward: snapshot.reward, reward_text: snapshot.reward_text, narrative_binding: snapshot.narrative_binding, steps: snapshot.steps, active_step_id: snapshot.active_step_id, visible_stage: snapshot.visible_stage, hidden_flags: snapshot.hidden_flags, discovered_fact_ids: snapshot.discovered_fact_ids, related_carrier_ids: snapshot.related_carrier_ids, consequence_ids: snapshot.consequence_ids, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), completed_at: snapshot .completed_at_micros .map(Timestamp::from_micros_since_unix_epoch), turned_in_at: snapshot .turned_in_at_micros .map(Timestamp::from_micros_since_unix_epoch), } } fn build_player_progression_row(snapshot: PlayerProgressionSnapshot) -> PlayerProgression { PlayerProgression { user_id: snapshot.user_id, level: snapshot.level, current_level_xp: snapshot.current_level_xp, total_xp: snapshot.total_xp, xp_to_next_level: snapshot.xp_to_next_level, pending_level_ups: snapshot.pending_level_ups, last_granted_source: snapshot.last_granted_source, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_player_progression_snapshot_from_row( row: &PlayerProgression, ) -> PlayerProgressionSnapshot { PlayerProgressionSnapshot { user_id: row.user_id.clone(), level: row.level, current_level_xp: row.current_level_xp, total_xp: row.total_xp, xp_to_next_level: row.xp_to_next_level, pending_level_ups: row.pending_level_ups, last_granted_source: row.last_granted_source, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_chapter_progression_id(user_id: &str, chapter_id: &str) -> String { format!("chapprog_{}_{}", user_id.trim(), chapter_id.trim()) } fn build_chapter_progression_row(snapshot: ChapterProgressionSnapshot) -> ChapterProgression { ChapterProgression { chapter_progression_id: build_chapter_progression_id( &snapshot.user_id, &snapshot.chapter_id, ), user_id: snapshot.user_id, chapter_id: snapshot.chapter_id, chapter_index: snapshot.chapter_index, total_chapters: snapshot.total_chapters, entry_pseudo_level_millis: snapshot.entry_pseudo_level_millis, exit_pseudo_level_millis: snapshot.exit_pseudo_level_millis, entry_level: snapshot.entry_level, exit_level: snapshot.exit_level, planned_total_xp: snapshot.planned_total_xp, planned_quest_xp: snapshot.planned_quest_xp, planned_hostile_xp: snapshot.planned_hostile_xp, actual_quest_xp: snapshot.actual_quest_xp, actual_hostile_xp: snapshot.actual_hostile_xp, expected_hostile_defeat_count: snapshot.expected_hostile_defeat_count, actual_hostile_defeat_count: snapshot.actual_hostile_defeat_count, level_at_entry: snapshot.level_at_entry, level_at_exit: snapshot.level_at_exit, pace_band: snapshot.pace_band, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_chapter_progression_snapshot_from_row( row: &ChapterProgression, ) -> ChapterProgressionSnapshot { ChapterProgressionSnapshot { user_id: row.user_id.clone(), chapter_id: row.chapter_id.clone(), chapter_index: row.chapter_index, total_chapters: row.total_chapters, entry_pseudo_level_millis: row.entry_pseudo_level_millis, exit_pseudo_level_millis: row.exit_pseudo_level_millis, entry_level: row.entry_level, exit_level: row.exit_level, planned_total_xp: row.planned_total_xp, planned_quest_xp: row.planned_quest_xp, planned_hostile_xp: row.planned_hostile_xp, actual_quest_xp: row.actual_quest_xp, actual_hostile_xp: row.actual_hostile_xp, expected_hostile_defeat_count: row.expected_hostile_defeat_count, actual_hostile_defeat_count: row.actual_hostile_defeat_count, level_at_entry: row.level_at_entry, level_at_exit: row.level_at_exit, pace_band: row.pace_band, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_quest_record_snapshot_from_row(row: &QuestRecord) -> QuestRecordSnapshot { QuestRecordSnapshot { quest_id: row.quest_id.clone(), runtime_session_id: row.runtime_session_id.clone(), story_session_id: row.story_session_id.clone(), actor_user_id: row.actor_user_id.clone(), issuer_npc_id: row.issuer_npc_id.clone(), issuer_npc_name: row.issuer_npc_name.clone(), scene_id: row.scene_id.clone(), chapter_id: row.chapter_id.clone(), act_id: row.act_id.clone(), thread_id: row.thread_id.clone(), contract_id: row.contract_id.clone(), title: row.title.clone(), description: row.description.clone(), summary: row.summary.clone(), objective: row.objective.clone(), progress: row.progress, status: row.status, completion_notified: row.completion_notified, reward: row.reward.clone(), reward_text: row.reward_text.clone(), narrative_binding: row.narrative_binding.clone(), steps: row.steps.clone(), active_step_id: row.active_step_id.clone(), visible_stage: row.visible_stage, hidden_flags: row.hidden_flags.clone(), discovered_fact_ids: row.discovered_fact_ids.clone(), related_carrier_ids: row.related_carrier_ids.clone(), consequence_ids: row.consequence_ids.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), completed_at_micros: row .completed_at .map(|value| value.to_micros_since_unix_epoch()), turned_in_at_micros: row .turned_in_at .map(|value| value.to_micros_since_unix_epoch()), } } fn build_inventory_slot_row(snapshot: InventorySlotSnapshot) -> InventorySlot { InventorySlot { slot_id: snapshot.slot_id, runtime_session_id: snapshot.runtime_session_id, story_session_id: snapshot.story_session_id, actor_user_id: snapshot.actor_user_id, container_kind: snapshot.container_kind, slot_key: snapshot.slot_key, item_id: snapshot.item_id, category: snapshot.category, name: snapshot.name, description: snapshot.description, quantity: snapshot.quantity, rarity: snapshot.rarity, tags: snapshot.tags, stackable: snapshot.stackable, stack_key: snapshot.stack_key, equipment_slot_id: snapshot.equipment_slot_id, source_kind: snapshot.source_kind, source_reference_id: snapshot.source_reference_id, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_inventory_slot_snapshot_from_row(row: &InventorySlot) -> InventorySlotSnapshot { InventorySlotSnapshot { slot_id: row.slot_id.clone(), runtime_session_id: row.runtime_session_id.clone(), story_session_id: row.story_session_id.clone(), actor_user_id: row.actor_user_id.clone(), container_kind: row.container_kind, slot_key: row.slot_key.clone(), item_id: row.item_id.clone(), category: row.category.clone(), name: row.name.clone(), description: row.description.clone(), quantity: row.quantity, rarity: row.rarity, tags: row.tags.clone(), stackable: row.stackable, stack_key: row.stack_key.clone(), equipment_slot_id: row.equipment_slot_id, source_kind: row.source_kind, source_reference_id: row.source_reference_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn grant_quest_reward_items( ctx: &ReducerContext, snapshot: &QuestRecordSnapshot, ) -> Result<(), String> { if !ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == snapshot.runtime_session_id && row.actor_user_id == snapshot.actor_user_id }) .all(|row| row.source_reference_id.as_deref() != Some(snapshot.quest_id.as_str())) { return Ok(()); } for (index, reward_item) in snapshot.reward.items.clone().into_iter().enumerate() { let inventory_item = build_inventory_item_snapshot_from_quest_reward_item(&snapshot.quest_id, reward_item); grant_inventory_item_to_actor( ctx, &snapshot.runtime_session_id, snapshot.story_session_id.clone(), &snapshot.actor_user_id, inventory_item, build_reward_seed(snapshot.updated_at_micros, index), snapshot.updated_at_micros, )?; } Ok(()) } fn grant_battle_reward_items( ctx: &ReducerContext, snapshot: &BattleStateSnapshot, ) -> Result<(), String> { if snapshot.reward_items.is_empty() { return Ok(()); } if !ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == snapshot.runtime_session_id && row.actor_user_id == snapshot.actor_user_id }) .all(|row| row.source_reference_id.as_deref() != Some(snapshot.battle_state_id.as_str())) { return Ok(()); } for (index, reward_item) in snapshot.reward_items.clone().into_iter().enumerate() { let inventory_item = build_inventory_item_snapshot_from_battle_reward_item( &snapshot.battle_state_id, reward_item, ); grant_inventory_item_to_actor( ctx, &snapshot.runtime_session_id, Some(snapshot.story_session_id.clone()), &snapshot.actor_user_id, inventory_item, build_reward_seed(snapshot.updated_at_micros, index), snapshot.updated_at_micros, )?; } Ok(()) } fn grant_inventory_item_to_actor( ctx: &ReducerContext, runtime_session_id: &str, story_session_id: Option, actor_user_id: &str, item: InventoryItemSnapshot, seed_micros: i64, updated_at_micros: i64, ) -> Result<(), String> { let current_slots = ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == runtime_session_id && row.actor_user_id == actor_user_id }) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); let slot_id = generate_inventory_slot_id(seed_micros); let mutation_id = generate_inventory_mutation_id(seed_micros); let outcome = apply_inventory_slot_mutation( current_slots, InventoryMutationInput { mutation_id, runtime_session_id: runtime_session_id.to_string(), story_session_id, actor_user_id: actor_user_id.to_string(), mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id, item }), updated_at_micros, }, ) .map_err(|error| error.to_string())?; for removed_slot_id in outcome.removed_slot_ids { ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); } for slot in outcome.next_slots { ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); ctx.db .inventory_slot() .insert(build_inventory_slot_row(slot)); } Ok(()) } fn build_inventory_item_snapshot_from_battle_reward_item( battle_state_id: &str, reward_item: RuntimeItemRewardItemSnapshot, ) -> InventoryItemSnapshot { InventoryItemSnapshot { item_id: reward_item.item_id, category: reward_item.category, name: reward_item.item_name, description: reward_item.description, quantity: reward_item.quantity, rarity: map_runtime_reward_item_rarity(reward_item.rarity), tags: reward_item.tags, stackable: reward_item.stackable, stack_key: reward_item.stack_key, equipment_slot_id: reward_item .equipment_slot_id .map(map_runtime_reward_equipment_slot), source_kind: InventoryItemSourceKind::CombatDrop, source_reference_id: Some(battle_state_id.to_string()), } } fn build_inventory_item_snapshot_from_quest_reward_item( quest_id: &str, reward_item: QuestRewardItem, ) -> InventoryItemSnapshot { InventoryItemSnapshot { item_id: reward_item.item_id, category: reward_item.category, name: reward_item.name, description: reward_item.description, quantity: reward_item.quantity, rarity: map_quest_reward_item_rarity(reward_item.rarity), tags: reward_item.tags, stackable: reward_item.stackable, stack_key: reward_item.stack_key, equipment_slot_id: reward_item .equipment_slot_id .map(map_quest_reward_equipment_slot), source_kind: InventoryItemSourceKind::QuestReward, source_reference_id: Some(quest_id.to_string()), } } fn map_quest_reward_item_rarity(rarity: QuestRewardItemRarity) -> InventoryItemRarity { match rarity { QuestRewardItemRarity::Common => InventoryItemRarity::Common, QuestRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, QuestRewardItemRarity::Rare => InventoryItemRarity::Rare, QuestRewardItemRarity::Epic => InventoryItemRarity::Epic, QuestRewardItemRarity::Legendary => InventoryItemRarity::Legendary, } } fn map_runtime_reward_item_rarity( rarity: module_runtime_item::RuntimeItemRewardItemRarity, ) -> InventoryItemRarity { match rarity { module_runtime_item::RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common, module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, module_runtime_item::RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare, module_runtime_item::RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic, module_runtime_item::RuntimeItemRewardItemRarity::Legendary => { InventoryItemRarity::Legendary } } } fn map_quest_reward_equipment_slot(slot: QuestRewardEquipmentSlot) -> InventoryEquipmentSlot { match slot { QuestRewardEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, QuestRewardEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, QuestRewardEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, } } fn map_runtime_reward_equipment_slot( slot: module_runtime_item::RuntimeItemEquipmentSlot, ) -> InventoryEquipmentSlot { match slot { module_runtime_item::RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, module_runtime_item::RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, module_runtime_item::RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, } } fn build_reward_seed(updated_at_micros: i64, index: usize) -> i64 { updated_at_micros.saturating_add(index as i64 + 1) } fn build_story_session_snapshot_from_row(row: &StorySession) -> StorySessionSnapshot { StorySessionSnapshot { story_session_id: row.story_session_id.clone(), runtime_session_id: row.runtime_session_id.clone(), actor_user_id: row.actor_user_id.clone(), world_profile_id: row.world_profile_id.clone(), initial_prompt: row.initial_prompt.clone(), opening_summary: row.opening_summary.clone(), latest_narrative_text: row.latest_narrative_text.clone(), latest_choice_function_id: row.latest_choice_function_id.clone(), status: row.status, version: row.version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_story_event_snapshot_from_row(row: &StoryEvent) -> StoryEventSnapshot { StoryEventSnapshot { event_id: row.event_id.clone(), story_session_id: row.story_session_id.clone(), event_kind: row.event_kind, narrative_text: row.narrative_text.clone(), choice_function_id: row.choice_function_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_treasure_record_snapshot_from_row(row: &TreasureRecord) -> TreasureRecordSnapshot { TreasureRecordSnapshot { treasure_record_id: row.treasure_record_id.clone(), runtime_session_id: row.runtime_session_id.clone(), story_session_id: row.story_session_id.clone(), actor_user_id: row.actor_user_id.clone(), encounter_id: row.encounter_id.clone(), encounter_name: row.encounter_name.clone(), scene_id: row.scene_id.clone(), scene_name: row.scene_name.clone(), action: row.action, reward_items: row.reward_items.clone(), reward_hp: row.reward_hp, reward_mana: row.reward_mana, reward_currency: row.reward_currency, story_hint: row.story_hint.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn append_quest_log( ctx: &ReducerContext, snapshot: &QuestRecordSnapshot, event_kind: QuestLogEventKind, signal_kind: Option, signal: Option, step_id: Option, step_progress: Option, created_at_micros: i64, ) { ctx.db.quest_log().insert(QuestLog { log_id: generate_quest_log_id(&snapshot.quest_id, event_kind, created_at_micros), quest_id: snapshot.quest_id.clone(), runtime_session_id: snapshot.runtime_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), event_kind, status_after: snapshot.status, signal_kind, signal, step_id, step_progress, created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), }); } fn get_player_progression_snapshot_tx( ctx: &ReducerContext, input: PlayerProgressionGetInput, ) -> Result { let user_id = input.user_id.trim().to_string(); if user_id.is_empty() { return Err("player_progression.user_id 不能为空".to_string()); } if let Some(existing) = ctx.db.player_progression().user_id().find(&user_id) { return Ok(build_player_progression_snapshot_from_row(&existing)); } create_initial_player_progression(user_id, 0).map_err(|error| error.to_string()) } fn upsert_player_progression_after_grant_tx( ctx: &ReducerContext, input: PlayerProgressionGrantInput, ) -> Result { let current = if let Some(existing) = ctx.db.player_progression().user_id().find(&input.user_id) { build_player_progression_snapshot_from_row(&existing) } else { create_initial_player_progression(input.user_id.clone(), input.updated_at_micros) .map_err(|error| error.to_string())? }; let next = grant_player_experience(current, input).map_err(|error| error.to_string())?; if ctx .db .player_progression() .user_id() .find(&next.user_id) .is_some() { ctx.db.player_progression().user_id().delete(&next.user_id); } ctx.db .player_progression() .insert(build_player_progression_row(next.clone())); Ok(next) } fn get_chapter_progression_snapshot_tx( ctx: &ReducerContext, input: ChapterProgressionGetInput, ) -> Result { let user_id = input.user_id.trim().to_string(); let chapter_id = input.chapter_id.trim().to_string(); if user_id.is_empty() { return Err("chapter_progression.user_id 不能为空".to_string()); } if chapter_id.is_empty() { return Err("chapter_progression.chapter_id 不能为空".to_string()); } let row_id = build_chapter_progression_id(&user_id, &chapter_id); let existing = ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .ok_or_else(|| "chapter_progression 不存在".to_string())?; Ok(build_chapter_progression_snapshot_from_row(&existing)) } fn upsert_chapter_progression_snapshot_tx( ctx: &ReducerContext, input: ChapterProgressionInput, ) -> Result { let snapshot = build_chapter_progression_snapshot(input).map_err(|error| error.to_string())?; let row_id = build_chapter_progression_id(&snapshot.user_id, &snapshot.chapter_id); if ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .is_some() { ctx.db .chapter_progression() .chapter_progression_id() .delete(&row_id); } ctx.db .chapter_progression() .insert(build_chapter_progression_row(snapshot.clone())); Ok(snapshot) } fn update_chapter_progression_ledger_tx( ctx: &ReducerContext, input: ChapterProgressionLedgerInput, ) -> Result { let row_id = build_chapter_progression_id(&input.user_id, &input.chapter_id); let current = ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .ok_or_else(|| "chapter_progression 不存在,无法记账".to_string())?; let next = apply_chapter_progression_ledger( build_chapter_progression_snapshot_from_row(¤t), input, ) .map_err(|error| error.to_string())?; ctx.db .chapter_progression() .chapter_progression_id() .delete(&row_id); ctx.db .chapter_progression() .insert(build_chapter_progression_row(next.clone())); Ok(next) } fn try_update_chapter_progression_ledger_tx( ctx: &ReducerContext, user_id: String, chapter_id: Option, input: ChapterProgressionLedgerInput, ) -> Result, String> { let Some(chapter_id) = chapter_id.map(|value| value.trim().to_string()) else { return Ok(None); }; if chapter_id.is_empty() || user_id.trim().is_empty() { return Ok(None); } let row_id = build_chapter_progression_id(user_id.trim(), &chapter_id); if ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .is_none() { return Ok(None); } update_chapter_progression_ledger_tx(ctx, input).map(Some) } fn upsert_asset_entity_binding( ctx: &ReducerContext, input: AssetEntityBindingInput, ) -> Result { validate_asset_entity_binding_fields( &input.binding_id, &input.asset_object_id, &input.entity_kind, &input.entity_id, &input.slot, &input.asset_kind, ) .map_err(|error| error.to_string())?; if ctx .db .asset_object() .asset_object_id() .find(&input.asset_object_id) .is_none() { return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); } let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 let current = ctx.db.asset_entity_binding().iter().find(|row| { row.entity_kind == input.entity_kind && row.entity_id == input.entity_id && row.slot == input.slot }); let snapshot = match current { Some(existing) => { ctx.db .asset_entity_binding() .binding_id() .delete(&existing.binding_id); let row = AssetEntityBinding { binding_id: existing.binding_id.clone(), asset_object_id: input.asset_object_id.clone(), entity_kind: input.entity_kind.clone(), entity_id: input.entity_id.clone(), slot: input.slot.clone(), asset_kind: input.asset_kind.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), created_at: existing.created_at, updated_at, }; ctx.db.asset_entity_binding().insert(row); AssetEntityBindingSnapshot { binding_id: existing.binding_id, asset_object_id: input.asset_object_id, entity_kind: input.entity_kind, entity_id: input.entity_id, slot: input.slot, asset_kind: input.asset_kind, owner_user_id: input.owner_user_id, profile_id: input.profile_id, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: input.updated_at_micros, } } None => { let created_at = updated_at; let row = AssetEntityBinding { binding_id: input.binding_id.clone(), asset_object_id: input.asset_object_id.clone(), entity_kind: input.entity_kind.clone(), entity_id: input.entity_id.clone(), slot: input.slot.clone(), asset_kind: input.asset_kind.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), created_at, updated_at, }; ctx.db.asset_entity_binding().insert(row); AssetEntityBindingSnapshot { binding_id: input.binding_id, asset_object_id: input.asset_object_id, entity_kind: input.entity_kind, entity_id: input.entity_id, slot: input.slot, asset_kind: input.asset_kind, owner_user_id: input.owner_user_id, profile_id: input.profile_id, created_at_micros: input.updated_at_micros, updated_at_micros: input.updated_at_micros, } } }; Ok(snapshot) } fn get_runtime_setting_snapshot( ctx: &ReducerContext, input: RuntimeSettingGetInput, ) -> Result { let validated_input = build_runtime_setting_get_input(input.user_id).map_err(|error| error.to_string())?; if let Some(existing) = ctx .db .runtime_setting() .user_id() .find(&validated_input.user_id) { return Ok(RuntimeSettingSnapshot { user_id: existing.user_id, music_volume: existing.music_volume, platform_theme: existing.platform_theme, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: existing.updated_at.to_micros_since_unix_epoch(), }); } Ok(RuntimeSettingSnapshot { user_id: validated_input.user_id, music_volume: DEFAULT_MUSIC_VOLUME, platform_theme: DEFAULT_PLATFORM_THEME, created_at_micros: 0, updated_at_micros: 0, }) } fn upsert_runtime_setting( ctx: &ReducerContext, input: RuntimeSettingUpsertInput, ) -> Result { let validated_input = build_runtime_setting_upsert_input( input.user_id, input.music_volume, input.platform_theme, input.updated_at_micros, ) .map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let snapshot = match ctx .db .runtime_setting() .user_id() .find(&validated_input.user_id) { Some(existing) => { ctx.db.runtime_setting().user_id().delete(&existing.user_id); ctx.db.runtime_setting().insert(RuntimeSetting { user_id: existing.user_id.clone(), music_volume: validated_input.music_volume, platform_theme: validated_input.platform_theme, created_at: existing.created_at, updated_at, }); RuntimeSettingSnapshot { user_id: existing.user_id, music_volume: validated_input.music_volume, platform_theme: validated_input.platform_theme, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: validated_input.updated_at_micros, } } None => { ctx.db.runtime_setting().insert(RuntimeSetting { user_id: validated_input.user_id.clone(), music_volume: validated_input.music_volume, platform_theme: validated_input.platform_theme, created_at: updated_at, updated_at, }); RuntimeSettingSnapshot { user_id: validated_input.user_id, music_volume: validated_input.music_volume, platform_theme: validated_input.platform_theme, created_at_micros: validated_input.updated_at_micros, updated_at_micros: validated_input.updated_at_micros, } } }; Ok(snapshot) } fn get_runtime_snapshot_record( ctx: &ReducerContext, input: RuntimeSnapshotGetInput, ) -> Result, String> { let validated_input = build_runtime_snapshot_get_input(input.user_id).map_err(|error| error.to_string())?; Ok(ctx .db .runtime_snapshot() .user_id() .find(&validated_input.user_id) .map(|row| build_runtime_snapshot_from_row(&row))) } fn upsert_runtime_snapshot_record( ctx: &ReducerContext, input: RuntimeSnapshotUpsertInput, ) -> Result { let current_story_value = parse_optional_json_str(input.current_story_json.as_deref())?; let game_state = parse_json_str(&input.game_state_json)?; let prepared = build_runtime_snapshot_upsert_input( input.user_id, input.saved_at_micros, input.bottom_tab, game_state.clone(), current_story_value.clone(), input.updated_at_micros, ) .map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros); let saved_at = Timestamp::from_micros_since_unix_epoch(prepared.saved_at_micros); let snapshot = match ctx.db.runtime_snapshot().user_id().find(&prepared.user_id) { Some(existing) => { ctx.db .runtime_snapshot() .user_id() .delete(&existing.user_id); ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { user_id: existing.user_id.clone(), version: SAVE_SNAPSHOT_VERSION, saved_at, bottom_tab: prepared.bottom_tab.clone(), game_state_json: prepared.game_state_json.clone(), current_story_json: prepared.current_story_json.clone(), created_at: existing.created_at, updated_at, }); RuntimeSnapshot { user_id: existing.user_id, version: SAVE_SNAPSHOT_VERSION, saved_at_micros: prepared.saved_at_micros, bottom_tab: prepared.bottom_tab, game_state_json: prepared.game_state_json, current_story_json: prepared.current_story_json, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: prepared.updated_at_micros, } } None => { ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { user_id: prepared.user_id.clone(), version: SAVE_SNAPSHOT_VERSION, saved_at, bottom_tab: prepared.bottom_tab.clone(), game_state_json: prepared.game_state_json.clone(), current_story_json: prepared.current_story_json.clone(), created_at: updated_at, updated_at, }); RuntimeSnapshot { user_id: prepared.user_id, version: SAVE_SNAPSHOT_VERSION, saved_at_micros: prepared.saved_at_micros, bottom_tab: prepared.bottom_tab, game_state_json: prepared.game_state_json, current_story_json: prepared.current_story_json, created_at_micros: prepared.updated_at_micros, updated_at_micros: prepared.updated_at_micros, } } }; sync_profile_projections_from_snapshot(ctx, &snapshot)?; Ok(snapshot) } fn delete_runtime_snapshot_record( ctx: &ReducerContext, input: RuntimeSnapshotDeleteInput, ) -> Result, String> { let validated_input = build_runtime_snapshot_delete_input(input.user_id).map_err(|error| error.to_string())?; let existing = ctx .db .runtime_snapshot() .user_id() .find(&validated_input.user_id); if let Some(existing) = existing { let snapshot = build_runtime_snapshot_from_row(&existing); ctx.db .runtime_snapshot() .user_id() .delete(&existing.user_id); return Ok(Some(snapshot)); } Ok(None) } fn list_profile_save_archive_rows( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveListInput, ) -> Result, 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::>(); 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) } 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(), }, )) } 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>, 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, profile_id: Option, world_type: Option, world_title: String, world_subtitle: String, } #[derive(Clone, Debug)] struct ProfileSaveArchiveMeta { world_key: String, owner_user_id: Option, profile_id: Option, world_type: Option, world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, } fn build_runtime_snapshot_from_row(row: &RuntimeSnapshotRow) -> RuntimeSnapshot { RuntimeSnapshot { user_id: row.user_id.clone(), version: row.version, 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 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 parse_json_str(raw: &str) -> Result { serde_json::from_str::(raw) .map_err(|error| format!("game_state_json 解析失败: {error}")) } fn parse_optional_json_str(raw: Option<&str>) -> Result, String> { match raw.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_json::from_str::(value) .map(Some) .map_err(|error| format!("current_story_json 解析失败: {error}")), None => Ok(None), } } 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::().ok().unwrap_or(0), _ => 0, } } fn read_string_from_json(value: Option<&JsonValue>) -> Option { 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>, ) -> Option { 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 { 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 { 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, 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::>(); 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 { 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::>(); 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 list_platform_browse_history_rows( ctx: &ReducerContext, input: RuntimeBrowseHistoryListInput, ) -> Result, String> { let validated_input = build_runtime_browse_history_list_input(input.user_id) .map_err(|error| error.to_string())?; let mut entries = ctx .db .user_browse_history() .iter() .filter(|row| row.user_id == validated_input.user_id) .map(|row| build_runtime_browse_history_snapshot_from_row(&row)) .collect::>(); entries.sort_by(|left, right| { right .visited_at_micros .cmp(&left.visited_at_micros) .then_with(|| left.browse_history_id.cmp(&right.browse_history_id)) }); Ok(entries) } fn upsert_platform_browse_history_rows( ctx: &ReducerContext, input: RuntimeBrowseHistorySyncInput, ) -> Result, String> { let user_id = input.user_id.clone(); let prepared_entries = prepare_runtime_browse_history_entries(input).map_err(|error| error.to_string())?; for prepared in prepared_entries { let existing = ctx .db .user_browse_history() .browse_history_id() .find(&prepared.browse_history_id); let created_at = existing .as_ref() .map(|row| row.created_at) .unwrap_or_else(|| Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros)); if let Some(existing) = existing { ctx.db .user_browse_history() .browse_history_id() .delete(&existing.browse_history_id); } ctx.db.user_browse_history().insert(UserBrowseHistory { browse_history_id: prepared.browse_history_id, user_id: prepared.user_id, owner_user_id: prepared.owner_user_id, profile_id: prepared.profile_id, world_name: prepared.world_name, subtitle: prepared.subtitle, summary_text: prepared.summary_text, cover_image_src: prepared.cover_image_src, theme_mode: prepared.theme_mode, author_display_name: prepared.author_display_name, visited_at: Timestamp::from_micros_since_unix_epoch(prepared.visited_at_micros), created_at, updated_at: Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros), }); } list_platform_browse_history_rows(ctx, RuntimeBrowseHistoryListInput { user_id }) } fn clear_platform_browse_history_rows( ctx: &ReducerContext, input: RuntimeBrowseHistoryClearInput, ) -> Result, String> { let validated_input = build_runtime_browse_history_clear_input(input.user_id) .map_err(|error| error.to_string())?; let row_ids = ctx .db .user_browse_history() .iter() .filter(|row| row.user_id == validated_input.user_id) .map(|row| row.browse_history_id.clone()) .collect::>(); for row_id in row_ids { ctx.db .user_browse_history() .browse_history_id() .delete(&row_id); } Ok(Vec::new()) } fn build_runtime_browse_history_snapshot_from_row( row: &UserBrowseHistory, ) -> RuntimeBrowseHistorySnapshot { RuntimeBrowseHistorySnapshot { browse_history_id: row.browse_history_id.clone(), user_id: row.user_id.clone(), owner_user_id: row.owner_user_id.clone(), profile_id: row.profile_id.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(), theme_mode: row.theme_mode, author_display_name: row.author_display_name.clone(), visited_at_micros: row.visited_at.to_micros_since_unix_epoch(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: 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, } } #[allow(dead_code)] fn build_runtime_browse_history_row(snapshot: RuntimeBrowseHistorySnapshot) -> UserBrowseHistory { UserBrowseHistory { 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: Timestamp::from_micros_since_unix_epoch(snapshot.visited_at_micros), created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_battle_state_row(snapshot: BattleStateSnapshot) -> BattleState { BattleState { battle_state_id: snapshot.battle_state_id, story_session_id: snapshot.story_session_id, runtime_session_id: snapshot.runtime_session_id, actor_user_id: snapshot.actor_user_id, chapter_id: snapshot.chapter_id, target_npc_id: snapshot.target_npc_id, target_name: snapshot.target_name, battle_mode: snapshot.battle_mode, status: snapshot.status, player_hp: snapshot.player_hp, player_max_hp: snapshot.player_max_hp, player_mana: snapshot.player_mana, player_max_mana: snapshot.player_max_mana, target_hp: snapshot.target_hp, target_max_hp: snapshot.target_max_hp, experience_reward: snapshot.experience_reward, reward_items: snapshot.reward_items, turn_index: snapshot.turn_index, last_action_function_id: snapshot.last_action_function_id, last_action_text: snapshot.last_action_text, last_result_text: snapshot.last_result_text, last_damage_dealt: snapshot.last_damage_dealt, last_damage_taken: snapshot.last_damage_taken, last_outcome: snapshot.last_outcome, version: snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_battle_state_snapshot_from_row(row: &BattleState) -> BattleStateSnapshot { BattleStateSnapshot { battle_state_id: row.battle_state_id.clone(), story_session_id: row.story_session_id.clone(), runtime_session_id: row.runtime_session_id.clone(), actor_user_id: row.actor_user_id.clone(), chapter_id: row.chapter_id.clone(), target_npc_id: row.target_npc_id.clone(), target_name: row.target_name.clone(), battle_mode: row.battle_mode, status: row.status, player_hp: row.player_hp, player_max_hp: row.player_max_hp, player_mana: row.player_mana, player_max_mana: row.player_max_mana, target_hp: row.target_hp, target_max_hp: row.target_max_hp, experience_reward: row.experience_reward, reward_items: row.reward_items.clone(), turn_index: row.turn_index, last_action_function_id: row.last_action_function_id.clone(), last_action_text: row.last_action_text.clone(), last_result_text: row.last_result_text.clone(), last_damage_dealt: row.last_damage_dealt, last_damage_taken: row.last_damage_taken, last_outcome: row.last_outcome, version: row.version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn upsert_npc_state_record( ctx: &ReducerContext, input: NpcStateUpsertInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let existing = ctx.db.npc_state().npc_state_id().find(&npc_state_id); let normalized = normalize_npc_state_snapshot( input, existing .as_ref() .map(|row| row.created_at.to_micros_since_unix_epoch()), ) .map_err(|error| error.to_string())?; if existing.is_some() { ctx.db.npc_state().npc_state_id().delete(&npc_state_id); } ctx.db .npc_state() .insert(build_npc_state_row(normalized.clone())); Ok(normalized) } fn resolve_npc_social_action_record( ctx: &ReducerContext, input: ResolveNpcSocialActionInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let current = ctx .db .npc_state() .npc_state_id() .find(&npc_state_id) .ok_or_else(|| "npc_state 不存在,无法执行社交动作".to_string())?; let next = apply_npc_social_action(build_npc_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .npc_state() .npc_state_id() .delete(¤t.npc_state_id); ctx.db.npc_state().insert(build_npc_state_row(next.clone())); Ok(next) } fn resolve_npc_interaction_record( ctx: &ReducerContext, input: ResolveNpcInteractionInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let current = ctx .db .npc_state() .npc_state_id() .find(&npc_state_id) .ok_or_else(|| "npc_state 不存在,无法执行交互".to_string())?; let result = resolve_npc_interaction_domain(build_npc_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .npc_state() .npc_state_id() .delete(¤t.npc_state_id); ctx.db .npc_state() .insert(build_npc_state_row(result.npc_state.clone())); Ok(result) } fn resolve_npc_battle_interaction_tx( ctx: &ReducerContext, input: ResolveNpcBattleInteractionInput, ) -> Result { validate_npc_battle_interaction_input(&input)?; let interaction = resolve_npc_interaction_record(ctx, input.npc_interaction.clone())?; let battle_mode = interaction .battle_mode .ok_or_else(|| "当前 NPC 交互没有产出 battle_mode,不能初始化 battle_state".to_string())?; let battle_state_id = input .battle_state_id .clone() .unwrap_or_else(|| generate_battle_state_id(input.npc_interaction.updated_at_micros)); if ctx .db .battle_state() .battle_state_id() .find(&battle_state_id) .is_some() { return Err("battle_state.battle_state_id 已存在".to_string()); } let battle_input = BattleStateInput { battle_state_id, story_session_id: input.story_session_id.trim().to_string(), runtime_session_id: interaction.npc_state.runtime_session_id.clone(), actor_user_id: input.actor_user_id.trim().to_string(), chapter_id: None, target_npc_id: interaction.npc_state.npc_id.clone(), target_name: interaction.npc_state.npc_name.clone(), battle_mode: map_npc_battle_mode(battle_mode), player_hp: input.player_hp, player_max_hp: input.player_max_hp, player_mana: input.player_mana, player_max_mana: input.player_max_mana, target_hp: input.target_hp, target_max_hp: input.target_max_hp, experience_reward: input.experience_reward, reward_items: input.reward_items.clone(), created_at_micros: input.npc_interaction.updated_at_micros, }; validate_battle_state_input(&battle_input).map_err(|error| error.to_string())?; let battle_state = build_battle_state_snapshot(battle_input); ctx.db .battle_state() .insert(build_battle_state_row(battle_state.clone())); Ok(NpcBattleInteractionResult { interaction, battle_state, }) } fn validate_npc_battle_interaction_input( input: &ResolveNpcBattleInteractionInput, ) -> Result<(), String> { if input.story_session_id.trim().is_empty() { return Err("resolve_npc_battle_interaction.story_session_id 不能为空".to_string()); } if input.actor_user_id.trim().is_empty() { return Err("resolve_npc_battle_interaction.actor_user_id 不能为空".to_string()); } if !matches!( input.npc_interaction.interaction_function_id.trim(), NPC_FIGHT_FUNCTION_ID | NPC_SPAR_FUNCTION_ID ) { return Err("resolve_npc_battle_interaction 只支持 npc_fight 或 npc_spar".to_string()); } Ok(()) } fn map_npc_battle_mode(mode: NpcInteractionBattleMode) -> BattleMode { match mode { NpcInteractionBattleMode::Fight => BattleMode::Fight, NpcInteractionBattleMode::Spar => BattleMode::Spar, } } fn build_npc_state_row(snapshot: NpcStateSnapshot) -> NpcState { NpcState { npc_state_id: snapshot.npc_state_id, runtime_session_id: snapshot.runtime_session_id, npc_id: snapshot.npc_id, npc_name: snapshot.npc_name, affinity: snapshot.affinity, relation_state: snapshot.relation_state, help_used: snapshot.help_used, chatted_count: snapshot.chatted_count, gifts_given: snapshot.gifts_given, recruited: snapshot.recruited, trade_stock_signature: snapshot.trade_stock_signature, revealed_facts: snapshot.revealed_facts, known_attribute_rumors: snapshot.known_attribute_rumors, first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, stance_profile: snapshot.stance_profile, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { NpcStateSnapshot { npc_state_id: row.npc_state_id.clone(), runtime_session_id: row.runtime_session_id.clone(), npc_id: row.npc_id.clone(), npc_name: row.npc_name.clone(), affinity: row.affinity, relation_state: row.relation_state.clone(), help_used: row.help_used, chatted_count: row.chatted_count, gifts_given: row.gifts_given, recruited: row.recruited, trade_stock_signature: row.trade_stock_signature.clone(), revealed_facts: row.revealed_facts.clone(), known_attribute_rumors: row.known_attribute_rumors.clone(), first_meaningful_contact_resolved: row.first_meaningful_contact_resolved, seen_backstory_chapter_ids: row.seen_backstory_chapter_ids.clone(), stance_profile: row.stance_profile.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_big_fish_session_snapshot( ctx: &ReducerContext, row: &BigFishCreationSession, ) -> Result { let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json).unwrap_or_else(|_| empty_anchor_pack()); let draft = row .draft_json .as_deref() .map(deserialize_draft) .transpose() .map_err(|error| format!("big_fish.draft_json 非法: {error}"))?; let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id); let asset_coverage = build_asset_coverage(draft.as_ref(), &asset_slots); let mut messages = ctx .db .big_fish_agent_message() .iter() .filter(|message| message.session_id == row.session_id) .map(|message| BigFishAgentMessageSnapshot { message_id: message.message_id, session_id: message.session_id, role: message.role, kind: message.kind, text: message.text, created_at_micros: message.created_at.to_micros_since_unix_epoch(), }) .collect::>(); messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); Ok(BigFishSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage, anchor_pack, draft, asset_slots, asset_coverage, messages, last_assistant_reply: row.last_assistant_reply.clone(), publish_ready: row.publish_ready, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) } fn list_big_fish_asset_slots( ctx: &ReducerContext, session_id: &str, ) -> Vec { let mut slots = ctx .db .big_fish_asset_slot() .iter() .filter(|slot| slot.session_id == session_id) .map(|slot| BigFishAssetSlotSnapshot { slot_id: slot.slot_id, session_id: slot.session_id, asset_kind: slot.asset_kind, level: slot.level, motion_key: slot.motion_key, status: slot.status, asset_url: slot.asset_url, prompt_snapshot: slot.prompt_snapshot, updated_at_micros: slot.updated_at.to_micros_since_unix_epoch(), }) .collect::>(); slots.sort_by_key(|slot| { ( slot.level.unwrap_or(0), slot.asset_kind.as_str().to_string(), slot.motion_key.clone().unwrap_or_default(), slot.slot_id.clone(), ) }); slots } fn replace_big_fish_session( ctx: &ReducerContext, current: &BigFishCreationSession, next: BigFishCreationSession, ) { ctx.db .big_fish_creation_session() .session_id() .delete(¤t.session_id); ctx.db.big_fish_creation_session().insert(next); } fn replace_big_fish_run( ctx: &ReducerContext, current: &BigFishRuntimeRun, next: BigFishRuntimeRun, ) { ctx.db .big_fish_runtime_run() .run_id() .delete(¤t.run_id); ctx.db.big_fish_runtime_run().insert(next); } fn upsert_big_fish_asset_slot(ctx: &ReducerContext, slot: BigFishAssetSlotSnapshot) { if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) { ctx.db .big_fish_asset_slot() .slot_id() .delete(&existing.slot_id); } ctx.db.big_fish_asset_slot().insert(BigFishAssetSlot { slot_id: slot.slot_id, session_id: slot.session_id, asset_kind: slot.asset_kind, level: slot.level, motion_key: slot.motion_key, status: slot.status, asset_url: slot.asset_url, prompt_snapshot: slot.prompt_snapshot, updated_at: Timestamp::from_micros_since_unix_epoch(slot.updated_at_micros), }); } fn append_big_fish_system_message( ctx: &ReducerContext, session_id: &str, message_id: String, text: String, created_at_micros: i64, ) { if ctx .db .big_fish_agent_message() .message_id() .find(&message_id) .is_some() { return; } ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { message_id, session_id: session_id.to_string(), role: BigFishAgentMessageRole::Assistant, kind: BigFishAgentMessageKind::ActionResult, text, created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), }); }