From 997a8daada53f17a309d2e476c4f879696737036 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 22 Apr 2026 12:34:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E9=87=8D=E5=86=99=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + .../00_MASTER_TASKLIST.md | 14 +- .../02_M3_RUNTIME_PROFILE.md | 55 +- .../03_M4_STORY_AND_GAMEPLAY.md | 142 +- .../04_M5_CUSTOM_WORLD_AND_AGENT.md | 111 +- .../07_CROSS_CUTTING_AND_ACCEPTANCE.md | 11 +- backend-rewrite-tasklist/README.md | 6 + ...ATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md | 32 +- ...NT_GAME_ITERATION_PRIORITIES_2026-04-03.md | 20 +- docs/planning/README.md | 5 +- ...ER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md | 97 + ...HECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md | 70 + ...TORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md | 299 + ...OARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md | 410 ++ ...INGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md | 276 + ...REWARD_INVENTORY_INTEGRATION_2026-04-22.md | 147 + ...MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md | 306 + ...M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md | 233 + ...DULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md | 266 + ...LE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md | 251 + ..._COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md | 336 ++ ...LE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md | 202 + ...PC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md | 188 + ...OMBAT_ORCHESTRATION_BASELINE_2026-04-21.md | 151 + ...ULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md | 273 + ...RESSION_SPACETIMEDB_BASELINE_2026-04-21.md | 174 + ...ION_QUEST_COMBAT_INTEGRATION_2026-04-21.md | 154 + ...VENTORY_SPACETIMEDB_BASELINE_2026-04-21.md | 156 + ...E_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md | 204 + ...Y_SESSION_STATE_QUERY_DESIGN_2026-04-22.md | 171 + ...E_STORY_SPACETIMEDB_BASELINE_2026-04-21.md | 250 + ...INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md | 232 + ...REASURE_SPACETIMEDB_BASELINE_2026-04-21.md | 142 + ...Y_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md | 264 + ...FORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md | 218 + docs/technical/README.md | 33 + ...ONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md | 207 + ...E_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md | 126 + ...D_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md | 106 + ...KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md | 112 + ...E_STAGE3_VALUE_NORMALIZATION_2026-04-22.md | 84 + ...GE4_REQUIRED_STRING_ADOPTION_2026-04-22.md | 72 + ...5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md | 89 + ..._AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md | 190 + ...MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md | 188 + ..._AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md | 187 + ...LD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md | 313 + ...LD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md | 213 + ...TAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md | 198 + ...IBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md | 275 + ...ROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md | 208 + ..._PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md | 177 + scripts/check-encoding.mjs | 5 + server-rs/Cargo.lock | 184 + server-rs/Cargo.toml | 13 + server-rs/crates/api-server/Cargo.toml | 11 + server-rs/crates/api-server/README.md | 5 + server-rs/crates/api-server/src/ai_tasks.rs | 674 +++ .../crates/api-server/src/api_response.rs | 74 +- server-rs/crates/api-server/src/app.rs | 308 + server-rs/crates/api-server/src/assets.rs | 185 +- server-rs/crates/api-server/src/auth_me.rs | 43 +- .../crates/api-server/src/auth_sessions.rs | 27 +- server-rs/crates/api-server/src/config.rs | 96 + .../crates/api-server/src/custom_world.rs | 1015 ++++ server-rs/crates/api-server/src/http_error.rs | 17 +- server-rs/crates/api-server/src/llm.rs | 376 ++ .../crates/api-server/src/login_options.rs | 21 +- server-rs/crates/api-server/src/logout.rs | 7 +- server-rs/crates/api-server/src/logout_all.rs | 7 +- server-rs/crates/api-server/src/main.rs | 10 + .../crates/api-server/src/password_entry.rs | 34 +- server-rs/crates/api-server/src/phone_auth.rs | 42 +- .../crates/api-server/src/refresh_session.rs | 8 +- .../crates/api-server/src/request_context.rs | 4 +- .../crates/api-server/src/response_headers.rs | 10 +- .../api-server/src/runtime_browse_history.rs | 454 ++ .../api-server/src/runtime_inventory.rs | 196 + .../crates/api-server/src/runtime_profile.rs | 332 ++ .../crates/api-server/src/runtime_settings.rs | 372 ++ .../crates/api-server/src/runtime_story.rs | 593 ++ .../crates/api-server/src/session_client.rs | 12 +- server-rs/crates/api-server/src/state.rs | 89 + .../crates/api-server/src/story_battles.rs | 829 +++ .../crates/api-server/src/story_sessions.rs | 416 ++ .../crates/api-server/src/wechat_auth.rs | 45 +- server-rs/crates/module-ai/Cargo.toml | 15 + server-rs/crates/module-ai/README.md | 72 +- server-rs/crates/module-ai/src/lib.rs | 1050 ++++ server-rs/crates/module-assets/Cargo.toml | 1 + .../module-assets/src/asset_object_core.rs | 147 +- server-rs/crates/module-auth/Cargo.toml | 1 + server-rs/crates/module-auth/src/lib.rs | 95 +- server-rs/crates/module-combat/Cargo.toml | 15 + server-rs/crates/module-combat/README.md | 54 +- server-rs/crates/module-combat/src/lib.rs | 835 +++ .../crates/module-custom-world/Cargo.toml | 14 + .../crates/module-custom-world/README.md | 45 +- .../crates/module-custom-world/src/lib.rs | 1544 +++++ server-rs/crates/module-inventory/Cargo.toml | 14 + server-rs/crates/module-inventory/README.md | 48 +- server-rs/crates/module-inventory/src/lib.rs | 1063 ++++ server-rs/crates/module-npc/Cargo.toml | 14 + server-rs/crates/module-npc/README.md | 33 +- server-rs/crates/module-npc/src/lib.rs | 923 +++ .../crates/module-progression/Cargo.toml | 14 + server-rs/crates/module-progression/README.md | 50 +- .../crates/module-progression/src/lib.rs | 770 +++ server-rs/crates/module-quest/Cargo.toml | 14 + server-rs/crates/module-quest/README.md | 59 +- server-rs/crates/module-quest/src/lib.rs | 1156 ++++ .../crates/module-runtime-item/Cargo.toml | 15 + .../crates/module-runtime-item/README.md | 37 +- .../crates/module-runtime-item/src/lib.rs | 409 ++ server-rs/crates/module-runtime/Cargo.toml | 15 + server-rs/crates/module-runtime/src/lib.rs | 980 ++++ server-rs/crates/module-story/Cargo.toml | 14 + server-rs/crates/module-story/src/lib.rs | 610 ++ server-rs/crates/platform-auth/Cargo.toml | 1 + server-rs/crates/platform-auth/src/lib.rs | 56 +- server-rs/crates/platform-llm/Cargo.toml | 15 + server-rs/crates/platform-llm/README.md | 56 +- server-rs/crates/platform-llm/src/lib.rs | 1069 ++++ server-rs/crates/shared-contracts/Cargo.toml | 10 + server-rs/crates/shared-contracts/README.md | 57 +- server-rs/crates/shared-contracts/src/ai.rs | 223 + server-rs/crates/shared-contracts/src/api.rs | 168 + .../crates/shared-contracts/src/assets.rs | 361 ++ server-rs/crates/shared-contracts/src/auth.rs | 218 + server-rs/crates/shared-contracts/src/lib.rs | 8 + server-rs/crates/shared-contracts/src/llm.rs | 62 + .../crates/shared-contracts/src/runtime.rs | 517 ++ .../shared-contracts/src/runtime_story.rs | 324 ++ .../crates/shared-contracts/src/story.rs | 164 + server-rs/crates/shared-kernel/Cargo.toml | 11 + server-rs/crates/shared-kernel/README.md | 39 +- server-rs/crates/shared-kernel/src/lib.rs | 138 + server-rs/crates/spacetime-client/Cargo.toml | 10 + server-rs/crates/spacetime-client/src/lib.rs | 3681 +++++++++++- .../module_bindings/accept_quest_reducer.rs | 68 + .../acknowledge_quest_completion_reducer.rs | 68 + .../ai_result_reference_input_type.rs | 21 + .../ai_result_reference_kind_type.rs | 26 + .../ai_result_reference_snapshot_type.rs | 22 + .../ai_result_reference_table.rs | 166 + .../ai_result_reference_type.rs | 77 + .../ai_stage_completion_input_type.rs | 22 + .../ai_task_cancel_input_type.rs | 16 + .../ai_task_create_input_type.rs | 26 + .../ai_task_failure_input_type.rs | 17 + .../ai_task_finish_input_type.rs | 16 + .../src/module_bindings/ai_task_kind_type.rs | 26 + .../ai_task_procedure_result_type.rs | 21 + .../module_bindings/ai_task_snapshot_type.rs | 37 + .../ai_task_stage_blueprint_type.rs | 20 + .../ai_task_stage_kind_type.rs | 24 + .../ai_task_stage_snapshot_type.rs | 27 + .../ai_task_stage_start_input_type.rs | 19 + .../ai_task_stage_status_type.rs | 22 + .../module_bindings/ai_task_stage_table.rs | 161 + .../src/module_bindings/ai_task_stage_type.rs | 90 + .../ai_task_start_input_type.rs | 16 + .../module_bindings/ai_task_status_type.rs | 24 + .../src/module_bindings/ai_task_table.rs | 161 + .../src/module_bindings/ai_task_type.rs | 109 + .../ai_text_chunk_append_input_type.rs | 21 + .../ai_text_chunk_snapshot_type.rs | 22 + .../module_bindings/ai_text_chunk_table.rs | 162 + .../src/module_bindings/ai_text_chunk_type.rs | 71 + ...pend_ai_text_chunk_and_return_procedure.rs | 59 + ...ssion_ledger_entry_and_return_procedure.rs | 62 + ...hapter_progression_ledger_entry_reducer.rs | 73 + .../apply_inventory_mutation_reducer.rs | 68 + .../apply_quest_signal_reducer.rs | 68 + .../asset_entity_binding_table.rs | 161 + .../src/module_bindings/asset_object_table.rs | 160 + ...i_result_reference_and_return_procedure.rs | 59 + .../src/module_bindings/battle_mode_type.rs | 18 + .../battle_state_input_type.rs | 34 + .../battle_state_procedure_result_type.rs | 19 + .../battle_state_query_input_type.rs | 15 + .../battle_state_snapshot_type.rs | 46 + .../src/module_bindings/battle_state_table.rs | 163 + .../src/module_bindings/battle_state_type.rs | 144 + .../src/module_bindings/battle_status_type.rs | 20 + ...egin_story_session_and_return_procedure.rs | 59 + .../begin_story_session_reducer.rs | 68 + .../cancel_ai_task_and_return_procedure.rs | 59 + .../module_bindings/chapter_pace_band_type.rs | 22 + .../chapter_progression_get_input_type.rs | 16 + .../chapter_progression_input_type.rs | 31 + .../chapter_progression_ledger_input_type.rs | 21 + ...apter_progression_procedure_result_type.rs | 19 + .../chapter_progression_snapshot_type.rs | 36 + .../chapter_progression_table.rs | 166 + .../chapter_progression_type.rs | 133 + ...orm_browse_history_and_return_procedure.rs | 59 + .../module_bindings/combat_outcome_type.rs | 22 + ...ustom_world_published_profile_procedure.rs | 62 + .../complete_ai_stage_and_return_procedure.rs | 59 + .../complete_ai_task_and_return_procedure.rs | 59 + .../consume_inventory_item_input_type.rs | 16 + .../continue_story_and_return_procedure.rs | 59 + .../module_bindings/continue_story_reducer.rs | 68 + .../create_ai_task_and_return_procedure.rs | 59 + .../module_bindings/create_ai_task_reducer.rs | 68 + ...reate_battle_state_and_return_procedure.rs | 59 + .../create_battle_state_reducer.rs | 68 + ...te_custom_world_agent_session_procedure.rs | 59 + ...ustom_world_agent_message_snapshot_type.rs | 24 + ...m_world_agent_message_submit_input_type.rs | 20 + .../custom_world_agent_message_table.rs | 164 + .../custom_world_agent_message_type.rs | 75 + ...om_world_agent_operation_get_input_type.rs | 17 + ...d_agent_operation_procedure_result_type.rs | 19 + ...tom_world_agent_operation_snapshot_type.rs | 27 + .../custom_world_agent_operation_table.rs | 168 + .../custom_world_agent_operation_type.rs | 82 + ...m_world_agent_session_create_input_type.rs | 32 + ...stom_world_agent_session_get_input_type.rs | 16 + ...rld_agent_session_procedure_result_type.rs | 19 + ...ustom_world_agent_session_snapshot_type.rs | 45 + .../custom_world_agent_session_table.rs | 163 + .../custom_world_agent_session_type.rs | 151 + .../custom_world_draft_card_snapshot_type.rs | 32 + .../custom_world_draft_card_table.rs | 164 + .../custom_world_draft_card_type.rs | 100 + .../custom_world_gallery_detail_input_type.rs | 16 + ...ustom_world_gallery_entry_snapshot_type.rs | 28 + .../custom_world_gallery_entry_table.rs | 163 + .../custom_world_gallery_entry_type.rs | 91 + .../custom_world_gallery_list_result_type.rs | 19 + .../custom_world_generation_mode_type.rs | 18 + .../custom_world_library_detail_input_type.rs | 16 + ...stom_world_library_mutation_result_type.rs | 21 + .../custom_world_profile_list_input_type.rs | 15 + .../custom_world_profile_list_result_type.rs | 19 + ...custom_world_profile_publish_input_type.rs | 18 + .../custom_world_profile_snapshot_type.rs | 33 + .../custom_world_profile_table.rs | 163 + .../custom_world_profile_type.rs | 115 + ...stom_world_profile_unpublish_input_type.rs | 18 + .../custom_world_profile_upsert_input_type.rs | 29 + .../custom_world_publication_status_type.rs | 18 + .../custom_world_publish_world_input_type.rs | 22 + .../custom_world_publish_world_result_type.rs | 25 + ...ld_published_profile_compile_input_type.rs | 22 + ...d_published_profile_compile_result_type.rs | 19 + ...published_profile_compile_snapshot_type.rs | 28 + .../custom_world_role_asset_status_type.rs | 22 + .../custom_world_session_status_type.rs | 24 + .../custom_world_session_table.rs | 163 + .../custom_world_session_type.rs | 93 + .../custom_world_theme_mode_type.rs | 26 + .../equip_inventory_item_input_type.rs | 15 + .../fail_ai_task_and_return_procedure.rs | 59 + .../get_battle_state_procedure.rs | 59 + .../get_chapter_progression_procedure.rs | 59 + ..._custom_world_agent_operation_procedure.rs | 59 + ...et_custom_world_agent_session_procedure.rs | 59 + ...t_custom_world_gallery_detail_procedure.rs | 59 + ...t_custom_world_library_detail_procedure.rs | 59 + ...player_progression_or_default_procedure.rs | 59 + .../get_profile_dashboard_procedure.rs | 59 + .../get_profile_play_stats_procedure.rs | 59 + .../get_runtime_inventory_state_procedure.rs | 59 + ...et_runtime_setting_or_default_procedure.rs | 59 + .../get_story_session_state_procedure.rs | 59 + .../grant_inventory_item_input_type.rs | 18 + ...ression_experience_and_return_procedure.rs | 59 + ...t_player_progression_experience_reducer.rs | 71 + .../inventory_container_kind_type.rs | 18 + .../inventory_equipment_slot_type.rs | 20 + .../inventory_item_rarity_type.rs | 24 + .../inventory_item_snapshot_type.rs | 30 + .../inventory_item_source_kind_type.rs | 32 + .../inventory_mutation_input_type.rs | 22 + .../inventory_mutation_type.rs | 26 + .../inventory_slot_snapshot_type.rs | 39 + .../module_bindings/inventory_slot_table.rs | 163 + .../module_bindings/inventory_slot_type.rs | 124 + ..._custom_world_gallery_entries_procedure.rs | 54 + .../list_custom_world_profiles_procedure.rs | 59 + .../list_platform_browse_history_procedure.rs | 59 + .../list_profile_wallet_ledger_procedure.rs | 59 + .../src/module_bindings/mod.rs | 1450 ++++- ...attle_interaction_procedure_result_type.rs | 19 + .../npc_battle_interaction_result_type.rs | 19 + .../npc_interaction_battle_mode_type.rs | 18 + .../npc_interaction_procedure_result_type.rs | 19 + .../npc_interaction_result_type.rs | 28 + .../npc_interaction_status_type.rs | 26 + .../npc_relation_stance_type.rs | 24 + .../npc_relation_state_type.rs | 18 + .../npc_social_action_kind_type.rs | 24 + .../npc_stance_profile_type.rs | 22 + .../npc_state_procedure_result_type.rs | 19 + .../npc_state_snapshot_type.rs | 35 + .../src/module_bindings/npc_state_table.rs | 161 + .../src/module_bindings/npc_state_type.rs | 122 + .../npc_state_upsert_input_type.rs | 31 + .../player_progression_get_input_type.rs | 15 + .../player_progression_grant_input_type.rs | 20 + .../player_progression_grant_source_type.rs | 18 + ...layer_progression_procedure_result_type.rs | 19 + .../player_progression_snapshot_type.rs | 25 + .../player_progression_table.rs | 162 + .../player_progression_type.rs | 79 + .../profile_dashboard_state_table.rs | 161 + .../profile_dashboard_state_type.rs | 61 + .../profile_played_world_table.rs | 161 + .../profile_played_world_type.rs | 84 + .../profile_wallet_ledger_table.rs | 162 + .../profile_wallet_ledger_type.rs | 69 + ...stom_world_profile_and_return_procedure.rs | 59 + .../publish_custom_world_profile_reducer.rs | 71 + .../publish_custom_world_world_procedure.rs | 59 + .../quest_completion_ack_input_type.rs | 16 + .../quest_hostile_npc_defeated_signal_type.rs | 16 + .../quest_item_delivered_signal_type.rs | 17 + .../quest_log_event_kind_type.rs | 24 + .../src/module_bindings/quest_log_table.rs | 163 + .../src/module_bindings/quest_log_type.rs | 93 + .../quest_narrative_binding_snapshot_type.rs | 24 + .../quest_narrative_origin_type.rs | 18 + .../quest_narrative_type_type.rs | 26 + .../quest_npc_spar_completed_signal_type.rs | 15 + .../quest_npc_talk_completed_signal_type.rs | 15 + .../quest_objective_kind_type.rs | 26 + .../quest_objective_snapshot_type.rs | 22 + .../quest_progress_signal_type.rs | 32 + .../quest_record_input_type.rs | 46 + .../src/module_bindings/quest_record_table.rs | 164 + .../src/module_bindings/quest_record_type.rs | 166 + .../quest_reward_equipment_slot_type.rs | 20 + .../quest_reward_intel_type.rs | 16 + .../quest_reward_item_rarity_type.rs | 24 + .../module_bindings/quest_reward_item_type.rs | 27 + .../quest_reward_snapshot_type.rs | 23 + .../quest_scene_reached_signal_type.rs | 15 + .../quest_signal_apply_input_type.rs | 19 + .../module_bindings/quest_signal_kind_type.rs | 26 + .../src/module_bindings/quest_status_type.rs | 26 + .../quest_step_snapshot_type.rs | 27 + .../quest_treasure_inspected_signal_type.rs | 15 + .../quest_turn_in_input_type.rs | 16 + ...olve_combat_action_and_return_procedure.rs | 59 + .../resolve_combat_action_input_type.rs | 23 + ...lve_combat_action_procedure_result_type.rs | 19 + .../resolve_combat_action_reducer.rs | 68 + .../resolve_combat_action_result_type.rs | 21 + ...battle_interaction_and_return_procedure.rs | 59 + ...solve_npc_battle_interaction_input_type.rs | 29 + ...ve_npc_interaction_and_return_procedure.rs | 59 + .../resolve_npc_interaction_input_type.rs | 20 + .../resolve_npc_interaction_reducer.rs | 68 + ..._npc_social_action_and_return_procedure.rs | 59 + .../resolve_npc_social_action_input_type.rs | 23 + .../resolve_npc_social_action_reducer.rs | 68 + ...easure_interaction_and_return_procedure.rs | 59 + .../resolve_treasure_interaction_reducer.rs | 68 + .../rpg_agent_draft_card_kind_type.rs | 34 + .../rpg_agent_draft_card_status_type.rs | 22 + .../rpg_agent_message_kind_type.rs | 26 + .../rpg_agent_message_role_type.rs | 20 + .../rpg_agent_operation_status_type.rs | 22 + .../rpg_agent_operation_type_type.rs | 40 + .../module_bindings/rpg_agent_stage_type.rs | 32 + ...runtime_browse_history_clear_input_type.rs | 15 + .../runtime_browse_history_list_input_type.rs | 15 + ...me_browse_history_procedure_result_type.rs | 19 + .../runtime_browse_history_snapshot_type.rs | 29 + .../runtime_browse_history_sync_input_type.rs | 19 + .../runtime_browse_history_theme_mode_type.rs | 26 + ...runtime_browse_history_write_input_type.rs | 23 + ...e_inventory_state_procedure_result_type.rs | 19 + ...untime_inventory_state_query_input_type.rs | 16 + .../runtime_inventory_state_snapshot_type.rs | 20 + .../runtime_item_equipment_slot_type.rs | 20 + .../runtime_item_reward_item_rarity_type.rs | 24 + .../runtime_item_reward_item_snapshot_type.rs | 27 + .../runtime_platform_theme_type.rs | 18 + ...untime_profile_dashboard_get_input_type.rs | 15 + ...profile_dashboard_procedure_result_type.rs | 19 + ...runtime_profile_dashboard_snapshot_type.rs | 19 + ...ntime_profile_play_stats_get_input_type.rs | 15 + ...rofile_play_stats_procedure_result_type.rs | 19 + ...untime_profile_play_stats_snapshot_type.rs | 20 + ...time_profile_played_world_snapshot_type.rs | 25 + ...ofile_wallet_ledger_entry_snapshot_type.rs | 22 + ...e_profile_wallet_ledger_list_input_type.rs | 15 + ...ile_wallet_ledger_procedure_result_type.rs | 19 + ..._profile_wallet_ledger_source_type_type.rs | 16 + .../runtime_setting_get_input_type.rs | 15 + .../runtime_setting_procedure_result_type.rs | 19 + .../runtime_setting_snapshot_type.rs | 21 + .../module_bindings/runtime_setting_table.rs | 160 + .../module_bindings/runtime_setting_type.rs | 63 + .../runtime_setting_upsert_input_type.rs | 20 + .../module_bindings/start_ai_task_reducer.rs | 68 + .../start_ai_task_stage_reducer.rs | 68 + .../story_continue_input_type.rs | 19 + .../module_bindings/story_event_kind_type.rs | 18 + .../story_event_snapshot_type.rs | 22 + .../src/module_bindings/story_event_table.rs | 160 + .../src/module_bindings/story_event_type.rs | 68 + .../story_session_input_type.rs | 21 + .../story_session_procedure_result_type.rs | 21 + .../story_session_snapshot_type.rs | 28 + .../story_session_state_input_type.rs | 15 + ...ory_session_state_procedure_result_type.rs | 21 + .../story_session_status_type.rs | 20 + .../module_bindings/story_session_table.rs | 160 + .../src/module_bindings/story_session_type.rs | 97 + ...it_custom_world_agent_message_procedure.rs | 59 + .../treasure_interaction_action_type.rs | 20 + .../treasure_record_procedure_result_type.rs | 19 + .../treasure_record_snapshot_type.rs | 33 + .../module_bindings/treasure_record_table.rs | 163 + .../module_bindings/treasure_record_type.rs | 112 + .../treasure_resolve_input_type.rs | 33 + .../module_bindings/turn_in_quest_reducer.rs | 68 + .../unequip_inventory_item_input_type.rs | 15 + ...stom_world_profile_and_return_procedure.rs | 59 + .../unpublish_custom_world_profile_reducer.rs | 71 + ...hapter_progression_and_return_procedure.rs | 59 + .../upsert_chapter_progression_reducer.rs | 68 + ...stom_world_profile_and_return_procedure.rs | 59 + .../upsert_custom_world_profile_reducer.rs | 71 + .../upsert_npc_state_and_return_procedure.rs | 59 + .../upsert_npc_state_reducer.rs | 68 + ...orm_browse_history_and_return_procedure.rs | 59 + ...rt_runtime_setting_and_return_procedure.rs | 59 + .../user_browse_history_table.rs | 164 + .../user_browse_history_type.rs | 92 + server-rs/crates/spacetime-module/Cargo.toml | 10 + server-rs/crates/spacetime-module/README.md | 42 +- server-rs/crates/spacetime-module/src/lib.rs | 5073 ++++++++++++++++- 438 files changed, 53355 insertions(+), 865 deletions(-) create mode 100644 docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md create mode 100644 docs/technical/ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md create mode 100644 docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md create mode 100644 docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md create mode 100644 docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md create mode 100644 docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md create mode 100644 docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md create mode 100644 docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md create mode 100644 docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md create mode 100644 docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md create mode 100644 docs/technical/M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md create mode 100644 docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md create mode 100644 docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md create mode 100644 docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md create mode 100644 docs/technical/M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md create mode 100644 docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md create mode 100644 docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md create mode 100644 docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md create mode 100644 docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md create mode 100644 docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md create mode 100644 docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md create mode 100644 docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md create mode 100644 docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md create mode 100644 docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md create mode 100644 docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md create mode 100644 server-rs/crates/api-server/src/ai_tasks.rs create mode 100644 server-rs/crates/api-server/src/custom_world.rs create mode 100644 server-rs/crates/api-server/src/llm.rs create mode 100644 server-rs/crates/api-server/src/runtime_browse_history.rs create mode 100644 server-rs/crates/api-server/src/runtime_inventory.rs create mode 100644 server-rs/crates/api-server/src/runtime_profile.rs create mode 100644 server-rs/crates/api-server/src/runtime_settings.rs create mode 100644 server-rs/crates/api-server/src/runtime_story.rs create mode 100644 server-rs/crates/api-server/src/story_battles.rs create mode 100644 server-rs/crates/api-server/src/story_sessions.rs create mode 100644 server-rs/crates/module-ai/Cargo.toml create mode 100644 server-rs/crates/module-ai/src/lib.rs create mode 100644 server-rs/crates/module-combat/Cargo.toml create mode 100644 server-rs/crates/module-combat/src/lib.rs create mode 100644 server-rs/crates/module-custom-world/Cargo.toml create mode 100644 server-rs/crates/module-custom-world/src/lib.rs create mode 100644 server-rs/crates/module-inventory/Cargo.toml create mode 100644 server-rs/crates/module-inventory/src/lib.rs create mode 100644 server-rs/crates/module-npc/Cargo.toml create mode 100644 server-rs/crates/module-npc/src/lib.rs create mode 100644 server-rs/crates/module-progression/Cargo.toml create mode 100644 server-rs/crates/module-progression/src/lib.rs create mode 100644 server-rs/crates/module-quest/Cargo.toml create mode 100644 server-rs/crates/module-quest/src/lib.rs create mode 100644 server-rs/crates/module-runtime-item/Cargo.toml create mode 100644 server-rs/crates/module-runtime-item/src/lib.rs create mode 100644 server-rs/crates/module-runtime/Cargo.toml create mode 100644 server-rs/crates/module-runtime/src/lib.rs create mode 100644 server-rs/crates/module-story/Cargo.toml create mode 100644 server-rs/crates/module-story/src/lib.rs create mode 100644 server-rs/crates/platform-llm/Cargo.toml create mode 100644 server-rs/crates/platform-llm/src/lib.rs create mode 100644 server-rs/crates/shared-contracts/Cargo.toml create mode 100644 server-rs/crates/shared-contracts/src/ai.rs create mode 100644 server-rs/crates/shared-contracts/src/api.rs create mode 100644 server-rs/crates/shared-contracts/src/assets.rs create mode 100644 server-rs/crates/shared-contracts/src/auth.rs create mode 100644 server-rs/crates/shared-contracts/src/lib.rs create mode 100644 server-rs/crates/shared-contracts/src/llm.rs create mode 100644 server-rs/crates/shared-contracts/src/runtime.rs create mode 100644 server-rs/crates/shared-contracts/src/runtime_story.rs create mode 100644 server-rs/crates/shared-contracts/src/story.rs create mode 100644 server-rs/crates/shared-kernel/Cargo.toml create mode 100644 server-rs/crates/shared-kernel/src/lib.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_stage_completion_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_cancel_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_create_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_failure_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_finish_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_blueprint_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_start_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_start_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_task_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_append_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/apply_inventory_mutation_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/apply_quest_signal_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/asset_object_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_mode_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_state_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_state_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_state_query_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_state_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_state_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_state_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/battle_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_pace_band_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_ledger_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/combat_outcome_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/consume_inventory_item_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/continue_story_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_submit_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_create_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_detail_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_list_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_generation_mode_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_detail_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_mutation_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_unpublish_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_publication_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_role_asset_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/custom_world_theme_mode_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/equip_inventory_item_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/grant_inventory_item_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_container_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_equipment_slot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_item_rarity_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_item_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_item_source_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_battle_mode_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_relation_stance_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_relation_state_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_social_action_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_stance_profile_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_state_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_state_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_state_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_state_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/npc_state_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/player_progression_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_source_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/player_progression_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/player_progression_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/player_progression_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/player_progression_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_completion_ack_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_hostile_npc_defeated_signal_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_item_delivered_signal_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_log_event_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_log_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_log_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_binding_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_origin_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_type_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_npc_spar_completed_signal_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_npc_talk_completed_signal_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_objective_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_objective_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_progress_signal_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_record_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_record_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_record_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_reward_equipment_slot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_reward_intel_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_rarity_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_reward_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_scene_reached_signal_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_signal_apply_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_signal_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_step_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_treasure_inspected_signal_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/quest_turn_in_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_role_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_type_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_stage_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_clear_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_sync_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_theme_mode_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_write_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_query_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_item_equipment_slot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_rarity_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_platform_theme_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_played_world_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_continue_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_event_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_event_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_event_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_event_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_state_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_state_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/story_session_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/treasure_interaction_action_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/treasure_record_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/treasure_record_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/treasure_record_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/treasure_record_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/treasure_resolve_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/turn_in_quest_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/unequip_inventory_item_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_reducer.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_type.rs diff --git a/.gitignore b/.gitignore index 1896b30d..a9ea5a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,17 @@ coverage/ .DS_Store *.log /.codex-logs/ +/.codex-cargo-home-*/ +/.codex-cache*/ +/.tmp*/ .preview.* tmp_* tmp/ npc-editor-* temp-write-check.txt temp-build-goal-check/ +/server-rs-codex-*/ +/server-rs/target-*/ **/__pycache__/ *.py[cod] /public/generated-custom-world-scenes diff --git a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md b/backend-rewrite-tasklist/00_MASTER_TASKLIST.md index cce7fee6..f952d46a 100644 --- a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md +++ b/backend-rewrite-tasklist/00_MASTER_TASKLIST.md @@ -6,6 +6,8 @@ - [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) - [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md) +- [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) +- [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) 关联拆分任务: @@ -70,9 +72,9 @@ 重点: -1. 迁移 story action 主循环 -2. 迁移 gameplay reducer -3. 兼容当前 story view model 与 state 恢复接口 +1. 迁移 RPG runtime story 主循环 +2. 迁移 RPG 入口 / session / runtime 对应的后端边界与编译职责 +3. 兼容当前 story view model 与 state 恢复接口,并与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory` 口径对齐 详见: @@ -82,9 +84,9 @@ 重点: -1. 迁移传统 custom world 问答流 -2. 迁移 custom world library / gallery -3. 迁移 custom world agent 会话、消息、卡片、操作 +1. 迁移 RPG 创作主链:Agent session、result preview、published profile +2. 迁移 works / library / gallery / publish / enter-world 配套链路 +3. 旧 `custom-world/sessions` 传统问答流只按历史兼容台账处理,不再作为当前主链扩展目标 详见: diff --git a/backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md b/backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md index 9a2da5b2..b5a9528c 100644 --- a/backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md +++ b/backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md @@ -3,12 +3,12 @@ ## 1. SpacetimeDB 运行时主表 - [ ] 设计 `runtime_snapshot` -- [ ] 设计 `runtime_setting` -- [ ] 设计 `profile_dashboard_state` -- [ ] 设计 `profile_wallet_ledger` -- [ ] 设计 `profile_played_world` +- [x] 设计 `runtime_setting` +- [x] 设计 `profile_dashboard_state` +- [x] 设计 `profile_wallet_ledger` +- [x] 设计 `profile_played_world` - [ ] 设计 `profile_save_archive` -- [ ] 设计 `user_browse_history` +- [x] 设计 `user_browse_history` ## 2. 兼容快照策略 @@ -16,31 +16,31 @@ - [ ] 设计 snapshot projection 刷新机制 - [ ] 迁移当前 snapshot hydration / normalize 规则 - [ ] 迁移当前 save archive 聚合逻辑 -- [ ] 迁移当前 browse history 去重与排序逻辑 +- [x] 迁移当前 browse history 去重与排序逻辑 ## 3. Axum facade - [ ] 兼容 `GET /api/runtime/save/snapshot` - [ ] 兼容 `PUT /api/runtime/save/snapshot` - [ ] 兼容 `DELETE /api/runtime/save/snapshot` -- [ ] 兼容 `GET /api/runtime/settings` -- [ ] 兼容 `PUT /api/runtime/settings` -- [ ] 兼容 `GET /api/runtime/profile/dashboard` -- [ ] 兼容 `GET /api/profile/dashboard` -- [ ] 兼容 `GET /api/runtime/profile/wallet-ledger` -- [ ] 兼容 `GET /api/profile/wallet-ledger` -- [ ] 兼容 `GET /api/runtime/profile/play-stats` -- [ ] 兼容 `GET /api/profile/play-stats` +- [x] 兼容 `GET /api/runtime/settings` +- [x] 兼容 `PUT /api/runtime/settings` +- [x] 兼容 `GET /api/runtime/profile/dashboard` +- [x] 兼容 `GET /api/profile/dashboard` +- [x] 兼容 `GET /api/runtime/profile/wallet-ledger` +- [x] 兼容 `GET /api/profile/wallet-ledger` +- [x] 兼容 `GET /api/runtime/profile/play-stats` +- [x] 兼容 `GET /api/profile/play-stats` - [ ] 兼容 `GET /api/runtime/profile/save-archives` - [ ] 兼容 `GET /api/profile/save-archives` - [ ] 兼容 `POST /api/runtime/profile/save-archives/:worldKey` - [ ] 兼容 `POST /api/profile/save-archives/:worldKey` -- [ ] 兼容 `GET /api/runtime/profile/browse-history` -- [ ] 兼容 `POST /api/runtime/profile/browse-history` -- [ ] 兼容 `DELETE /api/runtime/profile/browse-history` -- [ ] 兼容 `GET /api/profile/browse-history` -- [ ] 兼容 `POST /api/profile/browse-history` -- [ ] 兼容 `DELETE /api/profile/browse-history` +- [x] 兼容 `GET /api/runtime/profile/browse-history` +- [x] 兼容 `POST /api/runtime/profile/browse-history` +- [x] 兼容 `DELETE /api/runtime/profile/browse-history` +- [x] 兼容 `GET /api/profile/browse-history` +- [x] 兼容 `POST /api/profile/browse-history` +- [x] 兼容 `DELETE /api/profile/browse-history` ## 4. 阶段验收 @@ -48,3 +48,18 @@ - [ ] 兼容路径与主路径返回一致 - [ ] profile dashboard / browse history / save archive 行为一致 - [ ] 前端当前恢复流程可在不改 UI 的前提下跑通 + +## 5. 本轮进展记录 + +- `2026-04-21`:已完成 `runtime_setting` 首版设计与 `GET/PUT /api/runtime/settings` 的 Rust 主链迁移。 +- 本轮已落地 `module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 四层串联,并补齐定向测试。 +- 详细设计与字段冻结见: + - [../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) +- `2026-04-22`:已完成 `user_browse_history` 表设计冻结、去重与排序规则迁移,以及 `/api/runtime/profile/browse-history` 与 `/api/profile/browse-history` 双路径 facade 落地。 +- `2026-04-22`:已补 `browse history` 的 API 入口必填字段校验、批量 shape 兼容与定向测试,详细设计见: + - [../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) +- `2026-04-22`:已冻结 `profile_dashboard_state`、`profile_wallet_ledger`、`profile_played_world` 三张 projection 表,以及 `dashboard / wallet-ledger / play-stats` 的 Axum + SpacetimeDB 读链设计。 +- `2026-04-22`:已完成 `api-server` 的 `runtime_profile` facade 编译与定向测试收口,`/api/runtime/profile/*` 与 `/api/profile/*` 六条只读路由均已接通。 +- `2026-04-22`:已通过 `cargo check -p api-server --tests --message-format short`、`cargo test -p shared-contracts --lib`、`cargo test -p api-server runtime_profile::tests:: -- --nocapture` 验证本轮 profile projection 读链。 +- 详细设计见: + - [../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md) diff --git a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md b/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md index 946c3911..c7f11467 100644 --- a/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md +++ b/backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md @@ -1,48 +1,141 @@ # M4:story action 与 gameplay reducer 任务清单 +## 0. 当前执行基线 + +本阶段与当前仓库里的 RPG 入口与运行时主链重构直接对应,统一以以下文档为准: + +1. [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) +2. [../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md) +3. [../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md) + +当前任务清单只维护 Axum / SpacetimeDB 重写侧的后端迁移项,不再把旧 `GameShell / runtimeRoutes.ts / storyActionService.ts` 命名视为新架构目标。 + +### 当前进展(`2026-04-22`) + +本阶段首轮已先把 `server-rs` 从“只有 `module-story` 占位目录”推进到“SpacetimeDB 侧 story 会话基座真实可编译”: + +1. 已新增 `server-rs/crates/module-story` 真实 crate。 +2. 已冻结 `story_session / story_event` 的首版领域类型、状态枚举和字段校验 helper。 +3. 已在 `server-rs/crates/spacetime-module` 中新增 `story_session`、`story_event` 两张表。 +4. 已新增 `begin_story_session`、`continue_story` 两个 reducer,形成最小会话事件链。 +5. 已新增 `begin_story_session_and_return`、`continue_story_and_return` 两个 procedure,形成可同步返回快照的最小 story session contract。 +6. 已重新执行 `spacetime generate`,把 `story_session / story_event` Rust bindings 刷入 `spacetime-client/src/module_bindings`。 +7. 已在 `server-rs/crates/spacetime-client` 中新增 `begin_story_session(...)`、`continue_story(...)` facade。 +8. 已在 `server-rs/crates/api-server` 中新增: + - `POST /api/story/sessions` + - `POST /api/story/sessions/continue` +9. 已执行 `cargo check -p module-story -p spacetime-module -p spacetime-client -p api-server` 并通过。 +6. 已新增 `docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `battle_state` 与 `resolve_combat_action` 的首版字段与规则口径。 +7. 已新增 `server-rs/crates/module-runtime-item` 真实 crate。 +8. 已冻结 `treasure_record` 的首版领域类型、完整奖励物品快照和字段校验规则。 +9. 已在 `server-rs/crates/spacetime-module` 中新增 `treasure_record` 表。 +10. 已新增 `resolve_treasure_interaction` reducer 与 `resolve_treasure_interaction_and_return` procedure,并把宝箱奖励同步写入 `inventory_slot`。 +11. 已新增 `docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `inventory_slot` 与 `apply_inventory_mutation` 的首版字段与规则口径。 +12. 已新增 `server-rs/crates/module-inventory` 真实 crate。 +13. 已在 `server-rs/crates/spacetime-module` 中新增 `inventory_slot` 表。 +14. 已新增 `apply_inventory_mutation` reducer,形成最小背包主链。 +15. 已新增 `docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `npc_state`、`resolve_npc_social_action` 与 `resolve_npc_interaction` 的首版字段与交互口径。 +16. 已新增 `server-rs/crates/module-npc` 真实 crate。 +17. 已在 `server-rs/crates/spacetime-module` 中新增 `npc_state` 表。 +18. 已新增 `upsert_npc_state`、`resolve_npc_social_action`、`resolve_npc_interaction` 及对应 procedure。 +19. 已新增 `docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md`,冻结 `npc_fight / npc_spar` 到 `battle_state` 的最小联合编排口径。 +20. 已在 `server-rs/crates/spacetime-module` 中新增 `resolve_npc_battle_interaction_and_return` procedure,把 NPC 开战交互与 battle 初始化写入串到同一事务。 +15. 已新增 `docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `player_progression / chapter_progression` 的首版字段、成长曲线与章节预算口径。 +16. 已新增 `server-rs/crates/module-progression` 真实 crate。 +17. 已在 `server-rs/crates/spacetime-module` 中新增 `player_progression`、`chapter_progression` 两张表。 +18. 已新增 `get_player_progression_or_default`、`grant_player_progression_experience`、`upsert_chapter_progression`、`apply_chapter_progression_ledger_entry` 及对应 procedure。 +19. 已新增 `docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `quest_record / quest_log / apply_quest_signal` 的首版字段、日志口径与交付状态流转规则。 +20. 已新增 `server-rs/crates/module-quest` 真实 crate。 +21. 已在 `server-rs/crates/spacetime-module` 中新增 `quest_record`、`quest_log` 两张表。 +22. 已新增 `accept_quest`、`apply_quest_signal`、`acknowledge_quest_completion`、`turn_in_quest` reducer,形成最小任务闭环。 +23. 已执行 `cargo test -p module-quest`、`cargo check -p spacetime-module`、`cargo check -p api-server` 与全量 `cargo check` 并通过。 +24. 已新增 `docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md`,冻结任务交付与战斗胜利到成长系统的联动口径。 +25. 已把 `turn_in_quest` 接到 `player_progression / chapter_progression` 的最小经验写入。 +26. 已把 `resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 的最小经验写入。 +27. 已把 `turn_in_quest.reward.items` 接到 `inventory_slot` 发物链,形成任务交付的最小物品奖励闭环。 +28. 已新增 `docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `story state` 查询切片,只开放 `storySession + storyEvents` 真相态查询。 +29. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/sessions/:storySessionId/state`,通过 `spacetime-client.get_story_session_state(...)` 读取 `SpacetimeDB procedure` 返回的会话快照与事件流。 +30. 已新增 `docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md`,冻结 `battle_state.reward_items` 与 `resolve_combat_action(Victory)` 发物到 `inventory_slot` 的最小联动口径。 +31. 已新增 `docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `battle state` 查询切片,只开放单个 `battleState` 真相态查询。 +32. 已在 `server-rs/crates/spacetime-module` 中新增 `get_battle_state` procedure,按 `battle_state_id` 返回当前战斗快照。 +33. 已在 `server-rs/crates/spacetime-client` 中新增 `get_battle_state(...)` facade,供 Axum 同步读取 battle 真相态。 +34. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/battles/:battleStateId`,通过 `spacetime-client.get_battle_state(...)` 返回单战斗快照。 +35. 已在 `server-rs/crates/spacetime-client` 中新增 `resolve_npc_battle_interaction(...)` facade,把 `resolve_npc_battle_interaction_and_return` procedure 映射为稳定 Rust record,供 Axum 直接消费。 +36. 已在 `server-rs/crates/api-server` 中挂出 `POST /api/story/npc/battle`,当前只接受 `npc_fight / npc_spar`,同步返回 `npcInteraction + battleState`。 +37. 已执行 `cargo check -p spacetime-client -p api-server` 并通过,完成 `module-npc -> spacetime-client -> api-server` 的最小 NPC 开战同步返回链闭环。 +38. 已重新执行 `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes`,把 `get_battle_state`、`battle_state.reward_items` 与 `custom_world_agent_session` 相关 bindings 刷入 `spacetime-client/src/module_bindings`。 +39. 已把 `server-rs/crates/spacetime-client/src/lib.rs` 中原本占位返回错误的 `get_battle_state(...)` 改成真实 procedure 调用,当前 battle query 已不再停留在 facade stub。 +40. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 与 `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`,当前 battle/story 新链路在编译层已恢复通过。 +41. 已新增 `docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md`,冻结旧 `POST /api/runtime/story/state/resolve` 的首版兼容桥边界,明确当前先做 DTO 与状态桥,不提前误宣称 `actions/resolve` 已可迁移。 +42. 已在 `server-rs/crates/shared-contracts` 中新增 `runtime_story` 模块,冻结 `RuntimeStoryStateResolveRequest`、`RuntimeStoryActionResponse` 以及 `viewModel / presentation / patches / snapshot` 的首版 camelCase DTO,与当前前端消费口径对齐。 + +当前验证边界补充: + +1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时很长,已有多轮回归尝试,但还没有在单次时窗内收敛到最终断言结果。 +2. `npm run check:encoding` 已启动到 `node scripts/check-encoding.mjs`,但当前尚未在单次时窗内跑完,不能标记为已完成。 +3. 因此,当前可以确认的是 `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通;测试与编码检查仍应继续追。 + +当前这轮仍未扩到 `resolve_story_action`、`sync_runtime_snapshot_projection`、旧 `/api/runtime/story/*` 兼容接口和前端实际 runtime story API 切换,这些继续保留在后续 `M4` 工作项中。 + ## 1. SpacetimeDB gameplay 表 -- [ ] 设计 `story_session` -- [ ] 设计 `story_event` -- [ ] 设计 `npc_state` -- [ ] 设计 `quest_record` -- [ ] 设计 `inventory_slot` -- [ ] 设计 `treasure_record` -- [ ] 设计 `battle_state` -- [ ] 设计 `player_progression` -- [ ] 设计 `chapter_progression` +- [x] 设计 `story_session` +- [x] 设计 `story_event` +- [x] 设计 `npc_state` +- [x] 设计 `quest_record` +- [x] 设计 `inventory_slot` +- [x] 设计 `treasure_record` +- [x] 设计 `battle_state` +- [x] 设计 `player_progression` +- [x] 设计 `chapter_progression` ## 2. 核心 reducer - [ ] 设计 `resolve_story_action` -- [ ] 设计 `continue_story` -- [ ] 设计 `begin_story_session` +- [x] 设计 `continue_story` +- [x] 设计 `begin_story_session` - [ ] 设计 `sync_runtime_snapshot_projection` -- [ ] 设计 `apply_quest_signal` -- [ ] 设计 `apply_inventory_mutation` -- [ ] 设计 `resolve_npc_interaction` -- [ ] 设计 `resolve_treasure_interaction` -- [ ] 设计 `resolve_combat_action` -- [ ] 设计 `update_progression_state` +- [x] 设计 `apply_quest_signal` +- [x] 设计 `apply_inventory_mutation` +- [x] 设计 `resolve_npc_interaction` +- [x] 设计 `resolve_treasure_interaction` +- [x] 设计 `resolve_combat_action` +- [x] 设计 `update_progression_state` -## 3. 迁移模块规则 +## 3. 当前主链模块落位 -- [ ] 迁移 `story` -- [ ] 迁移 `combat` +- [ ] 迁移 `rpg-entry` 配套后端入口能力 +- [ ] 迁移 `rpg-profile` 资料域 +- [ ] 迁移 `rpg-runtime-story` +- [x] 迁移 `combat` - [ ] 迁移 `inventory` - [ ] 迁移 `npc` -- [ ] 迁移 `progression` -- [ ] 迁移 `quest` -- [ ] 迁移 `runtime-item` -- [ ] 迁移 `runtime` 的状态归一化规则 +- [x] 迁移 `progression` +- [x] 迁移 `quest` +- [x] 迁移 `runtime-item` +- [ ] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则 ## 4. 兼容接口 - [ ] 兼容 `POST /api/runtime/story/actions/resolve` - [ ] 兼容 `GET /api/runtime/story/state/:sessionId` +- [ ] 兼容 `POST /api/runtime/story/state/resolve` - [ ] 兼容 `POST /api/runtime/story/initial` - [ ] 兼容 `POST /api/runtime/story/continue` +补充说明: + +1. 当前已落地的是新的 Rust facade: + - `POST /api/story/sessions` + - `POST /api/story/sessions/continue` + - `GET /api/story/sessions/:storySessionId/state` + - `GET /api/story/battles/:battleStateId` + - `POST /api/story/npc/battle` +2. 其中前 3 个接口是 `story session` 真相链路,后 2 个接口是 battle / NPC 开战真相链路,都不等价于旧 Node 的 LLM `runtime/story/*` 兼容接口。 +3. 当前新增的 `story state` 查询只返回 `storySession + storyEvents`,还没有兼容旧 `RuntimeStoryActionResponse`、`currentStory`、`availableOptions`。 +4. 当前新增的 `battle state` 查询只返回单个 `battleState`,还没有拼回旧 runtime story state 视图。 +5. 在 `resolve_story_action / story state` contract 未冻结前,不应误勾选旧兼容接口。 + ## 5. ViewModel 兼容 - [ ] 兼容当前 `RuntimeStoryActionResponse` @@ -56,4 +149,5 @@ - [ ] 当前前端 story 选项点击后可走新后端闭环 - [ ] NPC / quest / treasure / combat 主循环行为不回退 - [ ] `story state` 恢复链可用 +- [ ] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致 - [ ] 旧 Node 版 story route 回归用例完成平移 diff --git a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md b/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md index c167be35..56e29479 100644 --- a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md +++ b/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md @@ -1,68 +1,87 @@ # M5:custom world / gallery / agent 任务清单 +## 0. 当前执行基线 + +本阶段与当前仓库里的创作链重构直接对应,统一以以下文档为准: + +1. [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) +2. [../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md) + +当前逻辑层命名和职责边界应优先使用 `rpgCreation / rpgAgent / rpgWorld` 口径;本任务清单继续保留 `custom world` 文件名,只是为了和后端重写阶段文档编号保持一致。 + +本轮首批可编码表设计见: + +3. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md) +4. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md) +5. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md) +6. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md) + ## 1. SpacetimeDB custom world 表 -- [ ] 设计 `custom_world_profile` -- [ ] 设计 `custom_world_session` -- [ ] 设计 `custom_world_agent_session` -- [ ] 设计 `custom_world_agent_message` -- [ ] 设计 `custom_world_agent_operation` -- [ ] 设计 `custom_world_draft_card` +- [x] 设计 `custom_world_profile` +- [x] 设计 `custom_world_session` +- [x] 设计 `custom_world_agent_session` +- [x] 设计 `custom_world_agent_message` +- [x] 设计 `custom_world_agent_operation` +- [x] 设计 `custom_world_draft_card` - [ ] 设计 `custom_world_asset_link` -- [ ] 设计 `custom_world_gallery_entry` +- [x] 设计 `custom_world_gallery_entry` -## 2. 传统 custom world 问答流 +## 2. 当前 RPG 创作主链 -- [ ] 迁移 `create session` -- [ ] 迁移 `answer question` -- [ ] 迁移 `generate stream` -- [ ] 迁移 profile compile -- [ ] 迁移 library 存储与删除 -- [ ] 迁移 publish / unpublish -- [ ] 迁移 gallery 列表与详情 +- [ ] 迁移 result preview compiler +- [x] 迁移 published profile compile(Stage 3 已落地) +- [ ] 迁移 works 聚合读模型 +- [x] 迁移 library 存储与删除(Stage 2 设计已冻结,待继续接 Axum 兼容) +- [x] 迁移 publish / unpublish(Stage 2 设计已冻结,待继续接 Agent publish gate) +- [x] 迁移 publish_world 串联主链(Stage 4 设计已冻结,待继续接 Axum action / publish gate) +- [ ] 迁移 publish gate / enter-world gate +- [x] 迁移 gallery 列表与详情(Stage 2 设计已冻结,待继续接 Axum 兼容) -## 3. custom world agent 主链 +## 3. RPG 创作 Agent 主链 -- [ ] 迁移 session create -- [ ] 迁移 session snapshot -- [ ] 迁移 message submit -- [ ] 迁移 message stream -- [ ] 迁移 operation query +- [x] 迁移 session create(Stage 6 首批 Agent session skeleton) +- [x] 迁移 session snapshot(Stage 6 首批 Agent session skeleton) +- [x] 迁移 message submit(Stage 7 deterministic message / operation 最小闭环) +- [x] 迁移 message stream(Stage 8 SSE facade 已落地) +- [x] 迁移 operation query(Stage 7 deterministic message / operation 最小闭环) - [ ] 迁移 card detail - [ ] 迁移 card update +- [ ] 迁移 action registry / supportedActions - [ ] 迁移 draft foundation +- [ ] 迁移 result preview 生成 - [ ] 迁移 entity generation -- [ ] 迁移 role asset sync +- [ ] 迁移 role / scene asset sync +- [ ] 迁移 checkpoint / blocker / quality findings 主链 ## 4. Axum 编排层 - [ ] 接入 LLM 编排 - [ ] 接入世界草稿编译 +- [ ] 接入服务端 result preview 编译 - [ ] 接入角色 / 地点 / 场景 NPC 生成 - [ ] 接入封面图生成 - [ ] 接入场景图生成 - [ ] 接入 OSS 对象写入与绑定 - [ ] 接入 SSE 事件分发 -## 5. 兼容接口 +## 5. 当前正式接口与历史兼容台账 -- [ ] 兼容 `/api/runtime/custom-world/sessions` -- [ ] 兼容 `/api/runtime/custom-world/sessions/:sessionId` -- [ ] 兼容 `/api/runtime/custom-world/sessions/:sessionId/answers` -- [ ] 兼容 `/api/runtime/custom-world/sessions/:sessionId/generate/stream` -- [ ] 兼容 `/api/runtime/custom-world-library` -- [ ] 兼容 `/api/runtime/custom-world-library/:profileId` -- [ ] 兼容 `/api/runtime/custom-world-library/:profileId/publish` -- [ ] 兼容 `/api/runtime/custom-world-library/:profileId/unpublish` -- [ ] 兼容 `/api/runtime/custom-world-gallery` -- [ ] 兼容 `/api/runtime/custom-world-gallery/:ownerUserId/:profileId` +### 5.1 当前正式接口 + +- [x] 兼容 `/api/runtime/custom-world-library`(Stage 5 首批 Axum facade) +- [x] 兼容 `/api/runtime/custom-world-library/:profileId`(owner-only detail 查询已补齐) +- [x] 兼容 `/api/runtime/custom-world-library/:profileId/publish`(Stage 5 首批 Axum facade) +- [x] 兼容 `/api/runtime/custom-world-library/:profileId/unpublish`(Stage 5 首批 Axum facade) +- [x] 兼容 `/api/runtime/custom-world-gallery`(Stage 5 首批 Axum facade) +- [x] 兼容 `/api/runtime/custom-world-gallery/:ownerUserId/:profileId`(Stage 5 首批 Axum facade) - [ ] 兼容 `/api/runtime/custom-world/works` -- [ ] 兼容 `/api/runtime/custom-world/agent/sessions` -- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId` -- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages` -- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` -- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions` -- [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` +- [x] 兼容 `/api/runtime/custom-world/agent/sessions`(Stage 6 首批 Axum facade) +- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`(Stage 6 首批 Axum facade) +- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`(Stage 7 deterministic message submit) +- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`(Stage 8 SSE facade) +- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`(Stage 5 仅支持 `publish_world` 显式 draft payload) +- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`(Stage 7 deterministic operation query) - [ ] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` - [ ] 兼容 `/api/custom-world/entity` - [ ] 兼容 `/api/runtime/custom-world/entity` @@ -72,9 +91,17 @@ - [ ] 兼容 `/api/custom-world/cover-image` - [ ] 兼容 `/api/custom-world/cover-upload` +### 5.2 历史兼容台账(非当前主链) + +- [ ] 评估 `/api/runtime/custom-world/sessions` 是否仍需保留历史兼容映射 +- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId` 是否仍需保留历史兼容映射 +- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId/answers` 是否仍需保留历史兼容映射 +- [ ] 评估 `/api/runtime/custom-world/sessions/:sessionId/generate/stream` 是否仍需保留历史兼容映射 + ## 6. 阶段验收 -- [ ] 传统 custom world 主链可用 -- [ ] custom world library / gallery 主链可用 -- [ ] custom world agent 主链可用 +- [ ] RPG 创作主链可用:`agent session -> result preview -> published profile` +- [ ] works / library / gallery / publish / enter-world 主链可用 +- [ ] RPG 创作 Agent 主链可用 - [ ] agent 会话、消息、卡片、操作不再依赖单大 JSON 会话体 +- [ ] 旧 `custom-world/sessions` 问答流不再作为当前主链扩展目标 diff --git a/backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md b/backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md index 5e1bec03..cf9191c4 100644 --- a/backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md +++ b/backend-rewrite-tasklist/07_CROSS_CUTTING_AND_ACCEPTANCE.md @@ -29,15 +29,18 @@ - [ ] 每个阶段完成后同步更新设计文档 - [ ] 每个阶段完成后补一份落地记录 - [ ] 完成接口迁移后更新新的模块与 API 索引文档 +- [ ] `M4` 结构变更同步对齐 `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` +- [ ] `M5` 结构变更同步对齐 `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` ## 2. 第一优先级建议执行顺序 1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。 2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。 3. 再做 `M3`,优先跑通快照、设置、profile。 -4. 再做 `M4`,把 story action 主循环真正迁走。 -5. 然后做 `M5`,迁 custom world 与 agent。 -6. 最后做 `M6 + M7`,收口 assets、editor、部署与切流。 +4. 进入 `M4` 和 `M5` 前,先用两份 `2026-04-21` 执行方案冻结当前仓库里的 RPG 运行时链与创作链结构口径。 +5. 再做 `M4`,把 RPG runtime story 主循环真正迁走。 +6. 然后做 `M5`,迁 RPG 创作主链、works/library/gallery 与 agent。 +7. 最后做 `M6 + M7`,收口 assets、editor、部署与切流。 ## 3. 最终验收清单 @@ -47,5 +50,7 @@ - [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界 - [ ] SpacetimeDB 已成为唯一运行时状态真相源 - [ ] 阿里云 OSS 已成为唯一资产对象仓 +- [ ] `M4` 已与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory / rpgProfile` 主链口径一致 +- [ ] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致 - [ ] 前端主流程在不大改 UI 的前提下可跑通 - [ ] 能完成灰度切流,并保留可回退能力 diff --git a/backend-rewrite-tasklist/README.md b/backend-rewrite-tasklist/README.md index 190d04ef..e36ef801 100644 --- a/backend-rewrite-tasklist/README.md +++ b/backend-rewrite-tasklist/README.md @@ -23,6 +23,12 @@ - [M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md):`M0` 仓库边界决议文档,用于持续冻结 `server-rs/` 落位、迁移期双栈共存、Axum 边界与副作用收口原则。 - [M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md):`M0 ~ M7` 阶段验收矩阵,用于固定每阶段的入口条件、核心交付、退出条件与跨阶段回归焦点。 +## 当前 M4 / M5 结构基线 + +- `M4` 当前涉及的前后端脚本结构、命名根、route/service/compiler/repository 落位,统一参照 [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。 +- `M5` 当前涉及的创作入口、Agent session、result preview、works/library/gallery、publish 与 enter-world 主链,统一参照 [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。 +- 旧 `custom-world/sessions` 传统问答链已经退出当前仓库正式主链;后续若在 `M5` 中提及,只按历史兼容台账处理,不再作为当前功能扩展目标。 + ## 维护规则 1. 总纲与拆分文件都以本目录为唯一维护位置。 diff --git a/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md index 16424b14..9adf1529 100644 --- a/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md +++ b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md @@ -1,6 +1,6 @@ # 当前 Agent 创作流程优化执行规划(大白话版) -更新时间:`2026-04-20` +更新时间:`2026-04-21` ## 先把话说死 @@ -16,6 +16,22 @@ 这份规划就是基于 [AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md](../audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md) 里已经确认的问题,重新收束出来的一版执行方案。 +## 0.1 当前正式执行基线 + +从 `2026-04-21` 起,当前仓库里和这条创作主链直接对应的文件级拆分、阶段验收、工作包进度,统一以以下文档为准: + +1. [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) +2. `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_*_PROGRESS_2026-04-21.md` +3. [../technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](../technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md) + +本文件继续承担的职责只剩 3 件事: + +1. 解释为什么当前版本只收口现有 Agent 创作动线,不再扩一套新流程 +2. 冻结这轮明确不做的能力边界 +3. 给出高层执行顺序与判断标准 + +凡是涉及目录落位、命名规范、前后端真相源边界、阶段完成度和工作包状态,统一以后面的技术执行文档为准。 + --- ## 1. 现在最大的问题,用大白话讲是什么 @@ -328,17 +344,17 @@ **让用户走起来更顺,让系统找回内容更稳定。** -## 第三阶段:再处理旧 pipeline 的降级和冻结 +## 第三阶段:清理旧链残留表述与剩余兼容误导 再做: -1. 旧 `custom-world/sessions` 链降级 -2. 结果页直改 profile 的旧能力收紧 -3. 兼容链保留边界写清楚 +1. 清理旧 `custom-world/sessions` 已删除链路在文档、索引、任务清单里的残留表述 +2. 结果页直改 profile 的旧能力继续收紧 +3. 把仍保留的兼容 façade / 兼容字段边界写清楚 这一阶段的目标是: -**减少系统自己和自己打架。** +**减少旧链文档误导和兼容边界漂移。** ## 第四阶段:最后做文档清理 @@ -382,8 +398,8 @@ 应该看到: -1. 重复 pipeline 明显减少 -2. 旧链不再继续吞主流程职责 +1. 已删除旧链不再继续出现在当前执行清单里 +2. 剩余兼容层的保留边界更清楚 3. 后续开发不会再不知道该往哪条链上接 ## 阶段四做完 diff --git a/docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md b/docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md index 8efeba2f..85f01d44 100644 --- a/docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md +++ b/docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md @@ -15,6 +15,15 @@ **现在的优先级不是“继续扩玩法宽度”,而是“先把底层规则、主流程边界和工程可维护性补齐,再扩玩法深度”。** +补充更新(`2026-04-21`): + +当前与“主流程边界补齐”直接对应的执行基线,已经从泛化的 `GameShell / useStoryGeneration / customWorld` 热点讨论,收口成两条正式技术方案: + +1. [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):负责创作入口 -> Agent session -> result preview -> published profile 主链。 +2. [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):负责平台入口 -> 继续游戏/开始游戏 -> RPG runtime -> runtime story 主链。 + +因此本文里的 `P0-2`,当前应按这两条主线落地,而不是继续围绕旧 `GameShell` / `PreGameSelectionFlow` / `runtimeRoutes.ts` 命名做泛化式重构。 + --- ## 优先级清单 @@ -57,9 +66,9 @@ ### 本阶段要做什么 -- 把 `useStoryGeneration` 收敛为 orchestration 层,不再直接吞下 NPC、任务、背包、锻造、聊天、奖励等全部细节 -- 把 `useCombatFlow` 进一步拆成“战斗结算”与“播放/演出同步” -- 让 `GameShell` 回到流程壳层职责,把 selection flow、overlay、scene transition 继续下沉 +- 按 [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) 收口创作链,统一 `Agent session -> result preview -> published profile` +- 按 [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) 收口 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` +- 旧 `GameShell / PreGameSelectionFlow / runtimeRoutes.ts / storyActionService.ts` 只作为历史热区名或兼容 façade,不再作为当前新任务默认落点 ### 做到什么算完成 @@ -231,7 +240,7 @@ - 继续横向扩 NPC 交互种类,但不补统一规则底座 - 继续堆宝藏、掉落、锻造分支,但不先做统一物品导演层 - 继续增加任务模板数量,但不升级任务 contract -- 继续往 `useStoryGeneration` / `useCombatFlow` / `GameShell` 里直接塞新逻辑 +- 继续往 `useStoryGeneration` / `useCombatFlow` / `GameShell` / `PreGameSelectionFlow` / `runtimeRoutes.ts` / `storyActionService.ts` 里直接塞新逻辑 原因很简单: @@ -244,7 +253,8 @@ ### 第一阶段:先稳住工程与主流程 1. 绿色基线与门禁收紧 -2. 运行时主链拆分 +2. 创作链按 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口 +3. RPG 进入游戏与运行时链按 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口 ### 第二阶段:先补统一语义底座 diff --git a/docs/planning/README.md b/docs/planning/README.md index 65201388..74aedef0 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -4,7 +4,9 @@ - [ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md](./ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md):面向无用历史代码、隐形多数据链路和半成品实现的一轮工程大清洗执行计划,强调先建台账、再删重收口、最后恢复主工程可读性。 - [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。 -- [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):在不新增前端创作流程的前提下,围绕当前 Agent 创作动线做收口、删重、补通和文档收束的大白话执行规划。 +- [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):当前创作入口、Agent session、结果页自动保存、作品库与进入世界主链的正式文件级重构基线;涉及目录落位、命名规范、阶段验收与工作包拆分时优先看这一份。 +- [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):当前平台入口、继续游戏、角色选择、RPG runtime 与 runtime story 主链的正式文件级重构基线;涉及入口壳层、session、runtime、story、route/service/repository 拆分时优先看这一份。 +- [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):创作链高层目标、冻结边界与执行顺序说明;文件级拆分与阶段验收以创作链重构执行方案为准。 - [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。 - [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。 - [BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md](./BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md):北京市方向 13 / 21 / 24 的统一判断、共用材料框架和准备顺序。 @@ -15,4 +17,5 @@ ## 使用建议 - 需要排期、拆阶段、判断先修基线还是先加功能时,先看这份。 +- 当前如果要推进创作链或 RPG 运行时主链重构,先看上面的两份 `2026-04-21` 执行方案,再回来看高层优先级和冻结边界。 - 这份文档大量引用了经验文档、工程审查和 PRD,适合作为跨文档导航页使用。 diff --git a/docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md b/docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md new file mode 100644 index 00000000..bbc96eea --- /dev/null +++ b/docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md @@ -0,0 +1,97 @@ +# `api-server` 接入 `platform-llm` 最小代理设计(2026-04-21) + +## 1. 目标 + +在 `platform-llm` 已落成真实 Rust crate 后,`api-server` 需要尽快拥有一条可正式消费的平台接线面,避免平台层只停留在“可编译但未接入”状态。 + +本次目标只做最小闭环: + +1. 在 `api-server` 配置层补齐 LLM 文本网关环境变量 +2. 在 `AppState` 注入 `platform-llm::LlmClient` +3. 提供 `/api/llm/chat/completions` 非流式兼容代理 +4. 保持与旧 Node 路由的鉴权位置和基本请求形态一致 + +## 2. 本次范围 + +### 2.1 本次实现 + +1. `AppConfig` 新增 LLM provider / base url / api key / model / timeout / retry 配置 +2. `AppState` 初始化 `LlmClient` +3. 新增 `shared-contracts::llm` +4. 新增 `api-server/src/llm.rs` +5. 路由挂载到 `/api/llm/chat/completions` + +### 2.2 本次不实现 + +1. 不实现 SSE 流式透传 +2. 不实现通用原样 body 转发 +3. 不实现媒体模型路由 +4. 不把 `module-ai` 编排接进来 + +## 3. 兼容口径 + +保持与旧 Node `POST /api/llm/chat/completions` 一致的基本语义: + +1. 需要登录态 +2. 接收 `model? + stream + messages[]` +3. 当前 `stream=true` 明确返回 `501`,避免伪装支持 +4. 非流式返回统一后的文本结果,而不是原样上游 JSON + +## 4. 返回结构 + +Rust 首版返回: + +1. `id` +2. `model` +3. `content` +4. `finishReason` + +原因: + +1. 当前 Rust 平台层已经把上游 `choices[0].message.content` 归一完成 +2. `api-server` 首版先保持稳定、可消费的文本结果接口 +3. 真正需要 OpenAI 完全兼容响应体时,再单独补“原样代理模式” + +## 5. 验收 + +1. `api-server` 能在配置合法时成功构建 `AppState` +2. `/api/llm/chat/completions` 能通过测试打到 mock 上游 +3. `stream=true` 返回明确错误 +4. crate 级 `check/test` 通过 + +## 6. 环境变量与默认值 + +`api-server` 首版按以下优先级解析 LLM 配置,保证兼容仓库现有 `.env` 口径: + +1. provider:`GENARRATIVE_LLM_PROVIDER` -> `LLM_PROVIDER` +2. base url:`GENARRATIVE_LLM_BASE_URL` -> `LLM_BASE_URL` +3. api key:`GENARRATIVE_LLM_API_KEY` -> `LLM_API_KEY` -> `ARK_API_KEY` +4. model:`GENARRATIVE_LLM_MODEL` -> `LLM_MODEL` -> `VITE_LLM_MODEL` +5. timeout:`GENARRATIVE_LLM_REQUEST_TIMEOUT_MS` -> `LLM_REQUEST_TIMEOUT_MS` +6. max retries:`GENARRATIVE_LLM_MAX_RETRIES` -> `LLM_MAX_RETRIES` +7. retry backoff:`GENARRATIVE_LLM_RETRY_BACKOFF_MS` -> `LLM_RETRY_BACKOFF_MS` + +默认值统一对齐 `platform-llm`: + +1. provider:`ark` +2. base url:`https://ark.cn-beijing.volces.com/api/v3` +3. model:`doubao-1-5-pro-32k-character-250715` +4. request timeout:`30000` +5. max retries:`1` +6. retry backoff:`500` + +补充约束: + +1. 如果 `api key` 未配置,`api-server` 允许继续启动,但 `/api/llm/chat/completions` 返回 `503` +2. 如果 provider 字符串非法,回退到默认 `ark`,避免因为环境变量拼写问题阻断开发态服务 + +## 7. 错误映射 + +`platform-llm` 到 HTTP 的错误映射固定如下: + +1. `InvalidRequest` -> `400 BAD_REQUEST` +2. `InvalidConfig` -> `503 SERVICE_UNAVAILABLE` +3. `Timeout` / `Connectivity` / `Transport` / `Deserialize` / `EmptyResponse` / `StreamUnavailable` -> `502 BAD_GATEWAY` +4. `Upstream(status=429)` -> `429 TOO_MANY_REQUESTS` +5. 其他 `Upstream` -> `502 BAD_GATEWAY` +6. `stream=true` 首版直接返回 `501 NOT_IMPLEMENTED` diff --git a/docs/technical/ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md b/docs/technical/ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md new file mode 100644 index 00000000..1cd35e49 --- /dev/null +++ b/docs/technical/ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md @@ -0,0 +1,70 @@ +# 编码检查与临时工作区噪音收口方案(2026-04-22) + +日期:`2026-04-22` + +## 1. 背景 + +当前仓库根目录存在多份本地临时工作区与 Cargo cache 目录,例如: + +1. `.codex-cargo-home-stage4*` +2. `server-rs-codex-stage4-*` +3. `server-rs/target-*` + +这些目录属于本地验证产物,不属于主工程源码、文档或正式资源,但 `npm run check:encoding` 仍会通过 `git ls-files --cached --others --exclude-standard` 把其中大量未跟踪文本文件纳入扫描,导致: + +1. 编码检查耗时被临时目录放大 +2. 检查结果容易被本地 cache / verify copy 噪音污染 +3. 仓库级 UTF-8 检查无法稳定反映真实工程文件状态 + +同时,当前脚本没有把 `.rs` 纳入文本扩展名集合,这与仓库约束“Rust / 工程代码中的中文注释也必须保证 UTF-8 正常”不一致。 + +## 2. 本次冻结规则 + +本轮对编码检查口径做以下冻结: + +1. `scripts/check-encoding.mjs` 只检查主工程真实文本文件,不扫描临时 Cargo cache、临时 verify copy 和 `server-rs/target-*` 目录。 +2. `.rs` 必须纳入 UTF-8 编码检查,避免 Rust 文件中的中文注释或中文错误文案被写坏后漏检。 +3. `.encoding-check-ignore` 继续只承载少量已知历史坏文本白名单,不用于掩盖大目录级临时产物。 +4. 对临时目录的处理优先通过 `.gitignore` 与脚本排除规则完成,不要求物理删除本地 cache。 + +## 3. 具体落地点 + +### 3.1 `.gitignore` + +新增忽略规则: + +1. `/.codex-cargo-home-*/` +2. `/server-rs-codex-*/` +3. `/server-rs/target-*/` + +目的: + +1. 让 `git ls-files --others --exclude-standard` 不再把这些临时目录当作待检查仓库文件。 +2. 与既有噪音清理基线保持一致,继续把本地检查产物留在仓库视野之外。 + +### 3.2 `scripts/check-encoding.mjs` + +脚本同步收紧两点: + +1. 增加对上述临时前缀目录的显式排除,避免脚本在显式传参或忽略规则未生效时仍误扫临时目录。 +2. 把 `.rs` 加入文本扩展名集合,确保 Rust 源文件进入 UTF-8 校验面。 + +## 4. 完成定义 + +当以下条件满足时,本次修复视为完成: + +1. `npm run check:encoding` 不再被临时 Cargo / verify 目录拖慢或污染结果。 +2. 真实工程中的 Rust 文件会参与 UTF-8 检查。 +3. 不需要清理用户本地 cache 目录,也不会对现有并行工作区造成破坏。 + +## 5. 不在本轮范围 + +本轮不处理: + +1. `.encoding-check-ignore` 中历史坏文本的逐条修复 +2. 各类本地 cache / verify 目录的物理删除 +3. 与 UTF-8 检查无关的 lint / typecheck / cargo 输出目录清理策略 + +## 6. 相关文档 + +1. [./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md) diff --git a/docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md b/docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md new file mode 100644 index 00000000..8b70d025 --- /dev/null +++ b/docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md @@ -0,0 +1,299 @@ +# M3:browse history Axum + SpacetimeDB 落地设计 + +日期:`2026-04-21` + +关联任务: + +- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md) +- [./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) + +关联现状: + +- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +- `server-node/src/repositories/runtimeRepository.ts` +- `packages/shared/src/contracts/runtime.ts` + +## 1. 文档目的 + +`02_M3_RUNTIME_PROFILE.md` 已经把 `user_browse_history` 和 `browse history` facade 列入 M3,但还没有冻结到可直接编码的字段、去重规则、路由兼容方式和错误语义。 + +本文只解决 `browse history` 这一个最小闭环切片: + +1. `user_browse_history` 真相表 +2. `GET /api/runtime/profile/browse-history` +3. `POST /api/runtime/profile/browse-history` +4. `DELETE /api/runtime/profile/browse-history` +5. `/api/profile/browse-history` 兼容路径 + +本文不新建 checklist,不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足本轮编码所需的冻结口径。 + +## 2. 旧实现冻结口径 + +当前 Node 侧行为来自: + +- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +- `server-node/src/repositories/runtimeRepository.ts` + +冻结行为如下。 + +### 2.1 路由 + +主路径与兼容路径都必须保留: + +1. `GET /api/runtime/profile/browse-history` +2. `POST /api/runtime/profile/browse-history` +3. `DELETE /api/runtime/profile/browse-history` +4. `GET /api/profile/browse-history` +5. `POST /api/profile/browse-history` +6. `DELETE /api/profile/browse-history` + +所有路径都要求 Bearer JWT。 + +### 2.2 数据字段 + +单条浏览记录字段与 `packages/shared/src/contracts/runtime.ts` 保持一致: + +1. `ownerUserId` +2. `profileId` +3. `worldName` +4. `subtitle` +5. `summaryText` +6. `coverImageSrc` +7. `themeMode` +8. `authorDisplayName` +9. `visitedAt` + +### 2.3 POST 请求体兼容 + +`POST` 同时支持两种形态: + +1. 单条对象 +2. `{ entries: [...] }` 批量对象 + +批量最多 `100` 条。 + +### 2.4 归一化规则 + +旧 Node 仓储不是严格校验,而是宽松归一化: + +1. `ownerUserId`、`profileId`、`worldName` 去首尾空白后必须非空,否则该条忽略。 +2. `subtitle`、`summaryText`、`coverImageSrc` 去首尾空白,空串按空值处理。 +3. `themeMode` 不做严格枚举校验,未知值统一回落到 `mythic`。 +4. `authorDisplayName` 空值回落到 `玩家`。 +5. `visitedAt` 缺失时回落到当前时间。 + +### 2.5 去重与排序规则 + +旧 Node 仓储的关键行为必须保持: + +1. 去重键:`ownerUserId + profileId` +2. 同一批写入时,先按 `visitedAt DESC` 排序,再去重,只保留最新一条 +3. 表内最终查询结果按 `visitedAt DESC` 返回 + +### 2.6 清空行为 + +`DELETE` 清空当前用户的全部浏览历史,并返回: + +```json +{ + "entries": [] +} +``` + +## 3. Rust 落位决议 + +### 3.1 crate 分工 + +本切片固定按以下边界落位: + +1. `crates/module-runtime` + - 定义 `browse history` DTO、字段校验、去重排序与宽松归一化规则。 +2. `crates/spacetime-module` + - 定义 `user_browse_history` 表。 + - 提供 `list / upsert / clear` 三个 procedure。 +3. `crates/spacetime-client` + - 提供 `list_platform_browse_history` + - 提供 `upsert_platform_browse_history_entries` + - 提供 `clear_platform_browse_history` +4. `crates/shared-contracts` + - 冻结 Axum facade 的请求/响应 DTO。 +5. `crates/api-server` + - 提供双路径兼容 facade。 + - 保持 envelope / 错误格式与当前 `runtime settings` 一致。 + +### 3.2 身份边界 + +本轮仍沿用 Axum Bearer JWT 作为唯一鉴权边界: + +1. `require_bearer_auth` 校验 token。 +2. 从 claims 中提取 `user_id`。 +3. `user_id` 作为 procedure 入参传入 SpacetimeDB。 + +当前阶段不把浏览历史直接暴露给前端直连订阅。 + +## 4. SpacetimeDB 表设计 + +### 4.1 表名 + +`user_browse_history` + +### 4.2 字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `browse_history_id` | `String` | 主键,格式为 `user_id:owner_user_id:profile_id` | +| `user_id` | `String` | 当前登录用户 | +| `owner_user_id` | `String` | 被浏览世界所属用户 | +| `profile_id` | `String` | 被浏览世界 profile | +| `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` | 最近更新时间 | + +### 4.3 索引 + +至少保留以下访问路径: + +1. 主键 `browse_history_id` +2. `(user_id, visited_at)` 用于当前用户按时间倒序列出 +3. `(user_id, owner_user_id, profile_id)` 用于幂等 upsert + +### 4.4 设计决议 + +1. 不额外引入自增 ID,直接把幂等键收口成主键。 +2. `visited_at` 单独持久化成 `Timestamp`,避免把时间排序退回字符串比较。 +3. `theme_mode` 在表内固定为枚举,但 Axum 输入仍宽松接受字符串。 + +## 5. Procedure 设计 + +### 5.1 procedure 列表 + +1. `list_platform_browse_history` +2. `upsert_platform_browse_history_and_return` +3. `clear_platform_browse_history_and_return` + +统一返回: + +```text +RuntimeBrowseHistoryProcedureResult { + ok + entries + error_message +} +``` + +### 5.2 行为约束 + +`list_platform_browse_history` + +1. 校验 `user_id` +2. 读取当前用户所有记录 +3. 按 `visited_at DESC` 返回 + +`upsert_platform_browse_history_and_return` + +1. 校验 `user_id` +2. 接受单批最多 `100` 条 +3. 先按旧 Node 规则宽松归一化 +4. 先按 `visitedAt DESC` 排序,再按 `ownerUserId + profileId` 去重 +5. 用 `browse_history_id` 幂等 upsert +6. 返回当前用户完整倒序列表 + +`clear_platform_browse_history_and_return` + +1. 校验 `user_id` +2. 删除当前用户全部记录 +3. 返回空列表 + +## 6. Axum facade 设计 + +### 6.1 双路径兼容 + +两组路径必须共用同一组 handler: + +1. `/api/runtime/profile/browse-history` +2. `/api/profile/browse-history` + +只允许路由名不同,不允许行为分叉。 + +### 6.2 GET + +行为: + +1. Bearer JWT 校验 +2. 读取 claims 中的 `user_id` +3. 调 `spacetime_client.list_platform_browse_history` +4. 返回 `PlatformBrowseHistoryResponse` + +### 6.3 POST + +行为: + +1. Bearer JWT 校验 +2. 通过 `serde(untagged)` 同时接单条和批量 shape +3. 不对 `themeMode` 做严格 400 拒绝 +4. 对 `ownerUserId`、`profileId`、`worldName` 的缺失或空串按旧 Node 路由规则直接返回 `400` +5. 写入成功后返回最新完整列表 + +### 6.4 DELETE + +行为: + +1. Bearer JWT 校验 +2. 清空当前用户全部记录 +3. 返回 `entries: []` + +### 6.5 错误映射 + +1. JSON 解析失败:`400 BAD_REQUEST` +2. DTO 构建失败:`400 BAD_REQUEST` +3. SpacetimeDB 调用失败:`502 BAD_GATEWAY` +4. JWT 缺失或失效:沿用当前 `401 UNAUTHORIZED` + +错误 `details.provider` 固定为: + +1. `browse-history` +2. `spacetimedb` + +## 7. 测试策略 + +### 7.1 必跑测试 + +1. `module-runtime` + - 宽松 theme 归一化 + - `visitedAt` 默认值 + - 去重与倒序逻辑 +2. `api-server` + - 未登录返回 `401` + - 兼容路径与主路径一致 + - `POST` 同时支持单条和批量 + - envelope 打开时错误结构稳定 + +### 7.2 可选联调测试 + +保留 `#[ignore]` 的本地 SpacetimeDB 集成测试: + +1. `POST -> GET` +2. `DELETE -> GET` + +## 8. 本文完成定义 + +当以下条件成立时,本设计视为完成: + +1. `user_browse_history` 表字段、主键和排序规则已冻结。 +2. 双路径 facade、请求 shape 和错误契约已冻结。 +3. 后续编码不再需要猜测: + - `themeMode` 是否严格校验 + - `POST` 是否支持单条/批量双 shape + - 去重时机与排序依据 + +## 9. 2026-04-22 实际落地进度 + +1. `module-runtime` 已切换为“API 入口严格校验 + 领域层静默过滤”的旧 Node 对齐模式。 +2. `api-server` 已补齐双路径 browse history handler,并补 `401`、`400`、批量 shape、兼容路径一致性测试。 +3. 剩余阻塞主要在工作树内其他并行任务带来的 Rust 编译占用与跨模块联调,不属于 browse history 方案本身。 diff --git a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md new file mode 100644 index 00000000..4d4e4782 --- /dev/null +++ b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md @@ -0,0 +1,410 @@ +# M3:profile dashboard / wallet ledger / play stats Axum + SpacetimeDB 落地设计 + +日期:`2026-04-22` + +关联任务: + +- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md) +- [../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md) + +关联现状: + +- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) +- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) +- [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) +- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +- `server-node/src/repositories/runtimeRepository.ts` + +## 1. 文档目的 + +当前 M3 checklist 已经列出: + +1. `profile_dashboard_state` +2. `profile_wallet_ledger` +3. `profile_played_world` +4. `/api/runtime/profile/dashboard` +5. `/api/runtime/profile/wallet-ledger` +6. `/api/runtime/profile/play-stats` + +但仓库里还没有把这一组 profile 只读 facade 细化到可以直接编码的程度。本文件补足: + +1. 旧 Node 行为冻结 +2. SpacetimeDB projection 表字段 +3. procedure 返回 contract +4. Axum 双路径 facade 与错误映射 +5. 本轮只做读链、不提前承诺 snapshot 写入的边界 + +本文件不新增新的 M3 checklist,只服务于现有 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md) 的后续落地。 + +## 2. 本轮范围 + +本轮只覆盖以下 6 条兼容路由: + +1. `GET /api/runtime/profile/dashboard` +2. `GET /api/profile/dashboard` +3. `GET /api/runtime/profile/wallet-ledger` +4. `GET /api/profile/wallet-ledger` +5. `GET /api/runtime/profile/play-stats` +6. `GET /api/profile/play-stats` + +本轮不做: + +1. `runtime_snapshot` +2. `save archive` +3. snapshot -> profile projection 自动刷新 +4. profile projection 的写 procedure + +这样拆分的原因是: + +1. 这组三个 profile 接口本质上都是 projection 读接口。 +2. 旧 Node 读语义已经稳定,且空数据时都有明确默认值。 +3. 先把读 contract 和表结构固定住,后续 `runtime_snapshot / save archive` 接上 projection writer 时不会再改 facade contract。 + +## 3. 旧 Node 行为冻结 + +Node 侧入口位于: + +1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +2. `server-node/src/repositories/runtimeRepository.ts` + +冻结口径如下。 + +### 3.1 dashboard + +路由: + +1. `GET /api/runtime/profile/dashboard` +2. `GET /api/profile/dashboard` + +返回: + +```json +{ + "walletBalance": 0, + "totalPlayTimeMs": 0, + "playedWorldCount": 0, + "updatedAt": null +} +``` + +语义: + +1. `walletBalance` 从 `profile_dashboard_state.wallet_balance` 读取。 +2. `totalPlayTimeMs` 从 `profile_dashboard_state.total_play_time_ms` 读取。 +3. `playedWorldCount` 通过 `profile_played_world` 的当前用户记录数计算。 +4. `updatedAt` 为空时返回 `null`。 +5. 当用户尚无任何 projection 时,仍返回默认零值,不返回 `404`。 + +### 3.2 wallet ledger + +路由: + +1. `GET /api/runtime/profile/wallet-ledger` +2. `GET /api/profile/wallet-ledger` + +返回: + +```json +{ + "entries": [ + { + "id": "ledger_001", + "amountDelta": 20, + "balanceAfter": 120, + "sourceType": "snapshot_sync", + "createdAt": "2026-04-22T10:00:00Z" + } + ] +} +``` + +语义: + +1. 只返回当前用户的流水。 +2. 按 `createdAt DESC` 排序。 +3. 最多返回最近 `50` 条。 +4. 当前旧 Node 仅冻结 `sourceType = "snapshot_sync"` 一种来源。 +5. 没有流水时返回 `{ "entries": [] }`。 + +### 3.3 play stats + +路由: + +1. `GET /api/runtime/profile/play-stats` +2. `GET /api/profile/play-stats` + +返回: + +```json +{ + "totalPlayTimeMs": 0, + "playedWorks": [], + "updatedAt": null +} +``` + +其中 `playedWorks` 单项字段冻结为: + +```json +{ + "worldKey": "builtin:WUXIA", + "ownerUserId": null, + "profileId": null, + "worldType": "WUXIA", + "worldTitle": "武侠世界", + "worldSubtitle": "", + "firstPlayedAt": "2026-04-20T10:00:00Z", + "lastPlayedAt": "2026-04-22T10:00:00Z", + "lastObservedPlayTimeMs": 120000 +} +``` + +语义: + +1. `totalPlayTimeMs` 与 dashboard 共用 `profile_dashboard_state.total_play_time_ms`。 +2. `playedWorks` 来自 `profile_played_world`。 +3. 按 `lastPlayedAt DESC` 排序。 +4. `updatedAt` 与 dashboard 共用 `profile_dashboard_state.updated_at`。 +5. 没有 projection 时返回空列表和零值,不返回 `404`。 + +## 4. 本轮边界决议 + +### 4.1 先做 projection 读链 + +本轮 profile 三接口只做: + +1. projection 表 schema +2. procedure 读接口 +3. Axum facade +4. shared contract + +不做 snapshot 写链,原因: + +1. `runtime_snapshot` 仍未冻结最终表结构。 +2. save archive 还未把“领域表真相 + 聚合快照”方案完全落到文档。 +3. 若现在提前补写逻辑,后续大概率要因为 snapshot 方案调整而返工。 + +### 4.2 默认值必须前置兼容 + +虽然 projection 还没有 writer,但 facade 仍要先兼容旧 Node 默认值: + +1. dashboard 返回零值 +2. wallet ledger 返回空数组 +3. play stats 返回零值 + 空数组 + +这样前端不会因为表暂时为空而收到 `404` 或 `null` 结构漂移。 + +## 5. SpacetimeDB 表设计 + +### 5.1 `profile_dashboard_state` + +字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `user_id` | `String` | 主键,绑定平台用户 | +| `wallet_balance` | `u64` | 当前钱包余额 | +| `total_play_time_ms` | `u64` | 累积游玩时长 | +| `created_at` | `Timestamp` | projection 首次建立时间 | +| `updated_at` | `Timestamp` | projection 最近刷新时间 | + +设计决议: + +1. 一名用户只保留一行 dashboard 聚合状态。 +2. `playedWorldCount` 不单独持久化,读取时直接统计 `profile_played_world`。 +3. 钱包余额与总游玩时长都固定为非负整数,不保留浮点。 + +### 5.2 `profile_wallet_ledger` + +字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `wallet_ledger_id` | `String` | 主键,流水 ID | +| `user_id` | `String` | 用户 ID | +| `amount_delta` | `i64` | 本次余额增减 | +| `balance_after` | `u64` | 变动后的余额 | +| `source_type` | `RuntimeProfileWalletLedgerSourceType` | 当前只冻结 `snapshot_sync` | +| `created_at` | `Timestamp` | 流水发生时间 | + +设计决议: + +1. 钱包流水是 append-only,不提供 update。 +2. 本轮只冻结 `snapshot_sync` 一种来源,避免前后端散落裸字符串。 +3. 读取排序由 procedure 保证,不依赖表天然顺序。 + +### 5.3 `profile_played_world` + +字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `played_world_id` | `String` | 主键,固定为 `user_id:world_key` | +| `user_id` | `String` | 用户 ID | +| `world_key` | `String` | 世界唯一键,兼容内置世界与自定义世界 | +| `owner_user_id` | `Option` | 自定义世界作者用户 ID | +| `profile_id` | `Option` | 自定义世界 profile ID | +| `world_type` | `Option` | 内置世界类型,例如 `WUXIA` | +| `world_title` | `String` | 世界标题 | +| `world_subtitle` | `String` | 世界副标题 | +| `first_played_at` | `Timestamp` | 首次游玩时间 | +| `last_played_at` | `Timestamp` | 最近游玩时间 | +| `last_observed_play_time_ms` | `u64` | 最近一次观测到的该世界累计游玩时长 | + +设计决议: + +1. 每个用户每个 `world_key` 只保留一行。 +2. `played_world_id = user_id:world_key`,避免额外自增 ID。 +3. `lastObservedPlayTimeMs` 保留在表中,为后续 snapshot sync 计算增量时长服务。 + +## 6. module-runtime DTO 设计 + +本轮在 `module-runtime` 新增以下类型族: + +1. `RuntimeProfileDashboardSnapshot` +2. `RuntimeProfileDashboardProcedureResult` +3. `RuntimeProfileDashboardGetInput` +4. `RuntimeProfileWalletLedgerEntrySnapshot` +5. `RuntimeProfileWalletLedgerProcedureResult` +6. `RuntimeProfileWalletLedgerListInput` +7. `RuntimeProfilePlayedWorldSnapshot` +8. `RuntimeProfilePlayStatsSnapshot` +9. `RuntimeProfilePlayStatsProcedureResult` +10. `RuntimeProfilePlayStatsGetInput` + +同时新增 record 层 DTO,供 `spacetime-client` 返回给 Axum: + +1. `RuntimeProfileDashboardRecord` +2. `RuntimeProfileWalletLedgerEntryRecord` +3. `RuntimeProfilePlayedWorldRecord` +4. `RuntimeProfilePlayStatsRecord` + +字段规则: + +1. 所有时间在 snapshot 内部统一保存为 `*_micros`。 +2. record 层统一格式化成 RFC3339 字符串。 +3. `updated_at_micros` 使用 `Option`,避免继续沿用 `0` 这种弱语义占位值。 + +## 7. Procedure 设计 + +本轮只新增 3 个只读 procedure: + +1. `get_profile_dashboard` +2. `list_profile_wallet_ledger` +3. `get_profile_play_stats` + +行为要求: + +### 7.1 `get_profile_dashboard` + +1. 校验 `user_id` 非空。 +2. 读取 `profile_dashboard_state`。 +3. 统计当前用户 `profile_played_world` 数量。 +4. 如果 dashboard 状态不存在,返回零值快照。 + +### 7.2 `list_profile_wallet_ledger` + +1. 校验 `user_id` 非空。 +2. 读取当前用户全部流水。 +3. 按 `created_at DESC` 排序。 +4. 截断到最近 `50` 条。 + +### 7.3 `get_profile_play_stats` + +1. 校验 `user_id` 非空。 +2. 从 `profile_dashboard_state` 读取 `total_play_time_ms` 和 `updated_at`。 +3. 读取当前用户 `profile_played_world`。 +4. 按 `last_played_at DESC` 排序。 +5. 如果 dashboard 状态不存在,仍返回零值与空数组。 + +## 8. spacetime-client 设计 + +新增 3 个调用封装: + +1. `get_profile_dashboard(user_id)` +2. `list_profile_wallet_ledger(user_id)` +3. `get_profile_play_stats(user_id)` + +错误映射保持当前链路习惯: + +1. 本地 DTO 构建失败 -> `SpacetimeClientError::Runtime` +2. procedure 执行失败 -> `SpacetimeClientError::Procedure` + +不在 client 层做默认值兜底;默认值由 `spacetime-module` procedure 保证,避免多个调用方重复实现。 + +## 9. Axum facade 设计 + +### 9.1 路由 + +本轮 Rust facade 固定暴露 6 条路由: + +1. `/api/runtime/profile/dashboard` +2. `/api/profile/dashboard` +3. `/api/runtime/profile/wallet-ledger` +4. `/api/profile/wallet-ledger` +5. `/api/runtime/profile/play-stats` +6. `/api/profile/play-stats` + +全部要求 Bearer JWT。 + +### 9.2 响应结构 + +1. dashboard 直接返回 `ProfileDashboardSummaryResponse` +2. wallet-ledger 返回 `ProfileWalletLedgerResponse` +3. play-stats 返回 `ProfilePlayStatsResponse` + +字段名保持 camelCase,与旧 Node contract 对齐。 + +### 9.3 错误映射 + +1. JWT 缺失或失效:沿用现有 `401` +2. 本地 DTO 准备失败:`400` +3. SpacetimeDB 调用失败:`502` + +`details.provider` 规则: + +1. 本地 DTO 错误使用当前接口自己的 provider +2. 下游 SpacetimeDB 错误统一使用 `spacetimedb` + +## 10. 本轮暂不处理的事项 + +以下事项在本设计中显式延后: + +1. `runtime_snapshot` 写入时如何刷新三张 profile projection 表 +2. `profile_wallet_ledger` 的更多 `source_type` +3. `profile_played_world` 的世界标题修复、补字段或回填历史迁移 +4. `save archive` 与 `play stats` 之间的联动 + +这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。 + +## 11. 测试策略 + +### 11.1 必跑 + +1. `module-runtime` + - `user_id` 非空校验 + - record 层时间格式化 + - wallet ledger source type 字符串格式化 +2. `shared-contracts` + - dashboard / wallet-ledger / play-stats 的 camelCase 序列化 +3. `api-server` + - 未登录返回 `401` + - 6 条 facade 都已挂接 + - SpacetimeDB 未发布时返回 `502` + - 主路径与兼容路径错误 envelope 一致 + +### 11.2 本轮不强制 + +1. 不强制本地 SpacetimeDB 联调测试 +2. 不强制 projection 写入集成测试 + +原因是这两类测试都依赖后续 `runtime_snapshot` 写链补齐。 + +## 12. 本文完成定义 + +当以下条件满足时,本设计文档视为完成: + +1. `profile_dashboard_state / profile_wallet_ledger / profile_played_world` 字段与 ID 规则已冻结。 +2. `dashboard / wallet-ledger / play-stats` 的 procedure 名、返回结构、排序与默认值已冻结。 +3. `api/runtime/*` 与兼容 `/api/profile/*` 双路径已冻结。 +4. 可以据此直接开始 `module-runtime`、`shared-contracts`、`spacetime-module`、`spacetime-client`、`api-server` 编码。 diff --git a/docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md b/docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md new file mode 100644 index 00000000..3dcd8695 --- /dev/null +++ b/docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md @@ -0,0 +1,276 @@ +# M3:runtime settings Axum + SpacetimeDB 落地设计 + +日期:`2026-04-21` + +关联任务: + +- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md) +- [../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md) + +关联现状: + +- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) +- [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) +- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +- `server-node/src/repositories/runtimeRepository.ts` + +## 1. 文档目的 + +`02_M3_RUNTIME_PROFILE.md` 已经冻结了 M3 的任务范围,但还没有把首批可编码切片细化到表字段、procedure、Axum facade、兼容错误格式和测试策略。 + +本文件只解决 M3 第一批最小纵向切片: + +1. `GET /api/runtime/settings` +2. `PUT /api/runtime/settings` + +以及其在 Rust 重写中的完整落位: + +1. `module-runtime` 的字段约束与 DTO +2. `crates/spacetime-module` 的 `runtime_setting` 表与 procedure +3. `crates/spacetime-client` 的 procedure 调用封装 +4. `crates/api-server` 的兼容 facade 与响应 contract + +本文件不新增 checklist,不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足可以直接编码的技术口径。 + +## 2. 为什么先做 runtime settings + +在 M3 范围内,`runtime settings` 是当前最适合先迁移的纵向切片: + +1. 读写模型最小,只依赖 `user_id + music_volume + platform_theme`。 +2. 旧 Node 逻辑没有跨表聚合、副作用和复杂 projection。 +3. 前端 contract 清晰,兼容路径只有一条,不涉及 `/api/profile/*` 双路径。 +4. 可以先把 `Axum -> JWT -> SpacetimeDB procedure -> 标准 envelope` 主链跑通,为后续 `browse history / snapshot / save archive / dashboard` 复用。 + +## 3. 旧实现冻结口径 + +当前 Node 侧 `runtime settings` 行为来自: + +- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +- `server-node/src/repositories/runtimeRepository.ts` + +冻结行为如下: + +### 3.1 路由 + +- `GET /api/runtime/settings` +- `PUT /api/runtime/settings` + +两条接口都要求 JWT。 + +### 3.2 请求体 + +`PUT /api/runtime/settings` 请求体: + +```json +{ + "musicVolume": 0.42, + "platformTheme": "light" +} +``` + +校验规则: + +1. `musicVolume` 必须在 `0 ~ 1`。 +2. `platformTheme` 只接受 `light | dark`。 + +### 3.3 默认值 + +默认值来自 `packages/shared/src/contracts/runtime.ts`: + +1. `DEFAULT_MUSIC_VOLUME = 0.42` +2. `DEFAULT_PLATFORM_THEME = "light"` + +当用户从未写入过设置时,读取接口必须返回默认值,而不是 `404` 或 `null`。 + +### 3.4 归一化规则 + +旧 Node 写入时会做以下归一化: + +1. `musicVolume` 强制 clamp 到 `0 ~ 1` +2. `platformTheme` 如果不是 `dark`,统一回退到 `light` + +Rust 重写阶段仍保持同样语义,避免前端产生行为漂移。 + +## 4. Rust 落位决议 + +### 4.1 crate 分工 + +本切片固定按以下边界落位: + +1. `crates/module-runtime` + - 定义 `RuntimeSettings` 领域 DTO、默认值、字段校验与归一化规则。 +2. `crates/spacetime-module` + - 定义 `runtime_setting` 表。 + - 提供 `upsert_runtime_setting_and_return` procedure。 +3. `crates/spacetime-client` + - 提供 `get_runtime_settings`、`put_runtime_settings` 调用封装。 +4. `crates/api-server` + - 提供 `GET/PUT /api/runtime/settings`。 + - 保持当前 envelope / 错误格式 / 请求头兼容。 + +### 4.2 身份边界 + +当前阶段前端仍只访问 Axum,不直连 SpacetimeDB。 + +因此: + +1. 用户身份仍由 Axum 侧 JWT middleware 校验。 +2. Axum 从已校验的 access token claims 中取 `user_id`。 +3. `user_id` 作为 procedure 入参写入 `runtime_setting`。 + +注意: + +1. 这不是最终的 SpacetimeDB 原生身份透传形态。 +2. 在 M3 首批切片里,先以 Axum 作为唯一鉴权边界,保证与当前前端 contract 一致。 + +## 5. SpacetimeDB 表设计 + +### 5.1 表名 + +`runtime_setting` + +### 5.2 字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `user_id` | `String` | 主键,绑定平台用户 | +| `music_volume` | `f32` | 音量,持久化归一化后的值 | +| `platform_theme` | `RuntimePlatformTheme` | 平台主题枚举 | +| `created_at` | `Timestamp` | 首次创建时间 | +| `updated_at` | `Timestamp` | 最近更新时间 | + +### 5.3 设计决议 + +1. 每个用户只保留一行设置,不做历史版本表。 +2. `user_id` 直接作为主键,避免再引入无业务价值的自增 ID。 +3. `platform_theme` 固定为枚举,不把 `light/dark` 继续散落成字符串字面量。 +4. 首批阶段不把设置拆成多行 KV 表,避免简单需求被过度抽象。 + +## 6. Procedure 设计 + +### 6.1 不单独暴露 reducer 给 Axum + +本切片优先提供 procedure,而不是让 Axum 直接调 reducer + 再查询表。 + +原因: + +1. 当前 `spacetime-client` 已经以 procedure 返回结果的模式承接资产链。 +2. 设置接口需要同步返回最终写入结果,procedure 可减少一次额外查询。 +3. 当前 `runtime_setting` 不需要客户端订阅,private table + procedure 更直接。 + +### 6.2 Procedure 列表 + +1. `get_runtime_setting_or_default` +2. `upsert_runtime_setting_and_return` + +返回 DTO 固定为: + +```text +RuntimeSettingSnapshot { + user_id + music_volume + platform_theme + created_at_micros + updated_at_micros +} +``` + +如果用户还没有设置记录: + +1. `get_runtime_setting_or_default` 返回默认值快照。 +2. 但不强制立即插入表,避免纯读取请求制造无意义写入。 + +## 7. Axum facade 设计 + +### 7.1 GET /api/runtime/settings + +行为: + +1. 走 `require_bearer_auth`。 +2. 从 `claims.user_id` 取用户 ID。 +3. 调 `spacetime_client.get_runtime_settings(user_id)`。 +4. 返回: + +```json +{ + "musicVolume": 0.42, + "platformTheme": "light" +} +``` + +### 7.2 PUT /api/runtime/settings + +行为: + +1. 走 `require_bearer_auth`。 +2. 使用 Axum `Json` + `serde` 解析请求。 +3. 在 `module-runtime` 内做归一化。 +4. 调 `spacetime_client.put_runtime_settings(user_id, payload)`。 +5. 返回归一化后的最终值。 + +### 7.3 错误映射 + +1. 请求体解析失败:`400 BAD_REQUEST` +2. 字段校验失败:`400 BAD_REQUEST` +3. SpacetimeDB 调用失败:`502 BAD_GATEWAY` +4. JWT 缺失或失效:沿用现有 `401 UNAUTHORIZED` + +错误 `details.provider` 固定为: + +1. `runtime-settings`:本地字段归一化或 DTO 构建失败 +2. `spacetimedb`:procedure 调用失败 + +## 8. 首批测试策略 + +本切片测试分两层: + +### 8.1 必跑测试 + +1. `module-runtime` + - 默认值 + - clamp 规则 + - theme 归一化 +2. `api-server` + - 未登录返回 `401` + - 请求 envelope 打开时返回标准 `ok/data/error/meta` + - JSON 结构与字段名兼容 + +### 8.2 可选联调测试 + +补一条 `#[ignore]` 的集成测试: + +1. 需要本地 SpacetimeDB 已启动 +2. 需要当前 `spacetime-module` 已发布 +3. 验证 `PUT -> GET` 能往返一致 + +原因: + +1. 当前仓库已有资产链的 `#[ignore]` 集成测试模式。 +2. 在未稳定建立测试 harness 前,不强制把 SpacetimeDB 作为默认单测前置条件。 + +## 9. 后续扩展顺序 + +`runtime settings` 完成后,M3 后续能力按以下顺序推进: + +1. `user_browse_history` +2. `runtime_snapshot` +3. `profile_save_archive` +4. `profile_dashboard_state + profile_wallet_ledger + profile_played_world` + +顺序原因: + +1. `browse_history` 仍是单表为主,只带去重与排序规则。 +2. `snapshot` 和 `save_archive` 依赖兼容聚合策略,复杂度更高。 +3. `dashboard / play-stats / wallet-ledger` 依赖 projection,更适合放在 snapshot 规则固定后收口。 + +## 10. 本文完成定义 + +当以下条件成立时,本设计文档视为完成: + +1. `runtime settings` 的字段、默认值、归一化规则、procedure 与 Axum facade 已书面冻结。 +2. 后续编码无需再猜测: + - 表字段名 + - 主键策略 + - 默认值来源 + - Axum 与 SpacetimeDB 的职责边界 +3. 可以直接据此开始 `module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 编码。 diff --git a/docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md b/docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md new file mode 100644 index 00000000..9b6b858b --- /dev/null +++ b/docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md @@ -0,0 +1,147 @@ +# M4 Combat Reward Inventory Integration(2026-04-22) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结一件事: + +**把 `resolve_combat_action(Victory)` 从“只发经验”推进到“经验与战利品可在同一事务内结算”的最小主链口径。** + +本轮不回收完整 runtime item 导演层,也不在 `module-combat` 内直接做 AI 语义生成;只承接已经编译好的 reward item 快照。 + +--- + +## 1. 本轮落地范围 + +本轮只落实下面 4 件事: + +1. 在 `module-combat` 中为 `battle_state` 补充 `reward_items` 字段。 +2. 允许 `BattleStateInput` 在初始化时携带已经编译好的战利品快照。 +3. 在 `spacetime-module::resolve_combat_action` 中,当结果为 `Victory` 时同步把 `reward_items` 写入 `inventory_slot`。 +4. 保持 `module-combat` 仍然是纯规则 crate,不直接依赖 `module-inventory`。 + +--- + +## 2. 当前冻结的战利品口径 + +### 2.1 `battle_state.reward_items` + +首版字段固定复用 `module-runtime-item::RuntimeItemRewardItemSnapshot`。 + +原因: + +1. 宝箱链已经用这套 reward item contract 打通到 `inventory_slot`。 +2. 任务奖励当前仍有独立 `QuestRewardItem`,但战斗奖励更接近 runtime item 导演层。 +3. 先复用现有 reward item 快照,避免本轮再发明第三套 combat 专属掉落结构。 + +### 2.2 battle 初始化来源 + +当前 `battle_state.reward_items` 不在战斗 reducer 内生成,只允许由上游在创建 battle 时传入: + +1. `resolve_npc_battle_interaction_and_return` +2. 后续 Axum façade / runtime story orchestration +3. 其它明确的 battle create 聚合入口 + +也就是说: + +1. `module-combat` 只消费已确定的 reward item 快照 +2. 不在 reducer 内做随机、提示词、外部世界图谱推导 + +当前已接通: + +1. `resolve_npc_battle_interaction_and_return` +2. `POST /api/story/npc/battle` +3. `POST /api/story/battles` + +这几条入口都只负责透传已编译奖励,不负责现场生成掉落。 + +--- + +## 3. Victory 发物规则 + +当 `resolve_combat_action` 结算结果满足: + +1. `outcome == Victory` + +则 `spacetime-module` 需要继续执行: + +1. `experience_reward > 0` 时写 `player_progression / chapter_progression` +2. `reward_items.len() > 0` 时写 `inventory_slot` + +### 3.1 发物方式 + +当前固定规则: + +1. 每个 reward item 显式映射成一条 `InventoryMutation::GrantItem` +2. `source_kind = CombatDrop` +3. `source_reference_id = battle_state_id` +4. 同一 `battle_state_id` 只允许发放一次 + +### 3.2 幂等约束 + +本轮先采用与 quest / treasure 一致的“按来源引用查重”思路: + +1. 若当前 actor 的 `inventory_slot` 中已经存在 `source_reference_id = battle_state_id` +2. 视为该 battle reward 已发放 +3. Victory 再次重放时跳过发物,但不影响 battle_state 已收束结果 + +--- + +## 4. 与既有链路的边界 + +### 4.1 与 `module-combat` + +`module-combat` 继续只负责: + +1. `battle_state` 结构 +2. `resolve_combat_action` 状态推进 +3. 胜负结果收束 + +不负责: + +1. inventory 写表 +2. progression 写表 +3. runtime item 生成 + +### 4.2 与 `module-runtime-item` + +本轮不把战斗奖励映射 helper 上提到 `module-runtime-item`。 + +原因: + +1. 当前 `RuntimeItemRewardItemSnapshot -> InventoryItemSnapshot` 的 helper 语义固定为 `TreasureReward` +2. 若直接复用,会把 `source_kind` 写错成 `TreasureReward` +3. 本轮先在 `spacetime-module` 里补一个 combat 专用映射,后续再统一抽象 + +--- + +## 5. 当前刻意未做 + +本轮明确不做下面这些扩张: + +1. 不把 Node 版 `monster_drop` AI 导演层整体迁到 Rust +2. 不在 `resolve_npc_battle_interaction_and_return` 里现场计算掉落 +3. 不处理 battle reward 的货币、好感、情报 +4. 不处理战斗内 `inventory_use` +5. 不把掉落展示或 Battle Reward 面板接到前端 + +--- + +## 6. 验证要求 + +本轮完成后至少执行: + +1. `npm run check:encoding` +2. `cargo test -p module-combat --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +3. `cargo check -p module-combat -p spacetime-module --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` + +--- + +## 7. 下一步建议 + +按当前节奏,后续应继续按下面顺序推进: + +1. 把 Node 侧 `monster_drop` runtime item 编译逻辑迁到 Rust 聚合层。 +2. 视复用收益决定是否把 battle / treasure 的 reward item 归一化 helper 上提到 `module-runtime-item`。 +3. 最后再把 battle reward 展示、story patch 和前端接口切到新后端。 diff --git a/docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md b/docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md new file mode 100644 index 00000000..40d55a6e --- /dev/null +++ b/docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md @@ -0,0 +1,306 @@ +# M4 module-ai Axum facade 设计(2026-04-22) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结一件事: + +**把已经在 `spacetime-module` 落地的 `module-ai` 任务真相表与最小 procedure / reducer,继续向上接到 `shared-contracts`、`spacetime-client` 与 `api-server`,形成可由 HTTP 直接调用的 AI task mutation facade。** + +本轮只做最小同步 mutation 链,不扩到 SSE、真实模型供应商请求或前端订阅。 + +--- + +## 1. 本轮要解决的问题 + +当前仓库已经具备: + +1. `module-ai` + - 统一 `AiTaskKind / AiTaskStageKind / AiResultReferenceKind` + - 统一任务、阶段、文本片段、结果引用领域模型 +2. `spacetime-module` + - `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` + - `create / append / complete / attach / fail / cancel` 最小 procedure + - `start_ai_task / start_ai_task_stage` reducer +3. `spacetime-client` + - 已生成 AI 相关 Rust bindings + +但当前仍缺三层: + +1. `shared-contracts` 还没有 AI task HTTP DTO +2. `spacetime-client` 还没有 AI facade 方法与 record 映射 +3. `api-server` 还没有 `/api/ai/tasks*` 路由 + +因此本轮只补下面三层: + +1. `shared-contracts` AI DTO +2. `spacetime-client` AI facade +3. `api-server` AI tasks HTTP route + +--- + +## 2. 当前明确不做的事 + +本轮明确不做: + +1. 不接入真实 `platform-llm` 流式回调 +2. 不提供 SSE 增量推送接口 +3. 不增加 AI task 查询 / 订阅 projection +4. 不把 story / npc / quest / custom-world 旧入口自动迁到这组新接口 +5. 不修改 `spacetime-client/src/module_bindings/*` 生成文件 + +原因很直接: + +1. 当前先把 AI task mutation 的最小 HTTP contract 固定下来 +2. SSE 与查询态必须等待后续订阅策略或 query procedure 冻结 +3. 业务编排入口切换应该在上层模块各自评估,不在本轮提前硬迁 + +--- + +## 3. 路由冻结 + +本轮首版新增以下路由: + +1. `POST /api/ai/tasks` +2. `POST /api/ai/tasks/{taskId}/start` +3. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/start` +4. `POST /api/ai/tasks/{taskId}/chunks` +5. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete` +6. `POST /api/ai/tasks/{taskId}/references` +7. `POST /api/ai/tasks/{taskId}/complete` +8. `POST /api/ai/tasks/{taskId}/fail` +9. `POST /api/ai/tasks/{taskId}/cancel` + +### 3.1 同步返回路由 + +当前下列路由走 `procedure`,成功时同步返回 `aiTask` 快照: + +1. `POST /api/ai/tasks` +2. `POST /api/ai/tasks/{taskId}/chunks` +3. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete` +4. `POST /api/ai/tasks/{taskId}/references` +5. `POST /api/ai/tasks/{taskId}/complete` +6. `POST /api/ai/tasks/{taskId}/fail` +7. `POST /api/ai/tasks/{taskId}/cancel` + +其中: + +1. `chunks` 额外返回 `aiTextChunk` +2. 其他 mutation 当前只返回 `aiTask` + +### 3.2 Accepted 路由 + +当前下列路由只接 `reducer`,不会同步返回快照: + +1. `POST /api/ai/tasks/{taskId}/start` +2. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/start` + +因此本轮明确冻结为: + +1. HTTP 成功状态码返回 `202 Accepted` +2. body 只返回: + - `accepted` + - `taskId` + - `action` + - `stageKind`(仅 stage start) +3. 不伪装成“已经拿到最新任务快照” + +后续如果要让这两条路由也同步返回快照,应先在 `spacetime-module` 增加对应 procedure。 + +--- + +## 4. 请求与响应 DTO 冻结 + +### 4.1 创建任务请求 + +`POST /api/ai/tasks` 请求体冻结为: + +1. `taskKind` +2. `requestLabel` +3. `sourceModule` +4. `sourceEntityId` +5. `requestPayloadJson` +6. `stageKinds` + +其中: + +1. `taskId` 不接受外部写入,由 Axum 使用 `generate_ai_task_id(nowMicros)` 生成 +2. `ownerUserId` 不接受外部写入,必须取自 Bearer token +3. `stageKinds` 为空时,由 `module-ai` 根据 `taskKind.default_stage_blueprints()` 自动补齐默认阶段蓝图 + +### 4.2 追加文本片段请求 + +`POST /api/ai/tasks/{taskId}/chunks` 请求体冻结为: + +1. `stageKind` +2. `sequence` +3. `deltaText` + +### 4.3 完成阶段请求 + +`POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete` 请求体冻结为: + +1. `textOutput` +2. `structuredPayloadJson` +3. `warningMessages` + +### 4.4 绑定结果引用请求 + +`POST /api/ai/tasks/{taskId}/references` 请求体冻结为: + +1. `referenceKind` +2. `referenceId` +3. `label` + +### 4.5 失败请求 + +`POST /api/ai/tasks/{taskId}/fail` 请求体冻结为: + +1. `failureMessage` + +### 4.6 成功响应 + +本轮统一返回以下 payload: + +1. `AiTaskPayload` +2. `AiTaskStagePayload` +3. `AiResultReferencePayload` +4. `AiTextChunkPayload` +5. `AiTaskMutationResponse` +6. `AiTaskAcceptedResponse` + +时间字段继续统一为 RFC3339 字符串。 + +--- + +## 5. `spacetime-client` 冻结口径 + +本轮新增以下 facade: + +1. `create_ai_task` +2. `start_ai_task` +3. `start_ai_task_stage` +4. `append_ai_text_chunk` +5. `complete_ai_stage` +6. `attach_ai_result_reference` +7. `complete_ai_task` +8. `fail_ai_task` +9. `cancel_ai_task` + +### 5.1 输入边界 + +1. procedure 输入直接复用 `module-ai` 领域输入结构 +2. `start_ai_task` 与 `start_ai_task_stage` 直接复用 reducer 输入结构 +3. 不让 `api-server` 直接依赖 generated binding 类型 + +### 5.2 输出边界 + +`spacetime-client` 新增下列 record,供 `api-server` 直接消费: + +1. `AiTaskRecord` +2. `AiTaskStageRecord` +3. `AiTextChunkRecord` +4. `AiResultReferenceRecord` +5. `AiTaskMutationRecord` + +字符串字段规范: + +1. `taskKind` 使用: + - `story_generation` + - `character_chat` + - `npc_chat` + - `custom_world_generation` + - `quest_intent` + - `runtime_item_intent` +2. `stageKind` 使用 `module-ai::AiTaskStageKind::as_str()` +3. `status` 使用 snake_case +4. `referenceKind` 使用 snake_case + +### 5.3 错误映射 + +AI facade 在 `spacetime-client` 内部按以下规则区分: + +1. procedure / reducer 返回的业务拒绝 + - 映射为 `SpacetimeClientError::Runtime` +2. SDK 调用、连接、超时、意外缺字段 + - 映射为 `Build / Procedure / ConnectDropped / Timeout` + +这样 `api-server` 才能稳定把业务错误映射成 `400`。 + +--- + +## 6. `api-server` 冻结口径 + +### 6.1 鉴权与身份 + +所有 `/api/ai/tasks*` 路由继续统一挂 Bearer 鉴权。 + +其中: + +1. `ownerUserId` 必须来自 `AuthenticatedAccessToken.claims().user_id()` +2. 不接受前端自行写入任务所有者 + +### 6.2 时间与 ID + +以下字段不接受外部写入: + +1. `taskId` +2. `createdAtMicros` +3. `startedAtMicros` +4. `completedAtMicros` + +统一由 Axum 在请求进入时生成。 + +### 6.3 字段解析 + +`api-server` 负责把 HTTP 字符串解析为领域枚举: + +1. `taskKind` +2. `stageKind` +3. `referenceKind` + +解析失败统一返回 `400`,`details.provider` 分别写: + +1. `ai-task` +2. `ai-task-stage` +3. `ai-task-reference` + +--- + +## 7. 错误映射 + +本轮 AI facade 的错误策略冻结如下: + +1. 请求 JSON 非法、路径字段非法、枚举解析失败:`400` +2. `SpacetimeClientError::Runtime(_)`:`400` +3. 其他 `SpacetimeClientError`:`502` + +`details.provider` 统一写: + +1. 路由入参准备错误:`ai-task` +2. SpacetimeDB 上游错误:`spacetimedb` + +--- + +## 8. 本轮验收口径 + +满足以下条件,视为本轮 facade 基线完成: + +1. `shared-contracts` 已新增 `ai.rs` +2. `spacetime-client` 已新增 AI facade 方法与 record 映射 +3. `api-server` 已新增 `ai_tasks.rs` +4. `/api/ai/tasks*` 路由已注册并挂 Bearer 鉴权 +5. `cargo fmt -p shared-contracts -p spacetime-client -p api-server` 通过 +6. `cargo check -p shared-contracts -p spacetime-client -p api-server` 通过 + +--- + +## 9. 下一步建议 + +本轮完成后,后续最稳的顺序是: + +1. 为 `start_ai_task / start_ai_task_stage` 增加同步 procedure +2. 增加 AI task 查询态或订阅 projection +3. 再把 `platform-llm` 流式回调真正接到 `append_ai_text_chunk / complete_ai_stage / fail_ai_task` +4. 最后再把 story / npc / custom-world / quest / runtime-item 的 AI 编排主链逐步切到这组新接口 diff --git a/docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md b/docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..9e4068a5 --- /dev/null +++ b/docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md @@ -0,0 +1,233 @@ +# `module-ai` 首版基座设计 + +日期:`2026-04-21` + +## 1. 文档目标 + +本文只冻结一件事: + +**为 `server-rs/crates/module-ai` 建立一套可以直接编码落地的首版领域模型与最小服务边界。** + +本轮不做以下内容: + +1. 不直接接入真实供应商 SDK。 +2. 不在 `SpacetimeDB` 里提前写完整 `ai_task` 表。 +3. 不提前改造 `api-server` 的 story/chat/custom world 路由。 + +本轮只解决两个问题: + +1. `module-ai` 不能再停留在“目录占位 + README 口号”状态。 +2. 后续 `api-server`、`platform-llm`、`spacetime-module` 接线时,需要先有稳定的任务、阶段、流式片段、结果引用领域模型可复用。 + +## 2. 依据 + +本文以以下现有文档和代码为准: + +1. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) +2. [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) +3. [EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md](./EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md) +4. `server-node/src/modules/ai/storyOrchestrator.ts` +5. `server-node/src/modules/ai/chatOrchestrator.ts` +6. `server-node/src/modules/ai/customWorldOrchestrator.ts` + +## 3. 现状问题 + +当前 `server-rs/crates/module-ai` 只有 README,占位描述虽然说明了“AI 编排模块”的方向,但还缺失编码级约束: + +1. 没有任务主键、阶段主键、结果引用 ID 的统一前缀。 +2. 没有 story/chat/custom world/quest/runtime-item 六类任务的共用枚举。 +3. 没有“排队中/运行中/已完成/失败/已取消”的状态模型。 +4. 没有“流式片段如何暂存与聚合”的领域对象。 +5. 没有“结果引用”与“最终文本/结构化结果”的最小抽象。 +6. 没有可供 `api-server` 直接依赖的最小内存服务。 + +如果继续在没有这层基座的前提下直接接 `platform-llm` 或 `api-server`,后续很容易再次把: + +1. 阶段枚举散落在 handler 里, +2. 流式文本拼接散落在路由里, +3. 结果引用结构散落在 story/custom-world/quest 各模块里。 + +## 4. 首版职责冻结 + +`module-ai` 首版只负责以下职责: + +1. 定义统一 AI 任务类型、任务状态、阶段状态、任务快照。 +2. 定义统一流式片段、阶段输出、结果引用、最终结果快照。 +3. 提供最小编排服务,支持: + - 创建任务 + - 启动任务 + - 记录阶段开始/完成 + - 追加流式文本片段 + - 绑定结果引用 + - 成功完成 / 失败 / 取消 +4. 提供一套内存态 store,作为 `api-server` 首轮联调和测试 fallback。 + +`module-ai` 首版明确不负责: + +1. 真实 HTTP 请求、重试、超时和供应商切换。 +2. SSE 协议写回。 +3. 数据库存储与表结构。 +4. 业务模块自己的 prompt 组装细节。 + +## 5. 任务类型范围 + +首版统一冻结以下任务类型: + +1. `StoryGeneration` +2. `CharacterChat` +3. `NpcChat` +4. `CustomWorldGeneration` +5. `QuestIntent` +6. `RuntimeItemIntent` + +说明: + +1. 这 6 类直接来自当前 Node 后端已经存在的正式运行时 AI 主链。 +2. 不提前引入媒体类资产生成任务,因为资产生成后续归 `module-assets + platform-oss` 主导。 +3. 如果后续要增加 `NarrativeRepair`、`ProfileRepair` 这类内部子任务,应作为新枚举值追加,不复用现有值的语义。 + +## 6. 阶段模型 + +首版阶段固定支持以下通用阶段语义: + +1. `PreparePrompt` +2. `RequestModel` +3. `RepairResponse` +4. `NormalizeResult` +5. `PersistResult` + +说明: + +1. 不是每种任务都必须走满 5 个阶段。 +2. 任务创建时应携带自己的阶段蓝图,按需要裁剪。 +3. 阶段蓝图是显示给上层 orchestration 的稳定数据,不把“阶段名字符串”重新散落到 handler。 + +首版建议默认蓝图: + +| 任务类型 | 默认阶段 | +| --- | --- | +| `StoryGeneration` | `PreparePrompt` -> `RequestModel` -> `RepairResponse` -> `NormalizeResult` | +| `CharacterChat` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` | +| `NpcChat` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` | +| `CustomWorldGeneration` | `PreparePrompt` -> `RequestModel` -> `RepairResponse` -> `NormalizeResult` -> `PersistResult` | +| `QuestIntent` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` | +| `RuntimeItemIntent` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` | + +## 7. 结果模型 + +首版结果拆成三层: + +### 7.1 流式片段 + +用于记录模型增量输出: + +1. `chunk_id` +2. `task_id` +3. `stage_kind` +4. `sequence` +5. `delta_text` +6. `created_at_micros` + +这层只负责“增量片段”,不直接宣称是最终结果。 + +### 7.2 阶段输出 + +用于记录阶段收口后的聚合内容: + +1. `stage_kind` +2. `text_output` +3. `structured_payload_json` +4. `warning_messages` + +### 7.3 结果引用 + +用于让 AI 编排结果和其他模块的记录形成稳定绑定: + +1. `result_ref_id` +2. `task_id` +3. `reference_kind` +4. `reference_id` +5. `label` + +首版 `reference_kind` 冻结为: + +1. `StorySession` +2. `StoryEvent` +3. `CustomWorldProfile` +4. `QuestRecord` +5. `RuntimeItemRecord` +6. `AssetObject` + +## 8. 服务边界 + +首版 `AiTaskService` 只暴露纯领域操作,不直接暴露供应商能力: + +1. `create_task` +2. `start_task` +3. `start_stage` +4. `append_text_chunk` +5. `complete_stage` +6. `attach_result_reference` +7. `complete_task` +8. `fail_task` +9. `cancel_task` +10. `get_task` + +这层返回的都是领域快照,不返回 HTTP DTO。 + +## 9. 与其他 crate 的边界 + +### 9.1 与 `platform-llm` + +`platform-llm` 负责: + +1. 真实模型请求 +2. 流式回调 +3. 超时 / 重试 / 供应商错误 + +`module-ai` 负责: + +1. 把这些外部回调映射为任务快照与阶段快照 +2. 把供应商响应组织成稳定的模块领域状态 + +### 9.2 与 `api-server` + +`api-server` 负责: + +1. HTTP 入参校验 +2. SSE 输出 +3. Bearer/Cookie 鉴权 +4. response envelope + +`module-ai` 不负责 HTTP。 + +### 9.3 与 `spacetime-module` + +后续 `spacetime-module` 负责: + +1. 任务真相表 +2. 阶段表 / 事件表 / 结果引用表 +3. reducer / procedure + +本轮 `module-ai` 只提供后续可映射到 SpacetimeDB 的稳定领域结构。 + +## 10. 首版编码要求 + +首版 crate 必须满足: + +1. 提供 `Cargo.toml` +2. 提供 `src/lib.rs` +3. 默认不依赖 `platform-llm` +4. 默认不依赖 `SpacetimeDB` +5. 可选提供 `spacetime-types` feature,便于后续映射表结构 +6. 提供完整中文注释与基础测试 + +## 11. 本轮验收口径 + +本轮完成后,以下条件同时满足才算 `module-ai` 首版落地: + +1. `server-rs/Cargo.toml` 已把 `module-ai` 纳入 workspace。 +2. `module-ai` 不再只有 README,而是有真实可编译源码。 +3. 任务/阶段/结果引用/流式片段领域模型已存在。 +4. 有最小内存服务可供后续 `api-server` 直接复用。 +5. 至少有任务创建、流式片段聚合、阶段完成、结果引用绑定、任务失败/取消等测试。 diff --git a/docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..520976b0 --- /dev/null +++ b/docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,266 @@ +# M4 module-ai SpacetimeDB 基座记录(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只记录一件事: + +**把 `module-ai` 从“只有领域模型和内存态服务”推进到“SpacetimeDB 侧已有最小 AI 任务真相表与 procedure 骨架”的真实落地结果。** + +本轮只做最小可编译基座,不扩到真实模型请求、SSE 输出或前端订阅联调。 + +--- + +## 1. 本轮落地范围 + +本轮只落实下面 5 件事: + +1. 在 `server-rs/crates/module-ai/` 中补齐面向 `SpacetimeDB` 接线的输入类型。 +2. 在 `server-rs/crates/spacetime-module/` 中新增 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 四张 private 表。 +3. 在 `spacetime-module` 中新增 AI 任务的最小 reducer / procedure。 +4. 把 `module-ai` 的领域快照与 `SpacetimeDB` 行结构之间的转换 helper 固定下来。 +5. 补充 crate README 与技术索引,明确当前 AI 真相源边界。 + +--- + +## 2. 新增的真实工程落点 + +### 2.1 `module-ai` + +1. `server-rs/crates/module-ai/src/lib.rs` + - 补充 `AiTaskStartInput` + - 补充 `AiTaskStageStartInput` + - 补充 `AiTextChunkAppendInput` + - 补充 `AiResultReferenceInput` + - 补充 `AiTaskFinishInput` + - 补充 `AiTaskCancelInput` + - 补充 `AiTaskFailureInput` + - 补充 `AI_TASK_STAGE_ID_PREFIX` + - 补充 `AiTaskStageKind::as_str()` + - 补充 `generate_ai_task_stage_id()` + +### 2.2 `spacetime-module` + +1. `server-rs/crates/spacetime-module/src/lib.rs` + - 新增 `ai_task` + - 新增 `ai_task_stage` + - 新增 `ai_text_chunk` + - 新增 `ai_result_reference` + - 新增 `create_ai_task` + - 新增 `create_ai_task_and_return` + - 新增 `start_ai_task` + - 新增 `start_ai_task_stage` + - 新增 `append_ai_text_chunk_and_return` + - 新增 `complete_ai_stage_and_return` + - 新增 `attach_ai_result_reference_and_return` + - 新增 `complete_ai_task_and_return` + - 新增 `fail_ai_task_and_return` + - 新增 `cancel_ai_task_and_return` + +--- + +## 3. 当前冻结的数据口径 + +### 3.1 `ai_task` + +当前首版字段冻结为: + +1. `task_id` +2. `task_kind` +3. `owner_user_id` +4. `request_label` +5. `source_module` +6. `source_entity_id` +7. `request_payload_json` +8. `status` +9. `failure_message` +10. `latest_text_output` +11. `latest_structured_payload_json` +12. `version` +13. `created_at` +14. `started_at` +15. `completed_at` +16. `updated_at` + +当前策略: + +1. `ai_task` 只保留任务级聚合字段,不在单行内嵌套 `Vec`。 +2. 阶段、增量文本、结果引用全部拆到独立表,避免后续更新整行大对象。 +3. `version` 继续沿用 `module-ai` 的任务快照版本语义。 + +### 3.2 `ai_task_stage` + +当前首版字段冻结为: + +1. `task_stage_id` +2. `task_id` +3. `stage_kind` +4. `label` +5. `detail` +6. `order` +7. `status` +8. `text_output` +9. `structured_payload_json` +10. `warning_messages` +11. `started_at` +12. `completed_at` + +当前策略: + +1. 一条 stage 一行。 +2. `task_stage_id` 使用 `generate_ai_task_stage_id(task_id, stage_kind)`,保持同任务内幂等。 +3. 当前不单独存“阶段版本”,统一归任务版本递增。 + +### 3.3 `ai_text_chunk` + +当前首版字段冻结为: + +1. `text_chunk_row_id` +2. `chunk_id` +3. `task_id` +4. `stage_kind` +5. `sequence` +6. `delta_text` +7. `created_at` + +当前策略: + +1. `chunk_id` 保留领域侧 ID 语义。 +2. 表级主键使用 `text_chunk_row_id`,避免 `generate_ai_text_chunk_id(seed, sequence)` 在不同任务之间碰撞。 +3. 流式文本聚合结果仍写回 `ai_task_stage.text_output` 和 `ai_task.latest_text_output`。 + +### 3.4 `ai_result_reference` + +当前首版字段冻结为: + +1. `result_reference_row_id` +2. `result_ref_id` +3. `task_id` +4. `reference_kind` +5. `reference_id` +6. `label` +7. `created_at` + +当前策略: + +1. `result_ref_id` 保留领域侧 ID 语义。 +2. 表级主键使用 `result_reference_row_id`,避免只按时间种子生成的领域 ID 在并发情况下直接作为主键带来碰撞风险。 + +--- + +## 4. 当前 reducer / procedure 口径 + +### 4.1 `create_ai_task` + +当前负责: + +1. 校验 `AiTaskCreateInput` +2. 拒绝重复 `task_id` +3. 写入 `ai_task` +4. 按蓝图写入 `ai_task_stage` + +### 4.2 `start_ai_task` + +当前负责: + +1. 校验目标任务存在 +2. 把 `ai_task.status` 从 `Pending` 推进到 `Running` +3. 填充 `started_at` + +### 4.3 `start_ai_task_stage` + +当前负责: + +1. 校验目标任务与目标阶段存在 +2. 推进任务为 `Running` +3. 推进对应 stage 为 `Running` + +### 4.4 `append_ai_text_chunk_and_return` + +当前负责: + +1. 校验任务与阶段存在 +2. 追加 `ai_text_chunk` +3. 按 `task_id + stage_kind + sequence` 聚合文本 +4. 回写 `ai_task_stage.text_output` +5. 回写 `ai_task.latest_text_output` + +### 4.5 `complete_ai_stage_and_return` + +当前负责: + +1. 更新 stage 状态、阶段输出、warning 列表 +2. 回写 `ai_task.latest_text_output` +3. 回写 `ai_task.latest_structured_payload_json` +4. 递增任务版本 + +### 4.6 `attach_ai_result_reference_and_return` + +当前负责: + +1. 追加 `ai_result_reference` +2. 更新任务 `updated_at` +3. 递增任务版本 + +### 4.7 `complete_ai_task_and_return` + +当前负责: + +1. 推进任务为 `Completed` +2. 填充 `completed_at` + +### 4.8 `fail_ai_task_and_return` + +当前负责: + +1. 推进任务为 `Failed` +2. 写入 `failure_message` +3. 填充 `completed_at` + +### 4.9 `cancel_ai_task_and_return` + +当前负责: + +1. 推进任务为 `Cancelled` +2. 填充 `completed_at` + +--- + +## 5. 当前刻意未做 + +本轮明确没有扩到以下范围: + +1. 还没有做 AI 任务公开订阅表。 +2. 还没有做 `api-server` 的 AI facade 路由。 +3. 还没有做 `platform-llm` 真实流式回调接线。 +4. 还没有做 story / custom-world / quest / runtime-item 对 AI 任务的自动建链。 +5. 还没有做清理旧任务、旧 chunk 的 schedule reducer。 + +也就是说,本轮只是把 AI 任务真相表和最小写入口立起来,不宣称已经完成 AI runtime 主链迁移。 + +--- + +## 6. 当前边界判断 + +当前仍保持以下职责划分: + +1. `module-ai` + - 负责领域模型、校验、快照结构与最小内存服务。 +2. `spacetime-module` + - 负责任务真相表、事务性持久化与 procedure 聚合返回。 +3. `platform-llm` + - 后续负责真实模型调用、超时、重试、供应商错误。 +4. `api-server` + - 后续负责 HTTP / SSE / 鉴权与外部 contract。 + +--- + +## 7. 下一步建议 + +按当前节奏,后续应继续按下面顺序推进: + +1. 先把 `platform-llm` 的文本网关正式接到 `append_ai_text_chunk_and_return / complete_ai_stage_and_return`。 +2. 再给 `api-server` 增加 AI 任务 facade,把 HTTP/SSE 对外 contract 冻结下来。 +3. 再把 story、custom-world、quest、runtime-item 各自的 AI 编排入口切到 `module-ai + spacetime-module`。 +4. 最后再根据订阅需求评估是否补 public projection 表或事件表。 diff --git a/docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md b/docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..277129c9 --- /dev/null +++ b/docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md @@ -0,0 +1,251 @@ +# M4 module-combat Axum facade 设计(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只冻结一件事: + +**把已经完成 reducer 化的 `module-combat` 再向上接一层最小同步返回链,让 `api-server` 可以显式创建战斗、推进单次战斗动作,并立即拿到 battle 快照结果。** + +这份文档不是完整 `runtime story actions/resolve` 兼容方案,也不替代后续的 `resolve_story_action` 编排设计。 + +--- + +## 1. 本轮要解决的问题 + +当前 `module-combat` 已具备: + +1. `battle_state` 真相表 +2. `create_battle_state` reducer +3. `resolve_combat_action` reducer +4. `fight / spar` 两种模式下的纯规则推进 + +但当前仍缺一层明确能力: + +1. Axum 还不能同步拿到 battle 快照 +2. `spacetime-client` 还没有 battle procedure 调用封装 +3. `api-server` 还没有独立的战斗 facade + +因此本轮只补下面三层: + +1. `spacetime-module` battle procedure +2. `spacetime-client` battle procedure 调用与返回值映射 +3. `api-server` 最小战斗 HTTP facade + +--- + +## 2. 当前明确不做的事 + +本轮刻意不做: + +1. 不兼容旧 `POST /api/runtime/story/actions/resolve` +2. 不兼容旧 `GET /api/runtime/story/state/:sessionId` +3. 不把 `inventory_use` 提前接回战斗主链 +4. 不把 `quest / progression / npc / story_event` 自动联动写回 +5. 不把 battle 直接拼进 `RuntimeStoryActionResponse` + +原因很直接: + +1. 这些属于更高层的 runtime story 编排问题 +2. 当前 battle 子域应该先把“独立可调用、同步可返回”这一层固定下来 +3. 先补 procedure + facade,后续 `resolve_story_action` 才有稳定下游可调入口 + +--- + +## 3. `spacetime-module` 的新增口径 + +### 3.1 reducer 继续保留 + +已有 reducer 继续保留: + +1. `create_battle_state` +2. `resolve_combat_action` + +职责不变: + +1. reducer 仍然只负责 battle 真相写入 +2. reducer 不直接向调用方返回业务快照 + +### 3.2 新增 procedure + +本轮新增两个 procedure: + +1. `create_battle_state_and_return` +2. `resolve_combat_action_and_return` + +职责冻结如下: + +1. procedure 只包一层 `try_with_tx` +2. procedure 内部复用 reducer 共享的写入 helper +3. procedure 负责把最终 `battle_state` 或 `resolve result` 同步返回给 Axum + +### 3.3 返回类型 + +本轮冻结两种返回 DTO: + +1. `BattleStateProcedureResult` +2. `ResolveCombatActionProcedureResult` + +字段口径统一为: + +1. `ok` +2. `snapshot` 或 `result` +3. `error_message` + +这样能与现有 `story / treasure / npc` procedure 返回风格保持一致。 + +--- + +## 4. `spacetime-client` 的新增口径 + +`spacetime-client` 本轮新增两条最小调用链: + +1. `create_battle_state` +2. `resolve_combat_action` + +调用策略继续沿用当前已验证模式: + +1. 先建立 `DbConnection` +2. 等待 `on_connect` +3. 再调用对应 procedure +4. 统一经 `oneshot + timeout` 收口结果 + +当前不做: + +1. battle 订阅 +2. battle cache 读模型 +3. battle 长连接复用策略 + +--- + +## 5. `api-server` 的新增 facade 口径 + +### 5.1 路由 + +本轮新增两条最小路由: + +1. `POST /api/story/battles` +2. `POST /api/story/battles/resolve` + +这两条路由的定位不是旧 runtime 兼容层,而是: + +1. 面向新 Rust 后端内部联调 +2. 面向后续 `resolve_story_action` 编排层调用 + +### 5.2 `POST /api/story/battles` + +请求体只提交 battle 建立所需的业务字段: + +1. `storySessionId` +2. `runtimeSessionId` +3. `targetNpcId` +4. `targetName` +5. `battleMode` +6. `playerHp` +7. `playerMaxHp` +8. `playerMana` +9. `playerMaxMana` +10. `targetHp` +11. `targetMaxHp` + +由 Axum 自动补齐: + +1. `battleStateId` +2. `actorUserId` +3. `createdAtMicros` + +响应返回: + +1. `battleState` + +### 5.3 `POST /api/story/battles/resolve` + +请求体只提交单次动作推进所需字段: + +1. `battleStateId` +2. `functionId` +3. `actionText` +4. `baseDamage` +5. `manaCost` +6. `heal` +7. `manaRestore` +8. `counterMultiplierBasisPoints` + +由 Axum 自动补齐: + +1. `updatedAtMicros` + +响应返回: + +1. `battleState` +2. `combat` + +其中 `combat` 至少包含: + +1. `damageDealt` +2. `damageTaken` +3. `outcome` + +--- + +## 6. 认证与字段真相边界 + +### 6.1 `actorUserId` + +`actorUserId` 不接受前端自填。 + +必须由: + +1. `AuthenticatedAccessToken` +2. `claims.user_id` + +直接生成。 + +### 6.2 时间字段 + +`createdAtMicros` 与 `updatedAtMicros` 不接受外部写入。 + +必须由 Axum 在请求时生成,原因如下: + +1. 避免客户端伪造 battle 创建时间 +2. 保持 Rust 后端各 facade 的时间字段风格一致 +3. 让后续 battle / story / npc 联调时便于统一日志与排障 + +--- + +## 7. 错误映射口径 + +当前 battle facade 的错误映射冻结如下: + +1. battle mode 非法、请求 JSON 非法、字段校验失败:`400` +2. `SpacetimeClientError::Runtime(_)`:`400` +3. 其他 `SpacetimeClientError`:`502` + +返回 `details.provider` 统一写: + +1. battle 输入准备错误:`story-battle` +2. SpacetimeDB 上游错误:`spacetimedb` + +--- + +## 8. 本轮验收 + +满足以下条件,视为本轮 facade 基线完成: + +1. `module-combat` 已新增 procedure 返回 DTO +2. `spacetime-module` 已新增 `create_battle_state_and_return` +3. `spacetime-module` 已新增 `resolve_combat_action_and_return` +4. `spacetime-client` 已可同步创建战斗并推进单次动作 +5. `api-server` 已新增两条最小 battle facade 路由 +6. `cargo check -p module-combat -p spacetime-client -p api-server -p spacetime-module` 通过 + +--- + +## 9. 下一步建议 + +本轮完成后,后续最稳的顺序是: + +1. 把 battle facade 接入 `resolve_story_action` +2. 设计 battle 结束后的 `story_event` 追加口径 +3. 再把 `quest / progression / inventory` 的联动收回到显式子域流程里 diff --git a/docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..e6b6f611 --- /dev/null +++ b/docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,336 @@ +# M4 module-combat SpacetimeDB 基线设计(2026-04-21) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结一件事: + +**把 `module-combat` 从“只有 README 占位”推进到“首版 battle_state 与 resolve_combat_action 可真实编码、可编译、可继续扩展”的工程基线。** + +本轮不宣称完成完整 `runtime story action` 迁移,也不把 `inventory / npc / story AI 续写` 直接耦进战斗 reducer;跨子域写入继续收敛在 `spacetime-module` 聚合层。 + +--- + +## 1. 本轮落地范围 + +本轮只做下面 5 件事: + +1. 新增 `server-rs/crates/module-combat/` 真实 crate。 +2. 冻结 `battle_state` 的首版领域类型、枚举、输入结构与字段校验 helper。 +3. 冻结 `resolve_combat_action` 的首版输入、输出与纯规则推进逻辑。 +4. 在 `server-rs/crates/spacetime-module/` 中新增 `battle_state` 表。 +5. 在 `spacetime-module` 中新增 `create_battle_state`、`resolve_combat_action` 两个 reducer。 + +--- + +## 2. 当前冻结的实现边界 + +### 2.1 首版必须支持的战斗 function + +首版与 [../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md) 保持一致,只支持以下单行为入口: + +1. `battle_attack_basic` +2. `battle_recover_breath` +3. `battle_use_skill` +4. `battle_escape_breakout` +5. 旧兼容攻击类: + - `battle_all_in_crush` + - `battle_guard_break` + - `battle_probe_pressure` + - `battle_feint_step` + - `battle_finisher_window` + +本轮刻意不接入: + +1. `inventory_use` +2. 技能与物品的正式外部明细读取 +3. 与 `quest_record`、`npc_state` 的联动写入 +4. 脱战后 `story_event` 追加与 AI 续写触发 + +### 2.2 为什么先不做 `inventory_use` + +当前 Rust 侧还没有 `inventory_slot` 正式表,也没有稳定的战斗内物品快照输入。 + +如果现在把 `inventory_use` 硬塞进 `module-combat`,只会出现两种坏结果: + +1. reducer 内部引入并不存在的 inventory 真相依赖; +2. 退回成“让 Axum 先算完再写 battle_state”的伪迁移。 + +因此本轮明确冻结为: + +1. `module-combat` 先完成纯战斗状态推进; +2. `inventory_use` 留到 `inventory_slot` 与 runtime snapshot projection 口径稳定后再接。 + +--- + +## 3. `battle_state` 首版字段 + +首版 `battle_state` 冻结为以下字段: + +1. `battle_state_id` +2. `story_session_id` +3. `runtime_session_id` +4. `actor_user_id` +5. `target_npc_id` +6. `target_name` +7. `battle_mode` +8. `status` +9. `player_hp` +10. `player_max_hp` +11. `player_mana` +12. `player_max_mana` +13. `target_hp` +14. `target_max_hp` +15. `chapter_id` +16. `experience_reward` +17. `reward_items` +18. `turn_index` +19. `last_action_function_id` +20. `last_action_text` +21. `last_result_text` +22. `last_damage_dealt` +23. `last_damage_taken` +24. `last_outcome` +25. `version` +26. `created_at` +27. `updated_at` + +### 3.1 设计意图 + +首版只解决下面这些真相问题: + +1. 当前战斗是否存在、是否仍在进行中; +2. 玩家与当前目标的 HP / MP 最小数值状态; +3. 当前是 `fight` 还是 `spar`; +4. 当前战斗归属哪个章节; +5. 本场战斗若胜利应发多少经验; +6. 本场战斗若胜利应发哪些已编译好的 reward item; +7. 最近一次动作结算了什么; +8. 当前 battle reducer 是否发生过版本推进。 + +### 3.2 当前刻意不放入的字段 + +本轮明确不放: + +1. 多目标列表 +2. 技能冷却 map +3. build buff 详情 +4. 掉落预算、好感预算、剧情上下文大对象 +5. 大型 `rawGameState` 镜像字段 + +原因很直接:这些都属于后续跨子域联动层,不适合在 `battle_state` 首版里重新堆一个大 JSON。 + +--- + +## 4. 枚举与动作口径 + +### 4.1 `BattleMode` + +只保留两种: + +1. `Fight` +2. `Spar` + +### 4.2 `BattleStatus` + +只保留三种: + +1. `Ongoing` +2. `Resolved` +3. `Aborted` + +说明: + +1. `Resolved` 表示战斗已正常收束,包括胜利、切磋结束、成功逃脱。 +2. `Aborted` 预留给后续 session 中断、外部清理、投影回滚等异常收束场景。 + +### 4.3 `CombatOutcome` + +首版冻结: + +1. `Ongoing` +2. `Victory` +3. `SparComplete` +4. `Escaped` + +这与当前共享契约里的 `RuntimeBattlePresentation.outcome` 一致,避免首版就制造新的枚举翻译成本。 + +--- + +## 5. `resolve_combat_action` 首版规则 + +### 5.1 输入 + +首版 reducer 输入只包含: + +1. `battle_state_id` +2. `function_id` +3. `action_text` +4. `base_damage` +5. `mana_cost` +6. `heal` +7. `mana_restore` +8. `counter_multiplier` +9. `updated_at_micros` + +### 5.2 为什么允许输入 `base_damage` + +本轮 `module-combat` 的职责是把战斗推进规则固定到 SpacetimeDB。 + +但玩家技能、装备 build、物品 buff、成长曲线这些正式真相仍未迁完,因此首版允许上游把已算好的 `base_damage / mana_cost / heal / mana_restore` 作为确定输入传进 reducer。 + +这意味着当前模块边界是: + +1. `module-combat` 负责状态推进、反击、逃跑、战斗收束规则; +2. 更高层的 build / skill / item 数值来源仍可在后续模块中逐步收敛; +3. 等 `inventory / progression / runtime build` 真相表稳定后,再继续把这些输入收得更窄。 + +### 5.3 动作规则 + +#### A. `battle_escape_breakout` + +直接结束战斗: + +1. `status = Resolved` +2. `last_outcome = Escaped` +3. `last_damage_dealt = 0` +4. `last_damage_taken = 0` + +#### B. `battle_recover_breath` + +恢复类动作: + +1. 玩家回复 `heal` +2. 玩家回复 `mana_restore` +3. 若战斗仍持续,则按 `counter_multiplier` 吃一次敌方反击 + +#### C. `battle_attack_basic` / 旧兼容攻击类 / `battle_use_skill` + +攻击类动作: + +1. 目标扣除 `base_damage` +2. 若目标已收束,则按 `battle_mode` 进入 `Victory / SparComplete` +3. 若目标未收束,则玩家按 `counter_multiplier` 吃一次敌方反击 + +### 5.4 反击规则 + +首版固定: + +1. `fight` 下敌方基础反击伤害 = `max(4, round(target_max_hp * 0.14 * counter_multiplier))` +2. `spar` 下敌方基础反击伤害固定为 `1` + +这是对当前 Node 逻辑的直接收敛,先保证行为方向不漂移,不在本轮发明新的战斗公式。 + +### 5.5 HP 下限规则 + +1. `fight` 下正常下限为 `0` +2. `spar` 下双方 HP 最低保留为 `1` + +这样能保留当前“切磋点到为止”的旧行为,不把 `spar` 错结算成死亡战斗。 + +--- + +## 6. `spacetime-module` 接线口径 + +### 6.1 battle_state 表 + +`spacetime-module` 首版只新增一张 private 真相表: + +1. `battle_state` + +建议索引: + +1. `by_story_session_id` +2. `by_runtime_session_id` +3. `by_actor_user_id` + +### 6.2 reducer + +当前仍只保留两个战斗 reducer: + +1. `create_battle_state` +2. `resolve_combat_action` + +职责: + +1. `create_battle_state` 只负责插入 battle 真相,不负责故事会话编排。 +2. `resolve_combat_action` 负责推进 battle 真相。 +3. 当 `Victory` 收束时,由 `spacetime-module` 聚合层继续把 `experience_reward` 联动写入 `player_progression / chapter_progression`。 +4. 当 `Victory` 收束且 `reward_items` 非空时,由 `spacetime-module` 聚合层继续把战利品写入 `inventory_slot`。 +5. `resolve_combat_action` 仍不负责 AI 续写和 quest signal 全量分发。 + +--- + +## 7. 与后续子域的边界 + +### 7.1 与 `story` + +当前关系: + +1. `story` 负责更高层 action 路由与后续 story_event 追加; +2. `combat` 只返回 battle 真相推进结果。 + +后续再补: + +1. 战斗结束时的 `story_event` +2. 脱战后的 `continue_story` / `resolve_story_action` + +### 7.2 与 `inventory` + +当前不直接耦合到 `module-combat` reducer。 + +后续再补: + +1. 战斗内 `inventory_use` +2. 消耗品扣减 +3. 战斗 buff 写入 + +当前已存在的聚合层联动: + +1. `Victory` 时可把 `battle_state.reward_items` 写入 `inventory_slot` + +### 7.3 与 `progression` + +当前不直接在 `module-combat` reducer 内发经验与等级变更。 + +后续再补: + +1. hostile scaling 与 reward 编译口径 + +当前已存在的聚合层联动: + +1. `fight_victory` 的经验发放 +2. 章节账本写入 + +### 7.4 与 `npc` + +当前不直接改好感。 + +后续再补: + +1. `spar_complete` 的 affinity 变化 +2. `fight / spar` 与 encounter 状态同步 + +--- + +## 8. 本轮验收口径 + +满足以下条件,视为本轮 `module-combat` 基线完成: + +1. `server-rs/crates/module-combat` 已从 README 占位升级为真实 crate。 +2. `battle_state`、`BattleMode`、`BattleStatus`、`CombatOutcome`、`ResolveCombatActionInput` 已冻结到代码。 +3. `spacetime-module` 已新增 `battle_state` 表。 +4. `spacetime-module` 已新增 `create_battle_state` 与 `resolve_combat_action` reducer。 +5. `cargo check -p module-combat -p spacetime-module` 通过。 + +--- + +## 9. 下一步建议 + +在本轮基线稳定后,下一步按以下顺序推进最稳: + +1. 设计 `inventory_slot` 与战斗内 `inventory_use` 的最小真相输入。 +2. 设计 `resolve_story_action` 如何编排 `story + combat + npc + quest + inventory`。 +3. 把 `battle_state` 结束事件接入 `story_event`。 +4. 再把 Axum facade 与 `RuntimeStoryActionResponse.battle` 真正打通。 diff --git a/docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md b/docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md new file mode 100644 index 00000000..6281242c --- /dev/null +++ b/docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md @@ -0,0 +1,202 @@ +# M4 module-combat battle state 查询设计(2026-04-22) + +更新时间:`2026-04-22` + +补充状态:`2026-04-22` + +当前 battle query 纵切片已经完成到“真实可编译、可生成 binding、可被 Axum 调用”的状态: + +1. `spacetime-module` 中的 `get_battle_state` procedure 已稳定存在。 +2. `spacetime-client/src/module_bindings` 已重新执行 `spacetime generate`,当前已真实包含: + - `battle_state_query_input_type` + - `get_battle_state_procedure` + - `battle_state.reward_items` 对应字段 +3. `spacetime-client/src/lib.rs` 里原本返回“binding 尚未生成”的占位 `get_battle_state(...)` 已替换为真实 procedure 调用。 +4. `cargo check -p spacetime-client` 与 `cargo check -p api-server` 已再次通过。 + +当前仍未完成的只有长时回归验证: + +1. `cargo test -p api-server --bin api-server story_battles --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 在当前机器上编译耗时较长,尚未在单次时窗内拿到最终断言结果。 +2. `npm run check:encoding` 已启动但尚未在单次时窗内跑完。 + +## 0. 文档目标 + +本文件只冻结当前 `M4` 的一个最小新增切片: + +**新增 `GET /api/story/battles/:battleStateId`,让 Axum 能从 `SpacetimeDB` 同步读取单个 `battle_state` 当前快照,不提前承诺旧 runtime story state 兼容。** + +这轮目标不是实现旧 `GET /api/runtime/story/state/:sessionId` 的战斗子视图兼容,也不是把 `battle + story_event + currentStory` 一次性收口进 `resolve_story_action`。 + +--- + +## 1. 为什么先补这个切片 + +当前 battle 链路已经具备: + +1. `module-combat` 已冻结 `battle_state` 领域类型与纯结算规则。 +2. `spacetime-module` 已有 `create_battle_state_and_return`、`resolve_combat_action_and_return`。 +3. `spacetime-client` 与 `api-server` 已能创建战斗并推进单次动作。 + +但现在仍缺一个最基本的恢复能力: + +1. battle 建立后,Axum 还不能按 `battle_state_id` 重新读取真相态。 +2. 页面刷新、重连或后续 story 编排都缺一个稳定的单战斗查询入口。 +3. 后续若要把 battle 收口进 `resolve_story_action`,也需要先有独立 battle query 可复用。 + +因此本轮先补最小 `battle state` 查询切片,不提前跳到更重的 runtime story 兼容。 + +--- + +## 2. 当前冻结范围 + +本轮只包含以下能力: + +1. 新增公开接口:`GET /api/story/battles/:battleStateId` +2. 认证方式:Bearer JWT +3. 数据来源:`SpacetimeDB procedure get_battle_state` +4. 返回体只包含: + - `battleState` + +本轮明确不做: + +1. 不兼容旧 `GET /api/runtime/story/state/:sessionId` +2. 不补 battle 列表查询 +3. 不做 `battle_state` 订阅与 cache 读模型 +4. 不在查询链路里拼装 `story_event / npc / quest / inventory` +5. 不把 battle query 直接拼回旧 `RuntimeStoryActionResponse` + +--- + +## 3. 接口 contract + +### 3.1 请求 + +- 方法:`GET` +- 路径:`/api/story/battles/:battleStateId` +- 认证:必须携带 Bearer JWT +- 路径参数: + - `battleStateId`:目标战斗状态 ID + +### 3.2 成功响应 + +成功响应延续当前 `api-server` 统一 envelope,`data` 字段结构为: + +```json +{ + "battleState": { + "battleStateId": "battle_xxx", + "storySessionId": "storysess_xxx", + "runtimeSessionId": "runtime_xxx", + "actorUserId": "user_xxx", + "chapterId": "chapter_xxx", + "targetNpcId": "npc_xxx", + "targetName": "黑爪狼", + "battleMode": "fight", + "status": "ongoing", + "playerHp": 42, + "playerMaxHp": 60, + "playerMana": 12, + "playerMaxMana": 20, + "targetHp": 18, + "targetMaxHp": 30, + "experienceReward": 18, + "rewardItems": [], + "turnIndex": 1, + "lastActionFunctionId": "battle_attack_basic", + "lastActionText": "普通攻击", + "lastResultText": "普通攻击命中了黑爪狼,本次攻击已经完成结算。", + "lastDamageDealt": 12, + "lastDamageTaken": 4, + "lastOutcome": "ongoing", + "version": 2, + "createdAt": "2026-04-22T00:00:00.000000Z", + "updatedAt": "2026-04-22T00:01:00.000000Z" + } +} +``` + +### 3.3 错误响应 + +当前延续 battle facade 已有策略: + +1. `SpacetimeClientError::Runtime(_)` 映射为 `400` +2. 其他 `SpacetimeClientError` 映射为 `502` +3. 错误 `details.provider` 固定为 `spacetimedb` + +--- + +## 4. 分层职责 + +### 4.1 `module-combat` + +职责: + +1. 冻结 `BattleStateQueryInput` +2. 负责 query input builder 与 validator +3. 继续复用 `BattleStateProcedureResult` 作为最小查询返回壳 + +不负责: + +1. HTTP 路径解析 +2. JWT 鉴权 +3. battle view model 编译 + +### 4.2 `spacetime-module` + +职责: + +1. 读取 `battle_state` +2. 校验 `battle_state_id` +3. 返回单个 `BattleStateSnapshot` + +### 4.3 `spacetime-client` + +职责: + +1. 构造 `BattleStateQueryInput` +2. 调用 `get_battle_state` +3. 把 generated binding 结果映射为 `BattleStateRecord` + +当前实现补充: + +1. `reward_items` 已按 generated binding 映射回 `BattleStateRecord.reward_items`,不再用空集合占位。 +2. battle query 当前不再依赖 façade stub 或手写假返回。 + +### 4.4 `api-server` + +职责: + +1. 暴露 `GET /api/story/battles/:battleStateId` +2. 做 Bearer JWT 鉴权 +3. 透传 `battleStateId` +4. 把 `BattleStateRecord` 映射到 battle JSON payload + +--- + +## 5. 验收口径 + +本轮验收只要求以下几点: + +1. `api-server` 路由树已真实挂出该接口 +2. 未登录访问返回 `401` +3. 在 `SpacetimeDB` 未发布或未连通时返回 `502` +4. `cargo test -p api-server story_battles` 可通过 +5. `cargo check -p module-combat -p spacetime-module -p spacetime-client -p api-server` 可通过 +6. `npm run check:encoding` 已执行,确保新增中文文档没有编码损坏 + +当前验证状态: + +1. 第 5 条已达成。 +2. 第 4、6 条仍在继续追,不应提前宣称通过。 + +--- + +## 6. 后续边界 + +这条最小 battle query 落地后,后续再继续拆下一层: + +1. 评估 battle 查询是否需要补 actor ownership 校验 +2. 设计 battle 结束事件如何接入 `story_event` +3. 再把 battle query 与 `story state / resolve_story_action / currentStory` 汇总到更高层编排 + +在这些 contract 未冻结前,不应把当前接口误称为“旧 runtime story state 已迁移完成”。 diff --git a/docs/technical/M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md b/docs/technical/M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md new file mode 100644 index 00000000..23a3c0b3 --- /dev/null +++ b/docs/technical/M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md @@ -0,0 +1,188 @@ +# M4 module-npc battle Axum facade 设计(2026-04-22) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结一件事: + +**把已经在 `spacetime-module` 落地的 `resolve_npc_battle_interaction_and_return` procedure 再向上接到 `spacetime-client` 与 `api-server`,并允许 HTTP 侧透传已编译好的 `experience_reward / reward_items`,形成可直接调用的 NPC 开战同步返回链。** + +这不是完整 `resolve_story_action` 兼容设计,也不替代后续 runtime story 总入口编排。 + +--- + +## 1. 本轮要解决的问题 + +当前仓库已经具备: + +1. `module-npc` + - `resolve_npc_interaction` + - `npc_fight / npc_spar -> BattlePending` +2. `module-combat` + - `battle_state` + - `resolve_combat_action` +3. `spacetime-module` + - `resolve_npc_battle_interaction_and_return` + - 同事务写入 `npc_state + battle_state` + +但当前仍缺两层: + +1. `spacetime-client` 还没有对应 facade +2. `api-server` 还没有独立 NPC 开战 HTTP 入口 + +因此本轮只补下面两层: + +1. `spacetime-client` facade +2. `api-server` HTTP route + +--- + +## 2. 当前刻意不做的事 + +本轮明确不做: + +1. 不兼容旧 `POST /api/runtime/story/actions/resolve` +2. 不把 `npc_chat / npc_help / npc_recruit / npc_leave` 一起搬成统一 HTTP facade +3. 不在接口层现场计算章节自动定级、经验奖励、掉落、story_event 自动联动 +4. 不把 battle 结果直接拼进旧 `RuntimeStoryActionResponse` + +原因很直接: + +1. 这轮目标只是把 `npc_fight / npc_spar` 的同步返回链闭环 +2. 更高层 story action 编排仍应等待 `resolve_story_action` 统一设计 + +--- + +## 3. `spacetime-client` 口径 + +### 3.1 新增 facade + +本轮新增: + +1. `resolve_npc_battle_interaction` + +### 3.2 输入 + +直接复用 `spacetime-module` procedure 输入: + +1. `ResolveNpcBattleInteractionInput` + +客户端 facade 负责: + +1. 调 `resolve_npc_battle_interaction_and_return` +2. 把 binding 结果映射成 Rust record +3. 统一沿用现有 `oneshot + timeout` 返回模式 + +### 3.3 输出 + +本轮冻结新的 client record: + +1. `NpcStateRecord` +2. `NpcInteractionRecord` +3. `NpcBattleInteractionRecord` + +这样可以避免 `api-server` 直接依赖 generated binding 结构。 + +--- + +## 4. `api-server` 口径 + +### 4.1 路由 + +本轮新增: + +1. `POST /api/story/npc/battle` + +这条路由的定位是: + +1. 独立的 NPC 开战 facade +2. 明确只处理 `npc_fight / npc_spar` +3. 返回: + - `npcInteraction` + - `battleState` + +### 4.2 输入 + +首版 HTTP 请求字段冻结为: + +1. `storySessionId` +2. `runtimeSessionId` +3. `npcId` +4. `npcName` +5. `interactionFunctionId` + - 当前只允许: + - `npc_fight` + - `npc_spar` +6. `releaseNpcId` +7. `battleStateId` +8. `playerHp` +9. `playerMaxHp` +10. `playerMana` +11. `playerMaxMana` +12. `targetHp` +13. `targetMaxHp` +14. `experienceReward` + - 默认 `0` + - 只接受上游已编译好的确定值 +15. `rewardItems` + - 默认空数组 + - 每项字段与 `RuntimeItemRewardItemSnapshot` 对齐 + - `rarity` 固定使用: + - `common` + - `uncommon` + - `rare` + - `epic` + - `legendary` + - `equipmentSlotId` 当前只允许: + - `weapon` + - `armor` + - `relic` + +### 4.3 返回 + +当前 HTTP 成功响应冻结为: + +1. `npcInteraction` +2. `battleState` + +其中: + +1. `npcInteraction` 保留: + - `npcState` + - `interactionStatus` + - `actionText` + - `resultText` + - `storyText` + - `battleMode` + - `encounterClosed` + - `affinityChanged` + - `previousAffinity` + - `nextAffinity` +2. `battleState` 继续复用 battle facade 已有 payload 结构 + +--- + +## 5. 错误策略 + +与现有 `story_battles` / `story_sessions` 保持一致: + +1. `SpacetimeClientError::Runtime` + - 映射 `400` +2. 其他 Spacetime 调用错误 + - 映射 `502` + +错误 body 继续统一返回: + +1. `provider` +2. `message` + +--- + +## 6. 后续建议 + +在这条 facade 稳定后,下一步按下面顺序推进: + +1. 让前端 runtime story action 先走这条独立 NPC 开战入口 +2. 再把 battle 初始化所需的 NPC 等级、经验奖励、reward item 编译来源、章节信息收口进更高层编排 +3. 最后再统一进完整 `resolve_story_action` diff --git a/docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md b/docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md new file mode 100644 index 00000000..21d8dbca --- /dev/null +++ b/docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md @@ -0,0 +1,151 @@ +# M4 module-npc 与 module-combat 联合编排基线(2026-04-21) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结一件事: + +**在不污染 `module-npc` 纯领域边界的前提下,把 `npc_fight / npc_spar` 从“只返回 `BattlePending` 语义”推进到“可在 `spacetime-module` 聚合层同步创建 `battle_state`”的最小联合编排口径。** + +这不是完整 `resolve_story_action` 设计,也不是完整战斗奖励编译、经验预算和剧情续写迁移。 + +--- + +## 1. 本轮落地范围 + +本轮只落实下面 4 件事: + +1. 明确 `module-npc` 继续只负责 NPC 交互语义,不直接依赖 `module-combat`。 +2. 在 `spacetime-module` 聚合层新增 `resolve_npc_battle_interaction_and_return` procedure。 +3. 让该 procedure 在同一事务内完成: + - `resolve_npc_interaction` + - `battle_state` 初始化写入 +4. 返回统一结果,供后续 `spacetime-client` / Axum facade 直接消费。 + +--- + +## 2. 为什么不把 battle 初始化塞进 module-npc + +原因很直接: + +1. `module-npc` 当前职责是 `npc_state / relation_state / stance_profile / interaction contract`。 +2. `battle_state` 属于 `module-combat` 真相,不应倒灌进 NPC 领域 crate。 +3. 如果把玩家 HP / MP、战斗生命、故事会话 ID 这些字段直接塞进 `ResolveNpcInteractionInput`,会把 `module-npc` 再次膨胀成跨子域入口。 + +因此本轮明确冻结为: + +1. `module-npc` + - 继续只返回 `BattlePending + battle_mode` +2. `spacetime-module` + - 负责把 NPC 交互结果编排成真正的 `battle_state` + +--- + +## 3. 新增 procedure 口径 + +### 3.1 名称 + +新增: + +1. `resolve_npc_battle_interaction_and_return` + +### 3.2 输入 + +首版输入冻结为: + +1. `npc_interaction` + - 原样复用 `ResolveNpcInteractionInput` + - 当前只允许 `npc_fight / npc_spar` +2. `story_session_id` +3. `actor_user_id` +4. `battle_state_id` + - 允许为空 + - 为空时按 `updated_at_micros` 自动派生 +5. `player_hp` +6. `player_max_hp` +7. `player_mana` +8. `player_max_mana` +9. `target_hp` +10. `target_max_hp` +11. `experience_reward` + - 由上游作为已编译好的确定奖励透传 + - 当前允许为 `0` +12. `reward_items` + - 类型固定为 `Vec` + - 只承接已经编译好的战利品快照,不在 procedure 内现场生成 + +### 3.3 输出 + +当前返回: + +1. `interaction` + - `module-npc::NpcInteractionResult` +2. `battle_state` + - `module-combat::BattleStateSnapshot` + +也就是说,这个 procedure 明确是一个**聚合返回口径**,不是新的底层领域真相。 + +--- + +## 4. 当前事务流程 + +单次调用按下面顺序执行: + +1. 校验 `story_session_id / actor_user_id` +2. 校验 `interaction_function_id` 必须是: + - `npc_fight` + - `npc_spar` +3. 先执行 `resolve_npc_interaction_record` + - 写入最新 `npc_state` + - 拿到 `NpcInteractionResult` +4. 从 `NpcInteractionResult.battle_mode` 映射出 `BattleMode` +5. 组装 `BattleStateInput` + - 透传 `experience_reward` + - 透传 `reward_items` +6. 复用 `module-combat` 的 `validate_battle_state_input` +7. 插入 `battle_state` +8. 返回: + - `interaction` + - `battle_state` + +--- + +## 5. 当前刻意未做 + +本轮明确不做下面这些扩张: + +1. 不在这个 procedure 里直接发经验 +2. 不在这个 procedure 里直接记 `chapter_progression` +3. 不在这个 procedure 里直接写 `story_event` +4. 不在这个 procedure 里现场计算掉落或经验预算 +5. 不在这个 procedure 里直接执行 `inventory_slot` 发物 +5. 不在这个 procedure 里直接接 `resolve_combat_action` +6. 不在这个 procedure 里推导敌方等级、强度、掉落预算 + +也就是说,这一层当前只解决: + +**NPC 宣告开战后,如何立刻把 battle 真相表连同已编译奖励真相一起建立起来。** + +--- + +## 6. 与现有文档的关系 + +本文件是对下面两份基线文档的补充,而不是替代: + +1. `M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md` + - 继续定义 NPC 领域 contract +2. `M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md` + - 继续定义 battle_state 与单行为战斗推进规则 + +新增编排只发生在 `spacetime-module` 聚合层。 + +--- + +## 7. 下一步建议 + +在这条最小联合编排稳定后,后续按下面顺序推进最稳: + +1. 把 Node 侧 `monster_drop` / hostile reward 编译逻辑收口到 Rust 聚合层。 +2. 再把章节自动定级、敌对经验预算和 `chapter_progression` 所需章节上下文收口进 battle 初始化编译器。 +3. 最后把这条链收口进完整 `resolve_story_action`。 diff --git a/docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..07f2e124 --- /dev/null +++ b/docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,273 @@ +# M4 module-npc SpacetimeDB 基座记录(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只记录一件事: + +**把 `module-npc` 从“只有占位 README”推进到“已有可编译 Rust 领域 contract,并接入 `SpacetimeDB` 最小 `npc_state` 真相表与社交动作 reducer/procedure”的真实落地结果。** + +本轮只做最小基座,不扩到完整 `npc_trade / npc_gift / npc_recruit` 的全链结算迁移,也不改前端 UI。 + +--- + +## 1. 本轮落地范围 + +本轮只落实下面 6 件事: + +1. 新增 `server-rs/crates/module-npc/` 真实 crate,而不是继续停留在目录占位。 +2. 在 `module-npc` 中冻结 `relation_state / stance_profile / npc_state` 的首版领域类型与校验 helper。 +3. 在 `module-npc` 中补齐 `build_initial_stance_profile`、`normalize_npc_state_snapshot`、`apply_npc_social_action` 等最小规则原语。 +4. 在 `server-rs/crates/spacetime-module/` 中新增 `npc_state` 表。 +5. 在 `spacetime-module` 中新增 `upsert_npc_state`、`resolve_npc_social_action` 及对应 procedure,形成最小可编译写入口。 +6. 在 `module-npc` 中新增 `resolve_npc_interaction` 的首版领域 contract,并在 `spacetime-module` 中补对应 reducer / procedure。 + +--- + +## 2. 本轮新增的真实工程落点 + +### 2.1 新增 crate + +1. `server-rs/crates/module-npc/Cargo.toml` +2. `server-rs/crates/module-npc/src/lib.rs` + +### 2.2 workspace 与主工程聚合 + +1. `server-rs/Cargo.toml` + - 已把 `crates/module-npc` 纳入 workspace members +2. `server-rs/crates/spacetime-module/Cargo.toml` + - 已接入 `module-npc` 依赖 +3. `server-rs/crates/spacetime-module/src/lib.rs` + - 已接入 `module-npc` 类型 + - 已新增 `npc_state` + - 已新增 `upsert_npc_state` + - 已新增 `upsert_npc_state_and_return` + - 已新增 `resolve_npc_social_action` + - 已新增 `resolve_npc_social_action_and_return` + - 已新增 `resolve_npc_interaction` + - 已新增 `resolve_npc_interaction_and_return` + +--- + +## 3. 当前冻结的数据口径 + +### 3.1 `relation_state` + +当前首版冻结为: + +1. `affinity` +2. `stance` + +`stance` 当前只冻结 5 档: + +1. `Hostile` +2. `Guarded` +3. `Neutral` +4. `Cooperative` +5. `Bonded` + +当前阈值直接对齐现有前端 / Node 原语: + +1. `< 0` -> `Hostile` +2. `< 15` -> `Guarded` +3. `< 30` -> `Neutral` +4. `< 60` -> `Cooperative` +5. `>= 60` -> `Bonded` + +### 3.2 `stance_profile` + +当前首版冻结为: + +1. `trust` +2. `warmth` +3. `ideological_fit` +4. `fear_or_guard` +5. `loyalty` +6. `current_conflict_tag` +7. `recent_approvals` +8. `recent_disapprovals` + +字段策略: + +1. 数值统一收敛到 `0 ~ 100`。 +2. 最近好评 / 反感文本统一只保留最近 `3` 条。 +3. `current_conflict_tag` 仍允许为空,不在本轮强绑世界线程 ID。 + +### 3.3 `npc_state` + +当前首版字段冻结为: + +1. `npc_state_id` +2. `runtime_session_id` +3. `npc_id` +4. `npc_name` +5. `affinity` +6. `relation_state` +7. `help_used` +8. `chatted_count` +9. `gifts_given` +10. `recruited` +11. `trade_stock_signature` +12. `revealed_facts` +13. `known_attribute_rumors` +14. `first_meaningful_contact_resolved` +15. `seen_backstory_chapter_ids` +16. `stance_profile` +17. `created_at` +18. `updated_at` + +当前策略: + +1. `npc_state` 保持 private 真相表口径。 +2. `npc_state_id` 允许由 `runtime_session_id + npc_id` 自动派生,避免外部每次重复拼接。 +3. `relation_state` 作为显式冗余字段落表,避免每次读取都重复派生。 +4. `npc_name` 当前保留为调试与兼容聚合字段,不承担唯一键职责。 + +--- + +## 4. 当前 reducer / procedure 口径 + +### 4.1 `upsert_npc_state` + +当前负责: + +1. 校验 `runtime_session_id / npc_id / npc_name` +2. 归一化 `stance_profile` +3. 归一化 `relation_state` +4. 以 `npc_state_id` 为主键执行幂等写入 + +### 4.2 `resolve_npc_social_action` + +当前只承接 **纯 NPC 关系状态** 的最小变更,不负责背包、任务、队伍、战斗副作用。 + +当前动作冻结为: + +1. `Chat` +2. `Help` +3. `Gift` +4. `Recruit` +5. `QuestAccept` + +当前规则: + +1. `Chat` + - 默认按 `max(2, 6 - chatted_count)` 推进好感 + - 递增 `chatted_count` + - 强制标记 `first_meaningful_contact_resolved = true` +2. `Help` + - 若已使用过援手则拒绝 + - 默认推进 `4` 点好感 + - 写入 `help_used = true` +3. `Gift` + - 递增 `gifts_given` + - 默认按 `4` 点好感推进,允许外部显式传入覆盖值 +4. `Recruit` + - 若当前好感 `< 60` 则拒绝 + - 写入 `recruited = true` + - 同时标记首遇已完成 +5. `QuestAccept` + - 默认推进 `3` 点好感 + - 只改 NPC 关系侧立场数据,不直接落 quest 真相 + +当前 procedure 仅返回最新 `NpcStateSnapshot`,不在本轮提前扩出 story patch / UI 文案 contract。 + +### 4.3 `resolve_npc_interaction` + +当前首版 `resolve_npc_interaction` 不直接承担所有跨子域副作用,而是先固定 **NPC 单次正式交互** 的最小统一结果口径。 + +当前输入冻结为: + +1. `runtime_session_id` +2. `npc_id` +3. `npc_name` +4. `interaction_function_id` +5. `updated_at_micros` +6. `release_npc_id`(仅为后续招募换队预留,当前不在 Rust 侧正式消费) + +当前支持的 function 只冻结为: + +1. `npc_preview_talk` +2. `npc_chat` +3. `npc_help` +4. `npc_recruit` +5. `npc_fight` +6. `npc_spar` +7. `npc_leave` + +当前输出冻结为: + +1. `npc_state` +2. `interaction_status` +3. `action_text` +4. `result_text` +5. `story_text` +6. `battle_mode` +7. `encounter_closed` +8. `affinity_changed` +9. `previous_affinity` +10. `next_affinity` + +当前规则: + +1. `npc_preview_talk` + - 只把交互状态切到 `Previewed` + - 不改好感 +2. `npc_chat` + - 复用 `resolve_npc_social_action(Chat)` 的关系推进 + - 返回 `interaction_status = Dialogue` +3. `npc_help` + - 复用 `resolve_npc_social_action(Help)` + - 返回 `interaction_status = Resolved` +4. `npc_recruit` + - 当前只负责把 `npc_state.recruited = true` + - 不在本轮承担 companion / roster 真相写入 + - 返回 `interaction_status = Recruited` +5. `npc_fight` + - 不改 `npc_state.affinity` + - 返回 `interaction_status = BattlePending` + - `battle_mode = Fight` +6. `npc_spar` + - 不改 `npc_state.affinity` + - 返回 `interaction_status = BattlePending` + - `battle_mode = Spar` +7. `npc_leave` + - 不改关系真相 + - 返回 `interaction_status = Left` + - `encounter_closed = true` + +当前刻意不做: + +1. 不直接生成 `RuntimeStoryPatch` +2. 不直接写 `companions / roster / inventory_slot` +3. 不直接把玩家 HP / MP、切磋战斗目标、战斗奖励塞进这个 reducer + +也就是说,这一层当前只负责把 **Node 侧 `resolveNpcInteraction` 的统一入口语义** 先冻结为可编译 contract,不宣称已经迁完全部副作用。 + +--- + +## 5. 当前刻意未做 + +本轮明确没有扩到以下范围: + +1. 还没有落 `npc_trade` 的库存与价格正式结算 +2. 还没有落 `npc_gift` 的背包扣减与物品收益结算 +3. 还没有落 `npc_recruit` 的队伍替换与 companion 真相迁移 +4. `npc_fight / npc_spar` 的正式 `battle_state` 初始化编排不在 `module-npc` crate 内部完成,而是下沉到 `spacetime-module` 聚合 procedure +5. 还没有把 `custom world` 的 `narrativeProfile / backstoryReveal` 真正投影进 SpacetimeDB +6. 还没有把 Node 侧 `npcInteractionService` 全量切到 `server-rs` +7. 还没有给前端接入 `SpacetimeDB` 的 NPC 订阅读模型 + +也就是说,本轮只是把 **NPC 关系状态基座** 立起来,不宣称已经完成完整 NPC 子域迁移。 + +--- + +## 6. 下一步建议 + +后续应继续按下面顺序推进: + +1. 把 `npc_recruit` 的 companion / roster 真相迁移拆成 `module-npc + module-runtime + module-story` 的联合 reducer 设计。 +2. 在 `spacetime-client` / Axum 侧继续把 `npc_fight / npc_spar` 的 `battle_state` 联合编排接口接出来。 +3. 把 `npc_trade / npc_gift` 的正式库存、扣减与收益迁到 `inventory / runtime-item` 联动链。 +4. 把 `backstoryReveal / privateChatUnlockAffinity / narrativeProfile` 的可见性规则投成显式读模型。 +5. 再接 `api-server` 的 NPC facade 与前端 runtime action。 diff --git a/docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..a857370e --- /dev/null +++ b/docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,174 @@ +# M4 Module Progression SpacetimeDB 基座记录(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只记录一件事: + +**把 `module-progression` 从“只有 README 占位”推进到“SpacetimeDB 侧已有最小可编译成长真相基座”的真实落地结果。** + +本轮先落等级/经验/章节计划与记账的最小领域骨架,并补上任务交付与战斗胜利的自动联动;不扩到完整 `custom-world` 章节蓝图编译或 Axum facade 全链迁移。 + +--- + +## 1. 本轮落地范围 + +本轮按 `LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md` 与 `SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md` 的交叉口径,只落实下面 6 件事: + +1. 新增 `server-rs/crates/module-progression/` 真实 crate,而不是继续停留在 README 占位。 +2. 在 `module-progression` 中冻结 `LevelBenchmark`、`PlayerProgressionSnapshot`、`ChapterProgressionSnapshot`、`RuntimeEntityLevelProfile` 的首版领域类型。 +3. 在 `module-progression` 中落地与当前 Node 版一致的等级经验曲线、参考强度曲线、章节 pseudo level 曲线与敌对战斗 fallback 规则。 +4. 在 `server-rs/crates/spacetime-module/` 中新增 `player_progression`、`chapter_progression` 两张表。 +5. 在 `spacetime-module` 中新增 `get_player_progression_or_default`、`grant_player_progression_experience`、`upsert_chapter_progression`、`apply_chapter_progression_ledger_entry` 及对应 procedure。 +6. 用 `module-story / module-quest` 同口径方式,把成长状态固定成“单用户成长表 + 单章节计划/记账表”的最小可编译基座。 + +--- + +## 2. 本轮新增的真实工程落点 + +### 2.1 新增 crate + +1. `server-rs/crates/module-progression/Cargo.toml` +2. `server-rs/crates/module-progression/src/lib.rs` + +### 2.2 workspace 与主工程聚合 + +1. `server-rs/Cargo.toml` + - 已把 `crates/module-progression` 纳入 workspace members +2. `server-rs/crates/spacetime-module/Cargo.toml` + - 已接入 `module-progression` 依赖 +3. `server-rs/crates/spacetime-module/src/lib.rs` + - 已接入 `module-progression` 类型 + - 已新增 `player_progression` + - 已新增 `chapter_progression` + - 已新增成长相关 reducer / procedure + +--- + +## 3. 当前冻结的数据口径 + +### 3.1 `player_progression` + +当前首版字段冻结为: + +1. `user_id` +2. `level` +3. `current_level_xp` +4. `total_xp` +5. `xp_to_next_level` +6. `pending_level_ups` +7. `last_granted_source` +8. `created_at` +9. `updated_at` + +当前策略: + +1. `player_progression` 保持 private 真相表口径。 +2. 当前统一按 `user_id` 单行持久化,不在本轮拆历史 grant 日志表。 +3. 若记录不存在,`procedure` 返回 `Lv.1 / 0 XP` 默认快照,但不额外写库。 + +### 3.2 `chapter_progression` + +当前首版字段冻结为: + +1. `chapter_progression_id` +2. `user_id` +3. `chapter_id` +4. `chapter_index` +5. `total_chapters` +6. `entry_pseudo_level_millis` +7. `exit_pseudo_level_millis` +8. `entry_level` +9. `exit_level` +10. `planned_total_xp` +11. `planned_quest_xp` +12. `planned_hostile_xp` +13. `actual_quest_xp` +14. `actual_hostile_xp` +15. `expected_hostile_defeat_count` +16. `actual_hostile_defeat_count` +17. `level_at_entry` +18. `level_at_exit` +19. `pace_band` +20. `created_at` +21. `updated_at` + +当前策略: + +1. `chapter_progression` 先用一张表同时承接 `ChapterProgressionPlan` 与 `ChapterExperienceLedger`。 +2. 当前不再额外拆计划表和记账表,避免首轮 schema 还没稳定就二次改表。 +3. 主键固定为 `user_id + chapter_id` 组合衍生 ID,保证同一玩家每章只有一条真相记录。 + +--- + +## 4. 当前 reducer / procedure 口径 + +### 4.1 `get_player_progression_or_default` + +当前负责: + +1. 按 `user_id` 读取 `player_progression` +2. 若不存在则返回默认 `Lv.1 / 0 XP` +3. 不产生额外写入 + +### 4.2 `grant_player_progression_experience` + +当前负责: + +1. 读取当前 `player_progression`,不存在则按默认成长态开始 +2. 根据 `PlayerProgressionGrantInput` 发放经验 +3. 统一更新 `level / current_level_xp / total_xp / xp_to_next_level / pending_level_ups` +4. 固定 `last_granted_source` + +### 4.3 `upsert_chapter_progression` + +当前负责: + +1. 写入或覆盖某玩家某章节的计划快照 +2. 固定章节预算、目标等级带与预期击杀数 +3. 把实际记账字段初始化为 `0` + +### 4.4 `apply_chapter_progression_ledger_entry` + +当前负责: + +1. 在已存在的章节计划上累计 `actual_quest_xp` +2. 累计 `actual_hostile_xp` +3. 累计 `actual_hostile_defeat_count` +4. 可选更新 `level_at_exit` + +--- + +## 5. 当前刻意未做 + +本轮明确没有扩到以下范围: + +1. 还没有把 `sceneChapterBlueprints` 的完整编译逻辑迁到 Rust。 +2. 还没有落 `repeatPenalty`、超预算衰减与章节偏差评级输出。 +3. 还没有把完整章节蓝图、掉落和全量 quest signal 都自动串进 `player_progression / chapter_progression`。 +4. 还没有新增 Axum 的 progression facade。 +5. 还没有把前端 `Lv.` 展示、任务奖励经验提示或敌对等级徽标切到 Rust 后端真相源。 + +也就是说,本轮只是把 `module-progression` 的 SpacetimeDB 成长基座立起来,不宣称已经完成成长系统迁移。 + +--- + +## 6. 验证要求 + +本轮工程变更完成后,至少执行下面两类验证: + +1. `npm run check:encoding` +2. `cargo test -p module-progression` +3. `cargo check -p spacetime-module` + +--- + +## 7. 下一步建议 + +按当前节奏,后续应继续按下面顺序推进: + +1. 先把章节预算编译从 Node `chapterProgressionPlanner` 平移到 Rust 领域层。 +2. 再把 `npc` / `combat` 入口改为消费 `RuntimeEntityLevelProfile` 和 `chapter_progression`。 +3. 再把掉落、任务物品、好感奖励并入统一奖励结算。 +4. 最后再接 Axum facade、兼容 DTO 与前端成长主链。 diff --git a/docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md b/docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md new file mode 100644 index 00000000..8187ea1e --- /dev/null +++ b/docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md @@ -0,0 +1,154 @@ +# M4 成长与 quest/combat 联动设计(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只冻结一件事: + +**把 `player_progression / chapter_progression` 从“可单独调用的成长基座”推进到“任务交付与战斗胜利可自动写入的最小联动闭环”。** + +本轮只落 `turn_in_quest` 和 `resolve_combat_action(Victory)` 两条经验链,不扩到完整章节蓝图 Rust 化、掉落分配、好感奖励或前端展示切换。 + +--- + +## 1. 本轮联动范围 + +本轮只接下面两条确定链路: + +1. `turn_in_quest` 成功后,把 `quest_record.reward.experience` 发放到 `player_progression`。 +2. `resolve_combat_action` 结算为 `Victory` 后,把 `battle_state.experience_reward` 发放到 `player_progression`。 + +补充规则: + +1. 若存在 `chapter_id`,同时尝试把经验记到 `chapter_progression` 账本。 +2. 若对应 `chapter_progression` 不存在,联动必须静默跳过,不能让任务交付或战斗结算失败。 +3. `SparComplete`、`Escaped`、`Ongoing` 都不发经验。 + +--- + +## 2. `turn_in_quest` 联动口径 + +### 2.1 经验来源 + +任务交付经验固定读取: + +1. `quest_record.reward.experience.unwrap_or(0)` + +### 2.2 成长写入 + +当经验值 `> 0` 时,`spacetime-module::turn_in_quest` 需要在任务状态切换为 `TurnedIn` 后调用: + +1. `upsert_player_progression_after_grant_tx` + +写入参数固定为: + +1. `user_id = next.actor_user_id` +2. `amount = reward_experience` +3. `source = PlayerProgressionGrantSource::Quest` +4. `updated_at_micros = next.updated_at_micros` + +### 2.3 章节账本写入 + +若 `next.chapter_id` 存在,则在成长写入后继续尝试调用章节账本 helper: + +1. `granted_quest_xp = reward_experience` +2. `granted_hostile_xp = 0` +3. `hostile_defeat_increment = 0` +4. `level_at_exit = Some(updated_player.level)` + +若章节记录不存在: + +1. 静默跳过 +2. 保留任务交付成功 +3. 不把“章节计划尚未初始化”视为任务错误 + +--- + +## 3. `resolve_combat_action` 联动口径 + +### 3.1 battle_state 新增字段 + +为避免在 reducer 里临时反查外部上下文,本轮给 `BattleStateInput / BattleStateSnapshot / battle_state` 表补两个最小字段: + +1. `chapter_id: Option` +2. `experience_reward: u32` + +设计意图: + +1. `chapter_id` 决定战斗胜利时是否记章节账本。 +2. `experience_reward` 作为已编译好的确定奖励,避免本轮就把章节蓝图和敌对档位计算重新耦回 battle reducer。 + +### 3.2 胜利经验发放 + +当 `resolve_combat_action` 返回: + +1. `CombatOutcome::Victory` + +则 `spacetime-module` 需要继续执行: + +1. `upsert_player_progression_after_grant_tx` + +写入参数固定为: + +1. `user_id = result.snapshot.actor_user_id` +2. `amount = result.snapshot.experience_reward` +3. `source = PlayerProgressionGrantSource::HostileNpc` +4. `updated_at_micros = result.snapshot.updated_at_micros` + +补充规则: + +1. 只有 `experience_reward > 0` 时才真正写成长表。 +2. `SparComplete` 不发经验,因为切磋不算敌对击杀。 + +### 3.3 章节账本写入 + +若 `result.snapshot.chapter_id` 存在,且本次为 `Victory`,则继续尝试: + +1. `granted_quest_xp = 0` +2. `granted_hostile_xp = experience_reward` +3. `hostile_defeat_increment = 1` +4. `level_at_exit = Some(updated_player.level)` + +同样地,若章节记录不存在: + +1. 静默跳过 +2. 仍保留 battle_state 的正常收束结果 + +--- + +## 4. reducer 分层约束 + +本轮保持以下分层不变: + +1. `module-combat` 仍只承接纯战斗状态推进,不直接依赖 `module-progression`。 +2. `module-quest` 仍只承接纯任务状态流转,不直接依赖 `module-progression`。 +3. 真正的跨域写入统一放在 `crates/spacetime-module` reducer / transaction helper 中完成。 + +这样做的原因是: + +1. 领域 crate 保持纯规则,便于后续单测和重用。 +2. SpacetimeDB 事务内的表写顺序集中在同一层,避免跨 crate 重复持久化策略。 + +--- + +## 5. 本轮明确不做 + +本轮明确不扩到以下内容: + +1. 还不把 battle reward 在 reducer 内现场计算为经验值。 +2. 还不把 `quest` 奖励里的物品、货币、好感奖励统一并入同一事务。 +3. 还不把 `quest signal` 自动从战斗/剧情全量分发到任务系统。 +4. 还不把 `chapter_progression` 缺失时自动补建计划记录。 + +--- + +## 6. 验证要求 + +本轮变更完成后,至少执行: + +1. `npm run check:encoding` +2. `cargo test -p module-combat` +3. `cargo test -p module-progression` +4. `cargo test -p module-quest` +5. `cargo check -p spacetime-module` diff --git a/docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..f0f05de7 --- /dev/null +++ b/docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,156 @@ +# M4 RPG Runtime Inventory SpacetimeDB 基座记录(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只记录一件事: + +**把 `module-inventory` 从“只有 README 占位”推进到“已有首版背包领域契约、SpacetimeDB `inventory_slot` 真相表与 `apply_inventory_mutation` reducer”的真实落地结果。** + +本轮目标不是一次性迁完 Node 版所有背包玩法,而是先把后续 `story / quest / runtime-item / npc` 都能稳定复用的最小背包真相源立起来。 + +--- + +## 1. 本轮落地范围 + +本轮只落实下面 4 件事: + +1. 新增 `server-rs/crates/module-inventory/` 真实 crate,而不是继续停留在 README 占位。 +2. 在 `module-inventory` 中冻结 `inventory_slot`、`apply_inventory_mutation` 的首版领域类型、输入输出和字段校验 helper。 +3. 在 `server-rs/crates/spacetime-module/` 中新增 `inventory_slot` 表。 +4. 在 `spacetime-module` 中新增 `apply_inventory_mutation` reducer,形成最小可编译背包主链。 + +--- + +## 2. 当前冻结的数据口径 + +### 2.1 `inventory_slot` + +当前首版字段冻结为: + +1. `slot_id` +2. `runtime_session_id` +3. `story_session_id` +4. `actor_user_id` +5. `container_kind` +6. `slot_key` +7. `item_id` +8. `category` +9. `name` +10. `description` +11. `quantity` +12. `rarity` +13. `tags` +14. `stackable` +15. `stack_key` +16. `equipment_slot_id` +17. `source_kind` +18. `source_reference_id` +19. `created_at` +20. `updated_at` + +当前策略: + +1. `inventory_slot` 采用“单槽位即单真相行”的口径,不再把背包塞回 runtime snapshot 大 JSON。 +2. `Backpack / Equipment` 统一进同一张表,通过 `container_kind + slot_key` 区分容器和装备位。 +3. 首版堆叠不再依赖 Node 版的隐式 heuristic,统一冻结为 `stackable + stack_key` 显式口径。 + +### 2.2 `apply_inventory_mutation` + +当前首版只支持 4 类 mutation: + +1. `GrantItem` +2. `ConsumeItem` +3. `EquipItem` +4. `UnequipItem` + +当前策略: + +1. `GrantItem` 负责发放新物品,并在 `Backpack` 内按 `stack_key` 合并可堆叠物品。 +2. `ConsumeItem` 负责安全扣减堆叠数量,数量归零时删除槽位。 +3. `EquipItem` 负责把背包中的可装备物品移动到目标装备位,并自动把原装备挪回背包。 +4. `UnequipItem` 负责把装备位物品退回背包。 + +--- + +## 3. 当前刻意未做 + +本轮明确没有扩到以下范围: + +1. 还没有落 `UseItem / Craft / Dismantle / Reforge` 这类更高阶背包动作。 +2. `quest_turn_in` 奖励物品链当前已进入聚合 reducer 接线,但 `npc_trade`、`npc_gift` 仍未落专属 reducer。 +3. 当前已经补上最小同步查询切片 `GET /api/runtime/sessions/:runtimeSessionId/inventory`, + 但还没有落背包 public view,也没有让前端直读 `inventory_slot`。 +4. 还没有把 Node 版 `inventoryMutationService.ts` 整体迁到 Rust,只先冻结首版真相表和最小规则。 + +也就是说,本轮只是把 `module-inventory` 的基座立起来,不宣称已经完成完整背包玩法迁移。 + +--- + +## 4. 关键规则冻结 + +### 4.1 非堆叠物品固定单槽位单数量 + +当前规则: + +1. `stackable = false` 的物品必须固定 `quantity = 1`。 +2. 可装备物品固定 `equipment_slot_id != None` 且必须 `stackable = false`。 +3. 后续如果要支持“同名但独立词缀装备”,继续沿用“一件装备一条 `inventory_slot`”。 + +### 4.2 装备切换不引入新真相副本 + +当前规则: + +1. 装备和卸下都只是在同一条 `inventory_slot` 上切换 `container_kind + slot_key`。 +2. 遇到同装备位冲突时,原装备直接回到 `Backpack`,不额外创建临时副本。 +3. 这样后续做 Axum façade 或前端 view 时,可以稳定用 `slot_id` 追踪同一件物品。 + +### 4.3 背包真相优先,展示读模型后置 + +当前规则: + +1. `module-inventory` 只负责状态真相与 mutation 规则。 +2. 若后续需要“前端背包列表”“装备面板读模型”,优先通过 `view` 或 Axum façade 暴露。 +3. 不新增第二份背包真相副本,也不回退到多个 service 各自改 JSON。 + +--- + +## 5. 本轮新增的真实工程落点 + +### 5.1 新增 crate + +1. `server-rs/crates/module-inventory/Cargo.toml` +2. `server-rs/crates/module-inventory/src/lib.rs` + +### 5.2 workspace 与主工程聚合 + +1. `server-rs/Cargo.toml` + - 已把 `crates/module-inventory` 纳入 workspace members +2. `server-rs/crates/spacetime-module/Cargo.toml` + - 已接入 `module-inventory` 依赖 +3. `server-rs/crates/spacetime-module/src/lib.rs` + - 已接入 `module-inventory` 类型 + - 已新增 `inventory_slot` + - 已新增 `apply_inventory_mutation` + +--- + +## 6. 验证目标 + +本轮应至少验证: + +1. `module-inventory` crate 可以独立 `cargo check / cargo test` +2. `spacetime-module` 能成功编译并接入新表与 reducer +3. 不会把现有中文内容写坏,编码检查继续通过 + +--- + +## 7. 下一步建议 + +按当前节奏,后续应继续按下面顺序推进: + +1. 在 `module-inventory` 中继续补 `UseItem / Craft / Dismantle / Reforge` 对应的纯规则契约。 +2. 继续把 `npc_trade / npc_gift / runtime-item` 发物链改成显式调用 `apply_inventory_mutation`,并补齐 quest / treasure 之外的奖励入口。 +3. 在最小同步查询切片稳定后,再评估是否继续补 `inventory view`、旧前端背包读模型兼容或 public subscription。 +4. 最后再把 Node 版 `inventoryMutationService.ts` 的玩法细节逐步迁走。 diff --git a/docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..29567752 --- /dev/null +++ b/docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,204 @@ +# M4 RPG Runtime Quest SpacetimeDB 基座记录(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只记录一件事: + +**把 `module-quest` 从“只有 README 占位”推进到“SpacetimeDB 侧已有最小可编译任务运行时基座”的真实落地结果。** + +本轮只落任务主状态、任务日志与最小 reducer,并补上任务交付到成长表、背包表的最小联动;不扩到完整奖励结算、Axum facade、前端任务面板或 story action 全量迁移。 + +--- + +## 1. 本轮落地范围 + +本轮按 `AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md`、`AI_NATIVE_TASK_DRIVEN_GOAL_EXPERIENCE_PRD_2026-04-07.md` 与 `SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md` 的交叉口径,只落实下面 5 件事: + +1. 新增 `server-rs/crates/module-quest/` 真实 crate,而不是继续停留在 README 占位。 +2. 在 `module-quest` 中冻结 `QuestRecord`、`QuestStep`、`QuestReward`、`QuestProgressSignal` 的首版 Rust 领域类型与校验/归一化 helper。 +3. 在 `server-rs/crates/spacetime-module/` 中新增 `quest_record`、`quest_log` 两张表。 +4. 在 `spacetime-module` 中新增 `accept_quest`、`apply_quest_signal`、`acknowledge_quest_completion`、`turn_in_quest` 四个 reducer。 +5. 用 `module-story` 同口径方式,把任务推进固定成“主记录 + 日志追加”的最小可编译基座。 +6. 把 `turn_in_quest.reward.items` 显式映射为 `InventoryMutation::GrantItem`,在任务交付时同步写入 `inventory_slot`。 + +--- + +## 2. 本轮新增的真实工程落点 + +### 2.1 新增 crate + +1. `server-rs/crates/module-quest/Cargo.toml` +2. `server-rs/crates/module-quest/src/lib.rs` + +### 2.2 workspace 与主工程聚合 + +1. `server-rs/Cargo.toml` + - 已把 `crates/module-quest` 纳入 workspace members +2. `server-rs/crates/spacetime-module/Cargo.toml` + - 已接入 `module-quest` 依赖 +3. `server-rs/crates/spacetime-module/src/lib.rs` + - 已接入 `module-quest` 类型 + - 已新增 `quest_record` + - 已新增 `quest_log` + - 已新增 `accept_quest` + - 已新增 `apply_quest_signal` + - 已新增 `acknowledge_quest_completion` + - 已新增 `turn_in_quest` + +--- + +## 3. 当前冻结的数据口径 + +### 3.1 `quest_record` + +当前首版字段冻结为: + +1. `quest_id` +2. `runtime_session_id` +3. `story_session_id` +4. `actor_user_id` +5. `issuer_npc_id` +6. `issuer_npc_name` +7. `scene_id` +8. `chapter_id` +9. `act_id` +10. `thread_id` +11. `contract_id` +12. `title` +13. `description` +14. `summary` +15. `objective` +16. `progress` +17. `status` +18. `completion_notified` +19. `reward` +20. `reward_text` +21. `narrative_binding` +22. `steps` +23. `active_step_id` +24. `visible_stage` +25. `hidden_flags` +26. `discovered_fact_ids` +27. `related_carrier_ids` +28. `consequence_ids` +29. `created_at` +30. `updated_at` +31. `completed_at` +32. `turned_in_at` + +当前策略: + +1. `quest_record` 保持 private 真相表口径。 +2. 当前仍沿用“一个任务一条主记录”的最小可编译策略,不在本轮拆 `quest_step` 独立表。 +3. `objective / progress / active_step_id / status` 统一由 `steps` 归一化导出,避免旧接口和新真相源在同一条记录里出现漂移。 + +### 3.2 `quest_log` + +当前首版字段冻结为: + +1. `log_id` +2. `quest_id` +3. `runtime_session_id` +4. `actor_user_id` +5. `event_kind` +6. `status_after` +7. `signal_kind` +8. `signal` +9. `step_id` +10. `step_progress` +11. `created_at` + +当前策略: + +1. `quest_log` 先作为 private 结构化日志表,不提前做 public event table。 +2. 当前只承接 `Accepted / Progressed / Completed / CompletionAcknowledged / TurnedIn` 五类事件。 +3. 后续若要接前端实时提示,再决定是否补 event table 或投影表,而不是现在先塞 UI 专用字段。 + +--- + +## 4. 当前 reducer 口径 + +### 4.1 `accept_quest` + +当前负责: + +1. 校验 `QuestRecordInput` +2. 拒绝重复 `quest_id` +3. 写入 `quest_record` +4. 追加一条 `Accepted` 日志 + +### 4.2 `apply_quest_signal` + +当前负责: + +1. 校验 `QuestSignalApplyInput` +2. 校验目标 `quest_record` 必须存在 +3. 只对当前 active step 应用任务信号 +4. 在命中信号时推进 step progress、刷新 `objective / active_step_id / progress / status` +5. 命中且未完成时追加 `Progressed` +6. 最后一条 step 完成时追加 `Completed` + +补充约束: + +1. 已经 `Completed / ReadyToTurnIn / TurnedIn / Failed / Expired` 的任务不会被重复推进。 +2. 信号未命中当前 active step 时,本轮 reducer 允许静默 no-op,保持幂等。 + +### 4.3 `acknowledge_quest_completion` + +当前负责: + +1. 把 `completion_notified` 标为 `true` +2. 追加一条 `CompletionAcknowledged` 日志 + +### 4.4 `turn_in_quest` + +当前负责: + +1. 校验任务已处于 `Completed / ReadyToTurnIn` +2. 固定所有 step progress 为完成态 +3. 把任务状态切到 `TurnedIn` +4. 补齐 `turned_in_at` +5. 追加一条 `TurnedIn` 日志 +6. 若存在 `reward.items`,同步发放到 `inventory_slot` +7. 若存在 `reward.items`,同步发放到 `inventory_slot` +8. 若 `reward.experience > 0`,同步发放 `player_progression` 经验 +9. 若存在已初始化的 `chapter_progression`,同步累计章节任务经验账本 + +--- + +## 5. 当前刻意未做 + +本轮明确没有扩到以下范围: + +1. 还没有落任务奖励的货币、好感、情报统一发放 reducer +2. 还没有接 `runtime_snapshot` projection / sync +3. 还没有接 `story_session`、`npc_state` 的更多跨域联动 +4. 还没有新增 Axum 的 runtime quest facade +5. 还没有把 `server-node` 现有 quest API 切到 `server-rs` +6. 还没有把 Goal Director、章节目标 handoff、前端任务 UI 切到 SpacetimeDB 真相源 + +也就是说,本轮只是把 `module-quest` 的 SpacetimeDB 任务基座立起来,不宣称已经完成任务系统迁移。 + +--- + +## 6. 验证要求 + +本轮工程变更完成后,至少执行下面两类验证: + +1. `npm run check:encoding` +2. `cargo test -p module-quest` +3. `cargo check -p spacetime-module` + +--- + +## 7. 下一步建议 + +按当前节奏,后续应继续按下面顺序推进: + +1. 先把 `quest_record` 与 `runtime_snapshot` 的投影边界补清。 +2. 再把 `resolve_story_action` 内的 quest signal 分发迁到 `apply_quest_signal`。 +3. 再把 `turn_in_quest` 与 `npc affinity / currency / intel projection` 的奖励结算接成显式 reducer。 +4. 再把 quest reward item 的映射 helper 上提到独立领域 crate,减少 `spacetime-module` 聚合层重复转换。 +5. 最后再接 Axum facade、兼容 DTO 与前端任务主链。 diff --git a/docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md b/docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md new file mode 100644 index 00000000..6a138d81 --- /dev/null +++ b/docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md @@ -0,0 +1,171 @@ +# M4 RPG Runtime Story Session State Query 设计(2026-04-22) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结当前 `M4` 的一个最小新增切片: + +**新增 `GET /api/story/sessions/:storySessionId/state`,让 Axum 能从 `SpacetimeDB` 同步读取 `story session` 当前快照与事件流,不提前承诺旧 `runtime story state` 兼容。** + +本轮目标不是实现旧 `GET /api/runtime/story/state/:sessionId` 的等价替换,也不是把 `resolve_story_action`、`currentStory`、`view model compiler` 一次性补齐。 + +--- + +## 1. 为什么先做这个切片 + +当前仓库里已经有三块能力先行落地: + +1. `module-story` 已有 `StorySessionStateInput`、`StorySessionStateRecord` 与 builder / validator。 +2. `spacetime-module` 已有 `get_story_session_state` procedure,可在单次事务内返回 `story_session + story_events`。 +3. `spacetime-client`、`shared-contracts`、`api-server/story_sessions.rs` 已存在大部分适配代码。 + +真正缺的是: + +1. 把这条查询链路正式挂到 Axum 路由树。 +2. 用文档明确当前只开放“真相态查询”,不误导为旧 runtime story 状态恢复已完成。 + +因此本轮选择先把最小查询切片收口,而不是直接跳到更重的旧接口兼容。 + +--- + +## 2. 当前冻结范围 + +本轮只包含以下能力: + +1. 新增公开接口:`GET /api/story/sessions/:storySessionId/state` +2. 认证方式:Bearer JWT +3. 数据来源:`SpacetimeDB procedure get_story_session_state` +4. 返回体只包含: + - `storySession` + - `storyEvents` + +本轮明确不做: + +1. 不兼容旧 `GET /api/runtime/story/state/:sessionId` +2. 不补 `POST /api/runtime/story/state/resolve` +3. 不返回旧 `RuntimeStoryViewModel` +4. 不回填旧 `currentStory` +5. 不拼装 `availableOptions / presentation / patch` +6. 不在查询链路里混入 `npc / quest / combat / inventory` 聚合快照 + +--- + +## 3. 接口 contract + +### 3.1 请求 + +- 方法:`GET` +- 路径:`/api/story/sessions/:storySessionId/state` +- 认证:必须携带 Bearer JWT +- 路径参数: + - `storySessionId`:目标故事会话 ID + +### 3.2 成功响应 + +成功响应延续当前 `api-server` 统一 envelope,`data` 字段结构为: + +```json +{ + "storySession": { + "storySessionId": "storysess_xxx", + "runtimeSessionId": "runtime_xxx", + "actorUserId": "user_xxx", + "worldProfileId": "profile_xxx", + "initialPrompt": "进入营地", + "openingSummary": "营地开场", + "latestNarrativeText": "你看见篝火边有人招手。", + "latestChoiceFunctionId": "talk_to_npc", + "status": "active", + "version": 2, + "createdAt": "2026-04-22T00:00:00.000000Z", + "updatedAt": "2026-04-22T00:01:00.000000Z" + }, + "storyEvents": [ + { + "eventId": "storyevt_xxx", + "storySessionId": "storysess_xxx", + "eventKind": "story_continued", + "narrativeText": "你看见篝火边有人招手。", + "choiceFunctionId": "talk_to_npc", + "createdAt": "2026-04-22T00:01:00.000000Z" + } + ] +} +``` + +### 3.3 错误响应 + +当前延续 story session facade 已有策略: + +1. `SpacetimeClientError::Runtime(_)` 映射为 `400` +2. 其他 `SpacetimeClientError` 映射为 `502` +3. 错误 `details.provider` 固定为 `spacetimedb` + +--- + +## 4. 分层职责 + +### 4.1 `module-story` + +职责: + +1. 冻结 `StorySessionStateInput` +2. 冻结 `StorySessionStateRecord` +3. 负责 builder、validator 与 record 映射 + +不负责: + +1. HTTP 参数解析 +2. JWT 鉴权 +3. 旧前端 view model 编译 + +### 4.2 `spacetime-module` + +职责: + +1. 读取 `story_session` +2. 读取同一 `story_session_id` 下的 `story_event` +3. 按时间与 `event_id` 排序 +4. 返回最小 `StorySessionStateProcedureResult` + +### 4.3 `spacetime-client` + +职责: + +1. 构造 `StorySessionStateInput` +2. 调用 `get_story_session_state` +3. 把 generated binding 结果映射为 `StorySessionStateRecord` + +### 4.4 `api-server` + +职责: + +1. 暴露 `GET /api/story/sessions/:storySessionId/state` +2. 做 Bearer JWT 鉴权 +3. 透传 `storySessionId` +4. 把 `StorySessionStateRecord` 映射到 `StorySessionStateResponse` + +--- + +## 5. 验收口径 + +本轮验收只要求以下几点: + +1. `api-server` 路由树已真实挂出该接口 +2. 未登录访问返回 `401` +3. 在 `SpacetimeDB` 未发布或未连通时返回 `502` +4. `cargo test -p api-server get_story_session_state` 可通过 +5. `npm run check:encoding` 通过,确保新增中文文档没有编码损坏 + +--- + +## 6. 后续边界 + +这条最小查询链路落地后,后续再继续拆下一层: + +1. 评估是否需要把旧 `runtime story state` 查询兼容到新 facade +2. 设计 `resolve_story_action` 真正冻结后的输入/输出 contract +3. 再考虑 `currentStory`、`availableOptions`、`presentation`、`patch` 的旧 view model 兼容 + +在这些 contract 未冻结前,不应把当前接口误称为“旧 runtime story state 已迁移完成”。 diff --git a/docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..6d27d854 --- /dev/null +++ b/docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,250 @@ +# M4 RPG Runtime Story SpacetimeDB 基座记录(2026-04-21) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只记录一件事: + +**把 `M4` 从“只有任务清单和 crate 占位”推进到“SpacetimeDB 侧已有最小可编译 story 会话基座”的真实落地结果。** + +本轮只落最小骨架,不扩到完整 runtime story action 迁移,不改前端交互界面设计。 + +--- + +## 1. 本轮落地范围 + +本轮按 `M4` 与 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 的交叉口径,只落实下面 6 件事: + +1. 新增 `server-rs/crates/module-story/` 真实 crate,而不是继续停留在 README 占位。 +2. 在 `module-story` 中冻结 `story_session / story_event` 的首版领域类型、ID 前缀、状态枚举与字段校验 helper。 +3. 在 `server-rs/crates/spacetime-module/` 中新增 `story_session`、`story_event` 两张表。 +4. 在 `spacetime-module` 中新增 `begin_story_session`、`continue_story` 两个 reducer,形成最小可编译会话主链。 +5. 在 `spacetime-module` 中新增 `begin_story_session_and_return`、`continue_story_and_return` 两个 procedure,让 Axum 可同步拿到结果快照。 +6. 在 `spacetime-client` 与 `api-server` 中新增最小 story session facade,打通 `server-rs` 侧纵向调用链。 + +--- + +## 2. 本轮新增的真实工程落点 + +### 2.1 新增 crate + +1. `server-rs/crates/module-story/Cargo.toml` +2. `server-rs/crates/module-story/src/lib.rs` + +### 2.2 workspace 与主工程聚合 + +1. `server-rs/Cargo.toml` + - 已把 `crates/module-story` 纳入 workspace members +2. `server-rs/crates/spacetime-module/Cargo.toml` + - 已接入 `module-story` 依赖 +3. `server-rs/crates/spacetime-module/src/lib.rs` + - 已接入 `module-story` 类型 + - 已新增 `story_session` + - 已新增 `story_event` + - 已新增 `begin_story_session` + - 已新增 `continue_story` + - 已新增 `begin_story_session_and_return` + - 已新增 `continue_story_and_return` +4. `server-rs/crates/spacetime-client/src/lib.rs` + - 已新增 `begin_story_session(...)` + - 已新增 `continue_story(...)` +5. `server-rs/crates/api-server/src/story_sessions.rs` + - 已新增 `POST /api/story/sessions` + - 已新增 `POST /api/story/sessions/continue` + +--- + +## 3. 当前冻结的数据口径 + +### 3.1 `story_session` + +当前首版字段冻结为: + +1. `story_session_id` +2. `runtime_session_id` +3. `actor_user_id` +4. `world_profile_id` +5. `initial_prompt` +6. `opening_summary` +7. `latest_narrative_text` +8. `latest_choice_function_id` +9. `status` +10. `version` +11. `created_at` +12. `updated_at` + +当前策略: + +1. `story_session` 保持 private 真相表口径。 +2. 当前只解决“故事会话存在、版本递增、最新叙事状态可追踪”。 +3. 不在本轮提前塞入 quest、combat、npc、inventory 混合字段。 + +### 3.2 `story_event` + +当前首版字段冻结为: + +1. `event_id` +2. `story_session_id` +3. `event_kind` +4. `narrative_text` +5. `choice_function_id` +6. `created_at` + +当前策略: + +1. 事件先只承接 `SessionStarted / StoryContinued` 两类最小事件。 +2. 先证明事件追加模型能工作,再扩到 `resolve_story_action` 真实子域事件。 + +--- + +## 4. 当前 reducer 口径 + +### 4.1 `begin_story_session` + +当前负责: + +1. 校验 `StorySessionInput` +2. 拒绝重复 `story_session_id` +3. 写入 `story_session` +4. 追加一条 `SessionStarted` 事件 + +### 4.2 `continue_story` + +当前负责: + +1. 校验 `StoryContinueInput` +2. 校验目标 `story_session` 必须存在 +3. 以事件追加方式写入 `story_event` +4. 递增 `story_session.version` +5. 更新 `latest_narrative_text / latest_choice_function_id / updated_at` + +--- + +## 5. 当前 procedure / facade 口径 + +### 5.1 `begin_story_session_and_return` + +当前负责: + +1. 在单次 procedure 调用里执行 `begin_story_session_tx` +2. 直接返回 `storySession + storyEvent` 快照 +3. 供 `spacetime-client` 与 `api-server` 直接同步消费 + +### 5.2 `continue_story_and_return` + +当前负责: + +1. 在单次 procedure 调用里执行 `continue_story_tx` +2. 直接返回推进后的 `storySession + storyEvent` 快照 +3. 避免 Axum 再额外读取 private table + +### 5.3 `spacetime-client` story facade + +当前已新增: + +1. `begin_story_session(...)` +2. `continue_story(...)` + +当前策略: + +1. `spacetime-client` 负责把 `module-story` 输入 builder 映射到 generated bindings。 +2. procedure 错误统一折叠为 `SpacetimeClientError`,供 Axum 映射为 `400 / 502`。 + +### 5.4 `api-server` story session facade + +当前已新增: + +1. `POST /api/story/sessions` +2. `POST /api/story/sessions/continue` + +当前 contract: + +1. 两个接口都要求 Bearer JWT。 +2. `actorUserId` 由 JWT claims 提供,不允许前端透传。 +3. `storySessionId` / `eventId` 由 Rust 服务端使用 `module-story` 的 ID helper 生成。 +4. 两个接口当前都返回: + - `storySession` + - `storyEvent` + +--- + +## 6. 当前刻意未做 + +本轮明确没有扩到以下范围: + +1. 还没有落 `resolve_story_action` +2. 还没有落 `sync_runtime_snapshot_projection` +3. 还没有接入 `npc_state / quest_record / battle_state / inventory_slot` +4. 还没有兼容旧 `POST /api/runtime/story/actions/resolve` +5. 还没有兼容旧 `GET /api/runtime/story/state/:sessionId` +6. 还没有兼容旧 `POST /api/runtime/story/state/resolve` +7. 还没有兼容旧 `POST /api/runtime/story/initial` +8. 还没有兼容旧 `POST /api/runtime/story/continue` +9. 还没有把 `server-node` 现有 `rpg-runtime-story` 主链切换到 `server-rs` +10. 还没有改任何前端交互界面设计 + +也就是说,本轮只是把 `M4` 的 SpacetimeDB 会话基座与最小 Axum facade 立起来,不宣称已经完成 runtime story 兼容迁移。 + +--- + +## 7. 验证结果 + +本轮已执行: + +1. `cargo check -p module-story --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +2. `cargo check -p spacetime-module --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +3. `cargo check -p spacetime-module --target wasm32-unknown-unknown --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +4. `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes` +5. `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +6. `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` +7. `npm run check:encoding` + +结果: + +1. 全部通过。 + +--- + +## 8. 下一步建议 + +按当前节奏,后续应继续按下面顺序推进: + +1. 先冻结 `story state` 查询 contract,明确是新 `/api/story/sessions/:storySessionId/state` 还是兼容旧 `/api/runtime/story/state/*`。 +2. 再把 `story_session` 与 `runtime_snapshot` 的 projection / sync 边界补清。 +3. 再把 `resolve_story_action` 的输入/输出与 `RuntimeStoryActionRequest` 对齐成下一个 reducer / procedure 设计。 +4. 再逐步把 `npc / quest / treasure / combat` 子域动作接成显式事件与独立 reducer。 +5. 最后再处理旧 Node `runtime story` 兼容接口与前端实际切换。 + +--- + +## 9. 后续增量状态(`2026-04-22`) + +在本文件记录的首轮 story session 基座之上,当前仓库又继续补了两条与 `M4 story runtime` 直接相关的增量切片: + +1. 已补 `GET /api/story/sessions/:storySessionId/state` + - 当前只返回 `storySession + storyEvents` + - 不兼容旧 `RuntimeStoryActionResponse` +2. 已补 `GET /api/story/battles/:battleStateId` + - 当前只返回单个 `battleState` + - 供 battle 刷新、重连和后续 story 编排复用 +3. 已补 `POST /api/story/npc/battle` + - 当前只承接 `npc_fight / npc_spar` + - 同步返回 `npcInteraction + battleState` + +同时,本轮还完成了以下工程收口: + +1. 已重新执行 `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes`。 +2. 已把 `spacetime-client` 中 battle query 的占位实现替换为真实 procedure 调用。 +3. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 与 `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 并通过。 + +当前仍需继续追的验证项: + +1. `story_sessions` / `story_battles` 相关二进制测试在当前机器上编译时间较长,还没有在单次时窗内拿到最终断言结果。 +2. `npm run check:encoding` 已启动,但尚未在单次时窗内跑完。 + +因此,当前准确口径应为: + +1. `M4 story session / story state / battle state / NPC 开战` 的最小后端编译链已经打通。 +2. 旧 `runtime story` 兼容接口与旧 view model 兼容仍未完成。 +3. 长时回归测试与编码检查仍在继续推进,不应提前宣称整阶段验收完成。 diff --git a/docs/technical/M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md b/docs/technical/M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md new file mode 100644 index 00000000..8c262437 --- /dev/null +++ b/docs/technical/M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md @@ -0,0 +1,232 @@ +# M4 Runtime Inventory State Query 设计(2026-04-22) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结当前 `M4` 的一个最小新增切片: + +**新增 `GET /api/runtime/sessions/:runtimeSessionId/inventory`,让 Axum 能从 `SpacetimeDB` 同步读取当前玩家在指定 `runtime_session` 下的 `inventory_slot` 真相态,不提前承诺旧 `GameState.playerInventory / playerEquipment` 全量兼容。** + +本轮目标不是把旧前端背包 view model 一次性全迁到 Rust,也不是把 `inventory_use / craft / dismantle / reforge` 一次性补齐。 + +--- + +## 1. 为什么先做这个切片 + +当前 inventory 主链已经具备: + +1. `module-inventory` 已冻结 `inventory_slot`、`apply_inventory_mutation` 的首版领域 contract。 +2. `spacetime-module` 已有 `inventory_slot` 真相表与 `apply_inventory_mutation` reducer。 +3. `treasure / quest / battle` 奖励物品已经能同步写入 `inventory_slot`。 + +真正缺的部分是: + +1. 背包真相态还没有稳定查询入口。 +2. `api-server` 还不能按 `runtime_session_id + 当前用户` 同步返回当前背包与装备状态。 +3. 后续如果要把背包面板、装备面板或 runtime story projection 收口到 `SpacetimeDB`,需要先有一个最小 inventory query 切片可复用。 + +因此本轮先补“只读查询”能力,不提前跳到更重的旧前端状态兼容。 + +--- + +## 2. 当前冻结范围 + +本轮只包含以下能力: + +1. 新增公开接口:`GET /api/runtime/sessions/:runtimeSessionId/inventory` +2. 认证方式:Bearer JWT +3. 查询 scope:`runtime_session_id + 当前登录用户` +4. 数据来源:`SpacetimeDB procedure get_runtime_inventory_state` +5. 返回体只包含: + - `runtimeSessionId` + - `actorUserId` + - `backpackItems` + - `equipmentItems` + +本轮明确不做: + +1. 不兼容旧 `GameState.playerInventory` +2. 不兼容旧 `GameState.playerEquipment` +3. 不补按 `story_session_id` 或全局用户维度的 inventory 查询 +4. 不做 inventory public subscription / view +5. 不在查询链路里拼装 quest / npc / battle / story_event +6. 不提前做 `inventory_use / craft / dismantle / reforge` + +--- + +## 3. 为什么按 `runtime_session_id + 当前用户` 查询 + +当前 `inventory_slot` 的主作用域字段是: + +1. `runtime_session_id` +2. `actor_user_id` +3. `story_session_id` + +本轮选择 `runtime_session_id + actor_user_id` 作为最小 query scope,原因如下: + +1. `inventory_slot` 当前是运行态背包真相,不是单个 story session 的临时投影。 +2. 同一 `runtime_session` 下的宝箱、任务、战斗奖励都已经按这个 scope 汇入同一张表。 +3. 旧前端背包面板语义本质上也是“当前运行态玩家的背包”,不是“某个 story session 的局部背包”。 +4. 若后续确实需要更细的 `story_session` 投影,应通过上层 façade 或专门 view 提供,而不是先把真相查询 scope 做窄。 + +--- + +## 4. 接口 contract + +### 4.1 请求 + +- 方法:`GET` +- 路径:`/api/runtime/sessions/:runtimeSessionId/inventory` +- 认证:必须携带 Bearer JWT +- 路径参数: + - `runtimeSessionId`:目标运行时会话 ID + +### 4.2 成功响应 + +成功响应延续当前 `api-server` 统一 envelope,`data` 字段结构为: + +```json +{ + "runtimeSessionId": "runtime_xxx", + "actorUserId": "user_xxx", + "backpackItems": [ + { + "slotId": "invslot_xxx", + "containerKind": "backpack", + "slotKey": "invslot_xxx", + "itemId": "consumable_heal_potion", + "category": "消耗品", + "name": "疗伤药", + "description": "用于恢复少量气血。", + "quantity": 3, + "rarity": "common", + "tags": ["healing"], + "stackable": true, + "stackKey": "heal_potion", + "equipmentSlotId": null, + "sourceKind": "treasure_reward", + "sourceReferenceId": "treasure_xxx", + "createdAt": "2026-04-22T00:00:00.000000Z", + "updatedAt": "2026-04-22T00:01:00.000000Z" + } + ], + "equipmentItems": [ + { + "slotId": "invslot_weapon_xxx", + "containerKind": "equipment", + "slotKey": "weapon", + "itemId": "weapon_trial_blade", + "category": "武器", + "name": "试作短剑", + "description": "一柄适合入门者的短剑。", + "quantity": 1, + "rarity": "rare", + "tags": ["weapon"], + "stackable": false, + "stackKey": "weapon_trial_blade", + "equipmentSlotId": "weapon", + "sourceKind": "quest_reward", + "sourceReferenceId": "quest_xxx", + "createdAt": "2026-04-22T00:00:00.000000Z", + "updatedAt": "2026-04-22T00:05:00.000000Z" + } + ] +} +``` + +### 4.3 错误响应 + +当前延续 runtime/profile/story 查询已有策略: + +1. `SpacetimeClientError::Runtime(_)` 映射为 `400` +2. 其他 `SpacetimeClientError` 映射为 `502` +3. 错误 `details.provider`: + - 参数构建或本地语义错误:`runtime-inventory` + - 下游 `SpacetimeDB` 失败:`spacetimedb` + +--- + +## 5. 分层职责 + +### 5.1 `module-inventory` + +职责: + +1. 冻结 `RuntimeInventoryStateQueryInput` +2. 冻结 `RuntimeInventoryStateSnapshot` +3. 冻结 `RuntimeInventorySlotRecord` +4. 负责 builder、validator 与 record 映射 + +不负责: + +1. HTTP 路径解析 +2. JWT 鉴权 +3. 旧前端背包 view model 编译 + +### 5.2 `spacetime-module` + +职责: + +1. 读取指定 `runtime_session_id + actor_user_id` 下的 `inventory_slot` +2. 按 `container_kind` 拆成 `backpackItems / equipmentItems` +3. 复用 `module-inventory` 的 query input / snapshot 结构 +4. 通过 `get_runtime_inventory_state` procedure 返回当前真相态 + +### 5.3 `spacetime-client` + +职责: + +1. 构造 `RuntimeInventoryStateQueryInput` +2. 调用 `get_runtime_inventory_state` +3. 把返回结果映射为稳定 Rust record + +### 5.4 `api-server` + +职责: + +1. 暴露 `GET /api/runtime/sessions/:runtimeSessionId/inventory` +2. 做 Bearer JWT 鉴权 +3. 从 token 中注入 `actorUserId` +4. 把 inventory record 映射到 JSON payload + +--- + +## 6. 排序规则 + +为了保证前端和后续 façade 读到稳定顺序,本轮冻结以下排序口径: + +1. `backpackItems` + - 先按 `slot_key` + - 再按 `slot_id` +2. `equipmentItems` + - 先按装备位固定顺序:`weapon -> armor -> relic` + - 再按 `slot_id` + +当前不在 query 层额外做“按稀有度”“按分类”“按来源”的排序投影。 + +--- + +## 7. 验收口径 + +本轮验收只要求以下几点: + +1. `module-inventory` 已补 inventory query contract 与最小测试 +2. `spacetime-module` 已新增 `get_runtime_inventory_state` procedure +3. `spacetime-client` 已能同步读取 inventory 真相态 +4. `api-server` 已真实挂出 `GET /api/runtime/sessions/:runtimeSessionId/inventory` +5. `cargo check -p module-inventory -p spacetime-module -p spacetime-client -p api-server` 可通过 +6. `npm run check:encoding` 已执行,确保新增中文文档与接口文件没有编码损坏 + +--- + +## 8. 后续边界 + +这条最小 inventory query 落地后,后续再继续拆下一层: + +1. 评估是否需要补 `story session` 局部 inventory projection +2. 评估是否需要把 inventory query 接成旧背包面板 view model +3. 再继续补 `inventory_use / craft / dismantle / reforge` +4. 最后再考虑 inventory subscription / public view + +在这些 contract 未冻结前,不应把当前接口误称为“旧背包系统已完整迁移完成”。 diff --git a/docs/technical/M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md b/docs/technical/M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md new file mode 100644 index 00000000..790e918f --- /dev/null +++ b/docs/technical/M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md @@ -0,0 +1,142 @@ +# M4 Runtime Item Treasure SpacetimeDB 基座记录(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只记录一件事: + +**把 `module-runtime-item` 从“只有 README 占位”推进到“SpacetimeDB 侧已有 `treasure_record` 真相表、`resolve_treasure_interaction` reducer/procedure,以及可桥接 `inventory_slot` 的奖励 contract”的真实落地结果。** + +本轮目标不是一次性迁完 Node 版所有 runtime item 玩法,而是先把宝藏奖励记录、奖励字段口径与后续背包接入边界固定下来。 + +--- + +## 1. 本轮落地范围 + +本轮只落实下面 5 件事: + +1. 新增 `server-rs/crates/module-runtime-item/` 真实 crate,而不是继续停留在 README 占位。 +2. 在 `module-runtime-item` 中冻结 `TreasureResolveInput / TreasureRecordSnapshot / RuntimeItemRewardItemSnapshot` 的首版领域类型与校验 helper。 +3. 在 `server-rs/crates/spacetime-module/` 中新增 `treasure_record` 表。 +4. 在 `spacetime-module` 中新增 `resolve_treasure_interaction` reducer 与 `resolve_treasure_interaction_and_return` procedure。 +5. 把宝藏奖励物品字段扩到可无损映射 `inventory_slot` 的粒度,并在聚合层接通宝藏奖励到背包真相表的最小发物链。 + +--- + +## 2. 当前冻结的数据口径 + +### 2.1 `treasure_record` + +当前首版字段冻结为: + +1. `treasure_record_id` +2. `runtime_session_id` +3. `story_session_id` +4. `actor_user_id` +5. `encounter_id` +6. `encounter_name` +7. `scene_id` +8. `scene_name` +9. `action` +10. `reward_items` +11. `reward_hp` +12. `reward_mana` +13. `reward_currency` +14. `story_hint` +15. `created_at` +16. `updated_at` + +当前策略: + +1. `treasure_record` 保持 private 真相表口径。 +2. `Inspect / Secure / Leave` 都会留下正式记录,避免宝藏交互继续散落在 runtime snapshot 大 JSON 或 story 文本里。 +3. `Leave` 允许不发物;`Inspect / Secure` 的奖励字段当前已经通过聚合层同步写入 `inventory_slot`。 +4. 同一 `treasure_record_id` 重放时直接返回既有记录,不重复执行发物,保证宝藏奖励幂等。 + +### 2.2 `RuntimeItemRewardItemSnapshot` + +当前奖励物品字段冻结为: + +1. `item_id` +2. `category` +3. `item_name` +4. `description` +5. `quantity` +6. `rarity` +7. `tags` +8. `stackable` +9. `stack_key` +10. `equipment_slot_id` + +当前策略: + +1. 奖励物品不再只保留 `item_id + item_name + quantity` 的轻量占位结构。 +2. 当前字段已经与 `inventory_slot` 的首版真相字段对齐到可桥接程度,避免后续发物时再回头猜品类、堆叠策略和装备位。 +3. `build_inventory_item_snapshot_from_reward_item(...)` 负责把宝藏奖励快照稳定映射为 `module-inventory::InventoryItemSnapshot`。 + +--- + +## 3. 当前 reducer / procedure 口径 + +### 3.1 `resolve_treasure_interaction` + +当前负责: + +1. 校验 `TreasureResolveInput` +2. 校验 `story_session_id / runtime_session_id / actor_user_id` 作用域一致 +3. 同一 `treasure_record_id` 重放时直接返回已落库快照 +4. 初次落库时写入 `treasure_record` +5. 初次落库后把 `reward_items` 同步发放到 `inventory_slot` + +### 3.2 `resolve_treasure_interaction_and_return` + +当前负责: + +1. 复用同一套 `treasure_record` upsert 规则 +2. 返回最终 `TreasureRecordSnapshot` +3. 避免 Axum facade 再额外读取 private table + +--- + +## 4. 当前刻意未做 + +本轮明确没有扩到以下范围: + +1. 还没有把 `reward_hp / reward_mana / reward_currency` 接到运行时资源真相表 +2. 还没有把 runtime item director 的完整物品导演链迁到 Rust +3. 还没有新增 Axum 的 runtime treasure facade +4. 还没有把前端 `treasureInteractions.ts` 主链切到 `server-rs` + +也就是说,本轮只是把宝藏结算真相表和奖励 contract 立起来,不宣称已经完成完整宝藏奖励迁移。 + +--- + +## 5. 本轮新增的真实工程落点 + +### 5.1 新增 crate + +1. `server-rs/crates/module-runtime-item/Cargo.toml` +2. `server-rs/crates/module-runtime-item/src/lib.rs` + +### 5.2 workspace 与主工程聚合 + +1. `server-rs/Cargo.toml` + - 已把 `crates/module-runtime-item` 纳入 workspace members +2. `server-rs/crates/spacetime-module/Cargo.toml` + - 已接入 `module-runtime-item` 依赖 +3. `server-rs/crates/spacetime-module/src/lib.rs` + - 已接入 `module-runtime-item` 类型 + - 已新增 `treasure_record` + - 已新增 `resolve_treasure_interaction` + - 已新增 `resolve_treasure_interaction_and_return` + +--- + +## 6. 下一步建议 + +按当前节奏,后续应继续按下面顺序推进: + +1. 先把 `treasure / quest / battle` 的奖励发物 helper 继续收敛,减少 `spacetime-module` 聚合层的重复映射代码。 +2. 再补 runtime 资源恢复、货币与 story projection 的跨域聚合写入。 +3. 最后再接 Axum facade 与前端真实 treasure 主链切换。 diff --git a/docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md b/docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md new file mode 100644 index 00000000..f731a135 --- /dev/null +++ b/docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md @@ -0,0 +1,264 @@ +# M4 Runtime Story 兼容状态桥设计(2026-04-22) + +更新时间:`2026-04-22` + +## 0. 文档目标 + +本文件只冻结 `M4` 当前下一条最小可落地兼容桥: + +**先把 Rust `api-server` 侧旧 `runtime story state` 兼容返回所需的 DTO 与状态桥边界冻结清楚,再进入 Axum handler 与状态编译迁移。** + +当前仓库已经有两条并行现实: + +1. `server-node` 侧旧兼容接口 `POST /api/runtime/story/state/resolve` 仍然在真实服务前端。 +2. `server-rs` 侧已经有 `story_session / battle_state / npc battle / inventory state` 等真相态接口,但还没有编译成旧前端消费的 `RuntimeStoryActionResponse`。 + +因此,本轮不直接宣称“runtime story 已迁完”,而是先把兼容桥 contract 冻结为下一段可编码的工程基线。 + +--- + +## 1. 当前真实现状 + +### 1.1 前端真实调用入口 + +当前前端 `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` 的真实行为是: + +1. 加载 option catalog 时优先调用 `POST /api/runtime/story/state/resolve` +2. story 选项点击时调用 `POST /api/runtime/story/actions/resolve` +3. 请求体默认携带: + - `sessionId` + - `clientVersion` + - `snapshot` + +其中 `snapshot` 当前来自前端内存态: + +1. `gameState` +2. `bottomTab` +3. `currentStory` + +这意味着兼容桥的第一优先级不是“把所有真相态一次性并回旧结构”,而是: + +**先让 Rust 侧能吃下同样的 `snapshot + sessionId + clientVersion`,并返回旧前端已经稳定消费的 `RuntimeStoryActionResponse` 形状。** + +### 1.2 Rust 侧当前已存在的真相态 + +当前 `server-rs` 已经真实挂出的接口包括: + +1. `POST /api/story/sessions` +2. `POST /api/story/sessions/continue` +3. `GET /api/story/sessions/:storySessionId/state` +4. `POST /api/story/battles` +5. `POST /api/story/battles/resolve` +6. `GET /api/story/battles/:battleStateId` +7. `POST /api/story/npc/battle` +8. `GET /api/runtime/sessions/:runtimeSessionId/inventory` + +但这些接口的返回仍是“真相态切片”,还没有拼成旧前端直接依赖的: + +1. `viewModel` +2. `presentation` +3. `patches` +4. `snapshot` + +### 1.3 Node 侧兼容链的真实落点 + +当前旧兼容链不再是任务清单里早期写法的 `server-node/src/modules/story/*`,而是已经迁到: + +1. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` +2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts` +3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts` + +因此,后续对照迁移时必须以这些新域路径为准,不再以已删除或已降级的旧 `modules/story/*` 口径作为实施依据。 + +--- + +## 2. 本轮冻结范围 + +本轮只冻结以下兼容桥边界: + +1. Rust `shared-contracts` 新增旧 `runtime story` 兼容响应 DTO +2. Rust `shared-contracts` 新增 `POST /api/runtime/story/state/resolve` 的最小请求 DTO +3. 明确 Rust 侧第一段只先承接“状态查询兼容桥” +4. 明确 `actions/resolve`、`initial`、`continue` 继续后置 + +本轮明确不做: + +1. 不在 `server-rs` 里直接落完整 `resolve_story_action` +2. 不迁移 Node 侧全部 story 行为决策 +3. 不把 `runtime snapshot` 正式持久化真相一次性迁到 Rust +4. 不在本轮让前端切到 Rust `api-server` + +--- + +## 3. 为什么先做状态桥 + +当前如果直接做 `POST /api/runtime/story/actions/resolve`,会同时撞上 4 个未冻结边界: + +1. `resolve_story_action` 本体 reducer / procedure 还没设计完成 +2. `runtime snapshot projection` 还没有 Rust 侧正式真相模型 +3. `battle / npc / quest / inventory` 旧 patch 结构还没完全映射 +4. LLM story 文本生成口径还没决定是继续经 Node 还是挂到 Rust `platform-llm` + +而 `POST /api/runtime/story/state/resolve` 的依赖更小: + +1. 前端已经会把当前 `snapshot` 一并传上来 +2. Node 侧现有状态读取逻辑本质上就是基于 `snapshot` 编译 `viewModel + availableOptions + currentStory` +3. Rust 侧可以先承接“兼容状态查询 + DTO 编译”这一条独立切片 + +因此当前正确顺序是: + +```text +先冻结 shared-contracts DTO +-> 再做 Rust state bridge compiler +-> 再挂 POST /api/runtime/story/state/resolve +-> 最后再进入 actions/resolve +``` + +--- + +## 4. 兼容桥 contract 冻结 + +## 4.1 请求:`POST /api/runtime/story/state/resolve` + +首版仍沿用前端现有字段名: + +```json +{ + "sessionId": "runtime-main", + "clientVersion": 7, + "snapshot": { + "savedAt": "2026-04-22T12:00:00.000Z", + "bottomTab": "adventure", + "gameState": {}, + "currentStory": {} + } +} +``` + +字段要求: + +1. `sessionId` 必填 +2. `clientVersion` 可选 +3. `snapshot` 可选 +4. `snapshot.gameState` 当前保持 `serde_json::Value`,不在本轮提前强类型化 +5. `snapshot.currentStory` 当前保持 `serde_json::Value | null` + +### 4.1.1 `snapshot` 缺省策略 + +在 Rust 状态桥首版里: + +1. 如果 `snapshot` 存在,则优先用它编译兼容状态 +2. 如果 `snapshot` 缺失,则允许后续 bridge handler 退回: + - 新真相态聚合 + - 或返回当前无法恢复状态的明确错误 + +本轮只冻结 DTO,不在文档里提前承诺缺省路径的最终实现方式。 + +## 4.2 成功响应:`RuntimeStoryActionResponse` + +为了兼容当前前端 `rpgRuntimeStoryClient.ts`,Rust 侧成功响应字段必须与现有共享 TS contract 保持同形: + +1. `sessionId` +2. `serverVersion` +3. `viewModel` +4. `presentation` +5. `patches` +6. `snapshot` + +其中: + +1. `viewModel.availableOptions` 必须继续使用旧 `RuntimeStoryOptionView` +2. `presentation.storyText` 必须保留 +3. `snapshot` 必须继续包含: + - `savedAt` + - `bottomTab` + - `gameState` + - `currentStory` + +### 4.2.1 `patches` 首版策略 + +状态查询接口本身不产生新的行为变更,因此 `state/resolve` 首版兼容桥返回: + +1. `patches` 固定为空数组 + +这与当前 Node `getRuntimeStoryState(...)` 的行为一致,不需要在状态查询时伪造 patch。 + +--- + +## 5. DTO 分层 + +## 5.1 `shared-contracts::runtime_story` + +新模块负责: + +1. `RuntimeStorySnapshotPayload` +2. `RuntimeStoryStateResolveRequest` +3. `RuntimeStoryActionResponse` +4. `RuntimeStoryViewModel` +5. `RuntimeStoryPresentation` +6. `RuntimeStoryPatch` +7. `RuntimeStoryOptionView` +8. `RuntimeBattlePresentation` +9. `RuntimeStoryOptionInteraction` + +当前策略: + +1. 兼容层 DTO 独立成新模块,不继续塞进 `story.rs` +2. `runtime.rs` 继续保留 settings / browse history / profile / inventory / custom world 的公开 DTO +3. `story.rs` 继续只承接 `story session` 真相链 DTO + +## 5.2 字段类型策略 + +为了先稳住兼容层: + +1. `snapshot.game_state` 使用 `serde_json::Value` +2. `snapshot.current_story` 使用 `Option` +3. `RuntimeStoryOptionView.payload` 使用 `Option` + +原因: + +1. 这些字段当前本来就是旧前端快照结构 +2. Rust 侧正式领域模型尚未冻结 +3. 提前强类型化只会放大后续迁移返工面 + +--- + +## 6. 第一段工程落地顺序 + +建议直接按下面顺序编码: + +1. `shared-contracts` 新增 `runtime_story.rs` +2. 为 `RuntimeStoryStateResolveRequest / RuntimeStoryActionResponse` 补 camelCase 序列化测试 +3. `docs/technical/README.md` 与 `shared-contracts/README.md` 更新索引 +4. `backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md` 追加当前冻结进展 +5. 下一轮再进入 `api-server` 的 `state/resolve` handler 与兼容 compiler + +--- + +## 7. 当前刻意不冻结的内容 + +以下内容继续明确后置: + +1. `POST /api/runtime/story/actions/resolve` 的请求 DTO 是否直接复用旧 TS contract 全量字段 +2. `resolve_story_action` 是否拆成: + - `resolve_story_action` + - `resolve_story_combat_action` + - `resolve_story_interaction_action` +3. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory` +4. `LLM` 文本续写是在 Rust bridge 内继续调用,还是继续通过 Node 兼容层兜底 + +这些边界在状态桥稳定前都不应提前拍死。 + +--- + +## 8. 完成定义 + +这一轮“兼容状态桥基线完成”的定义是: + +1. 已有独立技术文档冻结 `state/resolve` 兼容桥边界 +2. `shared-contracts` 已拥有旧 `runtime story` 兼容 DTO +3. DTO 字段名与当前前端消费口径保持一致 +4. `cargo test -p shared-contracts` 通过 +5. `npm run check:encoding` 通过,确保新增中文文档与 Rust 源文件编码未损坏 + +达到以上条件后,下一轮即可直接进入 Rust `state bridge compiler` 与 Axum handler 落地。 diff --git a/docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md b/docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md new file mode 100644 index 00000000..cd33de83 --- /dev/null +++ b/docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md @@ -0,0 +1,218 @@ +# `platform-llm` 文本网关首版设计(2026-04-21) + +## 1. 背景 + +`server-rs/crates/platform-llm/` 在 `2026-04-20` 只完成了目录占位,但当前仓库里已经存在一条稳定的 Node 侧文本模型主链: + +1. `server-node/src/services/llmClient.ts` +2. `server-node/src/modules/ai/*` +3. `server-node/src/services/storyService.ts` +4. `server-node/src/services/questService.ts` +5. `server-node/src/services/runtimeItemService.ts` + +这些调用点已经依赖一套隐含约束: + +1. 使用 OpenAI 兼容的 `/chat/completions` +2. 统一 Bearer 鉴权 +3. 同时支持非流式 JSON 响应与 SSE 流式增量 +4. 要求有超时、连接失败、上游错误和空响应兜底 + +如果 Rust 侧继续只保留 README 占位,后续 `api-server`、`module-ai`、`module-story`、`module-npc` 在落地时又会各自复制一份私有上游 client,重新造成平台层分叉。 + +因此本次先把 `platform-llm` 收口成一个真实可编译、可测试、可复用的 Rust crate,冻结文本主链基础设施。 + +## 2. 本次落地范围 + +### 2.1 本次明确实现 + +1. `LlmProvider`:冻结 provider 来源标签,首版包含 `ark`、`dash_scope`、`openai_compatible` +2. `LlmConfig`:统一 base url、api key、model、timeout、retry 配置 +3. `LlmMessageRole`、`LlmMessage`、`LlmTextRequest`:统一请求 DTO +4. `LlmClient::request_text(...)`:统一非流式文本调用 +5. `LlmClient::stream_text(...)`:统一流式 SSE 文本调用 +6. `LlmTextResponse`、`LlmStreamDelta`、`LlmTokenUsage`:统一响应 DTO +7. `LlmError`:统一配置错误、请求错误、超时、连接失败、上游错误、反序列化错误、空响应错误 +8. 基础重试策略:对 `408`、`429`、`5xx`、超时、连接失败重试 + +### 2.2 本次明确不做 + +1. 不在 `platform-llm` 内承接业务 prompt 组织 +2. 不在 `platform-llm` 内承接模块级状态写回 +3. 不在 `platform-llm` 内做 HTTP Route/SSE façade +4. 不提前把图片、视频、异步任务轮询混进同一个 crate +5. 不声称已经打通 DashScope 图像 API;当前首版只做文本网关 + +## 3. 当前边界口径 + +### 3.1 文本协议边界 + +首版只冻结 **OpenAI 兼容 chat completion**: + +1. 请求路径固定为 `base_url + /chat/completions` +2. Bearer Token 由 `Authorization: Bearer ` 注入 +3. 非流式返回解析 `choices[0].message.content` +4. 流式返回解析 `choices[0].delta.content` +5. `content` 若返回数组文本片段,也统一拼成单字符串 + +### 3.2 Provider 边界 + +1. `Ark`:当前仓库已有真实默认 base url,可直接作为 Rust 首版默认值 +2. `DashScope`:当前只保留 provider 标签,不在 crate 内硬编码其文本兼容入口 +3. `OpenAiCompatible`:用于其他兼容网关 + +这里故意不把 DashScope 文本 base url 写死,是因为当前仓库的真实 Node 主链并没有用 DashScope 跑文本 `/chat/completions`,而是主要用于图像任务;Rust 首版不应在没有仓库事实对齐的前提下硬塞一个未经验证的默认路径。 + +## 4. 对外 API 设计 + +### 4.1 `LlmConfig` + +字段: + +1. `provider` +2. `base_url` +3. `api_key` +4. `model` +5. `request_timeout_ms` +6. `max_retries` +7. `retry_backoff_ms` + +约束: + +1. `base_url`、`api_key`、`model` 不允许为空 +2. `request_timeout_ms` 必须大于 `0` +3. `max_retries` 表示“首轮之外还允许重试多少次” + +### 4.2 `LlmTextRequest` + +字段: + +1. `model: Option` +2. `messages: Vec` +3. `max_tokens: Option` + +约束: + +1. `messages` 不能为空 +2. 每条 `message.content` 不能为空字符串 +3. `model` 如果传入,则 trim 后不能为空 + +### 4.3 `LlmTextResponse` + +字段: + +1. `provider` +2. `model` +3. `content` +4. `finish_reason` +5. `response_id` +6. `usage` + +设计目的: + +1. 上层只拿统一文本结果,不再接触 `choices`、`delta`、`message` 等上游细节 +2. 后续 `api-server` 可以直接把这些字段映射到自己的 HTTP / SSE contract + +## 5. 错误与重试策略 + +### 5.1 错误分层 + +`LlmError` 首版固定为: + +1. `InvalidConfig` +2. `InvalidRequest` +3. `Timeout` +4. `Connectivity` +5. `Upstream` +6. `StreamUnavailable` +7. `EmptyResponse` +8. `Transport` +9. `Deserialize` + +### 5.2 重试规则 + +允许重试: + +1. 请求超时 +2. 连接失败 +3. `408` +4. `429` +5. `5xx` + +不重试: + +1. 配置错误 +2. 请求体无效 +3. 上游返回 `4xx` 非限流类错误 +4. 已成功开始返回流之后的解析错误 + +### 5.3 Backoff 规则 + +首版采用线性 backoff: + +1. 第 1 次重试等待 `retry_backoff_ms` +2. 第 2 次重试等待 `retry_backoff_ms * 2` +3. 依此类推 + +原因: + +1. 先保持实现简单 +2. 足以覆盖当前仓库文本上游的偶发抖动 +3. 真正需要指数退避时,再在平台层单点升级即可 + +## 6. 与 Node 现状对齐 + +Rust 首版有意对齐 `server-node/src/services/llmClient.ts` 的事实边界: + +1. 同样走 `/chat/completions` +2. 同样区分非流式与流式 +3. 同样在空文本时直接报错 +4. 同样把上游 JSON 错误体里的 `error.message` / `message` 提取出来 +5. 同样把重试、超时、连接失败收口在一个平台层里 + +但 Rust 版这次额外收紧两点: + +1. 不混入 Express Request/Response 转发逻辑 +2. 不把业务 prompt 参数与上游 client 绑定在一起 + +这样后续 `api-server` 和 `module-ai` 都只能依赖一套稳定基础设施,而不是复制旧 Node 的“传 HTTP 对象进去直接转发”的实现方式。 + +## 7. 当前测试覆盖 + +首版要求至少覆盖: + +1. 配置校验 +2. URL 归一化 +3. SSE 事件解析 +4. 非流式成功响应解析 +5. `500 -> retry -> 200` 的重试闭环 +6. 流式累计文本拼接 + +## 8. 后续衔接 + +### 8.1 `api-server` + +后续 `api-server` 应该: + +1. 在自身配置层解析环境变量 +2. 组装 `LlmConfig` +3. 注入 `LlmClient` +4. 在 handler / application façade 中调用 `request_text` 或 `stream_text` + +### 8.2 `module-ai` + +后续 `module-ai` 应该: + +1. 只负责 prompt 组织、阶段状态和结果引用 +2. 不直接依赖 `reqwest` +3. 不再自己解析 SSE 增量 +4. 统一通过 `platform-llm` 调模型 + +## 9. 本次验收标准 + +本次实现完成后,应满足: + +1. `server-rs` workspace 能识别 `platform-llm` crate +2. `cargo test -p platform-llm` 通过 +3. `cargo check -p platform-llm` 通过 +4. `platform-llm` README 不再是“仅目录占位” +5. `docs/technical/README.md` 有正式文档索引 diff --git a/docs/technical/README.md b/docs/technical/README.md index 0ddeabcc..f2f5af51 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,8 @@ ## 文档列表 +- [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md):`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。 +- [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md):`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。 - [PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md](./PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md):冻结手机号验证码登录第一阶段的真实落地边界,明确游客兜底默认关闭、公开请求不污染登录态,以及 smoke 必须覆盖短信登录主链。 - [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md):`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。 - [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。 @@ -19,6 +21,13 @@ - [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。 - [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth` 与 `SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。 - [RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](./RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md):Rust 工作区统一日志模块 `shared-logging` 的职责边界、API、输出风格与 `api-server` 迁移规则。 +- [RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md](./RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md):把 `shared-contracts` 从占位目录推进成真实共享协议 crate,冻结统一 envelope、`auth/*`、`runtime/settings`、`assets/*` 与 `story-sessions/*` 首批公开 DTO 的迁移边界。 +- [RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md](./RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md):继续把 `assets/*` 与 `story-sessions/*` 的成功响应从 handler 内 `json!` 手拼收口到 `shared-contracts`,冻结显式响应 DTO 与适配边界。 +- [RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md](./RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md):把 `shared-kernel` 从目录占位推进到首批真实共享内核,冻结第一阶段只允许上提的基础字符串、ID 与时间处理能力,以及首批接入 crate 范围。 +- [RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md](./RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md):在不扩公共 API 的前提下,把 `shared-kernel` 第一阶段已冻结的字符串、UUID 与时间处理能力继续接入 `module-runtime`、`module-story`、`spacetime-client` 与 `api-server`。 +- [RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md](./RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md):继续把 `shared-kernel` 扩到跨多个纯领域 crate 已稳定重复的字符串列表归一化与前缀种子 ID 拼接能力,明确第三阶段仍不进入 JSON、配置与平台语义处理。 +- [RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md](./RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md):继续把 `shared-kernel::normalize_required_string(...)` 接入更多纯领域 crate,收口跨模块重复的“trim + 判空 + 映射字段错误”逻辑,同时明确不进入平台与 JSON 语义。 +- [RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md](./RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md):继续把 `shared-kernel::normalize_required_string(...)` 接入 `module-runtime` 与 `module-assets` 剩余的纯字段级归一化逻辑,保留 `object_key` 等模块局部语义不变。 - [SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md):`M2` 第七张微信 OAuth 状态表 `wechat_auth_state` 的字段、过期/消费语义、`wechat/start` 与 `wechat/callback` 的单次消费规则,以及多实例下的清理策略。 - [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md):`M2` 第六张短信鉴权统计表 `sms_auth_event` 的事件范围、统计口径、索引与和风控/审计表的协作边界。 - [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md):`M2` 第五张风控状态表 `auth_risk_block` 的作用域、活跃态、刷新/解除规则与读取派生约束。 @@ -29,9 +38,16 @@ - [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 - [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md):冻结 M6 剩余的 STS 与服务端上传 helper 落地口径,明确当前上传主链为服务器上传 OSS,Web 端只负责签名读下载。 - [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。 +- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 首批 `runtime settings` 纵向切片的表字段、默认值、procedure、Axum facade、错误 contract 与测试策略。 +- [SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md):冻结 `M5` Agent session create / snapshot 的最小 SpacetimeDB 与 Axum facade 闭环,明确本轮不迁移 LLM、SSE、卡片更新和完整 action registry。 +- [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md):冻结 `M5` Agent `message submit / operation query` 的 deterministic 最小闭环,明确同步写入 user/assistant 消息、`process_message` operation 与 session 进度推进规则。 +- [SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md):冻结 `M5` Agent `/messages/stream` 的最小兼容 SSE facade,明确复用 Stage 7 的同步写表逻辑,只输出当前前端真实消费的 `reply_delta / session / done / error` 事件。 +- [SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md):补齐 `M5` Stage 5 遗漏的 owner-only `GET /api/runtime/custom-world-library/:profileId` 设计,冻结单条 profile detail 的 SpacetimeDB procedure、client facade、404 语义与 Axum 路由扩展方式。 +- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 第二批 `browse history` 纵向切片的 `user_browse_history` 表、双路径 facade、宽松归一化、去重排序规则与测试策略。 - [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。 - [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。 - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 +- [ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md](./ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md):冻结编码检查不扫描临时 Cargo / verify 工作区、同时把 Rust 源文件纳入 UTF-8 校验的修复口径。 - [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 - [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。 - [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。 @@ -51,6 +67,23 @@ - [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的前端 runtime story 主链真实迁移、NPC 交互与 gateway/client 收口、旧入口兼容降级,以及定向回归验证结果。 - [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的后端 runtime session / action service 物理迁移、新域原语导出、旧热点兼容降级,以及定向 runtime story 回归验证结果。 - [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的 RPG 运行时仓储拆分、shared runtime contract 分文件、旧 `story.ts` façade 兼容与定向回归结果。 +- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs` 侧 `M4` 首轮已落地的 `story_session / story_event` SpacetimeDB 基座、`begin_story_session / continue_story` reducer、同步返回快照的 story procedure、`spacetime-client` facade 与新的 `/api/story/sessions*` Axum 接口,以及当前尚未兼容旧 `runtime story` 路由的边界。 +- [M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/sessions/:storySessionId/state` 这条最小 story state 查询切片,明确当前只返回 `storySession + storyEvents`,不等价于旧 `runtime story state` 兼容完成。 +- [M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md](./M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md):冻结旧 `POST /api/runtime/story/state/resolve` 兼容桥的首版边界,明确先补 `RuntimeStoryActionResponse` DTO 与状态桥,再继续进入 Rust `actions/resolve` 与正式 snapshot projection。 +- [M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](./M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md):冻结 `module-ai` 首版的任务/阶段/流式片段/结果引用领域模型、最小内存服务与后续 `platform-llm` / `api-server` / `spacetime-module` 的边界。 +- [M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-ai` 在 `spacetime-module` 中首轮已落地的 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 真相表、最小 reducer/procedure 与当前仍未扩到真实模型调用和 Axum facade 的边界。 +- [M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `module-ai` 从 `shared-contracts`、`spacetime-client` 到 `api-server` 的最小 AI task mutation facade,明确 `start` 路由当前只返回 `202 Accepted`。 +- [M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-quest` 首轮已落地的 `quest_record / quest_log` 字段模型、`accept_quest / apply_quest_signal / acknowledge_quest_completion / turn_in_quest` reducer 边界,以及当前刻意未扩到奖励结算和 Axum facade 的范围。 +- [M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-runtime-item` 首轮已落地的 `treasure_record` 字段模型、奖励快照 contract、`resolve_treasure_interaction` reducer/procedure 边界,以及当前刻意未扩到 `inventory_slot` 和 Axum facade 的范围。 +- [M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md):冻结 `module-combat` 首版 `battle_state`、`resolve_combat_action`、`fight / spar` 收束规则与 `spacetime-module` 接线边界,明确当前暂不接入 `inventory_use` 与跨子域奖励联动。 +- [M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](./M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md):冻结 `module-combat` 从 `spacetime-module procedure` 到 `spacetime-client` 再到 `api-server` 的最小同步返回链,明确当前只新增独立 battle facade,不直接兼容旧 runtime story 总入口。 +- [M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](./M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/battles/:battleStateId` 这条最小 battle state 查询切片,明确当前只返回单个 `battleState` 真相态,不等价于旧 runtime story state 兼容完成。 +- [M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RUNTIME_INVENTORY_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/runtime/sessions/:runtimeSessionId/inventory` 这条最小 inventory 查询切片,明确当前只返回 `inventory_slot` 真相表拆分后的 `backpackItems + equipmentItems`。 +- [M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs` 侧 `module-npc` 首轮已落地的 `npc_state / relation_state / stance_profile` 领域 contract、`resolve_npc_social_action` 规则原语,以及 `spacetime-module` 的最小 reducer / procedure 接线边界。 +- [M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md](./M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md):冻结 `npc_fight / npc_spar` 在 `spacetime-module` 聚合层初始化 `battle_state` 的最小联合 procedure 边界,明确仍不把战斗初始化字段回灌到 `module-npc` 纯领域 crate。 +- [M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_NPC_BATTLE_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `resolve_npc_battle_interaction_and_return` 向上接入 `spacetime-client` 与 `api-server` 的最小同步返回链,明确当前只新增独立 `POST /api/story/npc/battle` facade。 +- [M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs` 侧 `module-progression` 首轮已落地的 `player_progression / chapter_progression` 字段模型、成长曲线、章节预算与记账 reducer / procedure 边界,以及当前刻意未扩到完整章节蓝图迁移和 quest/combat 自动联动的范围。 +- [M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](./M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md):冻结 `turn_in_quest` 与 `resolve_combat_action(Victory)` 到 `player_progression / chapter_progression` 的最小联动口径,明确 battle 奖励字段、章节账本静默跳过规则与本轮不扩到的范围。 - [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md):对照执行计划逐项复核第一批与第二批并行工作的真实落地状态,记录本轮确认到的测试合流收口遗漏与文档索引补齐结果。 - [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md):记录 RPG 执行计划第三批收口已完成的前端新域主链接回、后端新仓储接线、shared contract 直连收紧、旧兼容脚本物理删除,以及明确未扩到 UI 和无关历史文档的边界。 - [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md):记录 RPG 主链旧 `GameShell`、`useGame*`、`hooks/story`、`runtimeRoutes`、`modules/story/*`、`contracts/story.ts` 脚本的物理删除范围、残留依赖扫描和定向验证结果。 diff --git a/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md b/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md new file mode 100644 index 00000000..90a7fa4b --- /dev/null +++ b/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md @@ -0,0 +1,207 @@ +# Rust `shared-contracts` Stage1/Stage3 落地设计 + +日期:`2026-04-21` + +关联任务: + +- [../../backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md](../../backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md) +- [../../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](../../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md) + +关联现状: + +- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md) +- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md) +- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md) +- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md) +- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) +- [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md) +- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md) +- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md) + +## 1. 文档目的 + +`server-rs/crates/shared-contracts` 当前只有 README 占位,没有真正落到可被 `api-server`、后续模块 crate 与前端兼容层复用的共享协议代码。 + +本文件先解决 Stage1 的最小可编码切片,并补充 Stage2 的鉴权 DTO 扩展,以及 Stage3 的资产 / 叙事请求 DTO 收口: + +1. 把统一 response envelope / 头部常量落到真实 crate。 +2. 把已经冻结的 `auth/login-options`、`auth/me`、`auth/sessions`、`runtime/settings` DTO 收进共享 crate。 +3. 让 `api-server` 首次真实依赖 `shared-contracts`,而不是继续把协议类型散落在 handler 文件里。 +4. 继续把 `auth/entry`、`auth/refresh`、`auth/logout`、`auth/logout-all`、`auth/phone/*`、`auth/wechat/*` 的对外 HTTP DTO 收进共享 crate。 +5. 把 `assets/*` 与 `story-sessions/*` 已冻结的公开请求 DTO 收进共享 crate。 + +本文件不在本轮解决: + +1. SSE 事件总线的统一事件模型。 +2. 前端 TypeScript 与 Rust crate 自动生成同构协议。 +3. handler 里用 `json!` 拼装的响应体进一步升级为显式共享响应 DTO。 + +## 2. 当前问题 + +当前 Rust 工作区虽然已经创建了 `crates/shared-contracts` 目录,但协议仍然散落在 `crates/api-server/src/*.rs`: + +1. `api_response.rs` 自己维护 `API_VERSION`、`ApiResponseMeta`。 +2. `http_error.rs` 自己维护 `ApiErrorPayload`。 +3. `login_options.rs`、`auth_me.rs`、`auth_sessions.rs`、`runtime_settings.rs` 继续把 DTO 定义在 handler 文件顶部。 +4. `password_entry.rs`、`phone_auth.rs`、`refresh_session.rs`、`logout.rs`、`logout_all.rs`、`wechat_auth.rs` 也继续各自维护请求/响应结构。 + +这会带来三个直接问题: + +1. `api-server` 无法和后续模块 crate 共用同一份协议定义。 +2. DTO 一旦增多,handler 会重新退化成“协议 + 业务 + 装配”混写。 +3. `shared-contracts` 仍是空壳,无法作为多 crate 架构中的真实依赖节点。 + +## 3. Stage1 范围冻结 + +### 3.1 本轮允许进入 `shared-contracts` 的内容 + +1. HTTP response envelope 常量与结构: + - `API_VERSION` + - `x-genarrative-response-envelope` + - `x-request-id` + - `x-api-version` + - `x-route-version` + - `x-response-time-ms` + - `ApiResponseMeta` + - `ApiErrorPayload` + - success / error envelope 结构 +2. 鉴权相关 DTO: + - `AuthLoginOptionsResponse` + - `AuthUserPayload` + - `AuthMeResponse` + - `AuthSessionSummaryPayload` + - `AuthSessionsResponse` + - `PasswordEntryRequest` + - `PasswordEntryResponse` + - `RefreshSessionResponse` + - `LogoutResponse` + - `LogoutAllResponse` + - `PhoneSendCodeRequest` + - `PhoneSendCodeResponse` + - `PhoneLoginRequest` + - `PhoneLoginResponse` + - `WechatStartQuery` + - `WechatStartResponse` + - `WechatCallbackQuery` + - `WechatBindPhoneRequest` + - `WechatBindPhoneResponse` +3. runtime settings DTO: + - `RuntimeSettingsResponse` + - `PutRuntimeSettingsRequest` +4. 资产与叙事边界 DTO: + - `CreateDirectUploadTicketRequest` + - `GetReadUrlQuery` + - `ConfirmAssetObjectRequest` + - `BindAssetObjectRequest` + - `ConfirmAssetObjectAccessPolicy` + - `BeginStorySessionRequest` + - `ContinueStoryRequest` + +### 3.2 本轮明确不进入 `shared-contracts` 的内容 + +1. 业务规则、归一化逻辑、数据库字段验证。 +2. `module-auth`、`module-runtime` 内部领域对象。 +3. 供应商回包 DTO,例如微信 OAuth provider 响应、OSS SDK 内部结构。 +4. `RequestContext` 这类框架运行时对象。 + +## 4. crate 设计 + +### 4.1 目录结构 + +```text +server-rs/crates/shared-contracts/ +├─ Cargo.toml +└─ src/ + ├─ lib.rs + ├─ api.rs + ├─ auth.rs + ├─ runtime.rs + ├─ assets.rs + └─ story.rs +``` + +### 4.2 模块职责 + +1. `api.rs` + - 统一 API 版本常量、头部常量。 + - 定义 success / error envelope 的共享序列化结构。 +2. `auth.rs` + - 只放对外 HTTP DTO 与协议常量。 + - 不承接账号仓储、session 轮换、手机号/微信规则。 +3. `runtime.rs` + - 只放 `runtime/settings` 的请求响应 DTO。 + - 不承接 `module-runtime` 的默认值、归一化与 SpacetimeDB 表结构。 +4. `assets.rs` + - 只放资产上传、读签名、对象确认、实体绑定的公开 HTTP DTO。 + - 允许依赖 `platform-oss::OssObjectAccess` 这类跨 crate 协议字段类型,但不承接 OSS client、provider 回包与上传流程实现。 +5. `story.rs` + - 只放 `story-sessions` 的公开请求 DTO。 + - 不承接 `module-story` 的会话状态机、事件生成与持久化细节。 + +## 5. 关键约束 + +### 5.1 只放协议,不放业务 + +`shared-contracts` 的判断标准是: + +1. 这个类型是否直接出现在 HTTP / SSE 边界上。 +2. 它是否不需要访问仓储、配置、时钟、网络或第三方 SDK。 + +如果答案不是这两项都满足,就不应该进入本 crate。 + +### 5.2 Stage1 先接受字符串字段 + +本轮共享 DTO 里的部分值域虽然已经在文档中冻结,例如: + +1. `loginMethod` +2. `bindingStatus` +3. `platformTheme` + +但为了避免第一轮就把 Rust 领域枚举与 HTTP 枚举强绑定,本轮仍以字符串字段为主,只提供协议常量,不在 `shared-contracts` 内复制业务枚举和归一化规则。 + +这样做的原因: + +1. 当前 `api-server` 已有 `module-auth`、`module-runtime` 的真实值域来源。 +2. 先把协议层抽出来,能更快形成稳定依赖。 +3. 后续若要升级成显式枚举,可在 `shared-contracts` 单独做 breaking change,而不是把 Stage1 扩成大量语义迁移。 + +## 6. 首批接入策略 + +### 6.1 `api-server` + +本轮直接接入以下共享定义: + +1. `api_response.rs` 改为依赖 `shared-contracts::api::*` +2. `http_error.rs` 改为依赖 `shared-contracts::api::ApiErrorPayload` +3. `login_options.rs`、`auth_me.rs`、`auth_sessions.rs`、`runtime_settings.rs` 改为依赖 `shared-contracts` DTO +4. `password_entry.rs`、`phone_auth.rs`、`refresh_session.rs`、`logout.rs`、`logout_all.rs`、`wechat_auth.rs` 改为依赖 `shared-contracts::auth::*` +5. `assets.rs` 改为依赖 `shared-contracts::assets::*` +6. `story_sessions.rs` 改为依赖 `shared-contracts::story::*` + +### 6.2 其他 crate + +本轮暂不要求 `module-auth`、`module-runtime`、`module-story` 直接依赖 `shared-contracts`。 + +原因: + +1. 这些 crate 当前主要暴露领域模型,而不是对外 HTTP DTO。 +2. 先让 `api-server` 真实消费共享协议,才能验证 crate 边界是否稳定。 + +## 7. 验证要求 + +至少完成以下验证: + +1. `cargo test -p shared-contracts` +2. `cargo test -p api-server` +3. 文档索引补齐,确保后续任务能直接找到这份设计。 + +## 8. 完成定义 + +满足以下条件时,`实现 shared-contracts` 的 Stage1 到 Stage3 视为完成: + +1. `server-rs/crates/shared-contracts` 不再只有 README,占位 crate 变成真实可编译 crate。 +2. `server-rs/Cargo.toml` 已把它纳入 workspace members。 +3. `api-server` 已真实依赖并消费首批共享协议定义。 +4. 统一 envelope、auth/runtime/assets/story 的公开 DTO 不再散落定义在 handler 文件顶部。 +5. `api-server` 中剩余本地 `Request/Response/Query` 类型只保留框架运行时对象与第三方 provider 私有 DTO。 +6. 文档与技术索引已同步更新。 diff --git a/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md b/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md new file mode 100644 index 00000000..eb5ad5a7 --- /dev/null +++ b/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE4_RESPONSE_DTO_DESIGN_2026-04-21.md @@ -0,0 +1,126 @@ +# Rust `shared-contracts` Stage4 响应 DTO 落地设计 + +日期:`2026-04-21` + +关联文档: + +- [RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md](./RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md) +- [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md) +- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md) +- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md) + +## 1. 文档目的 + +Stage1 到 Stage3 已经把 `auth/runtime/assets/story` 的公开请求 DTO 收进 `shared-contracts`,但 `api-server` 里仍有两组成功响应还在 handler 内用 `json!` 直接手拼: + +1. `assets.rs` +2. `story_sessions.rs` + +这会让 handler 继续承担协议字段名维护职责,也会让共享 crate 在“请求已收口、响应仍分散”的中间态停太久。 + +本文件冻结 Stage4 的最小追加范围: + +1. 把 `assets/*` 的成功响应 DTO 显式落到 `shared-contracts::assets` +2. 把 `story-sessions/*` 的成功响应 DTO 显式落到 `shared-contracts::story` +3. 让 `api-server` 的这两组 handler 改为直接返回共享响应类型 + +## 2. 当前问题 + +当前 `api-server` 已经不再本地定义 `assets/story` 的请求 DTO,但成功响应仍然是字面量 JSON: + +1. `create_direct_upload_ticket` +2. `get_asset_read_url` +3. `confirm_asset_object` +4. `bind_asset_object_to_entity` +5. `begin_story_session` +6. `continue_story` + +风险有三点: + +1. 字段名仍散落在 handler 内,后续字段调整容易漏改测试或文档。 +2. `shared-contracts` 无法完整代表这几条公开接口的成功 contract。 +3. handler 重新退化成“业务编排 + 协议拼装”混写。 + +## 3. Stage4 范围冻结 + +### 3.1 本轮新增进入 `shared-contracts` 的类型 + +`shared-contracts::assets`: + +1. `CreateDirectUploadTicketResponse` +2. `DirectUploadTicketPayload` +3. `DirectUploadTicketFormFields` +4. `GetAssetReadUrlResponse` +5. `AssetReadUrlPayload` +6. `ConfirmAssetObjectResponse` +7. `AssetObjectPayload` +8. `BindAssetObjectResponse` +9. `AssetBindingPayload` + +`shared-contracts::story`: + +1. `StorySessionPayload` +2. `StoryEventPayload` +3. `StorySessionMutationResponse` + +### 3.2 本轮明确不进入 `shared-contracts` 的内容 + +1. `AppError.details` 里的错误明细 JSON 结构。 +2. `healthz`、内部调试路由或测试专用返回体。 +3. `spacetime-client`、`module-assets`、`module-story` 的领域记录类型。 +4. `platform-oss` 的上传客户端实现与原始返回对象直接对外暴露。 + +## 4. 设计约束 + +### 4.1 响应字段名必须完全兼容当前 JSON + +本轮不是协议重设计,而是把现有稳定输出显式类型化,因此: + +1. 字段名必须与当前测试断言一致。 +2. `assets` 返回中的 `formFields` 结构必须保持现有 key 命名。 +3. `story` 返回中的 `status`、`eventKind` 继续保持字符串,不升级成 Rust 枚举。 + +### 4.2 允许以适配层方式消费下游 crate + +`shared-contracts` 可以为 `platform-oss` 的稳定协议返回对象提供转换适配,但不直接把这些类型当作最终 HTTP contract 暴露出去。 + +这样做的原因: + +1. HTTP contract 仍由 `shared-contracts` 持有。 +2. `platform-oss` 仍只负责 OSS 协议与签名实现。 +3. 后续若 HTTP 返回要和底层实现解耦,可以只改适配层,不影响 handler 调用方式。 + +## 5. `api-server` 接入方式 + +### 5.1 `assets.rs` + +改造后 handler 只负责: + +1. 调用 `oss_client` / `spacetime_client` +2. 把结果映射成 `shared-contracts::assets::*` +3. 交给 `json_success_body` 输出 envelope 或裸数据 + +### 5.2 `story_sessions.rs` + +改造后 handler 只负责: + +1. 组装 `begin_story_session` / `continue_story` 调用参数 +2. 把 `StorySessionResultRecord` 映射成 `StorySessionMutationResponse` +3. 复用现有错误 envelope 输出 + +## 6. 验证要求 + +至少完成以下验证: + +1. `cargo fmt --all` +2. `cargo test -p shared-contracts` +3. `cargo test -p api-server` + +## 7. 完成定义 + +满足以下条件时,Stage4 视为完成: + +1. `shared-contracts::assets` 与 `shared-contracts::story` 已拥有这轮新增的成功响应 DTO。 +2. `api-server/src/assets.rs` 与 `api-server/src/story_sessions.rs` 不再直接手拼成功响应字段。 +3. 现有接口 JSON 输出对测试保持兼容。 +4. 文档索引与 crate README 已同步更新。 diff --git a/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md new file mode 100644 index 00000000..28e364b1 --- /dev/null +++ b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md @@ -0,0 +1,106 @@ +# Rust `shared-kernel` crate 第一阶段设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于把 `server-rs/crates/shared-kernel` 从“目录占位”推进到“首批真实可复用能力”,并明确第一阶段只实现已经在多个 Rust crate 中重复出现的最小领域内核。 + +本阶段目标不是提前把所有业务模型都上提,而是先解决当前已经出现的共享基础能力重复: + +1. 字符串归一化 +2. 前缀 ID 生成 +3. RFC3339 / 微秒时间戳格式化与解析 + +## 2. 当前问题 + +截至 `2026-04-21`,`shared-kernel` 已有目录与 README 占位,但仍存在以下工程问题: + +1. `server-rs/Cargo.toml` 尚未把 `shared-kernel` 纳入 workspace member +2. `module-auth`、`module-assets`、`platform-auth` 已经各自重复实现字符串裁剪、UUID 生成、时间格式化等基础逻辑 +3. 这些重复逻辑已经跨多个 crate 出现,继续分散会让后续 `SpacetimeDB` 表、reducer、Axum facade 和平台适配层继续复制 + +## 3. 第一阶段职责边界 + +### 3.1 `shared-kernel` 本阶段负责 + +1. 提供跨模块共享的基础字符串归一化函数 +2. 提供跨模块共享的前缀 ID 生成函数 +3. 提供跨模块共享的时间文本格式化与解析函数 +4. 为后续 `SpacetimeDB` 表字段、模块输入输出和平台适配提供统一基础值处理 + +### 3.2 `shared-kernel` 本阶段不负责 + +1. 不上提 `AuthProvider`、`BindingStatus`、`AssetObjectAccessPolicy` 这类仍带模块语义的业务枚举 +2. 不上提 `AuthUser`、`RefreshSessionRecord`、`AssetObjectRecord` 这类仍明显属于单模块的实体结构 +3. 不承担 `Axum`、`JWT`、`Cookie`、`OSS`、`SpacetimeDB SDK` 等框架或平台适配职责 +4. 不把 `shared-kernel` 做成新的“大公共工具箱” + +## 4. 第一阶段落地范围 + +首批只允许进入 `shared-kernel` 的能力如下: + +1. `normalize_required_string(...)` +2. `normalize_optional_string(...)` +3. `build_prefixed_seed_id(prefix, seed_micros)` +4. `build_prefixed_uuid_id(prefix)` +5. `new_uuid_simple_string()` +6. `format_timestamp_micros(...)` +7. `format_rfc3339(...)` +8. `parse_rfc3339(...)` + +选择这些能力的原因: + +1. 已经在 `module-auth`、`module-assets`、`platform-auth` 至少两处出现重复模式 +2. 它们只表达基础值处理,不携带模块私有业务语义 +3. 后续 `SpacetimeDB` 表、snapshot、procedure/reducer 输入输出会继续使用这些能力 + +## 5. 首批接入 crate + +第一阶段要求至少完成以下真实接入,避免出现“crate 存在但没有复用”的假落地: + +1. `module-assets` + - 接管 `normalize_optional_value` + - 接管 `generate_asset_object_id` + - 接管 `generate_asset_binding_id` + - 接管微秒时间戳格式化 +2. `module-auth` + - 接管随机会话 / state ID 生成 + - 接管可选字符串归一化 + - 接管 RFC3339 格式化与解析 +3. `platform-auth` + - 接管必填/可选字符串归一化 + - 接管 refresh session token UUID simple 生成 + +## 6. 与 SpacetimeDB 的边界 + +根据 `spacetimedb-rust` 与 `spacetimedb-concepts` 约束,`shared-kernel` 本阶段保持以下边界: + +1. 不在这里定义 `#[table]` 结构 +2. 不在这里实现 reducer / procedure +3. 不在这里引入外部副作用 +4. 只沉淀可以同时服务 `Axum` 模块、领域模块和后续 `SpacetimeDB` 类型的基础值对象处理 + +## 7. 完成定义 + +当以下条件满足时,本阶段 `shared-kernel` 落地视为完成: + +1. `shared-kernel` 已有正式 `Cargo.toml` 与 `src/lib.rs` +2. `server-rs/Cargo.toml` 已纳入 workspace member +3. `module-assets`、`module-auth`、`platform-auth` 至少有一批重复基础逻辑已改为复用 `shared-kernel` +4. `shared-kernel` 自身带有最小单元测试 +5. 文档索引已收录本设计文档 + +## 8. 后续阶段 + +第一阶段完成后,后续若要继续扩展 `shared-kernel`,必须满足以下前置条件: + +1. 新上提类型已经在多个模块稳定复用 +2. 已有文档能证明它不是单模块私有语义 +3. 不会把模块边界重新压回共享层 + +优先候选方向包括: + +1. 更稳定的核心 ID 新类型 +2. 共享版本号 / 状态值对象 +3. 更明确的时间或审计基础结构 diff --git a/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md new file mode 100644 index 00000000..5e7af529 --- /dev/null +++ b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md @@ -0,0 +1,112 @@ +# Rust `shared-kernel` crate 第二阶段接入设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +第一阶段已经把 `shared-kernel` 从占位目录推进成真实 crate,并冻结了最小共享 API。 + +第二阶段不新增共享 API,只继续把已经确认稳定的第一阶段能力接入更多 Rust crate,避免重复 helper 在工作区继续扩散。 + +## 2. 本阶段问题 + +截至当前,工作区里仍有几类已经被第一阶段覆盖、但尚未切到 `shared-kernel` 的重复实现: + +1. `module-runtime` 仍本地维护可选字符串归一化与 RFC3339 / 微秒时间戳格式化解析 +2. `module-story` 仍本地维护可选字符串归一化与微秒时间戳格式化 +3. `spacetime-client` 仍本地维护微秒时间戳格式化 +4. `api-server` 的 `session_client` 与 `assets` 测试仍各自维护可选字符串归一化、UUID simple 生成 + +这些逻辑都属于第一阶段已经冻结的共享值处理能力,继续保留本地副本只会增加后续漂移风险。 + +## 3. 本阶段边界 + +### 3.1 本阶段允许做的事 + +1. 继续复用第一阶段已存在的 `shared-kernel` API +2. 删除业务 crate 内与这些 API 完全等价的本地 helper +3. 在文档中补充第二阶段接入范围与验证结果 + +### 3.2 本阶段明确不做的事 + +1. 不新增新的 `shared-kernel` 公共函数 +2. 不上提 `module-runtime`、`module-story`、`api-server` 私有业务规则 +3. 不修改 `SpacetimeDB` table、reducer、procedure 的领域边界 +4. 不把 `shared-kernel` 扩成新的“通用工具箱” + +## 4. 第二阶段接入范围 + +### 4.1 `module-runtime` + +接入以下共享能力: + +1. `normalize_optional_string(...)` +2. `format_rfc3339(...)` +3. `parse_rfc3339(...)` + +保留本地 `format_utc_micros(...)` 作为 runtime 领域语义包装,但内部改为调用共享格式化/解析能力,统一异常兜底口径。 + +### 4.2 `module-story` + +接入以下共享能力: + +1. `normalize_optional_string(...)` +2. `format_timestamp_micros(...)` + +### 4.3 `spacetime-client` + +接入以下共享能力: + +1. `format_timestamp_micros(...)` + +### 4.4 `api-server` + +接入以下共享能力: + +1. `session_client` 使用 `normalize_optional_string(...)` +2. `assets` 测试使用 `new_uuid_simple_string(...)` + +## 5. 完成定义 + +当以下条件满足时,`shared-kernel` 第二阶段接入视为完成: + +1. 第二阶段设计文档已补齐并收录到技术索引 +2. 上述四个 crate 不再保留完全等价的重复 helper +3. 相关 crate 编译通过 +4. 关键测试与编码检查通过 + +## 6. 后续约束 + +后续若继续扩展 `shared-kernel`,仍必须满足: + +1. 已在多个 crate 稳定复用 +2. 已有文档证明它不是单模块私有语义 +3. 能通过“共享值处理”边界审核,而不是把业务规则上提 + +## 7. 本轮落地结果 + +### 7.1 实际收口情况 + +1. `module-runtime` 改为直接使用 `shared-kernel::normalize_optional_string(...)`,并保留 `format_utc_micros(...)` 作为 runtime 领域语义包装 +2. `module-story` 改为直接使用 `shared-kernel::format_timestamp_micros(...)`,并保留 `normalize_optional_value(...)` 作为现有公共 API 兼容入口 +3. `spacetime-client` 改为直接使用 `shared-kernel::format_timestamp_micros(...)`,同时移除 battle state 查询遗留的未使用导入噪音 +4. `api-server::session_client` 改为直接使用 `shared-kernel::normalize_optional_string(...)` +5. `module-auth` 第一阶段遗留的可选字符串归一化私有副本也一并收口到 `shared-kernel` + +### 7.2 验证结果 + +本轮基于独立 `CARGO_TARGET_DIR` 进行最小验证,避免与工作区内其他并行 `cargo` 任务争抢默认构建目录。 + +已通过的命令: + +1. `cargo check -p module-auth --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short` +2. `cargo check -p module-runtime --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short` +3. `cargo check -p module-story --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short` +4. `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short` +5. `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml --message-format short` + +### 7.3 当前刻意保留的兼容说明 + +1. 第二阶段没有新增 `shared-kernel` 公共 API,只扩大既有共享能力的复用范围 +2. `spacetime-client::get_battle_state(...)` 仍保持“明确报错”的临时兼容语义,因为当前生成的 SpacetimeDB binding 尚未带出对应可调用 procedure +3. 本轮没有修改自动生成的 `server-rs/crates/spacetime-client/src/module_bindings/*` diff --git a/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md new file mode 100644 index 00000000..b28b45b5 --- /dev/null +++ b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md @@ -0,0 +1,84 @@ +# Rust `shared-kernel` crate 第三阶段值归一化扩展设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +第二阶段已经把第一阶段冻结的字符串、UUID 与时间处理能力继续接入到更多 crate,但 `server-rs` 里仍有一批完全等价、且已经跨多个纯领域 crate 重复出现的“字符串列表归一化”和“前缀种子 ID 拼接”逻辑。 + +第三阶段目标不是继续扩大共享边界到业务规则,而是只补一个已经被多个 crate 重复验证过的最小共享能力,并把现有 `build_prefixed_seed_id(...)` 继续接到更多领域 crate。 + +## 2. 本阶段问题 + +截至当前,以下重复模式已在多个 crate 中稳定出现: + +1. `normalize_string_list(Vec) -> Vec`:逐项 `trim()`,过滤空白项 +2. `format!("{}{:x}", PREFIX, seed_micros)`:前缀 + 十六进制微秒种子 ID 生成 + +这些逻辑已经出现在 `module-ai`、`module-inventory`、`module-runtime-item`、`module-npc`、`module-quest`、`module-combat`、`module-story` 等多个纯领域 crate 中,继续分散维护会增加漂移风险。 + +## 3. 本阶段边界 + +### 3.1 本阶段允许做的事 + +1. 在 `shared-kernel` 中新增 `normalize_string_list(...)` +2. 把更多纯领域 crate 的前缀种子 ID 生成切到 `build_prefixed_seed_id(...)` +3. 把更多纯领域 crate 的字符串列表归一化切到 `normalize_string_list(...)` +4. 保留对外已有的领域兼容函数名,只替换其内部实现或导出方式 + +### 3.2 本阶段明确不做的事 + +1. 不上提 JSON payload、配置、HTTP header、OSS object key 等带平台语义的归一化规则 +2. 不上提 `task_id + stage_kind` 这种带模块专属结构的 ID 生成规则 +3. 不把单个字段的业务校验错误语义挪进 `shared-kernel` +4. 不修改 `SpacetimeDB` table、reducer、procedure 的领域边界 + +## 4. 第三阶段接入范围 + +### 4.1 `shared-kernel` + +新增: + +1. `normalize_string_list(values: Vec) -> Vec` + +函数职责只包含: + +1. 逐项裁剪字符串首尾空白 +2. 过滤归一化后为空的条目 +3. 返回顺序保持不变 + +### 4.2 继续接入 `build_prefixed_seed_id(...)` + +接入以下 crate: + +1. `module-ai` +2. `module-inventory` +3. `module-combat` +4. `module-story` + +### 4.3 继续接入 `normalize_string_list(...)` + +接入以下 crate: + +1. `module-ai` +2. `module-inventory` +3. `module-runtime-item` +4. `module-npc` +5. `module-quest` + +## 5. 完成定义 + +当以下条件满足时,第三阶段视为完成: + +1. 设计文档已收录到技术索引 +2. `shared-kernel` 新增 `normalize_string_list(...)` 且带最小测试 +3. 上述 crate 不再保留完全等价的重复列表归一化与前缀种子 ID 拼接实现 +4. 相关 crate 编译通过 + +## 6. 后续约束 + +后续若继续扩展 `shared-kernel`,仍必须满足: + +1. 已在多个 crate 稳定复用 +2. 仍属于共享值处理,而不是业务规则 +3. 必须先补文档再落代码 diff --git a/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md new file mode 100644 index 00000000..a0e9a1e1 --- /dev/null +++ b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md @@ -0,0 +1,72 @@ +# Rust `shared-kernel` crate 第四阶段必填字符串接入设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +第三阶段已经把前缀种子 ID 和字符串列表归一化继续接入到更多纯领域 crate,但工作区里仍有一批完全等价的“必填字符串裁剪后判空”逻辑分散在多个领域模块内。 + +第四阶段目标仍然不是新增业务规则,而是继续把已有的 `normalize_required_string(...)` 扩到更多纯领域 crate,减少重复实现。 + +## 2. 本阶段问题 + +截至当前,以下重复模式仍在多个领域 crate 中出现: + +1. `value.trim().to_string()` 后判空并返回模块自己的字段错误 +2. 构造输入 DTO 时先 `trim().to_string()`,再走统一校验函数 + +这些模式已经出现在 `module-progression`、`module-story`、`module-combat`、`module-ai`、`module-npc`、`module-runtime-item`、`module-inventory`、`module-quest` 中,且都只属于共享值处理能力。 + +## 3. 本阶段边界 + +### 3.1 本阶段允许做的事 + +1. 继续复用 `shared-kernel::normalize_required_string(...)` +2. 在各领域 crate 中保留原有错误枚举与错误语义 +3. 把本地 `normalize_required_*` 改成调用共享实现 +4. 把“构造时先 trim,再 validate”的输入构造改成共享归一化 + +### 3.2 本阶段明确不做的事 + +1. 不新增新的 `shared-kernel` 公共函数 +2. 不修改 JSON、配置、HTTP、OSS 等平台语义归一化逻辑 +3. 不改动 `SpacetimeDB` table、reducer、procedure 边界 +4. 不改动模块对外错误文案 + +## 4. 第四阶段接入范围 + +### 4.1 继续接入 `normalize_required_string(...)` + +接入以下 crate: + +1. `module-progression` +2. `module-story` +3. `module-combat` +4. `module-ai` +5. `module-npc` +6. `module-runtime-item` +7. `module-inventory` +8. `module-quest` + +### 4.2 接入策略 + +1. 若本地 helper 只是“trim + 判空 + 映射错误”,则改为调用 `normalize_required_string(...)` +2. 若本地构造函数只是先 `trim` 再调用 validate,则直接在构造阶段复用共享必填归一化 +3. 若逻辑同时包含模块私有结构规则,则只替换其中的基础字符串归一化部分 + +## 5. 完成定义 + +当以下条件满足时,第四阶段视为完成: + +1. 文档已收录到技术索引 +2. 上述 crate 中完全等价的必填字符串归一化重复实现已继续减少 +3. 相关 crate 编译通过 +4. 编码检查通过 + +## 6. 后续约束 + +后续若继续扩展 `shared-kernel`,仍必须满足: + +1. 已在多个 crate 稳定复用 +2. 仍属于共享值处理,而不是业务规则 +3. 先补文档再落代码 diff --git a/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md new file mode 100644 index 00000000..9871bd83 --- /dev/null +++ b/docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md @@ -0,0 +1,89 @@ +# Rust `shared-kernel` crate 第五阶段纯领域字段接入设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +第四阶段已经把 `normalize_required_string(...)` 接入到更多纯领域 crate,但 `module-runtime` 与 `module-assets` 中仍保留一批完全等价的“必填字符串裁剪后判空”逻辑。 + +第五阶段目标仍然不是扩展新的共享 API,而是继续把已冻结的共享能力接入剩余纯领域字段,进一步减少重复实现,同时保持各模块既有错误语义与字段特有规则不变。 + +## 2. 本阶段问题 + +截至当前,以下重复模式仍然存在: + +1. `user_id` 一类稳定必填字段在多个 runtime 输入构造函数里重复 `trim + 判空` +2. 浏览历史同步中 `owner_user_id / profile_id / world_name` 的字段级过滤仍然手写 `trim + is_empty` +3. 资产对象与绑定输入中的 `asset_object_id / bucket / asset_kind / binding_id / entity_kind / entity_id / slot` 等稳定字段仍然手写 `trim + 判空` + +这些逻辑都属于共享值处理,不涉及业务流程、平台配置或外部服务语义。 + +## 3. 本阶段边界 + +### 3.1 本阶段允许做的事 + +1. 继续复用 `shared-kernel::normalize_required_string(...)` +2. 在模块内增加薄包装 helper,把共享归一化结果映射到模块自己的错误枚举 +3. 对仍然需要字段特有语义的场景,仅替换其中的基础必填字符串归一化部分 + +### 3.2 本阶段明确不做的事 + +1. 不新增新的 `shared-kernel` 公共函数 +2. 不把 `theme_mode`、`visited_at`、RFC3339 解析等 runtime 语义上提到共享层 +3. 不把 OSS endpoint、metadata、HTTP request、JSON payload 归一化上提到共享层 +4. 不改动 `SpacetimeDB` reducer / procedure / table 结构 +5. 不改动模块对外错误文案 + +## 4. 第五阶段接入范围 + +### 4.1 `module-runtime` + +接入以下稳定字段: + +1. `RuntimeSettingGetInput.user_id` +2. `RuntimeSettingUpsertInput.user_id` +3. `RuntimeBrowseHistoryListInput.user_id` +4. `RuntimeBrowseHistoryClearInput.user_id` +5. `RuntimeBrowseHistorySyncInput.user_id` +6. `RuntimeProfileDashboardGetInput.user_id` +7. `RuntimeProfileWalletLedgerListInput.user_id` +8. `RuntimeProfilePlayStatsGetInput.user_id` +9. 浏览历史同步时单条记录过滤使用的 `owner_user_id / profile_id / world_name` + +### 4.2 `module-assets` + +接入以下稳定字段: + +1. `asset_object_id` +2. `bucket` +3. `asset_kind` +4. `binding_id` +5. `asset_object_id` +6. `entity_kind` +7. `entity_id` +8. `slot` + +其中 `object_key` 保持模块本地语义:先裁剪空白,再去掉前导 `/`,最后再做必填校验;该规则不抽到 `shared-kernel`。 + +## 5. 接入策略 + +1. 若字段只是“trim + 判空 + 返回模块错误”,则直接改为 `normalize_required_string(...)` +2. 若字段同时有局部规则,例如 `object_key` 需要去前导 `/`,则先在模块内保留局部处理,再复用共享必填归一化 +3. 若字段缺失时需要“静默过滤单条记录而非整批失败”,则继续保留该行为,只替换字段归一化实现 + +## 6. 完成定义 + +当以下条件满足时,第五阶段视为完成: + +1. 文档已收录到技术索引 +2. `module-runtime` 与 `module-assets` 中上述纯字段级重复实现已继续减少 +3. 相关 crate 编译通过 +4. 编码检查通过 + +## 7. 后续约束 + +后续若继续扩展 `shared-kernel`,仍必须满足: + +1. 已在多个 crate 中稳定重复出现 +2. 只属于共享值处理,不属于模块私有语义 +3. 先补文档,再落代码 diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md new file mode 100644 index 00000000..e7b75601 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md @@ -0,0 +1,190 @@ +# `M5` custom world Agent message / operation Stage 7 设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档冻结 `M5` 的下一段最小闭环:在 Stage 6 已具备 `session create / session snapshot` 的前提下,把 RPG 创作 Agent 的 `message submit / operation query` 接到 `SpacetimeDB` 真相表与 `Axum` facade。 + +本轮只做: + +1. `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` +2. `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` +3. `custom_world_agent_message` 写入用户消息 +4. `custom_world_agent_operation` 写入并返回 `process_message` 操作状态 +5. 在 `custom_world_agent_session` 内同步更新最小会话进度与 assistant 回复 + +本轮不做: + +1. LLM 编排 +2. SSE `message stream` +3. 真正的异步后台任务 +4. `card detail / card update` +5. `draft_foundation` +6. 真实 `pendingClarifications` 推导 + +## 2. 设计目标 + +Stage 6 已能创建和读取 session,但 `POST /messages` 与 `GET /operations/:operationId` 仍未迁移,导致旧前端无法继续沿 `session -> message -> operation -> session` 的基本主链工作。 + +本轮目标不是还原旧 Node 的完整单轮推理,而是先提供 deterministic、同步完成的最小消息处理链,确保: + +1. 前端可以提交消息 +2. 后端会记录 user / assistant 双消息 +3. 前端可以轮询 operation +4. session snapshot 会体现最新 turn、progress、stage 与最后回复 + +## 3. SpacetimeDB contract + +新增输入: + +1. `CustomWorldAgentMessageSubmitInput` +2. `CustomWorldAgentOperationGetInput` + +新增返回: + +1. `CustomWorldAgentOperationProcedureResult` + +新增 procedure: + +1. `submit_custom_world_agent_message` +2. `get_custom_world_agent_operation` + +## 4. 最小 deterministic 处理规则 + +### 4.1 message submit + +输入字段: + +1. `session_id` +2. `owner_user_id` +3. `user_message_id` +4. `user_message_text` +5. `operation_id` +6. `submitted_at_micros` + +处理步骤固定如下: + +1. 校验 session 存在且归属当前用户 +2. 校验 `user_message_id` 与 `operation_id` 未重复 +3. 写入一条 `role = user`、`kind = chat` 的消息 +4. 写入一条 `operation_type = process_message` 的操作,状态直接落为 `completed` +5. 写入一条 `role = assistant`、`kind = chat` 的 deterministic 回复消息 +6. 更新 `custom_world_agent_session`: + - `current_turn += 1` + - `last_assistant_reply = deterministic reply` + - `updated_at = submitted_at` + - `progress_percent` 按最小规则推进 + - `stage` 按最小规则推进 + +### 4.2 deterministic 回复规则 + +本轮不用 LLM,固定返回规则: + +1. 当消息为空白时直接报错,不写表 +2. 普通消息统一返回: + - `已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。` +3. 如果文本包含 `__phase1_force_fail__`,直接返回 procedure 错误: + - `forced failure` + +这个保留字只用于兼容旧 Node 测试中的失败路径,不代表正式产品行为。 + +### 4.3 最小 session 推进规则 + +1. 初次提交消息后,`progress_percent` 至少推进到 `20` +2. 当累计 user 消息数达到 `2` 条及以上时: + - `progress_percent = 100` + - `stage = foundation_review` + - `creator_intent_readiness_json` 改为 `{ "isReady": true, "completedKeys": ["seed_input"], "missingKeys": [] }` +3. 当累计 user 消息数只有 `1` 条时: + - `progress_percent = max(existing, 20)` + - `stage = clarifying` + - `creator_intent_readiness_json` 保持 `isReady = false` + - `pending_clarifications_json` 写入最小数组,至少包含一条问题 + +这样做的目的不是伪装完整意图抽取,而是提供一个能被旧前端继续驱动的最小状态机。 + +## 5. HTTP contract + +### 5.1 `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` + +请求: + +```json +{ + "clientMessageId": "client-001", + "text": "一条新的世界设定", + "quickFillRequested": false, + "focusCardId": null, + "selectedCardIds": [] +} +``` + +说明: + +1. `quickFillRequested`、`focusCardId`、`selectedCardIds` 本轮先只做兼容解析,不进入 SpacetimeDB 真相表 +2. `clientMessageId` 直接作为 user message 主键 +3. operation id 由 Axum 生成,前缀固定 `operation-` + +响应: + +```json +{ + "operation": { + "operationId": "operation-...", + "type": "process_message", + "status": "completed", + "phaseLabel": "消息已处理", + "phaseDetail": "...", + "progress": 100, + "error": null + } +} +``` + +### 5.2 `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` + +返回裸 `operation` JSON,不额外包 `{ operation }`,保持旧 Node contract。 + +## 6. Axum 与 client 边界 + +`api-server` 负责: + +1. Bearer JWT 鉴权 +2. 参数校验与错误 envelope +3. 生成 `operation-` 主键 +4. 把请求映射到 `spacetime-client` + +`spacetime-client` 负责: + +1. 调用 `submit_custom_world_agent_message` +2. 调用 `get_custom_world_agent_operation` +3. 把 procedure 结果映射成领域 record + +`spacetime-module` 负责: + +1. 真相表写入与最小 deterministic 状态推进 +2. 会话归属校验 +3. 快照与 operation 单条读取 + +## 7. 完成定义 + +当以下条件满足时,本轮 Stage 7 视为完成: + +1. `module-custom-world` 冻结 message submit / operation get 输入输出类型 +2. `spacetime-module` 可提交消息并返回 operation +3. `spacetime-module` 可查询单条 operation +4. `spacetime-client` 封装 submit / get operation +5. `api-server` 挂载: + - `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` + - `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` +6. `cargo check -p module-custom-world` +7. `cargo check -p spacetime-module` +8. `cargo check -p spacetime-client` +9. `cargo check -p api-server` + +## 8. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md) +3. [./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md new file mode 100644 index 00000000..b215d8ac --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md @@ -0,0 +1,188 @@ +# `M5` custom world Agent message stream Stage 8 设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档冻结 `M5` 在 Stage 7 之后的下一段最小闭环:补齐 RPG 创作 Agent 的 `message stream` 兼容接口,让当前前端默认使用的流式消息提交主链可以重新接上 `Axum` 后端。 + +本轮只做: + +1. `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` +2. 兼容前端当前消费的最小 SSE 事件集合 +3. 复用 Stage 7 已落地的 deterministic `message submit` +4. 在流式结束前返回最新 session snapshot + +本轮不做: + +1. 真实 LLM token streaming +2. SpacetimeDB 内部异步 operation 编排 +3. 持续多段增量推理 +4. `card detail / card update` +5. `draft_foundation` + +## 2. 现状与问题 + +当前 Rust 后端已经完成: + +1. `POST /messages` +2. `GET /operations/:operationId` +3. session snapshot 读取 + +但前端 RPG 创作主链默认并不是调用 `POST /messages`,而是调用: + +1. `POST /messages/stream` + +并消费以下 SSE 事件: + +1. `reply_delta` +2. `session` +3. `error` +4. `done` + +如果这条流式路由缺失,当前前端提交共创消息时会直接失败,导致 `message submit` 虽然已落地,但主链仍不可用。 + +## 3. 设计目标 + +本轮目标不是恢复旧 Node 那套真实按回调逐字增量刷新的内部推理过程,而是先提供一个最小兼容 SSE facade,满足当前前端协议: + +1. 前端仍然可以继续走 `/messages/stream` +2. 后端内部复用 Stage 7 的同步 deterministic 写表逻辑 +3. SSE 至少发送一条 `reply_delta` +4. SSE 最终发送一条 `session` +5. SSE 发送 `done` 后结束连接 + +## 4. 最小兼容 SSE 规则 + +### 4.1 请求体 + +请求体沿用 Stage 7: + +```json +{ + "clientMessageId": "client-001", + "text": "一条新的世界设定", + "quickFillRequested": false, + "focusCardId": null, + "selectedCardIds": [] +} +``` + +本轮继续保持: + +1. `quickFillRequested` +2. `focusCardId` +3. `selectedCardIds` + +只做兼容解析,不进入真相表。 + +### 4.2 处理顺序 + +`api-server` 收到 `/messages/stream` 后按以下顺序处理: + +1. 校验 `sessionId`、`clientMessageId`、`text` +2. 调用 `spacetime-client.submit_custom_world_agent_message(...)` +3. 再调用 `spacetime-client.get_custom_world_agent_session(...)` +4. 从 session snapshot 中取最后一条 assistant 消息文本 +5. 组装 SSE 文本并一次性返回 + +### 4.3 SSE 事件集合 + +返回事件严格控制为以下几类: + +1. `reply_delta` + - 载荷: + ```json + { "text": "assistant 最终回复全文" } + ``` +2. `session` + - 载荷: + ```json + { "session": { ...最新 session snapshot... } } + ``` +3. `done` + - 载荷: + ```json + { "ok": true } + ``` +4. `error` + - 仅错误时返回: + ```json + { "message": "..." } + ``` + +本轮不引入: + +1. 多段 `reply_delta` +2. operation 事件 +3. keepalive 心跳 +4. token / chunk 级别 streaming + +## 5. 与 Stage 7 的关系 + +本轮 `message stream` 不是重新发明一套消息处理逻辑,而是一个 facade: + +1. 真相写入仍然完全走 Stage 7 的 `submit_custom_world_agent_message` +2. session 读取仍然走 Stage 6 的 `get_custom_world_agent_session` +3. SSE 只是把“同步完成后的最终结果”包装成旧前端可消费的流式文本 + +因此本轮不会新增: + +1. `module-custom-world` 输入输出类型 +2. `spacetime-module` reducer / procedure +3. `spacetime-client` 新的 message stream 方法 + +## 6. HTTP contract + +### 6.1 成功响应 + +响应头: + +1. `Content-Type: text/event-stream; charset=utf-8` +2. `Cache-Control: no-cache` +3. `X-Accel-Buffering: no` + +响应体示例: + +```text +event: reply_delta +data: {"text":"已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。"} + +event: session +data: {"session":{"sessionId":"..."}} + +event: done +data: {"ok":true} + +``` + +### 6.2 错误响应 + +如果在真正开始返回 SSE 前就失败,仍按普通 JSON 错误 envelope 返回 HTTP 错误。 + +如果已经进入 SSE 写出阶段才失败,则返回: + +```text +event: error +data: {"message":"..."} + +``` + +然后结束连接。 + +## 7. 完成定义 + +当以下条件满足时,本轮 Stage 8 视为完成: + +1. `api-server` 挂载 `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` +2. 当前前端 `streamRpgCreationMessage(...)` 可解析返回内容 +3. `message stream` 内部复用 Stage 7 的 deterministic 消息写入 +4. `backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md` 勾选 `message stream` +5. `docs/technical/README.md` 收录本设计文档 +6. `cargo check -p api-server` + +## 8. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md) +3. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STAGE7_DESIGN_2026-04-22.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md new file mode 100644 index 00000000..7939a7b3 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md @@ -0,0 +1,187 @@ +# `M5` custom world Agent session Stage 6 设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档冻结 `M5` 的下一段最小闭环:把 RPG 创作 Agent 的 `session create / session snapshot` 从旧 Node 单大 JSON 会话体,迁到 `SpacetimeDB` 真相表与 `Axum` facade。 + +本轮只做: + +1. `custom_world_agent_session` 会话骨架创建 +2. 初始 assistant 欢迎消息写入 `custom_world_agent_message` +3. `session snapshot` 聚合读取 +4. `POST /api/runtime/custom-world/agent/sessions` +5. `GET /api/runtime/custom-world/agent/sessions/:sessionId` + +本轮不做: + +1. LLM 意图抽取与澄清问题生成 +2. message submit / message stream +3. card detail / card update +4. operation query +5. 完整 action registry +6. result preview compiler + +## 2. 当前问题 + +Stage 5 已接入 agent `publish_world` action,但调用方仍需要显式提供 `draftProfile` 与 `settingText`。根因是 Rust 侧还没有可读取的 Agent session snapshot。 + +现有 `custom_world_agent_session` 表已有大部分会话级字段,但缺少旧前端必填的 `pendingClarifications` 真相源。本轮必须补上 `pending_clarifications_json`,否则 Axum 只能用空数组猜测,后续 message turn 迁移会继续漂移。 + +## 3. 表结构调整 + +`custom_world_agent_session` 新增: + +1. `pending_clarifications_json: String` + +字段语义: + +1. 存储旧 contract 的 `pendingClarifications` 数组 JSON +2. 初始创建时固定为 `[]` +3. 后续 message turn / clarification 迁移后由服务端派生写入 +4. 读取 snapshot 时必须反序列化为 JSON 数组,非法 JSON 返回 procedure 错误 + +## 4. SpacetimeDB contract + +新增输入: + +1. `CustomWorldAgentSessionCreateInput` +2. `CustomWorldAgentSessionGetInput` + +新增快照: + +1. `CustomWorldAgentMessageSnapshot` +2. `CustomWorldAgentOperationSnapshot` +3. `CustomWorldDraftCardSnapshot` +4. `CustomWorldAgentSessionSnapshot` + +新增返回: + +1. `CustomWorldAgentSessionProcedureResult` + +新增 procedure: + +1. `create_custom_world_agent_session` +2. `get_custom_world_agent_session` + +创建规则: + +1. `session_id` 由 Axum 生成并传入,前缀保持 `custom-world-agent-session-` +2. `owner_user_id` 来自 Bearer JWT,不信任请求体 +3. `seed_text` 可为空 +4. `stage` 首版固定为 `collecting_intent` +5. `progress_percent` 首版固定为 `0` +6. `anchor_content_json` 固定写入八锚点空结构 +7. `creator_intent_json`、`anchor_pack_json`、`lock_state_json`、`draft_profile_json` 写入 `{}` 或最小草稿 +8. `creator_intent_readiness_json` 固定写入 `{ "isReady": false, "completedKeys": [], "missingKeys": [] }` +9. `pending_clarifications_json` 固定写入 `[]` +10. `asset_coverage_json` 固定写入空覆盖率结构 +11. 初始 assistant 消息写入 `custom_world_agent_message` + +## 5. Axum HTTP contract + +### 5.1 `POST /api/runtime/custom-world/agent/sessions` + +请求: + +```json +{ + "seedText": "可选的世界设定起点" +} +``` + +响应: + +```json +{ + "session": { + "sessionId": "custom-world-agent-session-...", + "currentTurn": 0, + "anchorContent": {}, + "progressPercent": 0, + "lastAssistantReply": "...", + "stage": "collecting_intent", + "focusCardId": null, + "creatorIntent": {}, + "creatorIntentReadiness": { + "isReady": false, + "completedKeys": [], + "missingKeys": [] + }, + "anchorPack": {}, + "lockState": {}, + "draftProfile": {}, + "messages": [], + "draftCards": [], + "pendingClarifications": [], + "suggestedActions": [], + "recommendedReplies": [], + "qualityFindings": [], + "assetCoverage": {}, + "checkpoints": [], + "supportedActions": [], + "resultPreview": null, + "updatedAt": "..." + } +} +``` + +说明: + +1. 返回字段保持旧 `RpgAgentSessionSnapshot` / `CustomWorldAgentSessionSnapshot` camelCase shape +2. 初始欢迎语只做 deterministic 文本,不调用 LLM +3. `supportedActions` 首版只给出保守能力状态,不伪装完整 registry + +### 5.2 `GET /api/runtime/custom-world/agent/sessions/:sessionId` + +读取同一个 snapshot。 + +权限: + +1. 必须 Bearer JWT +2. 只能读取 `owner_user_id = claims.user_id` 的 session +3. 不存在或不属于当前用户时返回 SpacetimeDB procedure 错误,由 Axum 映射到当前统一错误 envelope + +## 6. 完成定义 + +当以下条件满足时,Stage 6 视为完成: + +1. `module-custom-world` 冻结 session create/get/snapshot 领域类型 +2. `spacetime-module` 可创建并读取 agent session snapshot +3. `spacetime-client` 封装 create/get procedure +4. `api-server` 挂载 `POST /sessions` 与 `GET /sessions/:sessionId` +5. `M5` 任务清单勾选 `session create / session snapshot` +6. `cargo check -p module-custom-world` +7. `cargo check -p spacetime-module` +8. `cargo check -p spacetime-client` +9. `cargo check -p api-server` + +## 7. bindings 刷新约束 + +`spacetime-client/src/module_bindings` 必须视为生成产物,不允许手工补假类型来绕过 `spacetime-module` 与客户端之间的 schema 漂移。 + +本仓默认刷新命令: + +```bash +spacetime generate --no-config --lang rust --out-dir server-rs/crates/spacetime-client/src/module_bindings --module-path server-rs/crates/spacetime-module --include-private --yes +``` + +如果当前工作树里存在其他并行 `cargo build/test/check` 导致 `spacetime generate` 内部构建长期卡在锁竞争,则先在独立目标目录编译 wasm,再使用 `--bin-path` 生成: + +```bash +CARGO_TARGET_DIR=server-rs/target-spacetime-bindgen cargo build --manifest-path server-rs/crates/spacetime-module/Cargo.toml --target wasm32-unknown-unknown --release +spacetime generate --no-config --lang rust --out-dir server-rs/crates/spacetime-client/src/module_bindings --bin-path server-rs/target-spacetime-bindgen/wasm32-unknown-unknown/release/spacetime_module.wasm --include-private --yes +``` + +这样做的目的固定如下: + +1. 避开根目录 `spacetime.json` 多 generate target 对单次 Rust 生成的参数冲突。 +2. 避开共享 `target/` 目录被其他任务占锁时的构建阻塞。 +3. 保证 `create_custom_world_agent_session` / `get_custom_world_agent_session` 这类新 procedure 与输入输出类型能同步进入 Rust bindings。 + +## 8. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md) +3. [./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..5f1184ff --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md @@ -0,0 +1,313 @@ +# `M5` 首批 `custom world / agent` 表设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于把 `M5` 从“任务清单”推进到可直接编码的级别。 + +本轮只冻结首批最小可落地表: + +1. `custom_world_profile` +2. `custom_world_session` +3. `custom_world_agent_session` +4. `custom_world_agent_message` +5. `custom_world_agent_operation` +6. `custom_world_draft_card` +7. `custom_world_gallery_entry` + +本轮不直接落 `custom_world_asset_link`。 + +## 2. 本轮为什么不落 `custom_world_asset_link` + +`custom_world_asset_link` 的正式真相必须与: + +1. `asset_object` +2. `asset_entity_binding` +3. `M6 assets / OSS` + +三者的对象定位与业务槽位规则保持一致。 + +当前这些规则在 `custom world` 域还没有完全冻结: + +1. 角色主图槽位 +2. 动作集槽位 +3. 场景图槽位 +4. profile / preview / card 的引用回写规则 + +所以本轮先不硬落,避免在 `M6` 再拆主键和字段。 + +## 3. 设计原则 + +### 3.1 不允许回到单大 JSON 会话 + +当前允许存在边界清晰的 JSON 字符串列,例如: + +1. `draft_profile_json` +2. `result_preview_json` +3. `quality_findings_json` +4. `asset_coverage_json` + +但不允许再把 `message / operation / card` 混回一个整包 `session payload`。 + +### 3.2 发布态 profile 允许先存编译工件 + +`custom_world_profile` 当前承担: + +1. library +2. works 的发布态 +3. publish / unpublish +4. enter-world + +因此允许先保留 `profile_payload_json` 作为正式发布态工件。 + +### 3.3 gallery 单独投影 + +`custom_world_gallery_entry` 作为公开读模型单独建表,不再从 profile 运行时拼装。 + +## 4. 表设计 + +## 4.1 `custom_world_profile` + +### 职责 + +1. 存正式 profile 工件 +2. 承接 library / publish / enter-world 的发布态真相 + +### 访问级别 + +`private` + +### 字段 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `profile_id` | `String` | 是 | 主键 | +| `owner_user_id` | `String` | 是 | 归属用户 | +| `source_agent_session_id` | `Option` | 否 | 来源 Agent 会话 | +| `publication_status` | `CustomWorldPublicationStatus` | 是 | `draft / published` | +| `world_name` | `String` | 是 | 世界名 | +| `subtitle` | `String` | 是 | 副标题 | +| `summary_text` | `String` | 是 | 摘要 | +| `theme_mode` | `CustomWorldThemeMode` | 是 | 主题模式 | +| `cover_image_src` | `Option` | 否 | 封面 | +| `profile_payload_json` | `String` | 是 | 正式 profile JSON | +| `playable_npc_count` | `u32` | 是 | 角色数量摘要 | +| `landmark_count` | `u32` | 是 | 地标数量摘要 | +| `published_at` | `Option` | 否 | 发布时间 | +| `created_at` | `Timestamp` | 是 | 创建时间 | +| `updated_at` | `Timestamp` | 是 | 更新时间 | + +### 索引 + +1. `owner_user_id` +2. `publication_status` + +## 4.2 `custom_world_session` + +### 职责 + +1. 承接旧 `custom-world/sessions` 传统问答流历史兼容 + +### 访问级别 + +`private` + +### 字段 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `session_id` | `String` | 是 | 主键 | +| `owner_user_id` | `String` | 是 | 归属用户 | +| `generation_mode` | `CustomWorldGenerationMode` | 是 | `fast / full` | +| `status` | `CustomWorldSessionStatus` | 是 | 传统问答流状态 | +| `setting_text` | `String` | 是 | 原始设定输入 | +| `creator_intent_json` | `Option` | 否 | creator intent | +| `question_snapshot_json` | `String` | 是 | 问答快照 | +| `result_payload_json` | `Option` | 否 | 编译结果 | +| `last_error_message` | `Option` | 否 | 最近错误 | +| `created_at` | `Timestamp` | 是 | 创建时间 | +| `updated_at` | `Timestamp` | 是 | 更新时间 | + +### 索引 + +1. `owner_user_id` + +## 4.3 `custom_world_agent_session` + +### 职责 + +1. 承接 RPG 创作 Agent 会话真相 +2. 会话级元数据与 preview / readiness / quality / asset coverage + +### 访问级别 + +`private` + +### 字段 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `session_id` | `String` | 是 | 主键 | +| `owner_user_id` | `String` | 是 | 归属用户 | +| `seed_text` | `String` | 是 | 初始输入 | +| `current_turn` | `u32` | 是 | 当前轮次 | +| `progress_percent` | `u32` | 是 | 进度 0~100 | +| `stage` | `RpgAgentStage` | 是 | 当前阶段 | +| `focus_card_id` | `Option` | 否 | 当前焦点卡 | +| `anchor_content_json` | `String` | 是 | 八锚点 | +| `creator_intent_json` | `Option` | 否 | creator intent | +| `creator_intent_readiness_json` | `String` | 是 | readiness | +| `anchor_pack_json` | `Option` | 否 | anchor pack | +| `lock_state_json` | `Option` | 否 | lock state | +| `draft_profile_json` | `Option` | 否 | foundation draft | +| `last_assistant_reply` | `Option` | 否 | 最近回复 | +| `result_preview_json` | `Option` | 否 | 结果页预览 | +| `quality_findings_json` | `String` | 是 | 质量结论 | +| `suggested_actions_json` | `String` | 是 | 建议动作 | +| `recommended_replies_json` | `String` | 是 | 推荐回复 | +| `asset_coverage_json` | `String` | 是 | 资产覆盖率 | +| `checkpoints_json` | `String` | 是 | checkpoint 摘要 | +| `created_at` | `Timestamp` | 是 | 创建时间 | +| `updated_at` | `Timestamp` | 是 | 更新时间 | + +### 索引 + +1. `owner_user_id` +2. `stage` + +## 4.4 `custom_world_agent_message` + +### 职责 + +1. 存消息流 + +### 访问级别 + +`private` + +### 字段 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `message_id` | `String` | 是 | 主键 | +| `session_id` | `String` | 是 | 所属会话 | +| `role` | `RpgAgentMessageRole` | 是 | user / assistant / system | +| `kind` | `RpgAgentMessageKind` | 是 | chat / clarification / summary / checkpoint / warning / action_result | +| `text` | `String` | 是 | 正文 | +| `related_operation_id` | `Option` | 否 | 关联操作 | +| `created_at` | `Timestamp` | 是 | 创建时间 | + +### 索引 + +1. `session_id` + +## 4.5 `custom_world_agent_operation` + +### 职责 + +1. 存异步操作 + +### 访问级别 + +`private` + +### 字段 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `operation_id` | `String` | 是 | 主键 | +| `session_id` | `String` | 是 | 所属会话 | +| `operation_type` | `RpgAgentOperationType` | 是 | 动作类型或 `process_message` | +| `status` | `RpgAgentOperationStatus` | 是 | queued / running / completed / failed | +| `phase_label` | `String` | 是 | 阶段标题 | +| `phase_detail` | `String` | 是 | 阶段说明 | +| `progress` | `u32` | 是 | 进度 0~100 | +| `error_message` | `Option` | 否 | 失败原因 | +| `created_at` | `Timestamp` | 是 | 创建时间 | +| `updated_at` | `Timestamp` | 是 | 更新时间 | + +### 索引 + +1. `session_id` + +## 4.6 `custom_world_draft_card` + +### 职责 + +1. 把 card 从会话主体拆出 +2. 支撑 card detail / update + +### 访问级别 + +`private` + +### 字段 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `card_id` | `String` | 是 | 主键 | +| `session_id` | `String` | 是 | 所属会话 | +| `kind` | `RpgAgentDraftCardKind` | 是 | 卡片类型 | +| `status` | `RpgAgentDraftCardStatus` | 是 | suggested / confirmed / locked / warning | +| `title` | `String` | 是 | 标题 | +| `subtitle` | `String` | 是 | 副标题 | +| `summary` | `String` | 是 | 摘要 | +| `linked_ids_json` | `String` | 是 | 关联对象 ID 列表 | +| `warning_count` | `u32` | 是 | 警告数 | +| `asset_status` | `Option` | 否 | 资产状态 | +| `asset_status_label` | `Option` | 否 | 资产状态文案 | +| `detail_payload_json` | `Option` | 否 | 详情与 section | +| `created_at` | `Timestamp` | 是 | 创建时间 | +| `updated_at` | `Timestamp` | 是 | 更新时间 | + +### 索引 + +1. `session_id` +2. `kind` + +## 4.7 `custom_world_gallery_entry` + +### 职责 + +1. 作为公开画廊投影 + +### 访问级别 + +`public` + +### 字段 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `profile_id` | `String` | 是 | 主键 | +| `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` | 是 | 更新时间 | + +### 索引 + +1. `owner_user_id` +2. `theme_mode` + +## 5. 本轮完成定义 + +当以下条件满足时,本轮 `M5` 首批设计视为完成: + +1. 上述 7 张表字段、索引、访问级别已具体到可直接编码 +2. `module-custom-world` 已成为真实 crate +3. `spacetime-module` 已接入上述表骨架 + +## 6. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) +3. [./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md new file mode 100644 index 00000000..7da37289 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md @@ -0,0 +1,213 @@ +# `M5` custom world Axum facade Stage 5 设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档用于把 `M5` 已落地的 SpacetimeDB custom world 主链,接入 `server-rs/crates/api-server` 的 Axum 兼容接口首批 facade。 + +本轮只冻结: + +1. `custom-world-library` 首批读写接口 +2. `custom-world-gallery` 首批读接口 +3. agent `publish_world` action 的最小 HTTP facade +4. `api-server -> spacetime-client -> spacetime-module procedure` 的调用边界 + +本轮不做: + +1. `DELETE /api/runtime/custom-world-library/:profileId` +2. 完整 agent session create / snapshot / message / SSE +3. `publish gate` / `enter-world gate` blocker 规则迁移 +4. works 聚合读模型 +5. LLM、图片生成、OSS 副作用编排 + +## 2. 当前可复用能力 + +当前 SpacetimeDB module 已有: + +1. `list_custom_world_profiles` +2. `upsert_custom_world_profile_and_return` +3. `publish_custom_world_profile_and_return` +4. `unpublish_custom_world_profile_and_return` +5. `list_custom_world_gallery_entries` +6. `get_custom_world_gallery_detail` +7. `publish_custom_world_world` + +其中 `publish_custom_world_world` 已固定为: + +1. compile published profile +2. upsert profile +3. publish profile +4. 推进 `custom_world_agent_session.stage = published` + +## 3. 本轮冻结的 HTTP 兼容接口 + +### 3.1 library + +本轮接入: + +1. `GET /api/runtime/custom-world-library` +2. `PUT /api/runtime/custom-world-library/:profileId` +3. `POST /api/runtime/custom-world-library/:profileId/publish` +4. `POST /api/runtime/custom-world-library/:profileId/unpublish` + +本轮暂不接: + +1. `DELETE /api/runtime/custom-world-library/:profileId` + +原因: + +1. 当前 `spacetime-module` 还没有冻结 profile delete / soft delete contract +2. 不能在 Axum 层绕开 SpacetimeDB 直接制造第二套删除语义 + +### 3.2 gallery + +本轮接入: + +1. `GET /api/runtime/custom-world-gallery` +2. `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId` + +### 3.3 agent publish action + +本轮接入: + +1. `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` + +但只支持 payload: + +```json +{ "action": "publish_world" } +``` + +其他 agent action 继续返回 `501 NOT_IMPLEMENTED`,等待 agent session / operation / card 主链冻结。 + +## 4. 请求与响应 contract + +### 4.1 library entry 响应 + +继续兼容旧 Node 的 `CustomWorldLibraryEntry` camelCase shape: + +1. `ownerUserId` +2. `profileId` +3. `profile` +4. `visibility` +5. `publishedAt` +6. `updatedAt` +7. `authorDisplayName` +8. `worldName` +9. `subtitle` +10. `summaryText` +11. `coverImageSrc` +12. `themeMode` +13. `playableNpcCount` +14. `landmarkCount` + +### 4.2 `PUT /custom-world-library/:profileId` + +请求 body: + +```json +{ + "profile": {} +} +``` + +字段映射: + +1. `profile` 原样序列化为 `profile_payload_json` +2. 元数据首版由 `api-server` 从 profile JSON 提取: + - `name` + - `subtitle` + - `summary` + - `cover.imageSrc / camp.imageSrc / landmarks[0].imageSrc` + - `themeMode` + - `playableNpcs.length + storyNpcs.length` + - `landmarks.length` + +说明: + +1. 后续更完整的 metadata 推断可以迁回 `module-custom-world` +2. 本轮不把 Node 的全部 `customWorldLibraryMetadata.ts` 逻辑一次性强迁 + +### 4.3 `POST /custom-world-library/:profileId/publish` + +普通 library profile: + +1. 调用 `publish_custom_world_profile_and_return` +2. 返回 `CustomWorldLibraryMutationResponse` + +agent draft profile: + +1. 如果 profileId 形如 `agent-draft-${sessionId}`,但请求没有提供 draft payload,本轮不会在 library publish 里自动从 agent session 重建发布内容 +2. agent 发布主链应优先走 agent action facade + +原因: + +1. Stage 4 的 `publish_custom_world_world` 需要显式传入 `draft_profile_json` +2. session snapshot 主链尚未冻结,library publish 不能私自从 session 大 JSON 读取未定义字段 + +### 4.4 `POST /custom-world/agent/sessions/:sessionId/actions` + +请求 body: + +```json +{ + "action": "publish_world", + "profileId": "agent-draft-session-001", + "draftProfile": {}, + "legacyResultProfile": {}, + "settingText": "..." +} +``` + +说明: + +1. 旧 Node 的 `{ "action": "publish_world" }` 仍是最终目标 +2. 但当前 Rust 还没有完整 session snapshot/read model,所以本轮先要求 facade 调用方显式传入 `draftProfile` +3. 这不是最终产品 contract,后续在 agent session snapshot 迁移后再收口为旧 payload + +响应 body 继续使用旧 action 形状: + +```json +{ + "operation": { + "operationId": "...", + "type": "publish_world", + "status": "completed", + "phaseLabel": "世界已发布", + "phaseDetail": "...", + "progress": 100, + "error": null + } +} +``` + +## 5. `spacetime-client` 边界 + +`api-server` 不直接引用 generated bindings。 + +本轮在 `spacetime-client` 中新增领域侧 record: + +1. `CustomWorldLibraryEntryRecord` +2. `CustomWorldGalleryEntryRecord` +3. `CustomWorldLibraryMutationRecord` +4. `CustomWorldPublishWorldRecord` + +`api-server` 只消费这些 record,再映射到 `shared-contracts` 的 HTTP response。 + +## 6. 完成定义 + +当以下条件满足时,本轮 Stage 5 视为完成: + +1. `shared-contracts` 已补 custom world library/gallery/action 首批响应类型 +2. `spacetime-client` 已封装 custom world procedures +3. `api-server` 已挂载首批 custom world routes +4. `cargo check -p spacetime-client` 通过 +5. `cargo check -p api-server` 通过 +6. `npm run check:encoding` 通过 + +## 7. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md) +3. [./SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md new file mode 100644 index 00000000..6be1d836 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md @@ -0,0 +1,198 @@ +# `M5` custom world library detail Stage 5 扩展设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档用于补齐 `Stage 5` 首批 Axum facade 中遗漏的 owner-only library detail 查询接口: + +1. `GET /api/runtime/custom-world-library/:profileId` + +本轮目标不是扩新系统,而是在当前已经落地的 `custom_world_profile`、`spacetime-client`、`api-server` 基座上补完正式接口台账中的缺口。 + +## 2. 问题背景 + +当前仓库状态已经具备: + +1. `GET /api/runtime/custom-world-library` +2. `PUT /api/runtime/custom-world-library/:profileId` +3. `POST /api/runtime/custom-world-library/:profileId/publish` +4. `POST /api/runtime/custom-world-library/:profileId/unpublish` +5. `GET /api/runtime/custom-world-gallery` +6. `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId` + +但正式接口台账里仍缺: + +1. `GET /api/runtime/custom-world-library/:profileId` + +同时,当前 `spacetime-module` 只有: + +1. `list_custom_world_profiles` +2. `get_custom_world_gallery_detail` + +缺少 owner-only 的单条 profile detail procedure。 + +## 3. 兼容目标 + +### 3.1 查询语义 + +`GET /api/runtime/custom-world-library/:profileId` 只允许查询当前 Bearer 用户自己的 profile。 + +等价语义: + +1. `ownerUserId = authenticated user id` +2. `profileId = path param` +3. 不要求 profile 已发布 +4. 不从 gallery 投影取数,直接以 `custom_world_profile` 真相表为准 + +### 3.2 响应形状 + +响应继续复用 gallery detail 的稳定 shape: + +```json +{ + "entry": { + "ownerUserId": "user_xxx", + "profileId": "profile_xxx", + "profile": {}, + "visibility": "draft", + "publishedAt": null, + "updatedAt": "2026-04-22T00:00:00Z", + "authorDisplayName": "玩家", + "worldName": "潮雾群岛", + "subtitle": "旧航道与失控灯塔", + "summaryText": "......", + "coverImageSrc": null, + "themeMode": "tide", + "playableNpcCount": 2, + "landmarkCount": 3 + } +} +``` + +原因: + +1. 旧前端 detail 消费点本来就只认单个 `entry` +2. 当前 `shared-contracts` 已有 `CustomWorldGalleryDetailResponse { entry }` +3. 没必要为了 owner-only detail 再新增一套重复 envelope + +## 4. SpacetimeDB 设计 + +### 4.1 `module-custom-world` + +新增一个独立输入: + +1. `CustomWorldLibraryDetailInput` + +字段: + +1. `owner_user_id` +2. `profile_id` + +并新增对应校验: + +1. `validate_custom_world_library_detail_input(...)` + +### 4.2 `spacetime-module` + +新增 procedure: + +1. `get_custom_world_library_detail` + +返回继续复用: + +1. `CustomWorldLibraryMutationResult` + +原因: + +1. 当前已有 `entry + gallery_entry + error_message` 组合 +2. owner-only detail 只需要 `entry` +3. `gallery_entry` 在这个接口中固定允许为 `None` + +内部查询规则: + +1. 按 `profile_id` 命中单条 `custom_world_profile` +2. 再校验 `owner_user_id == input.owner_user_id` +3. 命中后返回 `Some(profile_snapshot)` +4. 不存在时返回 `Ok((None, None))` + +这里刻意不在 SpacetimeDB procedure 内把“不存在”包装成失败字符串,避免把“记录缺失”和“procedure 运行失败”混成一个错误通道。 + +## 5. `spacetime-client` 设计 + +新增 facade: + +1. `get_custom_world_library_detail(owner_user_id, profile_id)` + +客户端返回值继续复用: + +1. `CustomWorldLibraryMutationRecord` + +但需要单独增加一个 detail result mapper,在进入 HTTP 层之前就完成: + +1. `result.ok == false` 时返回 procedure error +2. `result.entry == None` 时返回 `SpacetimeClientError::Procedure("custom_world_profile 不存在")` + +这样 `api-server` 可以统一通过已有错误映射把缺失记录转成 `404`。 + +## 6. `api-server` 设计 + +### 6.1 路由 + +把: + +1. `/api/runtime/custom-world-library/{profile_id}` + +从仅支持 `PUT` 扩展为: + +1. `GET` +2. `PUT` + +### 6.2 handler + +新增: + +1. `get_custom_world_library_detail` + +流程: + +1. 读取 Bearer 身份 +2. 校验 `profile_id` 非空 +3. 调 `spacetime_client.get_custom_world_library_detail(...)` +4. 映射为 `CustomWorldGalleryDetailResponse { entry }` + +### 6.3 错误语义 + +本轮明确: + +1. `profileId` 为空 -> `400` +2. 当前用户下找不到 profile -> `404` +3. SpacetimeDB transport / procedure 异常 -> 继续按现有 `502/400` 规则返回 + +## 7. 任务清单同步 + +这轮还需要同步 `backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md`: + +1. 把已落地的 `message stream` 勾为完成 +2. 把 `/agent/sessions/:sessionId/messages/stream` 勾为完成 +3. 在本接口落地后,把 `/api/runtime/custom-world-library/:profileId` 勾为完成 + +## 8. 完成定义 + +当以下条件满足时,本轮扩展视为完成: + +1. 新设计文档已落到 `docs/technical/` +2. `docs/technical/README.md` 已补索引 +3. `module-custom-world` 已新增 library detail input 与校验 +4. `spacetime-module` 已新增 owner-only detail procedure +5. `spacetime-client` 已新增 detail facade +6. `api-server` 已挂 `GET /api/runtime/custom-world-library/:profileId` +7. `cargo check -p api-server` 通过 +8. `npm run check:encoding` 通过 + +## 9. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md) +3. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md) +4. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_MESSAGE_STREAM_STAGE8_DESIGN_2026-04-22.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md new file mode 100644 index 00000000..58eb0035 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md @@ -0,0 +1,275 @@ +# `M5` custom world library / publish / gallery Stage 2 设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于把 `M5` 的下一段从“已有表骨架”推进到“可以直接开始写 reducer / procedure”的级别。 + +本轮只冻结以下最小主链: + +1. `custom_world_profile` upsert +2. `custom_world_profile` publish +3. `custom_world_profile` unpublish +4. `custom_world_gallery_entry` 与发布态的同步规则 +5. library / gallery 的最小 procedure 返回口径 + +本轮仍不进入: + +1. Agent session -> profile 的自动编译 +2. works 聚合读模型 +3. enter-world gate +4. Axum HTTP façade + +## 2. Node 当前口径对齐结论 + +对照当前 Node 实现: + +1. `RpgWorldProfileRepository.ts` +2. `RpgWorldLibraryRepository.ts` +3. `customWorldAgentPublishingService.ts` +4. `packages/shared/src/contracts/runtime.ts` + +可以确认当前正式主链是: + +1. 先 upsert profile +2. publish 时把 profile 元数据重算后写回 +3. published profile 同时作为 gallery 公开读模型来源 +4. unpublish 后从公开画廊移除 + +因此 Rust / SpacetimeDB 首版不需要额外发明一套“发布态缓存”。 + +当前更稳妥的落法是: + +1. `custom_world_profile` 继续作为私有正式作品真相 +2. `custom_world_gallery_entry` 继续作为公开投影 +3. publish / unpublish 明确负责两张表的同步 + +## 3. 当前冻结的 procedure 契约 + +### 3.1 `CustomWorldProfileSnapshot` + +用于承接 library detail / publish / unpublish 的返回对象。 + +字段冻结为: + +1. `profile_id` +2. `owner_user_id` +3. `source_agent_session_id` +4. `publication_status` +5. `world_name` +6. `subtitle` +7. `summary_text` +8. `theme_mode` +9. `cover_image_src` +10. `profile_payload_json` +11. `playable_npc_count` +12. `landmark_count` +13. `author_display_name` +14. `published_at_micros` +15. `created_at_micros` +16. `updated_at_micros` + +说明: + +1. 虽然 `author_display_name` 当前不在 `custom_world_profile` 表骨架里,但 publish / gallery 主链已经需要它。 +2. 因此本轮允许给 `custom_world_profile` 补这一列,避免 publish procedure 只能从外部临时参数现拼。 + +### 3.2 `CustomWorldGalleryEntrySnapshot` + +用于承接 gallery 列表项和 detail 返回项。 + +字段冻结为: + +1. `profile_id` +2. `owner_user_id` +3. `author_display_name` +4. `world_name` +5. `subtitle` +6. `summary_text` +7. `cover_image_src` +8. `theme_mode` +9. `playable_npc_count` +10. `landmark_count` +11. `published_at_micros` +12. `updated_at_micros` + +### 3.3 `CustomWorldLibraryMutationResult` + +用于 upsert / publish / unpublish 的最小 procedure 返回。 + +字段冻结为: + +1. `ok` +2. `entry` +3. `gallery_entry` +4. `error_message` + +说明: + +1. 本轮不直接返回整包 `entries` 列表。 +2. 列表读取交给单独 list procedure,避免每次写入都重复扫描全表。 + +### 3.4 `CustomWorldProfileListResult` + +用于 list own library。 + +字段冻结为: + +1. `ok` +2. `entries` +3. `error_message` + +### 3.5 `CustomWorldGalleryListResult` + +用于 list public gallery。 + +字段冻结为: + +1. `ok` +2. `entries` +3. `error_message` + +## 4. 当前冻结的输入契约 + +### 4.1 `CustomWorldProfileUpsertInput` + +字段冻结为: + +1. `profile_id` +2. `owner_user_id` +3. `source_agent_session_id` +4. `world_name` +5. `subtitle` +6. `summary_text` +7. `theme_mode` +8. `cover_image_src` +9. `profile_payload_json` +10. `playable_npc_count` +11. `landmark_count` +12. `author_display_name` +13. `updated_at_micros` + +规则: + +1. upsert 默认把 `publication_status` 固定为当前行已有值;若不存在则为 `draft` +2. upsert 不直接负责 publish +3. 若 profile 已经处于 `published`,则需要同步刷新 gallery 投影 + +### 4.2 `CustomWorldProfilePublishInput` + +字段冻结为: + +1. `profile_id` +2. `owner_user_id` +3. `author_display_name` +4. `published_at_micros` + +规则: + +1. 目标 profile 必须存在 +2. publish 会把 `publication_status` 改为 `published` +3. publish 会把 `published_at` 与 `updated_at` 一并更新 +4. publish 会 upsert `custom_world_gallery_entry` + +### 4.3 `CustomWorldProfileUnpublishInput` + +字段冻结为: + +1. `profile_id` +2. `owner_user_id` +3. `author_display_name` +4. `updated_at_micros` + +规则: + +1. 目标 profile 必须存在 +2. unpublish 会把 `publication_status` 改为 `draft` +3. unpublish 会清空 `published_at` +4. unpublish 会删除 `custom_world_gallery_entry` + +### 4.4 `CustomWorldProfileListInput` + +字段冻结为: + +1. `owner_user_id` + +### 4.5 `CustomWorldGalleryDetailInput` + +字段冻结为: + +1. `owner_user_id` +2. `profile_id` + +## 5. 表同步规则冻结 + +### 5.1 `custom_world_profile` + +本轮允许补充: + +1. `author_display_name: String` + +原因: + +1. 当前 library / publish / gallery 全链路都依赖作者展示名 +2. 若不存回 profile 真相,publish / unpublish 过程会丢失元数据来源一致性 + +### 5.2 `custom_world_gallery_entry` + +同步规则冻结为: + +1. 仅 `published` profile 允许存在 gallery entry +2. `published_at` 必须与 profile 的正式发布时间一致 +3. `updated_at` 跟随 profile 最新更新时间 +4. `theme_mode / cover / title / subtitle / summary / counts` 全部以 profile 当前元数据为准 + +## 6. reducer / procedure 范围 + +本轮推荐直接落以下入口: + +1. reducer: `upsert_custom_world_profile` +2. procedure: `upsert_custom_world_profile_and_return` +3. reducer: `publish_custom_world_profile` +4. procedure: `publish_custom_world_profile_and_return` +5. reducer: `unpublish_custom_world_profile` +6. procedure: `unpublish_custom_world_profile_and_return` +7. procedure: `list_custom_world_profiles` +8. procedure: `list_custom_world_gallery_entries` +9. procedure: `get_custom_world_gallery_detail` + +说明: + +1. list / detail 当前优先做 procedure,避免先引入 view 设计。 +2. 这些入口足以支撑后续 Axum facade 接 `/runtime/custom-world-library` 和 `/runtime/custom-world-gallery`。 + +## 7. 当前刻意不做 + +本轮明确不做: + +1. `soft delete` +2. `MAX_*` limit 常量与分页 +3. `works` 聚合输出 +4. publish gate blocker 校验 +5. `custom_world_profile` 从 `agent session` 自动编译 +6. `agent session` 发布后自动推进 `published` stage + +这些内容后续会分别进入: + +1. M5 works 主链 +2. M5 agent publish 主链 +3. Axum facade 兼容层 + +## 8. 本轮完成定义 + +当以下条件满足时,本轮 Stage 2 视为完成: + +1. `module-custom-world` 已补齐上述输入 / 输出 snapshot 契约 +2. `spacetime-module` 已补 profile/library/gallery 的 reducer / procedure +3. `cargo check -p spacetime-module` 通过 +4. 文档与任务清单同步更新 + +## 9. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md) +3. [./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md new file mode 100644 index 00000000..93ea0a11 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md @@ -0,0 +1,208 @@ +# `M5` published profile compile Stage 3 设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于把 `M5` 的 “published profile compile” 从 Node 兼容实现推进到 Rust / SpacetimeDB 可直接编码的级别。 + +本轮只冻结: + +1. `agent draft -> published profile payload` 的最小编译边界 +2. 输入输出 contract +3. 和 `custom_world_profile` / `publish` 主链的衔接方式 + +本轮不做: + +1. publish gate blocker 规则迁移 +2. Axum HTTP 接口兼容 +3. works 聚合读模型 +4. `resultPreview` 全量生成主链迁移 + +## 2. 当前 Node 主链结论 + +对照当前实现: + +1. `rpgCreationPreviewProfileBuilder.ts` +2. `RpgWorldPreviewCompiler.ts` +3. `customWorldAgentPublishingService.ts` +4. `runtime-profile/buildCompiledProfile.ts` 及其 normalize 子模块 + +当前发布链不是“把 draft 原样存进去”,而是: + +1. 先从 `draftProfile` 取 foundation draft +2. 如果存在 `legacyResultProfile`,把旧 runtime-rich 字段当作 base profile +3. 用最新草稿资产覆盖 base profile 中的角色 / 地点 / 场景幕资产字段 +4. 再通过 runtime profile compiler 归一化为正式 `CustomWorldProfile` + +因此 Rust 首版不能只做“字段透传”。 + +但当前仓库约束也要求: + +1. 不做大爆炸重写 +2. 优先先把最小可运行主链迁过去 + +所以本轮采用折中方案: + +1. 先在 Rust 里落“编译输入 contract + profile payload 归一化 helper” +2. 首版继续允许 `compiled_profile_payload_json` 作为正式工件 +3. 不在本轮把 Node runtime-profile 的所有细节一次性 1:1 重写为强类型结构 + +## 3. 本轮冻结的输入 contract + +### 3.1 `CustomWorldPublishedProfileCompileInput` + +字段冻结为: + +1. `session_id` +2. `profile_id` +3. `owner_user_id` +4. `draft_profile_json` +5. `legacy_result_profile_json` +6. `setting_text` +7. `author_display_name` +8. `updated_at_micros` + +说明: + +1. `draft_profile_json` 是 foundation draft 的正式输入。 +2. `legacy_result_profile_json` 允许为空,用于兼容 Node 当前 Phase 5 的 runtime-rich 字段回灌。 +3. `setting_text` 单独传入,避免编译阶段再从松散 JSON 猜测。 + +## 4. 本轮冻结的输出 contract + +### 4.1 `CustomWorldPublishedProfileCompileSnapshot` + +字段冻结为: + +1. `profile_id` +2. `owner_user_id` +3. `world_name` +4. `subtitle` +5. `summary_text` +6. `theme_mode` +7. `cover_image_src` +8. `playable_npc_count` +9. `landmark_count` +10. `author_display_name` +11. `compiled_profile_payload_json` +12. `updated_at_micros` + +说明: + +1. 这不是完整 `custom_world_profile` 行快照,而是“编译产物摘要”。 +2. publish / upsert profile reducer 后续直接消费这里的结果写回 `custom_world_profile`。 + +### 4.2 `CustomWorldPublishedProfileCompileResult` + +字段冻结为: + +1. `ok` +2. `record` +3. `error_message` + +## 5. 本轮编译规则冻结 + +### 5.1 最小字段来源规则 + +首版 Rust 编译先固定以下字段: + +1. `world_name` + - 优先 `draft_profile.name` + - 否则 `legacy_result_profile.name` +2. `subtitle` + - 优先 `draft_profile.subtitle` + - 否则 `legacy_result_profile.subtitle` +3. `summary_text` + - 优先 `draft_profile.summary` + - 否则 `legacy_result_profile.summary` +4. `cover_image_src` + - 优先 `draft_profile.camp.imageSrc` + - 否则首个 `landmark.imageSrc` + - 否则 `legacy_result_profile.cover.imageSrc` +5. `playable_npc_count` + - `draft_profile.playableNpcs + draft_profile.storyNpcs` 去重后的总量 +6. `landmark_count` + - `draft_profile.landmarks.length` + +### 5.2 `theme_mode` 映射规则 + +首版 Rust 不强行完整复刻 Node 的主题推断器,而是采用稳定回退: + +1. 如果 `legacy_result_profile` 中已有合法主题枚举,则沿用 +2. 否则默认 `CustomWorldThemeMode::Mythic` + +原因: + +1. 当前 shared contract 中 `themeMode` 已经是摘要元数据,不影响 runtime profile 主体结构 +2. 真正复杂的主题推断不应在没有专项文档的情况下直接硬迁 + +### 5.3 payload 输出规则 + +本轮 `compiled_profile_payload_json` 先采用: + +1. 把 `legacy_result_profile_json` 解析为 base object +2. 把 `draft_profile_json` 中的关键字段覆盖到 base object +3. 至少保证以下键被同步: + - `id` + - `settingText` + - `name` + - `subtitle` + - `summary` + - `playableNpcs` + - `storyNpcs` + - `landmarks` + - `camp` + - `sceneChapterBlueprints` + - `updatedAt` + +当前刻意不做: + +1. 完整 runtime-profile normalize +2. attribute schema 重建 +3. theme pack / story graph / knowledge facts 的智能合并 + +这些继续留待后续 Stage 4 单独迁移。 + +## 6. 与 `custom_world_profile` 的衔接方式 + +本轮推荐新增: + +1. `module-custom-world`: + - `CustomWorldPublishedProfileCompileInput` + - `CustomWorldPublishedProfileCompileSnapshot` + - `CustomWorldPublishedProfileCompileResult` +2. `spacetime-module`: + - `compile_custom_world_published_profile` + - `compile_custom_world_published_profile_and_return` + +当前策略: + +1. compile procedure 只产出编译结果,不直接写表 +2. publish 主链下一步可以显式执行: + - compile + - upsert profile + - publish profile + +这样可以保持: + +1. 编译 +2. 持久化 +3. 发布 + +三段职责清晰分离。 + +## 7. 本轮完成定义 + +当以下条件满足时,本轮 Stage 3 视为完成: + +1. `module-custom-world` 已补编译输入输出 contract +2. `spacetime-module` 已新增 compile procedure +3. compile procedure 能基于最小字段规则返回合法 profile payload 与摘要 +4. `cargo check -p spacetime-module` 通过 + +## 8. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md) +3. [./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md new file mode 100644 index 00000000..220c2240 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md @@ -0,0 +1,177 @@ +# `M5` publish_world Stage 4 设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于把 `M5` 里的 `publish_world` 动作,从 Node 兼容实现推进到 Rust / SpacetimeDB 可直接编码的最小主链级别。 + +本轮只冻结: + +1. `publish_world` 的输入输出 contract +2. `compile -> upsert profile -> publish profile` 的事务顺序 +3. `custom_world_agent_session.stage` 推进到 `published` 的最小写法 + +本轮不做: + +1. `publish gate` / `enter-world gate` blocker 规则迁移 +2. `qualityFindings` 的 blocker 清洗与 warning 摘要回写 +3. Axum HTTP 接口兼容 +4. works 聚合读模型 +5. 资产对象绑定、OSS、封面上传等 `M6` 相关能力 + +## 2. 当前 Node 主链结论 + +对照当前 Node 链路: + +1. `customWorldAgentActionExecutors/publishWorldExecutor.ts` +2. `customWorldAgentPublishingService.ts` + +当前 `publish_world` 的职责可拆成两段: + +1. 发布门禁校验 +2. 发布写入串联 + +其中发布写入串联又固定为: + +1. 基于当前 session 草稿构建 publish readiness +2. 生成正式 published profile +3. 写入 repository / library +4. 推进 session stage 到 `published` + +Rust / SpacetimeDB 这一轮只迁第 2 段,也就是“发布写入串联”。 + +原因: + +1. `publish gate` 当前仍依赖 `qualityFindings`、角色资产覆盖率、场景图资产完整度等一系列未冻结的 blocker 口径 +2. 如果这一轮把 gate 一起硬迁,会把后续 `M5/M6` 的资产和 blocker 设计提前锁死 +3. 当前最需要的是先把 `published profile compile + library + publish + session stage` 串成一个可复用的后端真相主链 + +## 3. 本轮冻结的输入 contract + +### 3.1 `CustomWorldPublishWorldInput` + +字段冻结为: + +1. `session_id` +2. `profile_id` +3. `owner_user_id` +4. `draft_profile_json` +5. `legacy_result_profile_json` +6. `setting_text` +7. `author_display_name` +8. `published_at_micros` + +说明: + +1. 当前仍延续 Stage 2 / Stage 3 的显式 `owner_user_id` 传参口径,后续等 OIDC claims 真正确认后再切到 `ctx.sender()` 授权真相。 +2. `draft_profile_json` 与 `legacy_result_profile_json` 直接复用 Stage 3 compile 的输入来源,避免这一轮再新增 session 内部 JSON 解析耦合。 +3. `published_at_micros` 同时作为: + - profile publish 的 `published_at` + - profile upsert 的 `updated_at` + - session stage 推进的 `updated_at` + +当前不额外引入: + +1. `quality_findings_json` +2. `publish_gate_summary` +3. `result_preview_json` + +这些都留到后续 gate / works 迁移时再单独冻结。 + +## 4. 本轮冻结的输出 contract + +### 4.1 `CustomWorldPublishWorldResult` + +字段冻结为: + +1. `ok` +2. `compiled_record` +3. `entry` +4. `gallery_entry` +5. `session_stage` +6. `error_message` + +说明: + +1. `compiled_record` 返回 Stage 3 编译产物,方便 Axum 或后续 facade 直接复用,不必再次 compile。 +2. `entry` 返回最新 `custom_world_profile` 快照。 +3. `gallery_entry` 返回同步后的公开 gallery 投影。 +4. `session_stage` 当前只返回最终阶段枚举,最小闭环固定为 `published`。 + +## 5. 串联顺序冻结 + +本轮 `publish_world` procedure 固定按以下顺序执行: + +1. 先基于 `CustomWorldPublishWorldInput` 构造 Stage 3 compile 输入 +2. 调用 `build_custom_world_published_profile_compile_snapshot(...)` +3. 将 compile 结果映射成 `CustomWorldProfileUpsertInput` +4. 调用 `upsert_custom_world_profile_record(...)` +5. 调用 `publish_custom_world_profile_record(...)` +6. 根据 `session_id + owner_user_id` 查找 `custom_world_agent_session` +7. 将 session 的 `stage` 推进到 `RpgAgentStage::Published` +8. 返回 compile 结果、profile 快照、gallery 快照与最终 session stage + +### 5.1 为什么先 upsert 再 publish + +因为当前 Stage 2 已经冻结了两段职责: + +1. `upsert_custom_world_profile_record(...)` + - 负责把 profile 最新内容写回正式表 +2. `publish_custom_world_profile_record(...)` + - 负责把 profile 状态切到 `published` + - 同步 gallery 公开投影 + +Stage 4 不应绕开这两段既有入口重写一遍 publish 逻辑,否则会制造第二套 profile / gallery 写入口。 + +## 6. session stage 推进规则 + +本轮最小冻结规则: + +1. 只推进 `custom_world_agent_session.stage = published` +2. 其余字段保持原样 +3. `updated_at` 使用 `published_at_micros` + +本轮刻意不做: + +1. `quality_findings_json` 删除 blocker +2. `supportedActions` / `suggestedActions` 重算 +3. `progress_percent` 二次推断 + +原因: + +1. 当前 `custom_world_agent_session` 还没有冻结完整的 action registry / supportedActions 主链 +2. 如果这轮顺手改太多 session 派生字段,后面 Axum / agent 主链接入时容易和旧 Node 行为再次漂移 + +## 7. 与 Node 当前语义的最小对齐边界 + +这一轮只要求对齐以下事实: + +1. session draft 可以被编译成正式 published profile +2. 正式 profile 会写入 `custom_world_profile` +3. publish 后 gallery 有公开投影 +4. session 最终会进入 `published` 阶段 + +这一轮不要求 1:1 对齐: + +1. blocker message 文案 +2. publish readiness 的细粒度缺失项列表 +3. session 消息追加与 checkpoint 追加 +4. warning 数量摘要 + +## 8. 本轮完成定义 + +当以下条件满足时,本轮 Stage 4 视为完成: + +1. `module-custom-world` 已补 `CustomWorldPublishWorldInput / Result` +2. `spacetime-module` 已新增 `publish_world` procedure +3. procedure 能复用 Stage 2 / Stage 3 helper 串联 compile、upsert、publish +4. procedure 能把 `custom_world_agent_session.stage` 推进到 `published` +5. `cargo test -p module-custom-world` 通过 +6. `cargo check -p spacetime-module` 通过 + +## 9. 相关文档 + +1. [../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +2. [./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md) +3. [./SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](./SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md) diff --git a/scripts/check-encoding.mjs b/scripts/check-encoding.mjs index 4c0c7d00..0068e289 100644 --- a/scripts/check-encoding.mjs +++ b/scripts/check-encoding.mjs @@ -16,6 +16,7 @@ const TEXT_EXTENSIONS = new Set([ '.mjs', '.ps1', '.py', + '.rs', '.scss', '.sh', '.toml', @@ -40,12 +41,15 @@ const TEXT_FILENAMES = new Set([ const EXCLUDED_PREFIXES = [ '.codex-logs/', '.git/', + '.codex-cargo-home-', 'dist/', 'dist_check/', 'dist_check_monster_position/', 'media/', 'node_modules/', 'public/Icons/', + 'server-rs-codex-', + 'server-rs/target-', ]; const IGNORE_FILE = '.encoding-check-ignore'; @@ -58,6 +62,7 @@ function normalizePath(filePath) { function shouldCheck(filePath) { const normalizedPath = normalizePath(filePath); + // 本地 cargo cache / verify copy 不属于主工程源码,避免把临时工作区扫进仓库级编码检查。 if (EXCLUDED_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) { return false; } diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 4a50f7d9..1528fb93 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -75,14 +75,25 @@ dependencies = [ "hmac", "http-body-util", "httpdate", + "module-ai", "module-assets", "module-auth", + "module-combat", + "module-custom-world", + "module-inventory", + "module-npc", + "module-runtime", + "module-runtime-item", + "module-story", "platform-auth", + "platform-llm", "platform-oss", "reqwest", "serde", "serde_json", "sha1", + "shared-contracts", + "shared-kernel", "shared-logging", "spacetime-client", "time", @@ -1382,6 +1393,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "module-ai" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "module-assets" version = "0.1.0" @@ -1389,6 +1410,7 @@ dependencies = [ "platform-oss", "reqwest", "serde", + "shared-kernel", "spacetimedb", ] @@ -1397,11 +1419,96 @@ name = "module-auth" version = "0.1.0" dependencies = [ "platform-auth", + "shared-kernel", "time", "tokio", "uuid", ] +[[package]] +name = "module-combat" +version = "0.1.0" +dependencies = [ + "module-runtime-item", + "serde", + "shared-kernel", + "spacetimedb", +] + +[[package]] +name = "module-custom-world" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "spacetimedb", +] + +[[package]] +name = "module-inventory" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + +[[package]] +name = "module-npc" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + +[[package]] +name = "module-progression" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + +[[package]] +name = "module-quest" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + +[[package]] +name = "module-runtime" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", + "time", +] + +[[package]] +name = "module-runtime-item" +version = "0.1.0" +dependencies = [ + "module-inventory", + "serde", + "shared-kernel", + "spacetimedb", +] + +[[package]] +name = "module-story" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1605,12 +1712,24 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2", + "shared-kernel", "time", "tokio", "urlencoding", "uuid", ] +[[package]] +name = "platform-llm" +version = "0.1.0" +dependencies = [ + "log", + "reqwest", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "platform-oss" version = "0.1.0" @@ -1947,12 +2066,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -2273,6 +2394,23 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared-contracts" +version = "0.1.0" +dependencies = [ + "platform-oss", + "serde", + "serde_json", +] + +[[package]] +name = "shared-kernel" +version = "0.1.0" +dependencies = [ + "time", + "uuid", +] + [[package]] name = "shared-logging" version = "0.1.0" @@ -2346,7 +2484,17 @@ dependencies = [ name = "spacetime-client" version = "0.1.0" dependencies = [ + "module-ai", "module-assets", + "module-combat", + "module-custom-world", + "module-inventory", + "module-npc", + "module-runtime", + "module-runtime-item", + "module-story", + "serde_json", + "shared-kernel", "spacetimedb-sdk", "tokio", ] @@ -2356,7 +2504,17 @@ name = "spacetime-module" version = "0.1.0" dependencies = [ "log", + "module-ai", "module-assets", + "module-combat", + "module-custom-world", + "module-inventory", + "module-npc", + "module-progression", + "module-quest", + "module-runtime", + "module-runtime-item", + "module-story", "spacetimedb", ] @@ -2889,6 +3047,19 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3276,6 +3447,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index b9efbb85..843a3008 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -5,10 +5,23 @@ resolver = "2" members = [ "crates/api-server", + "crates/module-ai", "crates/module-assets", "crates/module-auth", + "crates/module-combat", + "crates/module-inventory", + "crates/module-custom-world", + "crates/module-npc", + "crates/module-progression", + "crates/module-quest", + "crates/module-runtime", + "crates/module-runtime-item", + "crates/module-story", "crates/platform-oss", "crates/platform-auth", + "crates/platform-llm", + "crates/shared-contracts", + "crates/shared-kernel", "crates/shared-logging", "crates/spacetime-client", "crates/spacetime-module", diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 3473903f..f8748289 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -8,12 +8,23 @@ license.workspace = true axum = "0.8" dotenvy = "0.15" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +module-ai = { path = "../module-ai" } module-assets = { path = "../module-assets" } module-auth = { path = "../module-auth" } +module-combat = { path = "../module-combat" } +module-custom-world = { path = "../module-custom-world" } +module-inventory = { path = "../module-inventory" } +module-npc = { path = "../module-npc" } +module-runtime = { path = "../module-runtime" } +module-runtime-item = { path = "../module-runtime-item" } +module-story = { path = "../module-story" } platform-auth = { path = "../platform-auth" } +platform-llm = { path = "../platform-llm" } platform-oss = { path = "../platform-oss" } serde = { version = "1", features = ["derive"] } serde_json = "1" +shared-contracts = { path = "../shared-contracts" } +shared-kernel = { path = "../shared-kernel" } shared-logging = { path = "../shared-logging" } spacetime-client = { path = "../spacetime-client" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 553fbb3f..b834bee3 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -42,6 +42,8 @@ 20. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路 21. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路 22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract +23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade +24. 接入 custom world agent `session create / session snapshot` Axum facade 后续与本 crate 直接相关的任务包括: @@ -64,6 +66,8 @@ 17. [x] 接入 `/api/auth/wechat/bind-phone` 18. [x] 接入 `/api/assets/objects/bind` 19. [x] 接入 `/api/assets/sts-upload-credentials` +20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade +21. [x] 接入 `custom world agent session create / snapshot` facade 当前 tracing 约定: @@ -131,3 +135,4 @@ 11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。 12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。 13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。 +14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。 diff --git a/server-rs/crates/api-server/src/ai_tasks.rs b/server-rs/crates/api-server/src/ai_tasks.rs new file mode 100644 index 00000000..9725820a --- /dev/null +++ b/server-rs/crates/api-server/src/ai_tasks.rs @@ -0,0 +1,674 @@ +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use module_ai::{ + AiResultReferenceInput, AiResultReferenceKind, AiStageCompletionInput, AiTaskCancelInput, + AiTaskCreateInput, AiTaskFailureInput, AiTaskFinishInput, AiTaskKind, AiTaskStageBlueprint, + AiTaskStageKind, AiTaskStageStartInput, AiTaskStartInput, AiTextChunkAppendInput, + generate_ai_task_id, +}; +use serde_json::{Value, json}; +use shared_contracts::ai::{ + AiResultReferencePayload, AiTaskAcceptedResponse, AiTaskMutationResponse, AiTaskPayload, + AiTaskStagePayload, AiTextChunkPayload, AppendAiTextChunkRequest, + AttachAiResultReferenceRequest, CompleteAiStageRequest, CreateAiTaskRequest, FailAiTaskRequest, +}; +use spacetime_client::{AiTaskMutationRecord, SpacetimeClientError}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn create_ai_task( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let now_micros = current_utc_micros(); + let task_kind = parse_ai_task_kind_strict(&payload.task_kind).ok_or_else(|| { + ai_tasks_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "ai-task", + "message": "taskKind 非法", + })), + ) + })?; + let stages = build_stage_blueprints(task_kind, payload.stage_kinds, &request_context)?; + let owner_user_id = authenticated.claims().user_id().to_string(); + + let result = state + .spacetime_client() + .create_ai_task(AiTaskCreateInput { + task_id: generate_ai_task_id(now_micros), + task_kind, + owner_user_id, + request_label: payload.request_label, + source_module: payload.source_module, + source_entity_id: payload.source_entity_id, + request_payload_json: payload.request_payload_json, + stages, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + build_ai_task_mutation_response(result), + )) +} + +pub async fn start_ai_task( + State(state): State, + Path(task_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result { + state + .spacetime_client() + .start_ai_task(AiTaskStartInput { + task_id: task_id.clone(), + started_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(ai_task_accepted_response( + &request_context, + AiTaskAcceptedResponse { + accepted: true, + task_id, + action: "start_task".to_string(), + stage_kind: None, + }, + )) +} + +pub async fn start_ai_task_stage( + State(state): State, + Path((task_id, stage_kind_text)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result { + let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| { + ai_tasks_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "ai-task-stage", + "message": "stageKind 非法", + })), + ) + })?; + + state + .spacetime_client() + .start_ai_task_stage(AiTaskStageStartInput { + task_id: task_id.clone(), + stage_kind, + started_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(ai_task_accepted_response( + &request_context, + AiTaskAcceptedResponse { + accepted: true, + task_id, + action: "start_stage".to_string(), + stage_kind: Some(stage_kind.as_str().to_string()), + }, + )) +} + +pub async fn append_ai_text_chunk( + State(state): State, + Path(task_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let stage_kind = parse_ai_task_stage_kind_strict(&payload.stage_kind).ok_or_else(|| { + ai_tasks_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "ai-task-stage", + "message": "stageKind 非法", + })), + ) + })?; + + let result = state + .spacetime_client() + .append_ai_text_chunk(AiTextChunkAppendInput { + task_id, + stage_kind, + sequence: payload.sequence, + delta_text: payload.delta_text, + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + build_ai_task_mutation_response(result), + )) +} + +pub async fn complete_ai_stage( + State(state): State, + Path((task_id, stage_kind_text)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| { + ai_tasks_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "ai-task-stage", + "message": "stageKind 非法", + })), + ) + })?; + + let result = state + .spacetime_client() + .complete_ai_stage(AiStageCompletionInput { + task_id, + stage_kind, + text_output: payload.text_output, + structured_payload_json: payload.structured_payload_json, + warning_messages: payload.warning_messages, + completed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + build_ai_task_mutation_response(result), + )) +} + +pub async fn attach_ai_result_reference( + State(state): State, + Path(task_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let reference_kind = parse_ai_result_reference_kind_strict(&payload.reference_kind) + .ok_or_else(|| { + ai_tasks_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "ai-task-reference", + "message": "referenceKind 非法", + })), + ) + })?; + + let result = state + .spacetime_client() + .attach_ai_result_reference(AiResultReferenceInput { + task_id, + reference_kind, + reference_id: payload.reference_id, + label: payload.label, + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + build_ai_task_mutation_response(result), + )) +} + +pub async fn complete_ai_task( + State(state): State, + Path(task_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + let result = state + .spacetime_client() + .complete_ai_task(AiTaskFinishInput { + task_id, + completed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + build_ai_task_mutation_response(result), + )) +} + +pub async fn fail_ai_task( + State(state): State, + Path(task_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let result = state + .spacetime_client() + .fail_ai_task(AiTaskFailureInput { + task_id, + failure_message: payload.failure_message, + completed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + build_ai_task_mutation_response(result), + )) +} + +pub async fn cancel_ai_task( + State(state): State, + Path(task_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + let result = state + .spacetime_client() + .cancel_ai_task(AiTaskCancelInput { + task_id, + completed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + ai_tasks_error_response(&request_context, map_ai_task_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + build_ai_task_mutation_response(result), + )) +} + +fn build_stage_blueprints( + task_kind: AiTaskKind, + stage_kinds: Vec, + request_context: &RequestContext, +) -> Result, Response> { + if stage_kinds.is_empty() { + return Ok(task_kind.default_stage_blueprints()); + } + + stage_kinds + .into_iter() + .enumerate() + .map(|(index, stage_kind_text)| { + let stage_kind = + parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| { + ai_tasks_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "ai-task-stage", + "message": format!("stageKinds[{index}] 非法"), + })), + ) + })?; + + Ok(AiTaskStageBlueprint { + stage_kind, + label: stage_kind.default_label().to_string(), + detail: stage_kind.default_detail().to_string(), + order: index as u32, + }) + }) + .collect() +} + +fn build_ai_task_mutation_response(record: AiTaskMutationRecord) -> AiTaskMutationResponse { + AiTaskMutationResponse { + ai_task: build_ai_task_payload(record.task), + ai_text_chunk: record.text_chunk.map(build_ai_text_chunk_payload), + } +} + +fn build_ai_task_payload(record: spacetime_client::AiTaskRecord) -> AiTaskPayload { + AiTaskPayload { + task_id: record.task_id, + task_kind: record.task_kind, + owner_user_id: record.owner_user_id, + request_label: record.request_label, + source_module: record.source_module, + source_entity_id: record.source_entity_id, + request_payload_json: record.request_payload_json, + status: record.status, + failure_message: record.failure_message, + stages: record + .stages + .into_iter() + .map(build_ai_task_stage_payload) + .collect(), + result_references: record + .result_references + .into_iter() + .map(build_ai_result_reference_payload) + .collect(), + latest_text_output: record.latest_text_output, + latest_structured_payload_json: record.latest_structured_payload_json, + version: record.version, + created_at: record.created_at, + started_at: record.started_at, + completed_at: record.completed_at, + updated_at: record.updated_at, + } +} + +fn build_ai_task_stage_payload(record: spacetime_client::AiTaskStageRecord) -> AiTaskStagePayload { + AiTaskStagePayload { + stage_kind: record.stage_kind, + label: record.label, + detail: record.detail, + order: record.order, + status: record.status, + text_output: record.text_output, + structured_payload_json: record.structured_payload_json, + warning_messages: record.warning_messages, + started_at: record.started_at, + completed_at: record.completed_at, + } +} + +fn build_ai_result_reference_payload( + record: spacetime_client::AiResultReferenceRecord, +) -> AiResultReferencePayload { + AiResultReferencePayload { + result_ref_id: record.result_ref_id, + task_id: record.task_id, + reference_kind: record.reference_kind, + reference_id: record.reference_id, + label: record.label, + created_at: record.created_at, + } +} + +fn build_ai_text_chunk_payload(record: spacetime_client::AiTextChunkRecord) -> AiTextChunkPayload { + AiTextChunkPayload { + chunk_id: record.chunk_id, + task_id: record.task_id, + stage_kind: record.stage_kind, + sequence: record.sequence, + delta_text: record.delta_text, + created_at: record.created_at, + } +} + +fn parse_ai_task_kind_strict(value: &str) -> Option { + match value.trim() { + "story_generation" => Some(AiTaskKind::StoryGeneration), + "character_chat" => Some(AiTaskKind::CharacterChat), + "npc_chat" => Some(AiTaskKind::NpcChat), + "custom_world_generation" => Some(AiTaskKind::CustomWorldGeneration), + "quest_intent" => Some(AiTaskKind::QuestIntent), + "runtime_item_intent" => Some(AiTaskKind::RuntimeItemIntent), + _ => None, + } +} + +fn parse_ai_task_stage_kind_strict(value: &str) -> Option { + match value.trim() { + "prepare_prompt" => Some(AiTaskStageKind::PreparePrompt), + "request_model" => Some(AiTaskStageKind::RequestModel), + "repair_response" => Some(AiTaskStageKind::RepairResponse), + "normalize_result" => Some(AiTaskStageKind::NormalizeResult), + "persist_result" => Some(AiTaskStageKind::PersistResult), + _ => None, + } +} + +fn parse_ai_result_reference_kind_strict(value: &str) -> Option { + match value.trim() { + "story_session" => Some(AiResultReferenceKind::StorySession), + "story_event" => Some(AiResultReferenceKind::StoryEvent), + "custom_world_profile" => Some(AiResultReferenceKind::CustomWorldProfile), + "quest_record" => Some(AiResultReferenceKind::QuestRecord), + "runtime_item_record" => Some(AiResultReferenceKind::RuntimeItemRecord), + "asset_object" => Some(AiResultReferenceKind::AssetObject), + _ => None, + } +} + +fn map_ai_task_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn ai_tasks_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +fn ai_task_accepted_response( + request_context: &RequestContext, + payload: AiTaskAcceptedResponse, +) -> Response { + let mut response = json_success_body(Some(request_context), payload).into_response(); + *response.status_mut() = StatusCode::ACCEPTED; + response +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::{Value, json}; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn create_ai_task_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/ai/tasks") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "taskKind": "story_generation", + "requestLabel": "营地开场", + "sourceModule": "story" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn create_ai_task_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/ai/tasks") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "taskKind": "npc_chat", + "requestLabel": "试探问话", + "sourceModule": "npc" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn start_ai_task_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/ai/tasks/aitask_001/start") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn start_ai_task_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/ai/tasks/aitask_001/start") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "ai_tasks_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_ai_tasks".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("AI 任务用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/api_response.rs b/server-rs/crates/api-server/src/api_response.rs index 4a0657f7..35a8bc64 100644 --- a/server-rs/crates/api-server/src/api_response.rs +++ b/server-rs/crates/api-server/src/api_response.rs @@ -1,25 +1,15 @@ use axum::Json; use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::Value; +#[cfg(test)] +use serde_json::json; +use shared_contracts::api::{ + API_VERSION, ApiErrorEnvelope, ApiErrorPayload, ApiResponseMeta, ApiSuccessEnvelope, + LegacyApiErrorResponse, +}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use crate::{http_error::ApiErrorPayload, request_context::RequestContext}; - -pub const API_VERSION: &str = "2026-04-08"; - -#[derive(Debug, Serialize)] -struct ApiResponseMeta { - #[serde(rename = "apiVersion")] - api_version: &'static str, - #[serde(rename = "requestId", skip_serializing_if = "Option::is_none")] - request_id: Option, - #[serde(rename = "routeVersion")] - route_version: &'static str, - operation: Option, - #[serde(rename = "latencyMs")] - latency_ms: u64, - timestamp: String, -} +use crate::request_context::RequestContext; // 当前阶段先把成功响应 envelope helper 准备好,后续 `/healthz` 与业务 handler 会直接复用这里的输出逻辑。 #[allow(dead_code)] @@ -30,12 +20,13 @@ where if let Some(context) = request_context && context.wants_envelope() { - return Json(json!({ - "ok": true, - "data": data, - "error": null, - "meta": build_api_response_meta(Some(context)), - })); + return Json( + serde_json::to_value(ApiSuccessEnvelope::new( + data, + build_api_response_meta(Some(context)), + )) + .unwrap_or(Value::Null), + ); } Json(serde_json::to_value(data).unwrap_or(Value::Null)) @@ -48,33 +39,30 @@ pub fn json_error_body( let meta = build_api_response_meta(request_context); if request_context.is_some_and(RequestContext::wants_envelope) { - return Json(json!({ - "ok": false, - "data": null, - "error": error, - "meta": meta, - })); + return Json( + serde_json::to_value(ApiErrorEnvelope::new(error.clone(), meta)).unwrap_or(Value::Null), + ); } - Json(json!({ - "error": error, - "meta": meta, - })) + Json( + serde_json::to_value(LegacyApiErrorResponse::new(error.clone(), meta)) + .unwrap_or(Value::Null), + ) } fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiResponseMeta { - ApiResponseMeta { - api_version: API_VERSION, - request_id: request_context.map(|context| context.request_id().to_string()), - route_version: API_VERSION, - operation: request_context.map(|context| context.operation().to_string()), - latency_ms: request_context + ApiResponseMeta::new( + API_VERSION, + request_context.map(|context| context.request_id().to_string()), + API_VERSION, + request_context.map(|context| context.operation().to_string()), + request_context .map(RequestContext::elapsed) .unwrap_or_default(), - timestamp: OffsetDateTime::now_utc() + OffsetDateTime::now_utc() .format(&Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()), - } + ) } #[cfg(test)] @@ -122,7 +110,7 @@ mod tests { fn error_body_returns_legacy_shape_without_envelope_header() { let request_context = build_request_context(false); let error = ApiErrorPayload { - code: "NOT_FOUND", + code: "NOT_FOUND".to_string(), message: "资源不存在".to_string(), details: None, }; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c7f93f6d..3c177a0f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -10,6 +10,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T use tracing::{Level, info_span}; use crate::{ + ai_tasks::{ + append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, + complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, + }, assets::{ bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket, create_sts_upload_credentials, get_asset_read_url, @@ -20,8 +24,18 @@ use crate::{ }, auth_me::auth_me, auth_sessions::auth_sessions, + custom_world::{ + create_custom_world_agent_session, execute_custom_world_agent_action, + get_custom_world_agent_operation, get_custom_world_agent_session, + get_custom_world_gallery_detail, get_custom_world_library, + get_custom_world_library_detail, list_custom_world_gallery, + publish_custom_world_library_profile, put_custom_world_library_profile, + stream_custom_world_agent_message, submit_custom_world_agent_message, + unpublish_custom_world_library_profile, + }, error_middleware::normalize_error_response, health::health_check, + llm::proxy_llm_chat_completions, login_options::auth_login_options, logout::logout, logout_all::logout_all, @@ -30,7 +44,18 @@ use crate::{ refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, + runtime_browse_history::{ + delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, + }, + runtime_inventory::get_runtime_inventory_state, + runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger}, + runtime_settings::{get_runtime_settings, put_runtime_settings}, + runtime_story::resolve_runtime_story_state, state::AppState, + story_battles::{ + create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle, + }, + story_sessions::{begin_story_session, continue_story, get_story_session_state}, wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, }; @@ -95,6 +120,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/llm/chat/completions", + post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/auth/logout", post(logout) @@ -114,6 +146,69 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/ai/tasks", + post(create_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/start", + post(start_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/stages/{stage_kind}/start", + post(start_ai_task_stage).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/chunks", + post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/stages/{stage_kind}/complete", + post(complete_ai_stage).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/references", + post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/complete", + post(complete_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/fail", + post(fail_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/ai/tasks/{task_id}/cancel", + post(cancel_ai_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/assets/direct-upload-tickets", post(create_direct_upload_ticket), @@ -128,6 +223,219 @@ pub fn build_router(state: AppState) -> Router { post(bind_asset_object_to_entity), ) .route("/api/assets/read-url", get(get_asset_read_url)) + .route( + "/api/runtime/settings", + get(get_runtime_settings) + .put(put_runtime_settings) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world-library", + get(get_custom_world_library).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world-library/{profile_id}", + get(get_custom_world_library_detail) + .put(put_custom_world_library_profile) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world-library/{profile_id}/publish", + post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world-library/{profile_id}/unpublish", + post(unpublish_custom_world_library_profile).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/custom-world-gallery", + get(list_custom_world_gallery), + ) + .route( + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}", + get(get_custom_world_gallery_detail), + ) + .route( + "/api/runtime/custom-world/agent/sessions", + post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/agent/sessions/{session_id}", + get(get_custom_world_agent_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/agent/sessions/{session_id}/messages", + post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream", + post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/agent/sessions/{session_id}/actions", + post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}", + get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/profile/browse-history", + get(get_runtime_browse_history) + .post(post_runtime_browse_history) + .delete(delete_runtime_browse_history) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/browse-history", + get(get_runtime_browse_history) + .post(post_runtime_browse_history) + .delete(delete_runtime_browse_history) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/profile/dashboard", + get(get_profile_dashboard).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/dashboard", + get(get_profile_dashboard).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/profile/wallet-ledger", + get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/wallet-ledger", + get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/profile/play-stats", + get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/sessions/{runtime_session_id}/inventory", + get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/story/state/resolve", + post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/play-stats", + get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/story/sessions", + post(begin_story_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/story/sessions/{story_session_id}/state", + get(get_story_session_state).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/story/sessions/continue", + post(continue_story).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/story/battles", + post(create_story_battle).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/story/battles/{battle_state_id}", + get(get_story_battle_state).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/story/npc/battle", + post(create_story_npc_battle).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/story/battles/resolve", + post(resolve_story_battle).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route("/api/auth/entry", post(password_entry)) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 278d1356..f26c0291 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -1,5 +1,3 @@ -use std::collections::BTreeMap; - use axum::{ Json, extract::{Extension, Query, State}, @@ -14,8 +12,13 @@ use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPostObjectRequest, OssSignedGetObjectUrlRequest, }; -use serde::Deserialize; use serde_json::{Value, json}; +use shared_contracts::assets::{ + AssetBindingPayload, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, + BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, + ConfirmAssetObjectResponse, CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, + DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery, +}; use spacetime_client::SpacetimeClientError; use crate::{ @@ -23,84 +26,6 @@ use crate::{ state::AppState, }; -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateDirectUploadTicketRequest { - pub legacy_prefix: String, - #[serde(default)] - pub path_segments: Vec, - pub file_name: String, - #[serde(default)] - pub content_type: Option, - #[serde(default)] - pub access: Option, - #[serde(default)] - pub metadata: BTreeMap, - #[serde(default)] - pub max_size_bytes: Option, - #[serde(default)] - pub expire_seconds: Option, - #[serde(default)] - pub success_action_status: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetReadUrlQuery { - #[serde(default)] - pub object_key: Option, - #[serde(default)] - pub legacy_public_path: Option, - #[serde(default)] - pub expire_seconds: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ConfirmAssetObjectRequest { - #[serde(default)] - pub bucket: Option, - pub object_key: String, - #[serde(default)] - pub content_type: Option, - #[serde(default)] - pub content_length: Option, - #[serde(default)] - pub content_hash: Option, - pub asset_kind: String, - #[serde(default)] - pub access_policy: Option, - #[serde(default)] - pub source_job_id: Option, - #[serde(default)] - pub owner_user_id: Option, - #[serde(default)] - pub profile_id: Option, - #[serde(default)] - pub entity_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BindAssetObjectRequest { - pub asset_object_id: String, - pub entity_kind: String, - pub entity_id: String, - pub slot: String, - pub asset_kind: String, - #[serde(default)] - pub owner_user_id: Option, - #[serde(default)] - pub profile_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ConfirmAssetObjectAccessPolicy { - Private, - PublicRead, -} - pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, @@ -141,9 +66,9 @@ pub async fn create_direct_upload_ticket( Ok(json_success_body( Some(&request_context), - json!({ - "upload": signed, - }), + CreateDirectUploadTicketResponse { + upload: DirectUploadTicketPayload::from(signed), + }, )) } @@ -180,9 +105,9 @@ pub async fn get_asset_read_url( Ok(json_success_body( Some(&request_context), - json!({ - "read": signed, - }), + GetAssetReadUrlResponse { + read: AssetReadUrlPayload::from(signed), + }, )) } @@ -223,25 +148,25 @@ pub async fn confirm_asset_object( Ok(json_success_body( Some(&request_context), - json!({ - "assetObject": { - "assetObjectId": result.asset_object_id, - "bucket": result.bucket, - "objectKey": result.object_key, - "accessPolicy": result.access_policy.as_str(), - "contentType": result.content_type, - "contentLength": result.content_length, - "contentHash": result.content_hash, - "version": result.version, - "sourceJobId": result.source_job_id, - "ownerUserId": result.owner_user_id, - "profileId": result.profile_id, - "entityId": result.entity_id, - "assetKind": result.asset_kind, - "createdAt": result.created_at, - "updatedAt": result.updated_at, - } - }), + ConfirmAssetObjectResponse { + asset_object: AssetObjectPayload { + asset_object_id: result.asset_object_id, + bucket: result.bucket, + object_key: result.object_key, + access_policy: result.access_policy.as_str().to_string(), + content_type: result.content_type, + content_length: result.content_length, + content_hash: result.content_hash, + version: result.version, + source_job_id: result.source_job_id, + owner_user_id: result.owner_user_id, + profile_id: result.profile_id, + entity_id: result.entity_id, + asset_kind: result.asset_kind, + created_at: result.created_at, + updated_at: result.updated_at, + }, + }, )) } @@ -272,20 +197,20 @@ pub async fn bind_asset_object_to_entity( Ok(json_success_body( Some(&request_context), - json!({ - "assetBinding": { - "bindingId": result.binding_id, - "assetObjectId": result.asset_object_id, - "entityKind": result.entity_kind, - "entityId": result.entity_id, - "slot": result.slot, - "assetKind": result.asset_kind, - "ownerUserId": result.owner_user_id, - "profileId": result.profile_id, - "createdAt": result.created_at, - "updatedAt": result.updated_at, - } - }), + BindAssetObjectResponse { + asset_binding: AssetBindingPayload { + binding_id: result.binding_id, + asset_object_id: result.asset_object_id, + entity_kind: result.entity_kind, + entity_id: result.entity_id, + slot: result.slot, + asset_kind: result.asset_kind, + owner_user_id: result.owner_user_id, + profile_id: result.profile_id, + created_at: result.created_at, + updated_at: result.updated_at, + }, + }, )) } @@ -355,7 +280,7 @@ async fn build_confirm_asset_object_upsert_input( head.object_key, payload .access_policy - .map(Into::into) + .map(map_confirm_asset_object_access_policy) .unwrap_or(AssetObjectAccessPolicy::Private), head.content_type .or_else(|| normalize_optional_value(payload.content_type)), @@ -439,12 +364,12 @@ impl std::fmt::Display for ConfirmAssetObjectPrepareError { } } -impl From for AssetObjectAccessPolicy { - fn from(value: ConfirmAssetObjectAccessPolicy) -> Self { - match value { - ConfirmAssetObjectAccessPolicy::Private => Self::Private, - ConfirmAssetObjectAccessPolicy::PublicRead => Self::PublicRead, - } +fn map_confirm_asset_object_access_policy( + value: ConfirmAssetObjectAccessPolicy, +) -> AssetObjectAccessPolicy { + match value { + ConfirmAssetObjectAccessPolicy::Private => AssetObjectAccessPolicy::Private, + ConfirmAssetObjectAccessPolicy::PublicRead => AssetObjectAccessPolicy::PublicRead, } } @@ -469,8 +394,8 @@ mod tests { use reqwest::{Method, multipart}; use serde_json::{Value, json}; use sha1::Sha1; + use shared_kernel::new_uuid_simple_string; use tower::ServiceExt; - use uuid::Uuid; use crate::{app::build_router, config::AppConfig, state::AppState}; @@ -885,7 +810,7 @@ mod tests { ensure_success_status(bucket_head.status().as_u16(), "bucket HEAD 应成功")?; let app = build_router(AppState::new(config.clone()).expect("state should build")); - let run_id = Uuid::new_v4().simple().to_string(); + let run_id = new_uuid_simple_string(); let file_name = format!("oss-live-{run_id}.txt"); let file_content = format!("Genarrative OSS Rust live test {run_id}"); @@ -1032,7 +957,7 @@ mod tests { let test_result = async { let app = build_router(AppState::new(config.clone()).expect("state should build")); - let run_id = Uuid::new_v4().simple().to_string(); + let run_id = new_uuid_simple_string(); let file_content = format!("Genarrative confirm asset object live test {run_id}"); let ticket_response = app diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index ad68a5c1..b1684e26 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -3,32 +3,13 @@ use axum::{ extract::{Extension, State}, http::StatusCode, }; -use serde::Serialize; +use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods}; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthMeResponse { - pub user: AuthMeUserPayload, - pub available_login_methods: Vec<&'static str>, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthMeUserPayload { - pub id: String, - pub username: String, - pub display_name: String, - pub phone_number_masked: Option, - pub login_method: &'static str, - pub binding_status: &'static str, - pub wechat_bound: bool, -} - pub async fn auth_me( State(state): State, Extension(request_context): Extension, @@ -49,27 +30,19 @@ pub async fn auth_me( Ok(json_success_body( Some(&request_context), AuthMeResponse { - user: AuthMeUserPayload { + user: AuthUserPayload { id: user.user.id, username: user.user.username, display_name: user.user.display_name, phone_number_masked: user.user.phone_number_masked, - login_method: user.user.login_method.as_str(), - binding_status: user.user.binding_status.as_str(), + login_method: user.user.login_method.as_str().to_string(), + binding_status: user.user.binding_status.as_str().to_string(), wechat_bound: user.user.wechat_bound, }, - available_login_methods: build_available_login_methods(&state), + available_login_methods: build_available_login_methods( + state.config.sms_auth_enabled, + state.config.wechat_auth_enabled, + ), }, )) } - -fn build_available_login_methods(state: &AppState) -> Vec<&'static str> { - let mut methods = Vec::new(); - if state.config.sms_auth_enabled { - methods.push("phone"); - } - if state.config.wechat_auth_enabled { - methods.push("wechat"); - } - methods -} diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index 96dbbf3d..b9c0b716 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -4,7 +4,7 @@ use axum::{ http::StatusCode, }; use platform_auth::hash_refresh_session_token; -use serde::Serialize; +use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse}; use time::OffsetDateTime; use crate::{ @@ -16,31 +16,6 @@ use crate::{ state::AppState, }; -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthSessionsResponse { - pub sessions: Vec, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthSessionSummaryPayload { - pub session_id: String, - pub client_type: String, - pub client_runtime: String, - pub client_platform: String, - pub client_label: String, - pub device_display_name: String, - pub mini_program_app_id: Option, - pub mini_program_env: Option, - pub user_agent: Option, - pub ip_masked: Option, - pub is_current: bool, - pub created_at: String, - pub last_seen_at: String, - pub expires_at: String, -} - pub async fn auth_sessions( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 845df0a7..577bc7a9 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,5 +1,12 @@ use std::{env, net::SocketAddr}; +use platform_llm::{ + DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_RETRY_BACKOFF_MS, LlmProvider, +}; + +const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715"; + // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 #[derive(Clone, Debug)] pub struct AppConfig { @@ -40,6 +47,13 @@ pub struct AppConfig { pub spacetime_server_url: String, pub spacetime_database: String, pub spacetime_token: Option, + pub llm_provider: LlmProvider, + pub llm_base_url: String, + pub llm_api_key: Option, + pub llm_model: String, + pub llm_request_timeout_ms: u64, + pub llm_max_retries: u32, + pub llm_retry_backoff_ms: u64, } impl Default for AppConfig { @@ -83,6 +97,13 @@ impl Default for AppConfig { spacetime_server_url: "http://127.0.0.1:3000".to_string(), spacetime_database: "genarrative-dev".to_string(), spacetime_token: None, + llm_provider: LlmProvider::Ark, + llm_base_url: DEFAULT_ARK_BASE_URL.to_string(), + llm_api_key: None, + llm_model: DEFAULT_LLM_MODEL.to_string(), + llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, + llm_max_retries: DEFAULT_MAX_RETRIES, + llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, } } } @@ -244,6 +265,46 @@ impl AppConfig { config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]); + if let Some(llm_provider) = + read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"]) + { + config.llm_provider = llm_provider; + } + + if let Some(llm_base_url) = + read_first_non_empty_env(&["GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL"]) + { + config.llm_base_url = llm_base_url; + } + + config.llm_api_key = + read_first_non_empty_env(&["GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY"]); + + if let Some(llm_model) = + read_first_non_empty_env(&["GENARRATIVE_LLM_MODEL", "LLM_MODEL", "VITE_LLM_MODEL"]) + { + config.llm_model = llm_model; + } + + if let Some(llm_request_timeout_ms) = read_first_positive_u64_env(&[ + "GENARRATIVE_LLM_REQUEST_TIMEOUT_MS", + "LLM_REQUEST_TIMEOUT_MS", + ]) { + config.llm_request_timeout_ms = llm_request_timeout_ms; + } + + if let Some(llm_max_retries) = + read_first_u32_env(&["GENARRATIVE_LLM_MAX_RETRIES", "LLM_MAX_RETRIES"]) + { + config.llm_max_retries = llm_max_retries; + } + + if let Some(llm_retry_backoff_ms) = + read_first_u64_env(&["GENARRATIVE_LLM_RETRY_BACKOFF_MS", "LLM_RETRY_BACKOFF_MS"]) + { + config.llm_retry_backoff_ms = llm_retry_backoff_ms; + } + config } @@ -281,6 +342,14 @@ fn read_first_bool_env(keys: &[&str]) -> Option { .find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value))) } +fn read_first_llm_provider_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_llm_provider(&value)) + }) +} + fn read_first_positive_u32_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -297,6 +366,16 @@ fn read_first_positive_u64_env(keys: &[&str]) -> Option { }) } +fn read_first_u32_env(keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| env::var(key).ok().and_then(|value| parse_u32(&value))) +} + +fn read_first_u64_env(keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value))) +} + fn read_first_positive_u16_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -338,6 +417,15 @@ fn parse_bool(raw: &str) -> Option { } } +fn parse_llm_provider(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "ark" => Some(LlmProvider::Ark), + "dash_scope" | "dashscope" => Some(LlmProvider::DashScope), + "openai_compatible" | "openai-compatible" | "openai" => Some(LlmProvider::OpenAiCompatible), + _ => None, + } +} + fn parse_positive_u32(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { @@ -347,6 +435,10 @@ fn parse_positive_u32(raw: &str) -> Option { Some(value) } +fn parse_u32(raw: &str) -> Option { + raw.trim().parse::().ok() +} + fn parse_positive_u64(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { @@ -356,6 +448,10 @@ fn parse_positive_u64(raw: &str) -> Option { Some(value) } +fn parse_u64(raw: &str) -> Option { + raw.trim().parse::().ok() +} + fn parse_positive_u16(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs new file mode 100644 index 00000000..4f109650 --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -0,0 +1,1015 @@ +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::{HeaderName, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use module_custom_world::{ + CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json, + empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object, +}; +use serde_json::{Map, Value, json}; +use shared_contracts::runtime::{ + CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse, + CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse, + CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse, + CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse, + CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse, + CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, + CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse, + SendCustomWorldAgentMessageRequest, +}; +use shared_kernel::build_prefixed_uuid_id; +use spacetime_client::{ + CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord, + CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord, + CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishWorldRecordInput, + CustomWorldSupportedActionRecord, SpacetimeClientError, +}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn get_custom_world_library( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let entries = state + .spacetime_client() + .list_custom_world_profiles(owner_user_id) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldLibraryResponse { + entries: entries + .into_iter() + .map(map_custom_world_library_entry_response) + .collect(), + }, + )) +} + +pub async fn get_custom_world_library_detail( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(profile_id): Path, +) -> Result, Response> { + if profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-library", + "message": "profileId is required", + })), + )); + } + + let detail = state + .spacetime_client() + .get_custom_world_library_detail( + authenticated.claims().user_id().to_string(), + profile_id, + ) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryDetailResponse { + entry: map_custom_world_library_entry_response(detail.entry), + }, + )) +} + +pub async fn put_custom_world_library_profile( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(profile_id): Path, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-library", + "message": error.body_text(), + })), + ) + })?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + if profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-library", + "message": "profileId is required", + })), + )); + } + + let metadata = extract_custom_world_metadata(&payload.profile).map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-library", + "message": error, + })), + ) + })?; + let author_display_name = resolve_author_display_name(&authenticated); + let mutation = state + .spacetime_client() + .upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput { + profile_id: profile_id.clone(), + owner_user_id: owner_user_id.clone(), + source_agent_session_id: None, + world_name: metadata.world_name, + subtitle: metadata.subtitle, + summary_text: metadata.summary_text, + theme_mode: metadata.theme_mode, + cover_image_src: metadata.cover_image_src, + profile_payload_json: serde_json::to_string(&payload.profile).map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-library", + "message": format!("profile JSON 序列化失败:{error}"), + })), + ) + })?, + playable_npc_count: metadata.playable_npc_count, + landmark_count: metadata.landmark_count, + author_display_name, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldLibraryMutationResponse { + entry: map_custom_world_library_entry_response(mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response(mutation.entry)], + }, + )) +} + +pub async fn publish_custom_world_library_profile( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(profile_id): Path, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + if profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-library", + "message": "profileId is required", + })), + )); + } + + let mutation = state + .spacetime_client() + .publish_custom_world_profile( + profile_id, + owner_user_id, + resolve_author_display_name(&authenticated), + current_utc_micros(), + ) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldLibraryMutationResponse { + entry: map_custom_world_library_entry_response(mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response(mutation.entry)], + }, + )) +} + +pub async fn unpublish_custom_world_library_profile( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(profile_id): Path, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + if profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-library", + "message": "profileId is required", + })), + )); + } + + let mutation = state + .spacetime_client() + .unpublish_custom_world_profile( + profile_id, + owner_user_id, + resolve_author_display_name(&authenticated), + current_utc_micros(), + ) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldLibraryMutationResponse { + entry: map_custom_world_library_entry_response(mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response(mutation.entry)], + }, + )) +} + +pub async fn list_custom_world_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let entries = state + .spacetime_client() + .list_custom_world_gallery_entries() + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryResponse { + entries: entries + .into_iter() + .map(map_custom_world_gallery_card_response) + .collect(), + }, + )) +} + +pub async fn get_custom_world_gallery_detail( + State(state): State, + Path((owner_user_id, profile_id)): Path<(String, String)>, + Extension(request_context): Extension, +) -> Result, Response> { + if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "ownerUserId and profileId are required", + })), + )); + } + + let detail = state + .spacetime_client() + .get_custom_world_gallery_detail(owner_user_id, profile_id) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryDetailResponse { + entry: map_custom_world_library_entry_response(detail.entry), + }, + )) +} + +pub async fn create_custom_world_agent_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": error.body_text(), + })), + ) + })?; + + let seed_text = payload.seed_text.unwrap_or_default().trim().to_string(); + let welcome_message_text = build_custom_world_agent_welcome_text(&seed_text); + let session = state + .spacetime_client() + .create_custom_world_agent_session(CustomWorldAgentSessionCreateRecordInput { + session_id: build_prefixed_uuid_id("custom-world-agent-session-"), + owner_user_id: authenticated.claims().user_id().to_string(), + seed_text, + welcome_message_id: build_prefixed_uuid_id("message-"), + welcome_message_text, + anchor_content_json: empty_agent_anchor_content_json(), + creator_intent_json: Some(empty_json_object()), + creator_intent_readiness_json: empty_agent_creator_intent_readiness_json(), + anchor_pack_json: Some(empty_json_object()), + lock_state_json: Some(empty_json_object()), + draft_profile_json: Some(empty_json_object()), + pending_clarifications_json: empty_json_array(), + suggested_actions_json: empty_json_array(), + recommended_replies_json: empty_json_array(), + quality_findings_json: empty_json_array(), + asset_coverage_json: empty_agent_asset_coverage_json(), + checkpoints_json: empty_json_array(), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldAgentSessionResponse { + session: map_custom_world_agent_session_response(session), + }, + )) +} + +pub async fn get_custom_world_agent_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + if session_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "sessionId is required", + })), + )); + } + + let session = state + .spacetime_client() + .get_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + map_custom_world_agent_session_response(session), + )) +} + +pub async fn submit_custom_world_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": error.body_text(), + })), + ) + })?; + + if session_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "sessionId is required", + })), + )); + } + + let client_message_id = payload.client_message_id.trim().to_string(); + let message_text = payload.text.trim().to_string(); + if client_message_id.is_empty() || message_text.is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "clientMessageId and text are required", + })), + )); + } + + let operation = state + .spacetime_client() + .submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput { + session_id, + owner_user_id: authenticated.claims().user_id().to_string(), + user_message_id: client_message_id, + user_message_text: message_text, + operation_id: build_prefixed_uuid_id("operation-"), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "operation": map_custom_world_agent_operation_response(operation), + }), + )) +} + +pub async fn stream_custom_world_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = payload.map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": error.body_text(), + })), + ) + })?; + + if session_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "sessionId is required", + })), + )); + } + + let client_message_id = payload.client_message_id.trim().to_string(); + let message_text = payload.text.trim().to_string(); + if client_message_id.is_empty() || message_text.is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "clientMessageId and text are required", + })), + )); + } + + let owner_user_id = authenticated.claims().user_id().to_string(); + state + .spacetime_client() + .submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + user_message_id: client_message_id, + user_message_text: message_text, + operation_id: build_prefixed_uuid_id("operation-"), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + let session = state + .spacetime_client() + .get_custom_world_agent_session(session_id, owner_user_id) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + let session_response = map_custom_world_agent_session_response(session); + let reply_text = resolve_stream_reply_text(&session_response); + + // 这里先用“一次性构造完整 SSE 文本”的最小兼容方案, + // 复用 Stage 7 的同步 deterministic 写表逻辑,保证前端当前的 reader 协议可直接消费。 + let mut sse_body = String::new(); + append_sse_event(&mut sse_body, "reply_delta", &json!({ "text": reply_text })) + .map_err(|error| custom_world_error_response(&request_context, error))?; + append_sse_event( + &mut sse_body, + "session", + &json!({ "session": session_response }), + ) + .map_err(|error| custom_world_error_response(&request_context, error))?; + append_sse_event(&mut sse_body, "done", &json!({ "ok": true })) + .map_err(|error| custom_world_error_response(&request_context, error))?; + + Ok(build_event_stream_response(sse_body)) +} + +pub async fn get_custom_world_agent_operation( + State(state): State, + Path((session_id, operation_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + if session_id.trim().is_empty() || operation_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "sessionId and operationId are required", + })), + )); + } + + let operation = state + .spacetime_client() + .get_custom_world_agent_operation( + session_id, + authenticated.claims().user_id().to_string(), + operation_id, + ) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + map_custom_world_agent_operation_response(operation), + )) +} + +pub async fn execute_custom_world_agent_action( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": error.body_text(), + })), + ) + })?; + + let action = payload + .get("action") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + if action != "publish_world" { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::NOT_IMPLEMENTED).with_details(json!({ + "provider": "custom-world-agent", + "message": "当前 Stage 5 仅支持 publish_world action", + })), + )); + } + + let profile_id = payload + .get("profileId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("agent-draft-{session_id}")); + let draft_profile = payload.get("draftProfile").cloned().ok_or_else(|| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "publish_world 当前必须显式提供 draftProfile", + })), + ) + })?; + let setting_text = payload + .get("settingText") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "publish_world 当前必须显式提供 settingText", + })), + ) + })?; + + let publish_result = state + .spacetime_client() + .publish_custom_world_world(CustomWorldPublishWorldRecordInput { + session_id: session_id.clone(), + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + draft_profile_json: serde_json::to_string(&draft_profile).map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": format!("draftProfile JSON 序列化失败:{error}"), + })), + ) + })?, + legacy_result_profile_json: payload + .get("legacyResultProfile") + .map(serde_json::to_string) + .transpose() + .map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": format!("legacyResultProfile JSON 序列化失败:{error}"), + })), + ) + })?, + setting_text, + author_display_name: resolve_author_display_name(&authenticated), + published_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "operation": { + "operationId": format!("publish-world-{session_id}"), + "type": "publish_world", + "status": "completed", + "phaseLabel": "世界已发布", + "phaseDetail": format!("正式世界档案已写入作品库:{}。", publish_result.entry.profile_id), + "progress": 100, + "error": Value::Null, + } + }), + )) +} + +fn map_custom_world_library_entry_response( + entry: CustomWorldLibraryEntryRecord, +) -> CustomWorldLibraryEntryResponse { + CustomWorldLibraryEntryResponse { + owner_user_id: entry.owner_user_id, + profile_id: entry.profile_id, + profile: entry.profile, + visibility: entry.visibility, + published_at: entry.published_at, + updated_at: entry.updated_at, + author_display_name: entry.author_display_name, + world_name: entry.world_name, + subtitle: entry.subtitle, + summary_text: entry.summary_text, + cover_image_src: entry.cover_image_src, + theme_mode: entry.theme_mode, + playable_npc_count: entry.playable_npc_count, + landmark_count: entry.landmark_count, + } +} + +fn map_custom_world_gallery_card_response( + entry: CustomWorldGalleryEntryRecord, +) -> CustomWorldGalleryCardResponse { + CustomWorldGalleryCardResponse { + owner_user_id: entry.owner_user_id, + profile_id: entry.profile_id, + visibility: entry.visibility, + published_at: entry.published_at, + updated_at: entry.updated_at, + author_display_name: entry.author_display_name, + world_name: entry.world_name, + subtitle: entry.subtitle, + summary_text: entry.summary_text, + cover_image_src: entry.cover_image_src, + theme_mode: entry.theme_mode, + playable_npc_count: entry.playable_npc_count, + landmark_count: entry.landmark_count, + } +} + +fn map_custom_world_agent_session_response( + session: CustomWorldAgentSessionRecord, +) -> CustomWorldAgentSessionSnapshotResponse { + CustomWorldAgentSessionSnapshotResponse { + session_id: session.session_id, + current_turn: session.current_turn, + anchor_content: session.anchor_content, + progress_percent: session.progress_percent, + last_assistant_reply: session.last_assistant_reply, + stage: session.stage, + focus_card_id: session.focus_card_id, + creator_intent: session.creator_intent, + creator_intent_readiness: session.creator_intent_readiness, + anchor_pack: session.anchor_pack, + lock_state: session.lock_state, + draft_profile: session.draft_profile, + messages: session + .messages + .into_iter() + .map(map_custom_world_agent_message_response) + .collect(), + draft_cards: session + .draft_cards + .into_iter() + .map(map_custom_world_draft_card_response) + .collect(), + pending_clarifications: session.pending_clarifications, + suggested_actions: session.suggested_actions, + recommended_replies: session.recommended_replies, + quality_findings: session.quality_findings, + asset_coverage: session.asset_coverage, + checkpoints: session + .checkpoints + .into_iter() + .map(map_custom_world_agent_checkpoint_response) + .collect(), + supported_actions: session + .supported_actions + .into_iter() + .map(map_custom_world_supported_action_response) + .collect(), + result_preview: session.result_preview, + updated_at: session.updated_at, + } +} + +fn map_custom_world_agent_message_response( + message: CustomWorldAgentMessageRecord, +) -> CustomWorldAgentMessageResponse { + CustomWorldAgentMessageResponse { + id: message.message_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at: message.created_at, + related_operation_id: message.related_operation_id, + } +} + +fn map_custom_world_agent_operation_response( + operation: CustomWorldAgentOperationRecord, +) -> CustomWorldAgentOperationResponse { + CustomWorldAgentOperationResponse { + operation_id: operation.operation_id, + operation_type: operation.operation_type, + status: operation.status, + phase_label: operation.phase_label, + phase_detail: operation.phase_detail, + progress: operation.progress, + error: operation.error_message, + } +} + +fn map_custom_world_draft_card_response( + card: CustomWorldDraftCardRecord, +) -> CustomWorldDraftCardSummaryResponse { + CustomWorldDraftCardSummaryResponse { + id: card.card_id, + kind: card.kind, + title: card.title, + subtitle: card.subtitle, + summary: card.summary, + status: card.status, + linked_ids: card.linked_ids, + warning_count: card.warning_count, + asset_status: card.asset_status, + asset_status_label: card.asset_status_label, + } +} + +fn map_custom_world_agent_checkpoint_response( + checkpoint: CustomWorldAgentCheckpointRecord, +) -> CustomWorldAgentCheckpointResponse { + CustomWorldAgentCheckpointResponse { + checkpoint_id: checkpoint.checkpoint_id, + created_at: checkpoint.created_at, + label: checkpoint.label, + } +} + +fn map_custom_world_supported_action_response( + action: CustomWorldSupportedActionRecord, +) -> CustomWorldSupportedActionResponse { + CustomWorldSupportedActionResponse { + action: action.action, + enabled: action.enabled, + reason: action.reason, + } +} + +fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse) -> String { + session + .last_assistant_reply + .clone() + .or_else(|| { + session + .messages + .iter() + .rev() + .find(|message| message.role == "assistant") + .map(|message| message.text.clone()) + }) + .unwrap_or_default() +} + +fn append_sse_event(body: &mut String, event: &str, payload: &Value) -> Result<(), AppError> { + let payload_text = serde_json::to_string(payload).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "custom-world-agent", + "message": format!("SSE payload 序列化失败:{error}"), + })) + })?; + + body.push_str("event: "); + body.push_str(event); + body.push('\n'); + body.push_str("data: "); + body.push_str(&payload_text); + body.push_str("\n\n"); + + Ok(()) +} + +fn build_event_stream_response(body: String) -> Response { + ( + [ + (header::CONTENT_TYPE, "text/event-stream; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + // 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。 + (HeaderName::from_static("x-accel-buffering"), "no"), + ], + body, + ) + .into_response() +} + +fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Procedure(message) if message.contains("custom_world_profile 不存在") => { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("custom_world_agent_session 不存在") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("custom_world_agent_operation 不存在") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn custom_world_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String { + "玩家".to_string() +} + +fn build_custom_world_agent_welcome_text(seed_text: &str) -> String { + if seed_text.trim().is_empty() { + return "我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。" + .to_string(); + } + + "我已经收到你的世界起点,会先把它整理成创作锚点。你可以继续补充玩家身份、核心冲突、关键关系或标志性元素。".to_string() +} + +struct CustomWorldProfileMetadata { + world_name: String, + subtitle: String, + summary_text: String, + cover_image_src: Option, + theme_mode: CustomWorldThemeMode, + playable_npc_count: u32, + landmark_count: u32, +} + +fn extract_custom_world_metadata(profile: &Value) -> Result { + let object = profile + .as_object() + .ok_or_else(|| "profile 必须是 JSON object".to_string())?; + + let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string()); + let subtitle = read_string_field(object, "subtitle").unwrap_or_default(); + let summary_text = read_string_field(object, "summary").unwrap_or_default(); + let cover_image_src = resolve_cover_image_src(object); + let theme_mode = read_string_field(object, "themeMode") + .and_then(|value| CustomWorldThemeMode::from_client_str(&value)) + .unwrap_or(CustomWorldThemeMode::Mythic); + let playable_npc_count = count_profile_roles(object); + let landmark_count = object + .get("landmarks") + .and_then(Value::as_array) + .map(|entries| entries.len() as u32) + .unwrap_or(0); + + Ok(CustomWorldProfileMetadata { + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count, + }) +} + +fn read_string_field(object: &Map, key: &str) -> Option { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn resolve_cover_image_src(object: &Map) -> Option { + object + .get("cover") + .and_then(Value::as_object) + .and_then(|cover| read_string_field(cover, "imageSrc")) + .or_else(|| { + object + .get("camp") + .and_then(Value::as_object) + .and_then(|camp| read_string_field(camp, "imageSrc")) + }) + .or_else(|| { + object + .get("landmarks") + .and_then(Value::as_array) + .and_then(|entries| entries.first()) + .and_then(Value::as_object) + .and_then(|landmark| read_string_field(landmark, "imageSrc")) + }) +} + +fn count_profile_roles(object: &Map) -> u32 { + let playable = object + .get("playableNpcs") + .and_then(Value::as_array) + .map(|entries| entries.len() as u32) + .unwrap_or(0); + let story = object + .get("storyNpcs") + .and_then(Value::as_array) + .map(|entries| entries.len() as u32) + .unwrap_or(0); + + playable.saturating_add(story) +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index b22ba953..3828f197 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -3,8 +3,8 @@ use axum::{ http::{HeaderMap, HeaderValue}, response::{IntoResponse, Response}, }; -use serde::Serialize; use serde_json::Value; +use shared_contracts::api::ApiErrorPayload; use crate::{api_response::json_error_body, request_context::RequestContext}; @@ -17,14 +17,6 @@ pub struct AppError { headers: HeaderMap, } -#[derive(Clone, Debug, Serialize)] -pub struct ApiErrorPayload { - pub code: &'static str, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - impl AppError { pub fn from_status(status_code: StatusCode) -> Self { let (code, message) = resolve_http_error(status_code); @@ -71,11 +63,7 @@ impl AppError { } fn to_payload(&self) -> ApiErrorPayload { - ApiErrorPayload { - code: self.code, - message: self.message.clone(), - details: self.details.clone(), - } + ApiErrorPayload::new(self.code, self.message.clone(), self.details.clone()) } } @@ -91,6 +79,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) { StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"), StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"), StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"), + StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"), StatusCode::CONFLICT => ("CONFLICT", "请求冲突"), StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"), StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"), diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs new file mode 100644 index 00000000..066c989b --- /dev/null +++ b/server-rs/crates/api-server/src/llm.rs @@ -0,0 +1,376 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, + response::Response, +}; +use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest}; +use serde_json::Value; +use shared_contracts::llm::{ + LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, +}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn proxy_llm_chat_completions( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + if payload.stream { + return Err(llm_error_response( + &request_context, + AppError::from_status(StatusCode::NOT_IMPLEMENTED) + .with_message("Rust `api-server` 首版暂不支持流式 LLM 代理"), + )); + } + + let llm_client = state.llm_client().ok_or_else(|| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("服务端尚未配置可用的 LLM API Key"), + ) + })?; + + let request = LlmTextRequest { + model: payload.model, + messages: payload + .messages + .into_iter() + .map(map_chat_message) + .collect::>(), + max_tokens: None, + }; + + let response = llm_client + .request_text(request) + .await + .map_err(|error| llm_error_response(&request_context, map_llm_error(error)))?; + + Ok(json_success_body( + Some(&request_context), + LlmChatCompletionResponse { + id: response.response_id, + model: response.model, + content: response.content, + finish_reason: response.finish_reason, + }, + )) +} + +fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage { + let role = match message.role { + LlmChatMessageRole::System => LlmMessageRole::System, + LlmChatMessageRole::User => LlmMessageRole::User, + LlmChatMessageRole::Assistant => LlmMessageRole::Assistant, + }; + + LlmMessage::new(role, message.content) +} + +fn map_llm_error(error: LlmError) -> AppError { + match error { + LlmError::InvalidRequest(message) => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) + } + LlmError::InvalidConfig(message) => { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) + } + LlmError::Upstream { + status_code: 429, + message, + } => AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(message), + LlmError::Upstream { message, .. } => { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message) + } + LlmError::Timeout { attempts } => AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("LLM 请求超时,累计尝试 {attempts} 次")), + LlmError::Connectivity { attempts, message } => { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("LLM 连接失败,累计尝试 {attempts} 次:{message}")) + } + LlmError::StreamUnavailable => { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 流式响应体不可用") + } + LlmError::EmptyResponse => { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 返回内容为空") + } + LlmError::Transport(message) | LlmError::Deserialize(message) => { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message) + } + } +} + +fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + time::Duration as StdDuration, + }; + + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::{Value, json}; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + struct MockResponse { + status_line: &'static str, + content_type: &'static str, + body: String, + } + + #[tokio::test] + async fn llm_chat_completions_returns_non_stream_text_payload() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_api_server_01","model":"ark-router-test","choices":[{"message":{"content":"代理成功"},"finish_reason":"stop"}]}"#.to_string(), + }]); + let state = seed_authenticated_state(AppConfig { + llm_base_url: server_url, + llm_api_key: Some("test-key".to_string()), + ..AppConfig::default() + }) + .await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/chat/completions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "messages": [ + { "role": "system", "content": "系统" }, + { "role": "user", "content": "用户" } + ] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!( + payload["data"]["id"], + Value::String("resp_api_server_01".to_string()) + ); + assert_eq!( + payload["data"]["model"], + Value::String("ark-router-test".to_string()) + ); + assert_eq!( + payload["data"]["content"], + Value::String("代理成功".to_string()) + ); + assert_eq!( + payload["data"]["finishReason"], + Value::String("stop".to_string()) + ); + } + + #[tokio::test] + async fn llm_chat_completions_rejects_stream_mode() { + let state = seed_authenticated_state(AppConfig::default()).await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/chat/completions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "stream": true, + "messages": [ + { "role": "user", "content": "用户" } + ] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["code"], + Value::String("NOT_IMPLEMENTED".to_string()) + ); + } + + async fn seed_authenticated_state(config: AppConfig) -> AppState { + let state = AppState::new(config).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "llm_proxy_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_llm_proxy".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("LLM 代理用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } + + fn spawn_mock_server(responses: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + + thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().expect("request should connect"); + read_request(&mut stream); + write_response(&mut stream, response); + } + }); + + format!("http://{address}") + } + + fn read_request(stream: &mut std::net::TcpStream) { + stream + .set_read_timeout(Some(StdDuration::from_secs(1))) + .expect("read timeout should be set"); + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + let mut expected_total = None; + + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(bytes_read) => { + buffer.extend_from_slice(&chunk[..bytes_read]); + + if expected_total.is_none() + && let Some(header_end) = find_header_end(&buffer) + { + let content_length = + read_content_length(&buffer[..header_end]).unwrap_or(0); + expected_total = Some(header_end + content_length); + } + + if let Some(total_bytes) = expected_total + && buffer.len() >= total_bytes + { + break; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(error) => panic!("mock server failed to read request: {error}"), + } + } + } + + fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) { + let body = response.body; + let raw_response = format!( + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status_line, + response.content_type, + body.len(), + body + ); + + stream + .write_all(raw_response.as_bytes()) + .expect("mock response should be written"); + stream.flush().expect("mock response should flush"); + } + + fn find_header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + } + + fn read_content_length(headers: &[u8]) -> Option { + let text = String::from_utf8_lossy(headers); + text.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + return value.trim().parse::().ok(); + } + None + }) + } +} diff --git a/server-rs/crates/api-server/src/login_options.rs b/server-rs/crates/api-server/src/login_options.rs index 8595124d..a5a060c1 100644 --- a/server-rs/crates/api-server/src/login_options.rs +++ b/server-rs/crates/api-server/src/login_options.rs @@ -2,32 +2,21 @@ use axum::{ Json, extract::{Extension, State}, }; -use serde::Serialize; +use shared_contracts::auth::{AuthLoginOptionsResponse, build_available_login_methods}; use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState}; -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthLoginOptionsResponse { - pub available_login_methods: Vec<&'static str>, -} - pub async fn auth_login_options( State(state): State, Extension(request_context): Extension, ) -> Json { - let mut methods = Vec::new(); - if state.config.sms_auth_enabled { - methods.push("phone"); - } - if state.config.wechat_auth_enabled { - methods.push("wechat"); - } - json_success_body( Some(&request_context), AuthLoginOptionsResponse { - available_login_methods: methods, + available_login_methods: build_available_login_methods( + state.config.sms_auth_enabled, + state.config.wechat_auth_enabled, + ), }, ) } diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs index c4fef132..126b6d74 100644 --- a/server-rs/crates/api-server/src/logout.rs +++ b/server-rs/crates/api-server/src/logout.rs @@ -5,7 +5,7 @@ use axum::{ }; use module_auth::LogoutCurrentSessionInput; use platform_auth::hash_refresh_session_token; -use serde::Serialize; +use shared_contracts::auth::LogoutResponse; use time::OffsetDateTime; use crate::{ @@ -19,11 +19,6 @@ use crate::{ state::AppState, }; -#[derive(Debug, Serialize)] -pub struct LogoutResponse { - pub ok: bool, -} - pub async fn logout( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/logout_all.rs b/server-rs/crates/api-server/src/logout_all.rs index 8dbcd445..93cd4927 100644 --- a/server-rs/crates/api-server/src/logout_all.rs +++ b/server-rs/crates/api-server/src/logout_all.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, }; use module_auth::LogoutAllSessionsInput; -use serde::Serialize; +use shared_contracts::auth::LogoutAllResponse; use time::OffsetDateTime; use crate::{ @@ -18,11 +18,6 @@ use crate::{ state::AppState, }; -#[derive(Debug, Serialize)] -pub struct LogoutAllResponse { - pub ok: bool, -} - pub async fn logout_all( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 91e04cc5..ff9b336f 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -1,3 +1,4 @@ +mod ai_tasks; mod api_response; mod app; mod assets; @@ -6,9 +7,11 @@ mod auth_me; mod auth_session; mod auth_sessions; mod config; +mod custom_world; mod error_middleware; mod health; mod http_error; +mod llm; mod login_options; mod logout; mod logout_all; @@ -17,8 +20,15 @@ mod phone_auth; mod refresh_session; mod request_context; mod response_headers; +mod runtime_browse_history; +mod runtime_inventory; +mod runtime_profile; +mod runtime_settings; +mod runtime_story; mod session_client; mod state; +mod story_battles; +mod story_sessions; mod wechat_auth; mod wechat_provider; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 9cb96323..b9e8e676 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -5,8 +5,8 @@ use axum::{ response::IntoResponse, }; use module_auth::{PasswordEntryError, PasswordEntryInput}; -use serde::{Deserialize, Serialize}; use serde_json::json; +use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse}; use crate::{ api_response::json_success_body, @@ -19,32 +19,6 @@ use crate::{ state::AppState, }; -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PasswordEntryRequest { - pub username: String, - pub password: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PasswordEntryResponse { - pub token: String, - pub user: PasswordEntryUserPayload, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PasswordEntryUserPayload { - pub id: String, - pub username: String, - pub display_name: String, - pub phone_number_masked: Option, - pub login_method: &'static str, - pub binding_status: &'static str, - pub wechat_bound: bool, -} - pub async fn password_entry( State(state): State, Extension(request_context): Extension, @@ -74,13 +48,13 @@ pub async fn password_entry( Some(&request_context), PasswordEntryResponse { token: signed_session.access_token, - user: PasswordEntryUserPayload { + user: AuthUserPayload { id: result.user.id, username: result.user.username, display_name: result.user.display_name, phone_number_masked: result.user.phone_number_masked, - login_method: result.user.login_method.as_str(), - binding_status: result.user.binding_status.as_str(), + login_method: result.user.login_method.as_str().to_string(), + binding_status: result.user.binding_status.as_str().to_string(), wechat_bound: result.user.wechat_bound, }, }, diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index e2789bab..536ede85 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -7,8 +7,11 @@ use axum::{ use module_auth::{ AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput, }; -use serde::{Deserialize, Serialize}; use serde_json::json; +use shared_contracts::auth::{ + AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, + PhoneSendCodeResponse, +}; use time::OffsetDateTime; use crate::{ @@ -17,42 +20,11 @@ use crate::{ attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, }, http_error::AppError, - password_entry::PasswordEntryUserPayload, request_context::RequestContext, session_client::resolve_session_client_context, state::AppState, }; -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PhoneSendCodeRequest { - pub phone: String, - pub scene: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PhoneSendCodeResponse { - pub ok: bool, - pub cooldown_seconds: u64, - pub expires_in_seconds: u64, - pub provider_request_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PhoneLoginRequest { - pub phone: String, - pub code: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PhoneLoginResponse { - pub token: String, - pub user: PasswordEntryUserPayload, -} - pub async fn send_phone_code( State(state): State, Extension(request_context): Extension, @@ -130,13 +102,13 @@ pub async fn phone_login( Some(&request_context), PhoneLoginResponse { token: signed_session.access_token, - user: PasswordEntryUserPayload { + user: AuthUserPayload { id: result.user.id, username: result.user.username, display_name: result.user.display_name, phone_number_masked: result.user.phone_number_masked, - login_method: result.user.login_method.as_str(), - binding_status: result.user.binding_status.as_str(), + login_method: result.user.login_method.as_str().to_string(), + binding_status: result.user.binding_status.as_str().to_string(), wechat_bound: result.user.wechat_bound, }, }, diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 7494f14a..3f558c92 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -5,7 +5,7 @@ use axum::{ }; use module_auth::{RefreshSessionError, RotateRefreshSessionInput}; use platform_auth::hash_refresh_session_token; -use serde::Serialize; +use shared_contracts::auth::RefreshSessionResponse; use time::OffsetDateTime; use crate::{ @@ -20,12 +20,6 @@ use crate::{ state::AppState, }; -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RefreshSessionResponse { - pub token: String, -} - pub async fn refresh_session( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/request_context.rs b/server-rs/crates/api-server/src/request_context.rs index 9fcaaaf8..bb94a66f 100644 --- a/server-rs/crates/api-server/src/request_context.rs +++ b/server-rs/crates/api-server/src/request_context.rs @@ -6,10 +6,10 @@ use axum::{ middleware::Next, response::Response, }; +use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; use uuid::Uuid; -pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; -pub const X_REQUEST_ID_HEADER: &str = "x-request-id"; +pub use shared_contracts::api::X_REQUEST_ID_HEADER; // 当前阶段先把请求级元信息统一挂到 extensions,后续响应头、envelope 与错误处理中间件继续复用。 #[derive(Clone, Debug)] diff --git a/server-rs/crates/api-server/src/response_headers.rs b/server-rs/crates/api-server/src/response_headers.rs index 6d373222..101d9eda 100644 --- a/server-rs/crates/api-server/src/response_headers.rs +++ b/server-rs/crates/api-server/src/response_headers.rs @@ -4,15 +4,11 @@ use axum::{ middleware::Next, response::Response, }; - -use crate::{ - api_response::API_VERSION, - request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id}, +use shared_contracts::api::{ + API_VERSION, API_VERSION_HEADER, RESPONSE_TIME_HEADER, ROUTE_VERSION_HEADER, }; -pub const API_VERSION_HEADER: &str = "x-api-version"; -pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms"; -pub const ROUTE_VERSION_HEADER: &str = "x-route-version"; +use crate::request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id}; pub async fn propagate_request_id_header(request: Request, next: Next) -> Response { let request_id = resolve_request_id(&request); diff --git a/server-rs/crates/api-server/src/runtime_browse_history.rs b/server-rs/crates/api-server/src/runtime_browse_history.rs new file mode 100644 index 00000000..e79eac8a --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_browse_history.rs @@ -0,0 +1,454 @@ +use axum::{ + Json, + extract::{Extension, State, rejection::JsonRejection}, + http::StatusCode, + response::Response, +}; +use module_runtime::{MAX_BROWSE_HISTORY_BATCH_SIZE, RuntimeBrowseHistoryWriteInput}; +use serde_json::{Value, json}; +use shared_contracts::runtime::{ + BROWSE_HISTORY_THEME_MODE_ARCANE, BROWSE_HISTORY_THEME_MODE_MACHINA, + BROWSE_HISTORY_THEME_MODE_MARTIAL, BROWSE_HISTORY_THEME_MODE_MYTHIC, + BROWSE_HISTORY_THEME_MODE_RIFT, BROWSE_HISTORY_THEME_MODE_TIDE, + PlatformBrowseHistoryEntryResponse, PlatformBrowseHistoryResponse, + PlatformBrowseHistoryUpsertRequest, PlatformBrowseHistoryWriteEntryRequest, +}; +use spacetime_client::SpacetimeClientError; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn get_runtime_browse_history( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let entries = state + .spacetime_client() + .list_platform_browse_history(user_id) + .await + .map_err(|error| { + runtime_browse_history_error_response( + &request_context, + map_runtime_browse_history_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PlatformBrowseHistoryResponse { + entries: entries + .into_iter() + .map(map_browse_history_entry_response) + .collect(), + }, + )) +} + +pub async fn post_runtime_browse_history( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + runtime_browse_history_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "browse-history", + "message": error.body_text(), + })), + ) + })?; + let now_micros = current_utc_micros(); + let user_id = authenticated.claims().user_id().to_string(); + let request_entries = payload.into_entries(); + validate_browse_history_request_entries(&request_context, &request_entries)?; + let entries = request_entries + .into_iter() + .map(|entry| RuntimeBrowseHistoryWriteInput { + owner_user_id: entry.owner_user_id, + profile_id: entry.profile_id, + world_name: entry.world_name, + subtitle: entry.subtitle, + summary_text: entry.summary_text, + cover_image_src: entry.cover_image_src, + theme_mode: entry.theme_mode, + author_display_name: entry.author_display_name, + visited_at: entry.visited_at, + }) + .collect::>(); + let entries = state + .spacetime_client() + .upsert_platform_browse_history_entries(user_id, entries, now_micros) + .await + .map_err(|error| { + runtime_browse_history_error_response( + &request_context, + map_runtime_browse_history_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PlatformBrowseHistoryResponse { + entries: entries + .into_iter() + .map(map_browse_history_entry_response) + .collect(), + }, + )) +} + +pub async fn delete_runtime_browse_history( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let entries = state + .spacetime_client() + .clear_platform_browse_history(user_id) + .await + .map_err(|error| { + runtime_browse_history_error_response( + &request_context, + map_runtime_browse_history_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PlatformBrowseHistoryResponse { + entries: entries + .into_iter() + .map(map_browse_history_entry_response) + .collect(), + }, + )) +} + +fn map_browse_history_entry_response( + entry: module_runtime::RuntimeBrowseHistoryRecord, +) -> PlatformBrowseHistoryEntryResponse { + PlatformBrowseHistoryEntryResponse { + owner_user_id: entry.owner_user_id, + profile_id: entry.profile_id, + world_name: entry.world_name, + subtitle: entry.subtitle, + summary_text: entry.summary_text, + cover_image_src: entry.cover_image_src, + theme_mode: map_browse_history_theme_mode(entry.theme_mode).to_string(), + author_display_name: entry.author_display_name, + visited_at: entry.visited_at, + } +} + +fn map_browse_history_theme_mode( + value: module_runtime::RuntimeBrowseHistoryThemeMode, +) -> &'static str { + match value { + module_runtime::RuntimeBrowseHistoryThemeMode::Martial => BROWSE_HISTORY_THEME_MODE_MARTIAL, + module_runtime::RuntimeBrowseHistoryThemeMode::Arcane => BROWSE_HISTORY_THEME_MODE_ARCANE, + module_runtime::RuntimeBrowseHistoryThemeMode::Machina => BROWSE_HISTORY_THEME_MODE_MACHINA, + module_runtime::RuntimeBrowseHistoryThemeMode::Tide => BROWSE_HISTORY_THEME_MODE_TIDE, + module_runtime::RuntimeBrowseHistoryThemeMode::Rift => BROWSE_HISTORY_THEME_MODE_RIFT, + module_runtime::RuntimeBrowseHistoryThemeMode::Mythic => BROWSE_HISTORY_THEME_MODE_MYTHIC, + } +} + +fn map_runtime_browse_history_client_error(error: SpacetimeClientError) -> AppError { + let (status, provider) = match error { + // 这类错误发生在 Rust 本地 DTO 构建阶段,语义上属于请求不合法,而不是下游不可用。 + SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "browse-history"), + _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), + }; + + AppError::from_status(status).with_details(json!({ + "provider": provider, + "message": error.to_string(), + })) +} + +fn runtime_browse_history_error_response( + request_context: &RequestContext, + error: AppError, +) -> Response { + error.into_response_with_context(Some(request_context)) +} + +fn validate_browse_history_request_entries( + request_context: &RequestContext, + entries: &[PlatformBrowseHistoryWriteEntryRequest], +) -> Result<(), Response> { + if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE { + return Err(runtime_browse_history_error_response( + request_context, + browse_history_bad_request(format!( + "entries 单次最多只允许 {} 条", + MAX_BROWSE_HISTORY_BATCH_SIZE + )), + )); + } + + for entry in entries { + if entry.owner_user_id.trim().is_empty() { + return Err(runtime_browse_history_error_response( + request_context, + browse_history_bad_request("ownerUserId 不能为空"), + )); + } + if entry.profile_id.trim().is_empty() { + return Err(runtime_browse_history_error_response( + request_context, + browse_history_bad_request("profileId 不能为空"), + )); + } + if entry.world_name.trim().is_empty() { + return Err(runtime_browse_history_error_response( + request_context, + browse_history_bad_request("worldName 不能为空"), + )); + } + } + + Ok(()) +} + +fn browse_history_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "browse-history", + "message": message.into(), + })) +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::{Value, json}; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn runtime_browse_history_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/profile/browse-history") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn runtime_browse_history_rejects_blank_required_fields() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/profile/browse-history") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "ownerUserId": " ", + "profileId": "profile-1", + "worldName": "世界A" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("browse-history".to_string()) + ); + } + + #[tokio::test] + async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway() + { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/profile/browse-history") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "entries": [{ + "ownerUserId": "owner-1", + "profileId": "profile-1", + "worldName": "世界A" + }] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn runtime_browse_history_compat_route_matches_main_route_error_shape() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let main_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/profile/browse-history") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + let compat_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/profile/browse-history") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(main_response.status(), compat_response.status()); + + let main_body = main_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let compat_body = compat_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let main_payload: Value = + serde_json::from_slice(&main_body).expect("response body should be valid json"); + let compat_payload: Value = + serde_json::from_slice(&compat_body).expect("response body should be valid json"); + + assert_eq!( + main_payload["error"]["details"]["provider"], + compat_payload["error"]["details"]["provider"] + ); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "browse_history_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_runtime_browse_history".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("浏览历史用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/runtime_inventory.rs b/server-rs/crates/api-server/src/runtime_inventory.rs new file mode 100644 index 00000000..8827182e --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_inventory.rs @@ -0,0 +1,196 @@ +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::Response, +}; +use serde_json::{Value, json}; +use shared_contracts::runtime::{RuntimeInventorySlotResponse, RuntimeInventoryStateResponse}; +use spacetime_client::SpacetimeClientError; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn get_runtime_inventory_state( + State(state): State, + Path(runtime_session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let actor_user_id = authenticated.claims().user_id().to_string(); + let record = state + .spacetime_client() + .get_runtime_inventory_state(runtime_session_id, actor_user_id) + .await + .map_err(|error| { + runtime_inventory_error_response( + &request_context, + map_runtime_inventory_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + RuntimeInventoryStateResponse { + runtime_session_id: record.runtime_session_id, + actor_user_id: record.actor_user_id, + backpack_items: record + .backpack_items + .into_iter() + .map(map_runtime_inventory_slot_response) + .collect(), + equipment_items: record + .equipment_items + .into_iter() + .map(map_runtime_inventory_slot_response) + .collect(), + }, + )) +} + +fn map_runtime_inventory_slot_response( + record: module_inventory::RuntimeInventorySlotRecord, +) -> RuntimeInventorySlotResponse { + RuntimeInventorySlotResponse { + slot_id: record.slot_id, + container_kind: record.container_kind, + slot_key: record.slot_key, + item_id: record.item_id, + category: record.category, + name: record.name, + description: record.description, + quantity: record.quantity, + rarity: record.rarity, + tags: record.tags, + stackable: record.stackable, + stack_key: record.stack_key, + equipment_slot_id: record.equipment_slot_id, + source_kind: record.source_kind, + source_reference_id: record.source_reference_id, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn map_runtime_inventory_client_error(error: SpacetimeClientError) -> AppError { + let (status, provider) = match error { + SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-inventory"), + _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), + }; + + AppError::from_status(status).with_details(json!({ + "provider": provider, + "message": error.to_string(), + })) +} + +fn runtime_inventory_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::Value; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn runtime_inventory_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/sessions/runtime_001/inventory") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn runtime_inventory_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/sessions/runtime_001/inventory") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "runtime_inventory_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_runtime_inventory".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("背包查询用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs new file mode 100644 index 00000000..8372095e --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -0,0 +1,332 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, + response::Response, +}; +use serde_json::{Value, json}; +use shared_contracts::runtime::{ + PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, + ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileWalletLedgerEntryResponse, + ProfileWalletLedgerResponse, +}; +use spacetime_client::SpacetimeClientError; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn get_profile_dashboard( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let record = state + .spacetime_client() + .get_profile_dashboard(user_id) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileDashboardSummaryResponse { + wallet_balance: record.wallet_balance, + total_play_time_ms: record.total_play_time_ms, + played_world_count: record.played_world_count, + updated_at: record.updated_at, + }, + )) +} + +pub async fn get_profile_wallet_ledger( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let entries = state + .spacetime_client() + .list_profile_wallet_ledger(user_id) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileWalletLedgerResponse { + entries: entries + .into_iter() + .map(|entry| ProfileWalletLedgerEntryResponse { + id: entry.wallet_ledger_id, + amount_delta: entry.amount_delta, + balance_after: entry.balance_after, + source_type: match entry.source_type { + module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string() + } + }, + created_at: entry.created_at, + }) + .collect(), + }, + )) +} + +pub async fn get_profile_play_stats( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let record = state + .spacetime_client() + .get_profile_play_stats(user_id) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfilePlayStatsResponse { + total_play_time_ms: record.total_play_time_ms, + played_works: record + .played_works + .into_iter() + .map(|entry| ProfilePlayedWorkSummaryResponse { + world_key: entry.world_key, + owner_user_id: entry.owner_user_id, + profile_id: entry.profile_id, + world_type: entry.world_type, + world_title: entry.world_title, + world_subtitle: entry.world_subtitle, + first_played_at: entry.first_played_at, + last_played_at: entry.last_played_at, + last_observed_play_time_ms: entry.last_observed_play_time_ms, + }) + .collect(), + updated_at: record.updated_at, + }, + )) +} + +fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { + let (status, provider) = match error { + SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), + _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), + }; + + AppError::from_status(status).with_details(json!({ + "provider": provider, + "message": error.to_string(), + })) +} + +fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::Value; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn profile_dashboard_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/profile/dashboard") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn profile_wallet_ledger_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/profile/wallet-ledger") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn profile_play_stats_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/profile/play-stats") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn profile_dashboard_compat_route_matches_main_route_error_shape() { + assert_compat_route_matches_main_route_error_shape( + "/api/runtime/profile/dashboard", + "/api/profile/dashboard", + ) + .await; + } + + #[tokio::test] + async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() { + assert_compat_route_matches_main_route_error_shape( + "/api/runtime/profile/wallet-ledger", + "/api/profile/wallet-ledger", + ) + .await; + } + + #[tokio::test] + async fn profile_play_stats_compat_route_matches_main_route_error_shape() { + assert_compat_route_matches_main_route_error_shape( + "/api/runtime/profile/play-stats", + "/api/profile/play-stats", + ) + .await; + } + + async fn assert_compat_route_matches_main_route_error_shape( + main_route: &str, + compat_route: &str, + ) { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let main_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(main_route) + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + let compat_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(compat_route) + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(main_response.status(), compat_response.status()); + + let main_body = main_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let compat_body = compat_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let main_payload: Value = + serde_json::from_slice(&main_body).expect("response body should be valid json"); + let compat_payload: Value = + serde_json::from_slice(&compat_body).expect("response body should be valid json"); + + assert_eq!( + main_payload["error"]["details"]["provider"], + compat_payload["error"]["details"]["provider"] + ); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "runtime_profile_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_runtime_profile".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("资料页用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/runtime_settings.rs b/server-rs/crates/api-server/src/runtime_settings.rs new file mode 100644 index 00000000..699baa68 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_settings.rs @@ -0,0 +1,372 @@ +use axum::{ + Json, + extract::{Extension, State, rejection::JsonRejection}, + http::StatusCode, + response::Response, +}; +use module_runtime::{ + RuntimePlatformTheme, RuntimeSettingsFieldError, build_runtime_setting_upsert_input, +}; +use serde_json::{Value, json}; +use shared_contracts::runtime::{ + PutRuntimeSettingsRequest, RUNTIME_PLATFORM_THEME_DARK, RUNTIME_PLATFORM_THEME_LIGHT, + RuntimeSettingsResponse, +}; +use spacetime_client::SpacetimeClientError; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn get_runtime_settings( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let settings = state + .spacetime_client() + .get_runtime_settings(user_id) + .await + .map_err(|error| { + runtime_settings_error_response( + &request_context, + map_runtime_settings_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + RuntimeSettingsResponse { + music_volume: settings.music_volume, + platform_theme: settings.platform_theme.as_str().to_string(), + }, + )) +} + +pub async fn put_runtime_settings( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + runtime_settings_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-settings", + "message": error.body_text(), + })), + ) + })?; + let user_id = authenticated.claims().user_id().to_string(); + let theme = parse_platform_theme_strict(&payload.platform_theme).ok_or_else(|| { + runtime_settings_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-settings", + "message": "platformTheme 仅支持 light 或 dark", + })), + ) + })?; + if !(0.0..=1.0).contains(&payload.music_volume) { + return Err(runtime_settings_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-settings", + "message": "musicVolume 必须在 0 到 1 之间", + })), + )); + } + let now_micros = current_utc_micros(); + let prepared = + build_runtime_setting_upsert_input(user_id, payload.music_volume, theme, now_micros) + .map_err(|error| { + runtime_settings_error_response( + &request_context, + map_runtime_settings_prepare_error(error), + ) + })?; + let settings = state + .spacetime_client() + .put_runtime_settings( + prepared.user_id, + prepared.music_volume, + prepared.platform_theme, + prepared.updated_at_micros, + ) + .await + .map_err(|error| { + runtime_settings_error_response( + &request_context, + map_runtime_settings_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + RuntimeSettingsResponse { + music_volume: settings.music_volume, + platform_theme: settings.platform_theme.as_str().to_string(), + }, + )) +} + +fn map_runtime_settings_prepare_error(error: RuntimeSettingsFieldError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-settings", + "message": error.to_string(), + })) +} + +fn map_runtime_settings_client_error(error: SpacetimeClientError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn runtime_settings_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +fn parse_platform_theme_strict(raw: &str) -> Option { + match raw.trim() { + RUNTIME_PLATFORM_THEME_LIGHT => Some(RuntimePlatformTheme::Light), + RUNTIME_PLATFORM_THEME_DARK => Some(RuntimePlatformTheme::Dark), + _ => None, + } +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::{Value, json}; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn runtime_settings_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/settings") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/settings") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn runtime_settings_rejects_invalid_theme_with_envelope() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/runtime/settings") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "musicVolume": 0.42, + "platformTheme": "mythic" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("runtime-settings".to_string()) + ); + } + + #[tokio::test] + #[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module;验证 PUT/GET settings 主链"] + async fn runtime_settings_round_trip_against_local_spacetimedb() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let put_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/runtime/settings") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "musicVolume": 1.4, + "platformTheme": "dark" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(put_response.status(), StatusCode::OK); + let put_body = put_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let put_payload: Value = + serde_json::from_slice(&put_body).expect("response body should be valid json"); + + assert_eq!( + put_payload["data"]["platformTheme"], + Value::String("dark".to_string()) + ); + assert_eq!(put_payload["data"]["musicVolume"], json!(1.0)); + + let get_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/settings") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(get_response.status(), StatusCode::OK); + let get_body = get_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let get_payload: Value = + serde_json::from_slice(&get_body).expect("response body should be valid json"); + + assert_eq!( + get_payload["data"]["platformTheme"], + Value::String("dark".to_string()) + ); + assert_eq!(get_payload["data"]["musicVolume"], json!(1.0)); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "runtime_settings_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_runtime_settings".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("设置用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/runtime_story.rs b/server-rs/crates/api-server/src/runtime_story.rs new file mode 100644 index 00000000..ab48af08 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_story.rs @@ -0,0 +1,593 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, + response::Response, +}; +use serde_json::{Value, json}; +use shared_contracts::runtime_story::{ + RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, + RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel, + RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, + RuntimeStoryStatusViewModel, RuntimeStoryViewModel, +}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn resolve_runtime_story_state( + State(_state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| { + runtime_story_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "field": "sessionId", + "message": "sessionId 不能为空", + })), + ) + })?; + let snapshot = payload.snapshot.ok_or_else(|| { + runtime_story_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-story", + "field": "snapshot", + "message": "当前首版兼容状态桥要求随请求提交 snapshot", + })), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_runtime_story_state_response( + &session_id, + payload.client_version, + snapshot, + ), + )) +} + +fn build_runtime_story_state_response( + requested_session_id: &str, + client_version: Option, + snapshot: RuntimeStorySnapshotPayload, +) -> RuntimeStoryActionResponse { + let session_id = read_runtime_session_id(&snapshot.game_state) + .unwrap_or_else(|| requested_session_id.to_string()); + let options = build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state); + let story_text = + read_story_text(snapshot.current_story.as_ref()).unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state)); + let server_version = + read_u32_field(&snapshot.game_state, "runtimeActionVersion").or(client_version).unwrap_or(0); + + RuntimeStoryActionResponse { + session_id, + server_version, + view_model: RuntimeStoryViewModel { + player: RuntimeStoryPlayerViewModel { + hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0), + max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1), + mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0), + max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1), + }, + encounter: build_runtime_story_encounter(&snapshot.game_state), + companions: build_runtime_story_companions(&snapshot.game_state), + available_options: options.clone(), + status: RuntimeStoryStatusViewModel { + in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false), + npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive") + .unwrap_or(false), + current_npc_battle_mode: read_optional_string_field( + &snapshot.game_state, + "currentNpcBattleMode", + ), + current_npc_battle_outcome: read_optional_string_field( + &snapshot.game_state, + "currentNpcBattleOutcome", + ), + }, + }, + presentation: RuntimeStoryPresentation { + action_text: String::new(), + result_text: String::new(), + story_text, + options, + toast: None, + battle: None, + }, + patches: Vec::new(), + snapshot, + } +} + +fn build_runtime_story_companions(game_state: &Value) -> Vec { + read_array_field(game_state, "companions") + .into_iter() + .filter_map(|entry| { + let npc_id = read_required_string_field(entry, "npcId")?; + Some(RuntimeStoryCompanionViewModel { + npc_id, + character_id: read_optional_string_field(entry, "characterId"), + joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0), + }) + }) + .collect() +} + +fn build_runtime_story_encounter(game_state: &Value) -> Option { + let encounter = read_object_field(game_state, "currentEncounter")?; + let npc_name = read_required_string_field(encounter, "npcName")?; + let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); + let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name); + + Some(RuntimeStoryEncounterViewModel { + id: encounter_id, + kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()), + npc_name, + hostile: read_bool_field(encounter, "hostile").unwrap_or(false), + affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")), + recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")), + interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false), + battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), + }) +} + +fn resolve_current_encounter_npc_state<'a>( + game_state: &'a Value, + encounter_id: &str, + npc_name: &str, +) -> Option<&'a Value> { + let npc_states = read_object_field(game_state, "npcStates")?; + + npc_states + .get(encounter_id) + .or_else(|| npc_states.get(npc_name)) +} + +fn build_runtime_story_options( + current_story: Option<&Value>, + game_state: &Value, +) -> Vec { + if let Some(story) = current_story { + let prefers_deferred = read_required_string_field(story, "displayMode") + .is_some_and(|value| value == "dialogue") + && !read_array_field(story, "deferredOptions").is_empty(); + + let source = if prefers_deferred { + read_array_field(story, "deferredOptions") + } else { + read_array_field(story, "options") + }; + + let compiled = source + .into_iter() + .filter_map(build_runtime_story_option_from_story_option) + .collect::>(); + + if !compiled.is_empty() { + return compiled; + } + } + + build_fallback_runtime_story_options(game_state) +} + +fn build_runtime_story_option_from_story_option(value: &Value) -> Option { + let function_id = read_required_string_field(value, "functionId")?; + let action_text = read_required_string_field(value, "actionText") + .or_else(|| read_required_string_field(value, "text")) + .unwrap_or_else(|| function_id.clone()); + + Some(RuntimeStoryOptionView { + scope: infer_option_scope(function_id.as_str()).to_string(), + detail_text: read_optional_string_field(value, "detailText"), + interaction: build_runtime_story_option_interaction(read_field(value, "interaction")), + payload: read_field(value, "runtimePayload").cloned(), + disabled: read_bool_field(value, "disabled"), + reason: read_optional_string_field(value, "disabledReason") + .or_else(|| read_optional_string_field(value, "reason")), + function_id, + action_text, + }) +} + +fn build_runtime_story_option_interaction( + value: Option<&Value>, +) -> Option { + let interaction = value?; + match read_required_string_field(interaction, "kind")?.as_str() { + "npc" => Some(RuntimeStoryOptionInteraction::Npc { + npc_id: read_required_string_field(interaction, "npcId")?, + action: read_required_string_field(interaction, "action")?, + quest_id: read_optional_string_field(interaction, "questId"), + }), + "treasure" => Some(RuntimeStoryOptionInteraction::Treasure { + action: read_required_string_field(interaction, "action")?, + }), + _ => None, + } +} + +fn build_fallback_runtime_story_options(game_state: &Value) -> Vec { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return vec![ + build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"), + build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"), + build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"), + ]; + } + + let encounter = read_object_field(game_state, "currentEncounter"); + if let Some(encounter) = encounter { + match read_required_string_field(encounter, "kind").as_deref() { + Some("npc") => { + let interaction_active = + read_bool_field(game_state, "npcInteractionActive").unwrap_or(false); + if interaction_active { + return vec![ + build_static_runtime_story_option("npc_chat", "继续交谈", "npc"), + build_static_runtime_story_option("npc_help", "请求援手", "npc"), + build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"), + build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"), + build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"), + ]; + } + + return vec![ + build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"), + build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"), + build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"), + ]; + } + Some("treasure") => { + return vec![ + build_static_runtime_story_option("treasure_secure", "直接收取", "story"), + build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"), + build_static_runtime_story_option("treasure_leave", "先记下位置", "story"), + ]; + } + _ => {} + } + } + + vec![ + build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"), + build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"), + build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"), + build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"), + build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"), + build_static_runtime_story_option("story_continue_adventure", "继续推进冒险", "story"), + ] +} + +fn build_static_runtime_story_option( + function_id: &str, + action_text: &str, + scope: &str, +) -> RuntimeStoryOptionView { + RuntimeStoryOptionView { + function_id: function_id.to_string(), + action_text: action_text.to_string(), + detail_text: None, + scope: scope.to_string(), + interaction: None, + payload: None, + disabled: None, + reason: None, + } +} + +fn infer_option_scope(function_id: &str) -> &'static str { + if function_id.starts_with("battle_") || function_id == "inventory_use" { + "combat" + } else if function_id.starts_with("npc_") { + "npc" + } else { + "story" + } +} + +fn read_story_text(current_story: Option<&Value>) -> Option { + current_story.and_then(|story| read_optional_string_field(story, "text")) +} + +fn build_fallback_story_text(game_state: &Value) -> String { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + let encounter_name = read_object_field(game_state, "currentEncounter") + .and_then(|encounter| read_optional_string_field(encounter, "npcName")) + .unwrap_or_else(|| "眼前的敌人".to_string()); + return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。"); + } + + if let Some(encounter) = read_object_field(game_state, "currentEncounter") + && let Some(npc_name) = read_optional_string_field(encounter, "npcName") + { + return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。"); + } + + "当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string() +} + +fn read_runtime_session_id(game_state: &Value) -> Option { + read_optional_string_field(game_state, "runtimeSessionId") +} + +fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + value.as_object()?.get(key) +} + +fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + let field = read_field(value, key)?; + field.is_object().then_some(field) +} + +fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> { + read_field(value, key) + .and_then(Value::as_array) + .map(|items| items.iter().collect()) + .unwrap_or_default() +} + +fn read_required_string_field(value: &Value, key: &str) -> Option { + normalize_required_string(read_field(value, key)?.as_str()?) +} + +fn read_optional_string_field(value: &Value, key: &str) -> Option { + normalize_optional_string(read_field(value, key).and_then(Value::as_str)) +} + +fn read_bool_field(value: &Value, key: &str) -> Option { + read_field(value, key).and_then(Value::as_bool) +} + +fn read_i32_field(value: &Value, key: &str) -> Option { + read_field(value, key) + .and_then(Value::as_i64) + .and_then(|number| i32::try_from(number).ok()) +} + +fn read_u32_field(value: &Value, key: &str) -> Option { + read_field(value, key) + .and_then(Value::as_u64) + .and_then(|number| u32::try_from(number).ok()) +} + +fn normalize_required_string(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn normalize_optional_string(value: Option<&str>) -> Option { + value.and_then(normalize_required_string) +} + +fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::{Value, json}; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn runtime_story_state_resolve_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/state/resolve") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "sessionId": "runtime-main", + "snapshot": { + "savedAt": "2026-04-22T12:00:00.000Z", + "bottomTab": "adventure", + "gameState": { + "runtimeSessionId": "runtime-main" + }, + "currentStory": null + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn runtime_story_state_resolve_rejects_missing_snapshot() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/state/resolve") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "sessionId": "runtime-main" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn runtime_story_state_resolve_returns_compiled_snapshot_response() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/state/resolve") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "sessionId": "runtime-main", + "clientVersion": 7, + "snapshot": { + "savedAt": "2026-04-22T12:00:00.000Z", + "bottomTab": "adventure", + "gameState": { + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 7, + "playerHp": 32, + "playerMaxHp": 40, + "playerMana": 18, + "playerMaxMana": 20, + "inBattle": false, + "npcInteractionActive": true, + "currentEncounter": { + "id": "npc_camp_firekeeper", + "kind": "npc", + "npcName": "守火人", + "hostile": false + }, + "npcStates": { + "npc_camp_firekeeper": { + "affinity": 12, + "recruited": false + } + }, + "companions": [{ + "npcId": "npc_companion_001", + "characterId": "char_companion_001", + "joinedAtAffinity": 64 + }] + }, + "currentStory": { + "text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。", + "displayMode": "dialogue", + "options": [{ + "functionId": "story_continue_adventure", + "actionText": "继续冒险" + }], + "deferredOptions": [{ + "functionId": "npc_chat", + "actionText": "继续交谈", + "detailText": "围绕当前话题继续推进关系判断。", + "interaction": { + "kind": "npc", + "npcId": "npc_camp_firekeeper", + "action": "chat" + }, + "runtimePayload": { + "note": "server-runtime-test" + } + }] + } + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!(payload["data"]["sessionId"], json!("runtime-main")); + assert_eq!(payload["data"]["serverVersion"], json!(7)); + assert_eq!( + payload["data"]["viewModel"]["encounter"]["npcName"], + json!("守火人") + ); + assert_eq!( + payload["data"]["viewModel"]["availableOptions"][0]["functionId"], + json!("npc_chat") + ); + assert_eq!( + payload["data"]["presentation"]["options"][0]["interaction"]["npcId"], + json!("npc_camp_firekeeper") + ); + assert_eq!( + payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"], + json!("npc_chat") + ); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "runtime_story_state_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_runtime_story_state".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("运行时剧情状态用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/session_client.rs b/server-rs/crates/api-server/src/session_client.rs index c6a7ae9b..0b86287c 100644 --- a/server-rs/crates/api-server/src/session_client.rs +++ b/server-rs/crates/api-server/src/session_client.rs @@ -1,6 +1,7 @@ use axum::http::HeaderMap; use module_auth::RefreshSessionClientInfo; use platform_auth::hash_refresh_session_token; +use shared_kernel::normalize_optional_string; const X_CLIENT_TYPE_HEADER: &str = "x-client-type"; const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime"; @@ -104,17 +105,6 @@ fn header_value(headers: &HeaderMap, name: &str) -> Option { .map(ToOwned::to_owned) } -fn normalize_optional_string(value: Option) -> Option { - value.and_then(|raw| { - let normalized = raw.trim().to_string(); - if normalized.is_empty() { - return None; - } - - Some(normalized) - }) -} - fn normalize_client_type(value: Option) -> Option { value.and_then(|raw| { let normalized = raw.trim().to_ascii_lowercase(); diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 21fd3e1f..95eabadd 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,5 +1,6 @@ use std::{error::Error, fmt}; +use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, RefreshSessionService, WechatAuthService, WechatAuthStateService, @@ -7,6 +8,7 @@ use module_auth::{ use platform_auth::{ JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, }; +use platform_llm::{LlmClient, LlmConfig, LlmError}; use platform_oss::{OssClient, OssConfig, OssError}; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig}; @@ -29,7 +31,10 @@ pub struct AppState { wechat_auth_state_service: WechatAuthStateService, wechat_auth_service: WechatAuthService, wechat_provider: WechatProvider, + #[cfg_attr(not(test), allow(dead_code))] + ai_task_service: AiTaskService, spacetime_client: SpacetimeClient, + llm_client: Option, } #[derive(Debug)] @@ -37,6 +42,7 @@ pub enum AppStateInitError { Jwt(JwtError), RefreshCookie(RefreshCookieError), Oss(OssError), + Llm(LlmError), } impl AppState { @@ -68,11 +74,14 @@ impl AppState { let wechat_provider = build_wechat_provider(&config); let refresh_session_service = RefreshSessionService::new(auth_store, config.refresh_session_ttl_days); + // AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。 + let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default()); let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig { server_url: config.spacetime_server_url.clone(), database: config.spacetime_database.clone(), token: config.spacetime_token.clone(), }); + let llm_client = build_llm_client(&config)?; Ok(Self { config, @@ -86,7 +95,9 @@ impl AppState { wechat_auth_state_service, wechat_auth_service, wechat_provider, + ai_task_service, spacetime_client, + llm_client, }) } @@ -130,9 +141,18 @@ impl AppState { &self.wechat_provider } + #[cfg_attr(not(test), allow(dead_code))] + pub fn ai_task_service(&self) -> &AiTaskService { + &self.ai_task_service + } + pub fn spacetime_client(&self) -> &SpacetimeClient { &self.spacetime_client } + + pub fn llm_client(&self) -> Option<&LlmClient> { + self.llm_client.as_ref() + } } impl fmt::Display for AppStateInitError { @@ -141,6 +161,7 @@ impl fmt::Display for AppStateInitError { Self::Jwt(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"), Self::Oss(error) => write!(f, "{error}"), + Self::Llm(error) => write!(f, "{error}"), } } } @@ -165,6 +186,12 @@ impl From for AppStateInitError { } } +impl From for AppStateInitError { + fn from(value: LlmError) -> Self { + Self::Llm(value) + } +} + fn build_oss_client(config: &AppConfig) -> Result, AppStateInitError> { let has_any_oss_field = config.oss_bucket.is_some() || config.oss_endpoint.is_some() @@ -188,3 +215,65 @@ fn build_oss_client(config: &AppConfig) -> Result, AppStateIni Ok(Some(OssClient::new(oss_config))) } + +fn build_llm_client(config: &AppConfig) -> Result, AppStateInitError> { + let Some(api_key) = config + .llm_api_key + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + + let llm_config = LlmConfig::new( + config.llm_provider, + config.llm_base_url.clone(), + api_key.to_string(), + config.llm_model.clone(), + config.llm_request_timeout_ms, + config.llm_max_retries, + config.llm_retry_backoff_ms, + )?; + + Ok(Some(LlmClient::new(llm_config)?)) +} + +#[cfg(test)] +mod tests { + use module_ai::{AiTaskKind, generate_ai_task_id}; + + use super::*; + + #[test] + fn app_state_exposes_usable_ai_task_service() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let task_id = generate_ai_task_id(1_713_680_000_000_000); + + let created = state + .ai_task_service() + .create_task(module_ai::AiTaskCreateInput { + task_id: task_id.clone(), + task_kind: AiTaskKind::StoryGeneration, + owner_user_id: "user_001".to_string(), + request_label: "营地开场".to_string(), + source_module: "story".to_string(), + source_entity_id: Some("storysess_001".to_string()), + request_payload_json: Some("{\"scene\":\"camp\"}".to_string()), + stages: AiTaskKind::StoryGeneration.default_stage_blueprints(), + created_at_micros: 1_713_680_000_000_000, + }) + .expect("ai task should create"); + + assert_eq!(created.task_id, task_id); + assert_eq!(created.task_kind, AiTaskKind::StoryGeneration); + assert_eq!(created.stages.len(), 4); + } + + #[test] + fn app_state_skips_llm_client_when_api_key_missing() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + + assert!(state.llm_client().is_none()); + } +} diff --git a/server-rs/crates/api-server/src/story_battles.rs b/server-rs/crates/api-server/src/story_battles.rs new file mode 100644 index 00000000..96f254df --- /dev/null +++ b/server-rs/crates/api-server/src/story_battles.rs @@ -0,0 +1,829 @@ +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::Response, +}; +use module_combat::{ + BattleMode, BattleStateInput, ResolveCombatActionInput, generate_battle_state_id, +}; +use module_npc::{NPC_FIGHT_FUNCTION_ID, NPC_SPAR_FUNCTION_ID, ResolveNpcInteractionInput}; +use serde::Deserialize; +use serde_json::{Value, json}; +use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; +use spacetime_client::{ResolveNpcBattleInteractionInput, SpacetimeClientError}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateStoryBattleRequest { + pub story_session_id: String, + pub runtime_session_id: String, + #[serde(default)] + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: String, + 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, + #[serde(default)] + pub experience_reward: u32, + #[serde(default)] + pub reward_items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveStoryBattleRequest { + pub battle_state_id: String, + pub function_id: String, + pub action_text: String, + pub base_damage: i32, + pub mana_cost: i32, + pub heal: i32, + pub mana_restore: i32, + pub counter_multiplier_basis_points: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateStoryNpcBattleRequest { + pub story_session_id: String, + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub interaction_function_id: String, + #[serde(default)] + pub release_npc_id: Option, + #[serde(default)] + 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, + #[serde(default)] + pub experience_reward: u32, + #[serde(default)] + pub reward_items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoryBattleRewardItemRequest { + pub item_id: String, + pub category: String, + pub item_name: String, + #[serde(default)] + pub description: Option, + pub quantity: u32, + pub rarity: String, + #[serde(default)] + pub tags: Vec, + pub stackable: bool, + #[serde(default)] + pub stack_key: String, + #[serde(default)] + pub equipment_slot_id: Option, +} + +pub async fn create_story_battle( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let now_micros = current_utc_micros(); + let actor_user_id = authenticated.claims().user_id().to_string(); + let battle_mode = parse_battle_mode_strict(&payload.battle_mode).ok_or_else(|| { + story_battles_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "story-battle", + "message": "battleMode 仅支持 fight 或 spar", + })), + ) + })?; + let reward_items = + parse_story_battle_reward_items(&payload.reward_items).map_err(|message| { + story_battles_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "story-battle", + "message": message, + })), + ) + })?; + + let result = state + .spacetime_client() + .create_battle_state(BattleStateInput { + battle_state_id: generate_battle_state_id(now_micros), + story_session_id: payload.story_session_id, + runtime_session_id: payload.runtime_session_id, + actor_user_id, + chapter_id: payload.chapter_id, + target_npc_id: payload.target_npc_id, + target_name: payload.target_name, + battle_mode, + player_hp: payload.player_hp, + player_max_hp: payload.player_max_hp, + player_mana: payload.player_mana, + player_max_mana: payload.player_max_mana, + target_hp: payload.target_hp, + target_max_hp: payload.target_max_hp, + experience_reward: payload.experience_reward, + reward_items, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + story_battles_error_response(&request_context, map_story_battle_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "battleState": build_battle_state_payload(&result), + }), + )) +} + +pub async fn resolve_story_battle( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let now_micros = current_utc_micros(); + let result = state + .spacetime_client() + .resolve_combat_action(ResolveCombatActionInput { + battle_state_id: payload.battle_state_id, + function_id: payload.function_id, + action_text: payload.action_text, + base_damage: payload.base_damage, + mana_cost: payload.mana_cost, + heal: payload.heal, + mana_restore: payload.mana_restore, + counter_multiplier_basis_points: payload.counter_multiplier_basis_points, + updated_at_micros: now_micros, + }) + .await + .map_err(|error| { + story_battles_error_response(&request_context, map_story_battle_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "battleState": build_battle_state_payload(&result.battle_state), + "combat": { + "damageDealt": result.damage_dealt, + "damageTaken": result.damage_taken, + "outcome": result.outcome, + } + }), + )) +} + +pub async fn get_story_battle_state( + State(state): State, + Path(battle_state_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + let result = state + .spacetime_client() + .get_battle_state(battle_state_id) + .await + .map_err(|error| { + story_battles_error_response(&request_context, map_story_battle_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "battleState": build_battle_state_payload(&result), + }), + )) +} + +pub async fn create_story_npc_battle( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let now_micros = current_utc_micros(); + let actor_user_id = authenticated.claims().user_id().to_string(); + let interaction_function_id = + parse_npc_battle_interaction_function_id_strict(&payload.interaction_function_id) + .ok_or_else(|| { + story_battles_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "story-npc-battle", + "message": "interactionFunctionId 仅支持 npc_fight 或 npc_spar", + })), + ) + })?; + let reward_items = + parse_story_battle_reward_items(&payload.reward_items).map_err(|message| { + story_battles_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "story-npc-battle", + "message": message, + })), + ) + })?; + + let result = state + .spacetime_client() + .resolve_npc_battle_interaction(ResolveNpcBattleInteractionInput { + npc_interaction: ResolveNpcInteractionInput { + runtime_session_id: payload.runtime_session_id, + npc_id: payload.npc_id, + npc_name: payload.npc_name, + interaction_function_id, + release_npc_id: payload.release_npc_id, + updated_at_micros: now_micros, + }, + story_session_id: payload.story_session_id, + actor_user_id, + battle_state_id: payload.battle_state_id, + player_hp: payload.player_hp, + player_max_hp: payload.player_max_hp, + player_mana: payload.player_mana, + player_max_mana: payload.player_max_mana, + target_hp: payload.target_hp, + target_max_hp: payload.target_max_hp, + experience_reward: payload.experience_reward, + reward_items, + }) + .await + .map_err(|error| { + story_battles_error_response(&request_context, map_story_battle_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "npcInteraction": build_npc_interaction_payload(&result.npc_interaction), + "battleState": build_battle_state_payload(&result.battle_state), + }), + )) +} + +fn build_battle_state_payload(record: &spacetime_client::BattleStateRecord) -> Value { + json!({ + "battleStateId": record.battle_state_id, + "storySessionId": record.story_session_id, + "runtimeSessionId": record.runtime_session_id, + "actorUserId": record.actor_user_id, + "chapterId": record.chapter_id, + "targetNpcId": record.target_npc_id, + "targetName": record.target_name, + "battleMode": record.battle_mode, + "status": record.status, + "playerHp": record.player_hp, + "playerMaxHp": record.player_max_hp, + "playerMana": record.player_mana, + "playerMaxMana": record.player_max_mana, + "targetHp": record.target_hp, + "targetMaxHp": record.target_max_hp, + "experienceReward": record.experience_reward, + "rewardItems": record.reward_items.iter().map(|item| { + json!({ + "itemId": item.item_id, + "category": item.category, + "itemName": item.item_name, + "description": item.description, + "quantity": item.quantity, + "rarity": format_runtime_item_reward_item_rarity(item.rarity), + "tags": item.tags, + "stackable": item.stackable, + "stackKey": item.stack_key, + "equipmentSlotId": item + .equipment_slot_id + .map(format_runtime_item_equipment_slot), + }) + }).collect::>(), + "turnIndex": record.turn_index, + "lastActionFunctionId": record.last_action_function_id, + "lastActionText": record.last_action_text, + "lastResultText": record.last_result_text, + "lastDamageDealt": record.last_damage_dealt, + "lastDamageTaken": record.last_damage_taken, + "lastOutcome": record.last_outcome, + "version": record.version, + "createdAt": record.created_at, + "updatedAt": record.updated_at, + }) +} + +fn format_runtime_item_reward_item_rarity( + value: module_runtime_item::RuntimeItemRewardItemRarity, +) -> &'static str { + match value { + module_runtime_item::RuntimeItemRewardItemRarity::Common => "common", + module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => "uncommon", + module_runtime_item::RuntimeItemRewardItemRarity::Rare => "rare", + module_runtime_item::RuntimeItemRewardItemRarity::Epic => "epic", + module_runtime_item::RuntimeItemRewardItemRarity::Legendary => "legendary", + } +} + +fn format_runtime_item_equipment_slot( + value: module_runtime_item::RuntimeItemEquipmentSlot, +) -> &'static str { + match value { + module_runtime_item::RuntimeItemEquipmentSlot::Weapon => "weapon", + module_runtime_item::RuntimeItemEquipmentSlot::Armor => "armor", + module_runtime_item::RuntimeItemEquipmentSlot::Relic => "relic", + } +} + +fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> Value { + json!({ + "npcStateId": record.npc_state_id, + "runtimeSessionId": record.runtime_session_id, + "npcId": record.npc_id, + "npcName": record.npc_name, + "affinity": record.affinity, + "relationStance": record.relation_stance, + "helpUsed": record.help_used, + "chattedCount": record.chatted_count, + "giftsGiven": record.gifts_given, + "recruited": record.recruited, + "tradeStockSignature": record.trade_stock_signature, + "revealedFacts": record.revealed_facts, + "knownAttributeRumors": record.known_attribute_rumors, + "firstMeaningfulContactResolved": record.first_meaningful_contact_resolved, + "seenBackstoryChapterIds": record.seen_backstory_chapter_ids, + "stanceProfile": { + "trust": record.trust, + "warmth": record.warmth, + "ideologicalFit": record.ideological_fit, + "fearOrGuard": record.fear_or_guard, + "loyalty": record.loyalty, + "currentConflictTag": record.current_conflict_tag, + "recentApprovals": record.recent_approvals, + "recentDisapprovals": record.recent_disapprovals, + }, + "createdAt": record.created_at, + "updatedAt": record.updated_at, + }) +} + +fn build_npc_interaction_payload(record: &spacetime_client::NpcInteractionRecord) -> Value { + json!({ + "npcState": build_npc_state_payload(&record.npc_state), + "interactionStatus": record.interaction_status, + "actionText": record.action_text, + "resultText": record.result_text, + "storyText": record.story_text, + "battleMode": record.battle_mode, + "encounterClosed": record.encounter_closed, + "affinityChanged": record.affinity_changed, + "previousAffinity": record.previous_affinity, + "nextAffinity": record.next_affinity, + }) +} + +fn parse_battle_mode_strict(raw: &str) -> Option { + match raw.trim() { + "fight" => Some(BattleMode::Fight), + "spar" => Some(BattleMode::Spar), + _ => None, + } +} + +fn parse_npc_battle_interaction_function_id_strict(raw: &str) -> Option { + match raw.trim() { + NPC_FIGHT_FUNCTION_ID => Some(NPC_FIGHT_FUNCTION_ID.to_string()), + NPC_SPAR_FUNCTION_ID => Some(NPC_SPAR_FUNCTION_ID.to_string()), + _ => None, + } +} + +fn parse_story_battle_reward_items( + values: &[StoryBattleRewardItemRequest], +) -> Result, String> { + values.iter().map(parse_story_battle_reward_item).collect() +} + +fn parse_story_battle_reward_item( + value: &StoryBattleRewardItemRequest, +) -> Result { + Ok(module_runtime_item::RuntimeItemRewardItemSnapshot { + item_id: normalize_required_string(&value.item_id) + .ok_or_else(|| "battleState.rewardItems[].itemId 不能为空".to_string())?, + category: normalize_required_string(&value.category) + .ok_or_else(|| "battleState.rewardItems[].category 不能为空".to_string())?, + item_name: normalize_required_string(&value.item_name) + .ok_or_else(|| "battleState.rewardItems[].itemName 不能为空".to_string())?, + description: normalize_optional_string(value.description.clone()), + quantity: value.quantity, + rarity: parse_runtime_item_reward_item_rarity(&value.rarity)?, + tags: normalize_string_list(value.tags.clone()), + stackable: value.stackable, + stack_key: value.stack_key.trim().to_string(), + equipment_slot_id: value + .equipment_slot_id + .as_deref() + .map(parse_runtime_item_equipment_slot) + .transpose()?, + }) +} + +fn parse_runtime_item_reward_item_rarity( + raw: &str, +) -> Result { + match raw.trim() { + "common" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Common), + "uncommon" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Uncommon), + "rare" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Rare), + "epic" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Epic), + "legendary" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Legendary), + _ => Err( + "battleState.rewardItems[].rarity 仅支持 common/uncommon/rare/epic/legendary" + .to_string(), + ), + } +} + +fn parse_runtime_item_equipment_slot( + raw: &str, +) -> Result { + match raw.trim() { + "weapon" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Weapon), + "armor" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Armor), + "relic" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Relic), + _ => Err("battleState.rewardItems[].equipmentSlotId 仅支持 weapon/armor/relic".to_string()), + } +} + +fn map_story_battle_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn story_battles_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::{Value, json}; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn create_story_battle_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/battles") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "storySessionId": "storysess_001", + "runtimeSessionId": "runtime_001", + "targetNpcId": "npc_001", + "targetName": "黑爪狼", + "battleMode": "fight", + "playerHp": 60, + "playerMaxHp": 60, + "playerMana": 20, + "playerMaxMana": 20, + "targetHp": 30, + "targetMaxHp": 30 + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn create_story_npc_battle_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/npc/battle") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "storySessionId": "storysess_001", + "runtimeSessionId": "runtime_001", + "npcId": "npc_001", + "npcName": "试剑门徒", + "interactionFunctionId": "npc_fight", + "playerHp": 60, + "playerMaxHp": 60, + "playerMana": 20, + "playerMaxMana": 20, + "targetHp": 30, + "targetMaxHp": 30 + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn create_story_battle_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/battles") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "storySessionId": "storysess_001", + "runtimeSessionId": "runtime_001", + "targetNpcId": "npc_001", + "targetName": "黑爪狼", + "battleMode": "fight", + "playerHp": 60, + "playerMaxHp": 60, + "playerMana": 20, + "playerMaxMana": 20, + "targetHp": 30, + "targetMaxHp": 30 + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn create_story_npc_battle_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/npc/battle") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "storySessionId": "storysess_001", + "runtimeSessionId": "runtime_001", + "npcId": "npc_001", + "npcName": "试剑门徒", + "interactionFunctionId": "npc_fight", + "playerHp": 60, + "playerMaxHp": 60, + "playerMana": 20, + "playerMaxMana": 20, + "targetHp": 30, + "targetMaxHp": 30 + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn get_story_battle_state_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/story/battles/battle_001") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/story/battles/battle_001") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn resolve_story_battle_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/battles/resolve") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "battleStateId": "battle_001", + "functionId": "battle_attack_basic", + "actionText": "普通攻击", + "baseDamage": 10, + "manaCost": 0, + "heal": 0, + "manaRestore": 0, + "counterMultiplierBasisPoints": 10000 + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "story_battles_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_story_battles".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("战斗接口用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/story_sessions.rs b/server-rs/crates/api-server/src/story_sessions.rs new file mode 100644 index 00000000..5ad5f507 --- /dev/null +++ b/server-rs/crates/api-server/src/story_sessions.rs @@ -0,0 +1,416 @@ +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::Response, +}; +use serde_json::{Value, json}; +use shared_contracts::story::{ + BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload, + StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse, +}; +use spacetime_client::SpacetimeClientError; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +pub async fn begin_story_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let now_micros = current_utc_micros(); + let actor_user_id = authenticated.claims().user_id().to_string(); + let result = state + .spacetime_client() + .begin_story_session( + module_story::generate_story_session_id(now_micros), + payload.runtime_session_id, + actor_user_id, + payload.world_profile_id, + payload.initial_prompt, + payload.opening_summary, + now_micros, + ) + .await + .map_err(|error| { + story_sessions_error_response(&request_context, map_story_session_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + StorySessionMutationResponse { + story_session: StorySessionPayload { + story_session_id: result.session.story_session_id, + runtime_session_id: result.session.runtime_session_id, + actor_user_id: result.session.actor_user_id, + world_profile_id: result.session.world_profile_id, + initial_prompt: result.session.initial_prompt, + opening_summary: result.session.opening_summary, + latest_narrative_text: result.session.latest_narrative_text, + latest_choice_function_id: result.session.latest_choice_function_id, + status: result.session.status, + version: result.session.version, + created_at: result.session.created_at, + updated_at: result.session.updated_at, + }, + story_event: StoryEventPayload { + event_id: result.event.event_id, + story_session_id: result.event.story_session_id, + event_kind: result.event.event_kind, + narrative_text: result.event.narrative_text, + choice_function_id: result.event.choice_function_id, + created_at: result.event.created_at, + }, + }, + )) +} + +pub async fn continue_story( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let now_micros = current_utc_micros(); + let result = state + .spacetime_client() + .continue_story( + payload.story_session_id, + module_story::generate_story_event_id(now_micros), + payload.narrative_text, + payload.choice_function_id, + now_micros, + ) + .await + .map_err(|error| { + story_sessions_error_response(&request_context, map_story_session_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + StorySessionMutationResponse { + story_session: StorySessionPayload { + story_session_id: result.session.story_session_id, + runtime_session_id: result.session.runtime_session_id, + actor_user_id: result.session.actor_user_id, + world_profile_id: result.session.world_profile_id, + initial_prompt: result.session.initial_prompt, + opening_summary: result.session.opening_summary, + latest_narrative_text: result.session.latest_narrative_text, + latest_choice_function_id: result.session.latest_choice_function_id, + status: result.session.status, + version: result.session.version, + created_at: result.session.created_at, + updated_at: result.session.updated_at, + }, + story_event: StoryEventPayload { + event_id: result.event.event_id, + story_session_id: result.event.story_session_id, + event_kind: result.event.event_kind, + narrative_text: result.event.narrative_text, + choice_function_id: result.event.choice_function_id, + created_at: result.event.created_at, + }, + }, + )) +} + +pub async fn get_story_session_state( + State(state): State, + Path(story_session_id): Path, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + let result = state + .spacetime_client() + .get_story_session_state(story_session_id) + .await + .map_err(|error| { + story_sessions_error_response(&request_context, map_story_session_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + StorySessionStateResponse { + story_session: StorySessionPayload { + story_session_id: result.session.story_session_id, + runtime_session_id: result.session.runtime_session_id, + actor_user_id: result.session.actor_user_id, + world_profile_id: result.session.world_profile_id, + initial_prompt: result.session.initial_prompt, + opening_summary: result.session.opening_summary, + latest_narrative_text: result.session.latest_narrative_text, + latest_choice_function_id: result.session.latest_choice_function_id, + status: result.session.status, + version: result.session.version, + created_at: result.session.created_at, + updated_at: result.session.updated_at, + }, + story_events: result + .events + .into_iter() + .map(|event| StoryEventPayload { + event_id: event.event_id, + story_session_id: event.story_session_id, + event_kind: event.event_kind, + narrative_text: event.narrative_text, + choice_function_id: event.choice_function_id, + created_at: event.created_at, + }) + .collect(), + }, + )) +} + +fn map_story_session_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn story_sessions_error_response(request_context: &RequestContext, error: AppError) -> Response { + // story session 路由需要保留 request_context,确保错误 envelope 与 requestId 一致。 + error.into_response_with_context(Some(request_context)) +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::{Value, json}; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[tokio::test] + async fn begin_story_session_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/sessions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "runtimeSessionId": "runtime_001", + "worldProfileId": "profile_001", + "initialPrompt": "进入营地", + "openingSummary": "营地开场" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn begin_story_session_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/sessions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "runtimeSessionId": "runtime_001", + "worldProfileId": "profile_001", + "initialPrompt": "进入营地", + "openingSummary": "营地开场" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn continue_story_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/story/sessions/continue") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "storySessionId": "storysess_001", + "narrativeText": "你看见篝火边有人招手。", + "choiceFunctionId": "talk_to_npc" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[tokio::test] + async fn get_story_session_state_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/story/sessions/storysess_001/state") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn get_story_session_state_returns_bad_gateway_when_spacetime_not_published() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/story/sessions/storysess_001/state") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "story_sessions_user".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); + state + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_story_sessions".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("故事会话用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } +} diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index f43e80b8..7ac1e688 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -8,7 +8,10 @@ use module_auth::{ AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError, WechatAuthScene, }; -use serde::{Deserialize, Serialize}; +use shared_contracts::auth::{ + AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, + WechatStartQuery, WechatStartResponse, +}; use time::OffsetDateTime; use url::Url; @@ -19,45 +22,11 @@ use crate::{ attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, }, http_error::AppError, - password_entry::PasswordEntryUserPayload, request_context::RequestContext, session_client::resolve_session_client_context, state::AppState, }; -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WechatStartQuery { - pub redirect_path: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WechatStartResponse { - pub authorization_url: String, -} - -#[derive(Debug, Deserialize)] -pub struct WechatCallbackQuery { - pub state: Option, - pub code: Option, - pub mock_code: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WechatBindPhoneRequest { - pub phone: String, - pub code: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WechatBindPhoneResponse { - pub token: String, - pub user: PasswordEntryUserPayload, -} - pub async fn start_wechat_login( State(state): State, Extension(request_context): Extension, @@ -230,13 +199,13 @@ pub async fn bind_wechat_phone( Some(&request_context), WechatBindPhoneResponse { token: signed_session.access_token, - user: PasswordEntryUserPayload { + user: AuthUserPayload { id: result.user.id, username: result.user.username, display_name: result.user.display_name, phone_number_masked: result.user.phone_number_masked, - login_method: result.user.login_method.as_str(), - binding_status: result.user.binding_status.as_str(), + login_method: result.user.login_method.as_str().to_string(), + binding_status: result.user.binding_status.as_str().to_string(), wechat_bound: result.user.wechat_bound, }, }, diff --git a/server-rs/crates/module-ai/Cargo.toml b/server-rs/crates/module-ai/Cargo.toml new file mode 100644 index 00000000..c3700c0e --- /dev/null +++ b/server-rs/crates/module-ai/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "module-ai" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-ai/README.md b/server-rs/crates/module-ai/README.md index d888be76..b4c4ba46 100644 --- a/server-rs/crates/module-ai/README.md +++ b/server-rs/crates/module-ai/README.md @@ -1,29 +1,67 @@ -# module-ai 独立模块 package 占位说明 +# module-ai 模块说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. package 职责 -`module-ai` 是 AI 编排模块 package,后续负责: +`module-ai` 是 AI 编排模块 crate,当前已经落地首版领域基座,负责: -1. 剧情、聊天、自定义世界、运行时物品等生成型流程的模块级编排 -2. prompt 组织、阶段状态、结果引用与模块间协同 -3. 与 `apps/api-server` 的流式输出与兼容接口对接 -4. 与 `apps/spacetime-module` 的任务状态、结果引用聚合对接 +1. 统一 AI 任务类型、任务状态、阶段状态与任务快照 +2. 统一流式文本片段、阶段输出、结果引用与最终结果聚合 +3. 为 `api-server` 与后续 `platform-llm` 接线提供稳定的模块领域服务接口 +4. 为 `spacetime-module` 映射 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 提供稳定类型基础 ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入模型调用、流式编排与结果回写实现。 +当前提交已完成: -后续与本 package 直接相关的任务包括: +1. `module-ai` 的 `Cargo.toml` +2. 首版核心类型: + - `AiTaskKind` + - `AiTaskStatus` + - `AiTaskStageKind` + - `AiTaskSnapshot` + - `AiTextChunkSnapshot` + - `AiResultReferenceSnapshot` +3. 默认阶段蓝图与 ID 前缀 +4. `InMemoryAiTaskStore` +5. `AiTaskService` +6. 面向 `SpacetimeDB` 的输入类型与 ID helper: + - `AiTaskStartInput` + - `AiTaskStageStartInput` + - `AiTextChunkAppendInput` + - `AiResultReferenceInput` + - `AiTaskFinishInput` + - `AiTaskCancelInput` + - `AiTaskFailureInput` +7. 基础单元测试 -1. 设计多模型编排与任务状态抽象 -2. 对齐剧情、聊天、自定义世界等生成链路 -3. 对齐流式输出、阶段事件与兼容响应结构 -4. 接入模块级结果回写与任务引用绑定 +首版详细设计见: -## 3. 边界约束 +1. [../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md) +2. [../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md) +3. [../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md) -1. `module-ai` 负责生成型流程的模块级编排,不把供应商 SDK 直接散落到各业务模块里。 -2. 实际模型接入通过 `packages/platform-llm` 完成,状态与结果引用最终回写到 `apps/spacetime-module` 聚合的状态模型中。 -3. 前端兼容 REST 与 SSE 由 `apps/api-server` 暴露,但 AI 编排过程不能再次退回单个大 orchestrator 的黑盒写法。 +## 3. 当前仍未进入的范围 + +当前刻意未进入: + +1. 真实供应商 SDK 与模型请求 +2. SSE 协议输出 +3. 任务订阅 projection 与清理调度 +4. 业务模块自己的 prompt 组装实现 + +这些后续分别由: + +1. `platform-llm` +2. `api-server` +3. `spacetime-module + spacetime-client` +4. `module-story` / `module-npc` / `module-custom-world` / `module-quest` / `module-runtime-item` + +继续接入。 + +## 4. 边界约束 + +1. `module-ai` 只负责生成型流程的模块级编排领域模型与最小服务,不直接承接供应商 HTTP 适配。 +2. 真实模型接入通过 `platform-llm` 完成,任务真相状态最终应下沉到 `spacetime-module`。 +3. `api-server` 负责 REST / SSE 对外协议,`module-ai` 不返回 HTTP DTO。 diff --git a/server-rs/crates/module-ai/src/lib.rs b/server-rs/crates/module-ai/src/lib.rs new file mode 100644 index 00000000..c63f395f --- /dev/null +++ b/server-rs/crates/module-ai/src/lib.rs @@ -0,0 +1,1050 @@ +use std::{ + collections::HashMap, + error::Error, + fmt, + sync::{Arc, Mutex}, +}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + build_prefixed_seed_id, normalize_optional_string as normalize_shared_optional_string, + normalize_required_string, normalize_string_list as normalize_shared_string_list, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const AI_TASK_ID_PREFIX: &str = "aitask_"; +pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_"; +pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_"; +pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_"; +pub const INITIAL_AI_TASK_VERSION: u32 = 1; + +// AI 编排类型与当前 Node 正式运行时主链保持一致,避免后续接线时重新发明命名。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiTaskKind { + StoryGeneration, + CharacterChat, + NpcChat, + CustomWorldGeneration, + QuestIntent, + RuntimeItemIntent, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiTaskStatus { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AiTaskStageKind { + PreparePrompt, + RequestModel, + RepairResponse, + NormalizeResult, + PersistResult, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiTaskStageStatus { + Pending, + Running, + Completed, + Skipped, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiResultReferenceKind { + StorySession, + StoryEvent, + CustomWorldProfile, + QuestRecord, + RuntimeItemRecord, + AssetObject, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStageBlueprint { + pub stage_kind: AiTaskStageKind, + pub label: String, + pub detail: String, + pub order: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStageSnapshot { + pub stage_kind: AiTaskStageKind, + pub label: String, + pub detail: String, + pub order: u32, + pub status: AiTaskStageStatus, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub started_at_micros: Option, + pub completed_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskCreateInput { + pub task_id: String, + pub task_kind: AiTaskKind, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub stages: Vec, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStartInput { + pub task_id: String, + pub started_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskStageStartInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub started_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskSnapshot { + pub task_id: String, + pub task_kind: AiTaskKind, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub status: AiTaskStatus, + pub failure_message: Option, + pub stages: Vec, + pub result_references: Vec, + pub latest_text_output: Option, + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at_micros: i64, + pub started_at_micros: Option, + pub completed_at_micros: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTextChunkSnapshot { + pub chunk_id: String, + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub sequence: u32, + pub delta_text: String, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTextChunkAppendInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub sequence: u32, + pub delta_text: String, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiStageCompletionInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub completed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiResultReferenceInput { + pub task_id: String, + pub reference_kind: AiResultReferenceKind, + pub reference_id: String, + pub label: Option, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiResultReferenceSnapshot { + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: AiResultReferenceKind, + pub reference_id: String, + pub label: Option, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskFinishInput { + pub task_id: String, + pub completed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskCancelInput { + pub task_id: String, + pub completed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskFailureInput { + pub task_id: String, + pub failure_message: String, + pub completed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiTaskProcedureResult { + pub ok: bool, + pub task: Option, + pub text_chunk: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AiTaskFieldError { + MissingTaskId, + MissingOwnerUserId, + MissingRequestLabel, + MissingSourceModule, + MissingStageBlueprints, + DuplicateStageBlueprint, + MissingReferenceId, + MissingChunkText, + InvalidSequence, + MissingFailureMessage, + MissingStage, + InvalidTaskState, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AiTaskServiceError { + Field(AiTaskFieldError), + TaskAlreadyExists, + TaskNotFound, + StageNotFound, + Store(String), +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryAiTaskStore { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct InMemoryAiTaskStoreState { + tasks: HashMap, + text_chunks: HashMap>, +} + +#[derive(Clone, Debug)] +pub struct AiTaskService { + store: InMemoryAiTaskStore, +} + +impl AiTaskKind { + // 默认阶段蓝图只冻结通用语义,具体 prompt 内容与供应商策略仍由上层模块决定。 + pub fn default_stage_blueprints(self) -> Vec { + let ordered_kinds = match self { + Self::StoryGeneration => vec![ + AiTaskStageKind::PreparePrompt, + AiTaskStageKind::RequestModel, + AiTaskStageKind::RepairResponse, + AiTaskStageKind::NormalizeResult, + ], + Self::CharacterChat | Self::NpcChat | Self::QuestIntent | Self::RuntimeItemIntent => { + vec![ + AiTaskStageKind::PreparePrompt, + AiTaskStageKind::RequestModel, + AiTaskStageKind::NormalizeResult, + ] + } + Self::CustomWorldGeneration => vec![ + AiTaskStageKind::PreparePrompt, + AiTaskStageKind::RequestModel, + AiTaskStageKind::RepairResponse, + AiTaskStageKind::NormalizeResult, + AiTaskStageKind::PersistResult, + ], + }; + + ordered_kinds + .into_iter() + .enumerate() + .map(|(index, stage_kind)| AiTaskStageBlueprint { + stage_kind, + label: stage_kind.default_label().to_string(), + detail: stage_kind.default_detail().to_string(), + order: index as u32, + }) + .collect() + } +} + +impl AiTaskStageKind { + pub fn as_str(self) -> &'static str { + match self { + Self::PreparePrompt => "prepare_prompt", + Self::RequestModel => "request_model", + Self::RepairResponse => "repair_response", + Self::NormalizeResult => "normalize_result", + Self::PersistResult => "persist_result", + } + } + + pub fn default_label(self) -> &'static str { + match self { + Self::PreparePrompt => "整理提示词", + Self::RequestModel => "请求模型", + Self::RepairResponse => "修复响应", + Self::NormalizeResult => "归一结果", + Self::PersistResult => "回写结果", + } + } + + pub fn default_detail(self) -> &'static str { + match self { + Self::PreparePrompt => "整理输入上下文并构建本轮提示词。", + Self::RequestModel => "向上游模型发起正式推理请求。", + Self::RepairResponse => "对非严格输出做补救修复或二次编排。", + Self::NormalizeResult => "把模型输出归一成模块可消费结构。", + Self::PersistResult => "把结果引用或聚合状态回写到下游模块。", + } + } +} + +impl AiTaskStatus { + fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +impl AiTaskService { + pub fn new(store: InMemoryAiTaskStore) -> Self { + Self { store } + } + + pub fn create_task( + &self, + input: AiTaskCreateInput, + ) -> Result { + validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?; + + let snapshot = AiTaskSnapshot { + task_id: input.task_id.clone(), + task_kind: input.task_kind, + owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(), + request_label: normalize_required_string(input.request_label).unwrap_or_default(), + source_module: normalize_required_string(input.source_module).unwrap_or_default(), + source_entity_id: normalize_optional_text(input.source_entity_id), + request_payload_json: normalize_optional_text(input.request_payload_json), + status: AiTaskStatus::Pending, + failure_message: None, + stages: input + .stages + .into_iter() + .map(|stage| AiTaskStageSnapshot { + stage_kind: stage.stage_kind, + label: normalize_required_string(stage.label).unwrap_or_default(), + detail: normalize_required_string(stage.detail).unwrap_or_default(), + 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, + }; + + self.store.insert_task(snapshot) + } + + pub fn start_task( + &self, + task_id: &str, + started_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + if task.status.is_terminal() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )); + } + + task.status = AiTaskStatus::Running; + task.started_at_micros.get_or_insert(started_at_micros); + task.updated_at_micros = started_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn start_stage( + &self, + task_id: &str, + stage_kind: AiTaskStageKind, + started_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + if task.status.is_terminal() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )); + } + + task.status = AiTaskStatus::Running; + task.started_at_micros.get_or_insert(started_at_micros); + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + stage.status = AiTaskStageStatus::Running; + stage.started_at_micros.get_or_insert(started_at_micros); + task.updated_at_micros = started_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn append_text_chunk( + &self, + task_id: &str, + stage_kind: AiTaskStageKind, + sequence: u32, + delta_text: String, + created_at_micros: i64, + ) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), AiTaskServiceError> { + if delta_text.trim().is_empty() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::MissingChunkText, + )); + } + if sequence == 0 { + return Err(AiTaskServiceError::Field(AiTaskFieldError::InvalidSequence)); + } + + let chunk = AiTextChunkSnapshot { + chunk_id: generate_ai_text_chunk_id(created_at_micros, sequence), + task_id: normalize_required_string(task_id).unwrap_or_default(), + stage_kind, + sequence, + delta_text: normalize_required_string(delta_text).unwrap_or_default(), + created_at_micros, + }; + + let task = self.store.append_text_chunk(chunk.clone())?; + Ok((task, chunk)) + } + + pub fn complete_stage( + &self, + input: AiStageCompletionInput, + ) -> Result { + self.store.update_task(&input.task_id, |task| { + if task.status.is_terminal() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )); + } + + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == input.stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + 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()); + + task.latest_text_output = stage.text_output.clone(); + task.latest_structured_payload_json = stage.structured_payload_json.clone(); + task.updated_at_micros = input.completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn attach_result_reference( + &self, + task_id: &str, + reference_kind: AiResultReferenceKind, + reference_id: String, + label: Option, + created_at_micros: i64, + ) -> Result { + let Some(reference_id) = normalize_required_string(reference_id) else { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::MissingReferenceId, + )); + }; + + self.store.update_task(task_id, |task| { + task.result_references.push(AiResultReferenceSnapshot { + result_ref_id: generate_ai_result_ref_id(created_at_micros), + task_id: task.task_id.clone(), + reference_kind, + reference_id: reference_id.clone(), + label: normalize_optional_text(label.clone()), + created_at_micros, + }); + task.updated_at_micros = created_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn complete_task( + &self, + task_id: &str, + completed_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + if task.status.is_terminal() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )); + } + + task.status = AiTaskStatus::Completed; + task.completed_at_micros = Some(completed_at_micros); + task.updated_at_micros = completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn fail_task( + &self, + task_id: &str, + failure_message: String, + completed_at_micros: i64, + ) -> Result { + let Some(failure_message) = normalize_required_string(failure_message) else { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::MissingFailureMessage, + )); + }; + + self.store.update_task(task_id, |task| { + if task.status.is_terminal() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )); + } + + task.status = AiTaskStatus::Failed; + task.failure_message = Some(failure_message.clone()); + task.completed_at_micros = Some(completed_at_micros); + task.updated_at_micros = completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn cancel_task( + &self, + task_id: &str, + completed_at_micros: i64, + ) -> Result { + self.store.update_task(task_id, |task| { + if task.status.is_terminal() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )); + } + + task.status = AiTaskStatus::Cancelled; + task.completed_at_micros = Some(completed_at_micros); + task.updated_at_micros = completed_at_micros; + task.version += 1; + Ok(()) + }) + } + + pub fn get_task(&self, task_id: &str) -> Result { + self.store.get_task(task_id) + } +} + +impl InMemoryAiTaskStore { + fn insert_task(&self, task: AiTaskSnapshot) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + + if state.tasks.contains_key(&task.task_id) { + return Err(AiTaskServiceError::TaskAlreadyExists); + } + + state.text_chunks.insert(task.task_id.clone(), Vec::new()); + state.tasks.insert(task.task_id.clone(), task.clone()); + Ok(task) + } + + fn update_task( + &self, + task_id: &str, + mut apply: F, + ) -> Result + where + F: FnMut(&mut AiTaskSnapshot) -> Result<(), AiTaskServiceError>, + { + let mut state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + let task = state + .tasks + .get_mut(task_id.trim()) + .ok_or(AiTaskServiceError::TaskNotFound)?; + apply(task)?; + Ok(task.clone()) + } + + fn append_text_chunk( + &self, + chunk: AiTextChunkSnapshot, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + { + let task = state + .tasks + .get_mut(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + if task.status.is_terminal() { + return Err(AiTaskServiceError::Field( + AiTaskFieldError::InvalidTaskState, + )); + } + + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == chunk.stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + if stage.status == AiTaskStageStatus::Pending { + stage.status = AiTaskStageStatus::Running; + stage.started_at_micros = Some(chunk.created_at_micros); + } + + task.status = AiTaskStatus::Running; + task.started_at_micros + .get_or_insert(chunk.created_at_micros); + } + + let chunks = state + .text_chunks + .get_mut(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + chunks.push(chunk.clone()); + chunks.sort_by_key(|value| value.sequence); + + let aggregated_text = chunks + .iter() + .filter(|value| value.stage_kind == chunk.stage_kind) + .map(|value| value.delta_text.as_str()) + .collect::>() + .join(""); + let normalized_output = if aggregated_text.trim().is_empty() { + None + } else { + Some(aggregated_text) + }; + + let task = state + .tasks + .get_mut(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + let stage = task + .stages + .iter_mut() + .find(|stage| stage.stage_kind == chunk.stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + stage.text_output = normalized_output.clone(); + task.latest_text_output = normalized_output; + task.updated_at_micros = chunk.created_at_micros; + task.version += 1; + Ok(task.clone()) + } + + fn get_task(&self, task_id: &str) -> Result { + let state = self + .inner + .lock() + .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; + state + .tasks + .get(task_id.trim()) + .cloned() + .ok_or(AiTaskServiceError::TaskNotFound) + } +} + +pub fn validate_task_create_input(input: &AiTaskCreateInput) -> Result<(), AiTaskFieldError> { + if normalize_required_string(&input.task_id).is_none() { + return Err(AiTaskFieldError::MissingTaskId); + } + if normalize_required_string(&input.owner_user_id).is_none() { + return Err(AiTaskFieldError::MissingOwnerUserId); + } + if normalize_required_string(&input.request_label).is_none() { + return Err(AiTaskFieldError::MissingRequestLabel); + } + if normalize_required_string(&input.source_module).is_none() { + return Err(AiTaskFieldError::MissingSourceModule); + } + if input.stages.is_empty() { + return Err(AiTaskFieldError::MissingStageBlueprints); + } + + let mut seen = HashMap::new(); + for stage in &input.stages { + if normalize_required_string(&stage.label).is_none() + || normalize_required_string(&stage.detail).is_none() + { + return Err(AiTaskFieldError::MissingStageBlueprints); + } + + if seen.insert(stage.stage_kind, true).is_some() { + return Err(AiTaskFieldError::DuplicateStageBlueprint); + } + } + + Ok(()) +} + +pub fn generate_ai_task_id(seed_micros: i64) -> String { + build_prefixed_seed_id(AI_TASK_ID_PREFIX, seed_micros) +} + +pub fn generate_ai_task_stage_id(task_id: &str, stage_kind: AiTaskStageKind) -> String { + format!( + "{}{}_{}", + AI_TASK_STAGE_ID_PREFIX, + task_id.trim(), + stage_kind.as_str() + ) +} + +pub fn generate_ai_result_ref_id(seed_micros: i64) -> String { + build_prefixed_seed_id(AI_RESULT_REF_ID_PREFIX, seed_micros) +} + +pub fn generate_ai_text_chunk_id(seed_micros: i64, sequence: u32) -> String { + format!("{}{seed_micros:x}_{sequence:x}", AI_TEXT_CHUNK_ID_PREFIX) +} + +pub fn normalize_optional_text(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} + +impl fmt::Display for AiTaskFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingTaskId => f.write_str("ai_task.task_id 不能为空"), + Self::MissingOwnerUserId => f.write_str("ai_task.owner_user_id 不能为空"), + Self::MissingRequestLabel => f.write_str("ai_task.request_label 不能为空"), + Self::MissingSourceModule => f.write_str("ai_task.source_module 不能为空"), + Self::MissingStageBlueprints => f.write_str("ai_task.stages 至少需要一个有效阶段"), + Self::DuplicateStageBlueprint => f.write_str("ai_task.stages 不能包含重复阶段"), + Self::MissingReferenceId => f.write_str("ai_result_reference.reference_id 不能为空"), + Self::MissingChunkText => f.write_str("ai_text_chunk.delta_text 不能为空"), + Self::InvalidSequence => f.write_str("ai_text_chunk.sequence 必须大于 0"), + Self::MissingFailureMessage => f.write_str("ai_task.failure_message 不能为空"), + Self::MissingStage => f.write_str("ai_task.stage 不存在"), + Self::InvalidTaskState => f.write_str("当前 ai_task 状态不允许执行该操作"), + } + } +} + +impl Error for AiTaskFieldError {} + +impl fmt::Display for AiTaskServiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Field(error) => write!(f, "{error}"), + Self::TaskAlreadyExists => f.write_str("ai_task 已存在,不能重复创建"), + Self::TaskNotFound => f.write_str("ai_task 不存在"), + Self::StageNotFound => f.write_str("ai_task.stage 不存在"), + Self::Store(message) => f.write_str(message), + } + } +} + +impl Error for AiTaskServiceError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_service() -> AiTaskService { + AiTaskService::new(InMemoryAiTaskStore::default()) + } + + fn build_create_input(task_kind: AiTaskKind) -> AiTaskCreateInput { + AiTaskCreateInput { + task_id: generate_ai_task_id(1_713_680_000_000_000), + task_kind, + owner_user_id: "user_001".to_string(), + request_label: "首轮故事生成".to_string(), + source_module: "story".to_string(), + source_entity_id: Some("storysess_001".to_string()), + request_payload_json: Some("{\"scene\":\"camp\"}".to_string()), + stages: task_kind.default_stage_blueprints(), + created_at_micros: 1_713_680_000_000_000, + } + } + + #[test] + fn default_stage_blueprints_match_story_baseline() { + let stages = AiTaskKind::StoryGeneration.default_stage_blueprints(); + + assert_eq!(stages.len(), 4); + assert_eq!(stages[0].stage_kind, AiTaskStageKind::PreparePrompt); + assert_eq!(stages[1].stage_kind, AiTaskStageKind::RequestModel); + assert_eq!(stages[2].stage_kind, AiTaskStageKind::RepairResponse); + assert_eq!(stages[3].stage_kind, AiTaskStageKind::NormalizeResult); + } + + #[test] + fn create_task_rejects_duplicate_stage_blueprints() { + let mut input = build_create_input(AiTaskKind::StoryGeneration); + input.stages.push(AiTaskStageBlueprint { + stage_kind: AiTaskStageKind::PreparePrompt, + label: "重复阶段".to_string(), + detail: "重复阶段".to_string(), + order: 99, + }); + + let error = validate_task_create_input(&input).expect_err("duplicate stages should fail"); + assert_eq!(error, AiTaskFieldError::DuplicateStageBlueprint); + } + + #[test] + fn generate_ai_task_stage_id_contains_task_and_stage_slug() { + let stage_id = generate_ai_task_stage_id("aitask_demo", AiTaskStageKind::NormalizeResult); + + assert_eq!(stage_id, "aistage_aitask_demo_normalize_result"); + } + + #[test] + fn create_and_start_task_updates_status() { + let service = build_service(); + let created = service + .create_task(build_create_input(AiTaskKind::QuestIntent)) + .expect("task should create"); + let started = service + .start_task(&created.task_id, created.created_at_micros + 1) + .expect("task should start"); + + assert_eq!(created.status, AiTaskStatus::Pending); + assert_eq!(started.status, AiTaskStatus::Running); + assert_eq!( + started.started_at_micros, + Some(created.created_at_micros + 1) + ); + assert_eq!(started.version, INITIAL_AI_TASK_VERSION + 1); + } + + #[test] + fn append_text_chunk_aggregates_stream_output_by_stage() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::CharacterChat)) + .expect("task should create"); + service + .start_stage( + &task.task_id, + AiTaskStageKind::RequestModel, + task.created_at_micros + 10, + ) + .expect("stage should start"); + + let (after_first, _) = service + .append_text_chunk( + &task.task_id, + AiTaskStageKind::RequestModel, + 1, + "你".to_string(), + task.created_at_micros + 20, + ) + .expect("first chunk should append"); + let (after_second, second_chunk) = service + .append_text_chunk( + &task.task_id, + AiTaskStageKind::RequestModel, + 2, + "好。".to_string(), + task.created_at_micros + 30, + ) + .expect("second chunk should append"); + + assert_eq!(after_first.latest_text_output.as_deref(), Some("你")); + assert_eq!(after_second.latest_text_output.as_deref(), Some("你好。")); + assert_eq!(second_chunk.sequence, 2); + } + + #[test] + fn complete_stage_updates_latest_outputs() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::StoryGeneration)) + .expect("task should create"); + + let completed = service + .complete_stage(AiStageCompletionInput { + task_id: task.task_id.clone(), + stage_kind: AiTaskStageKind::NormalizeResult, + text_output: Some("营地前的篝火重新亮了起来。".to_string()), + structured_payload_json: Some("{\"choices\":3}".to_string()), + warning_messages: vec!["使用了 fallback 选项池".to_string()], + completed_at_micros: task.created_at_micros + 50, + }) + .expect("stage should complete"); + + let stage = completed + .stages + .iter() + .find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult) + .expect("normalize stage should exist"); + assert_eq!(stage.status, AiTaskStageStatus::Completed); + assert_eq!( + completed.latest_text_output.as_deref(), + Some("营地前的篝火重新亮了起来。") + ); + assert_eq!( + completed.latest_structured_payload_json.as_deref(), + Some("{\"choices\":3}") + ); + assert_eq!(stage.warning_messages, vec!["使用了 fallback 选项池"]); + } + + #[test] + fn attach_result_reference_appends_binding() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::CustomWorldGeneration)) + .expect("task should create"); + + let updated = service + .attach_result_reference( + &task.task_id, + AiResultReferenceKind::CustomWorldProfile, + "profile_001".to_string(), + Some("主世界档案".to_string()), + task.created_at_micros + 10, + ) + .expect("reference should attach"); + + assert_eq!(updated.result_references.len(), 1); + assert_eq!( + updated.result_references[0].reference_kind, + AiResultReferenceKind::CustomWorldProfile + ); + assert_eq!(updated.result_references[0].reference_id, "profile_001"); + } + + #[test] + fn fail_and_cancel_task_move_into_terminal_states() { + let service = build_service(); + let first = service + .create_task(build_create_input(AiTaskKind::NpcChat)) + .expect("task should create"); + let failed = service + .fail_task( + &first.task_id, + "上游模型超时".to_string(), + first.created_at_micros + 10, + ) + .expect("task should fail"); + + assert_eq!(failed.status, AiTaskStatus::Failed); + assert_eq!(failed.failure_message.as_deref(), Some("上游模型超时")); + + let second = service + .create_task(AiTaskCreateInput { + task_id: generate_ai_task_id(1_713_680_000_000_999), + ..build_create_input(AiTaskKind::RuntimeItemIntent) + }) + .expect("second task should create"); + let cancelled = service + .cancel_task(&second.task_id, second.created_at_micros + 20) + .expect("task should cancel"); + + assert_eq!(cancelled.status, AiTaskStatus::Cancelled); + assert_eq!( + cancelled.completed_at_micros, + Some(second.created_at_micros + 20) + ); + } + + #[test] + fn complete_task_marks_terminal_success() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::QuestIntent)) + .expect("task should create"); + + let completed = service + .complete_task(&task.task_id, task.created_at_micros + 100) + .expect("task should complete"); + + assert_eq!(completed.status, AiTaskStatus::Completed); + assert_eq!( + completed.completed_at_micros, + Some(task.created_at_micros + 100) + ); + } +} diff --git a/server-rs/crates/module-assets/Cargo.toml b/server-rs/crates/module-assets/Cargo.toml index 387edf0a..f7293d8c 100644 --- a/server-rs/crates/module-assets/Cargo.toml +++ b/server-rs/crates/module-assets/Cargo.toml @@ -14,3 +14,4 @@ serde = { version = "1", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true } spacetimedb = { workspace = true, optional = true } platform-oss = { path = "../platform-oss", optional = true } +shared-kernel = { path = "../shared-kernel" } diff --git a/server-rs/crates/module-assets/src/asset_object_core.rs b/server-rs/crates/module-assets/src/asset_object_core.rs index 26b77421..b1ba7754 100644 --- a/server-rs/crates/module-assets/src/asset_object_core.rs +++ b/server-rs/crates/module-assets/src/asset_object_core.rs @@ -1,6 +1,10 @@ use std::{error::Error, fmt}; use serde::{Deserialize, Serialize}; +use shared_kernel::{ + build_prefixed_seed_id, format_timestamp_micros, normalize_optional_string, + normalize_required_string, +}; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; @@ -175,6 +179,28 @@ impl AssetObjectAccessPolicy { } } +// 资产核心对象字段需要继续保留模块自己的错误语义,但基础必填字符串归一化统一走 shared-kernel。 +fn normalize_required_asset_field( + value: impl AsRef, + error: AssetObjectFieldError, +) -> Result { + normalize_required_string(value).ok_or(error) +} + +fn normalize_asset_object_key(value: impl AsRef) -> Result { + let normalized = value.as_ref().trim(); + let normalized = normalized.trim_start_matches('/'); + normalize_required_string(normalized).ok_or(AssetObjectFieldError::MissingObjectKey) +} + +fn validate_asset_object_version(version: u32) -> Result<(), AssetObjectFieldError> { + if version == 0 { + return Err(AssetObjectFieldError::InvalidVersion); + } + + Ok(()) +} + // bucket 与 object_key 是正式真相字段,因此这里只做字段校验,不回退成单字符串路径字段。 pub fn validate_asset_object_fields( bucket: &str, @@ -182,22 +208,10 @@ pub fn validate_asset_object_fields( asset_kind: &str, version: u32, ) -> Result<(), AssetObjectFieldError> { - if bucket.trim().is_empty() { - return Err(AssetObjectFieldError::MissingBucket); - } - - if object_key.trim().trim_start_matches('/').is_empty() { - return Err(AssetObjectFieldError::MissingObjectKey); - } - - if asset_kind.trim().is_empty() { - return Err(AssetObjectFieldError::MissingAssetKind); - } - - if version == 0 { - return Err(AssetObjectFieldError::InvalidVersion); - } - + normalize_required_asset_field(bucket, AssetObjectFieldError::MissingBucket)?; + normalize_asset_object_key(object_key)?; + normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?; + validate_asset_object_version(version)?; Ok(()) } @@ -210,30 +224,12 @@ pub fn validate_asset_entity_binding_fields( slot: &str, asset_kind: &str, ) -> Result<(), AssetObjectFieldError> { - if binding_id.trim().is_empty() { - return Err(AssetObjectFieldError::MissingBindingId); - } - - if asset_object_id.trim().is_empty() { - return Err(AssetObjectFieldError::MissingAssetObjectId); - } - - if entity_kind.trim().is_empty() { - return Err(AssetObjectFieldError::MissingEntityKind); - } - - if entity_id.trim().is_empty() { - return Err(AssetObjectFieldError::MissingEntityId); - } - - if slot.trim().is_empty() { - return Err(AssetObjectFieldError::MissingSlot); - } - - if asset_kind.trim().is_empty() { - return Err(AssetObjectFieldError::MissingAssetKind); - } - + normalize_required_asset_field(binding_id, AssetObjectFieldError::MissingBindingId)?; + normalize_required_asset_field(asset_object_id, AssetObjectFieldError::MissingAssetObjectId)?; + normalize_required_asset_field(entity_kind, AssetObjectFieldError::MissingEntityKind)?; + normalize_required_asset_field(entity_id, AssetObjectFieldError::MissingEntityId)?; + normalize_required_asset_field(slot, AssetObjectFieldError::MissingSlot)?; + normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?; Ok(()) } @@ -253,21 +249,20 @@ pub fn build_asset_object_upsert_input( entity_id: Option, updated_at_micros: i64, ) -> Result { - if asset_object_id.trim().is_empty() { - return Err(AssetObjectFieldError::MissingAssetObjectId); - } - - validate_asset_object_fields( - &bucket, - &object_key, - &asset_kind, - INITIAL_ASSET_OBJECT_VERSION, + let asset_object_id = normalize_required_asset_field( + asset_object_id, + AssetObjectFieldError::MissingAssetObjectId, )?; + let bucket = normalize_required_asset_field(bucket, AssetObjectFieldError::MissingBucket)?; + let object_key = normalize_asset_object_key(object_key)?; + let asset_kind = + normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?; + validate_asset_object_version(INITIAL_ASSET_OBJECT_VERSION)?; Ok(AssetObjectUpsertInput { - asset_object_id: asset_object_id.trim().to_string(), - bucket: bucket.trim().to_string(), - object_key: object_key.trim().trim_start_matches('/').to_string(), + asset_object_id, + bucket, + object_key, access_policy, content_type: normalize_optional_value(content_type), content_length, @@ -277,7 +272,7 @@ pub fn build_asset_object_upsert_input( owner_user_id: normalize_optional_value(owner_user_id), profile_id: normalize_optional_value(profile_id), entity_id: normalize_optional_value(entity_id), - asset_kind: asset_kind.trim().to_string(), + asset_kind, updated_at_micros, }) } @@ -314,22 +309,27 @@ pub fn build_asset_entity_binding_input( profile_id: Option, updated_at_micros: i64, ) -> Result { - validate_asset_entity_binding_fields( - &binding_id, - &asset_object_id, - &entity_kind, - &entity_id, - &slot, - &asset_kind, + let binding_id = + normalize_required_asset_field(binding_id, AssetObjectFieldError::MissingBindingId)?; + let asset_object_id = normalize_required_asset_field( + asset_object_id, + AssetObjectFieldError::MissingAssetObjectId, )?; + let entity_kind = + normalize_required_asset_field(entity_kind, AssetObjectFieldError::MissingEntityKind)?; + let entity_id = + normalize_required_asset_field(entity_id, AssetObjectFieldError::MissingEntityId)?; + let slot = normalize_required_asset_field(slot, AssetObjectFieldError::MissingSlot)?; + let asset_kind = + normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?; Ok(AssetEntityBindingInput { - binding_id: binding_id.trim().to_string(), - asset_object_id: asset_object_id.trim().to_string(), - entity_kind: entity_kind.trim().to_string(), - entity_id: entity_id.trim().to_string(), - slot: slot.trim().to_string(), - asset_kind: asset_kind.trim().to_string(), + binding_id, + asset_object_id, + entity_kind, + entity_id, + slot, + asset_kind, owner_user_id: normalize_optional_value(owner_user_id), profile_id: normalize_optional_value(profile_id), updated_at_micros, @@ -354,24 +354,15 @@ pub fn build_asset_entity_binding_record( } pub fn generate_asset_object_id(seed_micros: i64) -> String { - format!("{}{:x}", ASSET_OBJECT_ID_PREFIX, seed_micros) + build_prefixed_seed_id(ASSET_OBJECT_ID_PREFIX, seed_micros) } pub fn generate_asset_binding_id(seed_micros: i64) -> String { - format!("{}{:x}", ASSET_BINDING_ID_PREFIX, seed_micros) + build_prefixed_seed_id(ASSET_BINDING_ID_PREFIX, seed_micros) } pub fn normalize_optional_value(value: Option) -> Option { - value.and_then(|value| { - let value = value.trim().to_string(); - if value.is_empty() { None } else { Some(value) } - }) -} - -fn format_timestamp_micros(micros: i64) -> String { - let seconds = micros.div_euclid(1_000_000); - let subsec_micros = micros.rem_euclid(1_000_000); - format!("{seconds}.{subsec_micros:06}Z") + normalize_optional_string(value) } impl fmt::Display for AssetObjectFieldError { diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index 45366aea..dbaee69b 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] platform-auth = { path = "../platform-auth" } +shared-kernel = { path = "../shared-kernel" } time = { version = "0.3", features = ["formatting", "parsing"] } uuid = { version = "1", features = ["v4"] } diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index ae36cb7d..4e966c02 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -6,8 +6,11 @@ use std::{ }; use platform_auth::{hash_password, verify_password}; +use shared_kernel::{ + build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, + normalize_optional_string, normalize_required_string, parse_rfc3339, +}; use time::{Duration, OffsetDateTime}; -use uuid::Uuid; const USERNAME_MIN_LENGTH: usize = 3; const USERNAME_MAX_LENGTH: usize = 24; @@ -463,22 +466,14 @@ impl RefreshSessionService { .map_err(map_password_store_error)? .ok_or(RefreshSessionError::UserNotFound)?; - let session_id = format!("usess_{}", Uuid::new_v4().simple()); + let session_id = build_prefixed_uuid_id("usess_"); let expires_at = now .checked_add(Duration::days(i64::from(self.refresh_session_ttl_days))) .ok_or_else(|| { RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) })?; - let now_iso = now - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")) - })?; - let expires_at_iso = expires_at - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}")) - })?; + let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?; + let expires_at_iso = format_rfc3339_with_context(expires_at, "refresh session 过期时间")?; let session = RefreshSessionRecord { session_id, user_id: input.user_id, @@ -502,10 +497,9 @@ impl RefreshSessionService { input: RotateRefreshSessionInput, now: OffsetDateTime, ) -> Result { - let refresh_token_hash = input.refresh_token_hash.trim().to_string(); - if refresh_token_hash.is_empty() { + let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else { return Err(RefreshSessionError::MissingToken); - } + }; let session = self .store @@ -516,13 +510,8 @@ impl RefreshSessionService { return Err(RefreshSessionError::SessionNotFound); } - let expires_at = OffsetDateTime::parse( - &session.session.expires_at, - &time::format_description::well_known::Rfc3339, - ) - .map_err(|error| { - RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}")) - })?; + let expires_at = + parse_rfc3339_with_context(&session.session.expires_at, "refresh session 过期时间")?; if expires_at <= now { return Err(RefreshSessionError::SessionExpired); } @@ -538,16 +527,9 @@ impl RefreshSessionService { .ok_or_else(|| { RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) })?; - let now_iso = now - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")) - })?; - let next_expires_at_iso = next_expires_at - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}")) - })?; + let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?; + let next_expires_at_iso = + format_rfc3339_with_context(next_expires_at, "refresh session 过期时间")?; let updated_session = self.store.rotate_session( &session.session.session_id, @@ -719,9 +701,9 @@ impl WechatAuthStateService { WechatAuthError::Store(format!("微信 state 过期时间格式化失败:{message}")) })?; let state = WechatAuthStateRecord { - wechat_state_id: format!("wxstate_{}", Uuid::new_v4().simple()), + wechat_state_id: build_prefixed_uuid_id("wxstate_"), state_token: create_wechat_state_token(), - redirect_path: input.redirect_path.trim().to_string(), + redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(), scene: input.scene, request_user_agent: normalize_optional_string(input.request_user_agent), expires_at, @@ -1035,7 +1017,7 @@ impl InMemoryAuthStore { ); let identity = StoredWechatIdentity { user_id: user_id.clone(), - provider_uid: profile.provider_uid.trim().to_string(), + provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(), provider_union_id: normalize_optional_string(profile.provider_union_id), display_name: normalize_optional_string(profile.display_name), avatar_url: normalize_optional_string(profile.avatar_url), @@ -1100,7 +1082,8 @@ impl InMemoryAuthStore { let next_display_name = normalize_optional_string(profile.display_name); let next_avatar_url = normalize_optional_string(profile.avatar_url); let next_provider_union_id = normalize_optional_string(profile.provider_union_id); - let next_provider_uid = profile.provider_uid.trim().to_string(); + let next_provider_uid = + normalize_required_string(&profile.provider_uid).unwrap_or_default(); { let identity = state .wechat_identity_by_provider_uid @@ -1717,7 +1700,7 @@ fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError } fn normalize_username(raw_username: &str) -> Result { - let username = raw_username.trim().to_string(); + let username = normalize_required_string(raw_username).unwrap_or_default(); let valid_length = (USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count()); let valid_chars = username @@ -1775,21 +1758,11 @@ fn mask_phone_number(phone_number: &str) -> String { format!("{}****{}", &phone_number[..3], &phone_number[7..11]) } -fn normalize_optional_string(value: Option) -> Option { - value.and_then(|field| { - let trimmed = field.trim().to_string(); - if trimmed.is_empty() { - return None; - } - Some(trimmed) - }) -} - fn build_random_password_seed() -> String { format!( "seed_{}_{}", - Uuid::new_v4().simple(), - Uuid::new_v4().simple() + new_uuid_simple_string(), + new_uuid_simple_string() ) } @@ -1798,13 +1771,11 @@ fn build_system_username(prefix: &str, sequence: u64) -> String { } fn format_rfc3339(value: OffsetDateTime) -> Result { - value - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| error.to_string()) + format_shared_rfc3339(value) } fn parse_phone_code_time(value: &str, field_label: &str) -> Result { - OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + parse_rfc3339(value) .map_err(|error| PhoneAuthError::Store(format!("短信验证码{field_label}解析失败:{error}"))) } @@ -1818,7 +1789,23 @@ fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String { } fn create_wechat_state_token() -> String { - Uuid::new_v4().simple().to_string() + new_uuid_simple_string() +} + +fn format_rfc3339_with_context( + value: OffsetDateTime, + field_label: &str, +) -> Result { + format_shared_rfc3339(value) + .map_err(|error| RefreshSessionError::Store(format!("{field_label}格式化失败:{error}"))) +} + +fn parse_rfc3339_with_context( + value: &str, + field_label: &str, +) -> Result { + parse_rfc3339(value) + .map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}"))) } impl PhoneAuthScene { diff --git a/server-rs/crates/module-combat/Cargo.toml b/server-rs/crates/module-combat/Cargo.toml new file mode 100644 index 00000000..4954cf4d --- /dev/null +++ b/server-rs/crates/module-combat/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "module-combat" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +module-runtime-item = { path = "../module-runtime-item", default-features = false } +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-combat/README.md b/server-rs/crates/module-combat/README.md index b1823f91..98c134f8 100644 --- a/server-rs/crates/module-combat/README.md +++ b/server-rs/crates/module-combat/README.md @@ -1,29 +1,47 @@ -# module-combat 独立模块 package 占位说明 +# module-combat -日期:`2026-04-20` +日期:`2026-04-21` ## 1. package 职责 -`module-combat` 是战斗规则模块 package,后续负责: +`module-combat` 是 M4 阶段的战斗规则 crate,当前负责: -1. `battle_state` 等战斗状态模型 -2. 战斗指令、伤害结算、战斗阶段推进规则 -3. 与 story action 主循环的战斗联动 -4. 与 `apps/spacetime-module` 的战斗表、reducer、view 聚合对接 +1. `battle_state` 首版领域类型与字段校验 +2. `resolve_combat_action` 的纯规则推进 +3. `fight / spar` 两种模式下的战斗收束规则 +4. 为 `spacetime-module` 提供可直接复用的战斗状态与 reducer 输入输出类型 -## 2. 当前阶段说明 +## 2. 当前实现范围 -当前提交仅完成目录占位,不提前进入具体战斗规则与数值实现。 +当前已经真实落地: -后续与本 package 直接相关的任务包括: +1. `BattleMode / BattleStatus / CombatOutcome` +2. `BattleStateInput / BattleStateSnapshot / BattleStateQueryInput` +3. `ResolveCombatActionInput / ResolveCombatActionResult` +4. `BattleStateProcedureResult / ResolveCombatActionProcedureResult` +5. `battle_attack_basic / battle_recover_breath / battle_use_skill / battle_escape_breakout` +6. 旧攻击类 function 的兼容解析 +7. `chapter_id / experience_reward` 最小承载字段,供 `spacetime-module` 在胜利时联动成长结算 -1. 设计 `battle_state` -2. 设计 `resolve_combat_action` -3. 对齐 battle 结果与兼容响应结构 -4. 接入 story 主循环的战斗型 action 结算 +当前刻意未做: -## 3. 边界约束 +1. `inventory_use` +2. 掉落、好感、任务信号联动 +3. story AI 续写触发 +4. 多目标或完整 build / cooldown 真相建模 -1. `module-combat` 保持纯规则、纯状态计算,不直接承接 HTTP、LLM、OSS 或其他外部副作用。 -2. 战斗联动通过明确 reducer 与模块边界协作,不回到散落在多个 service 的过程式写法。 -3. 前端兼容输出由 `apps/api-server` 暴露,战斗真相由 `apps/spacetime-module` 聚合。 +## 3. 配套文档 + +落地依据见: + +1. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md) +2. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md) +3. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md) +4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md) +5. [../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md) + +## 4. 边界约束 + +1. `module-combat` 只做纯规则、纯状态推进,不承接 HTTP、LLM、OSS 或文件 IO。 +2. 任何与 `inventory / progression / npc / story` 的联动,都应先在文档里冻结边界后再继续接入。 +3. 该 crate 的目标不是替代 Axum facade,而是成为 `spacetime-module` 里的战斗真相规则层。 diff --git a/server-rs/crates/module-combat/src/lib.rs b/server-rs/crates/module-combat/src/lib.rs new file mode 100644 index 00000000..7fd22c1f --- /dev/null +++ b/server-rs/crates/module-combat/src/lib.rs @@ -0,0 +1,835 @@ +use std::{error::Error, fmt}; + +use module_runtime_item::{ + RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot, +}; +use serde::{Deserialize, Serialize}; +use shared_kernel::{build_prefixed_seed_id, normalize_required_string}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const BATTLE_STATE_ID_PREFIX: &str = "battle_"; +pub const INITIAL_BATTLE_VERSION: u32 = 1; +pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14; +pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4; +pub const SPAR_MIN_HP: i32 = 1; + +const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [ + "battle_all_in_crush", + "battle_guard_break", + "battle_probe_pressure", + "battle_feint_step", + "battle_finisher_window", +]; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BattleMode { + Fight, + Spar, +} + +impl BattleMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Fight => "fight", + Self::Spar => "spar", + } + } +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BattleStatus { + Ongoing, + Resolved, + Aborted, +} + +impl BattleStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Ongoing => "ongoing", + Self::Resolved => "resolved", + Self::Aborted => "aborted", + } + } +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CombatOutcome { + Ongoing, + Victory, + SparComplete, + Escaped, +} + +impl CombatOutcome { + pub fn as_str(&self) -> &'static str { + match self { + Self::Ongoing => "ongoing", + Self::Victory => "victory", + Self::SparComplete => "spar_complete", + Self::Escaped => "escaped", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CombatFieldError { + MissingBattleStateId, + MissingStorySessionId, + MissingRuntimeSessionId, + MissingActorUserId, + MissingTargetNpcId, + MissingTargetName, + MissingFunctionId, + InvalidVersion, + InvalidPlayerVitals, + InvalidTargetVitals, + InvalidRewardItem(String), + BattleAlreadyResolved, + UnsupportedFunctionId, + InsufficientMana, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BattleStateInput { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: BattleMode, + 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, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BattleStateSnapshot { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: BattleMode, + pub status: BattleStatus, + 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, + pub turn_index: u32, + pub last_action_function_id: Option, + pub last_action_text: Option, + pub last_result_text: Option, + pub last_damage_dealt: i32, + pub last_damage_taken: i32, + pub last_outcome: CombatOutcome, + pub version: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveCombatActionInput { + pub battle_state_id: String, + pub function_id: String, + pub action_text: String, + pub base_damage: i32, + pub mana_cost: i32, + pub heal: i32, + pub mana_restore: i32, + pub counter_multiplier_basis_points: u32, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BattleStateQueryInput { + pub battle_state_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveCombatActionResult { + pub snapshot: BattleStateSnapshot, + pub damage_dealt: i32, + pub damage_taken: i32, + pub outcome: CombatOutcome, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BattleStateProcedureResult { + pub ok: bool, + pub snapshot: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveCombatActionProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + +pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> { + if normalize_required_string(&input.battle_state_id).is_none() { + return Err(CombatFieldError::MissingBattleStateId); + } + if normalize_required_string(&input.story_session_id).is_none() { + return Err(CombatFieldError::MissingStorySessionId); + } + if normalize_required_string(&input.runtime_session_id).is_none() { + return Err(CombatFieldError::MissingRuntimeSessionId); + } + if normalize_required_string(&input.actor_user_id).is_none() { + return Err(CombatFieldError::MissingActorUserId); + } + if normalize_required_string(&input.target_npc_id).is_none() { + return Err(CombatFieldError::MissingTargetNpcId); + } + if normalize_required_string(&input.target_name).is_none() { + return Err(CombatFieldError::MissingTargetName); + } + if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp { + return Err(CombatFieldError::InvalidPlayerVitals); + } + if input.player_max_mana < 0 + || input.player_mana < 0 + || input.player_mana > input.player_max_mana + { + return Err(CombatFieldError::InvalidPlayerVitals); + } + if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp { + return Err(CombatFieldError::InvalidTargetVitals); + } + for reward_item in input.reward_items.iter().cloned() { + normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?; + } + + Ok(()) +} + +pub fn validate_resolve_combat_action_input( + input: &ResolveCombatActionInput, +) -> Result<(), CombatFieldError> { + if normalize_required_string(&input.battle_state_id).is_none() { + return Err(CombatFieldError::MissingBattleStateId); + } + if normalize_required_string(&input.function_id).is_none() { + return Err(CombatFieldError::MissingFunctionId); + } + if !is_supported_combat_function_id(&input.function_id) { + return Err(CombatFieldError::UnsupportedFunctionId); + } + + Ok(()) +} + +pub fn build_battle_state_query_input( + battle_state_id: String, +) -> Result { + let input = BattleStateQueryInput { + battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(), + }; + + validate_battle_state_query_input(&input)?; + + Ok(input) +} + +pub fn validate_battle_state_query_input( + input: &BattleStateQueryInput, +) -> Result<(), CombatFieldError> { + if normalize_required_string(&input.battle_state_id).is_none() { + return Err(CombatFieldError::MissingBattleStateId); + } + + Ok(()) +} + +pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot { + BattleStateSnapshot { + battle_state_id: input.battle_state_id, + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + chapter_id: input.chapter_id, + target_npc_id: input.target_npc_id, + target_name: input.target_name, + battle_mode: input.battle_mode, + status: BattleStatus::Ongoing, + 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, + turn_index: 0, + last_action_function_id: None, + last_action_text: None, + last_result_text: None, + last_damage_dealt: 0, + last_damage_taken: 0, + last_outcome: CombatOutcome::Ongoing, + version: INITIAL_BATTLE_VERSION, + created_at_micros: input.created_at_micros, + updated_at_micros: input.created_at_micros, + } +} + +pub fn resolve_combat_action( + current: BattleStateSnapshot, + input: ResolveCombatActionInput, +) -> Result { + validate_resolve_combat_action_input(&input)?; + + if current.version == 0 { + return Err(CombatFieldError::InvalidVersion); + } + if current.status != BattleStatus::Ongoing { + return Err(CombatFieldError::BattleAlreadyResolved); + } + if current.player_mana < input.mana_cost.max(0) { + return Err(CombatFieldError::InsufficientMana); + } + + let action_text = if input.action_text.trim().is_empty() { + input.function_id.clone() + } else { + normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone()) + }; + + if input.function_id == "battle_escape_breakout" { + let next = BattleStateSnapshot { + status: BattleStatus::Resolved, + turn_index: current.turn_index + 1, + last_action_function_id: Some(input.function_id), + last_action_text: Some(action_text), + last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)), + last_damage_dealt: 0, + last_damage_taken: 0, + last_outcome: CombatOutcome::Escaped, + version: current.version + 1, + updated_at_micros: input.updated_at_micros, + ..current + }; + + return Ok(ResolveCombatActionResult { + snapshot: next, + damage_dealt: 0, + damage_taken: 0, + outcome: CombatOutcome::Escaped, + }); + } + + let mana_cost = input.mana_cost.max(0); + let heal = input.heal.max(0); + let mana_restore = input.mana_restore.max(0); + let base_damage = input.base_damage.max(0); + + let mut next_player_hp = current.player_hp; + let mut next_player_mana = (current.player_mana - mana_cost).max(0); + let mut next_target_hp = current.target_hp; + let mut damage_dealt = 0; + let mut damage_taken = 0; + + next_player_hp = clamp_hp( + current.battle_mode, + next_player_hp + heal, + current.player_max_hp, + ); + next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana); + + if base_damage > 0 { + next_target_hp = + clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage); + damage_dealt = current.target_hp - next_target_hp; + } + + let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp) + { + let outcome = match current.battle_mode { + BattleMode::Fight => CombatOutcome::Victory, + BattleMode::Spar => CombatOutcome::SparComplete, + }; + + ( + BattleStatus::Resolved, + outcome, + build_resolved_result_text(&action_text, ¤t.target_name, outcome), + ) + } else { + damage_taken = compute_counter_damage( + current.battle_mode, + current.target_max_hp, + input.counter_multiplier_basis_points, + ); + next_player_hp = clamp_hp( + current.battle_mode, + next_player_hp - damage_taken, + current.player_max_hp, + ); + + ( + BattleStatus::Ongoing, + CombatOutcome::Ongoing, + build_ongoing_result_text(&input.function_id, &action_text, ¤t.target_name), + ) + }; + + let next = BattleStateSnapshot { + player_hp: next_player_hp, + player_mana: next_player_mana, + target_hp: next_target_hp, + status, + turn_index: current.turn_index + 1, + last_action_function_id: Some(input.function_id), + last_action_text: Some(action_text), + last_result_text: Some(result_text), + last_damage_dealt: damage_dealt, + last_damage_taken: damage_taken, + last_outcome: outcome, + version: current.version + 1, + updated_at_micros: input.updated_at_micros, + ..current + }; + + Ok(ResolveCombatActionResult { + snapshot: next, + damage_dealt, + damage_taken, + outcome, + }) +} + +pub fn generate_battle_state_id(seed_micros: i64) -> String { + build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros) +} + +pub fn is_supported_combat_function_id(function_id: &str) -> bool { + matches!( + function_id, + "battle_attack_basic" + | "battle_recover_breath" + | "battle_use_skill" + | "battle_escape_breakout" + ) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id) +} + +fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 { + let min_hp = match mode { + BattleMode::Fight => 0, + BattleMode::Spar => SPAR_MIN_HP, + }; + + value.clamp(min_hp, max_hp) +} + +fn clamp_mana(value: i32, max_mana: i32) -> i32 { + value.clamp(0, max_mana) +} + +fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 { + match mode { + BattleMode::Fight => (current_hp - damage).max(0), + BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP), + } +} + +fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool { + match mode { + BattleMode::Fight => target_hp <= 0, + BattleMode::Spar => target_hp <= SPAR_MIN_HP, + } +} + +fn compute_counter_damage( + mode: BattleMode, + target_max_hp: i32, + counter_multiplier_basis_points: u32, +) -> i32 { + match mode { + BattleMode::Spar => 1, + BattleMode::Fight => { + let multiplier = counter_multiplier_basis_points as f32 / 10_000.0; + let raw = + (target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32; + raw.max(MIN_FIGHT_COUNTER_DAMAGE) + } + } +} + +fn build_resolved_result_text( + action_text: &str, + target_name: &str, + outcome: CombatOutcome, +) -> String { + match outcome { + CombatOutcome::Victory => { + format!( + "{}命中了{},这轮战斗已经正式结束。", + action_text, target_name + ) + } + CombatOutcome::SparComplete => { + format!( + "{}压住了{}的节奏,这场切磋已经分出高下。", + action_text, target_name + ) + } + CombatOutcome::Escaped => { + format!("{}后你成功脱离了当前战斗。", action_text) + } + CombatOutcome::Ongoing => format!("{}已完成结算。", action_text), + } +} + +fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String { + match function_id { + "battle_recover_breath" => { + format!( + "你先把伤势和气息稳住了一轮,但{}仍在持续逼近。", + target_name + ) + } + "battle_use_skill" => { + format!( + "{}命中了{},这一轮技能效果已经直接结算。", + action_text, target_name + ) + } + _ => format!( + "{}命中了{},本次攻击已经完成结算。", + action_text, target_name + ), + } +} + +fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError { + let message = match error { + TreasureFieldError::MissingRewardItemId => { + "battle_state.reward_items[].item_id 不能为空".to_string() + } + TreasureFieldError::MissingRewardItemCategory => { + "battle_state.reward_items[].category 不能为空".to_string() + } + TreasureFieldError::MissingRewardItemName => { + "battle_state.reward_items[].item_name 不能为空".to_string() + } + TreasureFieldError::InvalidRewardItemQuantity => { + "battle_state.reward_items[].quantity 必须大于 0".to_string() + } + TreasureFieldError::MissingRewardItemStackKey => { + "battle_state.reward_items[].stack_key 不能为空".to_string() + } + TreasureFieldError::RewardEquipmentItemCannotStack => { + "battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string() + } + TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => { + "battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string() + } + other => other.to_string(), + }; + + CombatFieldError::InvalidRewardItem(message) +} + +impl fmt::Display for CombatFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"), + Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"), + Self::MissingRuntimeSessionId => { + f.write_str("battle_state.runtime_session_id 不能为空") + } + Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"), + Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"), + Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"), + Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"), + Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"), + Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"), + Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"), + Self::InvalidRewardItem(message) => f.write_str(message), + Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"), + Self::UnsupportedFunctionId => { + f.write_str("resolve_combat_action.function_id 当前不受支持") + } + Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"), + } + } +} + +impl Error for CombatFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_fight_snapshot() -> BattleStateSnapshot { + build_battle_state_snapshot(BattleStateInput { + battle_state_id: "battle_001".to_string(), + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + chapter_id: Some("chapter_001".to_string()), + target_npc_id: "npc_001".to_string(), + target_name: "黑爪狼".to_string(), + battle_mode: BattleMode::Fight, + player_hp: 60, + player_max_hp: 60, + player_mana: 20, + player_max_mana: 20, + target_hp: 30, + target_max_hp: 30, + experience_reward: 18, + reward_items: vec![], + created_at_micros: 10, + }) + } + + #[test] + fn validate_battle_state_input_accepts_minimal_contract() { + let result = validate_battle_state_input(&BattleStateInput { + battle_state_id: "battle_001".to_string(), + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + chapter_id: Some("chapter_001".to_string()), + target_npc_id: "npc_001".to_string(), + target_name: "黑爪狼".to_string(), + battle_mode: BattleMode::Fight, + player_hp: 50, + player_max_hp: 60, + player_mana: 10, + player_max_mana: 20, + target_hp: 30, + target_max_hp: 30, + experience_reward: 12, + reward_items: vec![], + created_at_micros: 1, + }); + + assert!(result.is_ok()); + } + + #[test] + fn validate_battle_state_input_rejects_invalid_reward_items() { + let error = validate_battle_state_input(&BattleStateInput { + battle_state_id: "battle_001".to_string(), + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + chapter_id: Some("chapter_001".to_string()), + target_npc_id: "npc_001".to_string(), + target_name: "黑爪狼".to_string(), + battle_mode: BattleMode::Fight, + player_hp: 50, + player_max_hp: 60, + player_mana: 10, + player_max_mana: 20, + target_hp: 30, + target_max_hp: 30, + experience_reward: 12, + reward_items: vec![RuntimeItemRewardItemSnapshot { + item_id: String::new(), + category: "遗物".to_string(), + item_name: "铜钥残片".to_string(), + description: None, + quantity: 1, + rarity: module_runtime_item::RuntimeItemRewardItemRarity::Rare, + tags: vec![], + stackable: false, + stack_key: String::new(), + equipment_slot_id: None, + }], + created_at_micros: 1, + }) + .expect_err("invalid reward item should be rejected"); + + assert_eq!( + error, + CombatFieldError::InvalidRewardItem( + "battle_state.reward_items[].item_id 不能为空".to_string() + ) + ); + } + + #[test] + fn build_battle_state_query_input_trims_and_validates_id() { + let input = build_battle_state_query_input(" battle_001 ".to_string()) + .expect("query input should build"); + + assert_eq!(input.battle_state_id, "battle_001"); + } + + #[test] + fn build_battle_state_query_input_rejects_empty_id() { + let error = + build_battle_state_query_input(" ".to_string()).expect_err("empty id should fail"); + + assert_eq!(error, CombatFieldError::MissingBattleStateId); + } + + #[test] + fn resolve_basic_attack_advances_turn_and_applies_counter_damage() { + let result = resolve_combat_action( + build_fight_snapshot(), + ResolveCombatActionInput { + battle_state_id: "battle_001".to_string(), + function_id: "battle_attack_basic".to_string(), + action_text: "普通攻击".to_string(), + base_damage: 10, + mana_cost: 0, + heal: 0, + mana_restore: 0, + counter_multiplier_basis_points: 10_000, + updated_at_micros: 20, + }, + ) + .expect("basic attack should succeed"); + + assert_eq!(result.snapshot.turn_index, 1); + assert_eq!(result.snapshot.target_hp, 20); + assert_eq!(result.snapshot.player_hp, 56); + assert_eq!(result.snapshot.last_damage_dealt, 10); + assert_eq!(result.snapshot.last_damage_taken, 4); + assert_eq!(result.outcome, CombatOutcome::Ongoing); + } + + #[test] + fn resolve_escape_marks_battle_resolved() { + let result = resolve_combat_action( + build_fight_snapshot(), + ResolveCombatActionInput { + battle_state_id: "battle_001".to_string(), + function_id: "battle_escape_breakout".to_string(), + action_text: "逃跑".to_string(), + base_damage: 0, + mana_cost: 0, + heal: 0, + mana_restore: 0, + counter_multiplier_basis_points: 0, + updated_at_micros: 20, + }, + ) + .expect("escape should succeed"); + + assert_eq!(result.snapshot.status, BattleStatus::Resolved); + assert_eq!(result.snapshot.last_outcome, CombatOutcome::Escaped); + assert_eq!(result.damage_dealt, 0); + assert_eq!(result.damage_taken, 0); + } + + #[test] + fn resolve_skill_can_finish_fight() { + let result = resolve_combat_action( + build_fight_snapshot(), + ResolveCombatActionInput { + battle_state_id: "battle_001".to_string(), + function_id: "battle_use_skill".to_string(), + action_text: "试锋斩".to_string(), + base_damage: 35, + mana_cost: 8, + heal: 0, + mana_restore: 0, + counter_multiplier_basis_points: 9_500, + updated_at_micros: 20, + }, + ) + .expect("skill should succeed"); + + assert_eq!(result.snapshot.status, BattleStatus::Resolved); + assert_eq!(result.snapshot.target_hp, 0); + assert_eq!(result.snapshot.player_mana, 12); + assert_eq!(result.outcome, CombatOutcome::Victory); + assert_eq!(result.damage_taken, 0); + } + + #[test] + fn spar_mode_keeps_hp_floor_at_one() { + let snapshot = build_battle_state_snapshot(BattleStateInput { + battle_state_id: "battle_002".to_string(), + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + chapter_id: Some("chapter_spar".to_string()), + target_npc_id: "npc_002".to_string(), + target_name: "卫队长".to_string(), + battle_mode: BattleMode::Spar, + player_hp: 5, + player_max_hp: 5, + player_mana: 10, + player_max_mana: 10, + target_hp: 3, + target_max_hp: 3, + experience_reward: 0, + reward_items: vec![], + created_at_micros: 10, + }); + + let result = resolve_combat_action( + snapshot, + ResolveCombatActionInput { + battle_state_id: "battle_002".to_string(), + function_id: "battle_attack_basic".to_string(), + action_text: "普通攻击".to_string(), + base_damage: 5, + mana_cost: 0, + heal: 0, + mana_restore: 0, + counter_multiplier_basis_points: 10_000, + updated_at_micros: 20, + }, + ) + .expect("spar attack should succeed"); + + assert_eq!(result.snapshot.target_hp, 1); + assert_eq!(result.snapshot.status, BattleStatus::Resolved); + assert_eq!(result.outcome, CombatOutcome::SparComplete); + } + + #[test] + fn resolve_rejects_unsupported_function() { + let error = resolve_combat_action( + build_fight_snapshot(), + ResolveCombatActionInput { + battle_state_id: "battle_001".to_string(), + function_id: "inventory_use".to_string(), + action_text: "使用物品".to_string(), + base_damage: 0, + mana_cost: 0, + heal: 0, + mana_restore: 0, + counter_multiplier_basis_points: 7_200, + updated_at_micros: 20, + }, + ) + .expect_err("inventory_use should be deferred for now"); + + assert_eq!(error, CombatFieldError::UnsupportedFunctionId); + } +} diff --git a/server-rs/crates/module-custom-world/Cargo.toml b/server-rs/crates/module-custom-world/Cargo.toml new file mode 100644 index 00000000..c14bdba6 --- /dev/null +++ b/server-rs/crates/module-custom-world/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-custom-world" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-custom-world/README.md b/server-rs/crates/module-custom-world/README.md index 3cebd176..b5bd3eae 100644 --- a/server-rs/crates/module-custom-world/README.md +++ b/server-rs/crates/module-custom-world/README.md @@ -1,6 +1,6 @@ -# module-custom-world 独立模块 package 占位说明 +# module-custom-world 独立模块 package 说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. package 职责 @@ -14,7 +14,41 @@ ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入问答流、agent 流、世界编译与资产绑定实现。 +当前阶段已经不再是单纯目录占位,而是先把 `M5` 首批 `custom world / agent` 类型契约与字段校验固定下来,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。 + +当前已落地: + +1. 真实 `Cargo.toml` crate scaffold +2. `CustomWorldPublicationStatus`、`CustomWorldThemeMode`、`CustomWorldGenerationMode` +3. `CustomWorldSessionStatus`、`RpgAgentStage` +4. `RpgAgentMessageRole`、`RpgAgentMessageKind` +5. `RpgAgentOperationType`、`RpgAgentOperationStatus` +6. `RpgAgentDraftCardKind`、`RpgAgentDraftCardStatus` +7. `CustomWorldRoleAssetStatus` +8. 首批表字段校验函数与最小单测 +9. `published profile compile` 输入输出 contract +10. `publish_world` 串联输入输出 contract + +当前 crate 仍然只承接: + +1. 共享枚举与类型口径 +2. 字段校验与字符串归一化 +3. published profile compile 的最小编译摘要 contract +4. 后续 `spacetime-module` 聚合表时需要复用的领域边界 + +当前阶段明确不提前进入: + +1. 旧问答流 reducer 编排 +2. RPG 创作 Agent 编排 +3. publish gate blocker 规则迁移 +4. 资产绑定与图片生成副作用 + +当前设计依据: + +1. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md) +2. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md) +4. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md) 后续与本 package 直接相关的任务包括: @@ -26,5 +60,6 @@ ## 3. 边界约束 1. `module-custom-world` 负责世界状态真相、agent 状态与模块级编排,不把整个会话重新塞回单大 JSON 体。 -2. 外部 LLM、图片生成、OSS 写入等副作用通过平台适配和应用层完成,状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。 -3. 前端兼容 REST 与 SSE 由 `apps/api-server` 暴露,但自定义世界主链状态不能再次分散到本地 session store 或前端临时状态中。 +2. 外部 LLM、图片生成、OSS 写入等副作用通过平台适配和应用层完成,状态最终回写到 `spacetime-module` 聚合的状态模型中。 +3. 前端兼容 REST 与 SSE 由 `api-server` 暴露,但自定义世界主链状态不能再次分散到本地 session store 或前端临时状态中。 +4. `custom_world_asset_link` 本轮不冻结,等待 `asset_object / asset_entity_binding / M6 assets` 的槽位规则稳定后再接。 diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs new file mode 100644 index 00000000..2eae38c5 --- /dev/null +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -0,0 +1,1544 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const MAX_PROGRESS_PERCENT: u32 = 100; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CustomWorldPublicationStatus { + Draft, + Published, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CustomWorldThemeMode { + Martial, + Arcane, + Machina, + Tide, + Rift, + Mythic, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CustomWorldGenerationMode { + Fast, + Full, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CustomWorldSessionStatus { + Clarifying, + ReadyToGenerate, + Generating, + Completed, + GenerationError, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RpgAgentStage { + CollectingIntent, + Clarifying, + FoundationReview, + ObjectRefining, + VisualRefining, + LongTailReview, + ReadyToPublish, + Published, + Error, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RpgAgentMessageRole { + User, + Assistant, + System, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RpgAgentMessageKind { + Chat, + Clarification, + Summary, + Checkpoint, + Warning, + ActionResult, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RpgAgentOperationType { + ProcessMessage, + DraftFoundation, + UpdateDraftCard, + SyncResultProfile, + GenerateCharacters, + GenerateLandmarks, + GenerateRoleAssets, + SyncRoleAssets, + GenerateSceneAssets, + SyncSceneAssets, + ExpandLongTail, + PublishWorld, + RevertCheckpoint, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RpgAgentOperationStatus { + Queued, + Running, + Completed, + Failed, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RpgAgentDraftCardKind { + World, + Camp, + Faction, + Character, + Landmark, + Thread, + Chapter, + SceneChapter, + Carrier, + SidequestSeed, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RpgAgentDraftCardStatus { + Suggested, + Confirmed, + Locked, + Warning, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CustomWorldRoleAssetStatus { + Missing, + VisualReady, + AnimationsReady, + Complete, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CustomWorldFieldError { + MissingProfileId, + MissingSessionId, + MissingOwnerUserId, + MissingWorldName, + MissingDraftProfileJson, + MissingProfilePayloadJson, + MissingSettingText, + MissingQuestionSnapshotJson, + MissingAnchorContentJson, + MissingCreatorIntentReadinessJson, + MissingAssetCoverageJson, + MissingPendingClarificationsJson, + MissingMessageId, + MissingMessageText, + MissingOperationId, + MissingPhaseLabel, + InvalidProgressPercent, + MissingCardId, + MissingCardTitle, + MissingCardSummary, + MissingLinkedIdsJson, + MissingAuthorDisplayName, + InvalidDraftProfileJson, + InvalidLegacyResultProfileJson, + InvalidJsonPayload, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub source_agent_session_id: Option, + pub publication_status: CustomWorldPublicationStatus, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub published_at_micros: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldGalleryEntrySnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub published_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldLibraryMutationResult { + pub ok: bool, + pub entry: Option, + pub gallery_entry: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileListResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldGalleryListResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: RpgAgentMessageRole, + pub kind: RpgAgentMessageKind, + pub text: String, + pub related_operation_id: Option, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentOperationSnapshot { + pub operation_id: String, + pub session_id: String, + pub operation_type: RpgAgentOperationType, + pub status: RpgAgentOperationStatus, + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, + pub error_message: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldDraftCardSnapshot { + pub card_id: String, + pub session_id: String, + pub kind: RpgAgentDraftCardKind, + pub status: RpgAgentDraftCardStatus, + pub title: String, + pub subtitle: String, + pub summary: String, + pub linked_ids_json: String, + pub warning_count: u32, + pub asset_status: Option, + pub asset_status_label: Option, + pub detail_payload_json: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: RpgAgentStage, + pub focus_card_id: Option, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub last_assistant_reply: Option, + pub result_preview_json: Option, + pub pending_clarifications_json: String, + pub quality_findings_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub messages: Vec, + pub draft_cards: Vec, + pub operations: Vec, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentSessionProcedureResult { + pub ok: bool, + pub session: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileUpsertInput { + pub profile_id: String, + pub owner_user_id: String, + pub source_agent_session_id: Option, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfilePublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub published_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileUnpublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileListInput { + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldLibraryDetailInput { + pub owner_user_id: String, + pub profile_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldGalleryDetailInput { + pub owner_user_id: String, + pub profile_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub pending_clarifications_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub quality_findings_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub operation_id: String, + pub submitted_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentOperationGetInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentOperationProcedureResult { + pub ok: bool, + pub operation: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldPublishedProfileCompileInput { + pub session_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + pub setting_text: String, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldPublishedProfileCompileSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub compiled_profile_payload_json: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldPublishedProfileCompileResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldPublishWorldInput { + pub session_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + pub setting_text: String, + pub author_display_name: String, + pub published_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldPublishWorldResult { + pub ok: bool, + pub compiled_record: Option, + pub entry: Option, + pub gallery_entry: Option, + pub session_stage: Option, + pub error_message: Option, +} + +impl CustomWorldPublicationStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + } + } +} + +impl CustomWorldThemeMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Martial => "martial", + Self::Arcane => "arcane", + Self::Machina => "machina", + Self::Tide => "tide", + Self::Rift => "rift", + Self::Mythic => "mythic", + } + } + + pub fn from_client_str(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "martial" => Some(Self::Martial), + "arcane" => Some(Self::Arcane), + "machina" => Some(Self::Machina), + "tide" => Some(Self::Tide), + "rift" => Some(Self::Rift), + "mythic" => Some(Self::Mythic), + _ => None, + } + } +} + +impl CustomWorldGenerationMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Fast => "fast", + Self::Full => "full", + } + } +} + +impl CustomWorldSessionStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Clarifying => "clarifying", + Self::ReadyToGenerate => "ready_to_generate", + Self::Generating => "generating", + Self::Completed => "completed", + Self::GenerationError => "generation_error", + } + } +} + +impl RpgAgentStage { + pub fn as_str(&self) -> &'static str { + match self { + Self::CollectingIntent => "collecting_intent", + Self::Clarifying => "clarifying", + Self::FoundationReview => "foundation_review", + Self::ObjectRefining => "object_refining", + Self::VisualRefining => "visual_refining", + Self::LongTailReview => "long_tail_review", + Self::ReadyToPublish => "ready_to_publish", + Self::Published => "published", + Self::Error => "error", + } + } +} + +impl RpgAgentMessageRole { + pub fn as_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::Assistant => "assistant", + Self::System => "system", + } + } +} + +impl RpgAgentMessageKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Chat => "chat", + Self::Clarification => "clarification", + Self::Summary => "summary", + Self::Checkpoint => "checkpoint", + Self::Warning => "warning", + Self::ActionResult => "action_result", + } + } +} + +impl RpgAgentOperationType { + pub fn as_str(&self) -> &'static str { + match self { + Self::ProcessMessage => "process_message", + Self::DraftFoundation => "draft_foundation", + Self::UpdateDraftCard => "update_draft_card", + Self::SyncResultProfile => "sync_result_profile", + Self::GenerateCharacters => "generate_characters", + Self::GenerateLandmarks => "generate_landmarks", + Self::GenerateRoleAssets => "generate_role_assets", + Self::SyncRoleAssets => "sync_role_assets", + Self::GenerateSceneAssets => "generate_scene_assets", + Self::SyncSceneAssets => "sync_scene_assets", + Self::ExpandLongTail => "expand_long_tail", + Self::PublishWorld => "publish_world", + Self::RevertCheckpoint => "revert_checkpoint", + } + } +} + +impl RpgAgentOperationStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Running => "running", + Self::Completed => "completed", + Self::Failed => "failed", + } + } +} + +impl RpgAgentDraftCardKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::World => "world", + Self::Camp => "camp", + Self::Faction => "faction", + Self::Character => "character", + Self::Landmark => "landmark", + Self::Thread => "thread", + Self::Chapter => "chapter", + Self::SceneChapter => "scene_chapter", + Self::Carrier => "carrier", + Self::SidequestSeed => "sidequest_seed", + } + } +} + +impl RpgAgentDraftCardStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Suggested => "suggested", + Self::Confirmed => "confirmed", + Self::Locked => "locked", + Self::Warning => "warning", + } + } +} + +impl CustomWorldRoleAssetStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Missing => "missing", + Self::VisualReady => "visual_ready", + Self::AnimationsReady => "animations_ready", + Self::Complete => "complete", + } + } +} + +pub fn validate_custom_world_profile_fields( + profile_id: &str, + owner_user_id: &str, + world_name: &str, + profile_payload_json: &str, +) -> Result<(), CustomWorldFieldError> { + if profile_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfileId); + } + if owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if world_name.trim().is_empty() { + return Err(CustomWorldFieldError::MissingWorldName); + } + if profile_payload_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfilePayloadJson); + } + + Ok(()) +} + +pub fn validate_custom_world_published_profile_compile_input( + input: &CustomWorldPublishedProfileCompileInput, +) -> Result<(), CustomWorldFieldError> { + if input.session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if input.profile_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfileId); + } + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.draft_profile_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingDraftProfileJson); + } + if input.setting_text.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSettingText); + } + if input.author_display_name.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAuthorDisplayName); + } + + Ok(()) +} + +pub fn validate_custom_world_publish_world_input( + input: &CustomWorldPublishWorldInput, +) -> Result<(), CustomWorldFieldError> { + validate_custom_world_published_profile_compile_input( + &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, + }, + ) +} + +pub fn validate_custom_world_profile_upsert_input( + input: &CustomWorldProfileUpsertInput, +) -> Result<(), CustomWorldFieldError> { + validate_custom_world_profile_fields( + &input.profile_id, + &input.owner_user_id, + &input.world_name, + &input.profile_payload_json, + )?; + + if input.author_display_name.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAuthorDisplayName); + } + + Ok(()) +} + +pub fn validate_custom_world_profile_publish_input( + input: &CustomWorldProfilePublishInput, +) -> Result<(), CustomWorldFieldError> { + if input.profile_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfileId); + } + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.author_display_name.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAuthorDisplayName); + } + + Ok(()) +} + +pub fn validate_custom_world_profile_unpublish_input( + input: &CustomWorldProfileUnpublishInput, +) -> Result<(), CustomWorldFieldError> { + if input.profile_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfileId); + } + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.author_display_name.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAuthorDisplayName); + } + + Ok(()) +} + +pub fn validate_custom_world_profile_list_input( + input: &CustomWorldProfileListInput, +) -> Result<(), CustomWorldFieldError> { + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + + Ok(()) +} + +pub fn validate_custom_world_library_detail_input( + input: &CustomWorldLibraryDetailInput, +) -> Result<(), CustomWorldFieldError> { + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.profile_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfileId); + } + + Ok(()) +} + +pub fn validate_custom_world_gallery_detail_input( + input: &CustomWorldGalleryDetailInput, +) -> Result<(), CustomWorldFieldError> { + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.profile_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfileId); + } + + Ok(()) +} + +pub fn validate_custom_world_session_fields( + session_id: &str, + owner_user_id: &str, + setting_text: &str, + question_snapshot_json: &str, +) -> Result<(), CustomWorldFieldError> { + if session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if setting_text.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSettingText); + } + if question_snapshot_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingQuestionSnapshotJson); + } + + Ok(()) +} + +pub fn validate_custom_world_agent_session_fields( + session_id: &str, + owner_user_id: &str, + anchor_content_json: &str, + creator_intent_readiness_json: &str, + pending_clarifications_json: &str, + asset_coverage_json: &str, + progress_percent: u32, +) -> Result<(), CustomWorldFieldError> { + if session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if anchor_content_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAnchorContentJson); + } + if creator_intent_readiness_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingCreatorIntentReadinessJson); + } + if pending_clarifications_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingPendingClarificationsJson); + } + if asset_coverage_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAssetCoverageJson); + } + if progress_percent > MAX_PROGRESS_PERCENT { + return Err(CustomWorldFieldError::InvalidProgressPercent); + } + + Ok(()) +} + +pub fn validate_custom_world_agent_session_create_input( + input: &CustomWorldAgentSessionCreateInput, +) -> Result<(), CustomWorldFieldError> { + validate_custom_world_agent_session_fields( + &input.session_id, + &input.owner_user_id, + &input.anchor_content_json, + &input.creator_intent_readiness_json, + &input.pending_clarifications_json, + &input.asset_coverage_json, + 0, + )?; + + validate_custom_world_agent_message_fields( + &input.welcome_message_id, + &input.session_id, + &input.welcome_message_text, + )?; + ensure_json_object(&input.anchor_content_json)?; + ensure_optional_json_object(input.creator_intent_json.as_deref())?; + ensure_json_object(&input.creator_intent_readiness_json)?; + ensure_optional_json_object(input.anchor_pack_json.as_deref())?; + ensure_optional_json_object(input.lock_state_json.as_deref())?; + ensure_optional_json_object(input.draft_profile_json.as_deref())?; + ensure_json_array(&input.pending_clarifications_json)?; + ensure_json_array(&input.suggested_actions_json)?; + ensure_json_array(&input.recommended_replies_json)?; + ensure_json_array(&input.quality_findings_json)?; + ensure_json_object(&input.asset_coverage_json)?; + ensure_json_array(&input.checkpoints_json)?; + + Ok(()) +} + +pub fn validate_custom_world_agent_session_get_input( + input: &CustomWorldAgentSessionGetInput, +) -> Result<(), CustomWorldFieldError> { + if input.session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + + Ok(()) +} + +pub fn validate_custom_world_agent_message_submit_input( + input: &CustomWorldAgentMessageSubmitInput, +) -> Result<(), CustomWorldFieldError> { + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + + validate_custom_world_agent_message_fields( + &input.user_message_id, + &input.session_id, + &input.user_message_text, + )?; + validate_custom_world_agent_operation_fields( + &input.operation_id, + &input.session_id, + "消息已处理", + MAX_PROGRESS_PERCENT, + )?; + + Ok(()) +} + +pub fn validate_custom_world_agent_operation_get_input( + input: &CustomWorldAgentOperationGetInput, +) -> Result<(), CustomWorldFieldError> { + if input.session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if input.owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if input.operation_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOperationId); + } + + Ok(()) +} + +pub fn validate_custom_world_agent_message_fields( + message_id: &str, + session_id: &str, + text: &str, +) -> Result<(), CustomWorldFieldError> { + if message_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingMessageId); + } + if session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if text.trim().is_empty() { + return Err(CustomWorldFieldError::MissingMessageText); + } + + Ok(()) +} + +pub fn validate_custom_world_agent_operation_fields( + operation_id: &str, + session_id: &str, + phase_label: &str, + progress: u32, +) -> Result<(), CustomWorldFieldError> { + if operation_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOperationId); + } + if session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if phase_label.trim().is_empty() { + return Err(CustomWorldFieldError::MissingPhaseLabel); + } + if progress > MAX_PROGRESS_PERCENT { + return Err(CustomWorldFieldError::InvalidProgressPercent); + } + + Ok(()) +} + +pub fn validate_custom_world_draft_card_fields( + card_id: &str, + session_id: &str, + title: &str, + summary: &str, + linked_ids_json: &str, +) -> Result<(), CustomWorldFieldError> { + if card_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingCardId); + } + if session_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingSessionId); + } + if title.trim().is_empty() { + return Err(CustomWorldFieldError::MissingCardTitle); + } + if summary.trim().is_empty() { + return Err(CustomWorldFieldError::MissingCardSummary); + } + if linked_ids_json.trim().is_empty() { + return Err(CustomWorldFieldError::MissingLinkedIdsJson); + } + + Ok(()) +} + +pub fn validate_custom_world_gallery_entry_fields( + profile_id: &str, + owner_user_id: &str, + author_display_name: &str, + world_name: &str, +) -> Result<(), CustomWorldFieldError> { + if profile_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingProfileId); + } + if owner_user_id.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } + if author_display_name.trim().is_empty() { + return Err(CustomWorldFieldError::MissingAuthorDisplayName); + } + if world_name.trim().is_empty() { + return Err(CustomWorldFieldError::MissingWorldName); + } + + Ok(()) +} + +pub fn build_custom_world_published_profile_compile_snapshot( + input: CustomWorldPublishedProfileCompileInput, +) -> Result { + validate_custom_world_published_profile_compile_input(&input)?; + + let draft = parse_required_json_object( + &input.draft_profile_json, + CustomWorldFieldError::InvalidDraftProfileJson, + )?; + let legacy = parse_optional_json_object( + input.legacy_result_profile_json.clone(), + CustomWorldFieldError::InvalidLegacyResultProfileJson, + )?; + + let world_name = resolve_text_field(&draft, &legacy, "name") + .ok_or(CustomWorldFieldError::MissingWorldName)?; + let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default(); + let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default(); + let cover_image_src = resolve_cover_image_src(&draft, &legacy); + let theme_mode = resolve_theme_mode(&legacy); + let playable_npc_count = + count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs")); + let landmark_count = to_array(draft.get("landmarks")).len() as u32; + + let compiled_payload_json = build_compiled_profile_payload_json( + &input, + &draft, + &legacy, + &world_name, + &subtitle, + &summary_text, + )?; + + Ok(CustomWorldPublishedProfileCompileSnapshot { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + world_name, + subtitle, + summary_text, + theme_mode, + cover_image_src, + playable_npc_count, + landmark_count, + author_display_name: input.author_display_name, + compiled_profile_payload_json: compiled_payload_json, + updated_at_micros: input.updated_at_micros, + }) +} + +pub fn empty_agent_anchor_content_json() -> String { + r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":[],"hiddenLines":null,"iconicElements":null}"#.to_string() +} + +pub fn empty_agent_creator_intent_readiness_json() -> String { + r#"{"isReady":false,"completedKeys":[],"missingKeys":[]}"#.to_string() +} + +pub fn empty_agent_asset_coverage_json() -> String { + r#"{"roleAssets":[],"sceneAssets":[],"allRoleAssetsReady":false,"allSceneAssetsReady":false}"# + .to_string() +} + +pub fn empty_json_object() -> String { + "{}".to_string() +} + +pub fn empty_json_array() -> String { + "[]".to_string() +} + +pub fn normalize_optional_json_slice(value: Option) -> Option { + value.and_then(|value| { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + }) +} + +fn ensure_json_object(value: &str) -> Result<(), CustomWorldFieldError> { + match serde_json::from_str::(value) { + Ok(Value::Object(_)) => Ok(()), + _ => Err(CustomWorldFieldError::InvalidJsonPayload), + } +} + +fn ensure_optional_json_object(value: Option<&str>) -> Result<(), CustomWorldFieldError> { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => ensure_json_object(value), + None => Ok(()), + } +} + +fn ensure_json_array(value: &str) -> Result<(), CustomWorldFieldError> { + match serde_json::from_str::(value) { + Ok(Value::Array(_)) => Ok(()), + _ => Err(CustomWorldFieldError::InvalidJsonPayload), + } +} + +fn parse_required_json_object( + value: &str, + error: CustomWorldFieldError, +) -> Result, CustomWorldFieldError> { + match serde_json::from_str::(value) { + Ok(Value::Object(object)) => Ok(object), + _ => Err(error), + } +} + +fn parse_optional_json_object( + value: Option, + error: CustomWorldFieldError, +) -> Result, CustomWorldFieldError> { + match normalize_optional_json_slice(value) { + Some(value) => parse_required_json_object(&value, error), + None => Ok(Map::new()), + } +} + +fn to_text(value: Option<&Value>) -> Option { + match value { + Some(Value::String(value)) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + _ => None, + } +} + +fn to_array(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(items)) => items.clone(), + _ => Vec::new(), + } +} + +fn to_object(value: Option<&Value>) -> Option> { + match value { + Some(Value::Object(object)) => Some(object.clone()), + _ => None, + } +} + +fn resolve_text_field( + draft: &Map, + legacy: &Map, + key: &str, +) -> Option { + to_text(draft.get(key)).or_else(|| to_text(legacy.get(key))) +} + +fn resolve_theme_mode(legacy: &Map) -> CustomWorldThemeMode { + to_text(legacy.get("themeMode")) + .and_then(|value| CustomWorldThemeMode::from_client_str(&value)) + .unwrap_or(CustomWorldThemeMode::Mythic) +} + +fn resolve_cover_image_src( + draft: &Map, + legacy: &Map, +) -> Option { + if let Some(camp) = to_object(draft.get("camp")) { + if let Some(image_src) = to_text(camp.get("imageSrc")) { + return Some(image_src); + } + } + + for landmark in to_array(draft.get("landmarks")) { + if let Value::Object(landmark) = landmark { + if let Some(image_src) = to_text(landmark.get("imageSrc")) { + return Some(image_src); + } + } + } + + if let Some(cover) = to_object(legacy.get("cover")) { + if let Some(image_src) = to_text(cover.get("imageSrc")) { + return Some(image_src); + } + } + + to_text(legacy.get("coverImageSrc")) +} + +fn count_distinct_roles(playable: Option<&Value>, story: Option<&Value>) -> u32 { + let mut seen = std::collections::BTreeSet::new(); + + for role in to_array(playable).into_iter().chain(to_array(story)) { + if let Value::Object(role) = role { + let key = to_text(role.get("id")) + .or_else(|| to_text(role.get("name"))) + .unwrap_or_else(|| format!("role-{}", seen.len())); + seen.insert(key); + } + } + + seen.len() as u32 +} + +fn build_compiled_profile_payload_json( + input: &CustomWorldPublishedProfileCompileInput, + draft: &Map, + legacy: &Map, + world_name: &str, + subtitle: &str, + summary_text: &str, +) -> Result { + let mut payload = legacy.clone(); + + payload.insert("id".to_string(), Value::String(input.profile_id.clone())); + payload.insert( + "settingText".to_string(), + Value::String(input.setting_text.trim().to_string()), + ); + payload.insert("name".to_string(), Value::String(world_name.to_string())); + payload.insert("subtitle".to_string(), Value::String(subtitle.to_string())); + payload.insert( + "summary".to_string(), + Value::String(summary_text.to_string()), + ); + payload.insert( + "updatedAtMicros".to_string(), + Value::Number(input.updated_at_micros.into()), + ); + + for key in ["tone", "playerGoal"] { + if let Some(value) = draft.get(key) { + payload.insert(key.to_string(), value.clone()); + } + } + + for key in [ + "majorFactions", + "coreConflicts", + "playableNpcs", + "storyNpcs", + "landmarks", + "camp", + ] { + if let Some(value) = draft.get(key) { + payload.insert(key.to_string(), value.clone()); + } + } + + if let Some(scene_chapters) = draft.get("sceneChapters") { + payload.insert("sceneChapterBlueprints".to_string(), scene_chapters.clone()); + } + + serde_json::to_string(&Value::Object(payload)) + .map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson) +} + +impl fmt::Display for CustomWorldFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"), + Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"), + Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"), + Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"), + Self::MissingDraftProfileJson => { + f.write_str("custom_world.compile.draft_profile_json 不能为空") + } + Self::MissingProfilePayloadJson => { + f.write_str("custom_world.profile_payload_json 不能为空") + } + Self::MissingSettingText => f.write_str("custom_world.setting_text 不能为空"), + Self::MissingQuestionSnapshotJson => { + f.write_str("custom_world.question_snapshot_json 不能为空") + } + Self::MissingAnchorContentJson => { + f.write_str("custom_world.anchor_content_json 不能为空") + } + Self::MissingCreatorIntentReadinessJson => { + f.write_str("custom_world.creator_intent_readiness_json 不能为空") + } + Self::MissingAssetCoverageJson => { + f.write_str("custom_world.asset_coverage_json 不能为空") + } + Self::MissingPendingClarificationsJson => { + f.write_str("custom_world.pending_clarifications_json 不能为空") + } + Self::MissingMessageId => f.write_str("custom_world_agent_message.message_id 不能为空"), + Self::MissingMessageText => f.write_str("custom_world_agent_message.text 不能为空"), + Self::MissingOperationId => { + f.write_str("custom_world_agent_operation.operation_id 不能为空") + } + Self::MissingPhaseLabel => { + f.write_str("custom_world_agent_operation.phase_label 不能为空") + } + Self::InvalidProgressPercent => f.write_str("progress 必须位于 0~100"), + Self::MissingCardId => f.write_str("custom_world_draft_card.card_id 不能为空"), + Self::MissingCardTitle => f.write_str("custom_world_draft_card.title 不能为空"), + Self::MissingCardSummary => f.write_str("custom_world_draft_card.summary 不能为空"), + Self::MissingLinkedIdsJson => { + f.write_str("custom_world_draft_card.linked_ids_json 不能为空") + } + Self::MissingAuthorDisplayName => { + f.write_str("custom_world_gallery_entry.author_display_name 不能为空") + } + Self::InvalidDraftProfileJson => { + f.write_str("custom_world.compile.draft_profile_json 不是合法 JSON object") + } + Self::InvalidLegacyResultProfileJson => { + f.write_str("custom_world.compile.legacy_result_profile_json 不是合法 JSON object") + } + Self::InvalidJsonPayload => f.write_str("custom_world JSON payload 结构非法"), + } + } +} + +impl Error for CustomWorldFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_validation_rejects_blank_owner() { + let error = validate_custom_world_profile_fields( + "cwprof_001", + " ", + "裂潮边城", + "{\"id\":\"cwprof_001\"}", + ) + .expect_err("blank owner should fail"); + + assert_eq!(error, CustomWorldFieldError::MissingOwnerUserId); + } + + #[test] + fn agent_session_validation_rejects_progress_over_hundred() { + let error = validate_custom_world_agent_session_fields( + "custom-world-agent-session-001", + "user_001", + "{}", + "{}", + "[]", + "{}", + 101, + ) + .expect_err("progress greater than 100 should fail"); + + assert_eq!(error, CustomWorldFieldError::InvalidProgressPercent); + } + + #[test] + fn enum_string_values_match_current_contract() { + assert_eq!( + RpgAgentOperationType::PublishWorld.as_str(), + "publish_world" + ); + assert_eq!(RpgAgentStage::ReadyToPublish.as_str(), "ready_to_publish"); + assert_eq!(RpgAgentMessageRole::Assistant.as_str(), "assistant"); + assert_eq!(RpgAgentMessageKind::ActionResult.as_str(), "action_result"); + assert_eq!( + RpgAgentDraftCardKind::SceneChapter.as_str(), + "scene_chapter" + ); + assert_eq!( + CustomWorldRoleAssetStatus::VisualReady.as_str(), + "visual_ready" + ); + assert_eq!(CustomWorldThemeMode::Rift.as_str(), "rift"); + } + + #[test] + fn agent_session_create_input_validates_required_json_shapes() { + let input = CustomWorldAgentSessionCreateInput { + session_id: "custom-world-agent-session-001".to_string(), + owner_user_id: "user_001".to_string(), + seed_text: "".to_string(), + welcome_message_id: "message-001".to_string(), + welcome_message_text: "你好!我是你的世界设定助手。".to_string(), + anchor_content_json: empty_agent_anchor_content_json(), + creator_intent_json: Some(empty_json_object()), + creator_intent_readiness_json: empty_agent_creator_intent_readiness_json(), + anchor_pack_json: Some(empty_json_object()), + lock_state_json: Some(empty_json_object()), + draft_profile_json: Some(empty_json_object()), + pending_clarifications_json: empty_json_array(), + suggested_actions_json: empty_json_array(), + recommended_replies_json: empty_json_array(), + quality_findings_json: empty_json_array(), + asset_coverage_json: empty_agent_asset_coverage_json(), + checkpoints_json: empty_json_array(), + created_at_micros: 1, + }; + + validate_custom_world_agent_session_create_input(&input) + .expect("valid skeleton input should pass"); + } + + #[test] + fn profile_upsert_input_requires_author_display_name() { + let error = validate_custom_world_profile_upsert_input(&CustomWorldProfileUpsertInput { + profile_id: "cwprof_001".to_string(), + owner_user_id: "user_001".to_string(), + source_agent_session_id: None, + world_name: "裂潮边城".to_string(), + subtitle: "港口余烬".to_string(), + summary_text: "一座被裂潮与旧械共同撕扯的沿海城邦。".to_string(), + theme_mode: CustomWorldThemeMode::Tide, + cover_image_src: None, + profile_payload_json: "{\"id\":\"cwprof_001\"}".to_string(), + playable_npc_count: 3, + landmark_count: 2, + author_display_name: " ".to_string(), + updated_at_micros: 1, + }) + .expect_err("blank author display name should fail"); + + assert_eq!(error, CustomWorldFieldError::MissingAuthorDisplayName); + } + + #[test] + fn profile_list_input_requires_owner_user_id() { + let error = validate_custom_world_profile_list_input(&CustomWorldProfileListInput { + owner_user_id: " ".to_string(), + }) + .expect_err("blank owner user id should fail"); + + assert_eq!(error, CustomWorldFieldError::MissingOwnerUserId); + } + + #[test] + fn published_profile_compile_merges_legacy_theme_and_latest_assets() { + let snapshot = build_custom_world_published_profile_compile_snapshot( + CustomWorldPublishedProfileCompileInput { + session_id: "session_001".to_string(), + profile_id: "agent-draft-session_001".to_string(), + owner_user_id: "user_001".to_string(), + draft_profile_json: r#"{ + "name":"潮雾列岛", + "subtitle":"旧灯塔与失控航路", + "summary":"第一版世界底稿已经整理完成。", + "tone":"压抑、潮湿、悬疑", + "playerGoal":"查清沉船与禁航区异动的真相。", + "playableNpcs":[{"id":"playable-1","name":"沈砺","imageSrc":"/generated/playable-1.png"}], + "storyNpcs":[{"id":"story-1","name":"顾潮音"}], + "landmarks":[{"id":"landmark-1","name":"回潮旧灯塔","imageSrc":"/generated/landmark-1.png"}], + "camp":{"id":"camp-1","name":"回潮暂栖所","imageSrc":"/generated/camp.png"}, + "sceneChapters":[{"id":"scene-chapter-1","sceneId":"landmark-1","title":"灯塔初章"}] + }"#.to_string(), + legacy_result_profile_json: Some( + r#"{ + "id":"legacy_profile", + "themeMode":"tide", + "themePack":{"id":"theme-pack:tide"}, + "storyGraph":{"visibleThreads":[{"id":"thread-1"}]} + }"# + .to_string(), + ), + setting_text: "被海雾吞没的旧航路群岛".to_string(), + author_display_name: "测试玩家".to_string(), + updated_at_micros: 42, + }, + ) + .expect("compile should succeed"); + + assert_eq!(snapshot.world_name, "潮雾列岛"); + assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Tide); + assert_eq!( + snapshot.cover_image_src.as_deref(), + Some("/generated/camp.png") + ); + assert_eq!(snapshot.playable_npc_count, 2); + assert_eq!(snapshot.landmark_count, 1); + assert!( + snapshot + .compiled_profile_payload_json + .contains("\"sceneChapterBlueprints\"") + ); + assert!( + snapshot + .compiled_profile_payload_json + .contains("\"themePack\"") + ); + } + + #[test] + fn published_profile_compile_defaults_theme_to_mythic_without_legacy_theme() { + let snapshot = build_custom_world_published_profile_compile_snapshot( + CustomWorldPublishedProfileCompileInput { + session_id: "session_002".to_string(), + profile_id: "profile_002".to_string(), + owner_user_id: "user_002".to_string(), + draft_profile_json: r#"{ + "name":"裂帆荒湾", + "subtitle":"雾岸残潮", + "summary":"港湾里还剩最后一条能退走的潮沟。", + "playableNpcs":[], + "storyNpcs":[], + "landmarks":[{"id":"landmark-1","name":"裂帆湾","imageSrc":"/generated/landmark-cover.png"}] + }"# + .to_string(), + legacy_result_profile_json: None, + setting_text: "被潮沟切开的荒湾".to_string(), + author_display_name: "玩家二号".to_string(), + updated_at_micros: 84, + }, + ) + .expect("compile should succeed"); + + assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Mythic); + assert_eq!( + snapshot.cover_image_src.as_deref(), + Some("/generated/landmark-cover.png") + ); + } +} diff --git a/server-rs/crates/module-inventory/Cargo.toml b/server-rs/crates/module-inventory/Cargo.toml new file mode 100644 index 00000000..b12531ba --- /dev/null +++ b/server-rs/crates/module-inventory/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-inventory" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-inventory/README.md b/server-rs/crates/module-inventory/README.md index 32c22f0a..23a8763e 100644 --- a/server-rs/crates/module-inventory/README.md +++ b/server-rs/crates/module-inventory/README.md @@ -1,29 +1,45 @@ -# module-inventory 独立模块 package 占位说明 +# module-inventory 独立模块 package 说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. package 职责 -`module-inventory` 是背包与物品变更模块 package,后续负责: +`module-inventory` 是背包与物品变更模块 package,当前负责: -1. `inventory_slot` 等背包状态模型 -2. 物品获得、消耗、赠礼、背包变更规则 -3. 与 story action、runtime item、NPC 交互的背包联动 -4. 与 `apps/spacetime-module` 的背包表、reducer、view 聚合对接 +1. 冻结 `inventory_slot` 的首版领域字段与枚举类型。 +2. 提供 `apply_inventory_mutation` 纯规则入口,先覆盖: + - `GrantItem` + - `ConsumeItem` + - `EquipItem` + - `UnequipItem` +3. 冻结 `RuntimeInventoryStateQueryInput / RuntimeInventoryStateSnapshot / RuntimeInventoryStateRecord` +4. 为 `spacetime-module` 的背包表、procedure 与 reducer 聚合提供可复用契约。 ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入具体背包规则与读模型实现。 +当前提交已经从“目录占位”推进到“真实 crate 基座”,但仍然只落最小背包主链,不提前扩成完整玩法迁移。 -后续与本 package 直接相关的任务包括: +本轮已落地: -1. 设计 `inventory_slot` -2. 设计 `apply_inventory_mutation` -3. 对齐背包 patch、奖励结果与兼容响应结构 -4. 接入 story action 主循环的背包联动 +1. `InventorySlotSnapshot` / `InventoryMutationInput` / `InventoryMutationOutcome` +2. 背包堆叠、扣减、装备切换、卸下回包的纯规则 +3. `runtime_session_id + actor_user_id` 作用域下的最小 inventory state query contract +4. 与 `spacetime-module` 对接所需的 `SpacetimeType` 兼容类型 -## 3. 边界约束 +本轮明确未做: -1. `module-inventory` 负责物品状态真相与背包规则,不把外部 AI、OSS 或 HTTP 协议塞进模块内部。 +1. `UseItem / Craft / Dismantle / Reforge` +2. `npc_trade / npc_gift / quest_turn_in` 的专属 reducer +3. 前端背包 view 或 Axum façade +4. 旧 `GameState.playerInventory / playerEquipment` 全量兼容 + +## 3. 当前冻结边界 + +1. `module-inventory` 只负责物品状态真相与背包规则,不把外部 AI、OSS 或 HTTP 协议塞进模块内部。 2. 与 `module-story`、`module-runtime-item`、`module-npc` 的协作通过明确 reducer 或投影边界完成。 -3. 前端兼容输出由 `apps/api-server` 暴露,背包状态真相由 `apps/spacetime-module` 聚合。 +3. 背包真相由 `spacetime-module` 聚合,对外兼容输出后续由 `api-server` 或 view 补齐。 + +## 4. 关联文档 + +1. `docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md` +2. `backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md` diff --git a/server-rs/crates/module-inventory/src/lib.rs b/server-rs/crates/module-inventory/src/lib.rs new file mode 100644 index 00000000..69510fca --- /dev/null +++ b/server-rs/crates/module-inventory/src/lib.rs @@ -0,0 +1,1063 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + build_prefixed_seed_id, format_timestamp_micros, + normalize_optional_string as normalize_shared_optional_string, normalize_required_string, + normalize_string_list as normalize_shared_string_list, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const INVENTORY_SLOT_ID_PREFIX: &str = "invslot_"; +pub const INVENTORY_MUTATION_ID_PREFIX: &str = "invmut_"; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum InventoryContainerKind { + Backpack, + Equipment, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum InventoryItemRarity { + Common, + Uncommon, + Rare, + Epic, + Legendary, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum InventoryEquipmentSlot { + Weapon, + Armor, + Relic, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum InventoryItemSourceKind { + StoryReward, + QuestReward, + TreasureReward, + NpcGift, + NpcTrade, + CombatDrop, + ForgeCraft, + ForgeReforge, + ManualPatch, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct InventoryItemSnapshot { + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: InventoryItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: InventoryItemSourceKind, + pub source_reference_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct InventorySlotSnapshot { + pub slot_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub container_kind: InventoryContainerKind, + pub slot_key: String, + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: InventoryItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: InventoryItemSourceKind, + pub source_reference_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GrantInventoryItemInput { + pub slot_id: String, + pub item: InventoryItemSnapshot, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConsumeInventoryItemInput { + pub slot_id: String, + pub quantity: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EquipInventoryItemInput { + pub slot_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnequipInventoryItemInput { + pub slot_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum InventoryMutation { + GrantItem(GrantInventoryItemInput), + ConsumeItem(ConsumeInventoryItemInput), + EquipItem(EquipInventoryItemInput), + UnequipItem(UnequipInventoryItemInput), +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct InventoryMutationInput { + pub mutation_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub mutation: InventoryMutation, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeInventoryStateQueryInput { + pub runtime_session_id: String, + pub actor_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeInventoryStateSnapshot { + pub runtime_session_id: String, + pub actor_user_id: String, + pub backpack_items: Vec, + pub equipment_items: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeInventoryStateProcedureResult { + pub ok: bool, + pub snapshot: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeInventorySlotRecord { + pub slot_id: String, + pub container_kind: String, + pub slot_key: String, + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: String, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: String, + pub source_reference_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeInventoryStateRecord { + pub runtime_session_id: String, + pub actor_user_id: String, + pub backpack_items: Vec, + pub equipment_items: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InventoryMutationOutcome { + pub next_slots: Vec, + pub changed: bool, + pub updated_slot_ids: Vec, + pub removed_slot_ids: Vec, + pub affected_equipment_slot: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InventoryMutationFieldError { + MissingMutationId, + MissingRuntimeSessionId, + MissingActorUserId, + MissingSlotId, + MissingItemId, + MissingCategory, + MissingName, + InvalidQuantity, + MissingStackKey, + NonStackableItemMustStaySingleQuantity, + EquipmentItemCannotStack, + SlotScopeMismatch, + ItemNotFound, + ItemNotInBackpack, + ItemNotEquipped, + InsufficientQuantity, + ItemNotEquippable, +} + +impl InventoryEquipmentSlot { + pub fn as_str(self) -> &'static str { + match self { + Self::Weapon => "weapon", + Self::Armor => "armor", + Self::Relic => "relic", + } + } +} + +pub fn generate_inventory_slot_id(seed_micros: i64) -> String { + build_prefixed_seed_id(INVENTORY_SLOT_ID_PREFIX, seed_micros) +} + +pub fn generate_inventory_mutation_id(seed_micros: i64) -> String { + build_prefixed_seed_id(INVENTORY_MUTATION_ID_PREFIX, seed_micros) +} + +pub fn normalize_optional_text(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} + +pub fn build_runtime_inventory_state_query_input( + runtime_session_id: String, + actor_user_id: String, +) -> Result { + let input = RuntimeInventoryStateQueryInput { + runtime_session_id: normalize_required_text( + runtime_session_id, + InventoryMutationFieldError::MissingRuntimeSessionId, + )?, + actor_user_id: normalize_required_text( + actor_user_id, + InventoryMutationFieldError::MissingActorUserId, + )?, + }; + + Ok(input) +} + +pub fn build_runtime_inventory_state_snapshot( + input: RuntimeInventoryStateQueryInput, + slots: Vec, +) -> RuntimeInventoryStateSnapshot { + let mut backpack_items = Vec::new(); + let mut equipment_items = Vec::new(); + + for slot in slots { + match slot.container_kind { + InventoryContainerKind::Backpack => backpack_items.push(slot), + InventoryContainerKind::Equipment => equipment_items.push(slot), + } + } + + backpack_items.sort_by(|left, right| { + left.slot_key + .cmp(&right.slot_key) + .then(left.slot_id.cmp(&right.slot_id)) + }); + equipment_items.sort_by(|left, right| { + equipment_slot_order(left.equipment_slot_id) + .cmp(&equipment_slot_order(right.equipment_slot_id)) + .then(left.slot_id.cmp(&right.slot_id)) + }); + + RuntimeInventoryStateSnapshot { + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + backpack_items, + equipment_items, + } +} + +pub fn apply_inventory_mutation( + current_slots: Vec, + input: InventoryMutationInput, +) -> Result { + let _mutation_id = normalize_required_text( + input.mutation_id, + InventoryMutationFieldError::MissingMutationId, + )?; + let runtime_session_id = normalize_required_text( + input.runtime_session_id, + InventoryMutationFieldError::MissingRuntimeSessionId, + )?; + let actor_user_id = normalize_required_text( + input.actor_user_id, + InventoryMutationFieldError::MissingActorUserId, + )?; + let story_session_id = normalize_optional_text(input.story_session_id); + + let mut slots = current_slots; + for slot in &slots { + if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id { + return Err(InventoryMutationFieldError::SlotScopeMismatch); + } + } + + let outcome = match input.mutation { + InventoryMutation::GrantItem(grant) => apply_grant_item( + &mut slots, + runtime_session_id, + story_session_id, + actor_user_id, + grant, + input.updated_at_micros, + )?, + InventoryMutation::ConsumeItem(consume) => { + apply_consume_item(&mut slots, consume, input.updated_at_micros)? + } + InventoryMutation::EquipItem(equip) => { + apply_equip_item(&mut slots, equip, input.updated_at_micros)? + } + InventoryMutation::UnequipItem(unequip) => { + apply_unequip_item(&mut slots, unequip, input.updated_at_micros)? + } + }; + + Ok(InventoryMutationOutcome { + next_slots: sort_inventory_slots(slots), + changed: outcome.changed, + updated_slot_ids: sort_string_list(outcome.updated_slot_ids), + removed_slot_ids: sort_string_list(outcome.removed_slot_ids), + affected_equipment_slot: outcome.affected_equipment_slot, + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct InventoryMutationInternalOutcome { + changed: bool, + updated_slot_ids: Vec, + removed_slot_ids: Vec, + affected_equipment_slot: Option, +} + +fn apply_grant_item( + slots: &mut Vec, + runtime_session_id: String, + story_session_id: Option, + actor_user_id: String, + grant: GrantInventoryItemInput, + updated_at_micros: i64, +) -> Result { + let slot_id = + normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?; + let item = normalize_inventory_item_snapshot(grant.item)?; + + if item.stackable { + if let Some(existing) = slots.iter_mut().find(|slot| { + slot.container_kind == InventoryContainerKind::Backpack + && slot.stackable + && slot.item_id == item.item_id + && slot.stack_key == item.stack_key + }) { + existing.category = item.category; + existing.name = item.name; + existing.description = item.description; + existing.quantity += item.quantity; + existing.rarity = item.rarity; + existing.tags = item.tags; + existing.stackable = item.stackable; + existing.stack_key = item.stack_key; + existing.equipment_slot_id = item.equipment_slot_id; + existing.source_kind = item.source_kind; + existing.source_reference_id = item.source_reference_id; + existing.updated_at_micros = updated_at_micros; + + return Ok(InventoryMutationInternalOutcome { + changed: true, + updated_slot_ids: vec![existing.slot_id.clone()], + removed_slot_ids: vec![], + affected_equipment_slot: None, + }); + } + } + + slots.push(InventorySlotSnapshot { + slot_id: slot_id.clone(), + runtime_session_id, + story_session_id, + actor_user_id, + container_kind: InventoryContainerKind::Backpack, + slot_key: build_backpack_slot_key(&slot_id), + item_id: item.item_id, + category: item.category, + name: item.name, + description: item.description, + quantity: item.quantity, + rarity: item.rarity, + tags: item.tags, + stackable: item.stackable, + stack_key: item.stack_key, + equipment_slot_id: item.equipment_slot_id, + source_kind: item.source_kind, + source_reference_id: item.source_reference_id, + created_at_micros: updated_at_micros, + updated_at_micros, + }); + + Ok(InventoryMutationInternalOutcome { + changed: true, + updated_slot_ids: vec![slot_id], + removed_slot_ids: vec![], + affected_equipment_slot: None, + }) +} + +fn apply_consume_item( + slots: &mut Vec, + consume: ConsumeInventoryItemInput, + updated_at_micros: i64, +) -> Result { + let slot_id = + normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?; + if consume.quantity == 0 { + return Err(InventoryMutationFieldError::InvalidQuantity); + } + + let slot_index = slots + .iter() + .position(|slot| slot.slot_id == slot_id) + .ok_or(InventoryMutationFieldError::ItemNotFound)?; + + if slots[slot_index].container_kind != InventoryContainerKind::Backpack { + return Err(InventoryMutationFieldError::ItemNotInBackpack); + } + + if slots[slot_index].quantity < consume.quantity { + return Err(InventoryMutationFieldError::InsufficientQuantity); + } + + if slots[slot_index].quantity == consume.quantity { + slots.remove(slot_index); + return Ok(InventoryMutationInternalOutcome { + changed: true, + updated_slot_ids: vec![], + removed_slot_ids: vec![slot_id], + affected_equipment_slot: None, + }); + } + + slots[slot_index].quantity -= consume.quantity; + slots[slot_index].updated_at_micros = updated_at_micros; + + Ok(InventoryMutationInternalOutcome { + changed: true, + updated_slot_ids: vec![slots[slot_index].slot_id.clone()], + removed_slot_ids: vec![], + affected_equipment_slot: None, + }) +} + +fn apply_equip_item( + slots: &mut [InventorySlotSnapshot], + equip: EquipInventoryItemInput, + updated_at_micros: i64, +) -> Result { + let slot_id = + normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?; + let source_index = slots + .iter() + .position(|slot| slot.slot_id == slot_id) + .ok_or(InventoryMutationFieldError::ItemNotFound)?; + let target_slot = slots[source_index] + .equipment_slot_id + .ok_or(InventoryMutationFieldError::ItemNotEquippable)?; + + if slots[source_index].stackable { + return Err(InventoryMutationFieldError::EquipmentItemCannotStack); + } + if slots[source_index].quantity != 1 { + return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity); + } + if slots[source_index].container_kind != InventoryContainerKind::Backpack { + if slots[source_index].container_kind == InventoryContainerKind::Equipment { + return Ok(InventoryMutationInternalOutcome { + changed: false, + updated_slot_ids: vec![], + removed_slot_ids: vec![], + affected_equipment_slot: Some(target_slot), + }); + } + + return Err(InventoryMutationFieldError::ItemNotInBackpack); + } + + let occupied_index = slots.iter().position(|slot| { + slot.container_kind == InventoryContainerKind::Equipment + && slot.slot_key == build_equipment_slot_key(target_slot) + }); + + let mut updated_slot_ids = vec![slot_id.clone()]; + if let Some(occupied_index) = occupied_index { + // 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。 + slots[occupied_index].container_kind = InventoryContainerKind::Backpack; + slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id); + slots[occupied_index].updated_at_micros = updated_at_micros; + updated_slot_ids.push(slots[occupied_index].slot_id.clone()); + } + + slots[source_index].container_kind = InventoryContainerKind::Equipment; + slots[source_index].slot_key = build_equipment_slot_key(target_slot); + slots[source_index].updated_at_micros = updated_at_micros; + + Ok(InventoryMutationInternalOutcome { + changed: true, + updated_slot_ids, + removed_slot_ids: vec![], + affected_equipment_slot: Some(target_slot), + }) +} + +fn apply_unequip_item( + slots: &mut [InventorySlotSnapshot], + unequip: UnequipInventoryItemInput, + updated_at_micros: i64, +) -> Result { + let slot_id = + normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?; + let slot_index = slots + .iter() + .position(|slot| slot.slot_id == slot_id) + .ok_or(InventoryMutationFieldError::ItemNotFound)?; + + if slots[slot_index].container_kind != InventoryContainerKind::Equipment { + return Err(InventoryMutationFieldError::ItemNotEquipped); + } + + let affected_equipment_slot = slots[slot_index].equipment_slot_id; + slots[slot_index].container_kind = InventoryContainerKind::Backpack; + slots[slot_index].slot_key = build_backpack_slot_key(&slot_id); + slots[slot_index].updated_at_micros = updated_at_micros; + + Ok(InventoryMutationInternalOutcome { + changed: true, + updated_slot_ids: vec![slot_id], + removed_slot_ids: vec![], + affected_equipment_slot, + }) +} + +fn normalize_inventory_item_snapshot( + item: InventoryItemSnapshot, +) -> Result { + let item_id = + normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?; + let category = + normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?; + let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?; + if item.quantity == 0 { + return Err(InventoryMutationFieldError::InvalidQuantity); + } + + if !item.stackable && item.quantity != 1 { + return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity); + } + + if item.equipment_slot_id.is_some() && item.stackable { + return Err(InventoryMutationFieldError::EquipmentItemCannotStack); + } + + let stack_key = if item.stackable { + normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)? + } else { + normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone()) + }; + + Ok(InventoryItemSnapshot { + item_id, + category, + name, + description: normalize_optional_text(item.description), + quantity: item.quantity, + rarity: item.rarity, + tags: normalize_string_list(item.tags), + stackable: item.stackable, + stack_key, + equipment_slot_id: item.equipment_slot_id, + source_kind: item.source_kind, + source_reference_id: normalize_optional_text(item.source_reference_id), + }) +} + +fn normalize_required_text( + value: String, + error: InventoryMutationFieldError, +) -> Result { + normalize_required_string(value).ok_or(error) +} + +fn sort_inventory_slots(mut slots: Vec) -> Vec { + slots.sort_by(|left, right| { + container_order(left.container_kind) + .cmp(&container_order(right.container_kind)) + .then(left.slot_key.cmp(&right.slot_key)) + .then(left.slot_id.cmp(&right.slot_id)) + }); + slots +} + +fn sort_string_list(mut values: Vec) -> Vec { + values.sort(); + values +} + +fn container_order(kind: InventoryContainerKind) -> u8 { + match kind { + InventoryContainerKind::Equipment => 0, + InventoryContainerKind::Backpack => 1, + } +} + +fn equipment_slot_order(slot: Option) -> u8 { + match slot { + Some(InventoryEquipmentSlot::Weapon) => 0, + Some(InventoryEquipmentSlot::Armor) => 1, + Some(InventoryEquipmentSlot::Relic) => 2, + None => 3, + } +} + +fn build_backpack_slot_key(slot_id: &str) -> String { + slot_id.to_string() +} + +fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String { + slot.as_str().to_string() +} + +pub fn build_runtime_inventory_state_record( + snapshot: RuntimeInventoryStateSnapshot, +) -> RuntimeInventoryStateRecord { + RuntimeInventoryStateRecord { + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + backpack_items: snapshot + .backpack_items + .into_iter() + .map(build_runtime_inventory_slot_record) + .collect(), + equipment_items: snapshot + .equipment_items + .into_iter() + .map(build_runtime_inventory_slot_record) + .collect(), + } +} + +fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord { + RuntimeInventorySlotRecord { + slot_id: slot.slot_id, + container_kind: format_inventory_container_kind(slot.container_kind).to_string(), + slot_key: slot.slot_key, + item_id: slot.item_id, + category: slot.category, + name: slot.name, + description: slot.description, + quantity: slot.quantity, + rarity: format_inventory_item_rarity(slot.rarity).to_string(), + tags: slot.tags, + stackable: slot.stackable, + stack_key: slot.stack_key, + equipment_slot_id: slot + .equipment_slot_id + .map(|value| value.as_str().to_string()), + source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(), + source_reference_id: slot.source_reference_id, + created_at: format_timestamp_micros(slot.created_at_micros), + updated_at: format_timestamp_micros(slot.updated_at_micros), + } +} + +fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str { + match value { + InventoryContainerKind::Backpack => "backpack", + InventoryContainerKind::Equipment => "equipment", + } +} + +fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str { + match value { + InventoryItemRarity::Common => "common", + InventoryItemRarity::Uncommon => "uncommon", + InventoryItemRarity::Rare => "rare", + InventoryItemRarity::Epic => "epic", + InventoryItemRarity::Legendary => "legendary", + } +} + +fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str { + match value { + InventoryItemSourceKind::StoryReward => "story_reward", + InventoryItemSourceKind::QuestReward => "quest_reward", + InventoryItemSourceKind::TreasureReward => "treasure_reward", + InventoryItemSourceKind::NpcGift => "npc_gift", + InventoryItemSourceKind::NpcTrade => "npc_trade", + InventoryItemSourceKind::CombatDrop => "combat_drop", + InventoryItemSourceKind::ForgeCraft => "forge_craft", + InventoryItemSourceKind::ForgeReforge => "forge_reforge", + InventoryItemSourceKind::ManualPatch => "manual_patch", + } +} + +impl fmt::Display for InventoryMutationFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingMutationId => f.write_str("inventory_mutation.mutation_id 不能为空"), + Self::MissingRuntimeSessionId => { + f.write_str("inventory_mutation.runtime_session_id 不能为空") + } + Self::MissingActorUserId => f.write_str("inventory_mutation.actor_user_id 不能为空"), + Self::MissingSlotId => f.write_str("inventory_slot.slot_id 不能为空"), + Self::MissingItemId => f.write_str("inventory_item.item_id 不能为空"), + Self::MissingCategory => f.write_str("inventory_item.category 不能为空"), + Self::MissingName => f.write_str("inventory_item.name 不能为空"), + Self::InvalidQuantity => f.write_str("inventory_item.quantity 必须大于 0"), + Self::MissingStackKey => f.write_str("可堆叠物品必须提供 stack_key"), + Self::NonStackableItemMustStaySingleQuantity => { + f.write_str("不可堆叠物品必须固定为单槽位单数量") + } + Self::EquipmentItemCannotStack => f.write_str("可装备物品不能标记为 stackable"), + Self::SlotScopeMismatch => { + f.write_str("当前 inventory_slot 不属于本次 mutation 作用域") + } + Self::ItemNotFound => f.write_str("目标 inventory_slot 不存在"), + Self::ItemNotInBackpack => f.write_str("目标物品当前不在背包中"), + Self::ItemNotEquipped => f.write_str("目标物品当前不在装备位上"), + Self::InsufficientQuantity => f.write_str("当前背包数量不足,无法完成扣减"), + Self::ItemNotEquippable => f.write_str("目标物品当前不可装备"), + } + } +} + +impl Error for InventoryMutationFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_stackable_item(quantity: u32) -> InventoryItemSnapshot { + InventoryItemSnapshot { + item_id: "consumable_heal_potion".to_string(), + category: "消耗品".to_string(), + name: "疗伤药".to_string(), + description: Some("用于恢复少量气血。".to_string()), + quantity, + rarity: InventoryItemRarity::Common, + tags: vec!["healing".to_string()], + stackable: true, + stack_key: "heal_potion".to_string(), + equipment_slot_id: None, + source_kind: InventoryItemSourceKind::TreasureReward, + source_reference_id: Some("treasure_001".to_string()), + } + } + + fn build_weapon_item(slot_id: &str, name: &str) -> InventorySlotSnapshot { + InventorySlotSnapshot { + slot_id: slot_id.to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: Some("storysess_001".to_string()), + actor_user_id: "user_001".to_string(), + container_kind: InventoryContainerKind::Backpack, + slot_key: slot_id.to_string(), + item_id: format!("weapon:{slot_id}"), + category: "武器".to_string(), + name: name.to_string(), + description: Some("测试武器".to_string()), + quantity: 1, + rarity: InventoryItemRarity::Rare, + tags: vec!["weapon".to_string(), "快剑".to_string()], + stackable: false, + stack_key: format!("weapon:{slot_id}"), + equipment_slot_id: Some(InventoryEquipmentSlot::Weapon), + source_kind: InventoryItemSourceKind::StoryReward, + source_reference_id: Some("storyevt_001".to_string()), + created_at_micros: 1, + updated_at_micros: 1, + } + } + + fn build_mutation_input(mutation: InventoryMutation) -> InventoryMutationInput { + InventoryMutationInput { + mutation_id: "invmut_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: Some("storysess_001".to_string()), + actor_user_id: "user_001".to_string(), + mutation, + updated_at_micros: 10, + } + } + + #[test] + fn grant_item_merges_existing_stackable_slot() { + let current = vec![InventorySlotSnapshot { + slot_id: "invslot_existing".to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: Some("storysess_001".to_string()), + actor_user_id: "user_001".to_string(), + container_kind: InventoryContainerKind::Backpack, + slot_key: "invslot_existing".to_string(), + item_id: "consumable_heal_potion".to_string(), + category: "消耗品".to_string(), + name: "疗伤药".to_string(), + description: None, + quantity: 2, + rarity: InventoryItemRarity::Common, + tags: vec!["healing".to_string()], + stackable: true, + stack_key: "heal_potion".to_string(), + equipment_slot_id: None, + source_kind: InventoryItemSourceKind::TreasureReward, + source_reference_id: Some("treasure_000".to_string()), + created_at_micros: 1, + updated_at_micros: 1, + }]; + + let outcome = apply_inventory_mutation( + current, + build_mutation_input(InventoryMutation::GrantItem(GrantInventoryItemInput { + slot_id: "invslot_new".to_string(), + item: build_stackable_item(3), + })), + ) + .expect("grant should merge stackable row"); + + assert!(outcome.changed); + assert_eq!(outcome.next_slots.len(), 1); + assert_eq!(outcome.next_slots[0].quantity, 5); + assert_eq!( + outcome.updated_slot_ids, + vec!["invslot_existing".to_string()] + ); + } + + #[test] + fn grant_non_stackable_item_requires_single_quantity() { + let error = apply_inventory_mutation( + vec![], + build_mutation_input(InventoryMutation::GrantItem(GrantInventoryItemInput { + slot_id: "invslot_weapon".to_string(), + item: InventoryItemSnapshot { + item_id: "weapon_001".to_string(), + category: "武器".to_string(), + name: "试作短剑".to_string(), + description: None, + quantity: 2, + rarity: InventoryItemRarity::Rare, + tags: vec!["weapon".to_string()], + stackable: false, + stack_key: String::new(), + equipment_slot_id: Some(InventoryEquipmentSlot::Weapon), + source_kind: InventoryItemSourceKind::StoryReward, + source_reference_id: None, + }, + })), + ) + .expect_err("non-stackable item quantity must stay single"); + + assert_eq!( + error, + InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity + ); + } + + #[test] + fn consume_item_removes_slot_when_quantity_exhausted() { + let current = vec![InventorySlotSnapshot { + slot_id: "invslot_potion".to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: Some("storysess_001".to_string()), + actor_user_id: "user_001".to_string(), + container_kind: InventoryContainerKind::Backpack, + slot_key: "invslot_potion".to_string(), + item_id: "consumable_heal_potion".to_string(), + category: "消耗品".to_string(), + name: "疗伤药".to_string(), + description: None, + quantity: 1, + rarity: InventoryItemRarity::Common, + tags: vec!["healing".to_string()], + stackable: true, + stack_key: "heal_potion".to_string(), + equipment_slot_id: None, + source_kind: InventoryItemSourceKind::TreasureReward, + source_reference_id: None, + created_at_micros: 1, + updated_at_micros: 1, + }]; + + let outcome = apply_inventory_mutation( + current, + build_mutation_input(InventoryMutation::ConsumeItem(ConsumeInventoryItemInput { + slot_id: "invslot_potion".to_string(), + quantity: 1, + })), + ) + .expect("consume should remove exhausted slot"); + + assert!(outcome.next_slots.is_empty()); + assert_eq!(outcome.removed_slot_ids, vec!["invslot_potion".to_string()]); + } + + #[test] + fn equip_item_swaps_existing_equipment_back_to_backpack() { + let equipped = InventorySlotSnapshot { + container_kind: InventoryContainerKind::Equipment, + slot_key: InventoryEquipmentSlot::Weapon.as_str().to_string(), + ..build_weapon_item("invslot_old_weapon", "旧佩剑") + }; + let backpack_weapon = build_weapon_item("invslot_new_weapon", "逐风短剑"); + + let outcome = apply_inventory_mutation( + vec![equipped, backpack_weapon], + build_mutation_input(InventoryMutation::EquipItem(EquipInventoryItemInput { + slot_id: "invslot_new_weapon".to_string(), + })), + ) + .expect("equip should swap weapon"); + + assert!(outcome.changed); + assert_eq!( + outcome.affected_equipment_slot, + Some(InventoryEquipmentSlot::Weapon) + ); + + let weapon_slot = outcome + .next_slots + .iter() + .find(|slot| slot.slot_id == "invslot_new_weapon") + .expect("new weapon slot should exist"); + assert_eq!( + weapon_slot.container_kind, + InventoryContainerKind::Equipment + ); + assert_eq!(weapon_slot.slot_key, "weapon"); + + let old_weapon_slot = outcome + .next_slots + .iter() + .find(|slot| slot.slot_id == "invslot_old_weapon") + .expect("old weapon slot should exist"); + assert_eq!( + old_weapon_slot.container_kind, + InventoryContainerKind::Backpack + ); + assert_eq!(old_weapon_slot.slot_key, "invslot_old_weapon"); + } + + #[test] + fn unequip_item_moves_equipment_back_to_backpack() { + let equipped = InventorySlotSnapshot { + container_kind: InventoryContainerKind::Equipment, + slot_key: InventoryEquipmentSlot::Relic.as_str().to_string(), + equipment_slot_id: Some(InventoryEquipmentSlot::Relic), + ..build_weapon_item("invslot_relic", "旧誓护符") + }; + + let outcome = apply_inventory_mutation( + vec![equipped], + build_mutation_input(InventoryMutation::UnequipItem(UnequipInventoryItemInput { + slot_id: "invslot_relic".to_string(), + })), + ) + .expect("unequip should move relic back to backpack"); + + assert!(outcome.changed); + assert_eq!( + outcome.affected_equipment_slot, + Some(InventoryEquipmentSlot::Relic) + ); + assert_eq!(outcome.next_slots.len(), 1); + assert_eq!( + outcome.next_slots[0].container_kind, + InventoryContainerKind::Backpack + ); + assert_eq!(outcome.next_slots[0].slot_key, "invslot_relic"); + } + + #[test] + fn build_runtime_inventory_state_query_input_trims_scope_fields() { + let input = build_runtime_inventory_state_query_input( + " runtime_001 ".to_string(), + " user_001 ".to_string(), + ) + .expect("query input should build"); + + assert_eq!(input.runtime_session_id, "runtime_001"); + assert_eq!(input.actor_user_id, "user_001"); + } + + #[test] + fn build_runtime_inventory_state_snapshot_splits_backpack_and_equipment() { + let snapshot = build_runtime_inventory_state_snapshot( + RuntimeInventoryStateQueryInput { + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + }, + vec![ + InventorySlotSnapshot { + container_kind: InventoryContainerKind::Equipment, + slot_key: "weapon".to_string(), + ..build_weapon_item("invslot_weapon", "逐风短剑") + }, + InventorySlotSnapshot { + slot_id: "invslot_potion".to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: Some("storysess_001".to_string()), + actor_user_id: "user_001".to_string(), + container_kind: InventoryContainerKind::Backpack, + slot_key: "invslot_potion".to_string(), + item_id: "consumable_heal_potion".to_string(), + category: "消耗品".to_string(), + name: "疗伤药".to_string(), + description: Some("用于恢复少量气血。".to_string()), + quantity: 2, + rarity: InventoryItemRarity::Common, + tags: vec!["healing".to_string()], + stackable: true, + stack_key: "heal_potion".to_string(), + equipment_slot_id: None, + source_kind: InventoryItemSourceKind::TreasureReward, + source_reference_id: Some("treasure_001".to_string()), + created_at_micros: 1, + updated_at_micros: 2, + }, + ], + ); + + assert_eq!(snapshot.backpack_items.len(), 1); + assert_eq!(snapshot.equipment_items.len(), 1); + assert_eq!(snapshot.backpack_items[0].slot_id, "invslot_potion"); + assert_eq!(snapshot.equipment_items[0].slot_id, "invslot_weapon"); + } +} diff --git a/server-rs/crates/module-npc/Cargo.toml b/server-rs/crates/module-npc/Cargo.toml new file mode 100644 index 00000000..957bb219 --- /dev/null +++ b/server-rs/crates/module-npc/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-npc" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-npc/README.md b/server-rs/crates/module-npc/README.md index 0673c81a..ae0b3d4c 100644 --- a/server-rs/crates/module-npc/README.md +++ b/server-rs/crates/module-npc/README.md @@ -1,30 +1,31 @@ -# module-npc 独立模块 package 占位说明 +# module-npc 独立模块 package 说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. package 职责 -`module-npc` 是 NPC 状态与互动模块 package,后续负责: +`module-npc` 是 NPC 状态与互动模块 package,当前首轮已经开始承接: -1. `npc_state` 等 NPC 关系与状态模型 -2. 招募、关系变化、互动规则与场景语义状态 -3. 与 story action、runtime、custom world 的 NPC 联动 -4. 与 `apps/api-server` 的 NPC 相关 facade 与流式交互对接 -5. 与 `apps/spacetime-module` 的 NPC 表、reducer、view 聚合对接 +1. `relation_state` +2. `stance_profile` +3. `npc_state` +4. `resolve_npc_social_action` +5. 与 `spacetime-module` 的 NPC 表、reducer、procedure 聚合对接 ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入对话生成、状态投影与交互规则实现。 +当前提交不再只是目录占位,已经进入首版领域 contract 落地阶段。 -后续与本 package 直接相关的任务包括: +当前已冻结的最小能力包括: -1. 设计 `npc_state` -2. 设计 `resolve_npc_interaction` -3. 对齐 NPC 关系变化、招募、对话相关兼容输出 -4. 接入 story 主循环与 custom world 的 NPC 联动 +1. `npc_state` 字段、校验与归一化 helper +2. `relation_state` 与 `stance_profile` 的派生规则 +3. `Chat / Help / Gift / Recruit / QuestAccept` 五类社交动作的最小状态迁移 +4. `SpacetimeDB` 真相表与同步 procedure 接线 ## 3. 边界约束 1. `module-npc` 负责 NPC 状态真相与互动规则,外部 LLM 台词生成与流式文本输出不直接塞进模块内部。 -2. 对话与招募文案生成优先通过对应模块应用层和平台适配完成,NPC 状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。 -3. 前端兼容接口与 SSE 由 `apps/api-server` 暴露,但 NPC 状态不能再次分散到会话缓存或前端临时状态中。 +2. 对话与招募文案生成优先通过应用层和平台适配完成,`module-npc` 当前只处理状态真相,不直接产出台词。 +3. 前端兼容接口与 SSE 由 `api-server` 暴露,但 NPC 状态不能再次分散到会话缓存或前端临时状态中。 +4. 背包、任务、战斗、副本等副作用暂不在本 crate 内部结算,继续通过其他模块协作完成。 diff --git a/server-rs/crates/module-npc/src/lib.rs b/server-rs/crates/module-npc/src/lib.rs new file mode 100644 index 00000000..4cd756c6 --- /dev/null +++ b/server-rs/crates/module-npc/src/lib.rs @@ -0,0 +1,923 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + normalize_optional_string as normalize_shared_optional_string, normalize_required_string, + normalize_string_list as normalize_shared_string_list, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const NPC_STATE_ID_PREFIX: &str = "npcstate_"; +pub const MAX_STANCE_NOTES: usize = 3; +pub const NPC_RECRUIT_AFFINITY_THRESHOLD: i32 = 60; +pub const NPC_PREVIEW_TALK_FUNCTION_ID: &str = "npc_preview_talk"; +pub const NPC_CHAT_FUNCTION_ID: &str = "npc_chat"; +pub const NPC_HELP_FUNCTION_ID: &str = "npc_help"; +pub const NPC_RECRUIT_FUNCTION_ID: &str = "npc_recruit"; +pub const NPC_FIGHT_FUNCTION_ID: &str = "npc_fight"; +pub const NPC_SPAR_FUNCTION_ID: &str = "npc_spar"; +pub const NPC_LEAVE_FUNCTION_ID: &str = "npc_leave"; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NpcRelationStance { + Hostile, + Guarded, + Neutral, + Cooperative, + Bonded, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NpcSocialActionKind { + Chat, + Help, + Gift, + Recruit, + QuestAccept, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NpcInteractionStatus { + Previewed, + Dialogue, + Resolved, + Recruited, + BattlePending, + Left, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NpcInteractionBattleMode { + Fight, + Spar, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpcRelationState { + pub affinity: i32, + pub stance: NpcRelationStance, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpcStanceProfile { + pub trust: u8, + pub warmth: u8, + pub ideological_fit: u8, + pub fear_or_guard: u8, + pub loyalty: u8, + pub current_conflict_tag: Option, + pub recent_approvals: Vec, + pub recent_disapprovals: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpcStateSnapshot { + pub npc_state_id: String, + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub relation_state: NpcRelationState, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub stance_profile: NpcStanceProfile, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpcStateUpsertInput { + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub stance_profile: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveNpcSocialActionInput { + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub action_kind: NpcSocialActionKind, + pub affinity_gain_override: Option, + pub note: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveNpcInteractionInput { + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub interaction_function_id: String, + pub release_npc_id: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpcStateProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpcInteractionResult { + pub npc_state: NpcStateSnapshot, + pub interaction_status: NpcInteractionStatus, + pub action_text: String, + pub result_text: String, + pub story_text: Option, + pub battle_mode: Option, + pub encounter_closed: bool, + pub affinity_changed: bool, + pub previous_affinity: i32, + pub next_affinity: i32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NpcInteractionProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NpcStateFieldError { + MissingRuntimeSessionId, + MissingNpcId, + MissingNpcName, + MissingInteractionFunctionId, + HelpAlreadyUsed, + RecruitAffinityTooLow, + UnsupportedInteractionFunctionId, +} + +pub fn generate_npc_state_id(runtime_session_id: &str, npc_id: &str) -> String { + format!( + "{}{}:{}", + NPC_STATE_ID_PREFIX, + runtime_session_id.trim(), + npc_id.trim() + ) +} + +pub fn build_relation_state(affinity: i32) -> NpcRelationState { + NpcRelationState { + affinity, + stance: if affinity < 0 { + NpcRelationStance::Hostile + } else if affinity < 15 { + NpcRelationStance::Guarded + } else if affinity < 30 { + NpcRelationStance::Neutral + } else if affinity < NPC_RECRUIT_AFFINITY_THRESHOLD { + NpcRelationStance::Cooperative + } else { + NpcRelationStance::Bonded + }, + } +} + +pub fn build_initial_stance_profile( + affinity: i32, + recruited: bool, + hostile: bool, + role_text: Option<&str>, +) -> NpcStanceProfile { + let recruited_bonus = if recruited { 14.0 } else { 0.0 }; + let hostile_penalty = if hostile { 18.0 } else { 0.0 }; + let current_conflict_tag = role_text.and_then(infer_conflict_tag); + + NpcStanceProfile { + trust: clamp_stance_metric( + 42.0 + affinity as f32 * 0.55 + recruited_bonus - hostile_penalty, + ), + warmth: clamp_stance_metric(36.0 + affinity as f32 * 0.5 + recruited_bonus), + ideological_fit: clamp_stance_metric(48.0 + affinity as f32 * 0.25), + fear_or_guard: clamp_stance_metric(62.0 - affinity as f32 * 0.55 + hostile_penalty), + loyalty: clamp_stance_metric( + 24.0 + affinity as f32 * 0.35 + if recruited { 26.0 } else { 0.0 }, + ), + current_conflict_tag, + recent_approvals: Vec::new(), + recent_disapprovals: Vec::new(), + } +} + +pub fn normalize_npc_state_snapshot( + input: NpcStateUpsertInput, + existing_created_at_micros: Option, +) -> Result { + validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?; + + let affinity = input.affinity; + let stance_profile = normalize_stance_profile( + input.stance_profile, + affinity, + input.recruited, + affinity < 0, + None, + ); + let created_at_micros = existing_created_at_micros.unwrap_or(input.updated_at_micros); + + Ok(NpcStateSnapshot { + npc_state_id: generate_npc_state_id(&input.runtime_session_id, &input.npc_id), + runtime_session_id: normalize_required_string(input.runtime_session_id).unwrap_or_default(), + npc_id: normalize_required_string(input.npc_id).unwrap_or_default(), + npc_name: normalize_required_string(input.npc_name).unwrap_or_default(), + affinity, + relation_state: build_relation_state(affinity), + help_used: input.help_used, + chatted_count: input.chatted_count, + gifts_given: input.gifts_given, + recruited: input.recruited, + trade_stock_signature: normalize_optional_value(input.trade_stock_signature), + revealed_facts: normalize_string_list(input.revealed_facts), + known_attribute_rumors: normalize_string_list(input.known_attribute_rumors), + first_meaningful_contact_resolved: input.first_meaningful_contact_resolved, + seen_backstory_chapter_ids: normalize_string_list(input.seen_backstory_chapter_ids), + stance_profile, + created_at_micros, + updated_at_micros: input.updated_at_micros, + }) +} + +pub fn apply_npc_social_action( + current: NpcStateSnapshot, + input: ResolveNpcSocialActionInput, +) -> Result { + validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?; + + let note = normalize_optional_value(input.note); + let mut next = current; + + match input.action_kind { + NpcSocialActionKind::Chat => { + let affinity_gain = input + .affinity_gain_override + .unwrap_or_else(|| (6 - next.chatted_count as i32).max(2)); + next.affinity += affinity_gain; + next.chatted_count += 1; + next.first_meaningful_contact_resolved = true; + next.stance_profile = apply_story_choice_to_stance_profile( + &next.stance_profile, + input.action_kind, + affinity_gain, + next.recruited, + note.as_deref(), + ); + } + NpcSocialActionKind::Help => { + if next.help_used { + return Err(NpcStateFieldError::HelpAlreadyUsed); + } + let affinity_gain = input.affinity_gain_override.unwrap_or(4); + next.affinity += affinity_gain; + next.help_used = true; + next.stance_profile = apply_story_choice_to_stance_profile( + &next.stance_profile, + input.action_kind, + affinity_gain, + next.recruited, + note.as_deref(), + ); + } + NpcSocialActionKind::Gift => { + let affinity_gain = input.affinity_gain_override.unwrap_or(4); + next.affinity += affinity_gain; + next.gifts_given += 1; + next.stance_profile = apply_story_choice_to_stance_profile( + &next.stance_profile, + input.action_kind, + affinity_gain, + next.recruited, + note.as_deref(), + ); + } + NpcSocialActionKind::Recruit => { + if next.affinity < NPC_RECRUIT_AFFINITY_THRESHOLD { + return Err(NpcStateFieldError::RecruitAffinityTooLow); + } + next.recruited = true; + next.first_meaningful_contact_resolved = true; + next.stance_profile = apply_story_choice_to_stance_profile( + &next.stance_profile, + input.action_kind, + input.affinity_gain_override.unwrap_or(0), + true, + note.as_deref(), + ); + } + NpcSocialActionKind::QuestAccept => { + let affinity_gain = input.affinity_gain_override.unwrap_or(3); + next.affinity += affinity_gain; + next.stance_profile = apply_story_choice_to_stance_profile( + &next.stance_profile, + input.action_kind, + affinity_gain, + next.recruited, + note.as_deref(), + ); + } + } + + next.affinity = next.affinity.clamp(-100, 100); + next.npc_name = normalize_required_string(input.npc_name).unwrap_or_default(); + next.relation_state = build_relation_state(next.affinity); + next.updated_at_micros = input.updated_at_micros; + + Ok(next) +} + +pub fn resolve_npc_interaction( + current: NpcStateSnapshot, + input: ResolveNpcInteractionInput, +) -> Result { + validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?; + + let interaction_function_id = normalize_optional_value(Some(input.interaction_function_id)) + .ok_or(NpcStateFieldError::MissingInteractionFunctionId)?; + if !is_supported_npc_interaction_function_id(&interaction_function_id) { + return Err(NpcStateFieldError::UnsupportedInteractionFunctionId); + } + + let previous_affinity = current.affinity; + let mut next_state = current.clone(); + + let (interaction_status, action_text, result_text, story_text, battle_mode, encounter_closed) = + match interaction_function_id.as_str() { + NPC_PREVIEW_TALK_FUNCTION_ID => ( + NpcInteractionStatus::Previewed, + format!("转向{}", current.npc_name), + format!( + "你把注意力真正收回到{}身上,接下来可以围绕这名角色做正式交互了。", + current.npc_name + ), + None, + None, + false, + ), + NPC_CHAT_FUNCTION_ID => { + next_state = apply_npc_social_action( + current, + ResolveNpcSocialActionInput { + runtime_session_id: input.runtime_session_id, + npc_id: input.npc_id, + npc_name: input.npc_name, + action_kind: NpcSocialActionKind::Chat, + affinity_gain_override: None, + note: None, + updated_at_micros: input.updated_at_micros, + }, + )?; + ( + NpcInteractionStatus::Dialogue, + format!("继续和{}交谈", next_state.npc_name), + format!( + "{}愿意把话接下去,态度比刚才明显松动了一些。", + next_state.npc_name + ), + Some(format!( + "{}看起来已经愿意继续把话题往下接。", + next_state.npc_name + )), + None, + false, + ) + } + NPC_HELP_FUNCTION_ID => { + next_state = apply_npc_social_action( + current, + ResolveNpcSocialActionInput { + runtime_session_id: input.runtime_session_id, + npc_id: input.npc_id, + npc_name: input.npc_name, + action_kind: NpcSocialActionKind::Help, + affinity_gain_override: None, + note: None, + updated_at_micros: input.updated_at_micros, + }, + )?; + ( + NpcInteractionStatus::Resolved, + format!("向{}请求援手", next_state.npc_name), + format!( + "{}给了你一次及时支援,关系也顺势拉近了一点。", + next_state.npc_name + ), + None, + None, + false, + ) + } + NPC_RECRUIT_FUNCTION_ID => { + next_state = apply_npc_social_action( + current, + ResolveNpcSocialActionInput { + runtime_session_id: input.runtime_session_id, + npc_id: input.npc_id, + npc_name: input.npc_name, + action_kind: NpcSocialActionKind::Recruit, + affinity_gain_override: None, + note: None, + updated_at_micros: input.updated_at_micros, + }, + )?; + ( + NpcInteractionStatus::Recruited, + format!("邀请{}加入队伍", next_state.npc_name), + format!("{}接受了你的邀请。", next_state.npc_name), + Some(format!( + "{}已经明确接受了与你同行的关系。", + next_state.npc_name + )), + None, + true, + ) + } + NPC_FIGHT_FUNCTION_ID => ( + NpcInteractionStatus::BattlePending, + format!("与{}正面开战", current.npc_name), + format!( + "{}已经不再保留余地,当前冲突正式转入战斗结算。", + current.npc_name + ), + None, + Some(NpcInteractionBattleMode::Fight), + false, + ), + NPC_SPAR_FUNCTION_ID => ( + NpcInteractionStatus::BattlePending, + format!("与{}点到为止切磋", current.npc_name), + format!( + "{}摆开架势,准备和你来一场点到为止的切磋。", + current.npc_name + ), + None, + Some(NpcInteractionBattleMode::Spar), + false, + ), + NPC_LEAVE_FUNCTION_ID => ( + NpcInteractionStatus::Left, + format!("离开{}", current.npc_name), + format!( + "你暂时没有继续和{}纠缠,把注意力重新拉回了前路。", + current.npc_name + ), + None, + None, + true, + ), + _ => return Err(NpcStateFieldError::UnsupportedInteractionFunctionId), + }; + + Ok(NpcInteractionResult { + npc_state: next_state.clone(), + interaction_status, + action_text, + result_text, + story_text, + battle_mode, + encounter_closed, + affinity_changed: previous_affinity != next_state.affinity, + previous_affinity, + next_affinity: next_state.affinity, + }) +} + +pub fn normalize_optional_value(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} + +pub fn is_supported_npc_interaction_function_id(function_id: &str) -> bool { + matches!( + function_id, + NPC_PREVIEW_TALK_FUNCTION_ID + | NPC_CHAT_FUNCTION_ID + | NPC_HELP_FUNCTION_ID + | NPC_RECRUIT_FUNCTION_ID + | NPC_FIGHT_FUNCTION_ID + | NPC_SPAR_FUNCTION_ID + | NPC_LEAVE_FUNCTION_ID + ) +} + +fn validate_required_identity_fields( + runtime_session_id: &str, + npc_id: &str, + npc_name: &str, +) -> Result<(), NpcStateFieldError> { + if normalize_required_string(runtime_session_id).is_none() { + return Err(NpcStateFieldError::MissingRuntimeSessionId); + } + if normalize_required_string(npc_id).is_none() { + return Err(NpcStateFieldError::MissingNpcId); + } + if normalize_required_string(npc_name).is_none() { + return Err(NpcStateFieldError::MissingNpcName); + } + + Ok(()) +} + +fn normalize_stance_profile( + stance_profile: Option, + affinity: i32, + recruited: bool, + hostile: bool, + role_text: Option<&str>, +) -> NpcStanceProfile { + let Some(stance_profile) = stance_profile else { + return build_initial_stance_profile(affinity, recruited, hostile, role_text); + }; + + NpcStanceProfile { + trust: clamp_stance_metric(stance_profile.trust as f32), + warmth: clamp_stance_metric(stance_profile.warmth as f32), + ideological_fit: clamp_stance_metric(stance_profile.ideological_fit as f32), + fear_or_guard: clamp_stance_metric(stance_profile.fear_or_guard as f32), + loyalty: clamp_stance_metric(stance_profile.loyalty as f32), + current_conflict_tag: normalize_optional_value(stance_profile.current_conflict_tag), + recent_approvals: trim_recent_notes(stance_profile.recent_approvals), + recent_disapprovals: trim_recent_notes(stance_profile.recent_disapprovals), + } +} + +fn apply_story_choice_to_stance_profile( + stance_profile: &NpcStanceProfile, + action_kind: NpcSocialActionKind, + affinity_gain: i32, + recruited: bool, + note: Option<&str>, +) -> NpcStanceProfile { + let mut next = stance_profile.clone(); + + match action_kind { + NpcSocialActionKind::Chat => { + next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32 * 2.0); + next.warmth = + clamp_stance_metric(next.warmth as f32 + 4.0 + affinity_gain as f32 * 2.0); + next.fear_or_guard = + clamp_stance_metric(next.fear_or_guard as f32 - 5.0 - affinity_gain as f32); + if affinity_gain >= 0 { + push_recent_note( + &mut next.recent_approvals, + note.unwrap_or("你愿意先从眼前局势和试探开始说话。"), + ); + } else { + push_recent_note( + &mut next.recent_disapprovals, + note.unwrap_or("这轮交流没能真正对上节奏。"), + ); + } + } + NpcSocialActionKind::Help => { + next.trust = clamp_stance_metric(next.trust as f32 + 12.0); + next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0); + next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 8.0); + push_recent_note( + &mut next.recent_approvals, + note.unwrap_or("你在对方需要的时候搭了手。"), + ); + } + NpcSocialActionKind::Gift => { + next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32); + next.warmth = + clamp_stance_metric(next.warmth as f32 + 10.0 + affinity_gain as f32 * 2.0); + next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 4.0); + push_recent_note( + &mut next.recent_approvals, + note.unwrap_or("你给出的东西回应了对方眼下的处境。"), + ); + } + NpcSocialActionKind::Recruit => { + next.trust = clamp_stance_metric(next.trust as f32 + 8.0); + next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0); + next.loyalty = + clamp_stance_metric(next.loyalty as f32 + 18.0 + if recruited { 4.0 } else { 0.0 }); + next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 10.0); + push_recent_note( + &mut next.recent_approvals, + note.unwrap_or("你正式把对方纳入了同行关系。"), + ); + } + NpcSocialActionKind::QuestAccept => { + next.trust = clamp_stance_metric(next.trust as f32 + 7.0); + next.ideological_fit = clamp_stance_metric(next.ideological_fit as f32 + 5.0); + next.loyalty = clamp_stance_metric(next.loyalty as f32 + 4.0); + push_recent_note( + &mut next.recent_approvals, + note.unwrap_or("你接住了对方主动交出来的事。"), + ); + } + } + + next +} + +fn infer_conflict_tag(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else if trimmed.contains("旧案") || trimmed.contains("调查") || trimmed.contains("追查") + { + Some("旧案".to_string()) + } else if trimmed.contains('守') || trimmed.contains('卫') || trimmed.contains('巡') { + Some("守线".to_string()) + } else if trimmed.contains('商') || trimmed.contains('摊') || trimmed.contains("军需") { + Some("交易".to_string()) + } else { + None + } +} + +fn trim_recent_notes(values: Vec) -> Vec { + let mut values = normalize_string_list(values); + if values.len() > MAX_STANCE_NOTES { + values = values.split_off(values.len() - MAX_STANCE_NOTES); + } + values +} + +fn push_recent_note(target: &mut Vec, note: &str) { + let trimmed = note.trim(); + if trimmed.is_empty() { + return; + } + + target.push(trimmed.to_string()); + if target.len() > MAX_STANCE_NOTES { + let drain_len = target.len() - MAX_STANCE_NOTES; + target.drain(0..drain_len); + } +} + +fn clamp_stance_metric(value: f32) -> u8 { + value.round().clamp(0.0, 100.0) as u8 +} + +impl fmt::Display for NpcStateFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingRuntimeSessionId => f.write_str("npc_state.runtime_session_id 不能为空"), + Self::MissingNpcId => f.write_str("npc_state.npc_id 不能为空"), + Self::MissingNpcName => f.write_str("npc_state.npc_name 不能为空"), + Self::MissingInteractionFunctionId => { + f.write_str("resolve_npc_interaction.interaction_function_id 不能为空") + } + Self::HelpAlreadyUsed => f.write_str("npc_state.help_used 已经消耗,不能重复援手"), + Self::RecruitAffinityTooLow => { + f.write_str("npc_state.affinity 未达到招募阈值,不能执行招募动作") + } + Self::UnsupportedInteractionFunctionId => { + f.write_str("resolve_npc_interaction.interaction_function_id 当前不受支持") + } + } + } +} + +impl Error for NpcStateFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_base_state() -> NpcStateSnapshot { + normalize_npc_state_snapshot( + NpcStateUpsertInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + affinity: 18, + help_used: false, + chatted_count: 0, + gifts_given: 0, + recruited: false, + trade_stock_signature: None, + revealed_facts: vec![], + known_attribute_rumors: vec![], + first_meaningful_contact_resolved: false, + seen_backstory_chapter_ids: vec![], + stance_profile: None, + updated_at_micros: 10, + }, + None, + ) + .expect("base npc state should be valid") + } + + #[test] + fn relation_state_uses_expected_thresholds() { + assert_eq!(build_relation_state(-1).stance, NpcRelationStance::Hostile); + assert_eq!(build_relation_state(0).stance, NpcRelationStance::Guarded); + assert_eq!(build_relation_state(15).stance, NpcRelationStance::Neutral); + assert_eq!( + build_relation_state(30).stance, + NpcRelationStance::Cooperative + ); + assert_eq!(build_relation_state(60).stance, NpcRelationStance::Bonded); + } + + #[test] + fn normalize_npc_state_snapshot_builds_primary_fields() { + let snapshot = build_base_state(); + + assert_eq!(snapshot.npc_state_id, "npcstate_runtime_001:npc_001"); + assert_eq!(snapshot.relation_state.stance, NpcRelationStance::Neutral); + assert_eq!(snapshot.created_at_micros, 10); + assert_eq!(snapshot.updated_at_micros, 10); + } + + #[test] + fn chat_action_increases_affinity_and_marks_first_contact() { + let next = apply_npc_social_action( + build_base_state(), + ResolveNpcSocialActionInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + action_kind: NpcSocialActionKind::Chat, + affinity_gain_override: None, + note: None, + updated_at_micros: 20, + }, + ) + .expect("chat should succeed"); + + assert_eq!(next.affinity, 24); + assert_eq!(next.chatted_count, 1); + assert!(next.first_meaningful_contact_resolved); + assert_eq!(next.updated_at_micros, 20); + } + + #[test] + fn help_action_rejects_second_use() { + let used_state = NpcStateSnapshot { + help_used: true, + ..build_base_state() + }; + + let error = apply_npc_social_action( + used_state, + ResolveNpcSocialActionInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + action_kind: NpcSocialActionKind::Help, + affinity_gain_override: None, + note: None, + updated_at_micros: 20, + }, + ) + .expect_err("help should fail once used"); + + assert_eq!(error, NpcStateFieldError::HelpAlreadyUsed); + } + + #[test] + fn recruit_requires_threshold() { + let error = apply_npc_social_action( + build_base_state(), + ResolveNpcSocialActionInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + action_kind: NpcSocialActionKind::Recruit, + affinity_gain_override: None, + note: None, + updated_at_micros: 20, + }, + ) + .expect_err("recruit should require threshold"); + + assert_eq!(error, NpcStateFieldError::RecruitAffinityTooLow); + } + + #[test] + fn recruit_marks_state_when_affinity_is_high_enough() { + let recruitable = NpcStateSnapshot { + affinity: 66, + relation_state: build_relation_state(66), + ..build_base_state() + }; + + let next = apply_npc_social_action( + recruitable, + ResolveNpcSocialActionInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + action_kind: NpcSocialActionKind::Recruit, + affinity_gain_override: None, + note: None, + updated_at_micros: 20, + }, + ) + .expect("recruit should succeed"); + + assert!(next.recruited); + assert!(next.first_meaningful_contact_resolved); + } + + #[test] + fn resolve_preview_talk_keeps_affinity_unchanged() { + let result = resolve_npc_interaction( + build_base_state(), + ResolveNpcInteractionInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + interaction_function_id: NPC_PREVIEW_TALK_FUNCTION_ID.to_string(), + release_npc_id: None, + updated_at_micros: 20, + }, + ) + .expect("preview talk should succeed"); + + assert_eq!(result.interaction_status, NpcInteractionStatus::Previewed); + assert!(!result.affinity_changed); + assert_eq!(result.previous_affinity, 18); + assert_eq!(result.next_affinity, 18); + } + + #[test] + fn resolve_chat_updates_npc_state_and_returns_dialogue_status() { + let result = resolve_npc_interaction( + build_base_state(), + ResolveNpcInteractionInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + interaction_function_id: NPC_CHAT_FUNCTION_ID.to_string(), + release_npc_id: None, + updated_at_micros: 20, + }, + ) + .expect("chat interaction should succeed"); + + assert_eq!(result.interaction_status, NpcInteractionStatus::Dialogue); + assert!(result.affinity_changed); + assert_eq!(result.next_affinity, 24); + assert!(result.npc_state.first_meaningful_contact_resolved); + } + + #[test] + fn resolve_fight_returns_battle_pending_without_affinity_change() { + let result = resolve_npc_interaction( + build_base_state(), + ResolveNpcInteractionInput { + runtime_session_id: "runtime_001".to_string(), + npc_id: "npc_001".to_string(), + npc_name: "宁霜".to_string(), + interaction_function_id: NPC_FIGHT_FUNCTION_ID.to_string(), + release_npc_id: None, + updated_at_micros: 20, + }, + ) + .expect("fight interaction should succeed"); + + assert_eq!( + result.interaction_status, + NpcInteractionStatus::BattlePending + ); + assert_eq!(result.battle_mode, Some(NpcInteractionBattleMode::Fight)); + assert!(!result.affinity_changed); + } +} diff --git a/server-rs/crates/module-progression/Cargo.toml b/server-rs/crates/module-progression/Cargo.toml new file mode 100644 index 00000000..9b87b8d6 --- /dev/null +++ b/server-rs/crates/module-progression/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-progression" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-progression/README.md b/server-rs/crates/module-progression/README.md index 56ec3785..bc5e57b1 100644 --- a/server-rs/crates/module-progression/README.md +++ b/server-rs/crates/module-progression/README.md @@ -1,29 +1,43 @@ -# module-progression 独立模块 package 占位说明 +# module-progression 成长与章节推进模块 crate 说明 -日期:`2026-04-20` +日期:`2026-04-21` -## 1. package 职责 +## 1. crate 职责 -`module-progression` 是成长与章节推进模块 package,后续负责: +`module-progression` 是成长与章节推进模块 crate,当前与后续负责: -1. `player_progression`、`chapter_progression` 等成长状态模型 -2. 等级、章节推进、敌对强度与进程规则 -3. 与 runtime、story、quest 的成长联动 -4. 与 `apps/spacetime-module` 的成长表、reducer、view 聚合对接 +1. `player_progression`、`chapter_progression` 的领域快照与校验规则。 +2. 等级经验曲线、同级参考强度、敌对战斗生命值与经验掉落的统一数学基线。 +3. 章节经验预算、章节实际记账与章节自动定级的纯领域 helper。 +4. 与 `crates/spacetime-module` 的成长真相表、reducer、procedure 聚合对接。 ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入成长规则、投影与兼容接口实现。 +当前阶段已不再是目录占位,已经完成以下首版落地: -后续与本 package 直接相关的任务包括: +1. 新增 `Cargo.toml` 与 `src/lib.rs`,形成真实可编译 crate。 +2. 冻结 `LevelBenchmark`、`PlayerProgressionSnapshot`、`ChapterProgressionSnapshot`、`RuntimeEntityLevelProfile` 等首版领域类型。 +3. 固化与 Node 侧一致的经验曲线、参考强度曲线、章节 pseudo level 曲线与敌对经验/生命值 fallback 规则。 +4. 提供 `create_initial_player_progression`、`grant_player_experience`、`build_chapter_progression_snapshot`、`apply_chapter_progression_ledger` 等领域原语。 +5. 提供 `build_chapter_auto_level_profile`、`build_hostile_experience_reward`、`resolve_hostile_battle_max_hp`,为后续 `quest / combat / npc` 联动提供统一成长基线。 +6. `spacetime-module` 已把 `turn_in_quest` 与 `resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 最小经验结算链。 -1. 设计 `player_progression`、`chapter_progression` -2. 设计 `update_progression_state` -3. 对齐章节推进、成长变化与兼容输出结构 -4. 接入 runtime 与 story 的成长联动 +当前这轮刻意未做的范围: -## 3. 边界约束 +1. 还没有把 `custom-world` 章节蓝图编译直接迁进 Rust。 +2. 还没有把 `repeatPenalty`、超预算衰减和完整章节偏差审计表独立拆出。 +3. 还没有在 crate 内直接承接 HTTP、Axum、LLM 或 OSS 副作用。 -1. `module-progression` 保持纯领域规则与状态建模,不直接承接 LLM、OSS 或 HTTP 协议。 -2. 成长状态作为 runtime 与 story 的公共领域组件,不能再次散落回单个 handler 或临时 service 中。 -3. 前端兼容输出由 `apps/api-server` 暴露,成长状态真相由 `apps/spacetime-module` 聚合。 +## 3. 当前已冻结关联文档 + +1. [../../../docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](../../../docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md) +2. [../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) +3. [../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md) +4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md) + +## 4. 边界约束 + +1. `module-progression` 保持纯领域规则与状态建模,不直接承接 HTTP、JWT、OSS、LLM 或本地文件副作用。 +2. 等级、经验、章节预算、自动定级必须以本 crate 的规则为唯一数学基线,不能再次散落回 route handler 或前端临时推导。 +3. `player_progression` 与 `chapter_progression` 的持久化真相由 `crates/spacetime-module` 聚合,前端兼容输出与后端 facade 由 `crates/api-server` 暴露。 +4. 若后续 `module-custom-world` 的章节蓝图 Rust 化与当前 helper 有冲突,必须先校正文档和领域规则,再继续接线。 diff --git a/server-rs/crates/module-progression/src/lib.rs b/server-rs/crates/module-progression/src/lib.rs new file mode 100644 index 00000000..adc6568e --- /dev/null +++ b/server-rs/crates/module-progression/src/lib.rs @@ -0,0 +1,770 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::normalize_required_string; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const MAX_PLAYER_LEVEL: u32 = 20; +pub const DEFAULT_TERMINAL_STORY_LEVEL: u32 = 15; +pub const MIN_TERMINAL_STORY_LEVEL: u32 = 5; +pub const PSEUDO_LEVEL_CURVE_EXPONENT: f64 = 0.92; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlayerProgressionGrantSource { + Quest, + HostileNpc, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChapterPaceBand { + OpeningFast, + Steady, + Pressure, + FinaleDense, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProgressionRole { + Guide, + Ambient, + Support, + HostileStandard, + HostileElite, + HostileBoss, + Rival, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LevelProfileSource { + ChapterAuto, + PresetOverride, + Manual, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LevelBenchmark { + pub level: u32, + pub xp_to_next_level: u32, + pub cumulative_xp_required: u32, + pub reference_strength: u32, + pub base_hp: u32, + pub base_mana: u32, + pub baseline_damage_scale: f32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlayerProgressionSnapshot { + pub user_id: String, + pub level: u32, + pub current_level_xp: u32, + pub total_xp: u32, + pub xp_to_next_level: u32, + pub pending_level_ups: u32, + pub last_granted_source: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlayerProgressionGetInput { + pub user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlayerProgressionGrantInput { + pub user_id: String, + pub amount: u32, + pub source: PlayerProgressionGrantSource, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlayerProgressionProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChapterProgressionSnapshot { + pub user_id: String, + pub chapter_id: String, + pub chapter_index: u32, + pub total_chapters: u32, + pub entry_pseudo_level_millis: u32, + pub exit_pseudo_level_millis: u32, + pub entry_level: u32, + pub exit_level: u32, + pub planned_total_xp: u32, + pub planned_quest_xp: u32, + pub planned_hostile_xp: u32, + pub actual_quest_xp: u32, + pub actual_hostile_xp: u32, + pub expected_hostile_defeat_count: u32, + pub actual_hostile_defeat_count: u32, + pub level_at_entry: u32, + pub level_at_exit: Option, + pub pace_band: ChapterPaceBand, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChapterProgressionGetInput { + pub user_id: String, + pub chapter_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChapterProgressionInput { + pub user_id: String, + pub chapter_id: String, + pub chapter_index: u32, + pub total_chapters: u32, + pub entry_pseudo_level_millis: u32, + pub exit_pseudo_level_millis: u32, + pub entry_level: u32, + pub exit_level: u32, + pub planned_total_xp: u32, + pub planned_quest_xp: u32, + pub planned_hostile_xp: u32, + pub expected_hostile_defeat_count: u32, + pub level_at_entry: u32, + pub pace_band: ChapterPaceBand, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChapterProgressionLedgerInput { + pub user_id: String, + pub chapter_id: String, + pub granted_quest_xp: u32, + pub granted_hostile_xp: u32, + pub hostile_defeat_increment: u32, + pub level_at_exit: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChapterProgressionProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeEntityLevelProfile { + pub level: u32, + pub reference_strength: u32, + pub chapter_id: Option, + pub chapter_index: Option, + pub progression_role: ProgressionRole, + pub source: LevelProfileSource, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChapterAutoLevelProfileInput { + pub chapter_id: String, + pub chapter_index: u32, + pub entry_pseudo_level_millis: u32, + pub exit_pseudo_level_millis: u32, + pub stage_progress_millis: u32, + pub progression_role: ProgressionRole, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProgressionFieldError { + MissingUserId, + MissingChapterId, + InvalidChapterIndex, + InvalidTotalChapters, + InvalidLevel, + InvalidEntryExitLevel, + InvalidXpBudget, + InvalidExpectedHostileDefeatCount, +} + +fn clamp_level(level: u32) -> u32 { + level.clamp(1, MAX_PLAYER_LEVEL) +} + +fn round_metric(value: f64, digits: usize) -> f64 { + let factor = 10_f64.powi(digits as i32); + (value * factor).round() / factor +} + +fn scale(level: u32) -> u32 { + level.saturating_sub(1) +} + +// 等级经验曲线与现有 Node 版保持同一公式,避免 Rust 迁移后成长节奏漂移。 +pub fn compute_xp_to_next_level(level: u32) -> u32 { + let normalized_level = clamp_level(level); + let scale = scale(normalized_level); + 60 + 20 * scale + 8 * scale * scale +} + +pub fn build_level_benchmark(level: u32) -> LevelBenchmark { + let normalized_level = clamp_level(level); + let current_scale = scale(normalized_level); + let mut cumulative_xp_required = 0_u32; + + for current in 1..normalized_level { + cumulative_xp_required += compute_xp_to_next_level(current); + } + + let xp_to_next_level = if normalized_level >= MAX_PLAYER_LEVEL { + 0 + } else { + compute_xp_to_next_level(normalized_level) + }; + + LevelBenchmark { + level: normalized_level, + xp_to_next_level, + cumulative_xp_required, + reference_strength: 100 + 16 * current_scale + 6 * current_scale * current_scale, + base_hp: 180 + 24 * current_scale + 10 * current_scale * current_scale, + base_mana: 80 + 14 * current_scale + 6 * current_scale * current_scale, + baseline_damage_scale: round_metric( + 1.0 + 0.12 * f64::from(current_scale) + 0.03 * f64::from(current_scale * current_scale), + 3, + ) as f32, + } +} + +// 总经验决定真实等级,SpacetimeDB 持久化后不再允许前端自己推导等级结果。 +pub fn resolve_level_from_total_xp(total_xp: u32) -> u32 { + let mut resolved_level = 1; + + for level in 2..=MAX_PLAYER_LEVEL { + if total_xp < build_level_benchmark(level).cumulative_xp_required { + break; + } + resolved_level = level; + } + + resolved_level +} + +pub fn build_player_progression_snapshot( + user_id: String, + total_xp: u32, + last_granted_source: Option, + created_at_micros: i64, + updated_at_micros: i64, +) -> Result { + let user_id = normalize_required_text(user_id, ProgressionFieldError::MissingUserId)?; + let level = resolve_level_from_total_xp(total_xp); + let benchmark = build_level_benchmark(level); + + let (current_level_xp, xp_to_next_level) = if level >= MAX_PLAYER_LEVEL { + (0, 0) + } else { + ( + total_xp.saturating_sub(benchmark.cumulative_xp_required), + benchmark.xp_to_next_level, + ) + }; + + Ok(PlayerProgressionSnapshot { + user_id, + level, + current_level_xp, + total_xp, + xp_to_next_level, + pending_level_ups: 0, + last_granted_source, + created_at_micros, + updated_at_micros, + }) +} + +// 新存档默认统一回填为 Lv.1 / 0 XP,后续再由任务和战斗奖励驱动成长。 +pub fn create_initial_player_progression( + user_id: String, + created_at_micros: i64, +) -> Result { + build_player_progression_snapshot(user_id, 0, None, created_at_micros, created_at_micros) +} + +// 经验结算统一以“旧快照 + grant 输入”生成新快照,避免各调用方自行改等级字段。 +pub fn grant_player_experience( + current: PlayerProgressionSnapshot, + input: PlayerProgressionGrantInput, +) -> Result { + let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?; + if current.user_id != user_id { + return Err(ProgressionFieldError::MissingUserId); + } + + let next_total_xp = current.total_xp.saturating_add(input.amount); + let mut next = build_player_progression_snapshot( + current.user_id.clone(), + next_total_xp, + Some(input.source), + current.created_at_micros, + input.updated_at_micros, + )?; + next.pending_level_ups = next.level.saturating_sub(current.level); + Ok(next) +} + +// 章节快照同时承载计划预算与实际记账,这样 chapter_progression 一张表就能覆盖计划/偏差回看。 +pub fn build_chapter_progression_snapshot( + input: ChapterProgressionInput, +) -> Result { + let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?; + let chapter_id = + normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?; + + if input.chapter_index == 0 { + return Err(ProgressionFieldError::InvalidChapterIndex); + } + if input.total_chapters == 0 || input.chapter_index > input.total_chapters { + return Err(ProgressionFieldError::InvalidTotalChapters); + } + + let entry_level = clamp_level(input.entry_level); + let exit_level = clamp_level(input.exit_level); + if exit_level < entry_level { + return Err(ProgressionFieldError::InvalidEntryExitLevel); + } + + if input.planned_total_xp < input.planned_quest_xp + input.planned_hostile_xp { + return Err(ProgressionFieldError::InvalidXpBudget); + } + + Ok(ChapterProgressionSnapshot { + user_id, + chapter_id, + chapter_index: input.chapter_index, + total_chapters: input.total_chapters, + entry_pseudo_level_millis: input.entry_pseudo_level_millis.max(1_000), + exit_pseudo_level_millis: input + .exit_pseudo_level_millis + .max(input.entry_pseudo_level_millis.max(1_000)), + entry_level, + exit_level, + planned_total_xp: input.planned_total_xp, + planned_quest_xp: input.planned_quest_xp, + planned_hostile_xp: input.planned_hostile_xp, + actual_quest_xp: 0, + actual_hostile_xp: 0, + expected_hostile_defeat_count: input.expected_hostile_defeat_count, + actual_hostile_defeat_count: 0, + level_at_entry: clamp_level(input.level_at_entry), + level_at_exit: None, + pace_band: input.pace_band, + created_at_micros: input.updated_at_micros, + updated_at_micros: input.updated_at_micros, + }) +} + +// 按章累计实际任务经验、敌对经验与击杀次数,为后续章节节奏评估提供同一真相源。 +pub fn apply_chapter_progression_ledger( + current: ChapterProgressionSnapshot, + input: ChapterProgressionLedgerInput, +) -> Result { + let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?; + let chapter_id = + normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?; + + if current.user_id != user_id || current.chapter_id != chapter_id { + return Err(ProgressionFieldError::MissingChapterId); + } + + Ok(ChapterProgressionSnapshot { + actual_quest_xp: current + .actual_quest_xp + .saturating_add(input.granted_quest_xp), + actual_hostile_xp: current + .actual_hostile_xp + .saturating_add(input.granted_hostile_xp), + actual_hostile_defeat_count: current + .actual_hostile_defeat_count + .saturating_add(input.hostile_defeat_increment), + level_at_exit: input + .level_at_exit + .map(clamp_level) + .or(current.level_at_exit), + updated_at_micros: input.updated_at_micros, + ..current + }) +} + +pub fn resolve_terminal_story_level(total_chapters: u32) -> u32 { + let resolved = (3_f64 + f64::from(total_chapters.max(1)) * 2.4).round() as u32; + resolved.clamp(MIN_TERMINAL_STORY_LEVEL, DEFAULT_TERMINAL_STORY_LEVEL) +} + +// 章节边界先算 pseudo level,再反推经验预算;这里固化设计文档中的 0.92 曲线。 +pub fn resolve_chapter_boundary_pseudo_level_millis( + boundary_index: u32, + total_chapters: u32, +) -> u32 { + if boundary_index == 0 || total_chapters == 0 { + return 1_000; + } + + let progress = (f64::from(boundary_index) / f64::from(total_chapters)).clamp(0.0, 1.0); + let terminal_story_level = resolve_terminal_story_level(total_chapters); + let pseudo_level = 1.0 + + progress.powf(PSEUDO_LEVEL_CURVE_EXPONENT) + * f64::from(terminal_story_level.saturating_sub(1)); + + (round_metric(pseudo_level, 3) * 1_000.0).round() as u32 +} + +pub fn resolve_pseudo_level_xp_millis(pseudo_level_millis: u32) -> u32 { + let pseudo_level = f64::from(pseudo_level_millis.max(1_000)) / 1_000.0; + let lower_level = pseudo_level.floor().max(1.0) as u32; + let mut lower_level_xp = 0_u32; + + for level in 1..lower_level { + lower_level_xp = lower_level_xp.saturating_add(compute_xp_to_next_level(level)); + } + + let partial = (f64::from(compute_xp_to_next_level(lower_level)) + * (pseudo_level - f64::from(lower_level))) + .round() as u32; + + lower_level_xp.saturating_add(partial) +} + +// 章节自动定级当前先抽成纯数学 helper,等 custom-world Rust crate 就位后再直接接蓝图编译结果。 +pub fn build_chapter_auto_level_profile( + input: ChapterAutoLevelProfileInput, +) -> Result { + let chapter_id = + normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?; + if input.chapter_index == 0 { + return Err(ProgressionFieldError::InvalidChapterIndex); + } + + let base_stage_level = f64::from(input.entry_pseudo_level_millis.max(1_000)) + + f64::from( + input + .exit_pseudo_level_millis + .max(input.entry_pseudo_level_millis.max(1_000)) + .saturating_sub(input.entry_pseudo_level_millis.max(1_000)), + ) * (f64::from(input.stage_progress_millis.min(1_000)) / 1_000.0); + let base_stage_level = base_stage_level / 1_000.0; + let role_offset = role_level_offset(input.progression_role); + let level = clamp_level((base_stage_level + f64::from(role_offset)).round().max(1.0) as u32); + let benchmark = build_level_benchmark(level); + + Ok(RuntimeEntityLevelProfile { + level, + reference_strength: benchmark.reference_strength, + chapter_id: Some(chapter_id), + chapter_index: Some(input.chapter_index), + progression_role: input.progression_role, + source: LevelProfileSource::ChapterAuto, + }) +} + +pub fn resolve_hostile_battle_max_hp(level_profile: &RuntimeEntityLevelProfile) -> u32 { + let benchmark = build_level_benchmark(level_profile.level); + let role_bonus = match level_profile.progression_role { + ProgressionRole::HostileElite => 10, + ProgressionRole::HostileBoss => 24, + ProgressionRole::Rival => 6, + _ => 0, + }; + + (benchmark.base_hp / 9).max(32).saturating_add(role_bonus) +} + +// 击败敌对 NPC 的经验掉落沿用现有倍率口径,避免迁移后任务/战斗经验节奏突然变化。 +pub fn build_hostile_experience_reward( + player_level: u32, + level_profile: &RuntimeEntityLevelProfile, + chapter_stage_multiplier_millis: u32, + explicit_base_xp: Option, +) -> u32 { + let benchmark = build_level_benchmark(level_profile.level); + let base_kill_xp = explicit_base_xp + .unwrap_or_else(|| ((benchmark.xp_to_next_level as f64) * 0.08).round() as u32); + let level_delta_multiplier_millis = + resolve_level_delta_multiplier_millis(player_level, level_profile.level); + let role_multiplier_millis = match level_profile.progression_role { + ProgressionRole::HostileElite => 1_150, + ProgressionRole::HostileBoss => 1_300, + ProgressionRole::Guide | ProgressionRole::Ambient | ProgressionRole::Support => 0, + _ => 1_000, + }; + let scaled = u64::from(base_kill_xp) + .saturating_mul(u64::from(chapter_stage_multiplier_millis)) + .saturating_mul(u64::from(level_delta_multiplier_millis)) + .saturating_mul(u64::from(role_multiplier_millis as u32)) + / 1_000 + / 1_000 + / 1_000; + let rounded = ((scaled as u32 + 2) / 5) * 5; + rounded.max(5) +} + +fn resolve_level_delta_multiplier_millis(player_level: u32, target_level: u32) -> u32 { + if target_level + 4 <= player_level { + return 300; + } + if target_level + 2 <= player_level { + return 700; + } + if target_level >= player_level + 2 { + return 1_150; + } + 1_000 +} + +fn role_level_offset(role: ProgressionRole) -> i32 { + match role { + ProgressionRole::Ambient => -1, + ProgressionRole::HostileElite => 1, + ProgressionRole::HostileBoss => 2, + _ => 0, + } +} + +fn normalize_required_text( + value: String, + error: ProgressionFieldError, +) -> Result { + normalize_required_string(value).ok_or(error) +} + +impl ChapterPaceBand { + pub fn as_str(&self) -> &'static str { + match self { + Self::OpeningFast => "opening_fast", + Self::Steady => "steady", + Self::Pressure => "pressure", + Self::FinaleDense => "finale_dense", + } + } +} + +impl ProgressionRole { + pub fn as_str(&self) -> &'static str { + match self { + Self::Guide => "guide", + Self::Ambient => "ambient", + Self::Support => "support", + Self::HostileStandard => "hostile_standard", + Self::HostileElite => "hostile_elite", + Self::HostileBoss => "hostile_boss", + Self::Rival => "rival", + } + } +} + +impl LevelProfileSource { + pub fn as_str(&self) -> &'static str { + match self { + Self::ChapterAuto => "chapter_auto", + Self::PresetOverride => "preset_override", + Self::Manual => "manual", + } + } +} + +impl PlayerProgressionGrantSource { + pub fn as_str(&self) -> &'static str { + match self { + Self::Quest => "quest", + Self::HostileNpc => "hostile_npc", + } + } +} + +impl fmt::Display for ProgressionFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingUserId => f.write_str("player_progression.user_id 不能为空"), + Self::MissingChapterId => f.write_str("chapter_progression.chapter_id 不能为空"), + Self::InvalidChapterIndex => { + f.write_str("chapter_progression.chapter_index 必须大于 0") + } + Self::InvalidTotalChapters => f.write_str("chapter_progression.total_chapters 非法"), + Self::InvalidLevel => f.write_str("player_progression.level 非法"), + Self::InvalidEntryExitLevel => { + f.write_str("chapter_progression.entry_level / exit_level 非法") + } + Self::InvalidXpBudget => f.write_str("chapter_progression 经验预算非法"), + Self::InvalidExpectedHostileDefeatCount => { + f.write_str("chapter_progression.expected_hostile_defeat_count 非法") + } + } + } +} + +impl Error for ProgressionFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_initial_player_progression_starts_from_level_one() { + let snapshot = + create_initial_player_progression("user_001".to_string(), 10).expect("should build"); + + assert_eq!(snapshot.level, 1); + assert_eq!(snapshot.total_xp, 0); + assert_eq!(snapshot.current_level_xp, 0); + assert_eq!(snapshot.xp_to_next_level, 60); + assert_eq!(snapshot.last_granted_source, None); + } + + #[test] + fn grant_player_experience_promotes_level_from_quest_reward() { + let current = build_player_progression_snapshot("user_001".to_string(), 50, None, 10, 10) + .expect("current snapshot should build"); + + let next = grant_player_experience( + current, + PlayerProgressionGrantInput { + user_id: "user_001".to_string(), + amount: 40, + source: PlayerProgressionGrantSource::Quest, + updated_at_micros: 20, + }, + ) + .expect("grant should succeed"); + + assert_eq!(next.level, 2); + assert_eq!(next.total_xp, 90); + assert_eq!(next.current_level_xp, 30); + assert_eq!(next.xp_to_next_level, 88); + assert_eq!(next.pending_level_ups, 1); + assert_eq!( + next.last_granted_source, + Some(PlayerProgressionGrantSource::Quest) + ); + } + + #[test] + fn build_level_benchmark_matches_node_curve() { + let benchmark = build_level_benchmark(5); + + assert_eq!(benchmark.level, 5); + assert_eq!(benchmark.xp_to_next_level, 268); + assert_eq!(benchmark.cumulative_xp_required, 472); + assert_eq!(benchmark.reference_strength, 260); + assert_eq!(benchmark.base_hp, 436); + } + + #[test] + fn chapter_boundary_pseudo_level_millis_grows_with_chapter_index() { + let first = resolve_chapter_boundary_pseudo_level_millis(1, 3); + let second = resolve_chapter_boundary_pseudo_level_millis(2, 3); + let third = resolve_chapter_boundary_pseudo_level_millis(3, 3); + + assert!(second > first); + assert!(third > second); + } + + #[test] + fn build_chapter_auto_level_profile_applies_role_offset() { + let standard = build_chapter_auto_level_profile(ChapterAutoLevelProfileInput { + chapter_id: "chapter-3".to_string(), + chapter_index: 3, + entry_pseudo_level_millis: 6_200, + exit_pseudo_level_millis: 8_800, + stage_progress_millis: 1_000, + progression_role: ProgressionRole::HostileStandard, + }) + .expect("standard profile should build"); + let boss = build_chapter_auto_level_profile(ChapterAutoLevelProfileInput { + chapter_id: "chapter-3".to_string(), + chapter_index: 3, + entry_pseudo_level_millis: 6_200, + exit_pseudo_level_millis: 8_800, + stage_progress_millis: 1_000, + progression_role: ProgressionRole::HostileBoss, + }) + .expect("boss profile should build"); + + assert_eq!(standard.progression_role, ProgressionRole::HostileStandard); + assert_eq!(boss.progression_role, ProgressionRole::HostileBoss); + assert!(boss.level >= standard.level + 2); + assert_eq!(boss.source, LevelProfileSource::ChapterAuto); + } + + #[test] + fn build_hostile_experience_reward_matches_existing_fallback_expectation() { + let level_profile = RuntimeEntityLevelProfile { + level: 5, + reference_strength: 260, + chapter_id: None, + chapter_index: None, + progression_role: ProgressionRole::HostileStandard, + source: LevelProfileSource::Manual, + }; + + let reward = build_hostile_experience_reward(5, &level_profile, 1_000, None); + let hp = resolve_hostile_battle_max_hp(&level_profile); + + assert_eq!(reward, 20); + assert_eq!(hp, 48); + } + + #[test] + fn apply_chapter_progression_ledger_accumulates_actual_values() { + let current = build_chapter_progression_snapshot(ChapterProgressionInput { + user_id: "user_001".to_string(), + chapter_id: "chapter-1".to_string(), + chapter_index: 1, + total_chapters: 3, + entry_pseudo_level_millis: 1_000, + exit_pseudo_level_millis: 5_000, + entry_level: 1, + exit_level: 5, + planned_total_xp: 320, + planned_quest_xp: 200, + planned_hostile_xp: 120, + expected_hostile_defeat_count: 3, + level_at_entry: 1, + pace_band: ChapterPaceBand::OpeningFast, + updated_at_micros: 10, + }) + .expect("chapter snapshot should build"); + + let next = apply_chapter_progression_ledger( + current, + ChapterProgressionLedgerInput { + user_id: "user_001".to_string(), + chapter_id: "chapter-1".to_string(), + granted_quest_xp: 60, + granted_hostile_xp: 20, + hostile_defeat_increment: 1, + level_at_exit: Some(2), + updated_at_micros: 20, + }, + ) + .expect("ledger apply should succeed"); + + assert_eq!(next.actual_quest_xp, 60); + assert_eq!(next.actual_hostile_xp, 20); + assert_eq!(next.actual_hostile_defeat_count, 1); + assert_eq!(next.level_at_exit, Some(2)); + } +} diff --git a/server-rs/crates/module-quest/Cargo.toml b/server-rs/crates/module-quest/Cargo.toml new file mode 100644 index 00000000..25d81420 --- /dev/null +++ b/server-rs/crates/module-quest/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-quest" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-quest/README.md b/server-rs/crates/module-quest/README.md index a7c81018..3e993f0c 100644 --- a/server-rs/crates/module-quest/README.md +++ b/server-rs/crates/module-quest/README.md @@ -1,30 +1,55 @@ -# module-quest 独立模块 package 占位说明 +# module-quest 任务运行时模块说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. package 职责 -`module-quest` 是任务运行时模块 package,后续负责: +`module-quest` 是任务运行时模块 package,当前已经承接下面 4 类纯领域能力: -1. `quest_record` 等任务状态模型 -2. 任务进度、任务日志、任务信号处理规则 -3. 与 story、runtime、progression 的任务联动 -4. 与 `apps/api-server` 的任务兼容接口对接 -5. 与 `apps/spacetime-module` 的任务表、reducer、view 聚合对接 +1. `QuestRecord / QuestStep / QuestReward / QuestProgressSignal` 等任务状态模型 +2. 任务 step 归一化、active step 选择、状态收口规则 +3. `accept -> apply signal -> acknowledge completion -> turn in` 的最小任务状态流转 helper +4. 供 `crates/spacetime-module` 聚合表与 reducer 复用的纯 Rust 规则函数 -## 2. 当前阶段说明 +## 2. 当前已落地的真实范围 -当前提交仅完成目录占位,不提前进入任务草案、进度投影与兼容接口实现。 +当前 crate 已经提供: -后续与本 package 直接相关的任务包括: +1. `QuestRecordInput / QuestRecordSnapshot` +2. `QuestProgressSignal / QuestSignalApplyInput / QuestSignalApplyOutcome` +3. `QuestCompletionAckInput / QuestTurnInInput` +4. `build_quest_record_snapshot` +5. `apply_quest_signal` +6. `acknowledge_quest_completion` +7. `turn_in_quest_record` +8. `generate_quest_log_id` -1. 设计 `quest_record` -2. 设计 `apply_quest_signal` -3. 对齐任务进度、日志与兼容输出结构 -4. 接入 runtime 与 story 的任务联动 +补充说明: -## 3. 边界约束 +1. 当前 crate 仍保持“纯领域规则”定位,不直接依赖 Axum、SpacetimeDB reducer context 或外部平台。 +2. `spacetime-types` feature 只用于在 `spacetime-module` 中复用这些类型做表字段和 reducer 输入。 + +## 3. 当前未落地的范围 + +本 crate 当前明确还没有承接: + +1. AI 任务意图生成 +2. 奖励中的货币、好感、情报统一发放 +3. runtime snapshot 投影 +4. story action 跨域编排 +5. Axum 兼容接口 DTO + +补充说明: + +1. 当前 `QuestRewardItem` 的字段已经升级到可无损映射 `inventory_slot` 的粒度,包含 `description / stackable / stack_key / equipment_slot_id`。 +2. 当前 `turn_in_quest` 已在 `crates/spacetime-module` 中完成经验奖励到 `player_progression / chapter_progression` 的最小联动。 +3. 当前 `turn_in_quest` 已在 `crates/spacetime-module` 中完成物品奖励写入 `inventory_slot`;`module-quest` 本身仍只冻结奖励 contract,真实聚合写入继续由 `crates/spacetime-module` 负责。 + +这些能力后续分别由 `module-ai`、`module-runtime`、`module-story`、`api-server` 衔接,不在这里堆成大而全服务。 + +## 4. 边界约束 1. `module-quest` 负责任务状态真相与任务规则,生成型任务草案与外部 AI 编排不直接塞进模块内部。 -2. 任务状态最终回写到 `apps/spacetime-module` 聚合的状态模型中,前端兼容接口由 `apps/api-server` 暴露。 +2. 任务状态最终回写到 `crates/spacetime-module` 聚合的状态模型中,前端兼容接口由 `crates/api-server` 暴露。 3. 任务不能再次散落到 story service、runtime service 或前端临时状态里分别维护。 +4. 当前真实工程口径以 `docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md` 为准。 diff --git a/server-rs/crates/module-quest/src/lib.rs b/server-rs/crates/module-quest/src/lib.rs new file mode 100644 index 00000000..153654ce --- /dev/null +++ b/server-rs/crates/module-quest/src/lib.rs @@ -0,0 +1,1156 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + normalize_optional_string as normalize_shared_optional_string, normalize_required_string, + normalize_string_list as normalize_shared_string_list, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const QUEST_LOG_ID_PREFIX: &str = "questlog_"; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestStatus { + Active, + ReadyToTurnIn, + Completed, + TurnedIn, + Failed, + Expired, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestNarrativeType { + Bounty, + Escort, + Investigation, + Retrieval, + Relationship, + Trial, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestObjectiveKind { + DefeatHostileNpc, + InspectTreasure, + SparWithNpc, + TalkToNpc, + ReachScene, + DeliverItem, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestRewardItemRarity { + Common, + Uncommon, + Rare, + Epic, + Legendary, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestNarrativeOrigin { + AiCompiled, + FallbackBuilder, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestLogEventKind { + Accepted, + Progressed, + Completed, + CompletionAcknowledged, + TurnedIn, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestSignalKind { + HostileNpcDefeated, + TreasureInspected, + NpcSparCompleted, + NpcTalkCompleted, + SceneReached, + ItemDelivered, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestRewardItem { + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: QuestRewardItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestRewardEquipmentSlot { + Weapon, + Armor, + Relic, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestRewardIntel { + pub rumor_text: String, + pub unlocked_scene_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestRewardSnapshot { + pub affinity_bonus: i32, + pub currency: i64, + pub experience: Option, + pub items: Vec, + pub intel: Option, + pub story_hint: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestNarrativeBindingSnapshot { + pub origin: QuestNarrativeOrigin, + pub narrative_type: QuestNarrativeType, + pub dramatic_need: String, + pub issuer_goal: String, + pub player_hook: String, + pub world_reason: String, + pub followup_hooks: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestObjectiveSnapshot { + pub kind: QuestObjectiveKind, + pub target_hostile_npc_id: Option, + pub target_npc_id: Option, + pub target_scene_id: Option, + pub target_item_id: Option, + pub required_count: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestStepSnapshot { + pub step_id: String, + pub kind: QuestObjectiveKind, + pub target_hostile_npc_id: Option, + pub target_npc_id: Option, + pub target_scene_id: Option, + pub target_item_id: Option, + pub required_count: u32, + pub progress: u32, + pub title: String, + pub reveal_text: String, + pub complete_text: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestRecordInput { + pub quest_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub issuer_npc_id: String, + pub issuer_npc_name: String, + pub scene_id: Option, + pub chapter_id: Option, + pub act_id: Option, + pub thread_id: Option, + pub contract_id: Option, + pub title: String, + pub description: String, + pub summary: String, + pub status: QuestStatus, + pub completion_notified: bool, + pub reward: QuestRewardSnapshot, + pub reward_text: String, + pub narrative_binding: QuestNarrativeBindingSnapshot, + pub steps: Vec, + pub active_step_id: Option, + pub visible_stage: u32, + pub hidden_flags: Vec, + pub discovered_fact_ids: Vec, + pub related_carrier_ids: Vec, + pub consequence_ids: Vec, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestRecordSnapshot { + pub quest_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub issuer_npc_id: String, + pub issuer_npc_name: String, + pub scene_id: Option, + pub chapter_id: Option, + pub act_id: Option, + pub thread_id: Option, + pub contract_id: Option, + pub title: String, + pub description: String, + pub summary: String, + pub objective: QuestObjectiveSnapshot, + pub progress: u32, + pub status: QuestStatus, + pub completion_notified: bool, + pub reward: QuestRewardSnapshot, + pub reward_text: String, + pub narrative_binding: QuestNarrativeBindingSnapshot, + pub steps: Vec, + pub active_step_id: Option, + pub visible_stage: u32, + pub hidden_flags: Vec, + pub discovered_fact_ids: Vec, + pub related_carrier_ids: Vec, + pub consequence_ids: Vec, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub completed_at_micros: Option, + pub turned_in_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestHostileNpcDefeatedSignal { + pub scene_id: Option, + pub hostile_npc_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestTreasureInspectedSignal { + pub scene_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestNpcSparCompletedSignal { + pub npc_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestNpcTalkCompletedSignal { + pub npc_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestSceneReachedSignal { + pub scene_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestItemDeliveredSignal { + pub npc_id: String, + pub item_id: String, + pub quantity: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuestProgressSignal { + HostileNpcDefeated(QuestHostileNpcDefeatedSignal), + TreasureInspected(QuestTreasureInspectedSignal), + NpcSparCompleted(QuestNpcSparCompletedSignal), + NpcTalkCompleted(QuestNpcTalkCompletedSignal), + SceneReached(QuestSceneReachedSignal), + ItemDelivered(QuestItemDeliveredSignal), +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestSignalApplyInput { + pub quest_id: String, + pub signal: QuestProgressSignal, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestSignalApplyOutcome { + pub next_record: QuestRecordSnapshot, + pub changed: bool, + pub completed_now: bool, + pub changed_step_id: Option, + pub changed_step_progress: Option, + pub signal_kind: QuestSignalKind, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestCompletionAckInput { + pub quest_id: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestCompletionAckOutcome { + pub next_record: QuestRecordSnapshot, + pub changed: bool, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuestTurnInInput { + pub quest_id: String, + pub turned_in_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QuestRecordFieldError { + MissingQuestId, + MissingRuntimeSessionId, + MissingActorUserId, + MissingIssuerNpcId, + MissingIssuerNpcName, + MissingTitle, + MissingDescription, + MissingRewardText, + EmptySteps, + MissingStepId, + MissingStepTitle, + MissingStepRevealText, + MissingStepCompleteText, + QuestNotReadyToTurnIn, + MissingRewardItemId, + MissingRewardItemCategory, + MissingRewardItemName, + InvalidRewardItemQuantity, + MissingRewardItemStackKey, + RewardEquipmentItemCannotStack, + RewardNonStackableItemMustStaySingleQuantity, +} + +impl QuestStatus { + pub fn is_terminal(self) -> bool { + matches!(self, Self::TurnedIn | Self::Failed | Self::Expired) + } + + pub fn is_reward_ready(self) -> bool { + matches!(self, Self::ReadyToTurnIn | Self::Completed) + } +} + +impl QuestLogEventKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::Progressed => "progressed", + Self::Completed => "completed", + Self::CompletionAcknowledged => "completion_ack", + Self::TurnedIn => "turned_in", + } + } +} + +impl From<&QuestProgressSignal> for QuestSignalKind { + fn from(value: &QuestProgressSignal) -> Self { + match value { + QuestProgressSignal::HostileNpcDefeated(_) => Self::HostileNpcDefeated, + QuestProgressSignal::TreasureInspected(_) => Self::TreasureInspected, + QuestProgressSignal::NpcSparCompleted(_) => Self::NpcSparCompleted, + QuestProgressSignal::NpcTalkCompleted(_) => Self::NpcTalkCompleted, + QuestProgressSignal::SceneReached(_) => Self::SceneReached, + QuestProgressSignal::ItemDelivered(_) => Self::ItemDelivered, + } + } +} + +pub fn normalize_optional_text(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +pub fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} + +pub fn build_quest_record_snapshot( + input: QuestRecordInput, +) -> Result { + let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; + let runtime_session_id = normalize_required_text( + input.runtime_session_id, + QuestRecordFieldError::MissingRuntimeSessionId, + )?; + let actor_user_id = normalize_required_text( + input.actor_user_id, + QuestRecordFieldError::MissingActorUserId, + )?; + let issuer_npc_id = normalize_required_text( + input.issuer_npc_id, + QuestRecordFieldError::MissingIssuerNpcId, + )?; + let issuer_npc_name = normalize_required_text( + input.issuer_npc_name, + QuestRecordFieldError::MissingIssuerNpcName, + )?; + let title = normalize_required_text(input.title, QuestRecordFieldError::MissingTitle)?; + let description = + normalize_required_text(input.description, QuestRecordFieldError::MissingDescription)?; + let reward_text = + normalize_required_text(input.reward_text, QuestRecordFieldError::MissingRewardText)?; + + if input.steps.is_empty() { + return Err(QuestRecordFieldError::EmptySteps); + } + + let steps = input + .steps + .into_iter() + .map(normalize_quest_step) + .collect::, _>>()?; + let active_step = resolve_active_step(&steps, input.active_step_id.as_deref()); + let active_step_id = active_step.map(|step| step.step_id.clone()); + let fallback_step = steps + .last() + .cloned() + .expect("BUG: validated quest steps should not be empty"); + let objective = build_objective_from_step(active_step.unwrap_or(&fallback_step)); + let progress = active_step + .map(|step| step.progress) + .unwrap_or(fallback_step.required_count); + let status = normalize_quest_status(input.status, active_step.is_some()); + let completed_at_micros = if status.is_reward_ready() { + Some(input.created_at_micros) + } else { + None + }; + let turned_in_at_micros = if status == QuestStatus::TurnedIn { + Some(input.created_at_micros) + } else { + None + }; + + Ok(QuestRecordSnapshot { + quest_id, + runtime_session_id, + story_session_id: normalize_optional_text(input.story_session_id), + actor_user_id, + issuer_npc_id, + issuer_npc_name, + scene_id: normalize_optional_text(input.scene_id), + chapter_id: normalize_optional_text(input.chapter_id), + act_id: normalize_optional_text(input.act_id), + thread_id: normalize_optional_text(input.thread_id), + contract_id: normalize_optional_text(input.contract_id), + title, + description: description.clone(), + summary: normalize_optional_text(Some(input.summary)).unwrap_or(description), + objective, + progress, + status, + completion_notified: input.completion_notified || status == QuestStatus::TurnedIn, + reward: normalize_quest_reward(input.reward)?, + reward_text, + narrative_binding: normalize_quest_narrative_binding(input.narrative_binding), + steps, + active_step_id, + visible_stage: input.visible_stage, + hidden_flags: normalize_string_list(input.hidden_flags), + discovered_fact_ids: normalize_string_list(input.discovered_fact_ids), + related_carrier_ids: normalize_string_list(input.related_carrier_ids), + consequence_ids: normalize_string_list(input.consequence_ids), + created_at_micros: input.created_at_micros, + updated_at_micros: input.created_at_micros, + completed_at_micros, + turned_in_at_micros, + }) +} + +// 任务推进只认当前 active step,未命中或已终态时统一保持 no-op,确保 story action 可安全重复派发信号。 +pub fn apply_quest_signal( + current: QuestRecordSnapshot, + input: QuestSignalApplyInput, +) -> Result { + let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; + let signal_kind = QuestSignalKind::from(&input.signal); + + if current.quest_id != quest_id + || current.status.is_terminal() + || current.status.is_reward_ready() + { + return Ok(QuestSignalApplyOutcome { + next_record: current, + changed: false, + completed_now: false, + changed_step_id: None, + changed_step_progress: None, + signal_kind, + }); + } + + let active_step = match resolve_active_step(¤t.steps, current.active_step_id.as_deref()) { + Some(step) => step, + None => { + return Ok(QuestSignalApplyOutcome { + next_record: current, + changed: false, + completed_now: false, + changed_step_id: None, + changed_step_progress: None, + signal_kind, + }); + } + }; + + if !step_matches_signal(active_step, &input.signal) { + return Ok(QuestSignalApplyOutcome { + next_record: current, + changed: false, + completed_now: false, + changed_step_id: None, + changed_step_progress: None, + signal_kind, + }); + } + + let increment = signal_progress_increment(&input.signal); + let mut changed_step_id = None; + let mut changed_step_progress = None; + let next_steps = current + .steps + .iter() + .cloned() + .map(|mut step| { + if step.step_id == active_step.step_id { + let next_progress = (step.progress + increment).min(step.required_count); + if next_progress != step.progress { + step.progress = next_progress; + changed_step_id = Some(step.step_id.clone()); + changed_step_progress = Some(step.progress); + } + } + step + }) + .collect::>(); + + if changed_step_id.is_none() { + return Ok(QuestSignalApplyOutcome { + next_record: current, + changed: false, + completed_now: false, + changed_step_id: None, + changed_step_progress: None, + signal_kind, + }); + } + + let next_active_step = resolve_active_step(&next_steps, None); + let next_active_step_id = next_active_step.map(|step| step.step_id.clone()); + let fallback_step = next_steps + .last() + .cloned() + .expect("BUG: progressed quest should still contain steps"); + let next_status = normalize_quest_status(current.status, next_active_step.is_some()); + let completed_now = !current.status.is_reward_ready() && next_status.is_reward_ready(); + let next_objective = build_objective_from_step(next_active_step.unwrap_or(&fallback_step)); + let next_progress = next_active_step + .map(|step| step.progress) + .unwrap_or(fallback_step.required_count); + + Ok(QuestSignalApplyOutcome { + next_record: QuestRecordSnapshot { + objective: next_objective, + progress: next_progress, + status: next_status, + completion_notified: false, + steps: next_steps, + active_step_id: next_active_step_id, + updated_at_micros: input.updated_at_micros, + completed_at_micros: if completed_now { + Some(input.updated_at_micros) + } else { + current.completed_at_micros + }, + ..current + }, + changed: true, + completed_now, + changed_step_id, + changed_step_progress, + signal_kind, + }) +} + +pub fn acknowledge_quest_completion( + current: QuestRecordSnapshot, + input: QuestCompletionAckInput, +) -> Result { + let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; + + if current.quest_id != quest_id || current.completion_notified { + return Ok(QuestCompletionAckOutcome { + next_record: current, + changed: false, + }); + } + + Ok(QuestCompletionAckOutcome { + next_record: QuestRecordSnapshot { + completion_notified: true, + updated_at_micros: input.updated_at_micros, + ..current + }, + changed: true, + }) +} + +// 任务交付只负责把任务固定到 TurnedIn,不在本轮提前掺入货币、背包和关系奖励发放。 +pub fn turn_in_quest_record( + current: QuestRecordSnapshot, + input: QuestTurnInInput, +) -> Result { + let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; + + if current.quest_id != quest_id || !current.status.is_reward_ready() { + return Err(QuestRecordFieldError::QuestNotReadyToTurnIn); + } + + let steps = current + .steps + .into_iter() + .map(|mut step| { + step.progress = step.required_count; + step + }) + .collect::>(); + let fallback_step = steps + .last() + .cloned() + .expect("BUG: turn in quest should preserve steps"); + + Ok(QuestRecordSnapshot { + objective: build_objective_from_step(&fallback_step), + progress: fallback_step.required_count, + status: QuestStatus::TurnedIn, + completion_notified: true, + steps, + active_step_id: None, + updated_at_micros: input.turned_in_at_micros, + completed_at_micros: current + .completed_at_micros + .or(Some(input.turned_in_at_micros)), + turned_in_at_micros: Some(input.turned_in_at_micros), + ..current + }) +} + +pub fn generate_quest_log_id( + quest_id: &str, + event_kind: QuestLogEventKind, + seed_micros: i64, +) -> String { + format!( + "{}{}_{:x}_{}", + QUEST_LOG_ID_PREFIX, + event_kind.as_str(), + seed_micros, + quest_id + ) +} + +fn normalize_required_text( + value: String, + error: QuestRecordFieldError, +) -> Result { + normalize_required_string(value).ok_or(error) +} + +fn normalize_quest_reward( + mut reward: QuestRewardSnapshot, +) -> Result { + reward.story_hint = normalize_optional_text(reward.story_hint); + reward.intel = reward.intel.and_then(|intel| { + let rumor_text = intel.rumor_text.trim().to_string(); + let unlocked_scene_id = normalize_optional_text(intel.unlocked_scene_id); + if rumor_text.is_empty() { + None + } else { + Some(QuestRewardIntel { + rumor_text, + unlocked_scene_id, + }) + } + }); + reward.items = reward + .items + .into_iter() + .map( + |mut item| -> Result { + item.item_id = normalize_required_text( + item.item_id, + QuestRecordFieldError::MissingRewardItemId, + )?; + item.category = normalize_required_text( + item.category, + QuestRecordFieldError::MissingRewardItemCategory, + )?; + item.name = normalize_required_text( + item.name, + QuestRecordFieldError::MissingRewardItemName, + )?; + item.description = normalize_optional_text(item.description); + if item.quantity == 0 { + return Err(QuestRecordFieldError::InvalidRewardItemQuantity); + } + if !item.stackable && item.quantity != 1 { + return Err( + QuestRecordFieldError::RewardNonStackableItemMustStaySingleQuantity, + ); + } + if item.equipment_slot_id.is_some() && item.stackable { + return Err(QuestRecordFieldError::RewardEquipmentItemCannotStack); + } + item.tags = normalize_string_list(item.tags); + item.stack_key = if item.stackable { + normalize_required_text( + item.stack_key, + QuestRecordFieldError::MissingRewardItemStackKey, + )? + } else { + normalize_optional_text(Some(item.stack_key)) + .unwrap_or_else(|| item.item_id.clone()) + }; + Ok(item) + }, + ) + .collect::, _>>()?; + Ok(reward) +} + +fn normalize_quest_narrative_binding( + mut binding: QuestNarrativeBindingSnapshot, +) -> QuestNarrativeBindingSnapshot { + binding.dramatic_need = binding.dramatic_need.trim().to_string(); + binding.issuer_goal = binding.issuer_goal.trim().to_string(); + binding.player_hook = binding.player_hook.trim().to_string(); + binding.world_reason = binding.world_reason.trim().to_string(); + binding.followup_hooks = normalize_string_list(binding.followup_hooks); + binding +} + +fn normalize_quest_step( + mut step: QuestStepSnapshot, +) -> Result { + step.step_id = normalize_required_text(step.step_id, QuestRecordFieldError::MissingStepId)?; + step.title = normalize_required_text(step.title, QuestRecordFieldError::MissingStepTitle)?; + step.reveal_text = normalize_required_text( + step.reveal_text, + QuestRecordFieldError::MissingStepRevealText, + )?; + step.complete_text = normalize_required_text( + step.complete_text, + QuestRecordFieldError::MissingStepCompleteText, + )?; + step.required_count = step.required_count.max(1); + step.progress = step.progress.min(step.required_count); + step.target_hostile_npc_id = normalize_optional_text(step.target_hostile_npc_id); + step.target_npc_id = normalize_optional_text(step.target_npc_id); + step.target_scene_id = normalize_optional_text(step.target_scene_id); + step.target_item_id = normalize_optional_text(step.target_item_id); + Ok(step) +} + +fn resolve_active_step<'a>( + steps: &'a [QuestStepSnapshot], + active_step_id: Option<&str>, +) -> Option<&'a QuestStepSnapshot> { + if let Some(active_step_id) = active_step_id { + let active_step_id = active_step_id.trim(); + if !active_step_id.is_empty() { + if let Some(step) = steps + .iter() + .find(|step| step.step_id == active_step_id && step.progress < step.required_count) + { + return Some(step); + } + } + } + + steps + .iter() + .find(|step| step.progress < step.required_count) +} + +fn build_objective_from_step(step: &QuestStepSnapshot) -> QuestObjectiveSnapshot { + QuestObjectiveSnapshot { + kind: step.kind, + target_hostile_npc_id: step.target_hostile_npc_id.clone(), + target_npc_id: step.target_npc_id.clone(), + target_scene_id: step.target_scene_id.clone(), + target_item_id: step.target_item_id.clone(), + required_count: step.required_count, + } +} + +fn normalize_quest_status(status: QuestStatus, has_active_step: bool) -> QuestStatus { + if status.is_terminal() { + return status; + } + + if has_active_step { + QuestStatus::Active + } else if status == QuestStatus::ReadyToTurnIn { + QuestStatus::ReadyToTurnIn + } else { + QuestStatus::Completed + } +} + +fn step_matches_signal(step: &QuestStepSnapshot, signal: &QuestProgressSignal) -> bool { + match signal { + QuestProgressSignal::HostileNpcDefeated(payload) => { + step.kind == QuestObjectiveKind::DefeatHostileNpc + && step.target_hostile_npc_id.as_deref() == Some(payload.hostile_npc_id.as_str()) + && step + .target_scene_id + .as_deref() + .is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone()) + } + QuestProgressSignal::TreasureInspected(payload) => { + step.kind == QuestObjectiveKind::InspectTreasure + && step + .target_scene_id + .as_deref() + .is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone()) + } + QuestProgressSignal::NpcSparCompleted(payload) => { + step.kind == QuestObjectiveKind::SparWithNpc + && step.target_npc_id.as_deref() == Some(payload.npc_id.as_str()) + } + QuestProgressSignal::NpcTalkCompleted(payload) => { + step.kind == QuestObjectiveKind::TalkToNpc + && step.target_npc_id.as_deref() == Some(payload.npc_id.as_str()) + } + QuestProgressSignal::SceneReached(payload) => { + step.kind == QuestObjectiveKind::ReachScene + && step.target_scene_id.as_deref() == Some(payload.scene_id.as_str()) + } + QuestProgressSignal::ItemDelivered(payload) => { + step.kind == QuestObjectiveKind::DeliverItem + && step.target_npc_id.as_deref() == Some(payload.npc_id.as_str()) + && step.target_item_id.as_deref() == Some(payload.item_id.as_str()) + } + } +} + +fn signal_progress_increment(signal: &QuestProgressSignal) -> u32 { + match signal { + QuestProgressSignal::ItemDelivered(payload) => payload.quantity.max(1), + _ => 1, + } +} + +impl fmt::Display for QuestRecordFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingQuestId => f.write_str("quest_record.quest_id 不能为空"), + Self::MissingRuntimeSessionId => { + f.write_str("quest_record.runtime_session_id 不能为空") + } + Self::MissingActorUserId => f.write_str("quest_record.actor_user_id 不能为空"), + Self::MissingIssuerNpcId => f.write_str("quest_record.issuer_npc_id 不能为空"), + Self::MissingIssuerNpcName => f.write_str("quest_record.issuer_npc_name 不能为空"), + Self::MissingTitle => f.write_str("quest_record.title 不能为空"), + Self::MissingDescription => f.write_str("quest_record.description 不能为空"), + Self::MissingRewardText => f.write_str("quest_record.reward_text 不能为空"), + Self::EmptySteps => f.write_str("quest_record.steps 至少需要一条 step"), + Self::MissingStepId => f.write_str("quest_step.step_id 不能为空"), + Self::MissingStepTitle => f.write_str("quest_step.title 不能为空"), + Self::MissingStepRevealText => f.write_str("quest_step.reveal_text 不能为空"), + Self::MissingStepCompleteText => f.write_str("quest_step.complete_text 不能为空"), + Self::QuestNotReadyToTurnIn => f.write_str("当前任务还没有进入可交付状态"), + Self::MissingRewardItemId => f.write_str("quest_reward.items[].item_id 不能为空"), + Self::MissingRewardItemCategory => { + f.write_str("quest_reward.items[].category 不能为空") + } + Self::MissingRewardItemName => f.write_str("quest_reward.items[].name 不能为空"), + Self::InvalidRewardItemQuantity => { + f.write_str("quest_reward.items[].quantity 必须大于 0") + } + Self::MissingRewardItemStackKey => { + f.write_str("quest_reward.items[].stack_key 不能为空") + } + Self::RewardEquipmentItemCannotStack => { + f.write_str("quest_reward.items[] 可装备物品不能标记为 stackable") + } + Self::RewardNonStackableItemMustStaySingleQuantity => { + f.write_str("quest_reward.items[] 不可堆叠物品必须固定为单槽位单数量") + } + } + } +} + +impl Error for QuestRecordFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_test_reward() -> QuestRewardSnapshot { + QuestRewardSnapshot { + affinity_bonus: 12, + currency: 72, + experience: Some(35), + items: vec![QuestRewardItem { + item_id: "reward_item_01".to_string(), + category: "补给".to_string(), + name: "常备药包".to_string(), + description: Some("带着草药苦味的便携补给。".to_string()), + quantity: 1, + rarity: QuestRewardItemRarity::Rare, + tags: vec!["healing".to_string()], + stackable: true, + stack_key: "reward_item_01".to_string(), + equipment_slot_id: None, + }], + intel: Some(QuestRewardIntel { + rumor_text: "旧桥下面还埋着另一条线索。".to_string(), + unlocked_scene_id: Some("scene_old_bridge".to_string()), + }), + story_hint: Some("委托人的态度明显缓和了下来。".to_string()), + } + } + + fn build_test_binding() -> QuestNarrativeBindingSnapshot { + QuestNarrativeBindingSnapshot { + origin: QuestNarrativeOrigin::FallbackBuilder, + narrative_type: QuestNarrativeType::Investigation, + dramatic_need: "委托人需要先确认遗迹异动是真是假。".to_string(), + issuer_goal: "摸清遗迹周边的情况。".to_string(), + player_hook: "玩家正好就在现场。".to_string(), + world_reason: "最近的异常都收束到了这片区域。".to_string(), + followup_hooks: vec!["遗迹后方还有更深的入口".to_string()], + } + } + + fn build_test_steps() -> Vec { + vec![ + QuestStepSnapshot { + step_id: "step_investigate".to_string(), + kind: QuestObjectiveKind::InspectTreasure, + target_hostile_npc_id: None, + target_npc_id: None, + target_scene_id: Some("scene_ruins".to_string()), + target_item_id: None, + required_count: 1, + progress: 0, + title: "调查遗迹".to_string(), + reveal_text: "先去把遗迹边缘的异动看清楚。".to_string(), + complete_text: "遗迹调查已经完成。".to_string(), + }, + QuestStepSnapshot { + step_id: "step_report_back".to_string(), + kind: QuestObjectiveKind::TalkToNpc, + target_hostile_npc_id: None, + target_npc_id: Some("npc_scholar_lin".to_string()), + target_scene_id: None, + target_item_id: None, + required_count: 1, + progress: 0, + title: "回去汇报".to_string(), + reveal_text: "回去把结果告诉林朔。".to_string(), + complete_text: "林朔已经收到了你的回报。".to_string(), + }, + ] + } + + fn build_test_record() -> QuestRecordSnapshot { + build_quest_record_snapshot(QuestRecordInput { + quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: Some("storysess_001".to_string()), + actor_user_id: "user_001".to_string(), + issuer_npc_id: "npc_scholar_lin".to_string(), + issuer_npc_name: "林朔".to_string(), + scene_id: Some("scene_ruins".to_string()), + chapter_id: Some("chapter_01".to_string()), + act_id: Some("act_01".to_string()), + thread_id: Some("thread_ruins".to_string()), + contract_id: Some("contract_01".to_string()), + title: "遗迹异动".to_string(), + description: "林朔希望你先去确认遗迹外缘的异常。".to_string(), + summary: "调查遗迹异动,再回去汇报".to_string(), + status: QuestStatus::Active, + completion_notified: false, + reward: build_test_reward(), + reward_text: "完成后可获得赏金、补给和线索。".to_string(), + narrative_binding: build_test_binding(), + steps: build_test_steps(), + active_step_id: Some("step_investigate".to_string()), + visible_stage: 0, + hidden_flags: vec!["ruins".to_string()], + discovered_fact_ids: vec![], + related_carrier_ids: vec![], + consequence_ids: vec![], + created_at_micros: 1_713_680_000_000_000, + }) + .expect("test quest record should build") + } + + #[test] + fn build_quest_record_snapshot_rejects_empty_steps() { + let error = build_quest_record_snapshot(QuestRecordInput { + quest_id: "quest_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: None, + actor_user_id: "user_001".to_string(), + issuer_npc_id: "npc_001".to_string(), + issuer_npc_name: "林朔".to_string(), + scene_id: None, + chapter_id: None, + act_id: None, + thread_id: None, + contract_id: None, + title: "遗迹异动".to_string(), + description: "测试".to_string(), + summary: String::new(), + status: QuestStatus::Active, + completion_notified: false, + reward: build_test_reward(), + reward_text: "完成后可获得赏金。".to_string(), + narrative_binding: build_test_binding(), + steps: vec![], + active_step_id: None, + visible_stage: 0, + hidden_flags: vec![], + discovered_fact_ids: vec![], + related_carrier_ids: vec![], + consequence_ids: vec![], + created_at_micros: 1, + }) + .expect_err("empty steps should fail"); + + assert_eq!(error, QuestRecordFieldError::EmptySteps); + } + + #[test] + fn apply_quest_signal_advances_only_current_active_step() { + let current = build_test_record(); + let outcome = apply_quest_signal( + current, + QuestSignalApplyInput { + quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), + signal: QuestProgressSignal::TreasureInspected(QuestTreasureInspectedSignal { + scene_id: Some("scene_ruins".to_string()), + }), + updated_at_micros: 1_713_680_000_100_000, + }, + ) + .expect("signal apply should succeed"); + + assert!(outcome.changed); + assert!(!outcome.completed_now); + assert_eq!(outcome.next_record.status, QuestStatus::Active); + assert_eq!( + outcome.next_record.active_step_id.as_deref(), + Some("step_report_back") + ); + assert_eq!(outcome.changed_step_id.as_deref(), Some("step_investigate")); + assert_eq!(outcome.changed_step_progress, Some(1)); + } + + #[test] + fn apply_quest_signal_marks_completed_when_last_step_finishes() { + let current = apply_quest_signal( + build_test_record(), + QuestSignalApplyInput { + quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), + signal: QuestProgressSignal::TreasureInspected(QuestTreasureInspectedSignal { + scene_id: Some("scene_ruins".to_string()), + }), + updated_at_micros: 20, + }, + ) + .expect("first step should succeed") + .next_record; + + let outcome = apply_quest_signal( + current, + QuestSignalApplyInput { + quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), + signal: QuestProgressSignal::NpcTalkCompleted(QuestNpcTalkCompletedSignal { + npc_id: "npc_scholar_lin".to_string(), + }), + updated_at_micros: 30, + }, + ) + .expect("last step should complete"); + + assert!(outcome.changed); + assert!(outcome.completed_now); + assert_eq!(outcome.next_record.status, QuestStatus::Completed); + assert_eq!(outcome.next_record.active_step_id, None); + assert_eq!(outcome.next_record.completed_at_micros, Some(30)); + } + + #[test] + fn turn_in_quest_record_moves_status_to_turned_in() { + let completed = apply_quest_signal( + apply_quest_signal( + build_test_record(), + QuestSignalApplyInput { + quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), + signal: QuestProgressSignal::TreasureInspected(QuestTreasureInspectedSignal { + scene_id: Some("scene_ruins".to_string()), + }), + updated_at_micros: 20, + }, + ) + .expect("first step should succeed") + .next_record, + QuestSignalApplyInput { + quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), + signal: QuestProgressSignal::NpcTalkCompleted(QuestNpcTalkCompletedSignal { + npc_id: "npc_scholar_lin".to_string(), + }), + updated_at_micros: 30, + }, + ) + .expect("second step should succeed") + .next_record; + + let turned_in = turn_in_quest_record( + completed, + QuestTurnInInput { + quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), + turned_in_at_micros: 40, + }, + ) + .expect("completed quest should turn in"); + + assert_eq!(turned_in.status, QuestStatus::TurnedIn); + assert_eq!(turned_in.turned_in_at_micros, Some(40)); + assert!(turned_in.completion_notified); + assert!( + turned_in + .steps + .iter() + .all(|step| step.progress == step.required_count) + ); + } +} diff --git a/server-rs/crates/module-runtime-item/Cargo.toml b/server-rs/crates/module-runtime-item/Cargo.toml new file mode 100644 index 00000000..7378e261 --- /dev/null +++ b/server-rs/crates/module-runtime-item/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "module-runtime-item" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +module-inventory = { path = "../module-inventory", default-features = false } +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-runtime-item/README.md b/server-rs/crates/module-runtime-item/README.md index 1dbbfb90..89543fcc 100644 --- a/server-rs/crates/module-runtime-item/README.md +++ b/server-rs/crates/module-runtime-item/README.md @@ -1,30 +1,33 @@ -# module-runtime-item 独立模块 package 占位说明 +# module-runtime-item 运行时宝藏模块说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. package 职责 -`module-runtime-item` 是运行时物品模块 package,后续负责: +`module-runtime-item` 是运行时物品子域 crate,当前已经承接宝藏奖励快照与背包桥接相关的纯领域能力: -1. `treasure_record` 等运行时物品与宝藏状态模型 -2. 奖励解析、宝藏逻辑、运行时物品结算规则 -3. 与 story、inventory、quest 的运行时物品联动 -4. 与 `apps/api-server` 的运行时物品兼容接口对接 -5. 与 `apps/spacetime-module` 的运行时物品表、reducer、view 聚合对接 +1. `TreasureResolveInput / TreasureRecordSnapshot / TreasureInteractionAction` +2. 宝藏奖励物品 `RuntimeItemRewardItemSnapshot` 的字段校验与归一化 +3. `build_treasure_record_snapshot` +4. `build_inventory_item_snapshot_from_reward_item` +5. 供 `crates/spacetime-module` 聚合表与 reducer 复用的纯 Rust 规则函数 ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入奖励解析、意图生成与兼容接口实现。 +当前 crate 已经提供: -后续与本 package 直接相关的任务包括: +1. `treasure_record` 首版领域 contract +2. 运行时奖励物品到 `module-inventory::InventoryItemSnapshot` 的显式映射 helper +3. 中文错误语义与最小测试 +4. 供 `crates/spacetime-module` 在 `resolve_treasure_interaction` 中把宝藏奖励同步写入 `inventory_slot` 的桥接规则 -1. 设计 `treasure_record` -2. 设计运行时物品结算与宝藏交互 reducer -3. 对齐奖励、宝藏、patch 与兼容输出结构 -4. 接入 story action 主循环的运行时物品联动 +补充说明: + +1. 当前 crate 仍保持“纯领域规则”定位,不直接依赖 SpacetimeDB reducer context 或 Axum。 +2. `spacetime-types` feature 只用于在 `spacetime-module` 中复用这些类型做表字段和 reducer 输入。 ## 3. 边界约束 -1. `module-runtime-item` 负责运行时物品状态真相与奖励规则,生成型物品意图与外部 AI 编排不直接塞进模块内部。 -2. 奖励与宝藏状态最终回写到 `apps/spacetime-module` 聚合的状态模型中,前端兼容接口由 `apps/api-server` 暴露。 -3. 运行时物品逻辑不能再次散落到 story、inventory 或 route handler 中分别维护。 +1. 当前 crate 只负责任务奖励/宝藏奖励与 inventory 的字段桥接,不承接 story 编排或前端 DTO。 +2. 宝藏、任务、交易等不同奖励入口若要写入 `inventory_slot`,优先复用这里与 `module-inventory` 的桥接口径,而不是各自重新拼装物品字段。 +3. 当前真实工程口径以 `docs/technical/M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md` 为准。 diff --git a/server-rs/crates/module-runtime-item/src/lib.rs b/server-rs/crates/module-runtime-item/src/lib.rs new file mode 100644 index 00000000..792c6209 --- /dev/null +++ b/server-rs/crates/module-runtime-item/src/lib.rs @@ -0,0 +1,409 @@ +use std::{error::Error, fmt}; + +use module_inventory::{ + InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind, +}; +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + normalize_optional_string as normalize_shared_optional_string, normalize_required_string, + normalize_string_list as normalize_shared_string_list, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const TREASURE_RECORD_ID_PREFIX: &str = "treasure_"; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TreasureInteractionAction { + Inspect, + Leave, + Secure, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeItemRewardItemSnapshot { + pub item_id: String, + pub category: String, + pub item_name: String, + pub description: Option, + pub quantity: u32, + pub rarity: RuntimeItemRewardItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeItemRewardItemRarity { + Common, + Uncommon, + Rare, + Epic, + Legendary, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeItemEquipmentSlot { + Weapon, + Armor, + Relic, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TreasureResolveInput { + pub treasure_record_id: String, + pub runtime_session_id: String, + pub story_session_id: String, + pub actor_user_id: String, + pub encounter_id: String, + pub encounter_name: String, + pub scene_id: Option, + pub scene_name: Option, + pub action: TreasureInteractionAction, + pub reward_items: Vec, + pub reward_hp: u32, + pub reward_mana: u32, + pub reward_currency: u32, + pub story_hint: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TreasureRecordSnapshot { + pub treasure_record_id: String, + pub runtime_session_id: String, + pub story_session_id: String, + pub actor_user_id: String, + pub encounter_id: String, + pub encounter_name: String, + pub scene_id: Option, + pub scene_name: Option, + pub action: TreasureInteractionAction, + pub reward_items: Vec, + pub reward_hp: u32, + pub reward_mana: u32, + pub reward_currency: u32, + pub story_hint: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TreasureRecordProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TreasureFieldError { + MissingTreasureRecordId, + MissingRuntimeSessionId, + MissingStorySessionId, + MissingActorUserId, + MissingEncounterId, + MissingEncounterName, + MissingRewardItemId, + MissingRewardItemCategory, + MissingRewardItemName, + InvalidRewardItemQuantity, + MissingRewardItemStackKey, + RewardEquipmentItemCannotStack, + RewardNonStackableItemMustStaySingleQuantity, +} + +pub fn build_treasure_record_snapshot( + input: TreasureResolveInput, +) -> Result { + validate_treasure_input(&input)?; + + Ok(TreasureRecordSnapshot { + treasure_record_id: input.treasure_record_id, + runtime_session_id: input.runtime_session_id, + story_session_id: input.story_session_id, + actor_user_id: input.actor_user_id, + encounter_id: input.encounter_id, + encounter_name: input.encounter_name, + scene_id: normalize_optional_value(input.scene_id), + scene_name: normalize_optional_value(input.scene_name), + action: input.action, + reward_items: input + .reward_items + .into_iter() + .map(normalize_reward_item) + .collect::, _>>()?, + reward_hp: input.reward_hp, + reward_mana: input.reward_mana, + reward_currency: input.reward_currency, + story_hint: normalize_optional_value(input.story_hint), + created_at_micros: input.created_at_micros, + updated_at_micros: input.updated_at_micros, + }) +} + +pub fn build_inventory_item_snapshot_from_reward_item( + treasure_record_id: &str, + reward_item: RuntimeItemRewardItemSnapshot, +) -> Result { + let treasure_record_id = normalize_required_value( + treasure_record_id.to_string(), + TreasureFieldError::MissingTreasureRecordId, + )?; + let reward_item = normalize_reward_item(reward_item)?; + + Ok(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_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_reward_item_equipment_slot), + source_kind: InventoryItemSourceKind::TreasureReward, + source_reference_id: Some(treasure_record_id), + }) +} + +pub fn normalize_reward_item_snapshot( + reward_item: RuntimeItemRewardItemSnapshot, +) -> Result { + normalize_reward_item(reward_item) +} + +fn validate_treasure_input(input: &TreasureResolveInput) -> Result<(), TreasureFieldError> { + if input.treasure_record_id.trim().is_empty() { + return Err(TreasureFieldError::MissingTreasureRecordId); + } + if input.runtime_session_id.trim().is_empty() { + return Err(TreasureFieldError::MissingRuntimeSessionId); + } + if input.story_session_id.trim().is_empty() { + return Err(TreasureFieldError::MissingStorySessionId); + } + if input.actor_user_id.trim().is_empty() { + return Err(TreasureFieldError::MissingActorUserId); + } + if input.encounter_id.trim().is_empty() { + return Err(TreasureFieldError::MissingEncounterId); + } + if input.encounter_name.trim().is_empty() { + return Err(TreasureFieldError::MissingEncounterName); + } + + Ok(()) +} + +fn normalize_optional_value(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +fn normalize_reward_item( + mut item: RuntimeItemRewardItemSnapshot, +) -> Result { + item.item_id = normalize_required_value(item.item_id, TreasureFieldError::MissingRewardItemId)?; + item.category = + normalize_required_value(item.category, TreasureFieldError::MissingRewardItemCategory)?; + item.item_name = + normalize_required_value(item.item_name, TreasureFieldError::MissingRewardItemName)?; + item.description = normalize_optional_value(item.description); + if item.quantity == 0 { + return Err(TreasureFieldError::InvalidRewardItemQuantity); + } + if !item.stackable && item.quantity != 1 { + return Err(TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity); + } + if item.equipment_slot_id.is_some() && item.stackable { + return Err(TreasureFieldError::RewardEquipmentItemCannotStack); + } + item.tags = normalize_string_list(item.tags); + item.stack_key = if item.stackable { + normalize_required_value( + item.stack_key, + TreasureFieldError::MissingRewardItemStackKey, + )? + } else { + normalize_optional_value(Some(item.stack_key)).unwrap_or_else(|| item.item_id.clone()) + }; + Ok(item) +} + +fn normalize_required_value( + value: String, + error: TreasureFieldError, +) -> Result { + normalize_required_string(value).ok_or(error) +} + +fn normalize_string_list(values: Vec) -> Vec { + normalize_shared_string_list(values) +} + +fn map_reward_item_rarity(rarity: RuntimeItemRewardItemRarity) -> InventoryItemRarity { + match rarity { + RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common, + RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, + RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare, + RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic, + RuntimeItemRewardItemRarity::Legendary => InventoryItemRarity::Legendary, + } +} + +fn map_reward_item_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot { + match slot { + RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, + RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, + RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, + } +} + +impl fmt::Display for TreasureFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingTreasureRecordId => { + f.write_str("treasure_record.treasure_record_id 不能为空") + } + Self::MissingRuntimeSessionId => { + f.write_str("treasure_record.runtime_session_id 不能为空") + } + Self::MissingStorySessionId => f.write_str("treasure_record.story_session_id 不能为空"), + Self::MissingActorUserId => f.write_str("treasure_record.actor_user_id 不能为空"), + Self::MissingEncounterId => f.write_str("treasure_record.encounter_id 不能为空"), + Self::MissingEncounterName => f.write_str("treasure_record.encounter_name 不能为空"), + Self::MissingRewardItemId => { + f.write_str("treasure_record.reward_items[].item_id 不能为空") + } + Self::MissingRewardItemCategory => { + f.write_str("treasure_record.reward_items[].category 不能为空") + } + Self::MissingRewardItemName => { + f.write_str("treasure_record.reward_items[].item_name 不能为空") + } + Self::InvalidRewardItemQuantity => { + f.write_str("treasure_record.reward_items[].quantity 必须大于 0") + } + Self::MissingRewardItemStackKey => { + f.write_str("treasure_record.reward_items[].stack_key 不能为空") + } + Self::RewardEquipmentItemCannotStack => { + f.write_str("treasure_record.reward_items[] 可装备物品不能标记为 stackable") + } + Self::RewardNonStackableItemMustStaySingleQuantity => { + f.write_str("treasure_record.reward_items[] 不可堆叠物品必须固定为单槽位单数量") + } + } + } +} + +impl Error for TreasureFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_treasure_record_snapshot_accepts_minimal_contract() { + let snapshot = build_treasure_record_snapshot(TreasureResolveInput { + treasure_record_id: "treasure_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + story_session_id: "storysess_001".to_string(), + actor_user_id: "user_001".to_string(), + encounter_id: "enc_001".to_string(), + encounter_name: "旧钟楼暗格".to_string(), + scene_id: Some("scene_001".to_string()), + scene_name: Some("旧钟楼".to_string()), + action: TreasureInteractionAction::Inspect, + reward_items: vec![RuntimeItemRewardItemSnapshot { + item_id: "item_001".to_string(), + category: "遗物".to_string(), + item_name: "铜钥残片".to_string(), + description: Some("带着旧钟楼铜锈味的钥片。".to_string()), + quantity: 1, + rarity: RuntimeItemRewardItemRarity::Rare, + tags: vec!["钥片".to_string(), "钟楼".to_string()], + stackable: false, + stack_key: String::new(), + equipment_slot_id: None, + }], + reward_hp: 3, + reward_mana: 2, + reward_currency: 10, + story_hint: Some("发现了旧机关的回响。".to_string()), + created_at_micros: 10, + updated_at_micros: 10, + }) + .expect("minimal treasure snapshot should succeed"); + + assert_eq!(snapshot.treasure_record_id, "treasure_001"); + assert_eq!(snapshot.reward_items.len(), 1); + } + + #[test] + fn build_inventory_item_snapshot_from_reward_item_keeps_inventory_fields() { + let item = build_inventory_item_snapshot_from_reward_item( + "treasure_001", + RuntimeItemRewardItemSnapshot { + item_id: "item_001".to_string(), + category: "遗物".to_string(), + item_name: "铜钥残片".to_string(), + description: Some("带着旧钟楼铜锈味的钥片。".to_string()), + quantity: 1, + rarity: RuntimeItemRewardItemRarity::Rare, + tags: vec!["钥片".to_string(), "钟楼".to_string()], + stackable: false, + stack_key: String::new(), + equipment_slot_id: Some(RuntimeItemEquipmentSlot::Relic), + }, + ) + .expect("reward item should convert into inventory item"); + + assert_eq!(item.item_id, "item_001"); + assert_eq!(item.category, "遗物"); + assert_eq!(item.name, "铜钥残片"); + assert_eq!(item.rarity, InventoryItemRarity::Rare); + assert_eq!(item.stack_key, "item_001"); + assert_eq!(item.equipment_slot_id, Some(InventoryEquipmentSlot::Relic)); + assert_eq!(item.source_kind, InventoryItemSourceKind::TreasureReward); + assert_eq!(item.source_reference_id, Some("treasure_001".to_string())); + } + + #[test] + fn normalize_reward_item_snapshot_trims_and_fills_stack_key() { + let item = normalize_reward_item_snapshot(RuntimeItemRewardItemSnapshot { + item_id: " item_001 ".to_string(), + category: " 遗物 ".to_string(), + item_name: " 铜钥残片 ".to_string(), + description: Some(" 带着旧钟楼铜锈味的钥片。 ".to_string()), + quantity: 1, + rarity: RuntimeItemRewardItemRarity::Rare, + tags: vec![" 钥片 ".to_string(), "".to_string(), "钟楼".to_string()], + stackable: false, + stack_key: String::new(), + equipment_slot_id: None, + }) + .expect("reward item should normalize"); + + assert_eq!(item.item_id, "item_001"); + assert_eq!(item.category, "遗物"); + assert_eq!(item.item_name, "铜钥残片"); + assert_eq!( + item.description.as_deref(), + Some("带着旧钟楼铜锈味的钥片。") + ); + assert_eq!(item.tags, vec!["钥片".to_string(), "钟楼".to_string()]); + assert_eq!(item.stack_key, "item_001"); + } +} diff --git a/server-rs/crates/module-runtime/Cargo.toml b/server-rs/crates/module-runtime/Cargo.toml new file mode 100644 index 00000000..5f3c5ac0 --- /dev/null +++ b/server-rs/crates/module-runtime/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "module-runtime" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } +time = { version = "0.3", features = ["formatting", "parsing"] } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs new file mode 100644 index 00000000..6ceac34a --- /dev/null +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -0,0 +1,980 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + format_rfc3339 as format_shared_rfc3339, normalize_optional_string, normalize_required_string, + parse_rfc3339 as parse_shared_rfc3339, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; +use time::OffsetDateTime; + +pub const DEFAULT_MUSIC_VOLUME: f32 = 0.42; +pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::Light; +pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家"; +pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100; +pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50; + +// 运行时设置目前只冻结 light/dark 两种主题,避免各层散落字符串字面量。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimePlatformTheme { + Light, + Dark, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeSettings { + pub music_volume: f32, + pub platform_theme: RuntimePlatformTheme, +} + +// 浏览历史沿用平台已有的六种世界主题,但独立冻结在 runtime 领域内,避免反向耦合创作域 crate。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeBrowseHistoryThemeMode { + Martial, + Arcane, + Machina, + Tide, + Rift, + Mythic, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeSettingSnapshot { + pub user_id: String, + pub music_volume: f32, + pub platform_theme: RuntimePlatformTheme, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeSettingProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeSettingGetInput { + pub user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeSettingUpsertInput { + pub user_id: String, + pub music_volume: f32, + pub platform_theme: RuntimePlatformTheme, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeBrowseHistorySnapshot { + pub browse_history_id: String, + pub user_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: RuntimeBrowseHistoryThemeMode, + pub author_display_name: String, + pub visited_at_micros: i64, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeBrowseHistoryProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeBrowseHistoryListInput { + pub user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeBrowseHistoryClearInput { + pub user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeBrowseHistoryWriteInput { + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: Option, + pub summary_text: Option, + pub cover_image_src: Option, + pub theme_mode: Option, + pub author_display_name: Option, + pub visited_at: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeBrowseHistorySyncInput { + pub user_id: String, + pub entries: Vec, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileDashboardSnapshot { + pub user_id: String, + pub wallet_balance: u64, + pub total_play_time_ms: u64, + pub played_world_count: u32, + pub updated_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileDashboardProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileDashboardGetInput { + pub user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileWalletLedgerSourceType { + SnapshotSync, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletLedgerEntrySnapshot { + pub wallet_ledger_id: String, + pub user_id: String, + pub amount_delta: i64, + pub balance_after: u64, + pub source_type: RuntimeProfileWalletLedgerSourceType, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletLedgerProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletLedgerListInput { + pub user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfilePlayedWorldSnapshot { + pub played_world_id: String, + pub user_id: String, + pub world_key: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub world_type: Option, + pub world_title: String, + pub world_subtitle: String, + pub first_played_at_micros: i64, + pub last_played_at_micros: i64, + pub last_observed_play_time_ms: u64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfilePlayStatsSnapshot { + pub user_id: String, + pub total_play_time_ms: u64, + pub played_works: Vec, + pub updated_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfilePlayStatsProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfilePlayStatsGetInput { + pub user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RuntimeSettingsFieldError { + MissingUserId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RuntimeBrowseHistoryFieldError { + MissingUserId, + TooManyEntries, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RuntimeProfileFieldError { + MissingUserId, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeBrowseHistoryPreparedEntry { + pub browse_history_id: String, + pub user_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: RuntimeBrowseHistoryThemeMode, + pub author_display_name: String, + pub visited_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeBrowseHistoryRecord { + pub browse_history_id: String, + pub user_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: RuntimeBrowseHistoryThemeMode, + pub author_display_name: String, + pub visited_at: String, + pub visited_at_micros: i64, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl RuntimePlatformTheme { + pub fn as_str(&self) -> &'static str { + match self { + Self::Light => "light", + Self::Dark => "dark", + } + } + + pub fn from_client_str(value: &str) -> Self { + if value.trim().eq_ignore_ascii_case("dark") { + Self::Dark + } else { + Self::Light + } + } +} + +impl RuntimeBrowseHistoryThemeMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Martial => "martial", + Self::Arcane => "arcane", + Self::Machina => "machina", + Self::Tide => "tide", + Self::Rift => "rift", + Self::Mythic => "mythic", + } + } + + // 浏览历史主题沿用旧 Node 逻辑:不做严格校验,未知值统一回退到 mythic。 + pub fn from_client_str(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "martial" => Self::Martial, + "arcane" => Self::Arcane, + "machina" => Self::Machina, + "tide" => Self::Tide, + "rift" => Self::Rift, + _ => Self::Mythic, + } + } +} + +impl RuntimeSettings { + pub fn defaults() -> Self { + Self { + music_volume: DEFAULT_MUSIC_VOLUME, + platform_theme: DEFAULT_PLATFORM_THEME, + } + } + + // 与旧 Node 仓储保持一致:音量 clamp 到 0~1,主题除 dark 外统一回退到 light。 + pub fn normalized(music_volume: f32, platform_theme: RuntimePlatformTheme) -> Self { + Self { + music_volume: music_volume.clamp(0.0, 1.0), + platform_theme, + } + } +} + +// 统一把共享必填字符串归一化映射到 runtime 各自的字段错误,避免输入构造函数重复 trim + 判空。 +fn normalize_runtime_settings_user_id( + user_id: String, +) -> Result { + normalize_required_string(user_id).ok_or(RuntimeSettingsFieldError::MissingUserId) +} + +fn normalize_runtime_browse_history_user_id( + user_id: String, +) -> Result { + normalize_required_string(user_id).ok_or(RuntimeBrowseHistoryFieldError::MissingUserId) +} + +fn normalize_runtime_profile_user_id(user_id: String) -> Result { + normalize_required_string(user_id).ok_or(RuntimeProfileFieldError::MissingUserId) +} + +pub fn build_runtime_setting_get_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_settings_user_id(user_id)?; + Ok(RuntimeSettingGetInput { user_id }) +} + +pub fn build_runtime_setting_upsert_input( + user_id: String, + music_volume: f32, + platform_theme: RuntimePlatformTheme, + updated_at_micros: i64, +) -> Result { + let user_id = normalize_runtime_settings_user_id(user_id)?; + let normalized = RuntimeSettings::normalized(music_volume, platform_theme); + + Ok(RuntimeSettingUpsertInput { + user_id, + music_volume: normalized.music_volume, + platform_theme: normalized.platform_theme, + updated_at_micros, + }) +} + +pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord { + RuntimeSettingsRecord { + user_id: snapshot.user_id, + music_volume: snapshot.music_volume, + platform_theme: snapshot.platform_theme, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeSettingsRecord { + pub user_id: String, + pub music_volume: f32, + pub platform_theme: RuntimePlatformTheme, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileDashboardRecord { + pub user_id: String, + pub wallet_balance: u64, + pub total_play_time_ms: u64, + pub played_world_count: u32, + pub updated_at: Option, + pub updated_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileWalletLedgerEntryRecord { + pub wallet_ledger_id: String, + pub user_id: String, + pub amount_delta: i64, + pub balance_after: u64, + pub source_type: RuntimeProfileWalletLedgerSourceType, + pub created_at: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfilePlayedWorldRecord { + pub played_world_id: String, + pub user_id: String, + pub world_key: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub world_type: Option, + pub world_title: String, + pub world_subtitle: String, + pub first_played_at: String, + pub first_played_at_micros: i64, + pub last_played_at: String, + pub last_played_at_micros: i64, + pub last_observed_play_time_ms: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfilePlayStatsRecord { + pub user_id: String, + pub total_play_time_ms: u64, + pub played_works: Vec, + pub updated_at: Option, + pub updated_at_micros: Option, +} + +pub fn build_runtime_browse_history_list_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_browse_history_user_id(user_id)?; + Ok(RuntimeBrowseHistoryListInput { user_id }) +} + +pub fn build_runtime_profile_dashboard_get_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfileDashboardGetInput { user_id }) +} + +pub fn build_runtime_profile_wallet_ledger_list_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfileWalletLedgerListInput { user_id }) +} + +pub fn build_runtime_profile_play_stats_get_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfilePlayStatsGetInput { user_id }) +} + +pub fn build_runtime_browse_history_clear_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_browse_history_user_id(user_id)?; + Ok(RuntimeBrowseHistoryClearInput { user_id }) +} + +pub fn build_runtime_browse_history_sync_input( + user_id: String, + entries: Vec, + updated_at_micros: i64, +) -> Result { + let user_id = normalize_runtime_browse_history_user_id(user_id)?; + if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE { + return Err(RuntimeBrowseHistoryFieldError::TooManyEntries); + } + + let mut normalized_entries = Vec::with_capacity(entries.len()); + for entry in entries { + let Some(owner_user_id) = normalize_required_string(entry.owner_user_id) else { + continue; + }; + let Some(profile_id) = normalize_required_string(entry.profile_id) else { + continue; + }; + let Some(world_name) = normalize_required_string(entry.world_name) else { + continue; + }; + // 与旧 Node 仓储保持一致:单条缺少关键字段时静默过滤,不让整批请求失败。 + let visited_at_micros = entry + .visited_at + .as_deref() + .and_then(parse_utc_rfc3339_to_micros) + .unwrap_or(updated_at_micros); + + normalized_entries.push(RuntimeBrowseHistoryWriteInput { + owner_user_id, + profile_id, + world_name, + subtitle: normalize_optional_string(entry.subtitle), + summary_text: normalize_optional_string(entry.summary_text), + cover_image_src: normalize_optional_string(entry.cover_image_src), + theme_mode: normalize_optional_string(entry.theme_mode), + author_display_name: normalize_optional_string(entry.author_display_name), + // 统一把 visitedAt 收口成 RFC3339,避免后续排序与回包格式继续漂移。 + visited_at: Some(format_utc_micros(visited_at_micros)), + }); + } + + Ok(RuntimeBrowseHistorySyncInput { + user_id, + entries: normalized_entries, + updated_at_micros, + }) +} + +pub fn prepare_runtime_browse_history_entries( + input: RuntimeBrowseHistorySyncInput, +) -> Result, RuntimeBrowseHistoryFieldError> { + let validated_input = build_runtime_browse_history_sync_input( + input.user_id, + input.entries, + input.updated_at_micros, + )?; + let mut prepared_entries = validated_input + .entries + .into_iter() + .map(|entry| { + let visited_at_micros = entry + .visited_at + .as_deref() + .and_then(parse_utc_rfc3339_to_micros) + .unwrap_or(validated_input.updated_at_micros); + + RuntimeBrowseHistoryPreparedEntry { + browse_history_id: build_runtime_browse_history_id( + &validated_input.user_id, + &entry.owner_user_id, + &entry.profile_id, + ), + user_id: validated_input.user_id.clone(), + owner_user_id: entry.owner_user_id, + profile_id: entry.profile_id, + world_name: entry.world_name, + subtitle: entry.subtitle.unwrap_or_default(), + summary_text: entry.summary_text.unwrap_or_default(), + cover_image_src: entry.cover_image_src, + theme_mode: RuntimeBrowseHistoryThemeMode::from_client_str( + entry.theme_mode.as_deref().unwrap_or("mythic"), + ), + author_display_name: entry + .author_display_name + .unwrap_or_else(|| DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME.to_string()), + visited_at_micros, + updated_at_micros: validated_input.updated_at_micros, + } + }) + .collect::>(); + + // 与旧 Node 仓储保持一致:先按 visitedAt 倒序,再按 owner/profile 去重,只保留最近一次访问。 + prepared_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)) + }); + + let mut seen_ids = HashSet::new(); + prepared_entries.retain(|entry| seen_ids.insert(entry.browse_history_id.clone())); + + Ok(prepared_entries) +} + +pub fn build_runtime_browse_history_record( + snapshot: RuntimeBrowseHistorySnapshot, +) -> RuntimeBrowseHistoryRecord { + RuntimeBrowseHistoryRecord { + browse_history_id: snapshot.browse_history_id, + user_id: snapshot.user_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: snapshot.theme_mode, + author_display_name: snapshot.author_display_name, + visited_at: format_utc_micros(snapshot.visited_at_micros), + visited_at_micros: snapshot.visited_at_micros, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub fn build_runtime_profile_dashboard_record( + snapshot: RuntimeProfileDashboardSnapshot, +) -> RuntimeProfileDashboardRecord { + RuntimeProfileDashboardRecord { + user_id: snapshot.user_id, + wallet_balance: snapshot.wallet_balance, + total_play_time_ms: snapshot.total_play_time_ms, + played_world_count: snapshot.played_world_count, + updated_at: snapshot.updated_at_micros.map(format_utc_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub fn build_runtime_profile_wallet_ledger_entry_record( + snapshot: RuntimeProfileWalletLedgerEntrySnapshot, +) -> RuntimeProfileWalletLedgerEntryRecord { + RuntimeProfileWalletLedgerEntryRecord { + wallet_ledger_id: snapshot.wallet_ledger_id, + user_id: snapshot.user_id, + amount_delta: snapshot.amount_delta, + balance_after: snapshot.balance_after, + source_type: snapshot.source_type, + created_at: format_utc_micros(snapshot.created_at_micros), + created_at_micros: snapshot.created_at_micros, + } +} + +pub fn build_runtime_profile_played_world_record( + snapshot: RuntimeProfilePlayedWorldSnapshot, +) -> RuntimeProfilePlayedWorldRecord { + RuntimeProfilePlayedWorldRecord { + played_world_id: snapshot.played_world_id, + user_id: snapshot.user_id, + world_key: snapshot.world_key, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_type: snapshot.world_type, + world_title: snapshot.world_title, + world_subtitle: snapshot.world_subtitle, + first_played_at: format_utc_micros(snapshot.first_played_at_micros), + first_played_at_micros: snapshot.first_played_at_micros, + last_played_at: format_utc_micros(snapshot.last_played_at_micros), + last_played_at_micros: snapshot.last_played_at_micros, + last_observed_play_time_ms: snapshot.last_observed_play_time_ms, + } +} + +pub fn build_runtime_profile_play_stats_record( + snapshot: RuntimeProfilePlayStatsSnapshot, +) -> RuntimeProfilePlayStatsRecord { + RuntimeProfilePlayStatsRecord { + user_id: snapshot.user_id, + total_play_time_ms: snapshot.total_play_time_ms, + played_works: snapshot + .played_works + .into_iter() + .map(build_runtime_profile_played_world_record) + .collect(), + updated_at: snapshot.updated_at_micros.map(format_utc_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub fn build_runtime_browse_history_id( + user_id: &str, + owner_user_id: &str, + profile_id: &str, +) -> String { + format!("{user_id}:{owner_user_id}:{profile_id}") +} + +pub fn format_utc_micros(micros: i64) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000) + .unwrap_or(OffsetDateTime::UNIX_EPOCH); + format_shared_rfc3339(timestamp).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +fn parse_utc_rfc3339_to_micros(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + let nanos = parse_shared_rfc3339(trimmed).ok()?.unix_timestamp_nanos(); + i64::try_from(nanos / 1_000).ok() +} + +impl std::fmt::Display for RuntimeSettingsFieldError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingUserId => f.write_str("runtime_setting.user_id 不能为空"), + } + } +} + +impl std::fmt::Display for RuntimeBrowseHistoryFieldError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingUserId => f.write_str("browse_history.user_id 不能为空"), + Self::TooManyEntries => write!( + f, + "browse_history.entries 单次最多只允许 {} 条", + MAX_BROWSE_HISTORY_BATCH_SIZE + ), + } + } +} + +impl RuntimeProfileWalletLedgerSourceType { + pub fn as_str(&self) -> &'static str { + match self { + Self::SnapshotSync => "snapshot_sync", + } + } +} + +impl std::fmt::Display for RuntimeProfileFieldError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingUserId => f.write_str("profile.user_id 不能为空"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_shared_contract_baseline() { + let settings = RuntimeSettings::defaults(); + + assert!((settings.music_volume - DEFAULT_MUSIC_VOLUME).abs() < f32::EPSILON); + assert_eq!(settings.platform_theme, RuntimePlatformTheme::Light); + } + + #[test] + fn normalized_clamps_music_volume_into_valid_range() { + let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); + let high = RuntimeSettings::normalized(3.5, RuntimePlatformTheme::Dark); + + assert_eq!(low.music_volume, 0.0); + assert_eq!(high.music_volume, 1.0); + assert_eq!(high.platform_theme, RuntimePlatformTheme::Dark); + } + + #[test] + fn theme_from_client_string_falls_back_to_light() { + assert_eq!( + RuntimePlatformTheme::from_client_str("dark"), + RuntimePlatformTheme::Dark + ); + assert_eq!( + RuntimePlatformTheme::from_client_str("LIGHT"), + RuntimePlatformTheme::Light + ); + assert_eq!( + RuntimePlatformTheme::from_client_str("mythic"), + RuntimePlatformTheme::Light + ); + } + + #[test] + fn build_upsert_input_rejects_blank_user_id() { + let error = build_runtime_setting_upsert_input( + " ".to_string(), + DEFAULT_MUSIC_VOLUME, + RuntimePlatformTheme::Light, + 1, + ) + .expect_err("blank user id should fail"); + + assert_eq!(error, RuntimeSettingsFieldError::MissingUserId); + } + + #[test] + fn browse_history_theme_from_client_string_falls_back_to_mythic() { + assert_eq!( + RuntimeBrowseHistoryThemeMode::from_client_str("martial"), + RuntimeBrowseHistoryThemeMode::Martial + ); + assert_eq!( + RuntimeBrowseHistoryThemeMode::from_client_str("RIFT"), + RuntimeBrowseHistoryThemeMode::Rift + ); + assert_eq!( + RuntimeBrowseHistoryThemeMode::from_client_str("unknown"), + RuntimeBrowseHistoryThemeMode::Mythic + ); + } + + #[test] + fn build_browse_history_sync_input_normalizes_optionals_and_visited_at() { + let input = build_runtime_browse_history_sync_input( + " user-1 ".to_string(), + vec![RuntimeBrowseHistoryWriteInput { + owner_user_id: " owner-a ".to_string(), + profile_id: " profile-a ".to_string(), + world_name: " 世界A ".to_string(), + subtitle: Some(" ".to_string()), + summary_text: Some(" 简介 ".to_string()), + cover_image_src: Some(" /cover.png ".to_string()), + theme_mode: Some(" arcane ".to_string()), + author_display_name: Some(" ".to_string()), + visited_at: None, + }], + 1_713_680_000_000_000, + ) + .expect("sync input should build"); + + assert_eq!(input.user_id, "user-1"); + assert_eq!(input.entries.len(), 1); + assert_eq!(input.entries[0].owner_user_id, "owner-a"); + assert_eq!(input.entries[0].profile_id, "profile-a"); + assert_eq!(input.entries[0].world_name, "世界A"); + assert_eq!(input.entries[0].subtitle, None); + assert_eq!(input.entries[0].summary_text, Some("简介".to_string())); + assert_eq!( + input.entries[0].cover_image_src, + Some("/cover.png".to_string()) + ); + assert_eq!(input.entries[0].theme_mode, Some("arcane".to_string())); + assert_eq!(input.entries[0].author_display_name, None); + assert_eq!( + input.entries[0].visited_at, + Some("2024-04-21T06:13:20Z".to_string()) + ); + } + + #[test] + fn prepare_browse_history_entries_sorts_desc_and_dedups_by_owner_profile() { + let entries = prepare_runtime_browse_history_entries(RuntimeBrowseHistorySyncInput { + user_id: "user-1".to_string(), + entries: vec![ + RuntimeBrowseHistoryWriteInput { + owner_user_id: "owner-a".to_string(), + profile_id: "profile-a".to_string(), + world_name: "世界旧".to_string(), + subtitle: None, + summary_text: None, + cover_image_src: None, + theme_mode: Some("martial".to_string()), + author_display_name: None, + visited_at: Some("2026-04-20T10:00:00Z".to_string()), + }, + RuntimeBrowseHistoryWriteInput { + owner_user_id: "owner-b".to_string(), + profile_id: "profile-b".to_string(), + world_name: "世界B".to_string(), + subtitle: None, + summary_text: None, + cover_image_src: None, + theme_mode: Some("rift".to_string()), + author_display_name: Some("作者B".to_string()), + visited_at: Some("2026-04-21T10:00:00Z".to_string()), + }, + RuntimeBrowseHistoryWriteInput { + owner_user_id: "owner-a".to_string(), + profile_id: "profile-a".to_string(), + world_name: "世界新".to_string(), + subtitle: None, + summary_text: None, + cover_image_src: None, + theme_mode: Some("unknown".to_string()), + author_display_name: Some("".to_string()), + visited_at: Some("2026-04-21T11:00:00Z".to_string()), + }, + ], + updated_at_micros: 1_776_000_000_000_000, + }) + .expect("entries should prepare"); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].world_name, "世界新"); + assert_eq!(entries[0].theme_mode, RuntimeBrowseHistoryThemeMode::Mythic); + assert_eq!( + entries[0].author_display_name, + DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME + ); + assert_eq!(entries[1].world_name, "世界B"); + assert!(entries[0].visited_at_micros > entries[1].visited_at_micros); + } + + #[test] + fn build_browse_history_sync_input_silently_filters_invalid_entries() { + let input = build_runtime_browse_history_sync_input( + "user-1".to_string(), + vec![ + RuntimeBrowseHistoryWriteInput { + owner_user_id: " ".to_string(), + profile_id: "profile-a".to_string(), + world_name: "世界A".to_string(), + subtitle: None, + summary_text: None, + cover_image_src: None, + theme_mode: None, + author_display_name: None, + visited_at: None, + }, + RuntimeBrowseHistoryWriteInput { + owner_user_id: "owner-b".to_string(), + profile_id: "profile-b".to_string(), + world_name: " 世界B ".to_string(), + subtitle: None, + summary_text: None, + cover_image_src: None, + theme_mode: None, + author_display_name: None, + visited_at: None, + }, + RuntimeBrowseHistoryWriteInput { + owner_user_id: "owner-c".to_string(), + profile_id: "".to_string(), + world_name: "世界C".to_string(), + subtitle: None, + summary_text: None, + cover_image_src: None, + theme_mode: None, + author_display_name: None, + visited_at: None, + }, + ], + 1_776_000_000_000_000, + ) + .expect("sync input should build"); + + assert_eq!(input.entries.len(), 1); + assert_eq!(input.entries[0].owner_user_id, "owner-b"); + assert_eq!(input.entries[0].profile_id, "profile-b"); + assert_eq!(input.entries[0].world_name, "世界B"); + } + + #[test] + fn build_profile_inputs_reject_blank_user_id() { + assert_eq!( + build_runtime_profile_dashboard_get_input(" ".to_string()) + .expect_err("dashboard input should fail"), + RuntimeProfileFieldError::MissingUserId + ); + assert_eq!( + build_runtime_profile_wallet_ledger_list_input(" ".to_string()) + .expect_err("wallet ledger input should fail"), + RuntimeProfileFieldError::MissingUserId + ); + assert_eq!( + build_runtime_profile_play_stats_get_input(" ".to_string()) + .expect_err("play stats input should fail"), + RuntimeProfileFieldError::MissingUserId + ); + } + + #[test] + fn profile_dashboard_record_formats_optional_timestamp() { + let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot { + user_id: "user-1".to_string(), + wallet_balance: 8, + total_play_time_ms: 12, + played_world_count: 2, + updated_at_micros: Some(1_713_680_000_000_000), + }); + + assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string())); + } + + #[test] + fn profile_wallet_ledger_source_type_formats_to_snapshot_sync() { + assert_eq!( + RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(), + "snapshot_sync" + ); + } +} diff --git a/server-rs/crates/module-story/Cargo.toml b/server-rs/crates/module-story/Cargo.toml new file mode 100644 index 00000000..94f0f1aa --- /dev/null +++ b/server-rs/crates/module-story/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-story" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-story/src/lib.rs b/server-rs/crates/module-story/src/lib.rs new file mode 100644 index 00000000..19a2ebda --- /dev/null +++ b/server-rs/crates/module-story/src/lib.rs @@ -0,0 +1,610 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{ + build_prefixed_seed_id, format_timestamp_micros, + normalize_optional_string as normalize_shared_optional_string, normalize_required_string, +}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const STORY_SESSION_ID_PREFIX: &str = "storysess_"; +pub const STORY_EVENT_ID_PREFIX: &str = "storyevt_"; +pub const INITIAL_STORY_SESSION_VERSION: u32 = 1; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum StorySessionStatus { + Active, + Completed, + Archived, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum StoryEventKind { + SessionStarted, + StoryContinued, +} + +impl StorySessionStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Completed => "completed", + Self::Archived => "archived", + } + } +} + +impl StoryEventKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::SessionStarted => "session_started", + Self::StoryContinued => "story_continued", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StorySessionFieldError { + MissingSessionId, + MissingRuntimeSessionId, + MissingActorUserId, + MissingWorldProfileId, + MissingInitialPrompt, + MissingNarrativeText, + MissingEventId, + InvalidVersion, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StorySessionInput { + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + pub opening_summary: Option, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StorySessionSnapshot { + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + pub opening_summary: Option, + pub latest_narrative_text: String, + pub latest_choice_function_id: Option, + pub status: StorySessionStatus, + pub version: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoryContinueInput { + pub story_session_id: String, + pub event_id: String, + pub narrative_text: String, + pub choice_function_id: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StorySessionStateInput { + pub story_session_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoryEventSnapshot { + pub event_id: String, + pub story_session_id: String, + pub event_kind: StoryEventKind, + pub narrative_text: String, + pub choice_function_id: Option, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StorySessionProcedureResult { + pub ok: bool, + pub session: Option, + pub event: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StorySessionStateProcedureResult { + pub ok: bool, + pub session: Option, + pub events: Vec, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StorySessionRecord { + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + pub opening_summary: Option, + pub latest_narrative_text: String, + pub latest_choice_function_id: Option, + pub status: String, + pub version: u32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoryEventRecord { + pub event_id: String, + pub story_session_id: String, + pub event_kind: String, + pub narrative_text: String, + pub choice_function_id: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StorySessionResultRecord { + pub session: StorySessionRecord, + pub event: StoryEventRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StorySessionStateRecord { + pub session: StorySessionRecord, + pub events: Vec, +} + +pub fn build_story_session_input( + story_session_id: String, + runtime_session_id: String, + actor_user_id: String, + world_profile_id: String, + initial_prompt: String, + opening_summary: Option, + created_at_micros: i64, +) -> Result { + let input = StorySessionInput { + story_session_id: normalize_required_string(story_session_id).unwrap_or_default(), + runtime_session_id: normalize_required_string(runtime_session_id).unwrap_or_default(), + actor_user_id: normalize_required_string(actor_user_id).unwrap_or_default(), + world_profile_id: normalize_required_string(world_profile_id).unwrap_or_default(), + initial_prompt: normalize_required_string(initial_prompt).unwrap_or_default(), + opening_summary: normalize_optional_value(opening_summary), + created_at_micros, + }; + + validate_story_session_input(&input)?; + + Ok(input) +} + +pub fn build_story_session_state_input( + story_session_id: String, +) -> Result { + let input = StorySessionStateInput { + story_session_id: normalize_required_string(story_session_id).unwrap_or_default(), + }; + + validate_story_session_state_input(&input)?; + + Ok(input) +} + +pub fn build_story_continue_input( + story_session_id: String, + event_id: String, + narrative_text: String, + choice_function_id: Option, + updated_at_micros: i64, +) -> Result { + let input = StoryContinueInput { + story_session_id: normalize_required_string(story_session_id).unwrap_or_default(), + event_id: normalize_required_string(event_id).unwrap_or_default(), + narrative_text: normalize_required_string(narrative_text).unwrap_or_default(), + choice_function_id: normalize_optional_value(choice_function_id), + updated_at_micros, + }; + + validate_story_continue_input(&input)?; + + Ok(input) +} + +pub fn validate_story_session_input( + input: &StorySessionInput, +) -> Result<(), StorySessionFieldError> { + if normalize_required_string(&input.story_session_id).is_none() { + return Err(StorySessionFieldError::MissingSessionId); + } + if normalize_required_string(&input.runtime_session_id).is_none() { + return Err(StorySessionFieldError::MissingRuntimeSessionId); + } + if normalize_required_string(&input.actor_user_id).is_none() { + return Err(StorySessionFieldError::MissingActorUserId); + } + if normalize_required_string(&input.world_profile_id).is_none() { + return Err(StorySessionFieldError::MissingWorldProfileId); + } + if normalize_required_string(&input.initial_prompt).is_none() { + return Err(StorySessionFieldError::MissingInitialPrompt); + } + + Ok(()) +} + +pub fn validate_story_session_state_input( + input: &StorySessionStateInput, +) -> Result<(), StorySessionFieldError> { + if normalize_required_string(&input.story_session_id).is_none() { + return Err(StorySessionFieldError::MissingSessionId); + } + + Ok(()) +} + +pub fn validate_story_continue_input( + input: &StoryContinueInput, +) -> Result<(), StorySessionFieldError> { + if normalize_required_string(&input.story_session_id).is_none() { + return Err(StorySessionFieldError::MissingSessionId); + } + if normalize_required_string(&input.event_id).is_none() { + return Err(StorySessionFieldError::MissingEventId); + } + if normalize_required_string(&input.narrative_text).is_none() { + return Err(StorySessionFieldError::MissingNarrativeText); + } + + Ok(()) +} + +pub fn build_story_session_snapshot(input: StorySessionInput) -> StorySessionSnapshot { + StorySessionSnapshot { + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + world_profile_id: input.world_profile_id, + initial_prompt: input.initial_prompt, + opening_summary: normalize_optional_value(input.opening_summary), + latest_narrative_text: String::new(), + latest_choice_function_id: None, + status: StorySessionStatus::Active, + version: INITIAL_STORY_SESSION_VERSION, + created_at_micros: input.created_at_micros, + updated_at_micros: input.created_at_micros, + } +} + +pub fn build_story_started_event(snapshot: &StorySessionSnapshot) -> StoryEventSnapshot { + StoryEventSnapshot { + event_id: generate_story_event_id(snapshot.created_at_micros), + story_session_id: snapshot.story_session_id.clone(), + event_kind: StoryEventKind::SessionStarted, + narrative_text: snapshot + .opening_summary + .clone() + .unwrap_or_else(|| snapshot.initial_prompt.clone()), + choice_function_id: None, + created_at_micros: snapshot.created_at_micros, + } +} + +pub fn apply_story_continue( + current: StorySessionSnapshot, + input: StoryContinueInput, +) -> Result<(StorySessionSnapshot, StoryEventSnapshot), StorySessionFieldError> { + validate_story_continue_input(&input)?; + + if current.version == 0 { + return Err(StorySessionFieldError::InvalidVersion); + } + + let event = StoryEventSnapshot { + event_id: input.event_id, + story_session_id: current.story_session_id.clone(), + event_kind: StoryEventKind::StoryContinued, + narrative_text: input.narrative_text.clone(), + choice_function_id: normalize_optional_value(input.choice_function_id), + created_at_micros: input.updated_at_micros, + }; + + let next = StorySessionSnapshot { + latest_narrative_text: input.narrative_text, + latest_choice_function_id: event.choice_function_id.clone(), + version: current.version + 1, + updated_at_micros: input.updated_at_micros, + ..current + }; + + Ok((next, event)) +} + +pub fn generate_story_session_id(seed_micros: i64) -> String { + build_prefixed_seed_id(STORY_SESSION_ID_PREFIX, seed_micros) +} + +pub fn generate_story_event_id(seed_micros: i64) -> String { + build_prefixed_seed_id(STORY_EVENT_ID_PREFIX, seed_micros) +} + +pub fn build_story_session_record(snapshot: StorySessionSnapshot) -> StorySessionRecord { + StorySessionRecord { + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + world_profile_id: snapshot.world_profile_id, + initial_prompt: snapshot.initial_prompt, + opening_summary: snapshot.opening_summary, + latest_narrative_text: snapshot.latest_narrative_text, + latest_choice_function_id: snapshot.latest_choice_function_id, + status: snapshot.status.as_str().to_string(), + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub fn build_story_event_record(snapshot: StoryEventSnapshot) -> StoryEventRecord { + StoryEventRecord { + event_id: snapshot.event_id, + story_session_id: snapshot.story_session_id, + event_kind: snapshot.event_kind.as_str().to_string(), + narrative_text: snapshot.narrative_text, + choice_function_id: snapshot.choice_function_id, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub fn build_story_session_result_record( + session: StorySessionSnapshot, + event: StoryEventSnapshot, +) -> StorySessionResultRecord { + StorySessionResultRecord { + session: build_story_session_record(session), + event: build_story_event_record(event), + } +} + +pub fn build_story_session_state_record( + session: StorySessionSnapshot, + events: Vec, +) -> StorySessionStateRecord { + StorySessionStateRecord { + session: build_story_session_record(session), + events: events + .into_iter() + .map(build_story_event_record) + .collect::>(), + } +} + +pub fn normalize_optional_value(value: Option) -> Option { + normalize_shared_optional_string(value) +} + +impl fmt::Display for StorySessionFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingSessionId => f.write_str("story_session.story_session_id 不能为空"), + Self::MissingRuntimeSessionId => { + f.write_str("story_session.runtime_session_id 不能为空") + } + Self::MissingActorUserId => f.write_str("story_session.actor_user_id 不能为空"), + Self::MissingWorldProfileId => f.write_str("story_session.world_profile_id 不能为空"), + Self::MissingInitialPrompt => f.write_str("story_session.initial_prompt 不能为空"), + Self::MissingNarrativeText => f.write_str("story_event.narrative_text 不能为空"), + Self::MissingEventId => f.write_str("story_event.event_id 不能为空"), + Self::InvalidVersion => f.write_str("story_session.version 必须大于 0"), + } + } +} + +impl Error for StorySessionFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_story_session_input_accepts_minimal_contract() { + let result = validate_story_session_input(&StorySessionInput { + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + world_profile_id: "profile_001".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + created_at_micros: 1_713_680_000_000_000, + }); + + assert!(result.is_ok()); + } + + #[test] + fn validate_story_session_input_rejects_missing_required_fields() { + let error = validate_story_session_input(&StorySessionInput { + story_session_id: String::new(), + runtime_session_id: String::new(), + actor_user_id: String::new(), + world_profile_id: String::new(), + initial_prompt: String::new(), + opening_summary: None, + created_at_micros: 1, + }) + .expect_err("missing required story session fields should fail"); + + assert_eq!(error, StorySessionFieldError::MissingSessionId); + } + + #[test] + fn build_story_session_snapshot_uses_active_status_and_initial_version() { + let snapshot = build_story_session_snapshot(StorySessionInput { + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + world_profile_id: "profile_001".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some(" ".to_string()), + created_at_micros: 12, + }); + + assert_eq!(snapshot.status, StorySessionStatus::Active); + assert_eq!(snapshot.version, INITIAL_STORY_SESSION_VERSION); + assert_eq!(snapshot.opening_summary, None); + } + + #[test] + fn build_story_session_input_normalizes_optional_summary() { + let input = build_story_session_input( + " storysess_001 ".to_string(), + " runtime_001 ".to_string(), + " user_001 ".to_string(), + " profile_001 ".to_string(), + " 进入营地 ".to_string(), + Some(" ".to_string()), + 12, + ) + .expect("story session input should build"); + + assert_eq!(input.story_session_id, "storysess_001"); + assert_eq!(input.runtime_session_id, "runtime_001"); + assert_eq!(input.actor_user_id, "user_001"); + assert_eq!(input.world_profile_id, "profile_001"); + assert_eq!(input.initial_prompt, "进入营地"); + assert_eq!(input.opening_summary, None); + } + + #[test] + fn build_story_session_state_input_rejects_blank_session_id() { + let error = build_story_session_state_input(" ".to_string()) + .expect_err("blank story session id should fail"); + + assert_eq!(error, StorySessionFieldError::MissingSessionId); + } + + #[test] + fn build_story_session_state_record_maps_all_events() { + let record = build_story_session_state_record( + StorySessionSnapshot { + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + world_profile_id: "profile_001".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "你看见篝火边有人招手。".to_string(), + latest_choice_function_id: Some("talk_to_npc".to_string()), + status: StorySessionStatus::Active, + version: 2, + created_at_micros: 1_713_686_400_000_000, + updated_at_micros: 1_713_686_401_234_567, + }, + vec![ + StoryEventSnapshot { + event_id: "storyevt_001".to_string(), + story_session_id: "storysess_001".to_string(), + event_kind: StoryEventKind::SessionStarted, + narrative_text: "营地开场".to_string(), + choice_function_id: None, + created_at_micros: 1_713_686_400_000_000, + }, + StoryEventSnapshot { + event_id: "storyevt_002".to_string(), + story_session_id: "storysess_001".to_string(), + event_kind: StoryEventKind::StoryContinued, + narrative_text: "你看见篝火边有人招手。".to_string(), + choice_function_id: Some("talk_to_npc".to_string()), + created_at_micros: 1_713_686_401_234_567, + }, + ], + ); + + assert_eq!(record.session.story_session_id, "storysess_001"); + assert_eq!(record.events.len(), 2); + assert_eq!(record.events[0].event_kind, "session_started"); + assert_eq!(record.events[1].event_kind, "story_continued"); + } + + #[test] + fn build_story_session_result_record_formats_status_and_timestamps() { + let record = build_story_session_result_record( + StorySessionSnapshot { + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + world_profile_id: "profile_001".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "你看到营地中央的篝火。".to_string(), + latest_choice_function_id: Some("inspect_campfire".to_string()), + status: StorySessionStatus::Active, + version: 2, + created_at_micros: 1_713_686_400_000_000, + updated_at_micros: 1_713_686_401_234_567, + }, + StoryEventSnapshot { + event_id: "storyevt_001".to_string(), + story_session_id: "storysess_001".to_string(), + event_kind: StoryEventKind::StoryContinued, + narrative_text: "你看到营地中央的篝火。".to_string(), + choice_function_id: Some("inspect_campfire".to_string()), + created_at_micros: 1_713_686_401_234_567, + }, + ); + + assert_eq!(record.session.status, "active"); + assert_eq!(record.session.created_at, "1713686400.000000Z"); + assert_eq!(record.session.updated_at, "1713686401.234567Z"); + assert_eq!(record.event.event_kind, "story_continued"); + assert_eq!(record.event.created_at, "1713686401.234567Z"); + } + + #[test] + fn apply_story_continue_updates_latest_narrative_and_emits_event() { + let current = build_story_session_snapshot(StorySessionInput { + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + world_profile_id: "profile_001".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + created_at_micros: 10, + }); + + let (next, event) = apply_story_continue( + current, + StoryContinueInput { + story_session_id: "storysess_001".to_string(), + event_id: "storyevt_001".to_string(), + narrative_text: "你看见篝火边有人招手。".to_string(), + choice_function_id: Some("talk_to_npc".to_string()), + updated_at_micros: 20, + }, + ) + .expect("continue story should succeed"); + + assert_eq!(next.latest_narrative_text, "你看见篝火边有人招手。"); + assert_eq!( + next.latest_choice_function_id.as_deref(), + Some("talk_to_npc") + ); + assert_eq!(next.version, INITIAL_STORY_SESSION_VERSION + 1); + assert_eq!(event.event_kind, StoryEventKind::StoryContinued); + } +} diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index fe739ef0..5a598246 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -10,6 +10,7 @@ sha2 = "0.10" jsonwebtoken = "9" rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } time = { version = "0.3", features = ["std"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 6785ef5f..78863444 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -7,8 +7,8 @@ use jsonwebtoken::{ use rand_core::OsRng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string}; use time::{Duration, OffsetDateTime}; -use uuid::Uuid; pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256; pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60; @@ -114,16 +114,10 @@ impl JwtConfig { secret: String, access_token_ttl_seconds: u64, ) -> Result { - let issuer = issuer.trim().to_string(); - let secret = secret.trim().to_string(); - - if issuer.is_empty() { - return Err(JwtError::InvalidConfig("JWT issuer 不能为空")); - } - - if secret.is_empty() { - return Err(JwtError::InvalidConfig("JWT secret 不能为空")); - } + let issuer = normalize_required_string(&issuer) + .ok_or(JwtError::InvalidConfig("JWT issuer 不能为空"))?; + let secret = normalize_required_string(&secret) + .ok_or(JwtError::InvalidConfig("JWT secret 不能为空"))?; if access_token_ttl_seconds == 0 { return Err(JwtError::InvalidConfig( @@ -174,20 +168,12 @@ impl RefreshCookieConfig { cookie_same_site: RefreshCookieSameSite, refresh_session_ttl_days: u32, ) -> Result { - let cookie_name = cookie_name.trim().to_string(); - let cookie_path = cookie_path.trim().to_string(); - - if cookie_name.is_empty() { - return Err(RefreshCookieError::InvalidConfig( - "refresh cookie 名称不能为空", - )); - } - - if cookie_path.is_empty() { - return Err(RefreshCookieError::InvalidConfig( - "refresh cookie path 不能为空", - )); - } + let cookie_name = normalize_required_string(&cookie_name).ok_or( + RefreshCookieError::InvalidConfig("refresh cookie 名称不能为空"), + )?; + let cookie_path = normalize_required_string(&cookie_path).ok_or( + RefreshCookieError::InvalidConfig("refresh cookie path 不能为空"), + )?; if refresh_session_ttl_days == 0 { return Err(RefreshCookieError::InvalidConfig( @@ -401,7 +387,7 @@ pub async fn verify_password( } pub fn create_refresh_session_token() -> String { - Uuid::new_v4().simple().to_string() + new_uuid_simple_string() } pub fn hash_refresh_session_token(token: &str) -> String { @@ -484,23 +470,11 @@ fn normalize_required_field( value: String, error_message: &'static str, ) -> Result { - let value = value.trim().to_string(); - if value.is_empty() { - return Err(JwtError::InvalidClaims(error_message)); - } - - Ok(value) + normalize_required_string(&value).ok_or(JwtError::InvalidClaims(error_message)) } fn normalize_optional_field(value: Option) -> Option { - value.and_then(|field| { - let field = field.trim().to_string(); - if field.is_empty() { - return None; - } - - Some(field) - }) + normalize_optional_string(value) } fn normalize_roles(roles: Vec) -> Result, JwtError> { @@ -681,7 +655,7 @@ mod tests { assert_eq!( hash, - "0b6901f0dcee3f50df4115ecb29214f7740f8173919f94cc1f5eb92ff2481ce8" + "9fab76f9100ec6c151c8caa0c42ab10e10fbc7618f15e24cf3dffc93e19c4c4e" ); } diff --git a/server-rs/crates/platform-llm/Cargo.toml b/server-rs/crates/platform-llm/Cargo.toml new file mode 100644 index 00000000..56971711 --- /dev/null +++ b/server-rs/crates/platform-llm/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "platform-llm" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +log.workspace = true +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["time"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/server-rs/crates/platform-llm/README.md b/server-rs/crates/platform-llm/README.md index e60510f1..ad1a6e72 100644 --- a/server-rs/crates/platform-llm/README.md +++ b/server-rs/crates/platform-llm/README.md @@ -1,28 +1,48 @@ -# platform-llm 平台适配 package 占位说明 +# platform-llm 平台适配 crate -日期:`2026-04-20` +日期:`2026-04-21` -## 1. package 职责 +## 1. crate 职责 -`platform-llm` 是大模型平台适配 package,后续负责: +`platform-llm` 是 Rust 工作区里的大模型平台适配 crate,当前首版已经落地以下能力: -1. DashScope、Ark 与其他模型供应商适配 -2. 统一模型调用、流式输出、重试、超时与日志策略 -3. 供 `module-ai`、`module-story`、`module-npc`、`module-custom-world` 等模块复用的模型基础设施能力 +1. 统一 Ark / DashScope / 其他 OpenAI 兼容网关的文本模型配置结构 +2. 统一 `/chat/completions` 文本请求、非流式响应与 SSE 流式增量解析 +3. 统一超时、连接失败、上游错误、空响应与重试策略 +4. 为后续 `module-ai`、`module-story`、`module-npc`、`module-custom-world` 提供可直接复用的基础 client -## 2. 当前阶段说明 +## 2. 当前首版边界 -当前提交仅完成目录占位,不提前进入具体模型 SDK、流式调用与供应商切换实现。 +当前实现只覆盖“文本 chat completion”主链,不提前混入媒体生成和业务编排: -后续与本 package 直接相关的任务包括: +1. 支持 OpenAI 兼容格式的 JSON 请求与 SSE 增量响应 +2. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回放进本 crate +3. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API +4. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现 -1. 落地统一模型请求与响应适配 -2. 落地流式文本输出与阶段事件适配 -3. 落地重试、超时、错误与日志策略 -4. 设计多供应商切换与能力分层 +## 3. 核心导出 -## 3. 边界约束 +首版对外导出以下公共类型: -1. `platform-llm` 只承接模型平台适配,不承接业务模块的状态真相与业务规则。 -2. 生成型状态与结果引用最终由业务模块和 `apps/spacetime-module` 管理,前端接口由 `apps/api-server` 暴露。 -3. 不允许把供应商 SDK、流式细节和重试策略重新散落到多个业务模块里各自实现。 +1. `LlmProvider` +2. `LlmConfig` +3. `LlmMessageRole` +4. `LlmMessage` +5. `LlmTextRequest` +6. `LlmStreamDelta` +7. `LlmTextResponse` +8. `LlmTokenUsage` +9. `LlmClient` +10. `LlmError` + +## 4. 设计文档 + +详细约束与接口说明见: + +- [../../../docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md) + +## 5. 边界约束 + +1. `platform-llm` 只承接模型平台适配,不承接业务模块状态真相与业务规则。 +2. 业务模块只能依赖这里的统一 client / DTO / 错误模型,不能再把上游请求细节散落回各 crate。 +3. `api-server` 后续如果需要做 REST/SSE façade,只允许在协议层调用 `platform-llm`,不能复制一份私有实现。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs new file mode 100644 index 00000000..290463f9 --- /dev/null +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -0,0 +1,1069 @@ +use std::{error::Error, fmt, time::Duration}; + +use log::{debug, warn}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use tokio::time::sleep; + +pub const DEFAULT_ARK_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3"; +pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; +pub const DEFAULT_MAX_RETRIES: u32 = 1; +pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500; +pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions"; + +// 冻结平台来源,避免上层继续散落 provider 字符串。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmProvider { + Ark, + DashScope, + OpenAiCompatible, +} + +// 统一收口文本模型网关配置,避免 api-server 和业务模块各自重复解析环境变量。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LlmConfig { + provider: LlmProvider, + base_url: String, + api_key: String, + model: String, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, +} + +// 首版只冻结当前项目已稳定使用的 system/user/assistant 三种消息角色。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmMessageRole { + System, + User, + Assistant, +} + +// 单条消息保持 OpenAI 兼容格式,供统一请求体直接序列化。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LlmMessage { + pub role: LlmMessageRole, + pub content: String, +} + +// 文本补全请求冻结为“消息列表 + 可选模型覆盖 + 可选 max_tokens”最小闭环。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LlmTextRequest { + pub model: Option, + pub messages: Vec, + pub max_tokens: Option, +} + +// 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LlmStreamDelta { + pub accumulated_text: String, + pub delta_text: String, + pub finish_reason: Option, +} + +// 用于保留 token 计数,后续模块可以决定是否写入审计或成本统计。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LlmTokenUsage { + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub total_tokens: u64, +} + +// 统一文本响应,避免业务层再去解析 choices/message/content。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LlmTextResponse { + pub provider: LlmProvider, + pub model: String, + pub content: String, + pub finish_reason: Option, + pub response_id: Option, + pub usage: Option, +} + +// 将上游错误归一到稳定的领域枚举,后续 api-server 可以直接映射成 HTTP error contract。 +#[derive(Debug, PartialEq, Eq)] +pub enum LlmError { + InvalidConfig(String), + InvalidRequest(String), + Timeout { attempts: u32 }, + Connectivity { attempts: u32, message: String }, + Upstream { status_code: u16, message: String }, + StreamUnavailable, + EmptyResponse, + Transport(String), + Deserialize(String), +} + +// 统一 OpenAI 兼容文本网关 client。 +#[derive(Clone, Debug)] +pub struct LlmClient { + config: LlmConfig, + http_client: Client, +} + +#[derive(Serialize)] +struct ChatCompletionsRequestBody<'a> { + model: &'a str, + messages: &'a [LlmMessage], + stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, +} + +#[derive(Deserialize)] +struct ChatCompletionsResponseEnvelope { + id: Option, + model: Option, + choices: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct ChatCompletionsChoice { + #[serde(default)] + message: Option, + #[serde(default)] + delta: Option, + #[serde(default)] + finish_reason: Option, +} + +#[derive(Deserialize)] +struct ChatCompletionsMessage { + #[serde(default)] + content: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ChatCompletionsContent { + Text(String), + Parts(Vec), +} + +#[derive(Deserialize)] +struct ChatCompletionsContentPart { + #[serde(rename = "type")] + #[allow(dead_code)] + part_type: Option, + #[serde(default)] + text: Option, +} + +#[derive(Default)] +struct OpenAiCompatibleSseParser { + buffer: String, +} + +#[derive(Debug)] +struct ParsedStreamEvent { + delta_text: Option, + finish_reason: Option, +} + +impl LlmProvider { + pub fn as_str(&self) -> &'static str { + match self { + Self::Ark => "ark", + Self::DashScope => "dash_scope", + Self::OpenAiCompatible => "openai_compatible", + } + } +} + +impl LlmConfig { + #[allow(clippy::too_many_arguments)] + pub fn new( + provider: LlmProvider, + base_url: String, + api_key: String, + model: String, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, + ) -> Result { + let base_url = normalize_non_empty(base_url, "LLM base_url 不能为空")?; + let api_key = normalize_non_empty(api_key, "LLM api_key 不能为空")?; + let model = normalize_non_empty(model, "LLM model 不能为空")?; + + if request_timeout_ms == 0 { + return Err(LlmError::InvalidConfig( + "LLM request_timeout_ms 必须大于 0".to_string(), + )); + } + + Ok(Self { + provider, + base_url, + api_key, + model, + request_timeout_ms, + max_retries, + retry_backoff_ms, + }) + } + + pub fn ark_default(api_key: String, model: String) -> Result { + Self::new( + LlmProvider::Ark, + DEFAULT_ARK_BASE_URL.to_string(), + api_key, + model, + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + } + + pub fn provider(&self) -> LlmProvider { + self.provider + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn api_key(&self) -> &str { + &self.api_key + } + + pub fn model(&self) -> &str { + &self.model + } + + pub fn request_timeout_ms(&self) -> u64 { + self.request_timeout_ms + } + + pub fn max_retries(&self) -> u32 { + self.max_retries + } + + pub fn retry_backoff_ms(&self) -> u64 { + self.retry_backoff_ms + } + + pub fn chat_completions_url(&self) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + CHAT_COMPLETIONS_PATH.trim_start_matches('/') + ) + } +} + +impl LlmMessage { + pub fn new(role: LlmMessageRole, content: impl Into) -> Self { + Self { + role, + content: content.into(), + } + } + + pub fn system(content: impl Into) -> Self { + Self::new(LlmMessageRole::System, content) + } + + pub fn user(content: impl Into) -> Self { + Self::new(LlmMessageRole::User, content) + } + + pub fn assistant(content: impl Into) -> Self { + Self::new(LlmMessageRole::Assistant, content) + } +} + +impl LlmTextRequest { + pub fn new(messages: Vec) -> Self { + Self { + model: None, + messages, + max_tokens: None, + } + } + + pub fn single_turn(system_prompt: impl Into, user_prompt: impl Into) -> Self { + Self::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + } + + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { + self.max_tokens = Some(max_tokens); + self + } + + fn validate(&self) -> Result<(), LlmError> { + if self.messages.is_empty() { + return Err(LlmError::InvalidRequest( + "LLM messages 不能为空".to_string(), + )); + } + + for message in &self.messages { + if message.content.trim().is_empty() { + return Err(LlmError::InvalidRequest( + "LLM message.content 不能为空".to_string(), + )); + } + } + + if let Some(model) = &self.model + && model.trim().is_empty() + { + return Err(LlmError::InvalidRequest( + "LLM request.model 不能为空字符串".to_string(), + )); + } + + Ok(()) + } + + fn resolved_model<'a>(&'a self, fallback_model: &'a str) -> &'a str { + self.model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback_model) + } +} + +impl fmt::Display for LlmError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConfig(message) + | Self::InvalidRequest(message) + | Self::Transport(message) + | Self::Deserialize(message) => write!(f, "{message}"), + Self::Timeout { attempts } => { + write!(f, "LLM 请求超时,累计尝试 {attempts} 次") + } + Self::Connectivity { attempts, message } => { + write!(f, "LLM 连接失败,累计尝试 {attempts} 次:{message}") + } + Self::Upstream { + status_code, + message, + } => write!(f, "LLM 上游返回 {status_code}:{message}"), + Self::StreamUnavailable => write!(f, "LLM 流式响应体不可用"), + Self::EmptyResponse => write!(f, "LLM 返回内容为空"), + } + } +} + +impl Error for LlmError {} + +impl LlmClient { + pub fn new(config: LlmConfig) -> Result { + let http_client = Client::builder().build().map_err(|error| { + LlmError::InvalidConfig(format!("构建 reqwest client 失败:{error}")) + })?; + + Ok(Self { + config, + http_client, + }) + } + + pub fn config(&self) -> &LlmConfig { + &self.config + } + + pub async fn request_text(&self, request: LlmTextRequest) -> Result { + request.validate()?; + let resolved_model = request.resolved_model(self.config.model()).to_string(); + let response = self.execute_request(&request, false).await?; + let raw_text = response + .text() + .await + .map_err(|error| map_stream_read_error(error, 1))?; + + parse_chat_completions_response(self.config.provider(), &resolved_model, raw_text.as_str()) + } + + pub async fn request_single_message_text( + &self, + system_prompt: impl Into, + user_prompt: impl Into, + ) -> Result { + self.request_text(LlmTextRequest::single_turn(system_prompt, user_prompt)) + .await + } + + pub async fn stream_text( + &self, + request: LlmTextRequest, + mut on_delta: F, + ) -> Result + where + F: FnMut(&LlmStreamDelta), + { + request.validate()?; + let resolved_model = request.resolved_model(self.config.model()).to_string(); + let mut response = self.execute_request(&request, true).await?; + let response_id = response + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + + let mut parser = OpenAiCompatibleSseParser::default(); + let mut accumulated_text = String::new(); + let mut finish_reason = None; + + loop { + let next_chunk = response + .chunk() + .await + .map_err(|error| map_stream_read_error(error, 1))?; + + let Some(chunk) = next_chunk else { + break; + }; + + let chunk_text = String::from_utf8_lossy(chunk.as_ref()); + for event in parser.push_chunk(chunk_text.as_ref())? { + if let Some(delta_text) = event.delta_text + && !delta_text.is_empty() + { + accumulated_text.push_str(delta_text.as_str()); + let update = LlmStreamDelta { + accumulated_text: accumulated_text.clone(), + delta_text, + finish_reason: event.finish_reason.clone(), + }; + on_delta(&update); + } + + if event.finish_reason.is_some() { + finish_reason = event.finish_reason; + } + } + } + + for event in parser.finish()? { + if let Some(delta_text) = event.delta_text + && !delta_text.is_empty() + { + accumulated_text.push_str(delta_text.as_str()); + let update = LlmStreamDelta { + accumulated_text: accumulated_text.clone(), + delta_text, + finish_reason: event.finish_reason.clone(), + }; + on_delta(&update); + } + + if event.finish_reason.is_some() { + finish_reason = event.finish_reason; + } + } + + let content = accumulated_text.trim().to_string(); + if content.is_empty() { + return Err(LlmError::EmptyResponse); + } + + Ok(LlmTextResponse { + provider: self.config.provider(), + model: resolved_model, + content, + finish_reason, + response_id, + usage: None, + }) + } + + pub async fn stream_single_message_text( + &self, + system_prompt: impl Into, + user_prompt: impl Into, + on_delta: F, + ) -> Result + where + F: FnMut(&LlmStreamDelta), + { + self.stream_text( + LlmTextRequest::single_turn(system_prompt, user_prompt), + on_delta, + ) + .await + } + + async fn execute_request( + &self, + request: &LlmTextRequest, + stream: bool, + ) -> Result { + let request_body = ChatCompletionsRequestBody { + model: request.resolved_model(self.config.model()), + messages: request.messages.as_slice(), + stream, + max_tokens: request.max_tokens, + }; + let max_attempts = self.config.max_retries().saturating_add(1); + + for attempt in 1..=max_attempts { + debug!( + "platform-llm request started: provider={}, stream={}, attempt={}, model={}", + self.config.provider().as_str(), + stream, + attempt, + request_body.model + ); + + let send_result = self + .http_client + .post(self.config.chat_completions_url()) + .bearer_auth(self.config.api_key()) + .json(&request_body) + .timeout(Duration::from_millis(self.config.request_timeout_ms())) + .send() + .await; + + match send_result { + Ok(response) if response.status().is_success() => { + debug!( + "platform-llm request succeeded: provider={}, stream={}, attempt={}, status={}", + self.config.provider().as_str(), + stream, + attempt, + response.status().as_u16() + ); + return Ok(response); + } + Ok(response) => { + let status = response.status(); + let raw_text = response.text().await.unwrap_or_default(); + let message = extract_api_error_message(&raw_text, "LLM 上游请求失败"); + + if should_retry_status(status) && attempt < max_attempts { + warn!( + "platform-llm request retrying after upstream status: provider={}, attempt={}, status={}, message={}", + self.config.provider().as_str(), + attempt, + status.as_u16(), + message + ); + self.sleep_before_retry(attempt).await; + continue; + } + + return Err(LlmError::Upstream { + status_code: status.as_u16(), + message, + }); + } + Err(error) if error.is_timeout() => { + if attempt < max_attempts { + warn!( + "platform-llm request retrying after timeout: provider={}, attempt={}", + self.config.provider().as_str(), + attempt + ); + self.sleep_before_retry(attempt).await; + continue; + } + + return Err(LlmError::Timeout { attempts: attempt }); + } + Err(error) if error.is_connect() => { + let message = error.to_string(); + if attempt < max_attempts { + warn!( + "platform-llm request retrying after connectivity failure: provider={}, attempt={}, error={}", + self.config.provider().as_str(), + attempt, + message + ); + self.sleep_before_retry(attempt).await; + continue; + } + + return Err(LlmError::Connectivity { + attempts: attempt, + message, + }); + } + Err(error) => { + return Err(LlmError::Transport(error.to_string())); + } + } + } + + Err(LlmError::Transport( + "LLM 请求在重试循环后仍未返回结果".to_string(), + )) + } + + async fn sleep_before_retry(&self, attempt: u32) { + let backoff_ms = self + .config + .retry_backoff_ms() + .saturating_mul(u64::from(attempt)); + + if backoff_ms > 0 { + sleep(Duration::from_millis(backoff_ms)).await; + } + } +} + +impl OpenAiCompatibleSseParser { + fn push_chunk(&mut self, chunk: &str) -> Result, LlmError> { + self.buffer.push_str(chunk); + self.buffer = self.buffer.replace("\r\n", "\n"); + self.drain_complete_events() + } + + fn finish(&mut self) -> Result, LlmError> { + if self.buffer.trim().is_empty() { + return Ok(Vec::new()); + } + + self.buffer.push_str("\n\n"); + self.drain_complete_events() + } + + fn drain_complete_events(&mut self) -> Result, LlmError> { + let mut events = Vec::new(); + + while let Some(boundary) = self.buffer.find("\n\n") { + let block = self.buffer[..boundary].to_string(); + self.buffer = self.buffer[(boundary + 2)..].to_string(); + + if let Some(event) = parse_sse_event_block(block.as_str())? { + events.push(event); + } + } + + Ok(events) + } +} + +fn normalize_non_empty(value: String, error_message: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(LlmError::InvalidConfig(error_message.to_string())); + } + + Ok(trimmed) +} + +fn parse_chat_completions_response( + provider: LlmProvider, + fallback_model: &str, + raw_text: &str, +) -> Result { + let parsed: ChatCompletionsResponseEnvelope = serde_json::from_str(raw_text) + .map_err(|error| LlmError::Deserialize(format!("解析 LLM JSON 响应失败:{error}")))?; + + let first_choice = parsed + .choices + .first() + .ok_or_else(|| LlmError::Deserialize("LLM 响应缺少 choices[0]".to_string()))?; + let content = extract_message_text(first_choice) + .ok_or(LlmError::EmptyResponse)? + .trim() + .to_string(); + + if content.is_empty() { + return Err(LlmError::EmptyResponse); + } + + Ok(LlmTextResponse { + provider, + model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + content, + finish_reason: first_choice.finish_reason.clone(), + response_id: parsed.id, + usage: parsed.usage, + }) +} + +fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { + choice + .message + .as_ref() + .and_then(|message| message.content.as_ref()) + .and_then(extract_content_text) + .or_else(|| { + choice + .delta + .as_ref() + .and_then(|message| message.content.as_ref()) + .and_then(extract_content_text) + }) +} + +fn extract_content_text(content: &ChatCompletionsContent) -> Option { + match content { + ChatCompletionsContent::Text(text) => Some(text.clone()), + ChatCompletionsContent::Parts(parts) => { + let text = parts + .iter() + .filter_map(|part| part.text.as_deref()) + .collect::>() + .join(""); + + if text.is_empty() { None } else { Some(text) } + } + } +} + +fn parse_sse_event_block(block: &str) -> Result, LlmError> { + let data_lines = block + .lines() + .filter_map(|line| line.trim().strip_prefix("data:")) + .map(str::trim_start) + .collect::>(); + + if data_lines.is_empty() { + return Ok(None); + } + + let data = data_lines.join("\n"); + if data.trim().is_empty() || data.trim() == "[DONE]" { + return Ok(None); + } + + let parsed: ChatCompletionsResponseEnvelope = serde_json::from_str(data.as_str()) + .map_err(|error| LlmError::Deserialize(format!("解析 LLM SSE 事件失败:{error}")))?; + let first_choice = parsed + .choices + .first() + .ok_or_else(|| LlmError::Deserialize("LLM SSE 响应缺少 choices[0]".to_string()))?; + + Ok(Some(ParsedStreamEvent { + delta_text: extract_message_text(first_choice), + finish_reason: first_choice.finish_reason.clone(), + })) +} + +fn should_retry_status(status: StatusCode) -> bool { + status == StatusCode::REQUEST_TIMEOUT + || status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() +} + +fn extract_api_error_message(raw_text: &str, fallback_message: &str) -> String { + let trimmed = raw_text.trim(); + if trimmed.is_empty() { + return fallback_message.to_string(); + } + + let parsed = serde_json::from_str::(trimmed); + if let Ok(value) = parsed { + if let Some(message) = value + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + { + return message.to_string(); + } + + if let Some(message) = value + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + { + return message.to_string(); + } + } + + trimmed.to_string() +} + +fn map_stream_read_error(error: reqwest::Error, attempts: u32) -> LlmError { + if error.is_timeout() { + return LlmError::Timeout { attempts }; + } + + if error.is_connect() { + return LlmError::Connectivity { + attempts, + message: error.to_string(), + }; + } + + LlmError::Transport(error.to_string()) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + time::Duration as StdDuration, + }; + + use super::*; + + struct MockResponse { + status_line: &'static str, + content_type: &'static str, + body: String, + extra_headers: Vec<(&'static str, &'static str)>, + } + + #[test] + fn llm_config_rejects_blank_api_key() { + let error = LlmConfig::new( + LlmProvider::Ark, + DEFAULT_ARK_BASE_URL.to_string(), + " ".to_string(), + "model-a".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect_err("blank api key should be rejected"); + + assert_eq!( + error, + LlmError::InvalidConfig("LLM api_key 不能为空".to_string()) + ); + } + + #[test] + fn llm_chat_completion_url_normalizes_trailing_slash() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://example.com/base///".to_string(), + "secret".to_string(), + "model-a".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid"); + + assert_eq!( + config.chat_completions_url(), + "https://example.com/base/chat/completions" + ); + } + + #[test] + fn sse_parser_handles_split_chunks_and_done_marker() { + let mut parser = OpenAiCompatibleSseParser::default(); + let events_a = parser + .push_chunk("data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\r\n\r\n") + .expect("first chunk should parse"); + let events_b = parser + .push_chunk("data: {\"choices\":[{\"delta\":{\"content\":\"好\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + .expect("second chunk should parse"); + + assert_eq!(events_a.len(), 1); + assert_eq!(events_a[0].delta_text.as_deref(), Some("你")); + assert_eq!(events_b.len(), 1); + assert_eq!(events_b[0].delta_text.as_deref(), Some("好")); + assert_eq!(events_b[0].finish_reason.as_deref(), Some("stop")); + } + + #[tokio::test] + async fn request_text_parses_non_stream_response() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_01","model":"ark-test-model","choices":[{"message":{"content":"测试成功"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":6,"total_tokens":16}}"#.to_string(), + extra_headers: Vec::new(), + }]); + + let client = build_test_client(server_url, 0); + let response = client + .request_single_message_text("系统", "用户") + .await + .expect("request_text should succeed"); + + assert_eq!(response.provider, LlmProvider::Ark); + assert_eq!(response.model, "ark-test-model"); + assert_eq!(response.content, "测试成功"); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + assert_eq!(response.response_id.as_deref(), Some("resp_01")); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 10, + completion_tokens: 6, + total_tokens: 16, + }) + ); + } + + #[tokio::test] + async fn request_text_retries_after_upstream_500() { + let server_url = spawn_mock_server(vec![ + MockResponse { + status_line: "500 Internal Server Error", + content_type: "application/json; charset=utf-8", + body: r#"{"error":{"message":"temporary upstream failure"}}"#.to_string(), + extra_headers: Vec::new(), + }, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_retry","choices":[{"message":{"content":"第二次成功"},"finish_reason":"stop"}]}"#.to_string(), + extra_headers: Vec::new(), + }, + ]); + + let client = build_test_client(server_url, 1); + let response = client + .request_single_message_text("系统", "用户") + .await + .expect("second attempt should succeed"); + + assert_eq!(response.content, "第二次成功"); + assert_eq!(response.response_id.as_deref(), Some("resp_retry")); + } + + #[tokio::test] + async fn stream_text_accumulates_sse_response() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n" + ) + .to_string(), + extra_headers: vec![("x-request-id", "req_stream_01")], + }]); + + let client = build_test_client(server_url, 0); + let mut updates = Vec::new(); + let response = client + .stream_single_message_text("系统", "用户", |delta| { + updates.push(delta.accumulated_text.clone()); + }) + .await + .expect("stream_text should succeed"); + + assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]); + assert_eq!(response.content, "你好"); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + assert_eq!(response.response_id.as_deref(), Some("req_stream_01")); + } + + fn build_test_client(base_url: String, max_retries: u32) -> LlmClient { + let config = LlmConfig::new( + LlmProvider::Ark, + base_url, + "test-key".to_string(), + "test-model".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + max_retries, + 1, + ) + .expect("config should be valid"); + + LlmClient::new(config).expect("client should be created") + } + + fn spawn_mock_server(responses: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + + thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().expect("request should connect"); + read_request(&mut stream); + write_response(&mut stream, response); + } + }); + + format!("http://{address}") + } + + fn read_request(stream: &mut std::net::TcpStream) { + stream + .set_read_timeout(Some(StdDuration::from_secs(1))) + .expect("read timeout should be set"); + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + let mut expected_total = None; + + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(bytes_read) => { + buffer.extend_from_slice(&chunk[..bytes_read]); + + if expected_total.is_none() + && let Some(header_end) = find_header_end(&buffer) + { + let content_length = + read_content_length(&buffer[..header_end]).unwrap_or(0); + expected_total = Some(header_end + content_length); + } + + if let Some(total_bytes) = expected_total + && buffer.len() >= total_bytes + { + break; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(error) => panic!("mock server failed to read request: {error}"), + } + } + } + + fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) { + let body = response.body; + let mut raw_response = format!( + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n", + response.status_line, + response.content_type, + body.len() + ); + for (name, value) in response.extra_headers { + raw_response.push_str(format!("{name}: {value}\r\n").as_str()); + } + raw_response.push_str("\r\n"); + raw_response.push_str(body.as_str()); + + stream + .write_all(raw_response.as_bytes()) + .expect("mock response should be written"); + stream.flush().expect("mock response should flush"); + } + + fn find_header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + } + + fn read_content_length(headers: &[u8]) -> Option { + let text = String::from_utf8_lossy(headers); + text.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + return value.trim().parse::().ok(); + } + None + }) + } +} diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml new file mode 100644 index 00000000..d2f74a6f --- /dev/null +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "shared-contracts" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +platform-oss = { path = "../platform-oss" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/server-rs/crates/shared-contracts/README.md b/server-rs/crates/shared-contracts/README.md index 18fe0c20..fe942911 100644 --- a/server-rs/crates/shared-contracts/README.md +++ b/server-rs/crates/shared-contracts/README.md @@ -1,6 +1,6 @@ -# shared-contracts 共享 crate 占位说明 +# shared-contracts 共享 crate 说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. crate 职责 @@ -13,14 +13,55 @@ ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入 DTO、事件与兼容结构实现。 +当前阶段已完成 Stage1 最小真实落地: -后续与本 crate 直接相关的任务包括: +1. 统一 response envelope / 头部常量 +2. `auth/login-options` +3. `auth/me` +4. `auth/sessions` +5. `runtime/settings` -1. 对齐现有前端直接依赖的响应头与 envelope -2. 对齐 story、custom world、chat 等 SSE 事件结构 -3. 对齐 auth、runtime、assets 等兼容 DTO -4. 为 breaking change 建立显式变更边界 +当前阶段继续补齐的 Stage2 鉴权 DTO: + +1. `auth/entry` +2. `auth/refresh` +3. `auth/logout` +4. `auth/logout-all` +5. `auth/phone/send-code` +6. `auth/phone/login` +7. `auth/wechat/start` +8. `auth/wechat/callback` +9. `auth/wechat/bind-phone` + +当前阶段继续补齐的 Stage3 公开请求 DTO: + +1. `assets/direct-upload-tickets` +2. `assets/read-url` +3. `assets/objects/confirm` +4. `assets/objects/bind` +5. `story-sessions/begin` +6. `story-sessions/continue` + +当前阶段继续补齐的 Stage4 显式成功响应 DTO: + +1. `assets/direct-upload-tickets` +2. `assets/read-url` +3. `assets/objects/confirm` +4. `assets/objects/bind` +5. `story-sessions/begin` +6. `story-sessions/continue` + +当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线: + +1. `runtime/story/state/resolve` 请求 DTO +2. `RuntimeStoryActionResponse` 兼容响应 DTO +3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构 + +当前仍刻意未做: + +1. SSE 事件结构 +2. 自动代码生成或跨语言 contract 同步 +3. 其他尚未收口模块的 handler 响应体显式 DTO 化 ## 3. 边界约束 diff --git a/server-rs/crates/shared-contracts/src/ai.rs b/server-rs/crates/shared-contracts/src/ai.rs new file mode 100644 index 00000000..6050f4f0 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/ai.rs @@ -0,0 +1,223 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreateAiTaskRequest { + pub task_kind: String, + pub request_label: String, + pub source_module: String, + #[serde(default)] + pub source_entity_id: Option, + #[serde(default)] + pub request_payload_json: Option, + #[serde(default)] + pub stage_kinds: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppendAiTextChunkRequest { + pub stage_kind: String, + pub sequence: u32, + pub delta_text: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompleteAiStageRequest { + #[serde(default)] + pub text_output: Option, + #[serde(default)] + pub structured_payload_json: Option, + #[serde(default)] + pub warning_messages: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AttachAiResultReferenceRequest { + pub reference_kind: String, + pub reference_id: String, + #[serde(default)] + pub label: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FailAiTaskRequest { + pub failure_message: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiTaskStagePayload { + pub stage_kind: String, + pub label: String, + pub detail: String, + pub order: u32, + pub status: String, + #[serde(default)] + pub text_output: Option, + #[serde(default)] + pub structured_payload_json: Option, + pub warning_messages: Vec, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub completed_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiResultReferencePayload { + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: String, + pub reference_id: String, + #[serde(default)] + pub label: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiTextChunkPayload { + pub chunk_id: String, + pub task_id: String, + pub stage_kind: String, + pub sequence: u32, + pub delta_text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiTaskPayload { + pub task_id: String, + pub task_kind: String, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + #[serde(default)] + pub source_entity_id: Option, + #[serde(default)] + pub request_payload_json: Option, + pub status: String, + #[serde(default)] + pub failure_message: Option, + pub stages: Vec, + pub result_references: Vec, + #[serde(default)] + pub latest_text_output: Option, + #[serde(default)] + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at: String, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub completed_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiTaskMutationResponse { + pub ai_task: AiTaskPayload, + #[serde(default)] + pub ai_text_chunk: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiTaskAcceptedResponse { + pub accepted: bool, + pub task_id: String, + pub action: String, + #[serde(default)] + pub stage_kind: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn create_ai_task_request_uses_camel_case_fields() { + let payload = serde_json::to_value(CreateAiTaskRequest { + task_kind: "story_generation".to_string(), + request_label: "营地开场".to_string(), + source_module: "story".to_string(), + source_entity_id: Some("storysess_001".to_string()), + request_payload_json: Some("{\"scene\":\"camp\"}".to_string()), + stage_kinds: vec!["prepare_prompt".to_string(), "request_model".to_string()], + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "taskKind": "story_generation", + "requestLabel": "营地开场", + "sourceModule": "story", + "sourceEntityId": "storysess_001", + "requestPayloadJson": "{\"scene\":\"camp\"}", + "stageKinds": ["prepare_prompt", "request_model"] + }) + ); + } + + #[test] + fn ai_task_mutation_response_uses_camel_case_fields() { + let payload = serde_json::to_value(AiTaskMutationResponse { + ai_task: AiTaskPayload { + task_id: "aitask_001".to_string(), + task_kind: "npc_chat".to_string(), + owner_user_id: "user_001".to_string(), + request_label: "试探问话".to_string(), + source_module: "npc".to_string(), + source_entity_id: Some("npc_001".to_string()), + request_payload_json: None, + status: "running".to_string(), + failure_message: None, + stages: vec![AiTaskStagePayload { + stage_kind: "request_model".to_string(), + label: "请求模型".to_string(), + detail: "向上游模型发起正式推理请求。".to_string(), + order: 1, + status: "running".to_string(), + text_output: Some("你盯着对方的刀柄。".to_string()), + structured_payload_json: None, + warning_messages: vec![], + started_at: Some("2026-04-22T12:00:00Z".to_string()), + completed_at: None, + }], + result_references: vec![], + latest_text_output: Some("你盯着对方的刀柄。".to_string()), + latest_structured_payload_json: None, + version: 2, + created_at: "2026-04-22T12:00:00Z".to_string(), + started_at: Some("2026-04-22T12:00:01Z".to_string()), + completed_at: None, + updated_at: "2026-04-22T12:00:02Z".to_string(), + }, + ai_text_chunk: Some(AiTextChunkPayload { + chunk_id: "aichunk_001".to_string(), + task_id: "aitask_001".to_string(), + stage_kind: "request_model".to_string(), + sequence: 1, + delta_text: "你".to_string(), + created_at: "2026-04-22T12:00:02Z".to_string(), + }), + }) + .expect("payload should serialize"); + + assert_eq!(payload["aiTask"]["taskId"], json!("aitask_001")); + assert_eq!( + payload["aiTask"]["stages"][0]["stageKind"], + json!("request_model") + ); + assert_eq!(payload["aiTextChunk"]["deltaText"], json!("你")); + } +} diff --git a/server-rs/crates/shared-contracts/src/api.rs b/server-rs/crates/shared-contracts/src/api.rs new file mode 100644 index 00000000..c6c3b433 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/api.rs @@ -0,0 +1,168 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const API_VERSION: &str = "2026-04-08"; +pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; +pub const X_REQUEST_ID_HEADER: &str = "x-request-id"; +pub const API_VERSION_HEADER: &str = "x-api-version"; +pub const ROUTE_VERSION_HEADER: &str = "x-route-version"; +pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms"; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ApiResponseMeta { + #[serde(rename = "apiVersion")] + pub api_version: String, + #[serde(rename = "requestId", skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(rename = "routeVersion")] + pub route_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + #[serde(rename = "latencyMs")] + pub latency_ms: u64, + pub timestamp: String, +} + +impl ApiResponseMeta { + pub fn new( + api_version: impl Into, + request_id: Option, + route_version: impl Into, + operation: Option, + latency_ms: u64, + timestamp: impl Into, + ) -> Self { + Self { + api_version: api_version.into(), + request_id, + route_version: route_version.into(), + operation, + latency_ms, + timestamp: timestamp.into(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ApiErrorPayload { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ApiErrorPayload { + pub fn new( + code: impl Into, + message: impl Into, + details: Option, + ) -> Self { + Self { + code: code.into(), + message: message.into(), + details, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ApiSuccessEnvelope { + pub ok: bool, + pub data: T, + pub error: Option, + pub meta: ApiResponseMeta, +} + +impl ApiSuccessEnvelope { + pub fn new(data: T, meta: ApiResponseMeta) -> Self { + Self { + ok: true, + data, + error: None, + meta, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ApiErrorEnvelope { + pub ok: bool, + pub data: Option, + pub error: ApiErrorPayload, + pub meta: ApiResponseMeta, +} + +impl ApiErrorEnvelope { + pub fn new(error: ApiErrorPayload, meta: ApiResponseMeta) -> Self { + Self { + ok: false, + data: None, + error, + meta, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct LegacyApiErrorResponse { + pub error: ApiErrorPayload, + pub meta: ApiResponseMeta, +} + +impl LegacyApiErrorResponse { + pub fn new(error: ApiErrorPayload, meta: ApiResponseMeta) -> Self { + Self { error, meta } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn success_envelope_serializes_null_error_field() { + let payload = serde_json::to_value(ApiSuccessEnvelope::new( + json!({ "service": "genarrative" }), + ApiResponseMeta::new( + API_VERSION, + Some("req-1".to_string()), + API_VERSION, + Some("GET /healthz".to_string()), + 12, + "2026-04-21T00:00:00Z", + ), + )) + .expect("payload should serialize"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!(payload["error"], Value::Null); + assert_eq!( + payload["meta"]["apiVersion"], + Value::String(API_VERSION.to_string()) + ); + } + + #[test] + fn error_envelope_serializes_null_data_field() { + let payload = serde_json::to_value(ApiErrorEnvelope::new( + ApiErrorPayload::new("BAD_REQUEST", "请求参数不合法", None), + ApiResponseMeta::new( + API_VERSION, + Some("req-2".to_string()), + API_VERSION, + Some("POST /api/test".to_string()), + 21, + "2026-04-21T00:00:01Z", + ), + )) + .expect("payload should serialize"); + + assert_eq!(payload["ok"], Value::Bool(false)); + assert_eq!(payload["data"], Value::Null); + assert_eq!( + payload["error"]["code"], + Value::String("BAD_REQUEST".to_string()) + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs new file mode 100644 index 00000000..7ed99f16 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -0,0 +1,361 @@ +use std::collections::BTreeMap; + +use platform_oss::{ + OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectUploadTicketRequest { + pub legacy_prefix: String, + #[serde(default)] + pub path_segments: Vec, + pub file_name: String, + #[serde(default)] + pub content_type: Option, + #[serde(default)] + pub access: Option, + #[serde(default)] + pub metadata: BTreeMap, + #[serde(default)] + pub max_size_bytes: Option, + #[serde(default)] + pub expire_seconds: Option, + #[serde(default)] + pub success_action_status: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GetReadUrlQuery { + #[serde(default)] + pub object_key: Option, + #[serde(default)] + pub legacy_public_path: Option, + #[serde(default)] + pub expire_seconds: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConfirmAssetObjectAccessPolicy { + Private, + PublicRead, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmAssetObjectRequest { + #[serde(default)] + pub bucket: Option, + pub object_key: String, + #[serde(default)] + pub content_type: Option, + #[serde(default)] + pub content_length: Option, + #[serde(default)] + pub content_hash: Option, + pub asset_kind: String, + #[serde(default)] + pub access_policy: Option, + #[serde(default)] + pub source_job_id: Option, + #[serde(default)] + pub owner_user_id: Option, + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub entity_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BindAssetObjectRequest { + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + #[serde(default)] + pub owner_user_id: Option, + #[serde(default)] + pub profile_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectUploadTicketResponse { + pub upload: DirectUploadTicketPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DirectUploadTicketPayload { + pub signature_version: String, + pub provider: String, + pub bucket: String, + pub endpoint: String, + pub host: String, + pub object_key: String, + pub legacy_public_path: String, + #[serde(default)] + pub content_type: Option, + pub access: OssObjectAccess, + pub key_prefix: String, + pub expires_at: String, + pub max_size_bytes: u64, + pub success_action_status: u16, + pub form_fields: DirectUploadTicketFormFields, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct DirectUploadTicketFormFields { + pub key: String, + pub policy: String, + #[serde(rename = "OSSAccessKeyId")] + pub oss_access_key_id: String, + #[serde(rename = "Signature")] + pub signature: String, + #[serde(rename = "success_action_status")] + pub success_action_status: String, + #[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(flatten)] + pub metadata: BTreeMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GetAssetReadUrlResponse { + pub read: AssetReadUrlPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AssetReadUrlPayload { + pub provider: String, + pub bucket: String, + pub endpoint: String, + pub host: String, + pub object_key: String, + pub expires_at: String, + pub signed_url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmAssetObjectResponse { + pub asset_object: AssetObjectPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AssetObjectPayload { + pub asset_object_id: String, + pub bucket: String, + pub object_key: String, + pub access_policy: String, + #[serde(default)] + pub content_type: Option, + pub content_length: u64, + #[serde(default)] + pub content_hash: Option, + pub version: u32, + #[serde(default)] + pub source_job_id: Option, + #[serde(default)] + pub owner_user_id: Option, + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub entity_id: Option, + pub asset_kind: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BindAssetObjectResponse { + pub asset_binding: AssetBindingPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AssetBindingPayload { + pub binding_id: String, + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + #[serde(default)] + pub owner_user_id: Option, + #[serde(default)] + pub profile_id: Option, + pub created_at: String, + pub updated_at: String, +} + +impl From for DirectUploadTicketFormFields { + fn from(value: OssPostObjectFormFields) -> Self { + Self { + key: value.key, + policy: value.policy, + oss_access_key_id: value.oss_access_key_id, + signature: value.signature, + success_action_status: value.success_action_status, + content_type: value.content_type, + metadata: value.metadata, + } + } +} + +impl From for DirectUploadTicketPayload { + fn from(value: OssPostObjectResponse) -> Self { + Self { + signature_version: value.signature_version.to_string(), + provider: value.provider.to_string(), + bucket: value.bucket, + endpoint: value.endpoint, + host: value.host, + object_key: value.object_key, + legacy_public_path: value.legacy_public_path, + content_type: value.content_type, + access: value.access, + key_prefix: value.key_prefix, + expires_at: value.expires_at, + max_size_bytes: value.max_size_bytes, + success_action_status: value.success_action_status, + form_fields: value.form_fields.into(), + } + } +} + +impl From for AssetReadUrlPayload { + fn from(value: OssSignedGetObjectUrlResponse) -> Self { + Self { + provider: value.provider.to_string(), + bucket: value.bucket, + endpoint: value.endpoint, + host: value.host, + object_key: value.object_key, + expires_at: value.expires_at, + signed_url: value.signed_url, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn confirm_asset_object_access_policy_uses_snake_case() { + let payload = serde_json::to_value(ConfirmAssetObjectAccessPolicy::PublicRead) + .expect("payload should serialize"); + + assert_eq!(payload, json!("public_read")); + } + + #[test] + fn bind_asset_object_request_uses_camel_case_fields() { + let payload = serde_json::to_value(BindAssetObjectRequest { + asset_object_id: "assetobj_1".to_string(), + entity_kind: "character".to_string(), + entity_id: "npc_1".to_string(), + slot: "primary_visual".to_string(), + asset_kind: "character_visual".to_string(), + owner_user_id: Some("user_1".to_string()), + profile_id: Some("profile_1".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "assetObjectId": "assetobj_1", + "entityKind": "character", + "entityId": "npc_1", + "slot": "primary_visual", + "assetKind": "character_visual", + "ownerUserId": "user_1", + "profileId": "profile_1" + }) + ); + } + + #[test] + fn direct_upload_ticket_response_keeps_form_fields_shape() { + let payload = serde_json::to_value(CreateDirectUploadTicketResponse { + upload: DirectUploadTicketPayload::from(OssPostObjectResponse { + signature_version: "v1", + provider: "aliyun-oss", + bucket: "genarrative-assets".to_string(), + endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(), + host: "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com".to_string(), + object_key: "generated-characters/hero/master.png".to_string(), + legacy_public_path: "/generated-characters/hero/master.png".to_string(), + content_type: Some("image/png".to_string()), + access: OssObjectAccess::Private, + key_prefix: "generated-characters/hero".to_string(), + expires_at: "2026-04-21T00:00:00Z".to_string(), + max_size_bytes: 1024, + success_action_status: 200, + form_fields: OssPostObjectFormFields { + key: "generated-characters/hero/master.png".to_string(), + policy: "policy".to_string(), + oss_access_key_id: "ak".to_string(), + signature: "sig".to_string(), + success_action_status: "200".to_string(), + content_type: Some("image/png".to_string()), + metadata: BTreeMap::from([( + "x-oss-meta-asset-kind".to_string(), + "character_visual".to_string(), + )]), + }, + }), + }) + .expect("payload should serialize"); + + assert_eq!(payload["upload"]["signatureVersion"], json!("v1")); + assert_eq!( + payload["upload"]["formFields"]["OSSAccessKeyId"], + json!("ak") + ); + assert_eq!( + payload["upload"]["formFields"]["x-oss-meta-asset-kind"], + json!("character_visual") + ); + } + + #[test] + fn confirm_asset_object_response_uses_camel_case_fields() { + let payload = serde_json::to_value(ConfirmAssetObjectResponse { + asset_object: AssetObjectPayload { + asset_object_id: "assetobj_1".to_string(), + bucket: "genarrative-assets".to_string(), + object_key: "generated-characters/hero/master.png".to_string(), + access_policy: "private".to_string(), + content_type: Some("image/png".to_string()), + content_length: 1024, + content_hash: Some("etag-1".to_string()), + version: 1, + source_job_id: Some("job_1".to_string()), + owner_user_id: Some("user_1".to_string()), + profile_id: Some("profile_1".to_string()), + entity_id: Some("entity_1".to_string()), + asset_kind: "character_visual".to_string(), + created_at: "1.000000Z".to_string(), + updated_at: "1.000000Z".to_string(), + }, + }) + .expect("payload should serialize"); + + assert_eq!(payload["assetObject"]["assetObjectId"], json!("assetobj_1")); + assert_eq!(payload["assetObject"]["accessPolicy"], json!("private")); + assert_eq!(payload["assetObject"]["contentLength"], json!(1024)); + } +} diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs new file mode 100644 index 00000000..1298d70f --- /dev/null +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -0,0 +1,218 @@ +use serde::{Deserialize, Serialize}; + +pub const AUTH_LOGIN_METHOD_PASSWORD: &str = "password"; +pub const AUTH_LOGIN_METHOD_PHONE: &str = "phone"; +pub const AUTH_LOGIN_METHOD_WECHAT: &str = "wechat"; +pub const AUTH_BINDING_STATUS_ACTIVE: &str = "active"; +pub const AUTH_BINDING_STATUS_PENDING_BIND_PHONE: &str = "pending_bind_phone"; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthLoginOptionsResponse { + pub available_login_methods: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthUserPayload { + pub id: String, + pub username: String, + pub display_name: String, + pub phone_number_masked: Option, + pub login_method: String, + pub binding_status: String, + pub wechat_bound: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PasswordEntryRequest { + pub username: String, + pub password: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PasswordEntryResponse { + pub token: String, + pub user: AuthUserPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthMeResponse { + pub user: AuthUserPayload, + pub available_login_methods: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthSessionsResponse { + pub sessions: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthSessionSummaryPayload { + pub session_id: String, + pub client_type: String, + pub client_runtime: String, + pub client_platform: String, + pub client_label: String, + pub device_display_name: String, + pub mini_program_app_id: Option, + pub mini_program_env: Option, + pub user_agent: Option, + pub ip_masked: Option, + pub is_current: bool, + pub created_at: String, + pub last_seen_at: String, + pub expires_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RefreshSessionResponse { + pub token: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LogoutResponse { + pub ok: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LogoutAllResponse { + pub ok: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PhoneSendCodeRequest { + pub phone: String, + pub scene: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PhoneSendCodeResponse { + pub ok: bool, + pub cooldown_seconds: u64, + pub expires_in_seconds: u64, + pub provider_request_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PhoneLoginRequest { + pub phone: String, + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PhoneLoginResponse { + pub token: String, + pub user: AuthUserPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WechatStartQuery { + pub redirect_path: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WechatStartResponse { + pub authorization_url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct WechatCallbackQuery { + pub state: Option, + pub code: Option, + pub mock_code: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WechatBindPhoneRequest { + pub phone: String, + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WechatBindPhoneResponse { + pub token: String, + pub user: AuthUserPayload, +} + +pub fn build_available_login_methods( + sms_auth_enabled: bool, + wechat_auth_enabled: bool, +) -> Vec { + let mut methods = Vec::new(); + if sms_auth_enabled { + methods.push(AUTH_LOGIN_METHOD_PHONE.to_string()); + } + if wechat_auth_enabled { + methods.push(AUTH_LOGIN_METHOD_WECHAT.to_string()); + } + methods +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn available_login_methods_keep_phone_then_wechat_order() { + let methods = build_available_login_methods(true, true); + + assert_eq!( + methods, + vec![ + AUTH_LOGIN_METHOD_PHONE.to_string(), + AUTH_LOGIN_METHOD_WECHAT.to_string() + ] + ); + } + + #[test] + fn password_entry_request_uses_camel_case_fields() { + let payload = serde_json::to_value(PasswordEntryRequest { + username: "guest_001".to_string(), + password: "secret123".to_string(), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "username": "guest_001", + "password": "secret123" + }) + ); + } + + #[test] + fn wechat_callback_query_keeps_provider_compatible_field_names() { + let payload = serde_json::to_value(WechatCallbackQuery { + state: Some("state-1".to_string()), + code: Some("code-1".to_string()), + mock_code: Some("mock-1".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "state": "state-1", + "code": "code-1", + "mock_code": "mock-1" + }) + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs new file mode 100644 index 00000000..0cc3d258 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -0,0 +1,8 @@ +pub mod ai; +pub mod api; +pub mod assets; +pub mod auth; +pub mod llm; +pub mod runtime; +pub mod runtime_story; +pub mod story; diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs new file mode 100644 index 00000000..a4856a0d --- /dev/null +++ b/server-rs/crates/shared-contracts/src/llm.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LlmChatMessageRole { + System, + User, + Assistant, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LlmChatMessagePayload { + pub role: LlmChatMessageRole, + pub content: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LlmChatCompletionRequest { + #[serde(default)] + pub model: Option, + #[serde(default)] + pub stream: bool, + pub messages: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LlmChatCompletionResponse { + pub id: Option, + pub model: String, + pub content: String, + pub finish_reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn llm_chat_completion_request_keeps_openai_compatible_field_names() { + let payload = serde_json::to_value(LlmChatCompletionRequest { + model: Some("doubao-test".to_string()), + stream: false, + messages: vec![ + LlmChatMessagePayload { + role: LlmChatMessageRole::System, + content: "系统".to_string(), + }, + LlmChatMessagePayload { + role: LlmChatMessageRole::User, + content: "用户".to_string(), + }, + ], + }) + .expect("payload should serialize"); + + assert_eq!(payload["model"], json!("doubao-test")); + assert_eq!(payload["stream"], json!(false)); + assert_eq!(payload["messages"][0]["role"], json!("system")); + } +} diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs new file mode 100644 index 00000000..06fd4934 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -0,0 +1,517 @@ +use serde::{Deserialize, Serialize}; + +pub const RUNTIME_PLATFORM_THEME_LIGHT: &str = "light"; +pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync"; +pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; +pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; +pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; +pub const BROWSE_HISTORY_THEME_MODE_TIDE: &str = "tide"; +pub const BROWSE_HISTORY_THEME_MODE_RIFT: &str = "rift"; +pub const BROWSE_HISTORY_THEME_MODE_MYTHIC: &str = "mythic"; +pub const CUSTOM_WORLD_VISIBILITY_DRAFT: &str = "draft"; +pub const CUSTOM_WORLD_VISIBILITY_PUBLISHED: &str = "published"; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeSettingsResponse { + pub music_volume: f32, + pub platform_theme: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PutRuntimeSettingsRequest { + pub music_volume: f32, + pub platform_theme: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlatformBrowseHistoryEntryResponse { + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub author_display_name: String, + pub visited_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlatformBrowseHistoryWriteEntryRequest { + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + #[serde(default)] + pub subtitle: Option, + #[serde(default)] + pub summary_text: Option, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub theme_mode: Option, + #[serde(default)] + pub author_display_name: Option, + #[serde(default)] + pub visited_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlatformBrowseHistoryBatchSyncRequest { + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum PlatformBrowseHistoryUpsertRequest { + Single(PlatformBrowseHistoryWriteEntryRequest), + Batch(PlatformBrowseHistoryBatchSyncRequest), +} + +impl PlatformBrowseHistoryUpsertRequest { + pub fn into_entries(self) -> Vec { + match self { + Self::Single(entry) => vec![entry], + Self::Batch(batch) => batch.entries, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlatformBrowseHistoryResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileDashboardSummaryResponse { + pub wallet_balance: u64, + pub total_play_time_ms: u64, + pub played_world_count: u32, + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileWalletLedgerEntryResponse { + pub id: String, + pub amount_delta: i64, + pub balance_after: u64, + pub source_type: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileWalletLedgerResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfilePlayedWorkSummaryResponse { + pub world_key: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub world_type: Option, + pub world_title: String, + pub world_subtitle: String, + pub first_played_at: String, + pub last_played_at: String, + pub last_observed_play_time_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfilePlayStatsResponse { + pub total_play_time_ms: u64, + pub played_works: Vec, + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeInventorySlotResponse { + pub slot_id: String, + pub container_kind: String, + pub slot_key: String, + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: String, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: String, + pub source_reference_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeInventoryStateResponse { + pub runtime_session_id: String, + pub actor_user_id: String, + pub backpack_items: Vec, + pub equipment_items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldProfileUpsertRequest { + pub profile: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldLibraryEntryResponse { + pub owner_user_id: String, + pub profile_id: String, + pub profile: serde_json::Value, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldGalleryCardResponse { + pub owner_user_id: String, + pub profile_id: String, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldLibraryResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldLibraryMutationResponse { + pub entry: CustomWorldLibraryEntryResponse, + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldGalleryResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldGalleryDetailResponse { + pub entry: CustomWorldLibraryEntryResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateCustomWorldAgentSessionRequest { + #[serde(default)] + pub seed_text: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SendCustomWorldAgentMessageRequest { + pub client_message_id: String, + pub text: String, + #[serde(default)] + pub quick_fill_requested: Option, + #[serde(default)] + pub focus_card_id: Option, + #[serde(default)] + pub selected_card_ids: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldAgentMessageResponse { + pub id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, + pub related_operation_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldAgentOperationResponse { + pub operation_id: String, + #[serde(rename = "type")] + pub operation_type: String, + pub status: String, + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, + pub error: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldDraftCardSummaryResponse { + pub id: String, + pub kind: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub status: String, + pub linked_ids: Vec, + pub warning_count: u32, + pub asset_status: Option, + pub asset_status_label: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldAgentCheckpointResponse { + pub checkpoint_id: String, + pub created_at: String, + pub label: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldSupportedActionResponse { + pub action: String, + pub enabled: bool, + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldAgentSessionSnapshotResponse { + pub session_id: String, + pub current_turn: u32, + pub anchor_content: serde_json::Value, + pub progress_percent: u32, + pub last_assistant_reply: Option, + pub stage: String, + pub focus_card_id: Option, + pub creator_intent: serde_json::Value, + pub creator_intent_readiness: serde_json::Value, + pub anchor_pack: serde_json::Value, + pub lock_state: serde_json::Value, + pub draft_profile: serde_json::Value, + pub messages: Vec, + pub draft_cards: Vec, + pub pending_clarifications: Vec, + pub suggested_actions: Vec, + pub recommended_replies: Vec, + pub quality_findings: Vec, + pub asset_coverage: serde_json::Value, + pub checkpoints: Vec, + pub supported_actions: Vec, + pub result_preview: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomWorldAgentSessionResponse { + pub session: CustomWorldAgentSessionSnapshotResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn runtime_settings_request_uses_camel_case_fields() { + let payload = serde_json::to_value(PutRuntimeSettingsRequest { + music_volume: 0.42, + platform_theme: RUNTIME_PLATFORM_THEME_LIGHT.to_string(), + }) + .expect("payload should serialize"); + + assert_eq!(payload["platformTheme"], json!("light")); + let music_volume = payload["musicVolume"] + .as_f64() + .expect("musicVolume should serialize as number"); + assert!((music_volume - 0.42).abs() < 0.0001); + } + + #[test] + fn browse_history_response_uses_camel_case_fields() { + let payload = serde_json::to_value(PlatformBrowseHistoryResponse { + entries: vec![PlatformBrowseHistoryEntryResponse { + owner_user_id: "owner-1".to_string(), + profile_id: "profile-1".to_string(), + world_name: "世界".to_string(), + subtitle: "".to_string(), + summary_text: "".to_string(), + cover_image_src: None, + theme_mode: BROWSE_HISTORY_THEME_MODE_MYTHIC.to_string(), + author_display_name: "玩家".to_string(), + visited_at: "2026-04-21T00:00:00Z".to_string(), + }], + }) + .expect("payload should serialize"); + + assert_eq!(payload["entries"][0]["ownerUserId"], json!("owner-1")); + assert_eq!(payload["entries"][0]["themeMode"], json!("mythic")); + assert_eq!( + payload["entries"][0]["visitedAt"], + json!("2026-04-21T00:00:00Z") + ); + } + + #[test] + fn browse_history_upsert_request_accepts_single_or_batch_shape() { + let single: PlatformBrowseHistoryUpsertRequest = serde_json::from_value(json!({ + "ownerUserId": "owner-1", + "profileId": "profile-1", + "worldName": "世界" + })) + .expect("single shape should deserialize"); + let batch: PlatformBrowseHistoryUpsertRequest = serde_json::from_value(json!({ + "entries": [{ + "ownerUserId": "owner-1", + "profileId": "profile-1", + "worldName": "世界" + }] + })) + .expect("batch shape should deserialize"); + + assert_eq!(single.into_entries().len(), 1); + assert_eq!(batch.into_entries().len(), 1); + } + + #[test] + fn profile_dashboard_response_uses_camel_case_fields() { + let payload = serde_json::to_value(ProfileDashboardSummaryResponse { + wallet_balance: 8, + total_play_time_ms: 16, + played_world_count: 3, + updated_at: Some("2026-04-22T10:00:00Z".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!(payload["walletBalance"], json!(8)); + assert_eq!(payload["totalPlayTimeMs"], json!(16)); + assert_eq!(payload["playedWorldCount"], json!(3)); + assert_eq!(payload["updatedAt"], json!("2026-04-22T10:00:00Z")); + } + + #[test] + fn profile_wallet_ledger_response_uses_camel_case_fields() { + let payload = serde_json::to_value(ProfileWalletLedgerResponse { + entries: vec![ProfileWalletLedgerEntryResponse { + id: "ledger-1".to_string(), + amount_delta: 12, + balance_after: 80, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), + created_at: "2026-04-22T10:00:00Z".to_string(), + }], + }) + .expect("payload should serialize"); + + assert_eq!(payload["entries"][0]["amountDelta"], json!(12)); + assert_eq!(payload["entries"][0]["balanceAfter"], json!(80)); + assert_eq!(payload["entries"][0]["sourceType"], json!("snapshot_sync")); + assert_eq!( + payload["entries"][0]["createdAt"], + json!("2026-04-22T10:00:00Z") + ); + } + + #[test] + fn profile_play_stats_response_uses_camel_case_fields() { + let payload = serde_json::to_value(ProfilePlayStatsResponse { + total_play_time_ms: 18, + played_works: vec![ProfilePlayedWorkSummaryResponse { + world_key: "builtin:WUXIA".to_string(), + owner_user_id: None, + profile_id: None, + world_type: Some("WUXIA".to_string()), + world_title: "武侠世界".to_string(), + world_subtitle: "".to_string(), + first_played_at: "2026-04-20T10:00:00Z".to_string(), + last_played_at: "2026-04-22T10:00:00Z".to_string(), + last_observed_play_time_ms: 1200, + }], + updated_at: Some("2026-04-22T10:00:00Z".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!(payload["totalPlayTimeMs"], json!(18)); + assert_eq!( + payload["playedWorks"][0]["worldKey"], + json!("builtin:WUXIA") + ); + assert_eq!( + payload["playedWorks"][0]["lastObservedPlayTimeMs"], + json!(1200) + ); + assert_eq!(payload["updatedAt"], json!("2026-04-22T10:00:00Z")); + } + + #[test] + fn runtime_inventory_state_response_uses_camel_case_fields() { + let payload = serde_json::to_value(RuntimeInventoryStateResponse { + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_001".to_string(), + backpack_items: vec![RuntimeInventorySlotResponse { + slot_id: "invslot_001".to_string(), + container_kind: "backpack".to_string(), + slot_key: "invslot_001".to_string(), + item_id: "consumable_heal_potion".to_string(), + category: "消耗品".to_string(), + name: "疗伤药".to_string(), + description: Some("用于恢复少量气血。".to_string()), + quantity: 2, + rarity: "common".to_string(), + tags: vec!["healing".to_string()], + stackable: true, + stack_key: "heal_potion".to_string(), + equipment_slot_id: None, + source_kind: "treasure_reward".to_string(), + source_reference_id: Some("treasure_001".to_string()), + created_at: "2026-04-22T10:00:00Z".to_string(), + updated_at: "2026-04-22T10:01:00Z".to_string(), + }], + equipment_items: vec![], + }) + .expect("payload should serialize"); + + assert_eq!(payload["runtimeSessionId"], json!("runtime_001")); + assert_eq!(payload["actorUserId"], json!("user_001")); + assert_eq!(payload["backpackItems"][0]["slotId"], json!("invslot_001")); + assert_eq!( + payload["backpackItems"][0]["sourceKind"], + json!("treasure_reward") + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/runtime_story.rs b/server-rs/crates/shared-contracts/src/runtime_story.rs new file mode 100644 index 00000000..48784d61 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/runtime_story.rs @@ -0,0 +1,324 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStorySnapshotPayload { + pub saved_at: String, + pub bottom_tab: String, + pub game_state: Value, + #[serde(default)] + pub current_story: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryStateResolveRequest { + pub session_id: String, + #[serde(default)] + pub client_version: Option, + #[serde(default)] + pub snapshot: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryOptionView { + pub function_id: String, + pub action_text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail_text: Option, + pub scope: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interaction: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum RuntimeStoryOptionInteraction { + #[serde(rename_all = "camelCase")] + Npc { + npc_id: String, + action: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + quest_id: Option, + }, + #[serde(rename_all = "camelCase")] + Treasure { + action: String, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryPlayerViewModel { + pub hp: i32, + pub max_hp: i32, + pub mana: i32, + pub max_mana: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryCompanionViewModel { + pub npc_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub character_id: Option, + pub joined_at_affinity: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryEncounterViewModel { + pub id: String, + pub kind: String, + pub npc_name: String, + pub hostile: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub affinity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recruited: Option, + pub interaction_active: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub battle_mode: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryStatusViewModel { + pub in_battle: bool, + pub npc_interaction_active: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_npc_battle_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_npc_battle_outcome: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeBattlePresentation { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub damage_dealt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub damage_taken: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryViewModel { + pub player: RuntimeStoryPlayerViewModel, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encounter: Option, + pub companions: Vec, + pub available_options: Vec, + pub status: RuntimeStoryStatusViewModel, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryPresentation { + pub action_text: String, + pub result_text: String, + pub story_text: String, + pub options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toast: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub battle: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RuntimeStoryPatch { + #[serde(rename_all = "camelCase")] + StoryHistoryAppend { + action_text: String, + result_text: String, + }, + #[serde(rename_all = "camelCase")] + NpcAffinityChanged { + npc_id: String, + previous_affinity: i32, + next_affinity: i32, + }, + #[serde(rename_all = "camelCase")] + BattleResolved { + function_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + target_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + damage_dealt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + damage_taken: Option, + outcome: String, + }, + #[serde(rename_all = "camelCase")] + StatusChanged { + in_battle: bool, + npc_interaction_active: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + current_npc_battle_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + current_npc_battle_outcome: Option, + }, + #[serde(rename_all = "camelCase")] + EncounterChanged { + #[serde(default, skip_serializing_if = "Option::is_none")] + encounter_id: Option, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStoryActionResponse { + pub session_id: String, + pub server_version: u32, + pub view_model: RuntimeStoryViewModel, + pub presentation: RuntimeStoryPresentation, + pub patches: Vec, + pub snapshot: RuntimeStorySnapshotPayload, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn runtime_story_state_resolve_request_uses_camel_case_fields() { + let payload = serde_json::to_value(RuntimeStoryStateResolveRequest { + session_id: "runtime-main".to_string(), + client_version: Some(7), + snapshot: Some(RuntimeStorySnapshotPayload { + saved_at: "2026-04-22T12:00:00.000Z".to_string(), + bottom_tab: "adventure".to_string(), + game_state: json!({ "runtimeSessionId": "runtime-main" }), + current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })), + }), + }) + .expect("payload should serialize"); + + assert_eq!(payload["sessionId"], json!("runtime-main")); + assert_eq!(payload["clientVersion"], json!(7)); + assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z")); + assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure")); + assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main")); + assert_eq!( + payload["snapshot"]["currentStory"]["text"], + json!("营地里的火光还没有熄灭。") + ); + } + + #[test] + fn runtime_story_action_response_uses_camel_case_fields() { + let payload = serde_json::to_value(RuntimeStoryActionResponse { + session_id: "runtime-main".to_string(), + server_version: 8, + view_model: RuntimeStoryViewModel { + player: RuntimeStoryPlayerViewModel { + hp: 32, + max_hp: 40, + mana: 18, + max_mana: 20, + }, + encounter: Some(RuntimeStoryEncounterViewModel { + id: "npc_camp_firekeeper".to_string(), + kind: "npc".to_string(), + npc_name: "守火人".to_string(), + hostile: false, + affinity: Some(12), + recruited: Some(false), + interaction_active: true, + battle_mode: None, + }), + companions: vec![RuntimeStoryCompanionViewModel { + npc_id: "npc_companion_001".to_string(), + character_id: Some("char_companion_001".to_string()), + joined_at_affinity: 64, + }], + available_options: vec![RuntimeStoryOptionView { + function_id: "npc_chat".to_string(), + action_text: "继续交谈".to_string(), + detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), + scope: "npc".to_string(), + interaction: Some(RuntimeStoryOptionInteraction::Npc { + npc_id: "npc_camp_firekeeper".to_string(), + action: "chat".to_string(), + quest_id: None, + }), + payload: Some(json!({ "note": "server-runtime-test" })), + disabled: None, + reason: None, + }], + status: RuntimeStoryStatusViewModel { + in_battle: false, + npc_interaction_active: true, + current_npc_battle_mode: None, + current_npc_battle_outcome: None, + }, + }, + presentation: RuntimeStoryPresentation { + action_text: "".to_string(), + result_text: "".to_string(), + story_text: "守火人抬眼看了你一瞬,示意你把想问的话继续说完。".to_string(), + options: vec![RuntimeStoryOptionView { + function_id: "npc_chat".to_string(), + action_text: "继续交谈".to_string(), + detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), + scope: "npc".to_string(), + interaction: Some(RuntimeStoryOptionInteraction::Npc { + npc_id: "npc_camp_firekeeper".to_string(), + action: "chat".to_string(), + quest_id: None, + }), + payload: Some(json!({ "note": "server-runtime-test" })), + disabled: None, + reason: None, + }], + toast: None, + battle: None, + }, + patches: vec![RuntimeStoryPatch::StatusChanged { + in_battle: false, + npc_interaction_active: true, + current_npc_battle_mode: None, + current_npc_battle_outcome: None, + }], + snapshot: RuntimeStorySnapshotPayload { + saved_at: "2026-04-22T12:00:00.000Z".to_string(), + bottom_tab: "adventure".to_string(), + game_state: json!({ "runtimeSessionId": "runtime-main" }), + current_story: Some(json!({ + "text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。" + })), + }, + }) + .expect("payload should serialize"); + + assert_eq!(payload["sessionId"], json!("runtime-main")); + assert_eq!(payload["serverVersion"], json!(8)); + assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40)); + assert_eq!( + payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"], + json!("npc_camp_firekeeper") + ); + assert_eq!( + payload["presentation"]["storyText"], + json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。") + ); + assert_eq!(payload["patches"][0]["type"], json!("status_changed")); + assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure")); + } +} diff --git a/server-rs/crates/shared-contracts/src/story.rs b/server-rs/crates/shared-contracts/src/story.rs new file mode 100644 index 00000000..34344c22 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/story.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BeginStorySessionRequest { + pub runtime_session_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + #[serde(default)] + pub opening_summary: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ContinueStoryRequest { + pub story_session_id: String, + pub narrative_text: String, + #[serde(default)] + pub choice_function_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StorySessionPayload { + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + #[serde(default)] + pub opening_summary: Option, + pub latest_narrative_text: String, + #[serde(default)] + pub latest_choice_function_id: Option, + pub status: String, + pub version: u32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StoryEventPayload { + pub event_id: String, + pub story_session_id: String, + pub event_kind: String, + pub narrative_text: String, + #[serde(default)] + pub choice_function_id: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StorySessionMutationResponse { + pub story_session: StorySessionPayload, + pub story_event: StoryEventPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StorySessionStateResponse { + pub story_session: StorySessionPayload, + pub story_events: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn continue_story_request_uses_camel_case_fields() { + let payload = serde_json::to_value(ContinueStoryRequest { + story_session_id: "storysess_1".to_string(), + narrative_text: "继续前进".to_string(), + choice_function_id: Some("npc_chat".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "storySessionId": "storysess_1", + "narrativeText": "继续前进", + "choiceFunctionId": "npc_chat" + }) + ); + } + + #[test] + fn story_session_mutation_response_uses_camel_case_fields() { + let payload = serde_json::to_value(StorySessionMutationResponse { + story_session: StorySessionPayload { + story_session_id: "storysess_1".to_string(), + runtime_session_id: "runtime_1".to_string(), + actor_user_id: "user_1".to_string(), + world_profile_id: "profile_1".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "篝火正在燃烧。".to_string(), + latest_choice_function_id: Some("talk".to_string()), + status: "active".to_string(), + version: 1, + created_at: "1.000000Z".to_string(), + updated_at: "1.000000Z".to_string(), + }, + story_event: StoryEventPayload { + event_id: "storyevt_1".to_string(), + story_session_id: "storysess_1".to_string(), + event_kind: "session_started".to_string(), + narrative_text: "篝火正在燃烧。".to_string(), + choice_function_id: Some("talk".to_string()), + created_at: "1.000000Z".to_string(), + }, + }) + .expect("payload should serialize"); + + assert_eq!( + payload["storySession"]["storySessionId"], + json!("storysess_1") + ); + assert_eq!(payload["storyEvent"]["eventKind"], json!("session_started")); + assert_eq!(payload["storyEvent"]["choiceFunctionId"], json!("talk")); + } + + #[test] + fn story_session_state_response_uses_camel_case_fields() { + let payload = serde_json::to_value(StorySessionStateResponse { + story_session: StorySessionPayload { + story_session_id: "storysess_1".to_string(), + runtime_session_id: "runtime_1".to_string(), + actor_user_id: "user_1".to_string(), + world_profile_id: "profile_1".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "你看见篝火边有人招手。".to_string(), + latest_choice_function_id: Some("talk_to_npc".to_string()), + status: "active".to_string(), + version: 2, + created_at: "1.000000Z".to_string(), + updated_at: "2.000000Z".to_string(), + }, + story_events: vec![StoryEventPayload { + event_id: "storyevt_2".to_string(), + story_session_id: "storysess_1".to_string(), + event_kind: "story_continued".to_string(), + narrative_text: "你看见篝火边有人招手。".to_string(), + choice_function_id: Some("talk_to_npc".to_string()), + created_at: "2.000000Z".to_string(), + }], + }) + .expect("payload should serialize"); + + assert_eq!( + payload["storySession"]["latestChoiceFunctionId"], + json!("talk_to_npc") + ); + assert_eq!( + payload["storyEvents"][0]["eventKind"], + json!("story_continued") + ); + } +} diff --git a/server-rs/crates/shared-kernel/Cargo.toml b/server-rs/crates/shared-kernel/Cargo.toml new file mode 100644 index 00000000..f0b0e842 --- /dev/null +++ b/server-rs/crates/shared-kernel/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shared-kernel" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +time = { version = "0.3", features = ["formatting", "parsing"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +uuid = { version = "1", features = ["v4"] } diff --git a/server-rs/crates/shared-kernel/README.md b/server-rs/crates/shared-kernel/README.md index b38f9ff1..ca5b894a 100644 --- a/server-rs/crates/shared-kernel/README.md +++ b/server-rs/crates/shared-kernel/README.md @@ -1,10 +1,10 @@ -# shared-kernel 共享 crate 占位说明 +# shared-kernel 共享 crate 阶段性说明 -日期:`2026-04-20` +日期:`2026-04-22` ## 1. crate 职责 -`shared-kernel` 是跨模块共享领域内核 crate,后续负责: +`shared-kernel` 是跨模块共享领域内核 crate,当前阶段已经开始承接最小共享基础能力,负责: 1. 共享 ID、值对象、枚举与基础领域类型 2. 共享时间、状态、版本、通用校验等基础规则 @@ -12,7 +12,13 @@ ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入具体共享类型与基础规则实现。 +当前阶段已落地的共享能力: + +1. 必填/可选字符串归一化 +2. 字符串列表归一化 +3. 前缀 UUID / 前缀种子 ID 生成 +4. RFC3339 格式化与解析 +5. 微秒时间戳文本格式化 后续与本 crate 直接相关的任务包括: @@ -21,8 +27,33 @@ 3. 抽取真正跨模块复用的最小领域规则 4. 避免把模块私有规则错误上提到共享内核 +当前已接入的 crate 已覆盖: + +1. `module-assets` +2. `module-auth` +3. `platform-auth` +4. `module-runtime` +5. `module-story` +6. `spacetime-client` +7. `api-server` +8. `module-ai` +9. `module-inventory` +10. `module-runtime-item` +11. `module-npc` +12. `module-quest` +13. `module-combat` +14. `module-progression` + ## 3. 边界约束 1. `shared-kernel` 只放跨模块最小共享内核,不承接具体业务模块的私有规则。 2. 任何进入本 crate 的类型都必须证明至少被多个模块稳定复用。 3. 不能把主模块实现重新堆进共享内核,避免形成新的“大公共垃圾桶”。 + +更详细的阶段性设计见: + +1. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md` +2. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md` +3. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md` +4. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md` +5. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md` diff --git a/server-rs/crates/shared-kernel/src/lib.rs b/server-rs/crates/shared-kernel/src/lib.rs new file mode 100644 index 00000000..ad773b9c --- /dev/null +++ b/server-rs/crates/shared-kernel/src/lib.rs @@ -0,0 +1,138 @@ +use time::OffsetDateTime; +#[cfg(not(target_arch = "wasm32"))] +use uuid::Uuid; + +/// 统一做必填字符串归一化,避免各模块散落重复的 `trim().to_string()`。 +pub fn normalize_required_string(value: impl AsRef) -> Option { + let normalized = value.as_ref().trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + +/// 统一做可选字符串归一化,空白字符串一律视为 `None`。 +pub fn normalize_optional_string(value: Option) -> Option { + value.and_then(normalize_required_string) +} + +/// 统一做字符串列表归一化,逐项裁剪并丢弃空白项。 +pub fn normalize_string_list(values: Vec) -> Vec { + values + .into_iter() + .filter_map(|value| normalize_required_string(value)) + .collect() +} + +/// 统一生成“前缀 + 十六进制微秒种子”的稳定 ID,适合业务对象主键。 +pub fn build_prefixed_seed_id(prefix: &str, seed_micros: i64) -> String { + format!("{prefix}{seed_micros:x}") +} + +/// 统一生成“前缀 + UUID simple”随机 ID,适合会话态或一次性票据主键。 +#[cfg(not(target_arch = "wasm32"))] +pub fn build_prefixed_uuid_id(prefix: &str) -> String { + format!("{prefix}{}", Uuid::new_v4().simple()) +} + +/// SpacetimeDB 的 wasm32 模块不应走浏览器/本地随机 UUID 生成。 +#[cfg(target_arch = "wasm32")] +pub fn build_prefixed_uuid_id(_prefix: &str) -> String { + panic!( + "shared-kernel::build_prefixed_uuid_id 不支持 wasm32,请改用显式 ID 或 SpacetimeDB 上下文生成能力" + ) +} + +/// 统一生成 UUID simple 字符串,供 token、随机种子等轻量场景复用。 +#[cfg(not(target_arch = "wasm32"))] +pub fn new_uuid_simple_string() -> String { + Uuid::new_v4().simple().to_string() +} + +/// SpacetimeDB 的 wasm32 模块不应走浏览器/本地随机 UUID 生成。 +#[cfg(target_arch = "wasm32")] +pub fn new_uuid_simple_string() -> String { + panic!( + "shared-kernel::new_uuid_simple_string 不支持 wasm32,请改用显式 ID 或 SpacetimeDB 上下文生成能力" + ) +} + +/// 统一格式化微秒时间戳,当前阶段固定为 `seconds.microsZ` 文本口径。 +pub fn format_timestamp_micros(micros: i64) -> String { + let seconds = micros.div_euclid(1_000_000); + let subsec_micros = micros.rem_euclid(1_000_000); + format!("{seconds}.{subsec_micros:06}Z") +} + +/// 统一格式化 RFC3339 字符串,避免每个模块自己拼格式化错误文案。 +pub fn format_rfc3339(value: OffsetDateTime) -> Result { + value + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| error.to_string()) +} + +/// 统一解析 RFC3339 字符串,供模块自行补充更贴近业务的错误上下文。 +pub fn parse_rfc3339(value: &str) -> Result { + OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_required_string_trims_and_filters_blank() { + assert_eq!( + normalize_required_string(" hero_001 "), + Some("hero_001".to_string()) + ); + assert_eq!(normalize_required_string(" "), None); + } + + #[test] + fn normalize_optional_string_filters_blank() { + assert_eq!( + normalize_optional_string(Some(" profile_001 ".to_string())), + Some("profile_001".to_string()) + ); + assert_eq!(normalize_optional_string(Some(" ".to_string())), None); + assert_eq!(normalize_optional_string(None), None); + } + + #[test] + fn normalize_string_list_trims_and_filters_blank() { + assert_eq!( + normalize_string_list(vec![ + " alpha ".to_string(), + "".to_string(), + " ".to_string(), + "beta".to_string() + ]), + vec!["alpha".to_string(), "beta".to_string()] + ); + } + + #[test] + fn build_prefixed_seed_id_uses_hex_seed() { + assert_eq!(build_prefixed_seed_id("assetobj_", 255), "assetobj_ff"); + } + + #[test] + fn format_timestamp_micros_is_stable() { + assert_eq!( + format_timestamp_micros(1_713_686_401_234_567), + "1713686401.234567Z" + ); + } + + #[test] + fn format_and_parse_rfc3339_round_trip() { + let now = OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1_713_686_400); + let text = format_rfc3339(now).expect("rfc3339 should format"); + let parsed = parse_rfc3339(&text).expect("rfc3339 should parse"); + + assert_eq!(parsed.unix_timestamp(), now.unix_timestamp()); + } +} diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index cf7b5e7d..976fa6da 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -5,6 +5,16 @@ version.workspace = true license.workspace = true [dependencies] +module-ai = { path = "../module-ai" } +module-custom-world = { path = "../module-custom-world" } module-assets = { path = "../module-assets" } +module-combat = { path = "../module-combat" } +module-inventory = { path = "../module-inventory" } +module-npc = { path = "../module-npc" } +module-runtime = { path = "../module-runtime" } +module-runtime-item = { path = "../module-runtime-item" } +module-story = { path = "../module-story" } +serde_json = "1" +shared-kernel = { path = "../shared-kernel" } spacetimedb-sdk = "2.1.0" tokio = { version = "1", features = ["rt", "sync", "time"] } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 22a321d1..3d51cf2c 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -7,22 +7,217 @@ use std::{ time::Duration, }; +use module_ai::{ + AiResultReferenceInput as DomainAiResultReferenceInput, + AiResultReferenceKind as DomainAiResultReferenceKind, + AiStageCompletionInput as DomainAiStageCompletionInput, + AiTaskCancelInput as DomainAiTaskCancelInput, AiTaskCreateInput as DomainAiTaskCreateInput, + AiTaskFailureInput as DomainAiTaskFailureInput, AiTaskFinishInput as DomainAiTaskFinishInput, + AiTaskKind as DomainAiTaskKind, AiTaskStageBlueprint as DomainAiTaskStageBlueprint, + AiTaskStageKind as DomainAiTaskStageKind, AiTaskStageStartInput as DomainAiTaskStageStartInput, + AiTaskStartInput as DomainAiTaskStartInput, + AiTextChunkAppendInput as DomainAiTextChunkAppendInput, +}; use module_assets::{ AssetEntityBindingRecord, AssetObjectAccessPolicy, AssetObjectRecord, build_asset_entity_binding_record, build_asset_object_record, }; +use module_combat::{ + BattleMode as DomainBattleMode, BattleStateInput as DomainBattleStateInput, + BattleStateQueryInput as DomainBattleStateQueryInput, + BattleStateSnapshot as DomainBattleStateSnapshot, BattleStatus as DomainBattleStatus, + CombatOutcome as DomainCombatOutcome, + ResolveCombatActionInput as DomainResolveCombatActionInput, + ResolveCombatActionResult as DomainResolveCombatActionResult, build_battle_state_query_input, + validate_battle_state_input, validate_resolve_combat_action_input, +}; +use module_custom_world::CustomWorldThemeMode as DomainCustomWorldThemeMode; +use module_inventory::{ + RuntimeInventoryStateQueryInput as DomainRuntimeInventoryStateQueryInput, + RuntimeInventoryStateRecord, + RuntimeInventoryStateSnapshot as DomainRuntimeInventoryStateSnapshot, + build_runtime_inventory_state_query_input, build_runtime_inventory_state_record, +}; +use module_npc::{ + NpcInteractionBattleMode as DomainNpcInteractionBattleMode, + NpcInteractionResult as DomainNpcInteractionResult, + NpcInteractionStatus as DomainNpcInteractionStatus, + NpcRelationStance as DomainNpcRelationStance, NpcRelationState as DomainNpcRelationState, + NpcStanceProfile as DomainNpcStanceProfile, NpcStateSnapshot as DomainNpcStateSnapshot, + ResolveNpcInteractionInput as DomainResolveNpcInteractionInput, +}; +use module_runtime::{ + RuntimeBrowseHistoryRecord, RuntimeBrowseHistoryThemeMode, RuntimePlatformTheme, + RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, + RuntimeProfileWalletLedgerEntryRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeSettingsRecord, build_runtime_browse_history_clear_input, + build_runtime_browse_history_list_input, build_runtime_browse_history_record, + build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, + build_runtime_profile_dashboard_record, build_runtime_profile_play_stats_get_input, + build_runtime_profile_play_stats_record, build_runtime_profile_wallet_ledger_entry_record, + build_runtime_profile_wallet_ledger_list_input, build_runtime_setting_get_input, + build_runtime_setting_record, build_runtime_setting_upsert_input, +}; +use module_runtime_item::{ + RuntimeItemEquipmentSlot as DomainRuntimeItemEquipmentSlot, + RuntimeItemRewardItemRarity as DomainRuntimeItemRewardItemRarity, + RuntimeItemRewardItemSnapshot as DomainRuntimeItemRewardItemSnapshot, + normalize_reward_item_snapshot, +}; +use module_story::{ + StoryContinueInput as DomainStoryContinueInput, StoryEventKind as DomainStoryEventKind, + StoryEventRecord, StorySessionInput as DomainStorySessionInput, StorySessionRecord, + StorySessionResultRecord, StorySessionStateInput as DomainStorySessionStateInput, + StorySessionStateRecord, StorySessionStatus as DomainStorySessionStatus, + build_story_continue_input, build_story_session_input, build_story_session_state_input, +}; +use shared_kernel::format_timestamp_micros; use spacetimedb_sdk::DbContext; use tokio::{sync::oneshot, time::timeout}; use crate::module_bindings::{ + AiResultReferenceInput as BindingAiResultReferenceInput, + AiResultReferenceKind as BindingAiResultReferenceKind, + AiResultReferenceSnapshot as BindingAiResultReferenceSnapshot, + AiStageCompletionInput as BindingAiStageCompletionInput, + AiTaskCancelInput as BindingAiTaskCancelInput, AiTaskCreateInput as BindingAiTaskCreateInput, + AiTaskFailureInput as BindingAiTaskFailureInput, AiTaskFinishInput as BindingAiTaskFinishInput, + AiTaskKind as BindingAiTaskKind, AiTaskProcedureResult as BindingAiTaskProcedureResult, + AiTaskSnapshot as BindingAiTaskSnapshot, AiTaskStageBlueprint as BindingAiTaskStageBlueprint, + AiTaskStageKind as BindingAiTaskStageKind, AiTaskStageSnapshot as BindingAiTaskStageSnapshot, + AiTaskStageStartInput as BindingAiTaskStageStartInput, + AiTaskStageStatus as BindingAiTaskStageStatus, AiTaskStartInput as BindingAiTaskStartInput, + AiTaskStatus as BindingAiTaskStatus, AiTextChunkAppendInput as BindingAiTextChunkAppendInput, + AiTextChunkSnapshot as BindingAiTextChunkSnapshot, AssetEntityBindingInput as BindingAssetEntityBindingInput, AssetEntityBindingProcedureResult as BindingAssetEntityBindingProcedureResult, AssetEntityBindingSnapshot as BindingAssetEntityBindingSnapshot, AssetObjectProcedureResult as BindingAssetObjectProcedureResult, AssetObjectUpsertInput as BindingAssetObjectUpsertInput, - AssetObjectUpsertSnapshot as BindingAssetObjectUpsertSnapshot, DbConnection, + AssetObjectUpsertSnapshot as BindingAssetObjectUpsertSnapshot, BattleMode as BindingBattleMode, + BattleStateInput as BindingBattleStateInput, + BattleStateProcedureResult as BindingBattleStateProcedureResult, + BattleStateQueryInput as BindingBattleStateQueryInput, + BattleStateSnapshot as BindingBattleStateSnapshot, BattleStatus as BindingBattleStatus, + CombatOutcome as BindingCombatOutcome, + CustomWorldAgentMessageSnapshot as BindingCustomWorldAgentMessageSnapshot, + CustomWorldAgentMessageSubmitInput as BindingCustomWorldAgentMessageSubmitInput, + CustomWorldAgentOperationGetInput as BindingCustomWorldAgentOperationGetInput, + CustomWorldAgentOperationProcedureResult as BindingCustomWorldAgentOperationProcedureResult, + CustomWorldAgentOperationSnapshot as BindingCustomWorldAgentOperationSnapshot, + CustomWorldAgentSessionCreateInput as BindingCustomWorldAgentSessionCreateInput, + CustomWorldAgentSessionGetInput as BindingCustomWorldAgentSessionGetInput, + CustomWorldAgentSessionProcedureResult as BindingCustomWorldAgentSessionProcedureResult, + CustomWorldAgentSessionSnapshot as BindingCustomWorldAgentSessionSnapshot, + CustomWorldDraftCardSnapshot as BindingCustomWorldDraftCardSnapshot, + CustomWorldGalleryDetailInput as BindingCustomWorldGalleryDetailInput, + CustomWorldGalleryEntrySnapshot as BindingCustomWorldGalleryEntrySnapshot, + CustomWorldGalleryListResult as BindingCustomWorldGalleryListResult, + CustomWorldLibraryDetailInput as BindingCustomWorldLibraryDetailInput, + CustomWorldLibraryMutationResult as BindingCustomWorldLibraryMutationResult, + CustomWorldProfileListInput as BindingCustomWorldProfileListInput, + CustomWorldProfileListResult as BindingCustomWorldProfileListResult, + CustomWorldProfilePublishInput as BindingCustomWorldProfilePublishInput, + CustomWorldProfileSnapshot as BindingCustomWorldProfileSnapshot, + CustomWorldProfileUnpublishInput as BindingCustomWorldProfileUnpublishInput, + CustomWorldProfileUpsertInput as BindingCustomWorldProfileUpsertInput, + CustomWorldPublicationStatus as BindingCustomWorldPublicationStatus, + CustomWorldPublishWorldInput as BindingCustomWorldPublishWorldInput, + CustomWorldPublishWorldResult as BindingCustomWorldPublishWorldResult, + CustomWorldPublishedProfileCompileSnapshot as BindingCustomWorldPublishedProfileCompileSnapshot, + CustomWorldThemeMode as BindingCustomWorldThemeMode, DbConnection, + InventoryContainerKind as BindingInventoryContainerKind, + InventoryEquipmentSlot as BindingInventoryEquipmentSlot, + InventoryItemRarity as BindingInventoryItemRarity, + InventoryItemSourceKind as BindingInventoryItemSourceKind, + InventorySlotSnapshot as BindingInventorySlotSnapshot, + NpcBattleInteractionProcedureResult as BindingNpcBattleInteractionProcedureResult, + NpcBattleInteractionResult as BindingNpcBattleInteractionResult, + NpcInteractionBattleMode as BindingNpcInteractionBattleMode, + NpcInteractionResult as BindingNpcInteractionResult, + NpcInteractionStatus as BindingNpcInteractionStatus, + NpcRelationStance as BindingNpcRelationStance, NpcRelationState as BindingNpcRelationState, + NpcStanceProfile as BindingNpcStanceProfile, NpcStateSnapshot as BindingNpcStateSnapshot, + ResolveCombatActionInput as BindingResolveCombatActionInput, + ResolveCombatActionProcedureResult as BindingResolveCombatActionProcedureResult, + ResolveCombatActionResult as BindingResolveCombatActionResult, + ResolveNpcBattleInteractionInput as BindingResolveNpcBattleInteractionInput, + ResolveNpcInteractionInput as BindingResolveNpcInteractionInput, + RuntimeBrowseHistoryClearInput as BindingRuntimeBrowseHistoryClearInput, + RuntimeBrowseHistoryListInput as BindingRuntimeBrowseHistoryListInput, + RuntimeBrowseHistoryProcedureResult as BindingRuntimeBrowseHistoryProcedureResult, + RuntimeBrowseHistorySnapshot as BindingRuntimeBrowseHistorySnapshot, + RuntimeBrowseHistorySyncInput as BindingRuntimeBrowseHistorySyncInput, + RuntimeBrowseHistoryThemeMode as BindingRuntimeBrowseHistoryThemeMode, + RuntimeBrowseHistoryWriteInput as BindingRuntimeBrowseHistoryWriteInput, + RuntimeInventoryStateProcedureResult as BindingRuntimeInventoryStateProcedureResult, + RuntimeInventoryStateQueryInput as BindingRuntimeInventoryStateQueryInput, + RuntimeInventoryStateSnapshot as BindingRuntimeInventoryStateSnapshot, + RuntimeItemEquipmentSlot as BindingRuntimeItemEquipmentSlot, + RuntimeItemRewardItemRarity as BindingRuntimeItemRewardItemRarity, + RuntimeItemRewardItemSnapshot as BindingRuntimeItemRewardItemSnapshot, + RuntimePlatformTheme as BindingRuntimePlatformTheme, + RuntimeProfileDashboardGetInput as BindingRuntimeProfileDashboardGetInput, + RuntimeProfileDashboardProcedureResult as BindingRuntimeProfileDashboardProcedureResult, + RuntimeProfileDashboardSnapshot as BindingRuntimeProfileDashboardSnapshot, + RuntimeProfilePlayStatsGetInput as BindingRuntimeProfilePlayStatsGetInput, + RuntimeProfilePlayStatsProcedureResult as BindingRuntimeProfilePlayStatsProcedureResult, + RuntimeProfilePlayStatsSnapshot as BindingRuntimeProfilePlayStatsSnapshot, + RuntimeProfilePlayedWorldSnapshot as BindingRuntimeProfilePlayedWorldSnapshot, + RuntimeProfileWalletLedgerEntrySnapshot as BindingRuntimeProfileWalletLedgerEntrySnapshot, + RuntimeProfileWalletLedgerListInput as BindingRuntimeProfileWalletLedgerListInput, + RuntimeProfileWalletLedgerProcedureResult as BindingRuntimeProfileWalletLedgerProcedureResult, + RuntimeProfileWalletLedgerSourceType as BindingRuntimeProfileWalletLedgerSourceType, + RuntimeSettingGetInput as BindingRuntimeSettingGetInput, + RuntimeSettingProcedureResult as BindingRuntimeSettingProcedureResult, + RuntimeSettingSnapshot as BindingRuntimeSettingSnapshot, + RuntimeSettingUpsertInput as BindingRuntimeSettingUpsertInput, + StoryContinueInput as BindingStoryContinueInput, StoryEventKind as BindingStoryEventKind, + StoryEventSnapshot as BindingStoryEventSnapshot, StorySessionInput as BindingStorySessionInput, + StorySessionProcedureResult as BindingStorySessionProcedureResult, + StorySessionSnapshot as BindingStorySessionSnapshot, + StorySessionStateInput as BindingStorySessionStateInput, + StorySessionStateProcedureResult as BindingStorySessionStateProcedureResult, + StorySessionStatus as BindingStorySessionStatus, + append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return as _, + attach_ai_result_reference_and_return_procedure::attach_ai_result_reference_and_return as _, + begin_story_session_and_return_procedure::begin_story_session_and_return as _, bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return as _, + cancel_ai_task_and_return_procedure::cancel_ai_task_and_return as _, + clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return as _, + complete_ai_stage_and_return_procedure::complete_ai_stage_and_return as _, + complete_ai_task_and_return_procedure::complete_ai_task_and_return as _, confirm_asset_object_and_return_procedure::confirm_asset_object_and_return as _, + continue_story_and_return_procedure::continue_story_and_return as _, + create_ai_task_and_return_procedure::create_ai_task_and_return as _, + create_battle_state_and_return_procedure::create_battle_state_and_return as _, + create_custom_world_agent_session_procedure::create_custom_world_agent_session as _, + fail_ai_task_and_return_procedure::fail_ai_task_and_return as _, + get_battle_state_procedure::get_battle_state as _, + get_custom_world_agent_operation_procedure::get_custom_world_agent_operation as _, + get_custom_world_agent_session_procedure::get_custom_world_agent_session as _, + get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail as _, + get_custom_world_library_detail_procedure::get_custom_world_library_detail as _, + get_profile_dashboard_procedure::get_profile_dashboard as _, + get_profile_play_stats_procedure::get_profile_play_stats as _, + get_runtime_inventory_state_procedure::get_runtime_inventory_state as _, + get_runtime_setting_or_default_procedure::get_runtime_setting_or_default as _, + get_story_session_state_procedure::get_story_session_state as _, + list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries as _, + list_custom_world_profiles_procedure::list_custom_world_profiles as _, + list_platform_browse_history_procedure::list_platform_browse_history as _, + list_profile_wallet_ledger_procedure::list_profile_wallet_ledger as _, + publish_custom_world_profile_and_return_procedure::publish_custom_world_profile_and_return as _, + publish_custom_world_world_procedure::publish_custom_world_world as _, + resolve_combat_action_and_return_procedure::resolve_combat_action_and_return as _, + resolve_npc_battle_interaction_and_return_procedure::resolve_npc_battle_interaction_and_return as _, + start_ai_task_reducer::start_ai_task as _, + start_ai_task_stage_reducer::start_ai_task_stage as _, + submit_custom_world_agent_message_procedure::submit_custom_world_agent_message as _, + unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return as _, + upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return as _, + upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return as _, + upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return as _, }; #[derive(Clone, Debug)] @@ -50,12 +245,205 @@ const CONFIRM_ASSET_OBJECT_TIMEOUT: Duration = Duration::from_secs(10); type ProcedureResultSender = Arc>>>>; +type ReducerResultSender = Arc>>>>; impl SpacetimeClient { pub fn new(config: SpacetimeClientConfig) -> Self { Self { config } } + pub async fn create_ai_task( + &self, + input: DomainAiTaskCreateInput, + ) -> Result { + let procedure_input = map_ai_task_create_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn start_ai_task( + &self, + input: DomainAiTaskStartInput, + ) -> Result<(), SpacetimeClientError> { + let reducer_input = map_ai_task_start_input(input); + + self.call_reducer_after_connect(move |connection, sender| { + let callback_sender = sender.clone(); + if let Err(error) = + connection + .reducers + .start_ai_task_then(reducer_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(|inner| inner.map_err(SpacetimeClientError::Runtime)); + send_reducer_once(&callback_sender, mapped); + }) + { + send_reducer_once( + &sender, + Err(SpacetimeClientError::Procedure(error.to_string())), + ); + } + }) + .await + } + + pub async fn start_ai_task_stage( + &self, + input: DomainAiTaskStageStartInput, + ) -> Result<(), SpacetimeClientError> { + let reducer_input = map_ai_task_stage_start_input(input); + + self.call_reducer_after_connect(move |connection, sender| { + let callback_sender = sender.clone(); + if let Err(error) = + connection + .reducers + .start_ai_task_stage_then(reducer_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(|inner| inner.map_err(SpacetimeClientError::Runtime)); + send_reducer_once(&callback_sender, mapped); + }) + { + send_reducer_once( + &sender, + Err(SpacetimeClientError::Procedure(error.to_string())), + ); + } + }) + .await + } + + pub async fn append_ai_text_chunk( + &self, + input: DomainAiTextChunkAppendInput, + ) -> Result { + let procedure_input = map_ai_text_chunk_append_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .append_ai_text_chunk_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn complete_ai_stage( + &self, + input: DomainAiStageCompletionInput, + ) -> Result { + let procedure_input = map_ai_stage_completion_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().complete_ai_stage_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn attach_ai_result_reference( + &self, + input: DomainAiResultReferenceInput, + ) -> Result { + let procedure_input = map_ai_result_reference_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .attach_ai_result_reference_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn complete_ai_task( + &self, + input: DomainAiTaskFinishInput, + ) -> Result { + let procedure_input = map_ai_task_finish_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().complete_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn fail_ai_task( + &self, + input: DomainAiTaskFailureInput, + ) -> Result { + let procedure_input = map_ai_task_failure_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().fail_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn cancel_ai_task( + &self, + input: DomainAiTaskCancelInput, + ) -> Result { + let procedure_input = map_ai_task_cancel_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().cancel_ai_task_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn confirm_asset_object( &self, input: module_assets::AssetObjectUpsertInput, @@ -94,6 +482,699 @@ impl SpacetimeClient { .await } + pub async fn get_runtime_settings( + &self, + user_id: String, + ) -> Result { + let procedure_input = map_runtime_setting_get_input( + build_runtime_setting_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_runtime_setting_or_default_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_setting_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_custom_world_profiles( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = BindingCustomWorldProfileListInput { owner_user_id }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_custom_world_profiles_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_profile_list_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_custom_world_library_detail( + &self, + owner_user_id: String, + profile_id: String, + ) -> Result { + let procedure_input = BindingCustomWorldLibraryDetailInput { + owner_user_id, + profile_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_library_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_detail_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn upsert_custom_world_profile( + &self, + input: CustomWorldProfileUpsertRecordInput, + ) -> Result { + let procedure_input = map_custom_world_profile_upsert_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_custom_world_profile_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn publish_custom_world_profile( + &self, + profile_id: String, + owner_user_id: String, + author_display_name: String, + published_at_micros: i64, + ) -> Result { + let procedure_input = BindingCustomWorldProfilePublishInput { + profile_id, + owner_user_id, + author_display_name, + published_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .publish_custom_world_profile_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn unpublish_custom_world_profile( + &self, + profile_id: String, + owner_user_id: String, + author_display_name: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = BindingCustomWorldProfileUnpublishInput { + profile_id, + owner_user_id, + author_display_name, + updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .unpublish_custom_world_profile_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_custom_world_gallery_entries( + &self, + ) -> Result, SpacetimeClientError> { + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .list_custom_world_gallery_entries_then(move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_gallery_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_custom_world_gallery_detail( + &self, + owner_user_id: String, + profile_id: String, + ) -> Result { + let procedure_input = BindingCustomWorldGalleryDetailInput { + owner_user_id, + profile_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_gallery_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn publish_custom_world_world( + &self, + input: CustomWorldPublishWorldRecordInput, + ) -> Result { + let procedure_input = map_custom_world_publish_world_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().publish_custom_world_world_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_publish_world_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn create_custom_world_agent_session( + &self, + input: CustomWorldAgentSessionCreateRecordInput, + ) -> Result { + let procedure_input = BindingCustomWorldAgentSessionCreateInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + seed_text: input.seed_text, + welcome_message_id: input.welcome_message_id, + welcome_message_text: input.welcome_message_text, + anchor_content_json: input.anchor_content_json, + creator_intent_json: input.creator_intent_json, + creator_intent_readiness_json: input.creator_intent_readiness_json, + anchor_pack_json: input.anchor_pack_json, + lock_state_json: input.lock_state_json, + draft_profile_json: input.draft_profile_json, + pending_clarifications_json: input.pending_clarifications_json, + suggested_actions_json: input.suggested_actions_json, + recommended_replies_json: input.recommended_replies_json, + quality_findings_json: input.quality_findings_json, + asset_coverage_json: input.asset_coverage_json, + checkpoints_json: input.checkpoints_json, + created_at_micros: input.created_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .create_custom_world_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_custom_world_agent_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = BindingCustomWorldAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_custom_world_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn submit_custom_world_agent_message( + &self, + input: CustomWorldAgentMessageSubmitRecordInput, + ) -> Result { + let procedure_input = BindingCustomWorldAgentMessageSubmitInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + user_message_id: input.user_message_id, + user_message_text: input.user_message_text, + operation_id: input.operation_id, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .submit_custom_world_agent_message_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_custom_world_agent_operation( + &self, + session_id: String, + owner_user_id: String, + operation_id: String, + ) -> Result { + let procedure_input = BindingCustomWorldAgentOperationGetInput { + session_id, + owner_user_id, + operation_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_agent_operation_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn list_platform_browse_history( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_browse_history_list_input( + build_runtime_browse_history_list_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_platform_browse_history_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_profile_dashboard( + &self, + user_id: String, + ) -> Result { + let procedure_input = map_runtime_profile_dashboard_get_input( + build_runtime_profile_dashboard_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_profile_dashboard_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_dashboard_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_profile_wallet_ledger( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_profile_wallet_ledger_list_input( + build_runtime_profile_wallet_ledger_list_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_profile_wallet_ledger_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_wallet_ledger_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_profile_play_stats( + &self, + user_id: String, + ) -> Result { + let procedure_input = map_runtime_profile_play_stats_get_input( + build_runtime_profile_play_stats_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_profile_play_stats_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_play_stats_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn begin_story_session( + &self, + story_session_id: String, + runtime_session_id: String, + actor_user_id: String, + world_profile_id: String, + initial_prompt: String, + opening_summary: Option, + created_at_micros: i64, + ) -> Result { + let procedure_input = map_story_session_input( + build_story_session_input( + story_session_id, + runtime_session_id, + actor_user_id, + world_profile_id, + initial_prompt, + opening_summary, + created_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().begin_story_session_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_story_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn create_battle_state( + &self, + input: DomainBattleStateInput, + ) -> Result { + validate_battle_state_input(&input) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + let procedure_input = map_battle_state_input(input); + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_battle_state_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_battle_state_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_battle_state( + &self, + battle_state_id: String, + ) -> Result { + let procedure_input = map_battle_state_query_input( + build_battle_state_query_input(battle_state_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_battle_state_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_battle_state_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_runtime_inventory_state( + &self, + runtime_session_id: String, + actor_user_id: String, + ) -> Result { + let procedure_input = map_runtime_inventory_state_query_input( + build_runtime_inventory_state_query_input(runtime_session_id, actor_user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_runtime_inventory_state_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_inventory_state_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn resolve_npc_battle_interaction( + &self, + input: ResolveNpcBattleInteractionInput, + ) -> Result { + validate_npc_battle_interaction_input(&input)?; + let procedure_input = map_resolve_npc_battle_interaction_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .resolve_npc_battle_interaction_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_npc_battle_interaction_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn continue_story( + &self, + story_session_id: String, + event_id: String, + narrative_text: String, + choice_function_id: Option, + updated_at_micros: i64, + ) -> Result { + let procedure_input = map_story_continue_input( + build_story_continue_input( + story_session_id, + event_id, + narrative_text, + choice_function_id, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().continue_story_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_story_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_story_session_state( + &self, + story_session_id: String, + ) -> Result { + let procedure_input = map_story_session_state_input( + build_story_session_state_input(story_session_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_story_session_state_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_story_session_state_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn resolve_combat_action( + &self, + input: DomainResolveCombatActionInput, + ) -> Result { + validate_resolve_combat_action_input(&input) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + let procedure_input = map_resolve_combat_action_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .resolve_combat_action_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_resolve_combat_action_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn put_runtime_settings( + &self, + user_id: String, + music_volume: f32, + platform_theme: RuntimePlatformTheme, + updated_at_micros: i64, + ) -> Result { + let procedure_input = map_runtime_setting_upsert_input( + build_runtime_setting_upsert_input( + user_id, + music_volume, + platform_theme, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_runtime_setting_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_setting_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn upsert_platform_browse_history_entries( + &self, + user_id: String, + entries: Vec, + updated_at_micros: i64, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_browse_history_sync_input( + build_runtime_browse_history_sync_input(user_id, entries, updated_at_micros) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_platform_browse_history_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn clear_platform_browse_history( + &self, + user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = map_runtime_browse_history_clear_input( + build_runtime_browse_history_clear_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + ); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .clear_platform_browse_history_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + async fn call_after_connect( &self, call: impl FnOnce(&DbConnection, ProcedureResultSender) + Send + 'static, @@ -141,6 +1222,49 @@ impl SpacetimeClient { .map_err(|_| SpacetimeClientError::Timeout)? .map_err(|_| SpacetimeClientError::ConnectDropped)? } + + async fn call_reducer_after_connect( + &self, + call: impl FnOnce(&DbConnection, ReducerResultSender) + Send + 'static, + ) -> Result<(), SpacetimeClientError> { + let config = self.config.clone(); + let (sender, receiver) = oneshot::channel(); + let result_sender = Arc::new(Mutex::new(Some(sender))); + let connect_sender = result_sender.clone(); + let disconnect_sender = result_sender.clone(); + + let connection = tokio::task::spawn_blocking(move || { + DbConnection::builder() + .with_uri(config.server_url) + .with_database_name(config.database) + .with_token(config.token) + .on_connect(move |connection, _, _| { + call(connection, connect_sender); + }) + .on_disconnect(move |_, error| { + let message = error + .map(|error| error.to_string()) + .unwrap_or_else(|| "SpacetimeDB 连接在 reducer 返回前断开".to_string()); + send_reducer_once( + &disconnect_sender, + Err(SpacetimeClientError::Procedure(message)), + ); + }) + .build() + .map_err(|error| SpacetimeClientError::Build(error.to_string())) + }) + .await + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??; + + let runner = connection.run_threaded(); + let result = timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver).await; + let _ = connection.disconnect(); + drop(runner); + + result + .map_err(|_| SpacetimeClientError::Timeout)? + .map_err(|_| SpacetimeClientError::ConnectDropped)? + } } fn send_once(sender: &ProcedureResultSender, result: Result) { @@ -153,6 +1277,16 @@ fn send_once(sender: &ProcedureResultSender, result: Result) { + if let Some(sender) = sender + .lock() + .expect("spacetime reducer result sender should not poison") + .take() + { + let _ = sender.send(result); + } +} + fn map_entity_binding_input( input: module_assets::AssetEntityBindingInput, ) -> BindingAssetEntityBindingInput { @@ -188,6 +1322,324 @@ fn map_upsert_input(input: module_assets::AssetObjectUpsertInput) -> BindingAsse } } +fn map_runtime_setting_get_input( + input: module_runtime::RuntimeSettingGetInput, +) -> BindingRuntimeSettingGetInput { + BindingRuntimeSettingGetInput { + user_id: input.user_id, + } +} + +fn map_runtime_setting_upsert_input( + input: module_runtime::RuntimeSettingUpsertInput, +) -> BindingRuntimeSettingUpsertInput { + BindingRuntimeSettingUpsertInput { + user_id: input.user_id, + music_volume: input.music_volume, + platform_theme: map_runtime_platform_theme(input.platform_theme), + updated_at_micros: input.updated_at_micros, + } +} + +fn map_runtime_browse_history_list_input( + input: module_runtime::RuntimeBrowseHistoryListInput, +) -> BindingRuntimeBrowseHistoryListInput { + BindingRuntimeBrowseHistoryListInput { + user_id: input.user_id, + } +} + +fn map_runtime_browse_history_clear_input( + input: module_runtime::RuntimeBrowseHistoryClearInput, +) -> BindingRuntimeBrowseHistoryClearInput { + BindingRuntimeBrowseHistoryClearInput { + user_id: input.user_id, + } +} + +fn map_runtime_browse_history_sync_input( + input: module_runtime::RuntimeBrowseHistorySyncInput, +) -> BindingRuntimeBrowseHistorySyncInput { + BindingRuntimeBrowseHistorySyncInput { + user_id: input.user_id, + entries: input + .entries + .into_iter() + .map(map_runtime_browse_history_write_input) + .collect(), + updated_at_micros: input.updated_at_micros, + } +} + +fn map_runtime_browse_history_write_input( + input: module_runtime::RuntimeBrowseHistoryWriteInput, +) -> BindingRuntimeBrowseHistoryWriteInput { + BindingRuntimeBrowseHistoryWriteInput { + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + cover_image_src: input.cover_image_src, + theme_mode: input.theme_mode, + author_display_name: input.author_display_name, + visited_at: input.visited_at, + } +} + +fn map_runtime_profile_dashboard_get_input( + input: module_runtime::RuntimeProfileDashboardGetInput, +) -> BindingRuntimeProfileDashboardGetInput { + BindingRuntimeProfileDashboardGetInput { + user_id: input.user_id, + } +} + +fn map_runtime_profile_wallet_ledger_list_input( + input: module_runtime::RuntimeProfileWalletLedgerListInput, +) -> BindingRuntimeProfileWalletLedgerListInput { + BindingRuntimeProfileWalletLedgerListInput { + user_id: input.user_id, + } +} + +fn map_runtime_profile_play_stats_get_input( + input: module_runtime::RuntimeProfilePlayStatsGetInput, +) -> BindingRuntimeProfilePlayStatsGetInput { + BindingRuntimeProfilePlayStatsGetInput { + user_id: input.user_id, + } +} + +fn map_ai_task_create_input(input: DomainAiTaskCreateInput) -> BindingAiTaskCreateInput { + BindingAiTaskCreateInput { + task_id: input.task_id, + task_kind: map_ai_task_kind(input.task_kind), + owner_user_id: input.owner_user_id, + request_label: input.request_label, + source_module: input.source_module, + source_entity_id: input.source_entity_id, + request_payload_json: input.request_payload_json, + stages: input + .stages + .into_iter() + .map(map_ai_task_stage_blueprint) + .collect(), + created_at_micros: input.created_at_micros, + } +} + +fn map_ai_task_start_input(input: DomainAiTaskStartInput) -> BindingAiTaskStartInput { + BindingAiTaskStartInput { + task_id: input.task_id, + started_at_micros: input.started_at_micros, + } +} + +fn map_ai_task_stage_start_input( + input: DomainAiTaskStageStartInput, +) -> BindingAiTaskStageStartInput { + BindingAiTaskStageStartInput { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + started_at_micros: input.started_at_micros, + } +} + +fn map_ai_text_chunk_append_input( + input: DomainAiTextChunkAppendInput, +) -> BindingAiTextChunkAppendInput { + BindingAiTextChunkAppendInput { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + sequence: input.sequence, + delta_text: input.delta_text, + created_at_micros: input.created_at_micros, + } +} + +fn map_ai_stage_completion_input( + input: DomainAiStageCompletionInput, +) -> BindingAiStageCompletionInput { + BindingAiStageCompletionInput { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + text_output: input.text_output, + structured_payload_json: input.structured_payload_json, + warning_messages: input.warning_messages, + completed_at_micros: input.completed_at_micros, + } +} + +fn map_ai_result_reference_input( + input: DomainAiResultReferenceInput, +) -> BindingAiResultReferenceInput { + BindingAiResultReferenceInput { + task_id: input.task_id, + reference_kind: map_ai_result_reference_kind(input.reference_kind), + reference_id: input.reference_id, + label: input.label, + created_at_micros: input.created_at_micros, + } +} + +fn map_ai_task_finish_input(input: DomainAiTaskFinishInput) -> BindingAiTaskFinishInput { + BindingAiTaskFinishInput { + task_id: input.task_id, + completed_at_micros: input.completed_at_micros, + } +} + +fn map_ai_task_failure_input(input: DomainAiTaskFailureInput) -> BindingAiTaskFailureInput { + BindingAiTaskFailureInput { + task_id: input.task_id, + failure_message: input.failure_message, + completed_at_micros: input.completed_at_micros, + } +} + +fn map_ai_task_cancel_input(input: DomainAiTaskCancelInput) -> BindingAiTaskCancelInput { + BindingAiTaskCancelInput { + task_id: input.task_id, + completed_at_micros: input.completed_at_micros, + } +} + +fn map_ai_task_stage_blueprint( + blueprint: DomainAiTaskStageBlueprint, +) -> BindingAiTaskStageBlueprint { + BindingAiTaskStageBlueprint { + stage_kind: map_ai_task_stage_kind(blueprint.stage_kind), + label: blueprint.label, + detail: blueprint.detail, + order: blueprint.order, + } +} + +fn map_custom_world_profile_upsert_input( + input: CustomWorldProfileUpsertRecordInput, +) -> BindingCustomWorldProfileUpsertInput { + BindingCustomWorldProfileUpsertInput { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + source_agent_session_id: input.source_agent_session_id, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + theme_mode: map_custom_world_theme_mode(input.theme_mode), + cover_image_src: input.cover_image_src, + profile_payload_json: input.profile_payload_json, + playable_npc_count: input.playable_npc_count, + landmark_count: input.landmark_count, + author_display_name: input.author_display_name, + updated_at_micros: input.updated_at_micros, + } +} + +fn map_custom_world_publish_world_input( + input: CustomWorldPublishWorldRecordInput, +) -> BindingCustomWorldPublishWorldInput { + BindingCustomWorldPublishWorldInput { + session_id: input.session_id, + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + draft_profile_json: input.draft_profile_json, + legacy_result_profile_json: input.legacy_result_profile_json, + setting_text: input.setting_text, + author_display_name: input.author_display_name, + published_at_micros: input.published_at_micros, + } +} + +fn map_story_session_input(input: DomainStorySessionInput) -> BindingStorySessionInput { + BindingStorySessionInput { + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + world_profile_id: input.world_profile_id, + initial_prompt: input.initial_prompt, + opening_summary: input.opening_summary, + created_at_micros: input.created_at_micros, + } +} + +fn map_story_continue_input(input: DomainStoryContinueInput) -> BindingStoryContinueInput { + BindingStoryContinueInput { + story_session_id: input.story_session_id, + event_id: input.event_id, + narrative_text: input.narrative_text, + choice_function_id: input.choice_function_id, + updated_at_micros: input.updated_at_micros, + } +} + +fn map_story_session_state_input( + input: DomainStorySessionStateInput, +) -> BindingStorySessionStateInput { + BindingStorySessionStateInput { + story_session_id: input.story_session_id, + } +} + +fn map_runtime_inventory_state_query_input( + input: DomainRuntimeInventoryStateQueryInput, +) -> BindingRuntimeInventoryStateQueryInput { + BindingRuntimeInventoryStateQueryInput { + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + } +} + +fn map_battle_state_query_input( + input: DomainBattleStateQueryInput, +) -> BindingBattleStateQueryInput { + BindingBattleStateQueryInput { + battle_state_id: input.battle_state_id, + } +} + +fn map_battle_state_input(input: DomainBattleStateInput) -> BindingBattleStateInput { + BindingBattleStateInput { + battle_state_id: input.battle_state_id, + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + chapter_id: input.chapter_id, + target_npc_id: input.target_npc_id, + target_name: input.target_name, + battle_mode: map_battle_mode(input.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 + .into_iter() + .map(map_runtime_item_reward_item_snapshot) + .collect(), + created_at_micros: input.created_at_micros, + } +} + +fn map_resolve_combat_action_input( + input: DomainResolveCombatActionInput, +) -> BindingResolveCombatActionInput { + BindingResolveCombatActionInput { + battle_state_id: input.battle_state_id, + function_id: input.function_id, + action_text: input.action_text, + base_damage: input.base_damage, + mana_cost: input.mana_cost, + heal: input.heal, + mana_restore: input.mana_restore, + counter_multiplier_basis_points: input.counter_multiplier_basis_points, + updated_at_micros: input.updated_at_micros, + } +} + fn map_procedure_result( result: BindingAssetObjectProcedureResult, ) -> Result { @@ -226,6 +1678,446 @@ fn map_entity_binding_procedure_result( )) } +fn map_runtime_setting_procedure_result( + result: BindingRuntimeSettingProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 runtime settings 快照".to_string(), + ) + })?; + + Ok(build_runtime_setting_record(map_runtime_setting_snapshot( + snapshot, + ))) +} + +fn map_runtime_browse_history_procedure_result( + result: BindingRuntimeBrowseHistoryProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_browse_history_record(map_runtime_browse_history_snapshot(snapshot)) + }) + .collect()) +} + +fn map_runtime_profile_dashboard_procedure_result( + result: BindingRuntimeProfileDashboardProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 profile dashboard 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_dashboard_record( + map_runtime_profile_dashboard_snapshot(snapshot), + )) +} + +fn map_runtime_profile_wallet_ledger_procedure_result( + result: BindingRuntimeProfileWalletLedgerProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_wallet_ledger_entry_record( + map_runtime_profile_wallet_ledger_entry_snapshot(snapshot), + ) + }) + .collect()) +} + +fn map_runtime_profile_play_stats_procedure_result( + result: BindingRuntimeProfilePlayStatsProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 profile play stats 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_play_stats_record( + map_runtime_profile_play_stats_snapshot(snapshot), + )) +} + +fn map_ai_task_procedure_result( + result: BindingAiTaskProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Runtime( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let task = result.task.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 ai_task 快照".to_string()) + })?; + + Ok(AiTaskMutationRecord { + task: map_ai_task_snapshot(task), + text_chunk: result.text_chunk.map(map_ai_text_chunk_snapshot), + }) +} + +fn map_custom_world_profile_list_result( + result: BindingCustomWorldProfileListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + result + .entries + .into_iter() + .map(map_custom_world_library_entry_from_profile_snapshot) + .collect() +} + +fn map_custom_world_library_detail_result( + result: BindingCustomWorldLibraryMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let entry = result + .entry + .ok_or_else(|| SpacetimeClientError::Procedure("custom_world_profile 不存在".to_string())) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + + Ok(CustomWorldLibraryMutationRecord { + entry, + gallery_entry, + }) +} + +fn map_custom_world_gallery_list_result( + result: BindingCustomWorldGalleryListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + Ok(result + .entries + .into_iter() + .map(map_custom_world_gallery_entry_snapshot) + .collect::, _>>()?) +} + +fn map_custom_world_library_mutation_result( + result: BindingCustomWorldLibraryMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let entry = result + .entry + .ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB 未返回 custom world entry".to_string()) + }) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + + Ok(CustomWorldLibraryMutationRecord { + entry, + gallery_entry, + }) +} + +fn map_custom_world_publish_world_result( + result: BindingCustomWorldPublishWorldResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let compiled_record = result + .compiled_record + .ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 published profile compile 快照".to_string(), + ) + }) + .and_then(map_custom_world_published_profile_compile_snapshot)?; + let entry = result + .entry + .ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB 未返回 custom world entry".to_string()) + }) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + let session_stage = result + .session_stage + .ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB 未返回 session stage".to_string()) + }) + .map(map_rpg_agent_stage)?; + + Ok(CustomWorldPublishWorldRecord { + compiled_record, + entry, + gallery_entry, + session_stage, + }) +} + +fn map_custom_world_agent_session_procedure_result( + result: BindingCustomWorldAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world agent session 快照".to_string(), + ) + })?; + + map_custom_world_agent_session_snapshot(session) +} + +fn map_custom_world_agent_operation_procedure_result( + result: BindingCustomWorldAgentOperationProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let operation = result.operation.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 custom world agent operation 快照".to_string(), + ) + })?; + + Ok(map_custom_world_agent_operation_snapshot(operation)) +} + +fn map_story_session_procedure_result( + result: BindingStorySessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 story session 快照".to_string(), + ) + })?; + let event = result.event.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 story event 快照".to_string()) + })?; + + Ok(StorySessionResultRecord { + session: map_story_session_snapshot(session), + event: map_story_event_snapshot(event), + }) +} + +fn map_story_session_state_procedure_result( + result: BindingStorySessionStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 story session state 快照".to_string(), + ) + })?; + + Ok(StorySessionStateRecord { + session: map_story_session_snapshot(session), + events: result + .events + .into_iter() + .map(map_story_event_snapshot) + .collect(), + }) +} + +fn map_runtime_inventory_state_procedure_result( + result: BindingRuntimeInventoryStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.snapshot.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 runtime inventory state 快照".to_string(), + ) + })?; + + Ok(build_runtime_inventory_state_record( + map_runtime_inventory_state_snapshot(snapshot), + )) +} + +fn map_battle_state_procedure_result( + result: BindingBattleStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.snapshot.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 battle_state 快照".to_string(), + ) + })?; + + Ok(build_battle_state_record(map_battle_state_snapshot( + snapshot, + ))) +} + +fn map_resolve_combat_action_procedure_result( + result: BindingResolveCombatActionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let action_result = result.result.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回战斗结算结果".to_string()) + })?; + + Ok(build_resolve_combat_action_record( + map_resolve_combat_action_result(action_result), + )) +} + +fn map_npc_battle_interaction_procedure_result( + result: BindingNpcBattleInteractionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let interaction_result = result.result.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 NPC 开战结果".to_string()) + })?; + + Ok(build_npc_battle_interaction_record( + map_npc_battle_interaction_result(interaction_result), + )) +} + fn map_entity_binding_snapshot( snapshot: BindingAssetEntityBindingSnapshot, ) -> module_assets::AssetEntityBindingSnapshot { @@ -265,6 +2157,625 @@ fn map_snapshot( } } +fn map_runtime_setting_snapshot( + snapshot: BindingRuntimeSettingSnapshot, +) -> module_runtime::RuntimeSettingSnapshot { + module_runtime::RuntimeSettingSnapshot { + user_id: snapshot.user_id, + music_volume: snapshot.music_volume, + platform_theme: map_runtime_platform_theme_back(snapshot.platform_theme), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_runtime_browse_history_snapshot( + snapshot: BindingRuntimeBrowseHistorySnapshot, +) -> module_runtime::RuntimeBrowseHistorySnapshot { + module_runtime::RuntimeBrowseHistorySnapshot { + 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: map_runtime_browse_history_theme_mode_back(snapshot.theme_mode), + author_display_name: snapshot.author_display_name, + visited_at_micros: snapshot.visited_at_micros, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_runtime_profile_dashboard_snapshot( + snapshot: BindingRuntimeProfileDashboardSnapshot, +) -> module_runtime::RuntimeProfileDashboardSnapshot { + module_runtime::RuntimeProfileDashboardSnapshot { + user_id: snapshot.user_id, + wallet_balance: snapshot.wallet_balance, + total_play_time_ms: snapshot.total_play_time_ms, + played_world_count: snapshot.played_world_count, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_runtime_profile_wallet_ledger_entry_snapshot( + snapshot: BindingRuntimeProfileWalletLedgerEntrySnapshot, +) -> module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { + module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { + wallet_ledger_id: snapshot.wallet_ledger_id, + user_id: snapshot.user_id, + amount_delta: snapshot.amount_delta, + balance_after: snapshot.balance_after, + source_type: map_runtime_profile_wallet_ledger_source_type(snapshot.source_type), + created_at_micros: snapshot.created_at_micros, + } +} + +fn map_runtime_profile_played_world_snapshot( + snapshot: BindingRuntimeProfilePlayedWorldSnapshot, +) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { + module_runtime::RuntimeProfilePlayedWorldSnapshot { + played_world_id: snapshot.played_world_id, + user_id: snapshot.user_id, + world_key: snapshot.world_key, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_type: snapshot.world_type, + world_title: snapshot.world_title, + world_subtitle: snapshot.world_subtitle, + first_played_at_micros: snapshot.first_played_at_micros, + last_played_at_micros: snapshot.last_played_at_micros, + last_observed_play_time_ms: snapshot.last_observed_play_time_ms, + } +} + +fn map_runtime_profile_play_stats_snapshot( + snapshot: BindingRuntimeProfilePlayStatsSnapshot, +) -> module_runtime::RuntimeProfilePlayStatsSnapshot { + module_runtime::RuntimeProfilePlayStatsSnapshot { + user_id: snapshot.user_id, + total_play_time_ms: snapshot.total_play_time_ms, + played_works: snapshot + .played_works + .into_iter() + .map(map_runtime_profile_played_world_snapshot) + .collect(), + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_custom_world_library_entry_from_profile_snapshot( + snapshot: BindingCustomWorldProfileSnapshot, +) -> Result { + let profile = serde_json::from_str::(&snapshot.profile_payload_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!( + "custom world profile payload JSON 非法: {error}" + )) + })?; + + Ok(CustomWorldLibraryEntryRecord { + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + profile, + visibility: map_custom_world_publication_status(snapshot.publication_status).to_string(), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + author_display_name: snapshot.author_display_name, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + }) +} + +fn map_custom_world_gallery_entry_snapshot( + snapshot: BindingCustomWorldGalleryEntrySnapshot, +) -> Result { + Ok(CustomWorldGalleryEntryRecord { + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + visibility: "published".to_string(), + published_at: Some(format_timestamp_micros(snapshot.published_at_micros)), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + author_display_name: snapshot.author_display_name, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + }) +} + +fn map_custom_world_published_profile_compile_snapshot( + snapshot: BindingCustomWorldPublishedProfileCompileSnapshot, +) -> Result { + let compiled_profile = + serde_json::from_str::(&snapshot.compiled_profile_payload_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!( + "published profile compile JSON 非法: {error}" + )) + })?; + + Ok(CustomWorldPublishedProfileCompileRecord { + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + cover_image_src: snapshot.cover_image_src, + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + author_display_name: snapshot.author_display_name, + compiled_profile: compiled_profile, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +fn map_custom_world_agent_session_snapshot( + snapshot: BindingCustomWorldAgentSessionSnapshot, +) -> Result { + let anchor_content = parse_json_value( + &snapshot.anchor_content_json, + "custom world agent anchor_content_json", + )?; + let creator_intent = parse_optional_json_value( + snapshot.creator_intent_json.as_deref(), + serde_json::json!({}), + "custom world agent creator_intent_json", + )?; + let creator_intent_readiness = parse_json_value( + &snapshot.creator_intent_readiness_json, + "custom world agent creator_intent_readiness_json", + )?; + let anchor_pack = parse_optional_json_value( + snapshot.anchor_pack_json.as_deref(), + serde_json::json!({}), + "custom world agent anchor_pack_json", + )?; + let lock_state = parse_optional_json_value( + snapshot.lock_state_json.as_deref(), + serde_json::json!({}), + "custom world agent lock_state_json", + )?; + let draft_profile = parse_optional_json_value( + snapshot.draft_profile_json.as_deref(), + serde_json::json!({}), + "custom world agent draft_profile_json", + )?; + let pending_clarifications = parse_json_array( + &snapshot.pending_clarifications_json, + "custom world agent pending_clarifications_json", + )?; + let suggested_actions = parse_json_array( + &snapshot.suggested_actions_json, + "custom world agent suggested_actions_json", + )?; + let recommended_replies = parse_json_string_array( + &snapshot.recommended_replies_json, + "custom world agent recommended_replies_json", + )?; + let quality_findings = parse_json_array( + &snapshot.quality_findings_json, + "custom world agent quality_findings_json", + )?; + let asset_coverage = parse_json_value( + &snapshot.asset_coverage_json, + "custom world agent asset_coverage_json", + )?; + let checkpoints_json = parse_json_array( + &snapshot.checkpoints_json, + "custom world agent checkpoints_json", + )?; + let checkpoints = checkpoints_json + .into_iter() + .map(map_custom_world_checkpoint_record) + .collect::, _>>()?; + + Ok(CustomWorldAgentSessionRecord { + session_id: snapshot.session_id, + current_turn: snapshot.current_turn, + anchor_content, + progress_percent: snapshot.progress_percent, + last_assistant_reply: snapshot.last_assistant_reply, + stage: map_rpg_agent_stage(snapshot.stage), + focus_card_id: snapshot.focus_card_id, + creator_intent, + creator_intent_readiness, + anchor_pack, + lock_state, + draft_profile, + messages: snapshot + .messages + .into_iter() + .map(map_custom_world_agent_message_snapshot) + .collect(), + draft_cards: snapshot + .draft_cards + .into_iter() + .map(map_custom_world_draft_card_snapshot) + .collect::, _>>()?, + pending_clarifications, + suggested_actions, + recommended_replies, + quality_findings, + asset_coverage, + checkpoints, + supported_actions: build_minimal_custom_world_supported_actions( + snapshot.stage, + snapshot.progress_percent, + snapshot.result_preview_json.is_some(), + snapshot.checkpoints_json.as_str(), + ), + result_preview: snapshot + .result_preview_json + .as_deref() + .map(|value| parse_json_value(value, "custom world agent result_preview_json")) + .transpose()?, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +fn map_custom_world_agent_message_snapshot( + snapshot: BindingCustomWorldAgentMessageSnapshot, +) -> CustomWorldAgentMessageRecord { + CustomWorldAgentMessageRecord { + message_id: snapshot.message_id, + role: format_rpg_agent_message_role(snapshot.role).to_string(), + kind: format_rpg_agent_message_kind(snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + related_operation_id: snapshot.related_operation_id, + } +} + +fn map_custom_world_agent_operation_snapshot( + snapshot: BindingCustomWorldAgentOperationSnapshot, +) -> CustomWorldAgentOperationRecord { + CustomWorldAgentOperationRecord { + operation_id: snapshot.operation_id, + operation_type: format_rpg_agent_operation_type(snapshot.operation_type).to_string(), + status: format_rpg_agent_operation_status(snapshot.status).to_string(), + phase_label: snapshot.phase_label, + phase_detail: snapshot.phase_detail, + progress: snapshot.progress, + error_message: snapshot.error_message, + } +} + +fn map_custom_world_draft_card_snapshot( + snapshot: BindingCustomWorldDraftCardSnapshot, +) -> Result { + Ok(CustomWorldDraftCardRecord { + card_id: snapshot.card_id, + kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), + title: snapshot.title, + subtitle: snapshot.subtitle, + summary: snapshot.summary, + status: format_rpg_agent_draft_card_status(snapshot.status).to_string(), + linked_ids: parse_json_string_array( + &snapshot.linked_ids_json, + "custom world draft_card linked_ids_json", + )?, + warning_count: snapshot.warning_count, + asset_status: snapshot + .asset_status + .map(format_custom_world_role_asset_status_back), + asset_status_label: snapshot.asset_status_label, + }) +} + +fn map_story_session_snapshot(snapshot: BindingStorySessionSnapshot) -> StorySessionRecord { + StorySessionRecord { + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + world_profile_id: snapshot.world_profile_id, + initial_prompt: snapshot.initial_prompt, + opening_summary: snapshot.opening_summary, + latest_narrative_text: snapshot.latest_narrative_text, + latest_choice_function_id: snapshot.latest_choice_function_id, + status: map_story_session_status(snapshot.status) + .as_str() + .to_string(), + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_ai_task_snapshot(snapshot: BindingAiTaskSnapshot) -> AiTaskRecord { + AiTaskRecord { + task_id: snapshot.task_id, + task_kind: format_ai_task_kind(snapshot.task_kind).to_string(), + owner_user_id: snapshot.owner_user_id, + request_label: snapshot.request_label, + source_module: snapshot.source_module, + source_entity_id: snapshot.source_entity_id, + request_payload_json: snapshot.request_payload_json, + status: format_ai_task_status(snapshot.status).to_string(), + failure_message: snapshot.failure_message, + stages: snapshot + .stages + .into_iter() + .map(map_ai_task_stage_snapshot) + .collect(), + result_references: snapshot + .result_references + .into_iter() + .map(map_ai_result_reference_snapshot) + .collect(), + latest_text_output: snapshot.latest_text_output, + latest_structured_payload_json: snapshot.latest_structured_payload_json, + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_ai_task_stage_snapshot(snapshot: BindingAiTaskStageSnapshot) -> AiTaskStageRecord { + AiTaskStageRecord { + stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), + label: snapshot.label, + detail: snapshot.detail, + order: snapshot.order, + status: format_ai_task_stage_status(snapshot.status).to_string(), + text_output: snapshot.text_output, + structured_payload_json: snapshot.structured_payload_json, + warning_messages: snapshot.warning_messages, + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), + } +} + +fn map_ai_text_chunk_snapshot(snapshot: BindingAiTextChunkSnapshot) -> AiTextChunkRecord { + AiTextChunkRecord { + chunk_id: snapshot.chunk_id, + task_id: snapshot.task_id, + stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), + sequence: snapshot.sequence, + delta_text: snapshot.delta_text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_ai_result_reference_snapshot( + snapshot: BindingAiResultReferenceSnapshot, +) -> AiResultReferenceRecord { + AiResultReferenceRecord { + result_ref_id: snapshot.result_ref_id, + task_id: snapshot.task_id, + reference_kind: format_ai_result_reference_kind(snapshot.reference_kind).to_string(), + reference_id: snapshot.reference_id, + label: snapshot.label, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_story_event_snapshot(snapshot: BindingStoryEventSnapshot) -> StoryEventRecord { + StoryEventRecord { + event_id: snapshot.event_id, + story_session_id: snapshot.story_session_id, + event_kind: map_story_event_kind(snapshot.event_kind) + .as_str() + .to_string(), + narrative_text: snapshot.narrative_text, + choice_function_id: snapshot.choice_function_id, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_battle_state_snapshot(snapshot: BindingBattleStateSnapshot) -> DomainBattleStateSnapshot { + DomainBattleStateSnapshot { + 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: map_battle_mode_back(snapshot.battle_mode), + status: map_battle_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 + .into_iter() + .map(map_runtime_item_reward_item_snapshot_back) + .collect(), + 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: map_combat_outcome(snapshot.last_outcome), + version: snapshot.version, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_runtime_inventory_state_snapshot( + snapshot: BindingRuntimeInventoryStateSnapshot, +) -> DomainRuntimeInventoryStateSnapshot { + DomainRuntimeInventoryStateSnapshot { + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + backpack_items: snapshot + .backpack_items + .into_iter() + .map(map_inventory_slot_snapshot) + .collect(), + equipment_items: snapshot + .equipment_items + .into_iter() + .map(map_inventory_slot_snapshot) + .collect(), + } +} + +fn map_resolve_combat_action_result( + result: BindingResolveCombatActionResult, +) -> DomainResolveCombatActionResult { + DomainResolveCombatActionResult { + snapshot: map_battle_state_snapshot(result.snapshot), + damage_dealt: result.damage_dealt, + damage_taken: result.damage_taken, + outcome: map_combat_outcome(result.outcome), + } +} + +fn map_npc_battle_interaction_result( + result: BindingNpcBattleInteractionResult, +) -> NpcBattleInteractionSnapshot { + NpcBattleInteractionSnapshot { + interaction: map_npc_interaction_result(result.interaction), + battle_state: map_battle_state_snapshot(result.battle_state), + } +} + +fn map_inventory_slot_snapshot( + snapshot: BindingInventorySlotSnapshot, +) -> module_inventory::InventorySlotSnapshot { + module_inventory::InventorySlotSnapshot { + 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: map_inventory_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: map_inventory_item_rarity(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot.equipment_slot_id.map(map_inventory_equipment_slot), + source_kind: map_inventory_item_source_kind(snapshot.source_kind), + source_reference_id: snapshot.source_reference_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_npc_interaction_result(result: BindingNpcInteractionResult) -> DomainNpcInteractionResult { + DomainNpcInteractionResult { + npc_state: map_npc_state_snapshot(result.npc_state), + interaction_status: map_npc_interaction_status(result.interaction_status), + action_text: result.action_text, + result_text: result.result_text, + story_text: result.story_text, + battle_mode: result.battle_mode.map(map_npc_interaction_battle_mode), + encounter_closed: result.encounter_closed, + affinity_changed: result.affinity_changed, + previous_affinity: result.previous_affinity, + next_affinity: result.next_affinity, + } +} + +fn map_npc_state_snapshot(snapshot: BindingNpcStateSnapshot) -> DomainNpcStateSnapshot { + DomainNpcStateSnapshot { + 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: map_npc_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: map_npc_stance_profile(snapshot.stance_profile), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_npc_relation_state(value: BindingNpcRelationState) -> DomainNpcRelationState { + DomainNpcRelationState { + affinity: value.affinity, + stance: map_npc_relation_stance(value.stance), + } +} + +fn map_npc_stance_profile(value: BindingNpcStanceProfile) -> DomainNpcStanceProfile { + DomainNpcStanceProfile { + trust: value.trust, + warmth: value.warmth, + ideological_fit: value.ideological_fit, + fear_or_guard: value.fear_or_guard, + loyalty: value.loyalty, + current_conflict_tag: value.current_conflict_tag, + recent_approvals: value.recent_approvals, + recent_disapprovals: value.recent_disapprovals, + } +} + +fn map_npc_interaction_status(value: BindingNpcInteractionStatus) -> DomainNpcInteractionStatus { + match value { + BindingNpcInteractionStatus::Previewed => DomainNpcInteractionStatus::Previewed, + BindingNpcInteractionStatus::Dialogue => DomainNpcInteractionStatus::Dialogue, + BindingNpcInteractionStatus::Resolved => DomainNpcInteractionStatus::Resolved, + BindingNpcInteractionStatus::Recruited => DomainNpcInteractionStatus::Recruited, + BindingNpcInteractionStatus::BattlePending => DomainNpcInteractionStatus::BattlePending, + BindingNpcInteractionStatus::Left => DomainNpcInteractionStatus::Left, + } +} + +fn map_npc_interaction_battle_mode( + value: BindingNpcInteractionBattleMode, +) -> DomainNpcInteractionBattleMode { + match value { + BindingNpcInteractionBattleMode::Fight => DomainNpcInteractionBattleMode::Fight, + BindingNpcInteractionBattleMode::Spar => DomainNpcInteractionBattleMode::Spar, + } +} + +fn map_npc_relation_stance(value: BindingNpcRelationStance) -> DomainNpcRelationStance { + match value { + BindingNpcRelationStance::Hostile => DomainNpcRelationStance::Hostile, + BindingNpcRelationStance::Guarded => DomainNpcRelationStance::Guarded, + BindingNpcRelationStance::Neutral => DomainNpcRelationStance::Neutral, + BindingNpcRelationStance::Cooperative => DomainNpcRelationStance::Cooperative, + BindingNpcRelationStance::Bonded => DomainNpcRelationStance::Bonded, + } +} + fn map_access_policy( value: AssetObjectAccessPolicy, ) -> crate::module_bindings::AssetObjectAccessPolicy { @@ -291,6 +2802,1174 @@ fn map_access_policy_back( } } +fn map_runtime_platform_theme(value: RuntimePlatformTheme) -> BindingRuntimePlatformTheme { + match value { + RuntimePlatformTheme::Light => BindingRuntimePlatformTheme::Light, + RuntimePlatformTheme::Dark => BindingRuntimePlatformTheme::Dark, + } +} + +fn map_runtime_profile_wallet_ledger_source_type( + value: BindingRuntimeProfileWalletLedgerSourceType, +) -> RuntimeProfileWalletLedgerSourceType { + match value { + BindingRuntimeProfileWalletLedgerSourceType::SnapshotSync => { + RuntimeProfileWalletLedgerSourceType::SnapshotSync + } + } +} + +fn map_runtime_item_reward_item_rarity( + value: DomainRuntimeItemRewardItemRarity, +) -> BindingRuntimeItemRewardItemRarity { + match value { + DomainRuntimeItemRewardItemRarity::Common => BindingRuntimeItemRewardItemRarity::Common, + DomainRuntimeItemRewardItemRarity::Uncommon => BindingRuntimeItemRewardItemRarity::Uncommon, + DomainRuntimeItemRewardItemRarity::Rare => BindingRuntimeItemRewardItemRarity::Rare, + DomainRuntimeItemRewardItemRarity::Epic => BindingRuntimeItemRewardItemRarity::Epic, + DomainRuntimeItemRewardItemRarity::Legendary => { + BindingRuntimeItemRewardItemRarity::Legendary + } + } +} + +fn map_runtime_item_equipment_slot( + value: DomainRuntimeItemEquipmentSlot, +) -> BindingRuntimeItemEquipmentSlot { + match value { + DomainRuntimeItemEquipmentSlot::Weapon => BindingRuntimeItemEquipmentSlot::Weapon, + DomainRuntimeItemEquipmentSlot::Armor => BindingRuntimeItemEquipmentSlot::Armor, + DomainRuntimeItemEquipmentSlot::Relic => BindingRuntimeItemEquipmentSlot::Relic, + } +} + +fn map_custom_world_theme_mode(value: DomainCustomWorldThemeMode) -> BindingCustomWorldThemeMode { + match value { + DomainCustomWorldThemeMode::Martial => BindingCustomWorldThemeMode::Martial, + DomainCustomWorldThemeMode::Arcane => BindingCustomWorldThemeMode::Arcane, + DomainCustomWorldThemeMode::Machina => BindingCustomWorldThemeMode::Machina, + DomainCustomWorldThemeMode::Tide => BindingCustomWorldThemeMode::Tide, + DomainCustomWorldThemeMode::Rift => BindingCustomWorldThemeMode::Rift, + DomainCustomWorldThemeMode::Mythic => BindingCustomWorldThemeMode::Mythic, + } +} + +fn map_battle_mode(value: DomainBattleMode) -> BindingBattleMode { + match value { + DomainBattleMode::Fight => BindingBattleMode::Fight, + DomainBattleMode::Spar => BindingBattleMode::Spar, + } +} + +fn map_runtime_platform_theme_back(value: BindingRuntimePlatformTheme) -> RuntimePlatformTheme { + match value { + BindingRuntimePlatformTheme::Light => RuntimePlatformTheme::Light, + BindingRuntimePlatformTheme::Dark => RuntimePlatformTheme::Dark, + } +} + +fn map_runtime_item_reward_item_rarity_back( + value: BindingRuntimeItemRewardItemRarity, +) -> DomainRuntimeItemRewardItemRarity { + match value { + BindingRuntimeItemRewardItemRarity::Common => DomainRuntimeItemRewardItemRarity::Common, + BindingRuntimeItemRewardItemRarity::Uncommon => DomainRuntimeItemRewardItemRarity::Uncommon, + BindingRuntimeItemRewardItemRarity::Rare => DomainRuntimeItemRewardItemRarity::Rare, + BindingRuntimeItemRewardItemRarity::Epic => DomainRuntimeItemRewardItemRarity::Epic, + BindingRuntimeItemRewardItemRarity::Legendary => { + DomainRuntimeItemRewardItemRarity::Legendary + } + } +} + +fn map_runtime_item_equipment_slot_back( + value: BindingRuntimeItemEquipmentSlot, +) -> DomainRuntimeItemEquipmentSlot { + match value { + BindingRuntimeItemEquipmentSlot::Weapon => DomainRuntimeItemEquipmentSlot::Weapon, + BindingRuntimeItemEquipmentSlot::Armor => DomainRuntimeItemEquipmentSlot::Armor, + BindingRuntimeItemEquipmentSlot::Relic => DomainRuntimeItemEquipmentSlot::Relic, + } +} + +fn map_custom_world_theme_mode_back( + value: BindingCustomWorldThemeMode, +) -> DomainCustomWorldThemeMode { + match value { + BindingCustomWorldThemeMode::Martial => DomainCustomWorldThemeMode::Martial, + BindingCustomWorldThemeMode::Arcane => DomainCustomWorldThemeMode::Arcane, + BindingCustomWorldThemeMode::Machina => DomainCustomWorldThemeMode::Machina, + BindingCustomWorldThemeMode::Tide => DomainCustomWorldThemeMode::Tide, + BindingCustomWorldThemeMode::Rift => DomainCustomWorldThemeMode::Rift, + BindingCustomWorldThemeMode::Mythic => DomainCustomWorldThemeMode::Mythic, + } +} + +fn map_custom_world_publication_status(value: BindingCustomWorldPublicationStatus) -> &'static str { + match value { + BindingCustomWorldPublicationStatus::Draft => "draft", + BindingCustomWorldPublicationStatus::Published => "published", + } +} + +fn map_rpg_agent_stage(value: crate::module_bindings::RpgAgentStage) -> String { + match value { + crate::module_bindings::RpgAgentStage::CollectingIntent => "collecting_intent", + crate::module_bindings::RpgAgentStage::Clarifying => "clarifying", + crate::module_bindings::RpgAgentStage::FoundationReview => "foundation_review", + crate::module_bindings::RpgAgentStage::ObjectRefining => "object_refining", + crate::module_bindings::RpgAgentStage::VisualRefining => "visual_refining", + crate::module_bindings::RpgAgentStage::LongTailReview => "long_tail_review", + crate::module_bindings::RpgAgentStage::ReadyToPublish => "ready_to_publish", + crate::module_bindings::RpgAgentStage::Published => "published", + crate::module_bindings::RpgAgentStage::Error => "error", + } + .to_string() +} + +fn format_rpg_agent_message_role( + value: crate::module_bindings::RpgAgentMessageRole, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentMessageRole::User => "user", + crate::module_bindings::RpgAgentMessageRole::Assistant => "assistant", + crate::module_bindings::RpgAgentMessageRole::System => "system", + } +} + +fn format_rpg_agent_message_kind( + value: crate::module_bindings::RpgAgentMessageKind, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentMessageKind::Chat => "chat", + crate::module_bindings::RpgAgentMessageKind::Clarification => "clarification", + crate::module_bindings::RpgAgentMessageKind::Summary => "summary", + crate::module_bindings::RpgAgentMessageKind::Checkpoint => "checkpoint", + crate::module_bindings::RpgAgentMessageKind::Warning => "warning", + crate::module_bindings::RpgAgentMessageKind::ActionResult => "action_result", + } +} + +fn format_rpg_agent_operation_type( + value: crate::module_bindings::RpgAgentOperationType, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentOperationType::ProcessMessage => "process_message", + crate::module_bindings::RpgAgentOperationType::DraftFoundation => "draft_foundation", + crate::module_bindings::RpgAgentOperationType::UpdateDraftCard => "update_draft_card", + crate::module_bindings::RpgAgentOperationType::SyncResultProfile => "sync_result_profile", + crate::module_bindings::RpgAgentOperationType::GenerateCharacters => "generate_characters", + crate::module_bindings::RpgAgentOperationType::GenerateLandmarks => "generate_landmarks", + crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets => "generate_role_assets", + crate::module_bindings::RpgAgentOperationType::SyncRoleAssets => "sync_role_assets", + crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets => { + "generate_scene_assets" + } + crate::module_bindings::RpgAgentOperationType::SyncSceneAssets => "sync_scene_assets", + crate::module_bindings::RpgAgentOperationType::ExpandLongTail => "expand_long_tail", + crate::module_bindings::RpgAgentOperationType::PublishWorld => "publish_world", + crate::module_bindings::RpgAgentOperationType::RevertCheckpoint => "revert_checkpoint", + } +} + +fn format_rpg_agent_operation_status( + value: crate::module_bindings::RpgAgentOperationStatus, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentOperationStatus::Queued => "queued", + crate::module_bindings::RpgAgentOperationStatus::Running => "running", + crate::module_bindings::RpgAgentOperationStatus::Completed => "completed", + crate::module_bindings::RpgAgentOperationStatus::Failed => "failed", + } +} + +fn format_rpg_agent_draft_card_kind( + value: crate::module_bindings::RpgAgentDraftCardKind, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentDraftCardKind::World => "world", + crate::module_bindings::RpgAgentDraftCardKind::Camp => "camp", + crate::module_bindings::RpgAgentDraftCardKind::Faction => "faction", + crate::module_bindings::RpgAgentDraftCardKind::Character => "character", + crate::module_bindings::RpgAgentDraftCardKind::Landmark => "landmark", + crate::module_bindings::RpgAgentDraftCardKind::Thread => "thread", + crate::module_bindings::RpgAgentDraftCardKind::Chapter => "chapter", + crate::module_bindings::RpgAgentDraftCardKind::SceneChapter => "scene_chapter", + crate::module_bindings::RpgAgentDraftCardKind::Carrier => "carrier", + crate::module_bindings::RpgAgentDraftCardKind::SidequestSeed => "sidequest_seed", + } +} + +fn format_rpg_agent_draft_card_status( + value: crate::module_bindings::RpgAgentDraftCardStatus, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentDraftCardStatus::Suggested => "suggested", + crate::module_bindings::RpgAgentDraftCardStatus::Confirmed => "confirmed", + crate::module_bindings::RpgAgentDraftCardStatus::Locked => "locked", + crate::module_bindings::RpgAgentDraftCardStatus::Warning => "warning", + } +} + +fn format_custom_world_role_asset_status_back( + value: crate::module_bindings::CustomWorldRoleAssetStatus, +) -> String { + match value { + crate::module_bindings::CustomWorldRoleAssetStatus::Missing => "missing", + crate::module_bindings::CustomWorldRoleAssetStatus::VisualReady => "visual_ready", + crate::module_bindings::CustomWorldRoleAssetStatus::AnimationsReady => "animations_ready", + crate::module_bindings::CustomWorldRoleAssetStatus::Complete => "complete", + } + .to_string() +} + +fn format_custom_world_theme_mode(value: DomainCustomWorldThemeMode) -> &'static str { + match value { + DomainCustomWorldThemeMode::Martial => "martial", + DomainCustomWorldThemeMode::Arcane => "arcane", + DomainCustomWorldThemeMode::Machina => "machina", + DomainCustomWorldThemeMode::Tide => "tide", + DomainCustomWorldThemeMode::Rift => "rift", + DomainCustomWorldThemeMode::Mythic => "mythic", + } +} + +fn map_battle_mode_back(value: BindingBattleMode) -> DomainBattleMode { + match value { + BindingBattleMode::Fight => DomainBattleMode::Fight, + BindingBattleMode::Spar => DomainBattleMode::Spar, + } +} + +fn map_runtime_browse_history_theme_mode_back( + value: BindingRuntimeBrowseHistoryThemeMode, +) -> RuntimeBrowseHistoryThemeMode { + match value { + BindingRuntimeBrowseHistoryThemeMode::Martial => RuntimeBrowseHistoryThemeMode::Martial, + BindingRuntimeBrowseHistoryThemeMode::Arcane => RuntimeBrowseHistoryThemeMode::Arcane, + BindingRuntimeBrowseHistoryThemeMode::Machina => RuntimeBrowseHistoryThemeMode::Machina, + BindingRuntimeBrowseHistoryThemeMode::Tide => RuntimeBrowseHistoryThemeMode::Tide, + BindingRuntimeBrowseHistoryThemeMode::Rift => RuntimeBrowseHistoryThemeMode::Rift, + BindingRuntimeBrowseHistoryThemeMode::Mythic => RuntimeBrowseHistoryThemeMode::Mythic, + } +} + +fn map_story_session_status(value: BindingStorySessionStatus) -> DomainStorySessionStatus { + match value { + BindingStorySessionStatus::Active => DomainStorySessionStatus::Active, + BindingStorySessionStatus::Completed => DomainStorySessionStatus::Completed, + BindingStorySessionStatus::Archived => DomainStorySessionStatus::Archived, + } +} + +fn map_battle_status(value: BindingBattleStatus) -> DomainBattleStatus { + match value { + BindingBattleStatus::Ongoing => DomainBattleStatus::Ongoing, + BindingBattleStatus::Resolved => DomainBattleStatus::Resolved, + BindingBattleStatus::Aborted => DomainBattleStatus::Aborted, + } +} + +fn map_story_event_kind(value: BindingStoryEventKind) -> DomainStoryEventKind { + match value { + BindingStoryEventKind::SessionStarted => DomainStoryEventKind::SessionStarted, + BindingStoryEventKind::StoryContinued => DomainStoryEventKind::StoryContinued, + } +} + +fn map_ai_task_kind(value: DomainAiTaskKind) -> BindingAiTaskKind { + match value { + DomainAiTaskKind::StoryGeneration => BindingAiTaskKind::StoryGeneration, + DomainAiTaskKind::CharacterChat => BindingAiTaskKind::CharacterChat, + DomainAiTaskKind::NpcChat => BindingAiTaskKind::NpcChat, + DomainAiTaskKind::CustomWorldGeneration => BindingAiTaskKind::CustomWorldGeneration, + DomainAiTaskKind::QuestIntent => BindingAiTaskKind::QuestIntent, + DomainAiTaskKind::RuntimeItemIntent => BindingAiTaskKind::RuntimeItemIntent, + } +} + +fn map_ai_task_stage_kind(value: DomainAiTaskStageKind) -> BindingAiTaskStageKind { + match value { + DomainAiTaskStageKind::PreparePrompt => BindingAiTaskStageKind::PreparePrompt, + DomainAiTaskStageKind::RequestModel => BindingAiTaskStageKind::RequestModel, + DomainAiTaskStageKind::RepairResponse => BindingAiTaskStageKind::RepairResponse, + DomainAiTaskStageKind::NormalizeResult => BindingAiTaskStageKind::NormalizeResult, + DomainAiTaskStageKind::PersistResult => BindingAiTaskStageKind::PersistResult, + } +} + +fn map_ai_result_reference_kind( + value: DomainAiResultReferenceKind, +) -> BindingAiResultReferenceKind { + match value { + DomainAiResultReferenceKind::StorySession => BindingAiResultReferenceKind::StorySession, + DomainAiResultReferenceKind::StoryEvent => BindingAiResultReferenceKind::StoryEvent, + DomainAiResultReferenceKind::CustomWorldProfile => { + BindingAiResultReferenceKind::CustomWorldProfile + } + DomainAiResultReferenceKind::QuestRecord => BindingAiResultReferenceKind::QuestRecord, + DomainAiResultReferenceKind::RuntimeItemRecord => { + BindingAiResultReferenceKind::RuntimeItemRecord + } + DomainAiResultReferenceKind::AssetObject => BindingAiResultReferenceKind::AssetObject, + } +} + +fn format_ai_task_kind(value: BindingAiTaskKind) -> &'static str { + match value { + BindingAiTaskKind::StoryGeneration => "story_generation", + BindingAiTaskKind::CharacterChat => "character_chat", + BindingAiTaskKind::NpcChat => "npc_chat", + BindingAiTaskKind::CustomWorldGeneration => "custom_world_generation", + BindingAiTaskKind::QuestIntent => "quest_intent", + BindingAiTaskKind::RuntimeItemIntent => "runtime_item_intent", + } +} + +fn format_ai_task_status(value: BindingAiTaskStatus) -> &'static str { + match value { + BindingAiTaskStatus::Pending => "pending", + BindingAiTaskStatus::Running => "running", + BindingAiTaskStatus::Completed => "completed", + BindingAiTaskStatus::Failed => "failed", + BindingAiTaskStatus::Cancelled => "cancelled", + } +} + +fn format_ai_task_stage_kind(value: BindingAiTaskStageKind) -> &'static str { + match value { + BindingAiTaskStageKind::PreparePrompt => "prepare_prompt", + BindingAiTaskStageKind::RequestModel => "request_model", + BindingAiTaskStageKind::RepairResponse => "repair_response", + BindingAiTaskStageKind::NormalizeResult => "normalize_result", + BindingAiTaskStageKind::PersistResult => "persist_result", + } +} + +fn format_ai_task_stage_status(value: BindingAiTaskStageStatus) -> &'static str { + match value { + BindingAiTaskStageStatus::Pending => "pending", + BindingAiTaskStageStatus::Running => "running", + BindingAiTaskStageStatus::Completed => "completed", + BindingAiTaskStageStatus::Skipped => "skipped", + } +} + +fn format_ai_result_reference_kind(value: BindingAiResultReferenceKind) -> &'static str { + match value { + BindingAiResultReferenceKind::StorySession => "story_session", + BindingAiResultReferenceKind::StoryEvent => "story_event", + BindingAiResultReferenceKind::CustomWorldProfile => "custom_world_profile", + BindingAiResultReferenceKind::QuestRecord => "quest_record", + BindingAiResultReferenceKind::RuntimeItemRecord => "runtime_item_record", + BindingAiResultReferenceKind::AssetObject => "asset_object", + } +} + +fn map_combat_outcome(value: BindingCombatOutcome) -> DomainCombatOutcome { + match value { + BindingCombatOutcome::Ongoing => DomainCombatOutcome::Ongoing, + BindingCombatOutcome::Victory => DomainCombatOutcome::Victory, + BindingCombatOutcome::SparComplete => DomainCombatOutcome::SparComplete, + BindingCombatOutcome::Escaped => DomainCombatOutcome::Escaped, + } +} + +fn map_runtime_item_reward_item_snapshot( + snapshot: DomainRuntimeItemRewardItemSnapshot, +) -> BindingRuntimeItemRewardItemSnapshot { + BindingRuntimeItemRewardItemSnapshot { + item_id: snapshot.item_id, + category: snapshot.category, + item_name: snapshot.item_name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_runtime_item_reward_item_rarity(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot + .equipment_slot_id + .map(map_runtime_item_equipment_slot), + } +} + +fn map_runtime_item_reward_item_snapshot_back( + snapshot: BindingRuntimeItemRewardItemSnapshot, +) -> DomainRuntimeItemRewardItemSnapshot { + DomainRuntimeItemRewardItemSnapshot { + item_id: snapshot.item_id, + category: snapshot.category, + item_name: snapshot.item_name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_runtime_item_reward_item_rarity_back(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot + .equipment_slot_id + .map(map_runtime_item_equipment_slot_back), + } +} + +fn parse_json_value(value: &str, label: &str) -> Result { + serde_json::from_str::(value) + .map_err(|error| SpacetimeClientError::Runtime(format!("{label} 非法: {error}"))) +} + +fn parse_optional_json_value( + value: Option<&str>, + fallback: serde_json::Value, + label: &str, +) -> Result { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => parse_json_value(value, label), + None => Ok(fallback), + } +} + +fn parse_json_array( + value: &str, + label: &str, +) -> Result, SpacetimeClientError> { + match parse_json_value(value, label)? { + serde_json::Value::Array(entries) => Ok(entries), + _ => Err(SpacetimeClientError::Runtime(format!( + "{label} 必须是 JSON array" + ))), + } +} + +fn parse_json_string_array(value: &str, label: &str) -> Result, SpacetimeClientError> { + parse_json_array(value, label)? + .into_iter() + .map(|entry| match entry { + serde_json::Value::String(value) => Ok(value), + _ => Err(SpacetimeClientError::Runtime(format!( + "{label} 必须是 string array" + ))), + }) + .collect() +} + +fn map_custom_world_checkpoint_record( + value: serde_json::Value, +) -> Result { + let object = value.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint 必须是 JSON object".to_string()) + })?; + let checkpoint_id = object + .get("checkpointId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.checkpointId 缺失".to_string()) + })?; + let created_at = object + .get("createdAt") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.createdAt 缺失".to_string()) + })?; + let label = object + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.label 缺失".to_string()) + })?; + + Ok(CustomWorldCheckpointRecord { + checkpoint_id: checkpoint_id.to_string(), + created_at: created_at.to_string(), + label: label.to_string(), + }) +} + +fn build_minimal_custom_world_supported_actions( + stage: crate::module_bindings::RpgAgentStage, + progress_percent: u32, + has_result_preview: bool, + checkpoints_json: &str, +) -> Vec { + let has_checkpoint = parse_json_array(checkpoints_json, "custom world agent checkpoints_json") + .map(|entries| !entries.is_empty()) + .unwrap_or(false); + let refining_ready = matches!( + stage, + crate::module_bindings::RpgAgentStage::FoundationReview + | crate::module_bindings::RpgAgentStage::ObjectRefining + | crate::module_bindings::RpgAgentStage::VisualRefining + | crate::module_bindings::RpgAgentStage::LongTailReview + | crate::module_bindings::RpgAgentStage::ReadyToPublish + | crate::module_bindings::RpgAgentStage::Published + ); + + vec![ + CustomWorldSupportedActionRecord { + action: "draft_foundation".to_string(), + enabled: progress_percent >= 100, + reason: (progress_percent < 100) + .then(|| "draft_foundation requires progressPercent >= 100".to_string()), + }, + CustomWorldSupportedActionRecord { + action: "publish_world".to_string(), + enabled: refining_ready && has_result_preview, + reason: (!refining_ready || !has_result_preview) + .then(|| "publish_world requires refined draft and resultPreview".to_string()), + }, + CustomWorldSupportedActionRecord { + action: "revert_checkpoint".to_string(), + enabled: has_checkpoint, + reason: (!has_checkpoint) + .then(|| "revert_checkpoint requires at least one checkpoint".to_string()), + }, + ] +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BattleStateRecord { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: String, + pub status: String, + 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, + pub turn_index: u32, + pub last_action_function_id: Option, + pub last_action_text: Option, + pub last_result_text: Option, + pub last_damage_dealt: i32, + pub last_damage_taken: i32, + pub last_outcome: String, + pub version: u32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveCombatActionRecord { + pub battle_state: BattleStateRecord, + pub damage_dealt: i32, + pub damage_taken: i32, + pub outcome: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldLibraryEntryRecord { + pub owner_user_id: String, + pub profile_id: String, + pub profile: serde_json::Value, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldGalleryEntryRecord { + pub owner_user_id: String, + pub profile_id: String, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldLibraryMutationRecord { + pub entry: CustomWorldLibraryEntryRecord, + pub gallery_entry: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldPublishedProfileCompileRecord { + pub profile_id: String, + pub owner_user_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: String, + pub cover_image_src: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub compiled_profile: serde_json::Value, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldPublishWorldRecord { + pub compiled_record: CustomWorldPublishedProfileCompileRecord, + pub entry: CustomWorldLibraryEntryRecord, + pub gallery_entry: Option, + pub session_stage: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, + pub related_operation_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentOperationRecord { + pub operation_id: String, + pub operation_type: String, + pub status: String, + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldDraftCardRecord { + pub card_id: String, + pub kind: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub status: String, + pub linked_ids: Vec, + pub warning_count: u32, + pub asset_status: Option, + pub asset_status_label: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldSupportedActionRecord { + pub action: String, + pub enabled: bool, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldCheckpointRecord { + pub checkpoint_id: String, + pub created_at: String, + pub label: String, +} + +// 兼容并行 custom world facade 中仍在使用的旧命名,避免本轮 module-npc 收口被无关改动阻塞。 +pub type CustomWorldAgentCheckpointRecord = CustomWorldCheckpointRecord; + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentSessionRecord { + pub session_id: String, + pub current_turn: u32, + pub anchor_content: serde_json::Value, + pub progress_percent: u32, + pub last_assistant_reply: Option, + pub stage: String, + pub focus_card_id: Option, + pub creator_intent: serde_json::Value, + pub creator_intent_readiness: serde_json::Value, + pub anchor_pack: serde_json::Value, + pub lock_state: serde_json::Value, + pub draft_profile: serde_json::Value, + pub messages: Vec, + pub draft_cards: Vec, + pub pending_clarifications: Vec, + pub suggested_actions: Vec, + pub recommended_replies: Vec, + pub quality_findings: Vec, + pub asset_coverage: serde_json::Value, + pub checkpoints: Vec, + pub supported_actions: Vec, + pub result_preview: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileUpsertRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub source_agent_session_id: Option, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: DomainCustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldPublishWorldRecordInput { + pub session_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + pub setting_text: String, + pub author_display_name: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub pending_clarifications_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub quality_findings_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub operation_id: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveNpcBattleInteractionInput { + pub npc_interaction: DomainResolveNpcInteractionInput, + 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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskStageRecord { + pub stage_kind: String, + pub label: String, + pub detail: String, + pub order: u32, + pub status: String, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub started_at: Option, + pub completed_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiResultReferenceRecord { + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: String, + pub reference_id: String, + pub label: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTextChunkRecord { + pub chunk_id: String, + pub task_id: String, + pub stage_kind: String, + pub sequence: u32, + pub delta_text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskRecord { + pub task_id: String, + pub task_kind: String, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub status: String, + pub failure_message: Option, + pub stages: Vec, + pub result_references: Vec, + pub latest_text_output: Option, + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskMutationRecord { + pub task: AiTaskRecord, + pub text_chunk: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcStateRecord { + pub npc_state_id: String, + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub relation_stance: String, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub trust: u8, + pub warmth: u8, + pub ideological_fit: u8, + pub fear_or_guard: u8, + pub loyalty: u8, + pub current_conflict_tag: Option, + pub recent_approvals: Vec, + pub recent_disapprovals: Vec, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcInteractionRecord { + pub npc_state: NpcStateRecord, + pub interaction_status: String, + pub action_text: String, + pub result_text: String, + pub story_text: Option, + pub battle_mode: Option, + pub encounter_closed: bool, + pub affinity_changed: bool, + pub previous_affinity: i32, + pub next_affinity: i32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcBattleInteractionRecord { + pub npc_interaction: NpcInteractionRecord, + pub battle_state: BattleStateRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct NpcBattleInteractionSnapshot { + interaction: DomainNpcInteractionResult, + battle_state: DomainBattleStateSnapshot, +} + +fn build_battle_state_record(snapshot: DomainBattleStateSnapshot) -> BattleStateRecord { + BattleStateRecord { + 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.as_str().to_string(), + status: snapshot.status.as_str().to_string(), + 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.as_str().to_string(), + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn build_resolve_combat_action_record( + result: DomainResolveCombatActionResult, +) -> ResolveCombatActionRecord { + ResolveCombatActionRecord { + battle_state: build_battle_state_record(result.snapshot), + damage_dealt: result.damage_dealt, + damage_taken: result.damage_taken, + outcome: result.outcome.as_str().to_string(), + } +} + +fn map_resolve_npc_battle_interaction_input( + input: ResolveNpcBattleInteractionInput, +) -> BindingResolveNpcBattleInteractionInput { + BindingResolveNpcBattleInteractionInput { + npc_interaction: BindingResolveNpcInteractionInput { + runtime_session_id: input.npc_interaction.runtime_session_id, + npc_id: input.npc_interaction.npc_id, + npc_name: input.npc_interaction.npc_name, + interaction_function_id: input.npc_interaction.interaction_function_id, + release_npc_id: input.npc_interaction.release_npc_id, + updated_at_micros: input.npc_interaction.updated_at_micros, + }, + story_session_id: input.story_session_id, + actor_user_id: input.actor_user_id, + battle_state_id: input.battle_state_id, + 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 + .into_iter() + .map(map_runtime_item_reward_item_snapshot) + .collect(), + } +} + +fn validate_npc_battle_interaction_input( + input: &ResolveNpcBattleInteractionInput, +) -> Result<(), SpacetimeClientError> { + let battle_state_input = DomainBattleStateInput { + battle_state_id: input + .battle_state_id + .clone() + .unwrap_or_else(|| "battle_preview".to_string()), + story_session_id: input.story_session_id.clone(), + runtime_session_id: input.npc_interaction.runtime_session_id.clone(), + actor_user_id: input.actor_user_id.clone(), + chapter_id: None, + target_npc_id: input.npc_interaction.npc_id.clone(), + target_name: input.npc_interaction.npc_name.clone(), + battle_mode: DomainBattleMode::Fight, + 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_state_input) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + for reward_item in input.reward_items.iter().cloned() { + normalize_reward_item_snapshot(reward_item) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + } + + Ok(()) +} + +fn build_npc_state_record(snapshot: DomainNpcStateSnapshot) -> NpcStateRecord { + NpcStateRecord { + 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_stance: format_npc_relation_stance(snapshot.relation_state.stance).to_string(), + 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, + trust: snapshot.stance_profile.trust, + warmth: snapshot.stance_profile.warmth, + ideological_fit: snapshot.stance_profile.ideological_fit, + fear_or_guard: snapshot.stance_profile.fear_or_guard, + loyalty: snapshot.stance_profile.loyalty, + current_conflict_tag: snapshot.stance_profile.current_conflict_tag, + recent_approvals: snapshot.stance_profile.recent_approvals, + recent_disapprovals: snapshot.stance_profile.recent_disapprovals, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn build_npc_interaction_record(result: DomainNpcInteractionResult) -> NpcInteractionRecord { + NpcInteractionRecord { + npc_state: build_npc_state_record(result.npc_state), + interaction_status: format_npc_interaction_status(result.interaction_status).to_string(), + action_text: result.action_text, + result_text: result.result_text, + story_text: result.story_text, + battle_mode: result + .battle_mode + .map(|mode| format_npc_interaction_battle_mode(mode).to_string()), + encounter_closed: result.encounter_closed, + affinity_changed: result.affinity_changed, + previous_affinity: result.previous_affinity, + next_affinity: result.next_affinity, + } +} + +fn build_npc_battle_interaction_record( + result: NpcBattleInteractionSnapshot, +) -> NpcBattleInteractionRecord { + NpcBattleInteractionRecord { + npc_interaction: build_npc_interaction_record(result.interaction), + battle_state: build_battle_state_record(result.battle_state), + } +} + +fn format_npc_relation_stance(value: DomainNpcRelationStance) -> &'static str { + match value { + DomainNpcRelationStance::Hostile => "hostile", + DomainNpcRelationStance::Guarded => "guarded", + DomainNpcRelationStance::Neutral => "neutral", + DomainNpcRelationStance::Cooperative => "cooperative", + DomainNpcRelationStance::Bonded => "bonded", + } +} + +fn format_npc_interaction_status(value: DomainNpcInteractionStatus) -> &'static str { + match value { + DomainNpcInteractionStatus::Previewed => "previewed", + DomainNpcInteractionStatus::Dialogue => "dialogue", + DomainNpcInteractionStatus::Resolved => "resolved", + DomainNpcInteractionStatus::Recruited => "recruited", + DomainNpcInteractionStatus::BattlePending => "battle_pending", + DomainNpcInteractionStatus::Left => "left", + } +} + +fn format_npc_interaction_battle_mode(value: DomainNpcInteractionBattleMode) -> &'static str { + match value { + DomainNpcInteractionBattleMode::Fight => "fight", + DomainNpcInteractionBattleMode::Spar => "spar", + } +} + +fn map_inventory_container_kind( + value: BindingInventoryContainerKind, +) -> module_inventory::InventoryContainerKind { + match value { + BindingInventoryContainerKind::Backpack => { + module_inventory::InventoryContainerKind::Backpack + } + BindingInventoryContainerKind::Equipment => { + module_inventory::InventoryContainerKind::Equipment + } + } +} + +fn map_inventory_item_rarity( + value: BindingInventoryItemRarity, +) -> module_inventory::InventoryItemRarity { + match value { + BindingInventoryItemRarity::Common => module_inventory::InventoryItemRarity::Common, + BindingInventoryItemRarity::Uncommon => module_inventory::InventoryItemRarity::Uncommon, + BindingInventoryItemRarity::Rare => module_inventory::InventoryItemRarity::Rare, + BindingInventoryItemRarity::Epic => module_inventory::InventoryItemRarity::Epic, + BindingInventoryItemRarity::Legendary => module_inventory::InventoryItemRarity::Legendary, + } +} + +fn map_inventory_equipment_slot( + value: BindingInventoryEquipmentSlot, +) -> module_inventory::InventoryEquipmentSlot { + match value { + BindingInventoryEquipmentSlot::Weapon => module_inventory::InventoryEquipmentSlot::Weapon, + BindingInventoryEquipmentSlot::Armor => module_inventory::InventoryEquipmentSlot::Armor, + BindingInventoryEquipmentSlot::Relic => module_inventory::InventoryEquipmentSlot::Relic, + } +} + +fn map_inventory_item_source_kind( + value: BindingInventoryItemSourceKind, +) -> module_inventory::InventoryItemSourceKind { + match value { + BindingInventoryItemSourceKind::StoryReward => { + module_inventory::InventoryItemSourceKind::StoryReward + } + BindingInventoryItemSourceKind::QuestReward => { + module_inventory::InventoryItemSourceKind::QuestReward + } + BindingInventoryItemSourceKind::TreasureReward => { + module_inventory::InventoryItemSourceKind::TreasureReward + } + BindingInventoryItemSourceKind::NpcGift => { + module_inventory::InventoryItemSourceKind::NpcGift + } + BindingInventoryItemSourceKind::NpcTrade => { + module_inventory::InventoryItemSourceKind::NpcTrade + } + BindingInventoryItemSourceKind::CombatDrop => { + module_inventory::InventoryItemSourceKind::CombatDrop + } + BindingInventoryItemSourceKind::ForgeCraft => { + module_inventory::InventoryItemSourceKind::ForgeCraft + } + BindingInventoryItemSourceKind::ForgeReforge => { + module_inventory::InventoryItemSourceKind::ForgeReforge + } + BindingInventoryItemSourceKind::ManualPatch => { + module_inventory::InventoryItemSourceKind::ManualPatch + } + } +} + impl fmt::Display for SpacetimeClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs new file mode 100644 index 00000000..dfebf903 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/accept_quest_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_record_input_type::QuestRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct AcceptQuestArgs { + pub input: QuestRecordInput, +} + +impl From for super::Reducer { + fn from(args: AcceptQuestArgs) -> Self { + Self::AcceptQuest { input: args.input } + } +} + +impl __sdk::InModule for AcceptQuestArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `accept_quest`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait accept_quest { + /// Request that the remote module invoke the reducer `accept_quest` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`accept_quest:accept_quest_then`] to run a callback after the reducer completes. + fn accept_quest(&self, input: QuestRecordInput) -> __sdk::Result<()> { + self.accept_quest_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `accept_quest` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn accept_quest_then( + &self, + input: QuestRecordInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl accept_quest for super::RemoteReducers { + fn accept_quest_then( + &self, + input: QuestRecordInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(AcceptQuestArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs new file mode 100644 index 00000000..6ae2fd10 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_quest_completion_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_completion_ack_input_type::QuestCompletionAckInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct AcknowledgeQuestCompletionArgs { + pub input: QuestCompletionAckInput, +} + +impl From for super::Reducer { + fn from(args: AcknowledgeQuestCompletionArgs) -> Self { + Self::AcknowledgeQuestCompletion { input: args.input } + } +} + +impl __sdk::InModule for AcknowledgeQuestCompletionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `acknowledge_quest_completion`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait acknowledge_quest_completion { + /// Request that the remote module invoke the reducer `acknowledge_quest_completion` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`acknowledge_quest_completion:acknowledge_quest_completion_then`] to run a callback after the reducer completes. + fn acknowledge_quest_completion(&self, input: QuestCompletionAckInput) -> __sdk::Result<()> { + self.acknowledge_quest_completion_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `acknowledge_quest_completion` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn acknowledge_quest_completion_then( + &self, + input: QuestCompletionAckInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl acknowledge_quest_completion for super::RemoteReducers { + fn acknowledge_quest_completion_then( + &self, + input: QuestCompletionAckInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_input_type.rs new file mode 100644 index 00000000..b63aad41 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_result_reference_kind_type::AiResultReferenceKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiResultReferenceInput { + pub task_id: String, + pub reference_kind: AiResultReferenceKind, + pub reference_id: String, + pub label: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for AiResultReferenceInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_kind_type.rs new file mode 100644 index 00000000..0e5f045b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_kind_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum AiResultReferenceKind { + StorySession, + + StoryEvent, + + CustomWorldProfile, + + QuestRecord, + + RuntimeItemRecord, + + AssetObject, +} + +impl __sdk::InModule for AiResultReferenceKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_snapshot_type.rs new file mode 100644 index 00000000..f949db7f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_result_reference_kind_type::AiResultReferenceKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiResultReferenceSnapshot { + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: AiResultReferenceKind, + pub reference_id: String, + pub label: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for AiResultReferenceSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_table.rs new file mode 100644 index 00000000..09b7c492 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::ai_result_reference_kind_type::AiResultReferenceKind; +use super::ai_result_reference_type::AiResultReference; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `ai_result_reference`. +/// +/// Obtain a handle from the [`AiResultReferenceTableAccess::ai_result_reference`] method on [`super::RemoteTables`], +/// like `ctx.db.ai_result_reference()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_result_reference().on_insert(...)`. +pub struct AiResultReferenceTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `ai_result_reference`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AiResultReferenceTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AiResultReferenceTableHandle`], which mediates access to the table `ai_result_reference`. + fn ai_result_reference(&self) -> AiResultReferenceTableHandle<'_>; +} + +impl AiResultReferenceTableAccess for super::RemoteTables { + fn ai_result_reference(&self) -> AiResultReferenceTableHandle<'_> { + AiResultReferenceTableHandle { + imp: self + .imp + .get_table::("ai_result_reference"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AiResultReferenceInsertCallbackId(__sdk::CallbackId); +pub struct AiResultReferenceDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for AiResultReferenceTableHandle<'ctx> { + type Row = AiResultReference; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AiResultReferenceInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiResultReferenceInsertCallbackId { + AiResultReferenceInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AiResultReferenceInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AiResultReferenceDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiResultReferenceDeleteCallbackId { + AiResultReferenceDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AiResultReferenceDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AiResultReferenceUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AiResultReferenceTableHandle<'ctx> { + type UpdateCallbackId = AiResultReferenceUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AiResultReferenceUpdateCallbackId { + AiResultReferenceUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AiResultReferenceUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `result_reference_row_id` unique index on the table `ai_result_reference`, +/// which allows point queries on the field of the same name +/// via the [`AiResultReferenceResultReferenceRowIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_result_reference().result_reference_row_id().find(...)`. +pub struct AiResultReferenceResultReferenceRowIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AiResultReferenceTableHandle<'ctx> { + /// Get a handle on the `result_reference_row_id` unique index on the table `ai_result_reference`. + pub fn result_reference_row_id(&self) -> AiResultReferenceResultReferenceRowIdUnique<'ctx> { + AiResultReferenceResultReferenceRowIdUnique { + imp: self + .imp + .get_unique_constraint::("result_reference_row_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AiResultReferenceResultReferenceRowIdUnique<'ctx> { + /// Find the subscribed row whose `result_reference_row_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("ai_result_reference"); + _table.add_unique_constraint::("result_reference_row_id", |row| { + &row.result_reference_row_id + }); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AiResultReference`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait ai_result_referenceQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AiResultReference`. + fn ai_result_reference(&self) -> __sdk::__query_builder::Table; +} + +impl ai_result_referenceQueryTableAccess for __sdk::QueryTableAccessor { + fn ai_result_reference(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("ai_result_reference") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_type.rs new file mode 100644 index 00000000..3b1121b0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_result_reference_type.rs @@ -0,0 +1,77 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_result_reference_kind_type::AiResultReferenceKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiResultReference { + pub result_reference_row_id: String, + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: AiResultReferenceKind, + pub reference_id: String, + pub label: Option, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for AiResultReference { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AiResultReference`. +/// +/// Provides typed access to columns for query building. +pub struct AiResultReferenceCols { + pub result_reference_row_id: __sdk::__query_builder::Col, + pub result_ref_id: __sdk::__query_builder::Col, + pub task_id: __sdk::__query_builder::Col, + pub reference_kind: __sdk::__query_builder::Col, + pub reference_id: __sdk::__query_builder::Col, + pub label: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AiResultReference { + type Cols = AiResultReferenceCols; + fn cols(table_name: &'static str) -> Self::Cols { + AiResultReferenceCols { + result_reference_row_id: __sdk::__query_builder::Col::new( + table_name, + "result_reference_row_id", + ), + result_ref_id: __sdk::__query_builder::Col::new(table_name, "result_ref_id"), + task_id: __sdk::__query_builder::Col::new(table_name, "task_id"), + reference_kind: __sdk::__query_builder::Col::new(table_name, "reference_kind"), + reference_id: __sdk::__query_builder::Col::new(table_name, "reference_id"), + label: __sdk::__query_builder::Col::new(table_name, "label"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `AiResultReference`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AiResultReferenceIxCols { + pub result_reference_row_id: __sdk::__query_builder::IxCol, + pub task_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AiResultReference { + type IxCols = AiResultReferenceIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AiResultReferenceIxCols { + result_reference_row_id: __sdk::__query_builder::IxCol::new( + table_name, + "result_reference_row_id", + ), + task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AiResultReference {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_stage_completion_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_stage_completion_input_type.rs new file mode 100644 index 00000000..7ce33006 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_stage_completion_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiStageCompletionInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for AiStageCompletionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_cancel_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_cancel_input_type.rs new file mode 100644 index 00000000..93b697a6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_cancel_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskCancelInput { + pub task_id: String, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for AiTaskCancelInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_create_input_type.rs new file mode 100644 index 00000000..95b0506a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_create_input_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_kind_type::AiTaskKind; +use super::ai_task_stage_blueprint_type::AiTaskStageBlueprint; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskCreateInput { + pub task_id: String, + pub task_kind: AiTaskKind, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub stages: Vec, + pub created_at_micros: i64, +} + +impl __sdk::InModule for AiTaskCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_failure_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_failure_input_type.rs new file mode 100644 index 00000000..91a87cf5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_failure_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskFailureInput { + pub task_id: String, + pub failure_message: String, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for AiTaskFailureInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_finish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_finish_input_type.rs new file mode 100644 index 00000000..d8fe6713 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_finish_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskFinishInput { + pub task_id: String, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for AiTaskFinishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_kind_type.rs new file mode 100644 index 00000000..9468bc95 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_kind_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum AiTaskKind { + StoryGeneration, + + CharacterChat, + + NpcChat, + + CustomWorldGeneration, + + QuestIntent, + + RuntimeItemIntent, +} + +impl __sdk::InModule for AiTaskKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_procedure_result_type.rs new file mode 100644 index 00000000..57728f6e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_snapshot_type::AiTaskSnapshot; +use super::ai_text_chunk_snapshot_type::AiTextChunkSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskProcedureResult { + pub ok: bool, + pub task: Option, + pub text_chunk: Option, + pub error_message: Option, +} + +impl __sdk::InModule for AiTaskProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_snapshot_type.rs new file mode 100644 index 00000000..66fa9a15 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_snapshot_type.rs @@ -0,0 +1,37 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_result_reference_snapshot_type::AiResultReferenceSnapshot; +use super::ai_task_kind_type::AiTaskKind; +use super::ai_task_stage_snapshot_type::AiTaskStageSnapshot; +use super::ai_task_status_type::AiTaskStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskSnapshot { + pub task_id: String, + pub task_kind: AiTaskKind, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub status: AiTaskStatus, + pub failure_message: Option, + pub stages: Vec, + pub result_references: Vec, + pub latest_text_output: Option, + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at_micros: i64, + pub started_at_micros: Option, + pub completed_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for AiTaskSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_blueprint_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_blueprint_type.rs new file mode 100644 index 00000000..8ed04d04 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_blueprint_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskStageBlueprint { + pub stage_kind: AiTaskStageKind, + pub label: String, + pub detail: String, + pub order: u32, +} + +impl __sdk::InModule for AiTaskStageBlueprint { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_kind_type.rs new file mode 100644 index 00000000..1d7405de --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_kind_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum AiTaskStageKind { + PreparePrompt, + + RequestModel, + + RepairResponse, + + NormalizeResult, + + PersistResult, +} + +impl __sdk::InModule for AiTaskStageKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_snapshot_type.rs new file mode 100644 index 00000000..30cdb845 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_snapshot_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; +use super::ai_task_stage_status_type::AiTaskStageStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskStageSnapshot { + pub stage_kind: AiTaskStageKind, + pub label: String, + pub detail: String, + pub order: u32, + pub status: AiTaskStageStatus, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub started_at_micros: Option, + pub completed_at_micros: Option, +} + +impl __sdk::InModule for AiTaskStageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_start_input_type.rs new file mode 100644 index 00000000..956fff88 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_start_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskStageStartInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub started_at_micros: i64, +} + +impl __sdk::InModule for AiTaskStageStartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_status_type.rs new file mode 100644 index 00000000..e1a28d7c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_status_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum AiTaskStageStatus { + Pending, + + Running, + + Completed, + + Skipped, +} + +impl __sdk::InModule for AiTaskStageStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_table.rs new file mode 100644 index 00000000..0bd84f77 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::ai_task_stage_kind_type::AiTaskStageKind; +use super::ai_task_stage_status_type::AiTaskStageStatus; +use super::ai_task_stage_type::AiTaskStage; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `ai_task_stage`. +/// +/// Obtain a handle from the [`AiTaskStageTableAccess::ai_task_stage`] method on [`super::RemoteTables`], +/// like `ctx.db.ai_task_stage()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_task_stage().on_insert(...)`. +pub struct AiTaskStageTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `ai_task_stage`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AiTaskStageTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AiTaskStageTableHandle`], which mediates access to the table `ai_task_stage`. + fn ai_task_stage(&self) -> AiTaskStageTableHandle<'_>; +} + +impl AiTaskStageTableAccess for super::RemoteTables { + fn ai_task_stage(&self) -> AiTaskStageTableHandle<'_> { + AiTaskStageTableHandle { + imp: self.imp.get_table::("ai_task_stage"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AiTaskStageInsertCallbackId(__sdk::CallbackId); +pub struct AiTaskStageDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for AiTaskStageTableHandle<'ctx> { + type Row = AiTaskStage; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AiTaskStageInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiTaskStageInsertCallbackId { + AiTaskStageInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AiTaskStageInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AiTaskStageDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiTaskStageDeleteCallbackId { + AiTaskStageDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AiTaskStageDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AiTaskStageUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AiTaskStageTableHandle<'ctx> { + type UpdateCallbackId = AiTaskStageUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AiTaskStageUpdateCallbackId { + AiTaskStageUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AiTaskStageUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `task_stage_id` unique index on the table `ai_task_stage`, +/// which allows point queries on the field of the same name +/// via the [`AiTaskStageTaskStageIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_task_stage().task_stage_id().find(...)`. +pub struct AiTaskStageTaskStageIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AiTaskStageTableHandle<'ctx> { + /// Get a handle on the `task_stage_id` unique index on the table `ai_task_stage`. + pub fn task_stage_id(&self) -> AiTaskStageTaskStageIdUnique<'ctx> { + AiTaskStageTaskStageIdUnique { + imp: self.imp.get_unique_constraint::("task_stage_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AiTaskStageTaskStageIdUnique<'ctx> { + /// Find the subscribed row whose `task_stage_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("ai_task_stage"); + _table.add_unique_constraint::("task_stage_id", |row| &row.task_stage_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AiTaskStage`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait ai_task_stageQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AiTaskStage`. + fn ai_task_stage(&self) -> __sdk::__query_builder::Table; +} + +impl ai_task_stageQueryTableAccess for __sdk::QueryTableAccessor { + fn ai_task_stage(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("ai_task_stage") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_type.rs new file mode 100644 index 00000000..f4c902de --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_stage_type.rs @@ -0,0 +1,90 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; +use super::ai_task_stage_status_type::AiTaskStageStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskStage { + pub task_stage_id: String, + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub label: String, + pub detail: String, + pub stage_order: u32, + pub status: AiTaskStageStatus, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub started_at: Option<__sdk::Timestamp>, + pub completed_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for AiTaskStage { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AiTaskStage`. +/// +/// Provides typed access to columns for query building. +pub struct AiTaskStageCols { + pub task_stage_id: __sdk::__query_builder::Col, + pub task_id: __sdk::__query_builder::Col, + pub stage_kind: __sdk::__query_builder::Col, + pub label: __sdk::__query_builder::Col, + pub detail: __sdk::__query_builder::Col, + pub stage_order: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub text_output: __sdk::__query_builder::Col>, + pub structured_payload_json: __sdk::__query_builder::Col>, + pub warning_messages: __sdk::__query_builder::Col>, + pub started_at: __sdk::__query_builder::Col>, + pub completed_at: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for AiTaskStage { + type Cols = AiTaskStageCols; + fn cols(table_name: &'static str) -> Self::Cols { + AiTaskStageCols { + task_stage_id: __sdk::__query_builder::Col::new(table_name, "task_stage_id"), + task_id: __sdk::__query_builder::Col::new(table_name, "task_id"), + stage_kind: __sdk::__query_builder::Col::new(table_name, "stage_kind"), + label: __sdk::__query_builder::Col::new(table_name, "label"), + detail: __sdk::__query_builder::Col::new(table_name, "detail"), + stage_order: __sdk::__query_builder::Col::new(table_name, "stage_order"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + text_output: __sdk::__query_builder::Col::new(table_name, "text_output"), + structured_payload_json: __sdk::__query_builder::Col::new( + table_name, + "structured_payload_json", + ), + warning_messages: __sdk::__query_builder::Col::new(table_name, "warning_messages"), + started_at: __sdk::__query_builder::Col::new(table_name, "started_at"), + completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"), + } + } +} + +/// Indexed column accessor struct for the table `AiTaskStage`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AiTaskStageIxCols { + pub task_id: __sdk::__query_builder::IxCol, + pub task_stage_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AiTaskStage { + type IxCols = AiTaskStageIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AiTaskStageIxCols { + task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"), + task_stage_id: __sdk::__query_builder::IxCol::new(table_name, "task_stage_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AiTaskStage {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_start_input_type.rs new file mode 100644 index 00000000..d2145e81 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_start_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTaskStartInput { + pub task_id: String, + pub started_at_micros: i64, +} + +impl __sdk::InModule for AiTaskStartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_status_type.rs new file mode 100644 index 00000000..3c59cd6a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_status_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum AiTaskStatus { + Pending, + + Running, + + Completed, + + Failed, + + Cancelled, +} + +impl __sdk::InModule for AiTaskStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_table.rs new file mode 100644 index 00000000..83c04b8a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::ai_task_kind_type::AiTaskKind; +use super::ai_task_status_type::AiTaskStatus; +use super::ai_task_type::AiTask; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `ai_task`. +/// +/// Obtain a handle from the [`AiTaskTableAccess::ai_task`] method on [`super::RemoteTables`], +/// like `ctx.db.ai_task()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_task().on_insert(...)`. +pub struct AiTaskTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `ai_task`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AiTaskTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AiTaskTableHandle`], which mediates access to the table `ai_task`. + fn ai_task(&self) -> AiTaskTableHandle<'_>; +} + +impl AiTaskTableAccess for super::RemoteTables { + fn ai_task(&self) -> AiTaskTableHandle<'_> { + AiTaskTableHandle { + imp: self.imp.get_table::("ai_task"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AiTaskInsertCallbackId(__sdk::CallbackId); +pub struct AiTaskDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for AiTaskTableHandle<'ctx> { + type Row = AiTask; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AiTaskInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiTaskInsertCallbackId { + AiTaskInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AiTaskInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AiTaskDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiTaskDeleteCallbackId { + AiTaskDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AiTaskDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AiTaskUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AiTaskTableHandle<'ctx> { + type UpdateCallbackId = AiTaskUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AiTaskUpdateCallbackId { + AiTaskUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AiTaskUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `task_id` unique index on the table `ai_task`, +/// which allows point queries on the field of the same name +/// via the [`AiTaskTaskIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_task().task_id().find(...)`. +pub struct AiTaskTaskIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AiTaskTableHandle<'ctx> { + /// Get a handle on the `task_id` unique index on the table `ai_task`. + pub fn task_id(&self) -> AiTaskTaskIdUnique<'ctx> { + AiTaskTaskIdUnique { + imp: self.imp.get_unique_constraint::("task_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AiTaskTaskIdUnique<'ctx> { + /// Find the subscribed row whose `task_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("ai_task"); + _table.add_unique_constraint::("task_id", |row| &row.task_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AiTask`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait ai_taskQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AiTask`. + fn ai_task(&self) -> __sdk::__query_builder::Table; +} + +impl ai_taskQueryTableAccess for __sdk::QueryTableAccessor { + fn ai_task(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("ai_task") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_task_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_type.rs new file mode 100644 index 00000000..6b2845fc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_task_type.rs @@ -0,0 +1,109 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_kind_type::AiTaskKind; +use super::ai_task_status_type::AiTaskStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTask { + pub task_id: String, + pub task_kind: AiTaskKind, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub status: AiTaskStatus, + pub failure_message: Option, + pub latest_text_output: Option, + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at: __sdk::Timestamp, + pub started_at: Option<__sdk::Timestamp>, + pub completed_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for AiTask { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AiTask`. +/// +/// Provides typed access to columns for query building. +pub struct AiTaskCols { + pub task_id: __sdk::__query_builder::Col, + pub task_kind: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub request_label: __sdk::__query_builder::Col, + pub source_module: __sdk::__query_builder::Col, + pub source_entity_id: __sdk::__query_builder::Col>, + pub request_payload_json: __sdk::__query_builder::Col>, + pub status: __sdk::__query_builder::Col, + pub failure_message: __sdk::__query_builder::Col>, + pub latest_text_output: __sdk::__query_builder::Col>, + pub latest_structured_payload_json: __sdk::__query_builder::Col>, + pub version: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub started_at: __sdk::__query_builder::Col>, + pub completed_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AiTask { + type Cols = AiTaskCols; + fn cols(table_name: &'static str) -> Self::Cols { + AiTaskCols { + task_id: __sdk::__query_builder::Col::new(table_name, "task_id"), + task_kind: __sdk::__query_builder::Col::new(table_name, "task_kind"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + request_label: __sdk::__query_builder::Col::new(table_name, "request_label"), + source_module: __sdk::__query_builder::Col::new(table_name, "source_module"), + source_entity_id: __sdk::__query_builder::Col::new(table_name, "source_entity_id"), + request_payload_json: __sdk::__query_builder::Col::new( + table_name, + "request_payload_json", + ), + status: __sdk::__query_builder::Col::new(table_name, "status"), + failure_message: __sdk::__query_builder::Col::new(table_name, "failure_message"), + latest_text_output: __sdk::__query_builder::Col::new(table_name, "latest_text_output"), + latest_structured_payload_json: __sdk::__query_builder::Col::new( + table_name, + "latest_structured_payload_json", + ), + version: __sdk::__query_builder::Col::new(table_name, "version"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + started_at: __sdk::__query_builder::Col::new(table_name, "started_at"), + completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `AiTask`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AiTaskIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub status: __sdk::__query_builder::IxCol, + pub task_id: __sdk::__query_builder::IxCol, + pub task_kind: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AiTask { + type IxCols = AiTaskIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AiTaskIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + status: __sdk::__query_builder::IxCol::new(table_name, "status"), + task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"), + task_kind: __sdk::__query_builder::IxCol::new(table_name, "task_kind"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AiTask {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_append_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_append_input_type.rs new file mode 100644 index 00000000..0f462690 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_append_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTextChunkAppendInput { + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub sequence: u32, + pub delta_text: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for AiTextChunkAppendInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_snapshot_type.rs new file mode 100644 index 00000000..f386071c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTextChunkSnapshot { + pub chunk_id: String, + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub sequence: u32, + pub delta_text: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for AiTextChunkSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_table.rs new file mode 100644 index 00000000..7311d79f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::ai_task_stage_kind_type::AiTaskStageKind; +use super::ai_text_chunk_type::AiTextChunk; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `ai_text_chunk`. +/// +/// Obtain a handle from the [`AiTextChunkTableAccess::ai_text_chunk`] method on [`super::RemoteTables`], +/// like `ctx.db.ai_text_chunk()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_text_chunk().on_insert(...)`. +pub struct AiTextChunkTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `ai_text_chunk`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AiTextChunkTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AiTextChunkTableHandle`], which mediates access to the table `ai_text_chunk`. + fn ai_text_chunk(&self) -> AiTextChunkTableHandle<'_>; +} + +impl AiTextChunkTableAccess for super::RemoteTables { + fn ai_text_chunk(&self) -> AiTextChunkTableHandle<'_> { + AiTextChunkTableHandle { + imp: self.imp.get_table::("ai_text_chunk"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AiTextChunkInsertCallbackId(__sdk::CallbackId); +pub struct AiTextChunkDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for AiTextChunkTableHandle<'ctx> { + type Row = AiTextChunk; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AiTextChunkInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiTextChunkInsertCallbackId { + AiTextChunkInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AiTextChunkInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AiTextChunkDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AiTextChunkDeleteCallbackId { + AiTextChunkDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AiTextChunkDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AiTextChunkUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AiTextChunkTableHandle<'ctx> { + type UpdateCallbackId = AiTextChunkUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AiTextChunkUpdateCallbackId { + AiTextChunkUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AiTextChunkUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `text_chunk_row_id` unique index on the table `ai_text_chunk`, +/// which allows point queries on the field of the same name +/// via the [`AiTextChunkTextChunkRowIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.ai_text_chunk().text_chunk_row_id().find(...)`. +pub struct AiTextChunkTextChunkRowIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AiTextChunkTableHandle<'ctx> { + /// Get a handle on the `text_chunk_row_id` unique index on the table `ai_text_chunk`. + pub fn text_chunk_row_id(&self) -> AiTextChunkTextChunkRowIdUnique<'ctx> { + AiTextChunkTextChunkRowIdUnique { + imp: self + .imp + .get_unique_constraint::("text_chunk_row_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AiTextChunkTextChunkRowIdUnique<'ctx> { + /// Find the subscribed row whose `text_chunk_row_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("ai_text_chunk"); + _table.add_unique_constraint::("text_chunk_row_id", |row| &row.text_chunk_row_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AiTextChunk`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait ai_text_chunkQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AiTextChunk`. + fn ai_text_chunk(&self) -> __sdk::__query_builder::Table; +} + +impl ai_text_chunkQueryTableAccess for __sdk::QueryTableAccessor { + fn ai_text_chunk(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("ai_text_chunk") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_type.rs new file mode 100644 index 00000000..8a1a8c93 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/ai_text_chunk_type.rs @@ -0,0 +1,71 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_kind_type::AiTaskStageKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AiTextChunk { + pub text_chunk_row_id: String, + pub chunk_id: String, + pub task_id: String, + pub stage_kind: AiTaskStageKind, + pub sequence: u32, + pub delta_text: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for AiTextChunk { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AiTextChunk`. +/// +/// Provides typed access to columns for query building. +pub struct AiTextChunkCols { + pub text_chunk_row_id: __sdk::__query_builder::Col, + pub chunk_id: __sdk::__query_builder::Col, + pub task_id: __sdk::__query_builder::Col, + pub stage_kind: __sdk::__query_builder::Col, + pub sequence: __sdk::__query_builder::Col, + pub delta_text: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AiTextChunk { + type Cols = AiTextChunkCols; + fn cols(table_name: &'static str) -> Self::Cols { + AiTextChunkCols { + text_chunk_row_id: __sdk::__query_builder::Col::new(table_name, "text_chunk_row_id"), + chunk_id: __sdk::__query_builder::Col::new(table_name, "chunk_id"), + task_id: __sdk::__query_builder::Col::new(table_name, "task_id"), + stage_kind: __sdk::__query_builder::Col::new(table_name, "stage_kind"), + sequence: __sdk::__query_builder::Col::new(table_name, "sequence"), + delta_text: __sdk::__query_builder::Col::new(table_name, "delta_text"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `AiTextChunk`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AiTextChunkIxCols { + pub task_id: __sdk::__query_builder::IxCol, + pub text_chunk_row_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AiTextChunk { + type IxCols = AiTextChunkIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AiTextChunkIxCols { + task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"), + text_chunk_row_id: __sdk::__query_builder::IxCol::new(table_name, "text_chunk_row_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AiTextChunk {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs new file mode 100644 index 00000000..11323392 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_procedure_result_type::AiTaskProcedureResult; +use super::ai_text_chunk_append_input_type::AiTextChunkAppendInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AppendAiTextChunkAndReturnArgs { + pub input: AiTextChunkAppendInput, +} + +impl __sdk::InModule for AppendAiTextChunkAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `append_ai_text_chunk_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait append_ai_text_chunk_and_return { + fn append_ai_text_chunk_and_return(&self, input: AiTextChunkAppendInput) { + self.append_ai_text_chunk_and_return_then(input, |_, _| {}); + } + + fn append_ai_text_chunk_and_return_then( + &self, + input: AiTextChunkAppendInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl append_ai_text_chunk_and_return for super::RemoteProcedures { + fn append_ai_text_chunk_and_return_then( + &self, + input: AiTextChunkAppendInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( + "append_ai_text_chunk_and_return", + AppendAiTextChunkAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs new file mode 100644 index 00000000..bba4c841 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_progression_ledger_input_type::ChapterProgressionLedgerInput; +use super::chapter_progression_procedure_result_type::ChapterProgressionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ApplyChapterProgressionLedgerEntryAndReturnArgs { + pub input: ChapterProgressionLedgerInput, +} + +impl __sdk::InModule for ApplyChapterProgressionLedgerEntryAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `apply_chapter_progression_ledger_entry_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait apply_chapter_progression_ledger_entry_and_return { + fn apply_chapter_progression_ledger_entry_and_return( + &self, + input: ChapterProgressionLedgerInput, + ) { + self.apply_chapter_progression_ledger_entry_and_return_then(input, |_, _| {}); + } + + fn apply_chapter_progression_ledger_entry_and_return_then( + &self, + input: ChapterProgressionLedgerInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl apply_chapter_progression_ledger_entry_and_return for super::RemoteProcedures { + fn apply_chapter_progression_ledger_entry_and_return_then( + &self, + input: ChapterProgressionLedgerInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ChapterProgressionProcedureResult>( + "apply_chapter_progression_ledger_entry_and_return", + ApplyChapterProgressionLedgerEntryAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_reducer.rs new file mode 100644 index 00000000..98f821ef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_reducer.rs @@ -0,0 +1,73 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_progression_ledger_input_type::ChapterProgressionLedgerInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ApplyChapterProgressionLedgerEntryArgs { + pub input: ChapterProgressionLedgerInput, +} + +impl From for super::Reducer { + fn from(args: ApplyChapterProgressionLedgerEntryArgs) -> Self { + Self::ApplyChapterProgressionLedgerEntry { input: args.input } + } +} + +impl __sdk::InModule for ApplyChapterProgressionLedgerEntryArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `apply_chapter_progression_ledger_entry`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait apply_chapter_progression_ledger_entry { + /// Request that the remote module invoke the reducer `apply_chapter_progression_ledger_entry` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`apply_chapter_progression_ledger_entry:apply_chapter_progression_ledger_entry_then`] to run a callback after the reducer completes. + fn apply_chapter_progression_ledger_entry( + &self, + input: ChapterProgressionLedgerInput, + ) -> __sdk::Result<()> { + self.apply_chapter_progression_ledger_entry_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `apply_chapter_progression_ledger_entry` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn apply_chapter_progression_ledger_entry_then( + &self, + input: ChapterProgressionLedgerInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl apply_chapter_progression_ledger_entry for super::RemoteReducers { + fn apply_chapter_progression_ledger_entry_then( + &self, + input: ChapterProgressionLedgerInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + ApplyChapterProgressionLedgerEntryArgs { input }, + callback, + ) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/apply_inventory_mutation_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/apply_inventory_mutation_reducer.rs new file mode 100644 index 00000000..91f7d2c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/apply_inventory_mutation_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::inventory_mutation_input_type::InventoryMutationInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ApplyInventoryMutationArgs { + pub input: InventoryMutationInput, +} + +impl From for super::Reducer { + fn from(args: ApplyInventoryMutationArgs) -> Self { + Self::ApplyInventoryMutation { input: args.input } + } +} + +impl __sdk::InModule for ApplyInventoryMutationArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `apply_inventory_mutation`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait apply_inventory_mutation { + /// Request that the remote module invoke the reducer `apply_inventory_mutation` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`apply_inventory_mutation:apply_inventory_mutation_then`] to run a callback after the reducer completes. + fn apply_inventory_mutation(&self, input: InventoryMutationInput) -> __sdk::Result<()> { + self.apply_inventory_mutation_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `apply_inventory_mutation` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn apply_inventory_mutation_then( + &self, + input: InventoryMutationInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl apply_inventory_mutation for super::RemoteReducers { + fn apply_inventory_mutation_then( + &self, + input: InventoryMutationInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ApplyInventoryMutationArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/apply_quest_signal_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/apply_quest_signal_reducer.rs new file mode 100644 index 00000000..afb452b5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/apply_quest_signal_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_signal_apply_input_type::QuestSignalApplyInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ApplyQuestSignalArgs { + pub input: QuestSignalApplyInput, +} + +impl From for super::Reducer { + fn from(args: ApplyQuestSignalArgs) -> Self { + Self::ApplyQuestSignal { input: args.input } + } +} + +impl __sdk::InModule for ApplyQuestSignalArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `apply_quest_signal`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait apply_quest_signal { + /// Request that the remote module invoke the reducer `apply_quest_signal` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`apply_quest_signal:apply_quest_signal_then`] to run a callback after the reducer completes. + fn apply_quest_signal(&self, input: QuestSignalApplyInput) -> __sdk::Result<()> { + self.apply_quest_signal_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `apply_quest_signal` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn apply_quest_signal_then( + &self, + input: QuestSignalApplyInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl apply_quest_signal for super::RemoteReducers { + fn apply_quest_signal_then( + &self, + input: QuestSignalApplyInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ApplyQuestSignalArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_table.rs new file mode 100644 index 00000000..971704ae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::asset_entity_binding_type::AssetEntityBinding; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `asset_entity_binding`. +/// +/// Obtain a handle from the [`AssetEntityBindingTableAccess::asset_entity_binding`] method on [`super::RemoteTables`], +/// like `ctx.db.asset_entity_binding()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.asset_entity_binding().on_insert(...)`. +pub struct AssetEntityBindingTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `asset_entity_binding`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AssetEntityBindingTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AssetEntityBindingTableHandle`], which mediates access to the table `asset_entity_binding`. + fn asset_entity_binding(&self) -> AssetEntityBindingTableHandle<'_>; +} + +impl AssetEntityBindingTableAccess for super::RemoteTables { + fn asset_entity_binding(&self) -> AssetEntityBindingTableHandle<'_> { + AssetEntityBindingTableHandle { + imp: self + .imp + .get_table::("asset_entity_binding"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AssetEntityBindingInsertCallbackId(__sdk::CallbackId); +pub struct AssetEntityBindingDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for AssetEntityBindingTableHandle<'ctx> { + type Row = AssetEntityBinding; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AssetEntityBindingInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AssetEntityBindingInsertCallbackId { + AssetEntityBindingInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AssetEntityBindingInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AssetEntityBindingDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AssetEntityBindingDeleteCallbackId { + AssetEntityBindingDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AssetEntityBindingDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AssetEntityBindingUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AssetEntityBindingTableHandle<'ctx> { + type UpdateCallbackId = AssetEntityBindingUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AssetEntityBindingUpdateCallbackId { + AssetEntityBindingUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AssetEntityBindingUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `binding_id` unique index on the table `asset_entity_binding`, +/// which allows point queries on the field of the same name +/// via the [`AssetEntityBindingBindingIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.asset_entity_binding().binding_id().find(...)`. +pub struct AssetEntityBindingBindingIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AssetEntityBindingTableHandle<'ctx> { + /// Get a handle on the `binding_id` unique index on the table `asset_entity_binding`. + pub fn binding_id(&self) -> AssetEntityBindingBindingIdUnique<'ctx> { + AssetEntityBindingBindingIdUnique { + imp: self.imp.get_unique_constraint::("binding_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AssetEntityBindingBindingIdUnique<'ctx> { + /// Find the subscribed row whose `binding_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("asset_entity_binding"); + _table.add_unique_constraint::("binding_id", |row| &row.binding_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AssetEntityBinding`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait asset_entity_bindingQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AssetEntityBinding`. + fn asset_entity_binding(&self) -> __sdk::__query_builder::Table; +} + +impl asset_entity_bindingQueryTableAccess for __sdk::QueryTableAccessor { + fn asset_entity_binding(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("asset_entity_binding") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_table.rs new file mode 100644 index 00000000..526fa287 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_table.rs @@ -0,0 +1,160 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::asset_object_access_policy_type::AssetObjectAccessPolicy; +use super::asset_object_type::AssetObject; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `asset_object`. +/// +/// Obtain a handle from the [`AssetObjectTableAccess::asset_object`] method on [`super::RemoteTables`], +/// like `ctx.db.asset_object()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.asset_object().on_insert(...)`. +pub struct AssetObjectTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `asset_object`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AssetObjectTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AssetObjectTableHandle`], which mediates access to the table `asset_object`. + fn asset_object(&self) -> AssetObjectTableHandle<'_>; +} + +impl AssetObjectTableAccess for super::RemoteTables { + fn asset_object(&self) -> AssetObjectTableHandle<'_> { + AssetObjectTableHandle { + imp: self.imp.get_table::("asset_object"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AssetObjectInsertCallbackId(__sdk::CallbackId); +pub struct AssetObjectDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for AssetObjectTableHandle<'ctx> { + type Row = AssetObject; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AssetObjectInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AssetObjectInsertCallbackId { + AssetObjectInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AssetObjectInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AssetObjectDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AssetObjectDeleteCallbackId { + AssetObjectDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AssetObjectDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AssetObjectUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AssetObjectTableHandle<'ctx> { + type UpdateCallbackId = AssetObjectUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AssetObjectUpdateCallbackId { + AssetObjectUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AssetObjectUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `asset_object_id` unique index on the table `asset_object`, +/// which allows point queries on the field of the same name +/// via the [`AssetObjectAssetObjectIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.asset_object().asset_object_id().find(...)`. +pub struct AssetObjectAssetObjectIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AssetObjectTableHandle<'ctx> { + /// Get a handle on the `asset_object_id` unique index on the table `asset_object`. + pub fn asset_object_id(&self) -> AssetObjectAssetObjectIdUnique<'ctx> { + AssetObjectAssetObjectIdUnique { + imp: self.imp.get_unique_constraint::("asset_object_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AssetObjectAssetObjectIdUnique<'ctx> { + /// Find the subscribed row whose `asset_object_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("asset_object"); + _table.add_unique_constraint::("asset_object_id", |row| &row.asset_object_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AssetObject`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait asset_objectQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AssetObject`. + fn asset_object(&self) -> __sdk::__query_builder::Table; +} + +impl asset_objectQueryTableAccess for __sdk::QueryTableAccessor { + fn asset_object(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("asset_object") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs new file mode 100644 index 00000000..2f3edbe2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_result_reference_input_type::AiResultReferenceInput; +use super::ai_task_procedure_result_type::AiTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AttachAiResultReferenceAndReturnArgs { + pub input: AiResultReferenceInput, +} + +impl __sdk::InModule for AttachAiResultReferenceAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `attach_ai_result_reference_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait attach_ai_result_reference_and_return { + fn attach_ai_result_reference_and_return(&self, input: AiResultReferenceInput) { + self.attach_ai_result_reference_and_return_then(input, |_, _| {}); + } + + fn attach_ai_result_reference_and_return_then( + &self, + input: AiResultReferenceInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl attach_ai_result_reference_and_return for super::RemoteProcedures { + fn attach_ai_result_reference_and_return_then( + &self, + input: AiResultReferenceInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( + "attach_ai_result_reference_and_return", + AttachAiResultReferenceAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_mode_type.rs new file mode 100644 index 00000000..a88c25f4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_mode_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum BattleMode { + Fight, + + Spar, +} + +impl __sdk::InModule for BattleMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_state_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_input_type.rs new file mode 100644 index 00000000..6a176e2d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_input_type.rs @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_mode_type::BattleMode; +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BattleStateInput { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: BattleMode, + 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, + pub created_at_micros: i64, +} + +impl __sdk::InModule for BattleStateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_state_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_procedure_result_type.rs new file mode 100644 index 00000000..e66352ad --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_state_snapshot_type::BattleStateSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BattleStateProcedureResult { + pub ok: bool, + pub snapshot: Option, + pub error_message: Option, +} + +impl __sdk::InModule for BattleStateProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_state_query_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_query_input_type.rs new file mode 100644 index 00000000..53053cca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_query_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BattleStateQueryInput { + pub battle_state_id: String, +} + +impl __sdk::InModule for BattleStateQueryInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_state_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_snapshot_type.rs new file mode 100644 index 00000000..925d8468 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_snapshot_type.rs @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_mode_type::BattleMode; +use super::battle_status_type::BattleStatus; +use super::combat_outcome_type::CombatOutcome; +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BattleStateSnapshot { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: BattleMode, + pub status: BattleStatus, + 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, + pub turn_index: u32, + pub last_action_function_id: Option, + pub last_action_text: Option, + pub last_result_text: Option, + pub last_damage_dealt: i32, + pub last_damage_taken: i32, + pub last_outcome: CombatOutcome, + pub version: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for BattleStateSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_state_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_table.rs new file mode 100644 index 00000000..98a289a4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::battle_mode_type::BattleMode; +use super::battle_state_type::BattleState; +use super::battle_status_type::BattleStatus; +use super::combat_outcome_type::CombatOutcome; +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `battle_state`. +/// +/// Obtain a handle from the [`BattleStateTableAccess::battle_state`] method on [`super::RemoteTables`], +/// like `ctx.db.battle_state()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.battle_state().on_insert(...)`. +pub struct BattleStateTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `battle_state`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait BattleStateTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`BattleStateTableHandle`], which mediates access to the table `battle_state`. + fn battle_state(&self) -> BattleStateTableHandle<'_>; +} + +impl BattleStateTableAccess for super::RemoteTables { + fn battle_state(&self) -> BattleStateTableHandle<'_> { + BattleStateTableHandle { + imp: self.imp.get_table::("battle_state"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct BattleStateInsertCallbackId(__sdk::CallbackId); +pub struct BattleStateDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for BattleStateTableHandle<'ctx> { + type Row = BattleState; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = BattleStateInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BattleStateInsertCallbackId { + BattleStateInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: BattleStateInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = BattleStateDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BattleStateDeleteCallbackId { + BattleStateDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: BattleStateDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct BattleStateUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for BattleStateTableHandle<'ctx> { + type UpdateCallbackId = BattleStateUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> BattleStateUpdateCallbackId { + BattleStateUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: BattleStateUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `battle_state_id` unique index on the table `battle_state`, +/// which allows point queries on the field of the same name +/// via the [`BattleStateBattleStateIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.battle_state().battle_state_id().find(...)`. +pub struct BattleStateBattleStateIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> BattleStateTableHandle<'ctx> { + /// Get a handle on the `battle_state_id` unique index on the table `battle_state`. + pub fn battle_state_id(&self) -> BattleStateBattleStateIdUnique<'ctx> { + BattleStateBattleStateIdUnique { + imp: self.imp.get_unique_constraint::("battle_state_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> BattleStateBattleStateIdUnique<'ctx> { + /// Find the subscribed row whose `battle_state_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("battle_state"); + _table.add_unique_constraint::("battle_state_id", |row| &row.battle_state_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `BattleState`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait battle_stateQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `BattleState`. + fn battle_state(&self) -> __sdk::__query_builder::Table; +} + +impl battle_stateQueryTableAccess for __sdk::QueryTableAccessor { + fn battle_state(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("battle_state") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_type.rs new file mode 100644 index 00000000..9d9b852f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_state_type.rs @@ -0,0 +1,144 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_mode_type::BattleMode; +use super::battle_status_type::BattleStatus; +use super::combat_outcome_type::CombatOutcome; +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BattleState { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: BattleMode, + pub status: BattleStatus, + 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, + pub turn_index: u32, + pub last_action_function_id: Option, + pub last_action_text: Option, + pub last_result_text: Option, + pub last_damage_dealt: i32, + pub last_damage_taken: i32, + pub last_outcome: CombatOutcome, + pub version: u32, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for BattleState { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `BattleState`. +/// +/// Provides typed access to columns for query building. +pub struct BattleStateCols { + pub battle_state_id: __sdk::__query_builder::Col, + pub story_session_id: __sdk::__query_builder::Col, + pub runtime_session_id: __sdk::__query_builder::Col, + pub actor_user_id: __sdk::__query_builder::Col, + pub chapter_id: __sdk::__query_builder::Col>, + pub target_npc_id: __sdk::__query_builder::Col, + pub target_name: __sdk::__query_builder::Col, + pub battle_mode: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub player_hp: __sdk::__query_builder::Col, + pub player_max_hp: __sdk::__query_builder::Col, + pub player_mana: __sdk::__query_builder::Col, + pub player_max_mana: __sdk::__query_builder::Col, + pub target_hp: __sdk::__query_builder::Col, + pub target_max_hp: __sdk::__query_builder::Col, + pub experience_reward: __sdk::__query_builder::Col, + pub reward_items: __sdk::__query_builder::Col>, + pub turn_index: __sdk::__query_builder::Col, + pub last_action_function_id: __sdk::__query_builder::Col>, + pub last_action_text: __sdk::__query_builder::Col>, + pub last_result_text: __sdk::__query_builder::Col>, + pub last_damage_dealt: __sdk::__query_builder::Col, + pub last_damage_taken: __sdk::__query_builder::Col, + pub last_outcome: __sdk::__query_builder::Col, + pub version: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for BattleState { + type Cols = BattleStateCols; + fn cols(table_name: &'static str) -> Self::Cols { + BattleStateCols { + battle_state_id: __sdk::__query_builder::Col::new(table_name, "battle_state_id"), + story_session_id: __sdk::__query_builder::Col::new(table_name, "story_session_id"), + runtime_session_id: __sdk::__query_builder::Col::new(table_name, "runtime_session_id"), + actor_user_id: __sdk::__query_builder::Col::new(table_name, "actor_user_id"), + chapter_id: __sdk::__query_builder::Col::new(table_name, "chapter_id"), + target_npc_id: __sdk::__query_builder::Col::new(table_name, "target_npc_id"), + target_name: __sdk::__query_builder::Col::new(table_name, "target_name"), + battle_mode: __sdk::__query_builder::Col::new(table_name, "battle_mode"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + player_hp: __sdk::__query_builder::Col::new(table_name, "player_hp"), + player_max_hp: __sdk::__query_builder::Col::new(table_name, "player_max_hp"), + player_mana: __sdk::__query_builder::Col::new(table_name, "player_mana"), + player_max_mana: __sdk::__query_builder::Col::new(table_name, "player_max_mana"), + target_hp: __sdk::__query_builder::Col::new(table_name, "target_hp"), + target_max_hp: __sdk::__query_builder::Col::new(table_name, "target_max_hp"), + experience_reward: __sdk::__query_builder::Col::new(table_name, "experience_reward"), + reward_items: __sdk::__query_builder::Col::new(table_name, "reward_items"), + turn_index: __sdk::__query_builder::Col::new(table_name, "turn_index"), + last_action_function_id: __sdk::__query_builder::Col::new( + table_name, + "last_action_function_id", + ), + last_action_text: __sdk::__query_builder::Col::new(table_name, "last_action_text"), + last_result_text: __sdk::__query_builder::Col::new(table_name, "last_result_text"), + last_damage_dealt: __sdk::__query_builder::Col::new(table_name, "last_damage_dealt"), + last_damage_taken: __sdk::__query_builder::Col::new(table_name, "last_damage_taken"), + last_outcome: __sdk::__query_builder::Col::new(table_name, "last_outcome"), + version: __sdk::__query_builder::Col::new(table_name, "version"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `BattleState`. +/// +/// Provides typed access to indexed columns for query building. +pub struct BattleStateIxCols { + pub actor_user_id: __sdk::__query_builder::IxCol, + pub battle_state_id: __sdk::__query_builder::IxCol, + pub runtime_session_id: __sdk::__query_builder::IxCol, + pub story_session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for BattleState { + type IxCols = BattleStateIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + BattleStateIxCols { + actor_user_id: __sdk::__query_builder::IxCol::new(table_name, "actor_user_id"), + battle_state_id: __sdk::__query_builder::IxCol::new(table_name, "battle_state_id"), + runtime_session_id: __sdk::__query_builder::IxCol::new( + table_name, + "runtime_session_id", + ), + story_session_id: __sdk::__query_builder::IxCol::new(table_name, "story_session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for BattleState {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/battle_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/battle_status_type.rs new file mode 100644 index 00000000..0aba4f43 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/battle_status_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum BattleStatus { + Ongoing, + + Resolved, + + Aborted, +} + +impl __sdk::InModule for BattleStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs new file mode 100644 index 00000000..eef3de0f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_session_input_type::StorySessionInput; +use super::story_session_procedure_result_type::StorySessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct BeginStorySessionAndReturnArgs { + pub input: StorySessionInput, +} + +impl __sdk::InModule for BeginStorySessionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `begin_story_session_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait begin_story_session_and_return { + fn begin_story_session_and_return(&self, input: StorySessionInput) { + self.begin_story_session_and_return_then(input, |_, _| {}); + } + + fn begin_story_session_and_return_then( + &self, + input: StorySessionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl begin_story_session_and_return for super::RemoteProcedures { + fn begin_story_session_and_return_then( + &self, + input: StorySessionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, StorySessionProcedureResult>( + "begin_story_session_and_return", + BeginStorySessionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_reducer.rs new file mode 100644 index 00000000..6a082f41 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_session_input_type::StorySessionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct BeginStorySessionArgs { + pub input: StorySessionInput, +} + +impl From for super::Reducer { + fn from(args: BeginStorySessionArgs) -> Self { + Self::BeginStorySession { input: args.input } + } +} + +impl __sdk::InModule for BeginStorySessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `begin_story_session`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait begin_story_session { + /// Request that the remote module invoke the reducer `begin_story_session` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`begin_story_session:begin_story_session_then`] to run a callback after the reducer completes. + fn begin_story_session(&self, input: StorySessionInput) -> __sdk::Result<()> { + self.begin_story_session_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `begin_story_session` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn begin_story_session_then( + &self, + input: StorySessionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl begin_story_session for super::RemoteReducers { + fn begin_story_session_then( + &self, + input: StorySessionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(BeginStorySessionArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs new file mode 100644 index 00000000..b239e060 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_cancel_input_type::AiTaskCancelInput; +use super::ai_task_procedure_result_type::AiTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CancelAiTaskAndReturnArgs { + pub input: AiTaskCancelInput, +} + +impl __sdk::InModule for CancelAiTaskAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `cancel_ai_task_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait cancel_ai_task_and_return { + fn cancel_ai_task_and_return(&self, input: AiTaskCancelInput) { + self.cancel_ai_task_and_return_then(input, |_, _| {}); + } + + fn cancel_ai_task_and_return_then( + &self, + input: AiTaskCancelInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl cancel_ai_task_and_return for super::RemoteProcedures { + fn cancel_ai_task_and_return_then( + &self, + input: AiTaskCancelInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( + "cancel_ai_task_and_return", + CancelAiTaskAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_pace_band_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_pace_band_type.rs new file mode 100644 index 00000000..effbeb9d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_pace_band_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum ChapterPaceBand { + OpeningFast, + + Steady, + + Pressure, + + FinaleDense, +} + +impl __sdk::InModule for ChapterPaceBand { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_get_input_type.rs new file mode 100644 index 00000000..927a4b04 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ChapterProgressionGetInput { + pub user_id: String, + pub chapter_id: String, +} + +impl __sdk::InModule for ChapterProgressionGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_input_type.rs new file mode 100644 index 00000000..dae51fa0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_input_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_pace_band_type::ChapterPaceBand; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ChapterProgressionInput { + pub user_id: String, + pub chapter_id: String, + pub chapter_index: u32, + pub total_chapters: u32, + pub entry_pseudo_level_millis: u32, + pub exit_pseudo_level_millis: u32, + pub entry_level: u32, + pub exit_level: u32, + pub planned_total_xp: u32, + pub planned_quest_xp: u32, + pub planned_hostile_xp: u32, + pub expected_hostile_defeat_count: u32, + pub level_at_entry: u32, + pub pace_band: ChapterPaceBand, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for ChapterProgressionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_ledger_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_ledger_input_type.rs new file mode 100644 index 00000000..a599ffc9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_ledger_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ChapterProgressionLedgerInput { + pub user_id: String, + pub chapter_id: String, + pub granted_quest_xp: u32, + pub granted_hostile_xp: u32, + pub hostile_defeat_increment: u32, + pub level_at_exit: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for ChapterProgressionLedgerInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_procedure_result_type.rs new file mode 100644 index 00000000..276b3a26 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_progression_snapshot_type::ChapterProgressionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ChapterProgressionProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for ChapterProgressionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_snapshot_type.rs new file mode 100644 index 00000000..e1fde7fe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_snapshot_type.rs @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_pace_band_type::ChapterPaceBand; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ChapterProgressionSnapshot { + pub user_id: String, + pub chapter_id: String, + pub chapter_index: u32, + pub total_chapters: u32, + pub entry_pseudo_level_millis: u32, + pub exit_pseudo_level_millis: u32, + pub entry_level: u32, + pub exit_level: u32, + pub planned_total_xp: u32, + pub planned_quest_xp: u32, + pub planned_hostile_xp: u32, + pub actual_quest_xp: u32, + pub actual_hostile_xp: u32, + pub expected_hostile_defeat_count: u32, + pub actual_hostile_defeat_count: u32, + pub level_at_entry: u32, + pub level_at_exit: Option, + pub pace_band: ChapterPaceBand, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for ChapterProgressionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_table.rs new file mode 100644 index 00000000..2fb6c042 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::chapter_pace_band_type::ChapterPaceBand; +use super::chapter_progression_type::ChapterProgression; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `chapter_progression`. +/// +/// Obtain a handle from the [`ChapterProgressionTableAccess::chapter_progression`] method on [`super::RemoteTables`], +/// like `ctx.db.chapter_progression()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.chapter_progression().on_insert(...)`. +pub struct ChapterProgressionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `chapter_progression`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ChapterProgressionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ChapterProgressionTableHandle`], which mediates access to the table `chapter_progression`. + fn chapter_progression(&self) -> ChapterProgressionTableHandle<'_>; +} + +impl ChapterProgressionTableAccess for super::RemoteTables { + fn chapter_progression(&self) -> ChapterProgressionTableHandle<'_> { + ChapterProgressionTableHandle { + imp: self + .imp + .get_table::("chapter_progression"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ChapterProgressionInsertCallbackId(__sdk::CallbackId); +pub struct ChapterProgressionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ChapterProgressionTableHandle<'ctx> { + type Row = ChapterProgression; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ChapterProgressionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ChapterProgressionInsertCallbackId { + ChapterProgressionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ChapterProgressionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ChapterProgressionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ChapterProgressionDeleteCallbackId { + ChapterProgressionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ChapterProgressionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ChapterProgressionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ChapterProgressionTableHandle<'ctx> { + type UpdateCallbackId = ChapterProgressionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ChapterProgressionUpdateCallbackId { + ChapterProgressionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ChapterProgressionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `chapter_progression_id` unique index on the table `chapter_progression`, +/// which allows point queries on the field of the same name +/// via the [`ChapterProgressionChapterProgressionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.chapter_progression().chapter_progression_id().find(...)`. +pub struct ChapterProgressionChapterProgressionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ChapterProgressionTableHandle<'ctx> { + /// Get a handle on the `chapter_progression_id` unique index on the table `chapter_progression`. + pub fn chapter_progression_id(&self) -> ChapterProgressionChapterProgressionIdUnique<'ctx> { + ChapterProgressionChapterProgressionIdUnique { + imp: self + .imp + .get_unique_constraint::("chapter_progression_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ChapterProgressionChapterProgressionIdUnique<'ctx> { + /// Find the subscribed row whose `chapter_progression_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("chapter_progression"); + _table.add_unique_constraint::("chapter_progression_id", |row| { + &row.chapter_progression_id + }); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ChapterProgression`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait chapter_progressionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ChapterProgression`. + fn chapter_progression(&self) -> __sdk::__query_builder::Table; +} + +impl chapter_progressionQueryTableAccess for __sdk::QueryTableAccessor { + fn chapter_progression(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("chapter_progression") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_type.rs new file mode 100644 index 00000000..c94a7196 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/chapter_progression_type.rs @@ -0,0 +1,133 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_pace_band_type::ChapterPaceBand; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ChapterProgression { + pub chapter_progression_id: String, + pub user_id: String, + pub chapter_id: String, + pub chapter_index: u32, + pub total_chapters: u32, + pub entry_pseudo_level_millis: u32, + pub exit_pseudo_level_millis: u32, + pub entry_level: u32, + pub exit_level: u32, + pub planned_total_xp: u32, + pub planned_quest_xp: u32, + pub planned_hostile_xp: u32, + pub actual_quest_xp: u32, + pub actual_hostile_xp: u32, + pub expected_hostile_defeat_count: u32, + pub actual_hostile_defeat_count: u32, + pub level_at_entry: u32, + pub level_at_exit: Option, + pub pace_band: ChapterPaceBand, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ChapterProgression { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ChapterProgression`. +/// +/// Provides typed access to columns for query building. +pub struct ChapterProgressionCols { + pub chapter_progression_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub chapter_id: __sdk::__query_builder::Col, + pub chapter_index: __sdk::__query_builder::Col, + pub total_chapters: __sdk::__query_builder::Col, + pub entry_pseudo_level_millis: __sdk::__query_builder::Col, + pub exit_pseudo_level_millis: __sdk::__query_builder::Col, + pub entry_level: __sdk::__query_builder::Col, + pub exit_level: __sdk::__query_builder::Col, + pub planned_total_xp: __sdk::__query_builder::Col, + pub planned_quest_xp: __sdk::__query_builder::Col, + pub planned_hostile_xp: __sdk::__query_builder::Col, + pub actual_quest_xp: __sdk::__query_builder::Col, + pub actual_hostile_xp: __sdk::__query_builder::Col, + pub expected_hostile_defeat_count: __sdk::__query_builder::Col, + pub actual_hostile_defeat_count: __sdk::__query_builder::Col, + pub level_at_entry: __sdk::__query_builder::Col, + pub level_at_exit: __sdk::__query_builder::Col>, + pub pace_band: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ChapterProgression { + type Cols = ChapterProgressionCols; + fn cols(table_name: &'static str) -> Self::Cols { + ChapterProgressionCols { + chapter_progression_id: __sdk::__query_builder::Col::new( + table_name, + "chapter_progression_id", + ), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + chapter_id: __sdk::__query_builder::Col::new(table_name, "chapter_id"), + chapter_index: __sdk::__query_builder::Col::new(table_name, "chapter_index"), + total_chapters: __sdk::__query_builder::Col::new(table_name, "total_chapters"), + entry_pseudo_level_millis: __sdk::__query_builder::Col::new( + table_name, + "entry_pseudo_level_millis", + ), + exit_pseudo_level_millis: __sdk::__query_builder::Col::new( + table_name, + "exit_pseudo_level_millis", + ), + entry_level: __sdk::__query_builder::Col::new(table_name, "entry_level"), + exit_level: __sdk::__query_builder::Col::new(table_name, "exit_level"), + planned_total_xp: __sdk::__query_builder::Col::new(table_name, "planned_total_xp"), + planned_quest_xp: __sdk::__query_builder::Col::new(table_name, "planned_quest_xp"), + planned_hostile_xp: __sdk::__query_builder::Col::new(table_name, "planned_hostile_xp"), + actual_quest_xp: __sdk::__query_builder::Col::new(table_name, "actual_quest_xp"), + actual_hostile_xp: __sdk::__query_builder::Col::new(table_name, "actual_hostile_xp"), + expected_hostile_defeat_count: __sdk::__query_builder::Col::new( + table_name, + "expected_hostile_defeat_count", + ), + actual_hostile_defeat_count: __sdk::__query_builder::Col::new( + table_name, + "actual_hostile_defeat_count", + ), + level_at_entry: __sdk::__query_builder::Col::new(table_name, "level_at_entry"), + level_at_exit: __sdk::__query_builder::Col::new(table_name, "level_at_exit"), + pace_band: __sdk::__query_builder::Col::new(table_name, "pace_band"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ChapterProgression`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ChapterProgressionIxCols { + pub chapter_id: __sdk::__query_builder::IxCol, + pub chapter_progression_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ChapterProgression { + type IxCols = ChapterProgressionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ChapterProgressionIxCols { + chapter_id: __sdk::__query_builder::IxCol::new(table_name, "chapter_id"), + chapter_progression_id: __sdk::__query_builder::IxCol::new( + table_name, + "chapter_progression_id", + ), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ChapterProgression {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs new file mode 100644 index 00000000..2e623845 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_browse_history_clear_input_type::RuntimeBrowseHistoryClearInput; +use super::runtime_browse_history_procedure_result_type::RuntimeBrowseHistoryProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClearPlatformBrowseHistoryAndReturnArgs { + pub input: RuntimeBrowseHistoryClearInput, +} + +impl __sdk::InModule for ClearPlatformBrowseHistoryAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `clear_platform_browse_history_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait clear_platform_browse_history_and_return { + fn clear_platform_browse_history_and_return(&self, input: RuntimeBrowseHistoryClearInput) { + self.clear_platform_browse_history_and_return_then(input, |_, _| {}); + } + + fn clear_platform_browse_history_and_return_then( + &self, + input: RuntimeBrowseHistoryClearInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl clear_platform_browse_history_and_return for super::RemoteProcedures { + fn clear_platform_browse_history_and_return_then( + &self, + input: RuntimeBrowseHistoryClearInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeBrowseHistoryProcedureResult>( + "clear_platform_browse_history_and_return", + ClearPlatformBrowseHistoryAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/combat_outcome_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/combat_outcome_type.rs new file mode 100644 index 00000000..731563dd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/combat_outcome_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum CombatOutcome { + Ongoing, + + Victory, + + SparComplete, + + Escaped, +} + +impl __sdk::InModule for CombatOutcome { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs new file mode 100644 index 00000000..aefbe602 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_published_profile_compile_input_type::CustomWorldPublishedProfileCompileInput; +use super::custom_world_published_profile_compile_result_type::CustomWorldPublishedProfileCompileResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompileCustomWorldPublishedProfileArgs { + pub input: CustomWorldPublishedProfileCompileInput, +} + +impl __sdk::InModule for CompileCustomWorldPublishedProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `compile_custom_world_published_profile`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait compile_custom_world_published_profile { + fn compile_custom_world_published_profile( + &self, + input: CustomWorldPublishedProfileCompileInput, + ) { + self.compile_custom_world_published_profile_then(input, |_, _| {}); + } + + fn compile_custom_world_published_profile_then( + &self, + input: CustomWorldPublishedProfileCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl compile_custom_world_published_profile for super::RemoteProcedures { + fn compile_custom_world_published_profile_then( + &self, + input: CustomWorldPublishedProfileCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldPublishedProfileCompileResult>( + "compile_custom_world_published_profile", + CompileCustomWorldPublishedProfileArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs new file mode 100644 index 00000000..51375935 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_stage_completion_input_type::AiStageCompletionInput; +use super::ai_task_procedure_result_type::AiTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompleteAiStageAndReturnArgs { + pub input: AiStageCompletionInput, +} + +impl __sdk::InModule for CompleteAiStageAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `complete_ai_stage_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait complete_ai_stage_and_return { + fn complete_ai_stage_and_return(&self, input: AiStageCompletionInput) { + self.complete_ai_stage_and_return_then(input, |_, _| {}); + } + + fn complete_ai_stage_and_return_then( + &self, + input: AiStageCompletionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl complete_ai_stage_and_return for super::RemoteProcedures { + fn complete_ai_stage_and_return_then( + &self, + input: AiStageCompletionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( + "complete_ai_stage_and_return", + CompleteAiStageAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs new file mode 100644 index 00000000..040af639 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_finish_input_type::AiTaskFinishInput; +use super::ai_task_procedure_result_type::AiTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompleteAiTaskAndReturnArgs { + pub input: AiTaskFinishInput, +} + +impl __sdk::InModule for CompleteAiTaskAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `complete_ai_task_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait complete_ai_task_and_return { + fn complete_ai_task_and_return(&self, input: AiTaskFinishInput) { + self.complete_ai_task_and_return_then(input, |_, _| {}); + } + + fn complete_ai_task_and_return_then( + &self, + input: AiTaskFinishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl complete_ai_task_and_return for super::RemoteProcedures { + fn complete_ai_task_and_return_then( + &self, + input: AiTaskFinishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( + "complete_ai_task_and_return", + CompleteAiTaskAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/consume_inventory_item_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/consume_inventory_item_input_type.rs new file mode 100644 index 00000000..0e867e21 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/consume_inventory_item_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ConsumeInventoryItemInput { + pub slot_id: String, + pub quantity: u32, +} + +impl __sdk::InModule for ConsumeInventoryItemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs new file mode 100644 index 00000000..0d58ec1b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_continue_input_type::StoryContinueInput; +use super::story_session_procedure_result_type::StorySessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ContinueStoryAndReturnArgs { + pub input: StoryContinueInput, +} + +impl __sdk::InModule for ContinueStoryAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `continue_story_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait continue_story_and_return { + fn continue_story_and_return(&self, input: StoryContinueInput) { + self.continue_story_and_return_then(input, |_, _| {}); + } + + fn continue_story_and_return_then( + &self, + input: StoryContinueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl continue_story_and_return for super::RemoteProcedures { + fn continue_story_and_return_then( + &self, + input: StoryContinueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, StorySessionProcedureResult>( + "continue_story_and_return", + ContinueStoryAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/continue_story_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/continue_story_reducer.rs new file mode 100644 index 00000000..4117cfae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/continue_story_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_continue_input_type::StoryContinueInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ContinueStoryArgs { + pub input: StoryContinueInput, +} + +impl From for super::Reducer { + fn from(args: ContinueStoryArgs) -> Self { + Self::ContinueStory { input: args.input } + } +} + +impl __sdk::InModule for ContinueStoryArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `continue_story`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait continue_story { + /// Request that the remote module invoke the reducer `continue_story` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`continue_story:continue_story_then`] to run a callback after the reducer completes. + fn continue_story(&self, input: StoryContinueInput) -> __sdk::Result<()> { + self.continue_story_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `continue_story` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn continue_story_then( + &self, + input: StoryContinueInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl continue_story for super::RemoteReducers { + fn continue_story_then( + &self, + input: StoryContinueInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ContinueStoryArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs new file mode 100644 index 00000000..20d8ceee --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_create_input_type::AiTaskCreateInput; +use super::ai_task_procedure_result_type::AiTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateAiTaskAndReturnArgs { + pub input: AiTaskCreateInput, +} + +impl __sdk::InModule for CreateAiTaskAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_ai_task_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_ai_task_and_return { + fn create_ai_task_and_return(&self, input: AiTaskCreateInput) { + self.create_ai_task_and_return_then(input, |_, _| {}); + } + + fn create_ai_task_and_return_then( + &self, + input: AiTaskCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_ai_task_and_return for super::RemoteProcedures { + fn create_ai_task_and_return_then( + &self, + input: AiTaskCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( + "create_ai_task_and_return", + CreateAiTaskAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs new file mode 100644 index 00000000..213f28e5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_create_input_type::AiTaskCreateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct CreateAiTaskArgs { + pub input: AiTaskCreateInput, +} + +impl From for super::Reducer { + fn from(args: CreateAiTaskArgs) -> Self { + Self::CreateAiTask { input: args.input } + } +} + +impl __sdk::InModule for CreateAiTaskArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `create_ai_task`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait create_ai_task { + /// Request that the remote module invoke the reducer `create_ai_task` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`create_ai_task:create_ai_task_then`] to run a callback after the reducer completes. + fn create_ai_task(&self, input: AiTaskCreateInput) -> __sdk::Result<()> { + self.create_ai_task_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `create_ai_task` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn create_ai_task_then( + &self, + input: AiTaskCreateInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl create_ai_task for super::RemoteReducers { + fn create_ai_task_then( + &self, + input: AiTaskCreateInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(CreateAiTaskArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs new file mode 100644 index 00000000..ef11107a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_state_input_type::BattleStateInput; +use super::battle_state_procedure_result_type::BattleStateProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateBattleStateAndReturnArgs { + pub input: BattleStateInput, +} + +impl __sdk::InModule for CreateBattleStateAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_battle_state_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_battle_state_and_return { + fn create_battle_state_and_return(&self, input: BattleStateInput) { + self.create_battle_state_and_return_then(input, |_, _| {}); + } + + fn create_battle_state_and_return_then( + &self, + input: BattleStateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_battle_state_and_return for super::RemoteProcedures { + fn create_battle_state_and_return_then( + &self, + input: BattleStateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BattleStateProcedureResult>( + "create_battle_state_and_return", + CreateBattleStateAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_reducer.rs new file mode 100644 index 00000000..1072f8a5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_state_input_type::BattleStateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct CreateBattleStateArgs { + pub input: BattleStateInput, +} + +impl From for super::Reducer { + fn from(args: CreateBattleStateArgs) -> Self { + Self::CreateBattleState { input: args.input } + } +} + +impl __sdk::InModule for CreateBattleStateArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `create_battle_state`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait create_battle_state { + /// Request that the remote module invoke the reducer `create_battle_state` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`create_battle_state:create_battle_state_then`] to run a callback after the reducer completes. + fn create_battle_state(&self, input: BattleStateInput) -> __sdk::Result<()> { + self.create_battle_state_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `create_battle_state` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn create_battle_state_then( + &self, + input: BattleStateInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl create_battle_state for super::RemoteReducers { + fn create_battle_state_then( + &self, + input: BattleStateInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(CreateBattleStateArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs new file mode 100644 index 00000000..6a08bcc7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput; +use super::custom_world_agent_session_procedure_result_type::CustomWorldAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateCustomWorldAgentSessionArgs { + pub input: CustomWorldAgentSessionCreateInput, +} + +impl __sdk::InModule for CreateCustomWorldAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_custom_world_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_custom_world_agent_session { + fn create_custom_world_agent_session(&self, input: CustomWorldAgentSessionCreateInput) { + self.create_custom_world_agent_session_then(input, |_, _| {}); + } + + fn create_custom_world_agent_session_then( + &self, + input: CustomWorldAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_custom_world_agent_session for super::RemoteProcedures { + fn create_custom_world_agent_session_then( + &self, + input: CustomWorldAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldAgentSessionProcedureResult>( + "create_custom_world_agent_session", + CreateCustomWorldAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_snapshot_type.rs new file mode 100644 index 00000000..69368214 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::rpg_agent_message_kind_type::RpgAgentMessageKind; +use super::rpg_agent_message_role_type::RpgAgentMessageRole; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: RpgAgentMessageRole, + pub kind: RpgAgentMessageKind, + pub text: String, + pub related_operation_id: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldAgentMessageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_submit_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_submit_input_type.rs new file mode 100644 index 00000000..cf3a4230 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_submit_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub operation_id: String, + pub submitted_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldAgentMessageSubmitInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_table.rs new file mode 100644 index 00000000..1d0886e7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_table.rs @@ -0,0 +1,164 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::custom_world_agent_message_type::CustomWorldAgentMessage; +use super::rpg_agent_message_kind_type::RpgAgentMessageKind; +use super::rpg_agent_message_role_type::RpgAgentMessageRole; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `custom_world_agent_message`. +/// +/// Obtain a handle from the [`CustomWorldAgentMessageTableAccess::custom_world_agent_message`] method on [`super::RemoteTables`], +/// like `ctx.db.custom_world_agent_message()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_agent_message().on_insert(...)`. +pub struct CustomWorldAgentMessageTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `custom_world_agent_message`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CustomWorldAgentMessageTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CustomWorldAgentMessageTableHandle`], which mediates access to the table `custom_world_agent_message`. + fn custom_world_agent_message(&self) -> CustomWorldAgentMessageTableHandle<'_>; +} + +impl CustomWorldAgentMessageTableAccess for super::RemoteTables { + fn custom_world_agent_message(&self) -> CustomWorldAgentMessageTableHandle<'_> { + CustomWorldAgentMessageTableHandle { + imp: self + .imp + .get_table::("custom_world_agent_message"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CustomWorldAgentMessageInsertCallbackId(__sdk::CallbackId); +pub struct CustomWorldAgentMessageDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CustomWorldAgentMessageTableHandle<'ctx> { + type Row = CustomWorldAgentMessage; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CustomWorldAgentMessageInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentMessageInsertCallbackId { + CustomWorldAgentMessageInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CustomWorldAgentMessageInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CustomWorldAgentMessageDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentMessageDeleteCallbackId { + CustomWorldAgentMessageDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CustomWorldAgentMessageDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CustomWorldAgentMessageUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CustomWorldAgentMessageTableHandle<'ctx> { + type UpdateCallbackId = CustomWorldAgentMessageUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentMessageUpdateCallbackId { + CustomWorldAgentMessageUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CustomWorldAgentMessageUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `message_id` unique index on the table `custom_world_agent_message`, +/// which allows point queries on the field of the same name +/// via the [`CustomWorldAgentMessageMessageIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_agent_message().message_id().find(...)`. +pub struct CustomWorldAgentMessageMessageIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CustomWorldAgentMessageTableHandle<'ctx> { + /// Get a handle on the `message_id` unique index on the table `custom_world_agent_message`. + pub fn message_id(&self) -> CustomWorldAgentMessageMessageIdUnique<'ctx> { + CustomWorldAgentMessageMessageIdUnique { + imp: self.imp.get_unique_constraint::("message_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CustomWorldAgentMessageMessageIdUnique<'ctx> { + /// Find the subscribed row whose `message_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("custom_world_agent_message"); + _table.add_unique_constraint::("message_id", |row| &row.message_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CustomWorldAgentMessage`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait custom_world_agent_messageQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CustomWorldAgentMessage`. + fn custom_world_agent_message(&self) -> __sdk::__query_builder::Table; +} + +impl custom_world_agent_messageQueryTableAccess for __sdk::QueryTableAccessor { + fn custom_world_agent_message(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("custom_world_agent_message") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_type.rs new file mode 100644 index 00000000..75110860 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_message_type.rs @@ -0,0 +1,75 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::rpg_agent_message_kind_type::RpgAgentMessageKind; +use super::rpg_agent_message_role_type::RpgAgentMessageRole; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentMessage { + pub message_id: String, + pub session_id: String, + pub role: RpgAgentMessageRole, + pub kind: RpgAgentMessageKind, + pub text: String, + pub related_operation_id: Option, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CustomWorldAgentMessage { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CustomWorldAgentMessage`. +/// +/// Provides typed access to columns for query building. +pub struct CustomWorldAgentMessageCols { + pub message_id: __sdk::__query_builder::Col, + pub session_id: __sdk::__query_builder::Col, + pub role: __sdk::__query_builder::Col, + pub kind: __sdk::__query_builder::Col, + pub text: __sdk::__query_builder::Col, + pub related_operation_id: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CustomWorldAgentMessage { + type Cols = CustomWorldAgentMessageCols; + fn cols(table_name: &'static str) -> Self::Cols { + CustomWorldAgentMessageCols { + message_id: __sdk::__query_builder::Col::new(table_name, "message_id"), + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + role: __sdk::__query_builder::Col::new(table_name, "role"), + kind: __sdk::__query_builder::Col::new(table_name, "kind"), + text: __sdk::__query_builder::Col::new(table_name, "text"), + related_operation_id: __sdk::__query_builder::Col::new( + table_name, + "related_operation_id", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `CustomWorldAgentMessage`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CustomWorldAgentMessageIxCols { + pub message_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CustomWorldAgentMessage { + type IxCols = CustomWorldAgentMessageIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CustomWorldAgentMessageIxCols { + message_id: __sdk::__query_builder::IxCol::new(table_name, "message_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CustomWorldAgentMessage {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_get_input_type.rs new file mode 100644 index 00000000..1e249edf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_get_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentOperationGetInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, +} + +impl __sdk::InModule for CustomWorldAgentOperationGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_procedure_result_type.rs new file mode 100644 index 00000000..39d0e493 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentOperationProcedureResult { + pub ok: bool, + pub operation: Option, + pub error_message: Option, +} + +impl __sdk::InModule for CustomWorldAgentOperationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_snapshot_type.rs new file mode 100644 index 00000000..61e86eb3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_snapshot_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::rpg_agent_operation_status_type::RpgAgentOperationStatus; +use super::rpg_agent_operation_type_type::RpgAgentOperationType; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentOperationSnapshot { + pub operation_id: String, + pub session_id: String, + pub operation_type: RpgAgentOperationType, + pub status: RpgAgentOperationStatus, + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, + pub error_message: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldAgentOperationSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_table.rs new file mode 100644 index 00000000..316a95b1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_table.rs @@ -0,0 +1,168 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::custom_world_agent_operation_type::CustomWorldAgentOperation; +use super::rpg_agent_operation_status_type::RpgAgentOperationStatus; +use super::rpg_agent_operation_type_type::RpgAgentOperationType; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `custom_world_agent_operation`. +/// +/// Obtain a handle from the [`CustomWorldAgentOperationTableAccess::custom_world_agent_operation`] method on [`super::RemoteTables`], +/// like `ctx.db.custom_world_agent_operation()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_agent_operation().on_insert(...)`. +pub struct CustomWorldAgentOperationTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `custom_world_agent_operation`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CustomWorldAgentOperationTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CustomWorldAgentOperationTableHandle`], which mediates access to the table `custom_world_agent_operation`. + fn custom_world_agent_operation(&self) -> CustomWorldAgentOperationTableHandle<'_>; +} + +impl CustomWorldAgentOperationTableAccess for super::RemoteTables { + fn custom_world_agent_operation(&self) -> CustomWorldAgentOperationTableHandle<'_> { + CustomWorldAgentOperationTableHandle { + imp: self + .imp + .get_table::("custom_world_agent_operation"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CustomWorldAgentOperationInsertCallbackId(__sdk::CallbackId); +pub struct CustomWorldAgentOperationDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CustomWorldAgentOperationTableHandle<'ctx> { + type Row = CustomWorldAgentOperation; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CustomWorldAgentOperationInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentOperationInsertCallbackId { + CustomWorldAgentOperationInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CustomWorldAgentOperationInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CustomWorldAgentOperationDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentOperationDeleteCallbackId { + CustomWorldAgentOperationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CustomWorldAgentOperationDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CustomWorldAgentOperationUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CustomWorldAgentOperationTableHandle<'ctx> { + type UpdateCallbackId = CustomWorldAgentOperationUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentOperationUpdateCallbackId { + CustomWorldAgentOperationUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CustomWorldAgentOperationUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `operation_id` unique index on the table `custom_world_agent_operation`, +/// which allows point queries on the field of the same name +/// via the [`CustomWorldAgentOperationOperationIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_agent_operation().operation_id().find(...)`. +pub struct CustomWorldAgentOperationOperationIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CustomWorldAgentOperationTableHandle<'ctx> { + /// Get a handle on the `operation_id` unique index on the table `custom_world_agent_operation`. + pub fn operation_id(&self) -> CustomWorldAgentOperationOperationIdUnique<'ctx> { + CustomWorldAgentOperationOperationIdUnique { + imp: self.imp.get_unique_constraint::("operation_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CustomWorldAgentOperationOperationIdUnique<'ctx> { + /// Find the subscribed row whose `operation_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("custom_world_agent_operation"); + _table.add_unique_constraint::("operation_id", |row| &row.operation_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CustomWorldAgentOperation`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait custom_world_agent_operationQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CustomWorldAgentOperation`. + fn custom_world_agent_operation( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl custom_world_agent_operationQueryTableAccess for __sdk::QueryTableAccessor { + fn custom_world_agent_operation( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("custom_world_agent_operation") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_type.rs new file mode 100644 index 00000000..41957317 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_type.rs @@ -0,0 +1,82 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::rpg_agent_operation_status_type::RpgAgentOperationStatus; +use super::rpg_agent_operation_type_type::RpgAgentOperationType; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentOperation { + pub operation_id: String, + pub session_id: String, + pub operation_type: RpgAgentOperationType, + pub status: RpgAgentOperationStatus, + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, + pub error_message: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CustomWorldAgentOperation { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CustomWorldAgentOperation`. +/// +/// Provides typed access to columns for query building. +pub struct CustomWorldAgentOperationCols { + pub operation_id: __sdk::__query_builder::Col, + pub session_id: __sdk::__query_builder::Col, + pub operation_type: + __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub phase_label: __sdk::__query_builder::Col, + pub phase_detail: __sdk::__query_builder::Col, + pub progress: __sdk::__query_builder::Col, + pub error_message: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CustomWorldAgentOperation { + type Cols = CustomWorldAgentOperationCols; + fn cols(table_name: &'static str) -> Self::Cols { + CustomWorldAgentOperationCols { + operation_id: __sdk::__query_builder::Col::new(table_name, "operation_id"), + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + operation_type: __sdk::__query_builder::Col::new(table_name, "operation_type"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + phase_label: __sdk::__query_builder::Col::new(table_name, "phase_label"), + phase_detail: __sdk::__query_builder::Col::new(table_name, "phase_detail"), + progress: __sdk::__query_builder::Col::new(table_name, "progress"), + error_message: __sdk::__query_builder::Col::new(table_name, "error_message"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CustomWorldAgentOperation`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CustomWorldAgentOperationIxCols { + pub operation_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CustomWorldAgentOperation { + type IxCols = CustomWorldAgentOperationIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CustomWorldAgentOperationIxCols { + operation_id: __sdk::__query_builder::IxCol::new(table_name, "operation_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CustomWorldAgentOperation {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_create_input_type.rs new file mode 100644 index 00000000..d2ea6be9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_create_input_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub pending_clarifications_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub quality_findings_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldAgentSessionCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_get_input_type.rs new file mode 100644 index 00000000..733eaecb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for CustomWorldAgentSessionGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_procedure_result_type.rs new file mode 100644 index 00000000..1ca24f19 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_agent_session_snapshot_type::CustomWorldAgentSessionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentSessionProcedureResult { + pub ok: bool, + pub session: Option, + pub error_message: Option, +} + +impl __sdk::InModule for CustomWorldAgentSessionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs new file mode 100644 index 00000000..5982c401 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_snapshot_type.rs @@ -0,0 +1,45 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_agent_message_snapshot_type::CustomWorldAgentMessageSnapshot; +use super::custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; +use super::custom_world_draft_card_snapshot_type::CustomWorldDraftCardSnapshot; +use super::rpg_agent_stage_type::RpgAgentStage; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: RpgAgentStage, + pub focus_card_id: Option, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub last_assistant_reply: Option, + pub result_preview_json: Option, + pub pending_clarifications_json: String, + pub quality_findings_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub messages: Vec, + pub draft_cards: Vec, + pub operations: Vec, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldAgentSessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_table.rs new file mode 100644 index 00000000..d63d156c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::custom_world_agent_session_type::CustomWorldAgentSession; +use super::rpg_agent_stage_type::RpgAgentStage; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `custom_world_agent_session`. +/// +/// Obtain a handle from the [`CustomWorldAgentSessionTableAccess::custom_world_agent_session`] method on [`super::RemoteTables`], +/// like `ctx.db.custom_world_agent_session()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_agent_session().on_insert(...)`. +pub struct CustomWorldAgentSessionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `custom_world_agent_session`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CustomWorldAgentSessionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CustomWorldAgentSessionTableHandle`], which mediates access to the table `custom_world_agent_session`. + fn custom_world_agent_session(&self) -> CustomWorldAgentSessionTableHandle<'_>; +} + +impl CustomWorldAgentSessionTableAccess for super::RemoteTables { + fn custom_world_agent_session(&self) -> CustomWorldAgentSessionTableHandle<'_> { + CustomWorldAgentSessionTableHandle { + imp: self + .imp + .get_table::("custom_world_agent_session"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CustomWorldAgentSessionInsertCallbackId(__sdk::CallbackId); +pub struct CustomWorldAgentSessionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CustomWorldAgentSessionTableHandle<'ctx> { + type Row = CustomWorldAgentSession; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CustomWorldAgentSessionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentSessionInsertCallbackId { + CustomWorldAgentSessionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CustomWorldAgentSessionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CustomWorldAgentSessionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentSessionDeleteCallbackId { + CustomWorldAgentSessionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CustomWorldAgentSessionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CustomWorldAgentSessionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CustomWorldAgentSessionTableHandle<'ctx> { + type UpdateCallbackId = CustomWorldAgentSessionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CustomWorldAgentSessionUpdateCallbackId { + CustomWorldAgentSessionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CustomWorldAgentSessionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `session_id` unique index on the table `custom_world_agent_session`, +/// which allows point queries on the field of the same name +/// via the [`CustomWorldAgentSessionSessionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_agent_session().session_id().find(...)`. +pub struct CustomWorldAgentSessionSessionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CustomWorldAgentSessionTableHandle<'ctx> { + /// Get a handle on the `session_id` unique index on the table `custom_world_agent_session`. + pub fn session_id(&self) -> CustomWorldAgentSessionSessionIdUnique<'ctx> { + CustomWorldAgentSessionSessionIdUnique { + imp: self.imp.get_unique_constraint::("session_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CustomWorldAgentSessionSessionIdUnique<'ctx> { + /// Find the subscribed row whose `session_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("custom_world_agent_session"); + _table.add_unique_constraint::("session_id", |row| &row.session_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CustomWorldAgentSession`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait custom_world_agent_sessionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CustomWorldAgentSession`. + fn custom_world_agent_session(&self) -> __sdk::__query_builder::Table; +} + +impl custom_world_agent_sessionQueryTableAccess for __sdk::QueryTableAccessor { + fn custom_world_agent_session(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("custom_world_agent_session") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs new file mode 100644 index 00000000..c787f102 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_session_type.rs @@ -0,0 +1,151 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::rpg_agent_stage_type::RpgAgentStage; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentSession { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: RpgAgentStage, + pub focus_card_id: Option, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub last_assistant_reply: Option, + pub result_preview_json: Option, + pub pending_clarifications_json: String, + pub quality_findings_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CustomWorldAgentSession { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CustomWorldAgentSession`. +/// +/// Provides typed access to columns for query building. +pub struct CustomWorldAgentSessionCols { + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub seed_text: __sdk::__query_builder::Col, + pub current_turn: __sdk::__query_builder::Col, + pub progress_percent: __sdk::__query_builder::Col, + pub stage: __sdk::__query_builder::Col, + pub focus_card_id: __sdk::__query_builder::Col>, + pub anchor_content_json: __sdk::__query_builder::Col, + pub creator_intent_json: __sdk::__query_builder::Col>, + pub creator_intent_readiness_json: __sdk::__query_builder::Col, + pub anchor_pack_json: __sdk::__query_builder::Col>, + pub lock_state_json: __sdk::__query_builder::Col>, + pub draft_profile_json: __sdk::__query_builder::Col>, + pub last_assistant_reply: __sdk::__query_builder::Col>, + pub result_preview_json: __sdk::__query_builder::Col>, + pub pending_clarifications_json: __sdk::__query_builder::Col, + pub quality_findings_json: __sdk::__query_builder::Col, + pub suggested_actions_json: __sdk::__query_builder::Col, + pub recommended_replies_json: __sdk::__query_builder::Col, + pub asset_coverage_json: __sdk::__query_builder::Col, + pub checkpoints_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CustomWorldAgentSession { + type Cols = CustomWorldAgentSessionCols; + fn cols(table_name: &'static str) -> Self::Cols { + CustomWorldAgentSessionCols { + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + seed_text: __sdk::__query_builder::Col::new(table_name, "seed_text"), + current_turn: __sdk::__query_builder::Col::new(table_name, "current_turn"), + progress_percent: __sdk::__query_builder::Col::new(table_name, "progress_percent"), + stage: __sdk::__query_builder::Col::new(table_name, "stage"), + focus_card_id: __sdk::__query_builder::Col::new(table_name, "focus_card_id"), + anchor_content_json: __sdk::__query_builder::Col::new( + table_name, + "anchor_content_json", + ), + creator_intent_json: __sdk::__query_builder::Col::new( + table_name, + "creator_intent_json", + ), + creator_intent_readiness_json: __sdk::__query_builder::Col::new( + table_name, + "creator_intent_readiness_json", + ), + anchor_pack_json: __sdk::__query_builder::Col::new(table_name, "anchor_pack_json"), + lock_state_json: __sdk::__query_builder::Col::new(table_name, "lock_state_json"), + draft_profile_json: __sdk::__query_builder::Col::new(table_name, "draft_profile_json"), + last_assistant_reply: __sdk::__query_builder::Col::new( + table_name, + "last_assistant_reply", + ), + result_preview_json: __sdk::__query_builder::Col::new( + table_name, + "result_preview_json", + ), + pending_clarifications_json: __sdk::__query_builder::Col::new( + table_name, + "pending_clarifications_json", + ), + quality_findings_json: __sdk::__query_builder::Col::new( + table_name, + "quality_findings_json", + ), + suggested_actions_json: __sdk::__query_builder::Col::new( + table_name, + "suggested_actions_json", + ), + recommended_replies_json: __sdk::__query_builder::Col::new( + table_name, + "recommended_replies_json", + ), + asset_coverage_json: __sdk::__query_builder::Col::new( + table_name, + "asset_coverage_json", + ), + checkpoints_json: __sdk::__query_builder::Col::new(table_name, "checkpoints_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CustomWorldAgentSession`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CustomWorldAgentSessionIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, + pub stage: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CustomWorldAgentSession { + type IxCols = CustomWorldAgentSessionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CustomWorldAgentSessionIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + stage: __sdk::__query_builder::IxCol::new(table_name, "stage"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CustomWorldAgentSession {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_snapshot_type.rs new file mode 100644 index 00000000..84108f6e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_snapshot_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_role_asset_status_type::CustomWorldRoleAssetStatus; +use super::rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind; +use super::rpg_agent_draft_card_status_type::RpgAgentDraftCardStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldDraftCardSnapshot { + pub card_id: String, + pub session_id: String, + pub kind: RpgAgentDraftCardKind, + pub status: RpgAgentDraftCardStatus, + pub title: String, + pub subtitle: String, + pub summary: String, + pub linked_ids_json: String, + pub warning_count: u32, + pub asset_status: Option, + pub asset_status_label: Option, + pub detail_payload_json: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldDraftCardSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_table.rs new file mode 100644 index 00000000..6f175998 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_table.rs @@ -0,0 +1,164 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::custom_world_draft_card_type::CustomWorldDraftCard; +use super::custom_world_role_asset_status_type::CustomWorldRoleAssetStatus; +use super::rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind; +use super::rpg_agent_draft_card_status_type::RpgAgentDraftCardStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `custom_world_draft_card`. +/// +/// Obtain a handle from the [`CustomWorldDraftCardTableAccess::custom_world_draft_card`] method on [`super::RemoteTables`], +/// like `ctx.db.custom_world_draft_card()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_draft_card().on_insert(...)`. +pub struct CustomWorldDraftCardTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `custom_world_draft_card`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CustomWorldDraftCardTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CustomWorldDraftCardTableHandle`], which mediates access to the table `custom_world_draft_card`. + fn custom_world_draft_card(&self) -> CustomWorldDraftCardTableHandle<'_>; +} + +impl CustomWorldDraftCardTableAccess for super::RemoteTables { + fn custom_world_draft_card(&self) -> CustomWorldDraftCardTableHandle<'_> { + CustomWorldDraftCardTableHandle { + imp: self + .imp + .get_table::("custom_world_draft_card"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CustomWorldDraftCardInsertCallbackId(__sdk::CallbackId); +pub struct CustomWorldDraftCardDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CustomWorldDraftCardTableHandle<'ctx> { + type Row = CustomWorldDraftCard; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CustomWorldDraftCardInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldDraftCardInsertCallbackId { + CustomWorldDraftCardInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CustomWorldDraftCardInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CustomWorldDraftCardDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldDraftCardDeleteCallbackId { + CustomWorldDraftCardDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CustomWorldDraftCardDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CustomWorldDraftCardUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CustomWorldDraftCardTableHandle<'ctx> { + type UpdateCallbackId = CustomWorldDraftCardUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CustomWorldDraftCardUpdateCallbackId { + CustomWorldDraftCardUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CustomWorldDraftCardUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `card_id` unique index on the table `custom_world_draft_card`, +/// which allows point queries on the field of the same name +/// via the [`CustomWorldDraftCardCardIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_draft_card().card_id().find(...)`. +pub struct CustomWorldDraftCardCardIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CustomWorldDraftCardTableHandle<'ctx> { + /// Get a handle on the `card_id` unique index on the table `custom_world_draft_card`. + pub fn card_id(&self) -> CustomWorldDraftCardCardIdUnique<'ctx> { + CustomWorldDraftCardCardIdUnique { + imp: self.imp.get_unique_constraint::("card_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CustomWorldDraftCardCardIdUnique<'ctx> { + /// Find the subscribed row whose `card_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("custom_world_draft_card"); + _table.add_unique_constraint::("card_id", |row| &row.card_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CustomWorldDraftCard`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait custom_world_draft_cardQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CustomWorldDraftCard`. + fn custom_world_draft_card(&self) -> __sdk::__query_builder::Table; +} + +impl custom_world_draft_cardQueryTableAccess for __sdk::QueryTableAccessor { + fn custom_world_draft_card(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("custom_world_draft_card") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_type.rs new file mode 100644 index 00000000..600c1f31 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_draft_card_type.rs @@ -0,0 +1,100 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_role_asset_status_type::CustomWorldRoleAssetStatus; +use super::rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind; +use super::rpg_agent_draft_card_status_type::RpgAgentDraftCardStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldDraftCard { + pub card_id: String, + pub session_id: String, + pub kind: RpgAgentDraftCardKind, + pub status: RpgAgentDraftCardStatus, + pub title: String, + pub subtitle: String, + pub summary: String, + pub linked_ids_json: String, + pub warning_count: u32, + pub asset_status: Option, + pub asset_status_label: Option, + pub detail_payload_json: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CustomWorldDraftCard { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CustomWorldDraftCard`. +/// +/// Provides typed access to columns for query building. +pub struct CustomWorldDraftCardCols { + pub card_id: __sdk::__query_builder::Col, + pub session_id: __sdk::__query_builder::Col, + pub kind: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub summary: __sdk::__query_builder::Col, + pub linked_ids_json: __sdk::__query_builder::Col, + pub warning_count: __sdk::__query_builder::Col, + pub asset_status: + __sdk::__query_builder::Col>, + pub asset_status_label: __sdk::__query_builder::Col>, + pub detail_payload_json: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CustomWorldDraftCard { + type Cols = CustomWorldDraftCardCols; + fn cols(table_name: &'static str) -> Self::Cols { + CustomWorldDraftCardCols { + card_id: __sdk::__query_builder::Col::new(table_name, "card_id"), + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + kind: __sdk::__query_builder::Col::new(table_name, "kind"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"), + summary: __sdk::__query_builder::Col::new(table_name, "summary"), + linked_ids_json: __sdk::__query_builder::Col::new(table_name, "linked_ids_json"), + warning_count: __sdk::__query_builder::Col::new(table_name, "warning_count"), + asset_status: __sdk::__query_builder::Col::new(table_name, "asset_status"), + asset_status_label: __sdk::__query_builder::Col::new(table_name, "asset_status_label"), + detail_payload_json: __sdk::__query_builder::Col::new( + table_name, + "detail_payload_json", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CustomWorldDraftCard`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CustomWorldDraftCardIxCols { + pub card_id: __sdk::__query_builder::IxCol, + pub kind: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CustomWorldDraftCard { + type IxCols = CustomWorldDraftCardIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CustomWorldDraftCardIxCols { + card_id: __sdk::__query_builder::IxCol::new(table_name, "card_id"), + kind: __sdk::__query_builder::IxCol::new(table_name, "kind"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CustomWorldDraftCard {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_detail_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_detail_input_type.rs new file mode 100644 index 00000000..64724446 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_detail_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldGalleryDetailInput { + pub owner_user_id: String, + pub profile_id: String, +} + +impl __sdk::InModule for CustomWorldGalleryDetailInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs new file mode 100644 index 00000000..c2a84ee3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_theme_mode_type::CustomWorldThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldGalleryEntrySnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub published_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldGalleryEntrySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_table.rs new file mode 100644 index 00000000..425b6a12 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::custom_world_gallery_entry_type::CustomWorldGalleryEntry; +use super::custom_world_theme_mode_type::CustomWorldThemeMode; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `custom_world_gallery_entry`. +/// +/// Obtain a handle from the [`CustomWorldGalleryEntryTableAccess::custom_world_gallery_entry`] method on [`super::RemoteTables`], +/// like `ctx.db.custom_world_gallery_entry()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_gallery_entry().on_insert(...)`. +pub struct CustomWorldGalleryEntryTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `custom_world_gallery_entry`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CustomWorldGalleryEntryTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CustomWorldGalleryEntryTableHandle`], which mediates access to the table `custom_world_gallery_entry`. + fn custom_world_gallery_entry(&self) -> CustomWorldGalleryEntryTableHandle<'_>; +} + +impl CustomWorldGalleryEntryTableAccess for super::RemoteTables { + fn custom_world_gallery_entry(&self) -> CustomWorldGalleryEntryTableHandle<'_> { + CustomWorldGalleryEntryTableHandle { + imp: self + .imp + .get_table::("custom_world_gallery_entry"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CustomWorldGalleryEntryInsertCallbackId(__sdk::CallbackId); +pub struct CustomWorldGalleryEntryDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CustomWorldGalleryEntryTableHandle<'ctx> { + type Row = CustomWorldGalleryEntry; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CustomWorldGalleryEntryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldGalleryEntryInsertCallbackId { + CustomWorldGalleryEntryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CustomWorldGalleryEntryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CustomWorldGalleryEntryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldGalleryEntryDeleteCallbackId { + CustomWorldGalleryEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CustomWorldGalleryEntryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CustomWorldGalleryEntryUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CustomWorldGalleryEntryTableHandle<'ctx> { + type UpdateCallbackId = CustomWorldGalleryEntryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CustomWorldGalleryEntryUpdateCallbackId { + CustomWorldGalleryEntryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CustomWorldGalleryEntryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `profile_id` unique index on the table `custom_world_gallery_entry`, +/// which allows point queries on the field of the same name +/// via the [`CustomWorldGalleryEntryProfileIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_gallery_entry().profile_id().find(...)`. +pub struct CustomWorldGalleryEntryProfileIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CustomWorldGalleryEntryTableHandle<'ctx> { + /// Get a handle on the `profile_id` unique index on the table `custom_world_gallery_entry`. + pub fn profile_id(&self) -> CustomWorldGalleryEntryProfileIdUnique<'ctx> { + CustomWorldGalleryEntryProfileIdUnique { + imp: self.imp.get_unique_constraint::("profile_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CustomWorldGalleryEntryProfileIdUnique<'ctx> { + /// Find the subscribed row whose `profile_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("custom_world_gallery_entry"); + _table.add_unique_constraint::("profile_id", |row| &row.profile_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CustomWorldGalleryEntry`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait custom_world_gallery_entryQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CustomWorldGalleryEntry`. + fn custom_world_gallery_entry(&self) -> __sdk::__query_builder::Table; +} + +impl custom_world_gallery_entryQueryTableAccess for __sdk::QueryTableAccessor { + fn custom_world_gallery_entry(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("custom_world_gallery_entry") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs new file mode 100644 index 00000000..03ad4584 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs @@ -0,0 +1,91 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_theme_mode_type::CustomWorldThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldGalleryEntry { + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub published_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CustomWorldGalleryEntry { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CustomWorldGalleryEntry`. +/// +/// Provides typed access to columns for query building. +pub struct CustomWorldGalleryEntryCols { + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub world_name: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col>, + pub theme_mode: __sdk::__query_builder::Col, + pub playable_npc_count: __sdk::__query_builder::Col, + pub landmark_count: __sdk::__query_builder::Col, + pub published_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry { + type Cols = CustomWorldGalleryEntryCols; + fn cols(table_name: &'static str) -> Self::Cols { + CustomWorldGalleryEntryCols { + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + world_name: __sdk::__query_builder::Col::new(table_name, "world_name"), + subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"), + summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + theme_mode: __sdk::__query_builder::Col::new(table_name, "theme_mode"), + playable_npc_count: __sdk::__query_builder::Col::new(table_name, "playable_npc_count"), + landmark_count: __sdk::__query_builder::Col::new(table_name, "landmark_count"), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CustomWorldGalleryEntry`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CustomWorldGalleryEntryIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub theme_mode: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CustomWorldGalleryEntry { + type IxCols = CustomWorldGalleryEntryIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CustomWorldGalleryEntryIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + theme_mode: __sdk::__query_builder::IxCol::new(table_name, "theme_mode"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CustomWorldGalleryEntry {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_list_result_type.rs new file mode 100644 index 00000000..bf101ef1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_list_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_gallery_entry_snapshot_type::CustomWorldGalleryEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldGalleryListResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for CustomWorldGalleryListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_generation_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_generation_mode_type.rs new file mode 100644 index 00000000..8b424a9a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_generation_mode_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum CustomWorldGenerationMode { + Fast, + + Full, +} + +impl __sdk::InModule for CustomWorldGenerationMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_detail_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_detail_input_type.rs new file mode 100644 index 00000000..472c1b80 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_detail_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldLibraryDetailInput { + pub owner_user_id: String, + pub profile_id: String, +} + +impl __sdk::InModule for CustomWorldLibraryDetailInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_mutation_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_mutation_result_type.rs new file mode 100644 index 00000000..3c3bb3b3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_library_mutation_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_gallery_entry_snapshot_type::CustomWorldGalleryEntrySnapshot; +use super::custom_world_profile_snapshot_type::CustomWorldProfileSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldLibraryMutationResult { + pub ok: bool, + pub entry: Option, + pub gallery_entry: Option, + pub error_message: Option, +} + +impl __sdk::InModule for CustomWorldLibraryMutationResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_input_type.rs new file mode 100644 index 00000000..53c46830 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileListInput { + pub owner_user_id: String, +} + +impl __sdk::InModule for CustomWorldProfileListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_result_type.rs new file mode 100644 index 00000000..10db7d0e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_list_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_profile_snapshot_type::CustomWorldProfileSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileListResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for CustomWorldProfileListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs new file mode 100644 index 00000000..16d06654 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfilePublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub published_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfilePublishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs new file mode 100644 index 00000000..d5d81918 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs @@ -0,0 +1,33 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_publication_status_type::CustomWorldPublicationStatus; +use super::custom_world_theme_mode_type::CustomWorldThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub source_agent_session_id: Option, + pub publication_status: CustomWorldPublicationStatus, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub published_at_micros: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfileSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_table.rs new file mode 100644 index 00000000..22692434 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::custom_world_profile_type::CustomWorldProfile; +use super::custom_world_publication_status_type::CustomWorldPublicationStatus; +use super::custom_world_theme_mode_type::CustomWorldThemeMode; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `custom_world_profile`. +/// +/// Obtain a handle from the [`CustomWorldProfileTableAccess::custom_world_profile`] method on [`super::RemoteTables`], +/// like `ctx.db.custom_world_profile()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_profile().on_insert(...)`. +pub struct CustomWorldProfileTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `custom_world_profile`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CustomWorldProfileTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CustomWorldProfileTableHandle`], which mediates access to the table `custom_world_profile`. + fn custom_world_profile(&self) -> CustomWorldProfileTableHandle<'_>; +} + +impl CustomWorldProfileTableAccess for super::RemoteTables { + fn custom_world_profile(&self) -> CustomWorldProfileTableHandle<'_> { + CustomWorldProfileTableHandle { + imp: self + .imp + .get_table::("custom_world_profile"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CustomWorldProfileInsertCallbackId(__sdk::CallbackId); +pub struct CustomWorldProfileDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CustomWorldProfileTableHandle<'ctx> { + type Row = CustomWorldProfile; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CustomWorldProfileInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldProfileInsertCallbackId { + CustomWorldProfileInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CustomWorldProfileInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CustomWorldProfileDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldProfileDeleteCallbackId { + CustomWorldProfileDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CustomWorldProfileDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CustomWorldProfileUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CustomWorldProfileTableHandle<'ctx> { + type UpdateCallbackId = CustomWorldProfileUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CustomWorldProfileUpdateCallbackId { + CustomWorldProfileUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CustomWorldProfileUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `profile_id` unique index on the table `custom_world_profile`, +/// which allows point queries on the field of the same name +/// via the [`CustomWorldProfileProfileIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_profile().profile_id().find(...)`. +pub struct CustomWorldProfileProfileIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CustomWorldProfileTableHandle<'ctx> { + /// Get a handle on the `profile_id` unique index on the table `custom_world_profile`. + pub fn profile_id(&self) -> CustomWorldProfileProfileIdUnique<'ctx> { + CustomWorldProfileProfileIdUnique { + imp: self.imp.get_unique_constraint::("profile_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CustomWorldProfileProfileIdUnique<'ctx> { + /// Find the subscribed row whose `profile_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("custom_world_profile"); + _table.add_unique_constraint::("profile_id", |row| &row.profile_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CustomWorldProfile`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait custom_world_profileQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CustomWorldProfile`. + fn custom_world_profile(&self) -> __sdk::__query_builder::Table; +} + +impl custom_world_profileQueryTableAccess for __sdk::QueryTableAccessor { + fn custom_world_profile(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("custom_world_profile") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs new file mode 100644 index 00000000..6f91923a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs @@ -0,0 +1,115 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_publication_status_type::CustomWorldPublicationStatus; +use super::custom_world_theme_mode_type::CustomWorldThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfile { + pub profile_id: String, + pub owner_user_id: String, + pub source_agent_session_id: Option, + pub publication_status: CustomWorldPublicationStatus, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub published_at: Option<__sdk::Timestamp>, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CustomWorldProfile { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CustomWorldProfile`. +/// +/// Provides typed access to columns for query building. +pub struct CustomWorldProfileCols { + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_agent_session_id: __sdk::__query_builder::Col>, + pub publication_status: + __sdk::__query_builder::Col, + pub world_name: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub theme_mode: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col>, + pub profile_payload_json: __sdk::__query_builder::Col, + pub playable_npc_count: __sdk::__query_builder::Col, + pub landmark_count: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub published_at: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CustomWorldProfile { + type Cols = CustomWorldProfileCols; + fn cols(table_name: &'static str) -> Self::Cols { + CustomWorldProfileCols { + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_agent_session_id: __sdk::__query_builder::Col::new( + table_name, + "source_agent_session_id", + ), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + world_name: __sdk::__query_builder::Col::new(table_name, "world_name"), + subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"), + summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"), + theme_mode: __sdk::__query_builder::Col::new(table_name, "theme_mode"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + profile_payload_json: __sdk::__query_builder::Col::new( + table_name, + "profile_payload_json", + ), + playable_npc_count: __sdk::__query_builder::Col::new(table_name, "playable_npc_count"), + landmark_count: __sdk::__query_builder::Col::new(table_name, "landmark_count"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CustomWorldProfile`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CustomWorldProfileIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub publication_status: + __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CustomWorldProfile { + type IxCols = CustomWorldProfileIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CustomWorldProfileIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + publication_status: __sdk::__query_builder::IxCol::new( + table_name, + "publication_status", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CustomWorldProfile {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_unpublish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_unpublish_input_type.rs new file mode 100644 index 00000000..7d688d96 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_unpublish_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileUnpublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfileUnpublishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs new file mode 100644 index 00000000..f9a00111 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_theme_mode_type::CustomWorldThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileUpsertInput { + pub profile_id: String, + pub owner_user_id: String, + pub source_agent_session_id: Option, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfileUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publication_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publication_status_type.rs new file mode 100644 index 00000000..f21186ff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publication_status_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum CustomWorldPublicationStatus { + Draft, + + Published, +} + +impl __sdk::InModule for CustomWorldPublicationStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs new file mode 100644 index 00000000..206dffb9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldPublishWorldInput { + pub session_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + pub setting_text: String, + pub author_display_name: String, + pub published_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldPublishWorldInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_result_type.rs new file mode 100644 index 00000000..a44ca2b1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_result_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_gallery_entry_snapshot_type::CustomWorldGalleryEntrySnapshot; +use super::custom_world_profile_snapshot_type::CustomWorldProfileSnapshot; +use super::custom_world_published_profile_compile_snapshot_type::CustomWorldPublishedProfileCompileSnapshot; +use super::rpg_agent_stage_type::RpgAgentStage; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldPublishWorldResult { + pub ok: bool, + pub compiled_record: Option, + pub entry: Option, + pub gallery_entry: Option, + pub session_stage: Option, + pub error_message: Option, +} + +impl __sdk::InModule for CustomWorldPublishWorldResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_input_type.rs new file mode 100644 index 00000000..fda3d08b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldPublishedProfileCompileInput { + pub session_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + pub setting_text: String, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldPublishedProfileCompileInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_result_type.rs new file mode 100644 index 00000000..4783f889 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_published_profile_compile_snapshot_type::CustomWorldPublishedProfileCompileSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldPublishedProfileCompileResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for CustomWorldPublishedProfileCompileResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_snapshot_type.rs new file mode 100644 index 00000000..0af4d59e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_published_profile_compile_snapshot_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_theme_mode_type::CustomWorldThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldPublishedProfileCompileSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: CustomWorldThemeMode, + pub cover_image_src: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub compiled_profile_payload_json: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldPublishedProfileCompileSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_role_asset_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_role_asset_status_type.rs new file mode 100644 index 00000000..1ab238e9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_role_asset_status_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum CustomWorldRoleAssetStatus { + Missing, + + VisualReady, + + AnimationsReady, + + Complete, +} + +impl __sdk::InModule for CustomWorldRoleAssetStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_status_type.rs new file mode 100644 index 00000000..9b640e4f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_status_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum CustomWorldSessionStatus { + Clarifying, + + ReadyToGenerate, + + Generating, + + Completed, + + GenerationError, +} + +impl __sdk::InModule for CustomWorldSessionStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_table.rs new file mode 100644 index 00000000..2813b6e2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::custom_world_generation_mode_type::CustomWorldGenerationMode; +use super::custom_world_session_status_type::CustomWorldSessionStatus; +use super::custom_world_session_type::CustomWorldSession; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `custom_world_session`. +/// +/// Obtain a handle from the [`CustomWorldSessionTableAccess::custom_world_session`] method on [`super::RemoteTables`], +/// like `ctx.db.custom_world_session()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_session().on_insert(...)`. +pub struct CustomWorldSessionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `custom_world_session`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CustomWorldSessionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CustomWorldSessionTableHandle`], which mediates access to the table `custom_world_session`. + fn custom_world_session(&self) -> CustomWorldSessionTableHandle<'_>; +} + +impl CustomWorldSessionTableAccess for super::RemoteTables { + fn custom_world_session(&self) -> CustomWorldSessionTableHandle<'_> { + CustomWorldSessionTableHandle { + imp: self + .imp + .get_table::("custom_world_session"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CustomWorldSessionInsertCallbackId(__sdk::CallbackId); +pub struct CustomWorldSessionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CustomWorldSessionTableHandle<'ctx> { + type Row = CustomWorldSession; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CustomWorldSessionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldSessionInsertCallbackId { + CustomWorldSessionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CustomWorldSessionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CustomWorldSessionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CustomWorldSessionDeleteCallbackId { + CustomWorldSessionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CustomWorldSessionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CustomWorldSessionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CustomWorldSessionTableHandle<'ctx> { + type UpdateCallbackId = CustomWorldSessionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CustomWorldSessionUpdateCallbackId { + CustomWorldSessionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CustomWorldSessionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `session_id` unique index on the table `custom_world_session`, +/// which allows point queries on the field of the same name +/// via the [`CustomWorldSessionSessionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.custom_world_session().session_id().find(...)`. +pub struct CustomWorldSessionSessionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CustomWorldSessionTableHandle<'ctx> { + /// Get a handle on the `session_id` unique index on the table `custom_world_session`. + pub fn session_id(&self) -> CustomWorldSessionSessionIdUnique<'ctx> { + CustomWorldSessionSessionIdUnique { + imp: self.imp.get_unique_constraint::("session_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CustomWorldSessionSessionIdUnique<'ctx> { + /// Find the subscribed row whose `session_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("custom_world_session"); + _table.add_unique_constraint::("session_id", |row| &row.session_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CustomWorldSession`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait custom_world_sessionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CustomWorldSession`. + fn custom_world_session(&self) -> __sdk::__query_builder::Table; +} + +impl custom_world_sessionQueryTableAccess for __sdk::QueryTableAccessor { + fn custom_world_session(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("custom_world_session") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_type.rs new file mode 100644 index 00000000..58e9f40c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_session_type.rs @@ -0,0 +1,93 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_generation_mode_type::CustomWorldGenerationMode; +use super::custom_world_session_status_type::CustomWorldSessionStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldSession { + pub session_id: String, + pub owner_user_id: String, + pub generation_mode: CustomWorldGenerationMode, + pub status: CustomWorldSessionStatus, + pub setting_text: String, + pub creator_intent_json: Option, + pub question_snapshot_json: String, + pub result_payload_json: Option, + pub last_error_message: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CustomWorldSession { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CustomWorldSession`. +/// +/// Provides typed access to columns for query building. +pub struct CustomWorldSessionCols { + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub generation_mode: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub setting_text: __sdk::__query_builder::Col, + pub creator_intent_json: __sdk::__query_builder::Col>, + pub question_snapshot_json: __sdk::__query_builder::Col, + pub result_payload_json: __sdk::__query_builder::Col>, + pub last_error_message: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CustomWorldSession { + type Cols = CustomWorldSessionCols; + fn cols(table_name: &'static str) -> Self::Cols { + CustomWorldSessionCols { + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + generation_mode: __sdk::__query_builder::Col::new(table_name, "generation_mode"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + setting_text: __sdk::__query_builder::Col::new(table_name, "setting_text"), + creator_intent_json: __sdk::__query_builder::Col::new( + table_name, + "creator_intent_json", + ), + question_snapshot_json: __sdk::__query_builder::Col::new( + table_name, + "question_snapshot_json", + ), + result_payload_json: __sdk::__query_builder::Col::new( + table_name, + "result_payload_json", + ), + last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CustomWorldSession`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CustomWorldSessionIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CustomWorldSession { + type IxCols = CustomWorldSessionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CustomWorldSessionIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CustomWorldSession {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_theme_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_theme_mode_type.rs new file mode 100644 index 00000000..2084ea04 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_theme_mode_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum CustomWorldThemeMode { + Martial, + + Arcane, + + Machina, + + Tide, + + Rift, + + Mythic, +} + +impl __sdk::InModule for CustomWorldThemeMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/equip_inventory_item_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/equip_inventory_item_input_type.rs new file mode 100644 index 00000000..775ad7fa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/equip_inventory_item_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EquipInventoryItemInput { + pub slot_id: String, +} + +impl __sdk::InModule for EquipInventoryItemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs new file mode 100644 index 00000000..46090a01 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_failure_input_type::AiTaskFailureInput; +use super::ai_task_procedure_result_type::AiTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FailAiTaskAndReturnArgs { + pub input: AiTaskFailureInput, +} + +impl __sdk::InModule for FailAiTaskAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `fail_ai_task_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait fail_ai_task_and_return { + fn fail_ai_task_and_return(&self, input: AiTaskFailureInput) { + self.fail_ai_task_and_return_then(input, |_, _| {}); + } + + fn fail_ai_task_and_return_then( + &self, + input: AiTaskFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl fail_ai_task_and_return for super::RemoteProcedures { + fn fail_ai_task_and_return_then( + &self, + input: AiTaskFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( + "fail_ai_task_and_return", + FailAiTaskAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs new file mode 100644 index 00000000..a737fbdf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_state_procedure_result_type::BattleStateProcedureResult; +use super::battle_state_query_input_type::BattleStateQueryInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetBattleStateArgs { + pub input: BattleStateQueryInput, +} + +impl __sdk::InModule for GetBattleStateArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_battle_state`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_battle_state { + fn get_battle_state(&self, input: BattleStateQueryInput) { + self.get_battle_state_then(input, |_, _| {}); + } + + fn get_battle_state_then( + &self, + input: BattleStateQueryInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_battle_state for super::RemoteProcedures { + fn get_battle_state_then( + &self, + input: BattleStateQueryInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BattleStateProcedureResult>( + "get_battle_state", + GetBattleStateArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs new file mode 100644 index 00000000..eef158dc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_progression_get_input_type::ChapterProgressionGetInput; +use super::chapter_progression_procedure_result_type::ChapterProgressionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetChapterProgressionArgs { + pub input: ChapterProgressionGetInput, +} + +impl __sdk::InModule for GetChapterProgressionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_chapter_progression`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_chapter_progression { + fn get_chapter_progression(&self, input: ChapterProgressionGetInput) { + self.get_chapter_progression_then(input, |_, _| {}); + } + + fn get_chapter_progression_then( + &self, + input: ChapterProgressionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_chapter_progression for super::RemoteProcedures { + fn get_chapter_progression_then( + &self, + input: ChapterProgressionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ChapterProgressionProcedureResult>( + "get_chapter_progression", + GetChapterProgressionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs new file mode 100644 index 00000000..1c4ffd6a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput; +use super::custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetCustomWorldAgentOperationArgs { + pub input: CustomWorldAgentOperationGetInput, +} + +impl __sdk::InModule for GetCustomWorldAgentOperationArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_custom_world_agent_operation`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_custom_world_agent_operation { + fn get_custom_world_agent_operation(&self, input: CustomWorldAgentOperationGetInput) { + self.get_custom_world_agent_operation_then(input, |_, _| {}); + } + + fn get_custom_world_agent_operation_then( + &self, + input: CustomWorldAgentOperationGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_custom_world_agent_operation for super::RemoteProcedures { + fn get_custom_world_agent_operation_then( + &self, + input: CustomWorldAgentOperationGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldAgentOperationProcedureResult>( + "get_custom_world_agent_operation", + GetCustomWorldAgentOperationArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs new file mode 100644 index 00000000..212987e4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_agent_session_get_input_type::CustomWorldAgentSessionGetInput; +use super::custom_world_agent_session_procedure_result_type::CustomWorldAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetCustomWorldAgentSessionArgs { + pub input: CustomWorldAgentSessionGetInput, +} + +impl __sdk::InModule for GetCustomWorldAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_custom_world_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_custom_world_agent_session { + fn get_custom_world_agent_session(&self, input: CustomWorldAgentSessionGetInput) { + self.get_custom_world_agent_session_then(input, |_, _| {}); + } + + fn get_custom_world_agent_session_then( + &self, + input: CustomWorldAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_custom_world_agent_session for super::RemoteProcedures { + fn get_custom_world_agent_session_then( + &self, + input: CustomWorldAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldAgentSessionProcedureResult>( + "get_custom_world_agent_session", + GetCustomWorldAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs new file mode 100644 index 00000000..f5127dcf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_gallery_detail_input_type::CustomWorldGalleryDetailInput; +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetCustomWorldGalleryDetailArgs { + pub input: CustomWorldGalleryDetailInput, +} + +impl __sdk::InModule for GetCustomWorldGalleryDetailArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_custom_world_gallery_detail`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_custom_world_gallery_detail { + fn get_custom_world_gallery_detail(&self, input: CustomWorldGalleryDetailInput) { + self.get_custom_world_gallery_detail_then(input, |_, _| {}); + } + + fn get_custom_world_gallery_detail_then( + &self, + input: CustomWorldGalleryDetailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_custom_world_gallery_detail for super::RemoteProcedures { + fn get_custom_world_gallery_detail_then( + &self, + input: CustomWorldGalleryDetailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "get_custom_world_gallery_detail", + GetCustomWorldGalleryDetailArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs new file mode 100644 index 00000000..ab99274a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_detail_input_type::CustomWorldLibraryDetailInput; +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetCustomWorldLibraryDetailArgs { + pub input: CustomWorldLibraryDetailInput, +} + +impl __sdk::InModule for GetCustomWorldLibraryDetailArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_custom_world_library_detail`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_custom_world_library_detail { + fn get_custom_world_library_detail(&self, input: CustomWorldLibraryDetailInput) { + self.get_custom_world_library_detail_then(input, |_, _| {}); + } + + fn get_custom_world_library_detail_then( + &self, + input: CustomWorldLibraryDetailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_custom_world_library_detail for super::RemoteProcedures { + fn get_custom_world_library_detail_then( + &self, + input: CustomWorldLibraryDetailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "get_custom_world_library_detail", + GetCustomWorldLibraryDetailArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs new file mode 100644 index 00000000..97c139fb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::player_progression_get_input_type::PlayerProgressionGetInput; +use super::player_progression_procedure_result_type::PlayerProgressionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetPlayerProgressionOrDefaultArgs { + pub input: PlayerProgressionGetInput, +} + +impl __sdk::InModule for GetPlayerProgressionOrDefaultArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_player_progression_or_default`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_player_progression_or_default { + fn get_player_progression_or_default(&self, input: PlayerProgressionGetInput) { + self.get_player_progression_or_default_then(input, |_, _| {}); + } + + fn get_player_progression_or_default_then( + &self, + input: PlayerProgressionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_player_progression_or_default for super::RemoteProcedures { + fn get_player_progression_or_default_then( + &self, + input: PlayerProgressionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PlayerProgressionProcedureResult>( + "get_player_progression_or_default", + GetPlayerProgressionOrDefaultArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs new file mode 100644 index 00000000..38200b75 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; +use super::runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileDashboardArgs { + pub input: RuntimeProfileDashboardGetInput, +} + +impl __sdk::InModule for GetProfileDashboardArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_dashboard`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_dashboard { + fn get_profile_dashboard(&self, input: RuntimeProfileDashboardGetInput) { + self.get_profile_dashboard_then(input, |_, _| {}); + } + + fn get_profile_dashboard_then( + &self, + input: RuntimeProfileDashboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_dashboard for super::RemoteProcedures { + fn get_profile_dashboard_then( + &self, + input: RuntimeProfileDashboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileDashboardProcedureResult>( + "get_profile_dashboard", + GetProfileDashboardArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs new file mode 100644 index 00000000..2ad47ed2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_play_stats_get_input_type::RuntimeProfilePlayStatsGetInput; +use super::runtime_profile_play_stats_procedure_result_type::RuntimeProfilePlayStatsProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfilePlayStatsArgs { + pub input: RuntimeProfilePlayStatsGetInput, +} + +impl __sdk::InModule for GetProfilePlayStatsArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_play_stats`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_play_stats { + fn get_profile_play_stats(&self, input: RuntimeProfilePlayStatsGetInput) { + self.get_profile_play_stats_then(input, |_, _| {}); + } + + fn get_profile_play_stats_then( + &self, + input: RuntimeProfilePlayStatsGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_play_stats for super::RemoteProcedures { + fn get_profile_play_stats_then( + &self, + input: RuntimeProfilePlayStatsGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfilePlayStatsProcedureResult>( + "get_profile_play_stats", + GetProfilePlayStatsArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs new file mode 100644 index 00000000..abbf4f20 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_inventory_state_procedure_result_type::RuntimeInventoryStateProcedureResult; +use super::runtime_inventory_state_query_input_type::RuntimeInventoryStateQueryInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetRuntimeInventoryStateArgs { + pub input: RuntimeInventoryStateQueryInput, +} + +impl __sdk::InModule for GetRuntimeInventoryStateArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_runtime_inventory_state`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_runtime_inventory_state { + fn get_runtime_inventory_state(&self, input: RuntimeInventoryStateQueryInput) { + self.get_runtime_inventory_state_then(input, |_, _| {}); + } + + fn get_runtime_inventory_state_then( + &self, + input: RuntimeInventoryStateQueryInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_runtime_inventory_state for super::RemoteProcedures { + fn get_runtime_inventory_state_then( + &self, + input: RuntimeInventoryStateQueryInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeInventoryStateProcedureResult>( + "get_runtime_inventory_state", + GetRuntimeInventoryStateArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs new file mode 100644 index 00000000..261caed1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_setting_get_input_type::RuntimeSettingGetInput; +use super::runtime_setting_procedure_result_type::RuntimeSettingProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetRuntimeSettingOrDefaultArgs { + pub input: RuntimeSettingGetInput, +} + +impl __sdk::InModule for GetRuntimeSettingOrDefaultArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_runtime_setting_or_default`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_runtime_setting_or_default { + fn get_runtime_setting_or_default(&self, input: RuntimeSettingGetInput) { + self.get_runtime_setting_or_default_then(input, |_, _| {}); + } + + fn get_runtime_setting_or_default_then( + &self, + input: RuntimeSettingGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_runtime_setting_or_default for super::RemoteProcedures { + fn get_runtime_setting_or_default_then( + &self, + input: RuntimeSettingGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeSettingProcedureResult>( + "get_runtime_setting_or_default", + GetRuntimeSettingOrDefaultArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs new file mode 100644 index 00000000..7e566dc9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_session_state_input_type::StorySessionStateInput; +use super::story_session_state_procedure_result_type::StorySessionStateProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetStorySessionStateArgs { + pub input: StorySessionStateInput, +} + +impl __sdk::InModule for GetStorySessionStateArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_story_session_state`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_story_session_state { + fn get_story_session_state(&self, input: StorySessionStateInput) { + self.get_story_session_state_then(input, |_, _| {}); + } + + fn get_story_session_state_then( + &self, + input: StorySessionStateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_story_session_state for super::RemoteProcedures { + fn get_story_session_state_then( + &self, + input: StorySessionStateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, StorySessionStateProcedureResult>( + "get_story_session_state", + GetStorySessionStateArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/grant_inventory_item_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/grant_inventory_item_input_type.rs new file mode 100644 index 00000000..553aff65 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/grant_inventory_item_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::inventory_item_snapshot_type::InventoryItemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GrantInventoryItemInput { + pub slot_id: String, + pub item: InventoryItemSnapshot, +} + +impl __sdk::InModule for GrantInventoryItemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs new file mode 100644 index 00000000..4c67da63 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::player_progression_grant_input_type::PlayerProgressionGrantInput; +use super::player_progression_procedure_result_type::PlayerProgressionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GrantPlayerProgressionExperienceAndReturnArgs { + pub input: PlayerProgressionGrantInput, +} + +impl __sdk::InModule for GrantPlayerProgressionExperienceAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `grant_player_progression_experience_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait grant_player_progression_experience_and_return { + fn grant_player_progression_experience_and_return(&self, input: PlayerProgressionGrantInput) { + self.grant_player_progression_experience_and_return_then(input, |_, _| {}); + } + + fn grant_player_progression_experience_and_return_then( + &self, + input: PlayerProgressionGrantInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl grant_player_progression_experience_and_return for super::RemoteProcedures { + fn grant_player_progression_experience_and_return_then( + &self, + input: PlayerProgressionGrantInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PlayerProgressionProcedureResult>( + "grant_player_progression_experience_and_return", + GrantPlayerProgressionExperienceAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_reducer.rs new file mode 100644 index 00000000..83b48cf7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_reducer.rs @@ -0,0 +1,71 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::player_progression_grant_input_type::PlayerProgressionGrantInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct GrantPlayerProgressionExperienceArgs { + pub input: PlayerProgressionGrantInput, +} + +impl From for super::Reducer { + fn from(args: GrantPlayerProgressionExperienceArgs) -> Self { + Self::GrantPlayerProgressionExperience { input: args.input } + } +} + +impl __sdk::InModule for GrantPlayerProgressionExperienceArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `grant_player_progression_experience`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait grant_player_progression_experience { + /// Request that the remote module invoke the reducer `grant_player_progression_experience` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`grant_player_progression_experience:grant_player_progression_experience_then`] to run a callback after the reducer completes. + fn grant_player_progression_experience( + &self, + input: PlayerProgressionGrantInput, + ) -> __sdk::Result<()> { + self.grant_player_progression_experience_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `grant_player_progression_experience` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn grant_player_progression_experience_then( + &self, + input: PlayerProgressionGrantInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl grant_player_progression_experience for super::RemoteReducers { + fn grant_player_progression_experience_then( + &self, + input: PlayerProgressionGrantInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(GrantPlayerProgressionExperienceArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_container_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_container_kind_type.rs new file mode 100644 index 00000000..45522ce7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_container_kind_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum InventoryContainerKind { + Backpack, + + Equipment, +} + +impl __sdk::InModule for InventoryContainerKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_equipment_slot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_equipment_slot_type.rs new file mode 100644 index 00000000..e2055f6a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_equipment_slot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum InventoryEquipmentSlot { + Weapon, + + Armor, + + Relic, +} + +impl __sdk::InModule for InventoryEquipmentSlot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_rarity_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_rarity_type.rs new file mode 100644 index 00000000..85b46090 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_rarity_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum InventoryItemRarity { + Common, + + Uncommon, + + Rare, + + Epic, + + Legendary, +} + +impl __sdk::InModule for InventoryItemRarity { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_snapshot_type.rs new file mode 100644 index 00000000..3769735e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_snapshot_type.rs @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::inventory_equipment_slot_type::InventoryEquipmentSlot; +use super::inventory_item_rarity_type::InventoryItemRarity; +use super::inventory_item_source_kind_type::InventoryItemSourceKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct InventoryItemSnapshot { + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: InventoryItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: InventoryItemSourceKind, + pub source_reference_id: Option, +} + +impl __sdk::InModule for InventoryItemSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_source_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_source_kind_type.rs new file mode 100644 index 00000000..1d3961bc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_item_source_kind_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum InventoryItemSourceKind { + StoryReward, + + QuestReward, + + TreasureReward, + + NpcGift, + + NpcTrade, + + CombatDrop, + + ForgeCraft, + + ForgeReforge, + + ManualPatch, +} + +impl __sdk::InModule for InventoryItemSourceKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_input_type.rs new file mode 100644 index 00000000..3540ad3b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::inventory_mutation_type::InventoryMutation; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct InventoryMutationInput { + pub mutation_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub mutation: InventoryMutation, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for InventoryMutationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_type.rs new file mode 100644 index 00000000..3d0d0e4f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_mutation_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::consume_inventory_item_input_type::ConsumeInventoryItemInput; +use super::equip_inventory_item_input_type::EquipInventoryItemInput; +use super::grant_inventory_item_input_type::GrantInventoryItemInput; +use super::unequip_inventory_item_input_type::UnequipInventoryItemInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub enum InventoryMutation { + GrantItem(GrantInventoryItemInput), + + ConsumeItem(ConsumeInventoryItemInput), + + EquipItem(EquipInventoryItemInput), + + UnequipItem(UnequipInventoryItemInput), +} + +impl __sdk::InModule for InventoryMutation { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_snapshot_type.rs new file mode 100644 index 00000000..f20e5451 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_snapshot_type.rs @@ -0,0 +1,39 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::inventory_container_kind_type::InventoryContainerKind; +use super::inventory_equipment_slot_type::InventoryEquipmentSlot; +use super::inventory_item_rarity_type::InventoryItemRarity; +use super::inventory_item_source_kind_type::InventoryItemSourceKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct InventorySlotSnapshot { + pub slot_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub container_kind: InventoryContainerKind, + pub slot_key: String, + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: InventoryItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: InventoryItemSourceKind, + pub source_reference_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for InventorySlotSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_table.rs new file mode 100644 index 00000000..277df68a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::inventory_container_kind_type::InventoryContainerKind; +use super::inventory_equipment_slot_type::InventoryEquipmentSlot; +use super::inventory_item_rarity_type::InventoryItemRarity; +use super::inventory_item_source_kind_type::InventoryItemSourceKind; +use super::inventory_slot_type::InventorySlot; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `inventory_slot`. +/// +/// Obtain a handle from the [`InventorySlotTableAccess::inventory_slot`] method on [`super::RemoteTables`], +/// like `ctx.db.inventory_slot()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.inventory_slot().on_insert(...)`. +pub struct InventorySlotTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `inventory_slot`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait InventorySlotTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`InventorySlotTableHandle`], which mediates access to the table `inventory_slot`. + fn inventory_slot(&self) -> InventorySlotTableHandle<'_>; +} + +impl InventorySlotTableAccess for super::RemoteTables { + fn inventory_slot(&self) -> InventorySlotTableHandle<'_> { + InventorySlotTableHandle { + imp: self.imp.get_table::("inventory_slot"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct InventorySlotInsertCallbackId(__sdk::CallbackId); +pub struct InventorySlotDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for InventorySlotTableHandle<'ctx> { + type Row = InventorySlot; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = InventorySlotInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> InventorySlotInsertCallbackId { + InventorySlotInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: InventorySlotInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = InventorySlotDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> InventorySlotDeleteCallbackId { + InventorySlotDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: InventorySlotDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct InventorySlotUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for InventorySlotTableHandle<'ctx> { + type UpdateCallbackId = InventorySlotUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> InventorySlotUpdateCallbackId { + InventorySlotUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: InventorySlotUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `slot_id` unique index on the table `inventory_slot`, +/// which allows point queries on the field of the same name +/// via the [`InventorySlotSlotIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.inventory_slot().slot_id().find(...)`. +pub struct InventorySlotSlotIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> InventorySlotTableHandle<'ctx> { + /// Get a handle on the `slot_id` unique index on the table `inventory_slot`. + pub fn slot_id(&self) -> InventorySlotSlotIdUnique<'ctx> { + InventorySlotSlotIdUnique { + imp: self.imp.get_unique_constraint::("slot_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> InventorySlotSlotIdUnique<'ctx> { + /// Find the subscribed row whose `slot_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("inventory_slot"); + _table.add_unique_constraint::("slot_id", |row| &row.slot_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `InventorySlot`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait inventory_slotQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `InventorySlot`. + fn inventory_slot(&self) -> __sdk::__query_builder::Table; +} + +impl inventory_slotQueryTableAccess for __sdk::QueryTableAccessor { + fn inventory_slot(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("inventory_slot") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_type.rs new file mode 100644 index 00000000..fcfbab6a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/inventory_slot_type.rs @@ -0,0 +1,124 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::inventory_container_kind_type::InventoryContainerKind; +use super::inventory_equipment_slot_type::InventoryEquipmentSlot; +use super::inventory_item_rarity_type::InventoryItemRarity; +use super::inventory_item_source_kind_type::InventoryItemSourceKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct InventorySlot { + pub slot_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub container_kind: InventoryContainerKind, + pub slot_key: String, + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: InventoryItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, + pub source_kind: InventoryItemSourceKind, + pub source_reference_id: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for InventorySlot { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `InventorySlot`. +/// +/// Provides typed access to columns for query building. +pub struct InventorySlotCols { + pub slot_id: __sdk::__query_builder::Col, + pub runtime_session_id: __sdk::__query_builder::Col, + pub story_session_id: __sdk::__query_builder::Col>, + pub actor_user_id: __sdk::__query_builder::Col, + pub container_kind: __sdk::__query_builder::Col, + pub slot_key: __sdk::__query_builder::Col, + pub item_id: __sdk::__query_builder::Col, + pub category: __sdk::__query_builder::Col, + pub name: __sdk::__query_builder::Col, + pub description: __sdk::__query_builder::Col>, + pub quantity: __sdk::__query_builder::Col, + pub rarity: __sdk::__query_builder::Col, + pub tags: __sdk::__query_builder::Col>, + pub stackable: __sdk::__query_builder::Col, + pub stack_key: __sdk::__query_builder::Col, + pub equipment_slot_id: + __sdk::__query_builder::Col>, + pub source_kind: __sdk::__query_builder::Col, + pub source_reference_id: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for InventorySlot { + type Cols = InventorySlotCols; + fn cols(table_name: &'static str) -> Self::Cols { + InventorySlotCols { + slot_id: __sdk::__query_builder::Col::new(table_name, "slot_id"), + runtime_session_id: __sdk::__query_builder::Col::new(table_name, "runtime_session_id"), + story_session_id: __sdk::__query_builder::Col::new(table_name, "story_session_id"), + actor_user_id: __sdk::__query_builder::Col::new(table_name, "actor_user_id"), + container_kind: __sdk::__query_builder::Col::new(table_name, "container_kind"), + slot_key: __sdk::__query_builder::Col::new(table_name, "slot_key"), + item_id: __sdk::__query_builder::Col::new(table_name, "item_id"), + category: __sdk::__query_builder::Col::new(table_name, "category"), + name: __sdk::__query_builder::Col::new(table_name, "name"), + description: __sdk::__query_builder::Col::new(table_name, "description"), + quantity: __sdk::__query_builder::Col::new(table_name, "quantity"), + rarity: __sdk::__query_builder::Col::new(table_name, "rarity"), + tags: __sdk::__query_builder::Col::new(table_name, "tags"), + stackable: __sdk::__query_builder::Col::new(table_name, "stackable"), + stack_key: __sdk::__query_builder::Col::new(table_name, "stack_key"), + equipment_slot_id: __sdk::__query_builder::Col::new(table_name, "equipment_slot_id"), + source_kind: __sdk::__query_builder::Col::new(table_name, "source_kind"), + source_reference_id: __sdk::__query_builder::Col::new( + table_name, + "source_reference_id", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `InventorySlot`. +/// +/// Provides typed access to indexed columns for query building. +pub struct InventorySlotIxCols { + pub actor_user_id: __sdk::__query_builder::IxCol, + pub item_id: __sdk::__query_builder::IxCol, + pub runtime_session_id: __sdk::__query_builder::IxCol, + pub slot_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for InventorySlot { + type IxCols = InventorySlotIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + InventorySlotIxCols { + actor_user_id: __sdk::__query_builder::IxCol::new(table_name, "actor_user_id"), + item_id: __sdk::__query_builder::IxCol::new(table_name, "item_id"), + runtime_session_id: __sdk::__query_builder::IxCol::new( + table_name, + "runtime_session_id", + ), + slot_id: __sdk::__query_builder::IxCol::new(table_name, "slot_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for InventorySlot {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs new file mode 100644 index 00000000..63ee059f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs @@ -0,0 +1,54 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_gallery_list_result_type::CustomWorldGalleryListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListCustomWorldGalleryEntriesArgs {} + +impl __sdk::InModule for ListCustomWorldGalleryEntriesArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_custom_world_gallery_entries`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_custom_world_gallery_entries { + fn list_custom_world_gallery_entries(&self) { + self.list_custom_world_gallery_entries_then(|_, _| {}); + } + + fn list_custom_world_gallery_entries_then( + &self, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_custom_world_gallery_entries for super::RemoteProcedures { + fn list_custom_world_gallery_entries_then( + &self, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldGalleryListResult>( + "list_custom_world_gallery_entries", + ListCustomWorldGalleryEntriesArgs {}, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs new file mode 100644 index 00000000..f8834945 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_profile_list_input_type::CustomWorldProfileListInput; +use super::custom_world_profile_list_result_type::CustomWorldProfileListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListCustomWorldProfilesArgs { + pub input: CustomWorldProfileListInput, +} + +impl __sdk::InModule for ListCustomWorldProfilesArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_custom_world_profiles`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_custom_world_profiles { + fn list_custom_world_profiles(&self, input: CustomWorldProfileListInput) { + self.list_custom_world_profiles_then(input, |_, _| {}); + } + + fn list_custom_world_profiles_then( + &self, + input: CustomWorldProfileListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_custom_world_profiles for super::RemoteProcedures { + fn list_custom_world_profiles_then( + &self, + input: CustomWorldProfileListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldProfileListResult>( + "list_custom_world_profiles", + ListCustomWorldProfilesArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs new file mode 100644 index 00000000..0d368a99 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_browse_history_list_input_type::RuntimeBrowseHistoryListInput; +use super::runtime_browse_history_procedure_result_type::RuntimeBrowseHistoryProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPlatformBrowseHistoryArgs { + pub input: RuntimeBrowseHistoryListInput, +} + +impl __sdk::InModule for ListPlatformBrowseHistoryArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_platform_browse_history`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_platform_browse_history { + fn list_platform_browse_history(&self, input: RuntimeBrowseHistoryListInput) { + self.list_platform_browse_history_then(input, |_, _| {}); + } + + fn list_platform_browse_history_then( + &self, + input: RuntimeBrowseHistoryListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_platform_browse_history for super::RemoteProcedures { + fn list_platform_browse_history_then( + &self, + input: RuntimeBrowseHistoryListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeBrowseHistoryProcedureResult>( + "list_platform_browse_history", + ListPlatformBrowseHistoryArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs new file mode 100644 index 00000000..23496701 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput; +use super::runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListProfileWalletLedgerArgs { + pub input: RuntimeProfileWalletLedgerListInput, +} + +impl __sdk::InModule for ListProfileWalletLedgerArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_profile_wallet_ledger`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_profile_wallet_ledger { + fn list_profile_wallet_ledger(&self, input: RuntimeProfileWalletLedgerListInput) { + self.list_profile_wallet_ledger_then(input, |_, _| {}); + } + + fn list_profile_wallet_ledger_then( + &self, + input: RuntimeProfileWalletLedgerListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_profile_wallet_ledger for super::RemoteProcedures { + fn list_profile_wallet_ledger_then( + &self, + input: RuntimeProfileWalletLedgerListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletLedgerProcedureResult>( + "list_profile_wallet_ledger", + ListProfileWalletLedgerArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 961c26d0..b718ee1e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -6,33 +6,623 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +pub mod accept_quest_reducer; +pub mod acknowledge_quest_completion_reducer; +pub mod ai_result_reference_input_type; +pub mod ai_result_reference_kind_type; +pub mod ai_result_reference_snapshot_type; +pub mod ai_result_reference_table; +pub mod ai_result_reference_type; +pub mod ai_stage_completion_input_type; +pub mod ai_task_cancel_input_type; +pub mod ai_task_create_input_type; +pub mod ai_task_failure_input_type; +pub mod ai_task_finish_input_type; +pub mod ai_task_kind_type; +pub mod ai_task_procedure_result_type; +pub mod ai_task_snapshot_type; +pub mod ai_task_stage_blueprint_type; +pub mod ai_task_stage_kind_type; +pub mod ai_task_stage_snapshot_type; +pub mod ai_task_stage_start_input_type; +pub mod ai_task_stage_status_type; +pub mod ai_task_stage_table; +pub mod ai_task_stage_type; +pub mod ai_task_start_input_type; +pub mod ai_task_status_type; +pub mod ai_task_table; +pub mod ai_task_type; +pub mod ai_text_chunk_append_input_type; +pub mod ai_text_chunk_snapshot_type; +pub mod ai_text_chunk_table; +pub mod ai_text_chunk_type; +pub mod append_ai_text_chunk_and_return_procedure; +pub mod apply_chapter_progression_ledger_entry_and_return_procedure; +pub mod apply_chapter_progression_ledger_entry_reducer; +pub mod apply_inventory_mutation_reducer; +pub mod apply_quest_signal_reducer; pub mod asset_entity_binding_input_type; pub mod asset_entity_binding_procedure_result_type; pub mod asset_entity_binding_snapshot_type; +pub mod asset_entity_binding_table; pub mod asset_entity_binding_type; pub mod asset_object_access_policy_type; pub mod asset_object_procedure_result_type; +pub mod asset_object_table; pub mod asset_object_type; pub mod asset_object_upsert_input_type; pub mod asset_object_upsert_snapshot_type; +pub mod attach_ai_result_reference_and_return_procedure; +pub mod battle_mode_type; +pub mod battle_state_input_type; +pub mod battle_state_procedure_result_type; +pub mod battle_state_query_input_type; +pub mod battle_state_snapshot_type; +pub mod battle_state_table; +pub mod battle_state_type; +pub mod battle_status_type; +pub mod begin_story_session_and_return_procedure; +pub mod begin_story_session_reducer; pub mod bind_asset_object_to_entity_and_return_procedure; pub mod bind_asset_object_to_entity_reducer; +pub mod cancel_ai_task_and_return_procedure; +pub mod chapter_pace_band_type; +pub mod chapter_progression_get_input_type; +pub mod chapter_progression_input_type; +pub mod chapter_progression_ledger_input_type; +pub mod chapter_progression_procedure_result_type; +pub mod chapter_progression_snapshot_type; +pub mod chapter_progression_table; +pub mod chapter_progression_type; +pub mod clear_platform_browse_history_and_return_procedure; +pub mod combat_outcome_type; +pub mod compile_custom_world_published_profile_procedure; +pub mod complete_ai_stage_and_return_procedure; +pub mod complete_ai_task_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; pub mod confirm_asset_object_reducer; +pub mod consume_inventory_item_input_type; +pub mod continue_story_and_return_procedure; +pub mod continue_story_reducer; +pub mod create_ai_task_and_return_procedure; +pub mod create_ai_task_reducer; +pub mod create_battle_state_and_return_procedure; +pub mod create_battle_state_reducer; +pub mod create_custom_world_agent_session_procedure; +pub mod custom_world_agent_message_snapshot_type; +pub mod custom_world_agent_message_submit_input_type; +pub mod custom_world_agent_message_table; +pub mod custom_world_agent_message_type; +pub mod custom_world_agent_operation_get_input_type; +pub mod custom_world_agent_operation_procedure_result_type; +pub mod custom_world_agent_operation_snapshot_type; +pub mod custom_world_agent_operation_table; +pub mod custom_world_agent_operation_type; +pub mod custom_world_agent_session_create_input_type; +pub mod custom_world_agent_session_get_input_type; +pub mod custom_world_agent_session_procedure_result_type; +pub mod custom_world_agent_session_snapshot_type; +pub mod custom_world_agent_session_table; +pub mod custom_world_agent_session_type; +pub mod custom_world_draft_card_snapshot_type; +pub mod custom_world_draft_card_table; +pub mod custom_world_draft_card_type; +pub mod custom_world_gallery_detail_input_type; +pub mod custom_world_gallery_entry_snapshot_type; +pub mod custom_world_gallery_entry_table; +pub mod custom_world_gallery_entry_type; +pub mod custom_world_gallery_list_result_type; +pub mod custom_world_generation_mode_type; +pub mod custom_world_library_detail_input_type; +pub mod custom_world_library_mutation_result_type; +pub mod custom_world_profile_list_input_type; +pub mod custom_world_profile_list_result_type; +pub mod custom_world_profile_publish_input_type; +pub mod custom_world_profile_snapshot_type; +pub mod custom_world_profile_table; +pub mod custom_world_profile_type; +pub mod custom_world_profile_unpublish_input_type; +pub mod custom_world_profile_upsert_input_type; +pub mod custom_world_publication_status_type; +pub mod custom_world_publish_world_input_type; +pub mod custom_world_publish_world_result_type; +pub mod custom_world_published_profile_compile_input_type; +pub mod custom_world_published_profile_compile_result_type; +pub mod custom_world_published_profile_compile_snapshot_type; +pub mod custom_world_role_asset_status_type; +pub mod custom_world_session_status_type; +pub mod custom_world_session_table; +pub mod custom_world_session_type; +pub mod custom_world_theme_mode_type; +pub mod equip_inventory_item_input_type; +pub mod fail_ai_task_and_return_procedure; +pub mod get_battle_state_procedure; +pub mod get_chapter_progression_procedure; +pub mod get_custom_world_agent_operation_procedure; +pub mod get_custom_world_agent_session_procedure; +pub mod get_custom_world_gallery_detail_procedure; +pub mod get_custom_world_library_detail_procedure; +pub mod get_player_progression_or_default_procedure; +pub mod get_profile_dashboard_procedure; +pub mod get_profile_play_stats_procedure; +pub mod get_runtime_inventory_state_procedure; +pub mod get_runtime_setting_or_default_procedure; +pub mod get_story_session_state_procedure; +pub mod grant_inventory_item_input_type; +pub mod grant_player_progression_experience_and_return_procedure; +pub mod grant_player_progression_experience_reducer; +pub mod inventory_container_kind_type; +pub mod inventory_equipment_slot_type; +pub mod inventory_item_rarity_type; +pub mod inventory_item_snapshot_type; +pub mod inventory_item_source_kind_type; +pub mod inventory_mutation_input_type; +pub mod inventory_mutation_type; +pub mod inventory_slot_snapshot_type; +pub mod inventory_slot_table; +pub mod inventory_slot_type; +pub mod list_custom_world_gallery_entries_procedure; +pub mod list_custom_world_profiles_procedure; +pub mod list_platform_browse_history_procedure; +pub mod list_profile_wallet_ledger_procedure; +pub mod npc_battle_interaction_procedure_result_type; +pub mod npc_battle_interaction_result_type; +pub mod npc_interaction_battle_mode_type; +pub mod npc_interaction_procedure_result_type; +pub mod npc_interaction_result_type; +pub mod npc_interaction_status_type; +pub mod npc_relation_stance_type; +pub mod npc_relation_state_type; +pub mod npc_social_action_kind_type; +pub mod npc_stance_profile_type; +pub mod npc_state_procedure_result_type; +pub mod npc_state_snapshot_type; +pub mod npc_state_table; +pub mod npc_state_type; +pub mod npc_state_upsert_input_type; +pub mod player_progression_get_input_type; +pub mod player_progression_grant_input_type; +pub mod player_progression_grant_source_type; +pub mod player_progression_procedure_result_type; +pub mod player_progression_snapshot_type; +pub mod player_progression_table; +pub mod player_progression_type; +pub mod profile_dashboard_state_table; +pub mod profile_dashboard_state_type; +pub mod profile_played_world_table; +pub mod profile_played_world_type; +pub mod profile_wallet_ledger_table; +pub mod profile_wallet_ledger_type; +pub mod publish_custom_world_profile_and_return_procedure; +pub mod publish_custom_world_profile_reducer; +pub mod publish_custom_world_world_procedure; +pub mod quest_completion_ack_input_type; +pub mod quest_hostile_npc_defeated_signal_type; +pub mod quest_item_delivered_signal_type; +pub mod quest_log_event_kind_type; +pub mod quest_log_table; +pub mod quest_log_type; +pub mod quest_narrative_binding_snapshot_type; +pub mod quest_narrative_origin_type; +pub mod quest_narrative_type_type; +pub mod quest_npc_spar_completed_signal_type; +pub mod quest_npc_talk_completed_signal_type; +pub mod quest_objective_kind_type; +pub mod quest_objective_snapshot_type; +pub mod quest_progress_signal_type; +pub mod quest_record_input_type; +pub mod quest_record_table; +pub mod quest_record_type; +pub mod quest_reward_equipment_slot_type; +pub mod quest_reward_intel_type; +pub mod quest_reward_item_rarity_type; +pub mod quest_reward_item_type; +pub mod quest_reward_snapshot_type; +pub mod quest_scene_reached_signal_type; +pub mod quest_signal_apply_input_type; +pub mod quest_signal_kind_type; +pub mod quest_status_type; +pub mod quest_step_snapshot_type; +pub mod quest_treasure_inspected_signal_type; +pub mod quest_turn_in_input_type; +pub mod resolve_combat_action_and_return_procedure; +pub mod resolve_combat_action_input_type; +pub mod resolve_combat_action_procedure_result_type; +pub mod resolve_combat_action_reducer; +pub mod resolve_combat_action_result_type; +pub mod resolve_npc_battle_interaction_and_return_procedure; +pub mod resolve_npc_battle_interaction_input_type; +pub mod resolve_npc_interaction_and_return_procedure; +pub mod resolve_npc_interaction_input_type; +pub mod resolve_npc_interaction_reducer; +pub mod resolve_npc_social_action_and_return_procedure; +pub mod resolve_npc_social_action_input_type; +pub mod resolve_npc_social_action_reducer; +pub mod resolve_treasure_interaction_and_return_procedure; +pub mod resolve_treasure_interaction_reducer; +pub mod rpg_agent_draft_card_kind_type; +pub mod rpg_agent_draft_card_status_type; +pub mod rpg_agent_message_kind_type; +pub mod rpg_agent_message_role_type; +pub mod rpg_agent_operation_status_type; +pub mod rpg_agent_operation_type_type; +pub mod rpg_agent_stage_type; +pub mod runtime_browse_history_clear_input_type; +pub mod runtime_browse_history_list_input_type; +pub mod runtime_browse_history_procedure_result_type; +pub mod runtime_browse_history_snapshot_type; +pub mod runtime_browse_history_sync_input_type; +pub mod runtime_browse_history_theme_mode_type; +pub mod runtime_browse_history_write_input_type; +pub mod runtime_inventory_state_procedure_result_type; +pub mod runtime_inventory_state_query_input_type; +pub mod runtime_inventory_state_snapshot_type; +pub mod runtime_item_equipment_slot_type; +pub mod runtime_item_reward_item_rarity_type; +pub mod runtime_item_reward_item_snapshot_type; +pub mod runtime_platform_theme_type; +pub mod runtime_profile_dashboard_get_input_type; +pub mod runtime_profile_dashboard_procedure_result_type; +pub mod runtime_profile_dashboard_snapshot_type; +pub mod runtime_profile_play_stats_get_input_type; +pub mod runtime_profile_play_stats_procedure_result_type; +pub mod runtime_profile_play_stats_snapshot_type; +pub mod runtime_profile_played_world_snapshot_type; +pub mod runtime_profile_wallet_ledger_entry_snapshot_type; +pub mod runtime_profile_wallet_ledger_list_input_type; +pub mod runtime_profile_wallet_ledger_procedure_result_type; +pub mod runtime_profile_wallet_ledger_source_type_type; +pub mod runtime_setting_get_input_type; +pub mod runtime_setting_procedure_result_type; +pub mod runtime_setting_snapshot_type; +pub mod runtime_setting_table; +pub mod runtime_setting_type; +pub mod runtime_setting_upsert_input_type; +pub mod start_ai_task_reducer; +pub mod start_ai_task_stage_reducer; +pub mod story_continue_input_type; +pub mod story_event_kind_type; +pub mod story_event_snapshot_type; +pub mod story_event_table; +pub mod story_event_type; +pub mod story_session_input_type; +pub mod story_session_procedure_result_type; +pub mod story_session_snapshot_type; +pub mod story_session_state_input_type; +pub mod story_session_state_procedure_result_type; +pub mod story_session_status_type; +pub mod story_session_table; +pub mod story_session_type; +pub mod submit_custom_world_agent_message_procedure; +pub mod treasure_interaction_action_type; +pub mod treasure_record_procedure_result_type; +pub mod treasure_record_snapshot_type; +pub mod treasure_record_table; +pub mod treasure_record_type; +pub mod treasure_resolve_input_type; +pub mod turn_in_quest_reducer; +pub mod unequip_inventory_item_input_type; +pub mod unpublish_custom_world_profile_and_return_procedure; +pub mod unpublish_custom_world_profile_reducer; +pub mod upsert_chapter_progression_and_return_procedure; +pub mod upsert_chapter_progression_reducer; +pub mod upsert_custom_world_profile_and_return_procedure; +pub mod upsert_custom_world_profile_reducer; +pub mod upsert_npc_state_and_return_procedure; +pub mod upsert_npc_state_reducer; +pub mod upsert_platform_browse_history_and_return_procedure; +pub mod upsert_runtime_setting_and_return_procedure; +pub mod user_browse_history_table; +pub mod user_browse_history_type; +pub use accept_quest_reducer::accept_quest; +pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; +pub use ai_result_reference_input_type::AiResultReferenceInput; +pub use ai_result_reference_kind_type::AiResultReferenceKind; +pub use ai_result_reference_snapshot_type::AiResultReferenceSnapshot; +pub use ai_result_reference_table::*; +pub use ai_result_reference_type::AiResultReference; +pub use ai_stage_completion_input_type::AiStageCompletionInput; +pub use ai_task_cancel_input_type::AiTaskCancelInput; +pub use ai_task_create_input_type::AiTaskCreateInput; +pub use ai_task_failure_input_type::AiTaskFailureInput; +pub use ai_task_finish_input_type::AiTaskFinishInput; +pub use ai_task_kind_type::AiTaskKind; +pub use ai_task_procedure_result_type::AiTaskProcedureResult; +pub use ai_task_snapshot_type::AiTaskSnapshot; +pub use ai_task_stage_blueprint_type::AiTaskStageBlueprint; +pub use ai_task_stage_kind_type::AiTaskStageKind; +pub use ai_task_stage_snapshot_type::AiTaskStageSnapshot; +pub use ai_task_stage_start_input_type::AiTaskStageStartInput; +pub use ai_task_stage_status_type::AiTaskStageStatus; +pub use ai_task_stage_table::*; +pub use ai_task_stage_type::AiTaskStage; +pub use ai_task_start_input_type::AiTaskStartInput; +pub use ai_task_status_type::AiTaskStatus; +pub use ai_task_table::*; +pub use ai_task_type::AiTask; +pub use ai_text_chunk_append_input_type::AiTextChunkAppendInput; +pub use ai_text_chunk_snapshot_type::AiTextChunkSnapshot; +pub use ai_text_chunk_table::*; +pub use ai_text_chunk_type::AiTextChunk; +pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return; +pub use apply_chapter_progression_ledger_entry_and_return_procedure::apply_chapter_progression_ledger_entry_and_return; +pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry; +pub use apply_inventory_mutation_reducer::apply_inventory_mutation; +pub use apply_quest_signal_reducer::apply_quest_signal; pub use asset_entity_binding_input_type::AssetEntityBindingInput; pub use asset_entity_binding_procedure_result_type::AssetEntityBindingProcedureResult; pub use asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot; +pub use asset_entity_binding_table::*; pub use asset_entity_binding_type::AssetEntityBinding; pub use asset_object_access_policy_type::AssetObjectAccessPolicy; pub use asset_object_procedure_result_type::AssetObjectProcedureResult; +pub use asset_object_table::*; pub use asset_object_type::AssetObject; pub use asset_object_upsert_input_type::AssetObjectUpsertInput; pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; +pub use attach_ai_result_reference_and_return_procedure::attach_ai_result_reference_and_return; +pub use battle_mode_type::BattleMode; +pub use battle_state_input_type::BattleStateInput; +pub use battle_state_procedure_result_type::BattleStateProcedureResult; +pub use battle_state_query_input_type::BattleStateQueryInput; +pub use battle_state_snapshot_type::BattleStateSnapshot; +pub use battle_state_table::*; +pub use battle_state_type::BattleState; +pub use battle_status_type::BattleStatus; +pub use begin_story_session_and_return_procedure::begin_story_session_and_return; +pub use begin_story_session_reducer::begin_story_session; pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return; pub use bind_asset_object_to_entity_reducer::bind_asset_object_to_entity; +pub use cancel_ai_task_and_return_procedure::cancel_ai_task_and_return; +pub use chapter_pace_band_type::ChapterPaceBand; +pub use chapter_progression_get_input_type::ChapterProgressionGetInput; +pub use chapter_progression_input_type::ChapterProgressionInput; +pub use chapter_progression_ledger_input_type::ChapterProgressionLedgerInput; +pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureResult; +pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot; +pub use chapter_progression_table::*; +pub use chapter_progression_type::ChapterProgression; +pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return; +pub use combat_outcome_type::CombatOutcome; +pub use compile_custom_world_published_profile_procedure::compile_custom_world_published_profile; +pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return; +pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; pub use confirm_asset_object_reducer::confirm_asset_object; +pub use consume_inventory_item_input_type::ConsumeInventoryItemInput; +pub use continue_story_and_return_procedure::continue_story_and_return; +pub use continue_story_reducer::continue_story; +pub use create_ai_task_and_return_procedure::create_ai_task_and_return; +pub use create_ai_task_reducer::create_ai_task; +pub use create_battle_state_and_return_procedure::create_battle_state_and_return; +pub use create_battle_state_reducer::create_battle_state; +pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session; +pub use custom_world_agent_message_snapshot_type::CustomWorldAgentMessageSnapshot; +pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSubmitInput; +pub use custom_world_agent_message_table::*; +pub use custom_world_agent_message_type::CustomWorldAgentMessage; +pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput; +pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; +pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; +pub use custom_world_agent_operation_table::*; +pub use custom_world_agent_operation_type::CustomWorldAgentOperation; +pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput; +pub use custom_world_agent_session_get_input_type::CustomWorldAgentSessionGetInput; +pub use custom_world_agent_session_procedure_result_type::CustomWorldAgentSessionProcedureResult; +pub use custom_world_agent_session_snapshot_type::CustomWorldAgentSessionSnapshot; +pub use custom_world_agent_session_table::*; +pub use custom_world_agent_session_type::CustomWorldAgentSession; +pub use custom_world_draft_card_snapshot_type::CustomWorldDraftCardSnapshot; +pub use custom_world_draft_card_table::*; +pub use custom_world_draft_card_type::CustomWorldDraftCard; +pub use custom_world_gallery_detail_input_type::CustomWorldGalleryDetailInput; +pub use custom_world_gallery_entry_snapshot_type::CustomWorldGalleryEntrySnapshot; +pub use custom_world_gallery_entry_table::*; +pub use custom_world_gallery_entry_type::CustomWorldGalleryEntry; +pub use custom_world_gallery_list_result_type::CustomWorldGalleryListResult; +pub use custom_world_generation_mode_type::CustomWorldGenerationMode; +pub use custom_world_library_detail_input_type::CustomWorldLibraryDetailInput; +pub use custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +pub use custom_world_profile_list_input_type::CustomWorldProfileListInput; +pub use custom_world_profile_list_result_type::CustomWorldProfileListResult; +pub use custom_world_profile_publish_input_type::CustomWorldProfilePublishInput; +pub use custom_world_profile_snapshot_type::CustomWorldProfileSnapshot; +pub use custom_world_profile_table::*; +pub use custom_world_profile_type::CustomWorldProfile; +pub use custom_world_profile_unpublish_input_type::CustomWorldProfileUnpublishInput; +pub use custom_world_profile_upsert_input_type::CustomWorldProfileUpsertInput; +pub use custom_world_publication_status_type::CustomWorldPublicationStatus; +pub use custom_world_publish_world_input_type::CustomWorldPublishWorldInput; +pub use custom_world_publish_world_result_type::CustomWorldPublishWorldResult; +pub use custom_world_published_profile_compile_input_type::CustomWorldPublishedProfileCompileInput; +pub use custom_world_published_profile_compile_result_type::CustomWorldPublishedProfileCompileResult; +pub use custom_world_published_profile_compile_snapshot_type::CustomWorldPublishedProfileCompileSnapshot; +pub use custom_world_role_asset_status_type::CustomWorldRoleAssetStatus; +pub use custom_world_session_status_type::CustomWorldSessionStatus; +pub use custom_world_session_table::*; +pub use custom_world_session_type::CustomWorldSession; +pub use custom_world_theme_mode_type::CustomWorldThemeMode; +pub use equip_inventory_item_input_type::EquipInventoryItemInput; +pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; +pub use get_battle_state_procedure::get_battle_state; +pub use get_chapter_progression_procedure::get_chapter_progression; +pub use get_custom_world_agent_operation_procedure::get_custom_world_agent_operation; +pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session; +pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; +pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; +pub use get_player_progression_or_default_procedure::get_player_progression_or_default; +pub use get_profile_dashboard_procedure::get_profile_dashboard; +pub use get_profile_play_stats_procedure::get_profile_play_stats; +pub use get_runtime_inventory_state_procedure::get_runtime_inventory_state; +pub use get_runtime_setting_or_default_procedure::get_runtime_setting_or_default; +pub use get_story_session_state_procedure::get_story_session_state; +pub use grant_inventory_item_input_type::GrantInventoryItemInput; +pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; +pub use grant_player_progression_experience_reducer::grant_player_progression_experience; +pub use inventory_container_kind_type::InventoryContainerKind; +pub use inventory_equipment_slot_type::InventoryEquipmentSlot; +pub use inventory_item_rarity_type::InventoryItemRarity; +pub use inventory_item_snapshot_type::InventoryItemSnapshot; +pub use inventory_item_source_kind_type::InventoryItemSourceKind; +pub use inventory_mutation_input_type::InventoryMutationInput; +pub use inventory_mutation_type::InventoryMutation; +pub use inventory_slot_snapshot_type::InventorySlotSnapshot; +pub use inventory_slot_table::*; +pub use inventory_slot_type::InventorySlot; +pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries; +pub use list_custom_world_profiles_procedure::list_custom_world_profiles; +pub use list_platform_browse_history_procedure::list_platform_browse_history; +pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; +pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; +pub use npc_battle_interaction_result_type::NpcBattleInteractionResult; +pub use npc_interaction_battle_mode_type::NpcInteractionBattleMode; +pub use npc_interaction_procedure_result_type::NpcInteractionProcedureResult; +pub use npc_interaction_result_type::NpcInteractionResult; +pub use npc_interaction_status_type::NpcInteractionStatus; +pub use npc_relation_stance_type::NpcRelationStance; +pub use npc_relation_state_type::NpcRelationState; +pub use npc_social_action_kind_type::NpcSocialActionKind; +pub use npc_stance_profile_type::NpcStanceProfile; +pub use npc_state_procedure_result_type::NpcStateProcedureResult; +pub use npc_state_snapshot_type::NpcStateSnapshot; +pub use npc_state_table::*; +pub use npc_state_type::NpcState; +pub use npc_state_upsert_input_type::NpcStateUpsertInput; +pub use player_progression_get_input_type::PlayerProgressionGetInput; +pub use player_progression_grant_input_type::PlayerProgressionGrantInput; +pub use player_progression_grant_source_type::PlayerProgressionGrantSource; +pub use player_progression_procedure_result_type::PlayerProgressionProcedureResult; +pub use player_progression_snapshot_type::PlayerProgressionSnapshot; +pub use player_progression_table::*; +pub use player_progression_type::PlayerProgression; +pub use profile_dashboard_state_table::*; +pub use profile_dashboard_state_type::ProfileDashboardState; +pub use profile_played_world_table::*; +pub use profile_played_world_type::ProfilePlayedWorld; +pub use profile_wallet_ledger_table::*; +pub use profile_wallet_ledger_type::ProfileWalletLedger; +pub use publish_custom_world_profile_and_return_procedure::publish_custom_world_profile_and_return; +pub use publish_custom_world_profile_reducer::publish_custom_world_profile; +pub use publish_custom_world_world_procedure::publish_custom_world_world; +pub use quest_completion_ack_input_type::QuestCompletionAckInput; +pub use quest_hostile_npc_defeated_signal_type::QuestHostileNpcDefeatedSignal; +pub use quest_item_delivered_signal_type::QuestItemDeliveredSignal; +pub use quest_log_event_kind_type::QuestLogEventKind; +pub use quest_log_table::*; +pub use quest_log_type::QuestLog; +pub use quest_narrative_binding_snapshot_type::QuestNarrativeBindingSnapshot; +pub use quest_narrative_origin_type::QuestNarrativeOrigin; +pub use quest_narrative_type_type::QuestNarrativeType; +pub use quest_npc_spar_completed_signal_type::QuestNpcSparCompletedSignal; +pub use quest_npc_talk_completed_signal_type::QuestNpcTalkCompletedSignal; +pub use quest_objective_kind_type::QuestObjectiveKind; +pub use quest_objective_snapshot_type::QuestObjectiveSnapshot; +pub use quest_progress_signal_type::QuestProgressSignal; +pub use quest_record_input_type::QuestRecordInput; +pub use quest_record_table::*; +pub use quest_record_type::QuestRecord; +pub use quest_reward_equipment_slot_type::QuestRewardEquipmentSlot; +pub use quest_reward_intel_type::QuestRewardIntel; +pub use quest_reward_item_rarity_type::QuestRewardItemRarity; +pub use quest_reward_item_type::QuestRewardItem; +pub use quest_reward_snapshot_type::QuestRewardSnapshot; +pub use quest_scene_reached_signal_type::QuestSceneReachedSignal; +pub use quest_signal_apply_input_type::QuestSignalApplyInput; +pub use quest_signal_kind_type::QuestSignalKind; +pub use quest_status_type::QuestStatus; +pub use quest_step_snapshot_type::QuestStepSnapshot; +pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; +pub use quest_turn_in_input_type::QuestTurnInInput; +pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; +pub use resolve_combat_action_input_type::ResolveCombatActionInput; +pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; +pub use resolve_combat_action_reducer::resolve_combat_action; +pub use resolve_combat_action_result_type::ResolveCombatActionResult; +pub use resolve_npc_battle_interaction_and_return_procedure::resolve_npc_battle_interaction_and_return; +pub use resolve_npc_battle_interaction_input_type::ResolveNpcBattleInteractionInput; +pub use resolve_npc_interaction_and_return_procedure::resolve_npc_interaction_and_return; +pub use resolve_npc_interaction_input_type::ResolveNpcInteractionInput; +pub use resolve_npc_interaction_reducer::resolve_npc_interaction; +pub use resolve_npc_social_action_and_return_procedure::resolve_npc_social_action_and_return; +pub use resolve_npc_social_action_input_type::ResolveNpcSocialActionInput; +pub use resolve_npc_social_action_reducer::resolve_npc_social_action; +pub use resolve_treasure_interaction_and_return_procedure::resolve_treasure_interaction_and_return; +pub use resolve_treasure_interaction_reducer::resolve_treasure_interaction; +pub use rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind; +pub use rpg_agent_draft_card_status_type::RpgAgentDraftCardStatus; +pub use rpg_agent_message_kind_type::RpgAgentMessageKind; +pub use rpg_agent_message_role_type::RpgAgentMessageRole; +pub use rpg_agent_operation_status_type::RpgAgentOperationStatus; +pub use rpg_agent_operation_type_type::RpgAgentOperationType; +pub use rpg_agent_stage_type::RpgAgentStage; +pub use runtime_browse_history_clear_input_type::RuntimeBrowseHistoryClearInput; +pub use runtime_browse_history_list_input_type::RuntimeBrowseHistoryListInput; +pub use runtime_browse_history_procedure_result_type::RuntimeBrowseHistoryProcedureResult; +pub use runtime_browse_history_snapshot_type::RuntimeBrowseHistorySnapshot; +pub use runtime_browse_history_sync_input_type::RuntimeBrowseHistorySyncInput; +pub use runtime_browse_history_theme_mode_type::RuntimeBrowseHistoryThemeMode; +pub use runtime_browse_history_write_input_type::RuntimeBrowseHistoryWriteInput; +pub use runtime_inventory_state_procedure_result_type::RuntimeInventoryStateProcedureResult; +pub use runtime_inventory_state_query_input_type::RuntimeInventoryStateQueryInput; +pub use runtime_inventory_state_snapshot_type::RuntimeInventoryStateSnapshot; +pub use runtime_item_equipment_slot_type::RuntimeItemEquipmentSlot; +pub use runtime_item_reward_item_rarity_type::RuntimeItemRewardItemRarity; +pub use runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; +pub use runtime_platform_theme_type::RuntimePlatformTheme; +pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; +pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; +pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; +pub use runtime_profile_play_stats_get_input_type::RuntimeProfilePlayStatsGetInput; +pub use runtime_profile_play_stats_procedure_result_type::RuntimeProfilePlayStatsProcedureResult; +pub use runtime_profile_play_stats_snapshot_type::RuntimeProfilePlayStatsSnapshot; +pub use runtime_profile_played_world_snapshot_type::RuntimeProfilePlayedWorldSnapshot; +pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot; +pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput; +pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult; +pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; +pub use runtime_setting_get_input_type::RuntimeSettingGetInput; +pub use runtime_setting_procedure_result_type::RuntimeSettingProcedureResult; +pub use runtime_setting_snapshot_type::RuntimeSettingSnapshot; +pub use runtime_setting_table::*; +pub use runtime_setting_type::RuntimeSetting; +pub use runtime_setting_upsert_input_type::RuntimeSettingUpsertInput; +pub use start_ai_task_reducer::start_ai_task; +pub use start_ai_task_stage_reducer::start_ai_task_stage; +pub use story_continue_input_type::StoryContinueInput; +pub use story_event_kind_type::StoryEventKind; +pub use story_event_snapshot_type::StoryEventSnapshot; +pub use story_event_table::*; +pub use story_event_type::StoryEvent; +pub use story_session_input_type::StorySessionInput; +pub use story_session_procedure_result_type::StorySessionProcedureResult; +pub use story_session_snapshot_type::StorySessionSnapshot; +pub use story_session_state_input_type::StorySessionStateInput; +pub use story_session_state_procedure_result_type::StorySessionStateProcedureResult; +pub use story_session_status_type::StorySessionStatus; +pub use story_session_table::*; +pub use story_session_type::StorySession; +pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message; +pub use treasure_interaction_action_type::TreasureInteractionAction; +pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult; +pub use treasure_record_snapshot_type::TreasureRecordSnapshot; +pub use treasure_record_table::*; +pub use treasure_record_type::TreasureRecord; +pub use treasure_resolve_input_type::TreasureResolveInput; +pub use turn_in_quest_reducer::turn_in_quest; +pub use unequip_inventory_item_input_type::UnequipInventoryItemInput; +pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return; +pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; +pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; +pub use upsert_chapter_progression_reducer::upsert_chapter_progression; +pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return; +pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile; +pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return; +pub use upsert_npc_state_reducer::upsert_npc_state; +pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return; +pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return; +pub use user_browse_history_table::*; +pub use user_browse_history_type::UserBrowseHistory; #[derive(Clone, PartialEq, Debug)] @@ -42,8 +632,78 @@ pub use confirm_asset_object_reducer::confirm_asset_object; /// to indicate which reducer caused the event. pub enum Reducer { - BindAssetObjectToEntity { input: AssetEntityBindingInput }, - ConfirmAssetObject { input: AssetObjectUpsertInput }, + AcceptQuest { + input: QuestRecordInput, + }, + AcknowledgeQuestCompletion { + input: QuestCompletionAckInput, + }, + ApplyChapterProgressionLedgerEntry { + input: ChapterProgressionLedgerInput, + }, + ApplyInventoryMutation { + input: InventoryMutationInput, + }, + ApplyQuestSignal { + input: QuestSignalApplyInput, + }, + BeginStorySession { + input: StorySessionInput, + }, + BindAssetObjectToEntity { + input: AssetEntityBindingInput, + }, + ConfirmAssetObject { + input: AssetObjectUpsertInput, + }, + ContinueStory { + input: StoryContinueInput, + }, + CreateAiTask { + input: AiTaskCreateInput, + }, + CreateBattleState { + input: BattleStateInput, + }, + GrantPlayerProgressionExperience { + input: PlayerProgressionGrantInput, + }, + PublishCustomWorldProfile { + input: CustomWorldProfilePublishInput, + }, + ResolveCombatAction { + input: ResolveCombatActionInput, + }, + ResolveNpcInteraction { + input: ResolveNpcInteractionInput, + }, + ResolveNpcSocialAction { + input: ResolveNpcSocialActionInput, + }, + ResolveTreasureInteraction { + input: TreasureResolveInput, + }, + StartAiTask { + input: AiTaskStartInput, + }, + StartAiTaskStage { + input: AiTaskStageStartInput, + }, + TurnInQuest { + input: QuestTurnInInput, + }, + UnpublishCustomWorldProfile { + input: CustomWorldProfileUnpublishInput, + }, + UpsertChapterProgression { + input: ChapterProgressionInput, + }, + UpsertCustomWorldProfile { + input: CustomWorldProfileUpsertInput, + }, + UpsertNpcState { + input: NpcStateUpsertInput, + }, } impl __sdk::InModule for Reducer { @@ -53,33 +713,198 @@ impl __sdk::InModule for Reducer { impl __sdk::Reducer for Reducer { fn reducer_name(&self) -> &'static str { match self { + Reducer::AcceptQuest { .. } => "accept_quest", + Reducer::AcknowledgeQuestCompletion { .. } => "acknowledge_quest_completion", + Reducer::ApplyChapterProgressionLedgerEntry { .. } => { + "apply_chapter_progression_ledger_entry" + } + Reducer::ApplyInventoryMutation { .. } => "apply_inventory_mutation", + Reducer::ApplyQuestSignal { .. } => "apply_quest_signal", + Reducer::BeginStorySession { .. } => "begin_story_session", Reducer::BindAssetObjectToEntity { .. } => "bind_asset_object_to_entity", Reducer::ConfirmAssetObject { .. } => "confirm_asset_object", + Reducer::ContinueStory { .. } => "continue_story", + Reducer::CreateAiTask { .. } => "create_ai_task", + Reducer::CreateBattleState { .. } => "create_battle_state", + Reducer::GrantPlayerProgressionExperience { .. } => { + "grant_player_progression_experience" + } + Reducer::PublishCustomWorldProfile { .. } => "publish_custom_world_profile", + Reducer::ResolveCombatAction { .. } => "resolve_combat_action", + Reducer::ResolveNpcInteraction { .. } => "resolve_npc_interaction", + Reducer::ResolveNpcSocialAction { .. } => "resolve_npc_social_action", + Reducer::ResolveTreasureInteraction { .. } => "resolve_treasure_interaction", + Reducer::StartAiTask { .. } => "start_ai_task", + Reducer::StartAiTaskStage { .. } => "start_ai_task_stage", + Reducer::TurnInQuest { .. } => "turn_in_quest", + Reducer::UnpublishCustomWorldProfile { .. } => "unpublish_custom_world_profile", + Reducer::UpsertChapterProgression { .. } => "upsert_chapter_progression", + Reducer::UpsertCustomWorldProfile { .. } => "upsert_custom_world_profile", + Reducer::UpsertNpcState { .. } => "upsert_npc_state", _ => unreachable!(), } } #[allow(clippy::clone_on_copy)] fn args_bsatn(&self) -> Result, __sats::bsatn::EncodeError> { match self { - Reducer::BindAssetObjectToEntity { input } => __sats::bsatn::to_vec( - &bind_asset_object_to_entity_reducer::BindAssetObjectToEntityArgs { - input: input.clone(), - }, - ), - Reducer::ConfirmAssetObject { input } => { - __sats::bsatn::to_vec(&confirm_asset_object_reducer::ConfirmAssetObjectArgs { - input: input.clone(), - }) - } + Reducer::AcceptQuest{ + input, +} => __sats::bsatn::to_vec(&accept_quest_reducer::AcceptQuestArgs { + input: input.clone(), +}), + Reducer::AcknowledgeQuestCompletion{ + input, +} => __sats::bsatn::to_vec(&acknowledge_quest_completion_reducer::AcknowledgeQuestCompletionArgs { + input: input.clone(), +}), + Reducer::ApplyChapterProgressionLedgerEntry{ + input, +} => __sats::bsatn::to_vec(&apply_chapter_progression_ledger_entry_reducer::ApplyChapterProgressionLedgerEntryArgs { + input: input.clone(), +}), + Reducer::ApplyInventoryMutation{ + input, +} => __sats::bsatn::to_vec(&apply_inventory_mutation_reducer::ApplyInventoryMutationArgs { + input: input.clone(), +}), + Reducer::ApplyQuestSignal{ + input, +} => __sats::bsatn::to_vec(&apply_quest_signal_reducer::ApplyQuestSignalArgs { + input: input.clone(), +}), + Reducer::BeginStorySession{ + input, +} => __sats::bsatn::to_vec(&begin_story_session_reducer::BeginStorySessionArgs { + input: input.clone(), +}), + Reducer::BindAssetObjectToEntity{ + input, +} => __sats::bsatn::to_vec(&bind_asset_object_to_entity_reducer::BindAssetObjectToEntityArgs { + input: input.clone(), +}), + Reducer::ConfirmAssetObject{ + input, +} => __sats::bsatn::to_vec(&confirm_asset_object_reducer::ConfirmAssetObjectArgs { + input: input.clone(), +}), + Reducer::ContinueStory{ + input, +} => __sats::bsatn::to_vec(&continue_story_reducer::ContinueStoryArgs { + input: input.clone(), +}), + Reducer::CreateAiTask{ + input, +} => __sats::bsatn::to_vec(&create_ai_task_reducer::CreateAiTaskArgs { + input: input.clone(), +}), + Reducer::CreateBattleState{ + input, +} => __sats::bsatn::to_vec(&create_battle_state_reducer::CreateBattleStateArgs { + input: input.clone(), +}), + Reducer::GrantPlayerProgressionExperience{ + input, +} => __sats::bsatn::to_vec(&grant_player_progression_experience_reducer::GrantPlayerProgressionExperienceArgs { + input: input.clone(), +}), + Reducer::PublishCustomWorldProfile{ + input, +} => __sats::bsatn::to_vec(&publish_custom_world_profile_reducer::PublishCustomWorldProfileArgs { + input: input.clone(), +}), + Reducer::ResolveCombatAction{ + input, +} => __sats::bsatn::to_vec(&resolve_combat_action_reducer::ResolveCombatActionArgs { + input: input.clone(), +}), + Reducer::ResolveNpcInteraction{ + input, +} => __sats::bsatn::to_vec(&resolve_npc_interaction_reducer::ResolveNpcInteractionArgs { + input: input.clone(), +}), + Reducer::ResolveNpcSocialAction{ + input, +} => __sats::bsatn::to_vec(&resolve_npc_social_action_reducer::ResolveNpcSocialActionArgs { + input: input.clone(), +}), + Reducer::ResolveTreasureInteraction{ + input, +} => __sats::bsatn::to_vec(&resolve_treasure_interaction_reducer::ResolveTreasureInteractionArgs { + input: input.clone(), +}), + Reducer::StartAiTask{ + input, +} => __sats::bsatn::to_vec(&start_ai_task_reducer::StartAiTaskArgs { + input: input.clone(), +}), + Reducer::StartAiTaskStage{ + input, +} => __sats::bsatn::to_vec(&start_ai_task_stage_reducer::StartAiTaskStageArgs { + input: input.clone(), +}), + Reducer::TurnInQuest{ + input, +} => __sats::bsatn::to_vec(&turn_in_quest_reducer::TurnInQuestArgs { + input: input.clone(), +}), + Reducer::UnpublishCustomWorldProfile{ + input, +} => __sats::bsatn::to_vec(&unpublish_custom_world_profile_reducer::UnpublishCustomWorldProfileArgs { + input: input.clone(), +}), + Reducer::UpsertChapterProgression{ + input, +} => __sats::bsatn::to_vec(&upsert_chapter_progression_reducer::UpsertChapterProgressionArgs { + input: input.clone(), +}), + Reducer::UpsertCustomWorldProfile{ + input, +} => __sats::bsatn::to_vec(&upsert_custom_world_profile_reducer::UpsertCustomWorldProfileArgs { + input: input.clone(), +}), + Reducer::UpsertNpcState{ + input, +} => __sats::bsatn::to_vec(&upsert_npc_state_reducer::UpsertNpcStateArgs { + input: input.clone(), +}), _ => unreachable!(), - } +} } } #[derive(Default, Debug)] #[allow(non_snake_case)] #[doc(hidden)] -pub struct DbUpdate {} +pub struct DbUpdate { + ai_result_reference: __sdk::TableUpdate, + ai_task: __sdk::TableUpdate, + ai_task_stage: __sdk::TableUpdate, + ai_text_chunk: __sdk::TableUpdate, + asset_entity_binding: __sdk::TableUpdate, + asset_object: __sdk::TableUpdate, + battle_state: __sdk::TableUpdate, + chapter_progression: __sdk::TableUpdate, + custom_world_agent_message: __sdk::TableUpdate, + custom_world_agent_operation: __sdk::TableUpdate, + custom_world_agent_session: __sdk::TableUpdate, + custom_world_draft_card: __sdk::TableUpdate, + custom_world_gallery_entry: __sdk::TableUpdate, + custom_world_profile: __sdk::TableUpdate, + custom_world_session: __sdk::TableUpdate, + inventory_slot: __sdk::TableUpdate, + npc_state: __sdk::TableUpdate, + player_progression: __sdk::TableUpdate, + profile_dashboard_state: __sdk::TableUpdate, + profile_played_world: __sdk::TableUpdate, + profile_wallet_ledger: __sdk::TableUpdate, + quest_log: __sdk::TableUpdate, + quest_record: __sdk::TableUpdate, + runtime_setting: __sdk::TableUpdate, + story_event: __sdk::TableUpdate, + story_session: __sdk::TableUpdate, + treasure_record: __sdk::TableUpdate, + user_browse_history: __sdk::TableUpdate, +} impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { type Error = __sdk::Error; @@ -87,6 +912,91 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { let mut db_update = DbUpdate::default(); for table_update in __sdk::transaction_update_iter_table_updates(raw) { match &table_update.table_name[..] { + "ai_result_reference" => db_update + .ai_result_reference + .append(ai_result_reference_table::parse_table_update(table_update)?), + "ai_task" => db_update + .ai_task + .append(ai_task_table::parse_table_update(table_update)?), + "ai_task_stage" => db_update + .ai_task_stage + .append(ai_task_stage_table::parse_table_update(table_update)?), + "ai_text_chunk" => db_update + .ai_text_chunk + .append(ai_text_chunk_table::parse_table_update(table_update)?), + "asset_entity_binding" => db_update.asset_entity_binding.append( + asset_entity_binding_table::parse_table_update(table_update)?, + ), + "asset_object" => db_update + .asset_object + .append(asset_object_table::parse_table_update(table_update)?), + "battle_state" => db_update + .battle_state + .append(battle_state_table::parse_table_update(table_update)?), + "chapter_progression" => db_update + .chapter_progression + .append(chapter_progression_table::parse_table_update(table_update)?), + "custom_world_agent_message" => db_update.custom_world_agent_message.append( + custom_world_agent_message_table::parse_table_update(table_update)?, + ), + "custom_world_agent_operation" => db_update.custom_world_agent_operation.append( + custom_world_agent_operation_table::parse_table_update(table_update)?, + ), + "custom_world_agent_session" => db_update.custom_world_agent_session.append( + custom_world_agent_session_table::parse_table_update(table_update)?, + ), + "custom_world_draft_card" => db_update.custom_world_draft_card.append( + custom_world_draft_card_table::parse_table_update(table_update)?, + ), + "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append( + custom_world_gallery_entry_table::parse_table_update(table_update)?, + ), + "custom_world_profile" => db_update.custom_world_profile.append( + custom_world_profile_table::parse_table_update(table_update)?, + ), + "custom_world_session" => db_update.custom_world_session.append( + custom_world_session_table::parse_table_update(table_update)?, + ), + "inventory_slot" => db_update + .inventory_slot + .append(inventory_slot_table::parse_table_update(table_update)?), + "npc_state" => db_update + .npc_state + .append(npc_state_table::parse_table_update(table_update)?), + "player_progression" => db_update + .player_progression + .append(player_progression_table::parse_table_update(table_update)?), + "profile_dashboard_state" => db_update.profile_dashboard_state.append( + profile_dashboard_state_table::parse_table_update(table_update)?, + ), + "profile_played_world" => db_update.profile_played_world.append( + profile_played_world_table::parse_table_update(table_update)?, + ), + "profile_wallet_ledger" => db_update.profile_wallet_ledger.append( + profile_wallet_ledger_table::parse_table_update(table_update)?, + ), + "quest_log" => db_update + .quest_log + .append(quest_log_table::parse_table_update(table_update)?), + "quest_record" => db_update + .quest_record + .append(quest_record_table::parse_table_update(table_update)?), + "runtime_setting" => db_update + .runtime_setting + .append(runtime_setting_table::parse_table_update(table_update)?), + "story_event" => db_update + .story_event + .append(story_event_table::parse_table_update(table_update)?), + "story_session" => db_update + .story_session + .append(story_session_table::parse_table_update(table_update)?), + "treasure_record" => db_update + .treasure_record + .append(treasure_record_table::parse_table_update(table_update)?), + "user_browse_history" => db_update + .user_browse_history + .append(user_browse_history_table::parse_table_update(table_update)?), + unknown => { return Err(__sdk::InternalError::unknown_name( "table", @@ -112,12 +1022,226 @@ impl __sdk::DbUpdate for DbUpdate { ) -> AppliedDiff<'_> { let mut diff = AppliedDiff::default(); + diff.ai_result_reference = cache + .apply_diff_to_table::( + "ai_result_reference", + &self.ai_result_reference, + ) + .with_updates_by_pk(|row| &row.result_reference_row_id); + diff.ai_task = cache + .apply_diff_to_table::("ai_task", &self.ai_task) + .with_updates_by_pk(|row| &row.task_id); + diff.ai_task_stage = cache + .apply_diff_to_table::("ai_task_stage", &self.ai_task_stage) + .with_updates_by_pk(|row| &row.task_stage_id); + diff.ai_text_chunk = cache + .apply_diff_to_table::("ai_text_chunk", &self.ai_text_chunk) + .with_updates_by_pk(|row| &row.text_chunk_row_id); + diff.asset_entity_binding = cache + .apply_diff_to_table::( + "asset_entity_binding", + &self.asset_entity_binding, + ) + .with_updates_by_pk(|row| &row.binding_id); + diff.asset_object = cache + .apply_diff_to_table::("asset_object", &self.asset_object) + .with_updates_by_pk(|row| &row.asset_object_id); + diff.battle_state = cache + .apply_diff_to_table::("battle_state", &self.battle_state) + .with_updates_by_pk(|row| &row.battle_state_id); + diff.chapter_progression = cache + .apply_diff_to_table::( + "chapter_progression", + &self.chapter_progression, + ) + .with_updates_by_pk(|row| &row.chapter_progression_id); + diff.custom_world_agent_message = cache + .apply_diff_to_table::( + "custom_world_agent_message", + &self.custom_world_agent_message, + ) + .with_updates_by_pk(|row| &row.message_id); + diff.custom_world_agent_operation = cache + .apply_diff_to_table::( + "custom_world_agent_operation", + &self.custom_world_agent_operation, + ) + .with_updates_by_pk(|row| &row.operation_id); + diff.custom_world_agent_session = cache + .apply_diff_to_table::( + "custom_world_agent_session", + &self.custom_world_agent_session, + ) + .with_updates_by_pk(|row| &row.session_id); + diff.custom_world_draft_card = cache + .apply_diff_to_table::( + "custom_world_draft_card", + &self.custom_world_draft_card, + ) + .with_updates_by_pk(|row| &row.card_id); + diff.custom_world_gallery_entry = cache + .apply_diff_to_table::( + "custom_world_gallery_entry", + &self.custom_world_gallery_entry, + ) + .with_updates_by_pk(|row| &row.profile_id); + diff.custom_world_profile = cache + .apply_diff_to_table::( + "custom_world_profile", + &self.custom_world_profile, + ) + .with_updates_by_pk(|row| &row.profile_id); + diff.custom_world_session = cache + .apply_diff_to_table::( + "custom_world_session", + &self.custom_world_session, + ) + .with_updates_by_pk(|row| &row.session_id); + diff.inventory_slot = cache + .apply_diff_to_table::("inventory_slot", &self.inventory_slot) + .with_updates_by_pk(|row| &row.slot_id); + diff.npc_state = cache + .apply_diff_to_table::("npc_state", &self.npc_state) + .with_updates_by_pk(|row| &row.npc_state_id); + diff.player_progression = cache + .apply_diff_to_table::( + "player_progression", + &self.player_progression, + ) + .with_updates_by_pk(|row| &row.user_id); + diff.profile_dashboard_state = cache + .apply_diff_to_table::( + "profile_dashboard_state", + &self.profile_dashboard_state, + ) + .with_updates_by_pk(|row| &row.user_id); + diff.profile_played_world = cache + .apply_diff_to_table::( + "profile_played_world", + &self.profile_played_world, + ) + .with_updates_by_pk(|row| &row.played_world_id); + diff.profile_wallet_ledger = cache + .apply_diff_to_table::( + "profile_wallet_ledger", + &self.profile_wallet_ledger, + ) + .with_updates_by_pk(|row| &row.wallet_ledger_id); + diff.quest_log = cache + .apply_diff_to_table::("quest_log", &self.quest_log) + .with_updates_by_pk(|row| &row.log_id); + diff.quest_record = cache + .apply_diff_to_table::("quest_record", &self.quest_record) + .with_updates_by_pk(|row| &row.quest_id); + diff.runtime_setting = cache + .apply_diff_to_table::("runtime_setting", &self.runtime_setting) + .with_updates_by_pk(|row| &row.user_id); + diff.story_event = cache + .apply_diff_to_table::("story_event", &self.story_event) + .with_updates_by_pk(|row| &row.event_id); + diff.story_session = cache + .apply_diff_to_table::("story_session", &self.story_session) + .with_updates_by_pk(|row| &row.story_session_id); + diff.treasure_record = cache + .apply_diff_to_table::("treasure_record", &self.treasure_record) + .with_updates_by_pk(|row| &row.treasure_record_id); + diff.user_browse_history = cache + .apply_diff_to_table::( + "user_browse_history", + &self.user_browse_history, + ) + .with_updates_by_pk(|row| &row.browse_history_id); + diff } fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { let mut db_update = DbUpdate::default(); for table_rows in raw.tables { match &table_rows.table[..] { + "ai_result_reference" => db_update + .ai_result_reference + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "ai_task" => db_update + .ai_task + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "ai_task_stage" => db_update + .ai_task_stage + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "ai_text_chunk" => db_update + .ai_text_chunk + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "asset_entity_binding" => db_update + .asset_entity_binding + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "asset_object" => db_update + .asset_object + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "battle_state" => db_update + .battle_state + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "chapter_progression" => db_update + .chapter_progression + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_agent_message" => db_update + .custom_world_agent_message + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_agent_operation" => db_update + .custom_world_agent_operation + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_agent_session" => db_update + .custom_world_agent_session + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_draft_card" => db_update + .custom_world_draft_card + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_gallery_entry" => db_update + .custom_world_gallery_entry + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_profile" => db_update + .custom_world_profile + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_session" => db_update + .custom_world_session + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "inventory_slot" => db_update + .inventory_slot + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "npc_state" => db_update + .npc_state + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "player_progression" => db_update + .player_progression + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_dashboard_state" => db_update + .profile_dashboard_state + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_played_world" => db_update + .profile_played_world + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_wallet_ledger" => db_update + .profile_wallet_ledger + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "quest_log" => db_update + .quest_log + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "quest_record" => db_update + .quest_record + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "runtime_setting" => db_update + .runtime_setting + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "story_event" => db_update + .story_event + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "story_session" => db_update + .story_session + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "treasure_record" => db_update + .treasure_record + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "user_browse_history" => db_update + .user_browse_history + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -131,6 +1255,90 @@ impl __sdk::DbUpdate for DbUpdate { let mut db_update = DbUpdate::default(); for table_rows in raw.tables { match &table_rows.table[..] { + "ai_result_reference" => db_update + .ai_result_reference + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "ai_task" => db_update + .ai_task + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "ai_task_stage" => db_update + .ai_task_stage + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "ai_text_chunk" => db_update + .ai_text_chunk + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "asset_entity_binding" => db_update + .asset_entity_binding + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "asset_object" => db_update + .asset_object + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "battle_state" => db_update + .battle_state + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "chapter_progression" => db_update + .chapter_progression + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_agent_message" => db_update + .custom_world_agent_message + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_agent_operation" => db_update + .custom_world_agent_operation + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_agent_session" => db_update + .custom_world_agent_session + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_draft_card" => db_update + .custom_world_draft_card + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_gallery_entry" => db_update + .custom_world_gallery_entry + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_profile" => db_update + .custom_world_profile + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_session" => db_update + .custom_world_session + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "inventory_slot" => db_update + .inventory_slot + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "npc_state" => db_update + .npc_state + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "player_progression" => db_update + .player_progression + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_dashboard_state" => db_update + .profile_dashboard_state + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_played_world" => db_update + .profile_played_world + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_wallet_ledger" => db_update + .profile_wallet_ledger + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "quest_log" => db_update + .quest_log + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "quest_record" => db_update + .quest_record + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "runtime_setting" => db_update + .runtime_setting + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "story_event" => db_update + .story_event + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "story_session" => db_update + .story_session + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "treasure_record" => db_update + .treasure_record + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "user_browse_history" => db_update + .user_browse_history + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -146,6 +1354,34 @@ impl __sdk::DbUpdate for DbUpdate { #[allow(non_snake_case)] #[doc(hidden)] pub struct AppliedDiff<'r> { + ai_result_reference: __sdk::TableAppliedDiff<'r, AiResultReference>, + ai_task: __sdk::TableAppliedDiff<'r, AiTask>, + ai_task_stage: __sdk::TableAppliedDiff<'r, AiTaskStage>, + ai_text_chunk: __sdk::TableAppliedDiff<'r, AiTextChunk>, + asset_entity_binding: __sdk::TableAppliedDiff<'r, AssetEntityBinding>, + asset_object: __sdk::TableAppliedDiff<'r, AssetObject>, + battle_state: __sdk::TableAppliedDiff<'r, BattleState>, + chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>, + custom_world_agent_message: __sdk::TableAppliedDiff<'r, CustomWorldAgentMessage>, + custom_world_agent_operation: __sdk::TableAppliedDiff<'r, CustomWorldAgentOperation>, + custom_world_agent_session: __sdk::TableAppliedDiff<'r, CustomWorldAgentSession>, + custom_world_draft_card: __sdk::TableAppliedDiff<'r, CustomWorldDraftCard>, + custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>, + custom_world_profile: __sdk::TableAppliedDiff<'r, CustomWorldProfile>, + custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>, + inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>, + npc_state: __sdk::TableAppliedDiff<'r, NpcState>, + player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>, + profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>, + profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>, + profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>, + quest_log: __sdk::TableAppliedDiff<'r, QuestLog>, + quest_record: __sdk::TableAppliedDiff<'r, QuestRecord>, + runtime_setting: __sdk::TableAppliedDiff<'r, RuntimeSetting>, + story_event: __sdk::TableAppliedDiff<'r, StoryEvent>, + story_session: __sdk::TableAppliedDiff<'r, StorySession>, + treasure_record: __sdk::TableAppliedDiff<'r, TreasureRecord>, + user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>, __unused: std::marker::PhantomData<&'r ()>, } @@ -159,6 +1395,130 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { event: &EventContext, callbacks: &mut __sdk::DbCallbacks, ) { + callbacks.invoke_table_row_callbacks::( + "ai_result_reference", + &self.ai_result_reference, + event, + ); + callbacks.invoke_table_row_callbacks::("ai_task", &self.ai_task, event); + callbacks.invoke_table_row_callbacks::( + "ai_task_stage", + &self.ai_task_stage, + event, + ); + callbacks.invoke_table_row_callbacks::( + "ai_text_chunk", + &self.ai_text_chunk, + event, + ); + callbacks.invoke_table_row_callbacks::( + "asset_entity_binding", + &self.asset_entity_binding, + event, + ); + callbacks.invoke_table_row_callbacks::( + "asset_object", + &self.asset_object, + event, + ); + callbacks.invoke_table_row_callbacks::( + "battle_state", + &self.battle_state, + event, + ); + callbacks.invoke_table_row_callbacks::( + "chapter_progression", + &self.chapter_progression, + event, + ); + callbacks.invoke_table_row_callbacks::( + "custom_world_agent_message", + &self.custom_world_agent_message, + event, + ); + callbacks.invoke_table_row_callbacks::( + "custom_world_agent_operation", + &self.custom_world_agent_operation, + event, + ); + callbacks.invoke_table_row_callbacks::( + "custom_world_agent_session", + &self.custom_world_agent_session, + event, + ); + callbacks.invoke_table_row_callbacks::( + "custom_world_draft_card", + &self.custom_world_draft_card, + event, + ); + callbacks.invoke_table_row_callbacks::( + "custom_world_gallery_entry", + &self.custom_world_gallery_entry, + event, + ); + callbacks.invoke_table_row_callbacks::( + "custom_world_profile", + &self.custom_world_profile, + event, + ); + callbacks.invoke_table_row_callbacks::( + "custom_world_session", + &self.custom_world_session, + event, + ); + callbacks.invoke_table_row_callbacks::( + "inventory_slot", + &self.inventory_slot, + event, + ); + callbacks.invoke_table_row_callbacks::("npc_state", &self.npc_state, event); + callbacks.invoke_table_row_callbacks::( + "player_progression", + &self.player_progression, + event, + ); + callbacks.invoke_table_row_callbacks::( + "profile_dashboard_state", + &self.profile_dashboard_state, + event, + ); + callbacks.invoke_table_row_callbacks::( + "profile_played_world", + &self.profile_played_world, + event, + ); + callbacks.invoke_table_row_callbacks::( + "profile_wallet_ledger", + &self.profile_wallet_ledger, + event, + ); + callbacks.invoke_table_row_callbacks::("quest_log", &self.quest_log, event); + callbacks.invoke_table_row_callbacks::( + "quest_record", + &self.quest_record, + event, + ); + callbacks.invoke_table_row_callbacks::( + "runtime_setting", + &self.runtime_setting, + event, + ); + callbacks.invoke_table_row_callbacks::("story_event", &self.story_event, event); + callbacks.invoke_table_row_callbacks::( + "story_session", + &self.story_session, + event, + ); + callbacks.invoke_table_row_callbacks::( + "treasure_record", + &self.treasure_record, + event, + ); + callbacks.invoke_table_row_callbacks::( + "user_browse_history", + &self.user_browse_history, + event, + ); } } @@ -818,6 +2178,64 @@ impl __sdk::SpacetimeModule for RemoteModule { type SubscriptionHandle = SubscriptionHandle; type QueryBuilder = __sdk::QueryBuilder; - fn register_tables(client_cache: &mut __sdk::ClientCache) {} - const ALL_TABLE_NAMES: &'static [&'static str] = &[]; + fn register_tables(client_cache: &mut __sdk::ClientCache) { + ai_result_reference_table::register_table(client_cache); + ai_task_table::register_table(client_cache); + ai_task_stage_table::register_table(client_cache); + ai_text_chunk_table::register_table(client_cache); + asset_entity_binding_table::register_table(client_cache); + asset_object_table::register_table(client_cache); + battle_state_table::register_table(client_cache); + chapter_progression_table::register_table(client_cache); + custom_world_agent_message_table::register_table(client_cache); + custom_world_agent_operation_table::register_table(client_cache); + custom_world_agent_session_table::register_table(client_cache); + custom_world_draft_card_table::register_table(client_cache); + custom_world_gallery_entry_table::register_table(client_cache); + custom_world_profile_table::register_table(client_cache); + custom_world_session_table::register_table(client_cache); + inventory_slot_table::register_table(client_cache); + npc_state_table::register_table(client_cache); + player_progression_table::register_table(client_cache); + profile_dashboard_state_table::register_table(client_cache); + profile_played_world_table::register_table(client_cache); + profile_wallet_ledger_table::register_table(client_cache); + quest_log_table::register_table(client_cache); + quest_record_table::register_table(client_cache); + runtime_setting_table::register_table(client_cache); + story_event_table::register_table(client_cache); + story_session_table::register_table(client_cache); + treasure_record_table::register_table(client_cache); + user_browse_history_table::register_table(client_cache); + } + const ALL_TABLE_NAMES: &'static [&'static str] = &[ + "ai_result_reference", + "ai_task", + "ai_task_stage", + "ai_text_chunk", + "asset_entity_binding", + "asset_object", + "battle_state", + "chapter_progression", + "custom_world_agent_message", + "custom_world_agent_operation", + "custom_world_agent_session", + "custom_world_draft_card", + "custom_world_gallery_entry", + "custom_world_profile", + "custom_world_session", + "inventory_slot", + "npc_state", + "player_progression", + "profile_dashboard_state", + "profile_played_world", + "profile_wallet_ledger", + "quest_log", + "quest_record", + "runtime_setting", + "story_event", + "story_session", + "treasure_record", + "user_browse_history", + ]; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_procedure_result_type.rs new file mode 100644 index 00000000..d5f16a3f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_battle_interaction_result_type::NpcBattleInteractionResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcBattleInteractionProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + +impl __sdk::InModule for NpcBattleInteractionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_result_type.rs new file mode 100644 index 00000000..c276f969 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_battle_interaction_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_state_snapshot_type::BattleStateSnapshot; +use super::npc_interaction_result_type::NpcInteractionResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcBattleInteractionResult { + pub interaction: NpcInteractionResult, + pub battle_state: BattleStateSnapshot, +} + +impl __sdk::InModule for NpcBattleInteractionResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_battle_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_battle_mode_type.rs new file mode 100644 index 00000000..dc8e8819 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_battle_mode_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum NpcInteractionBattleMode { + Fight, + + Spar, +} + +impl __sdk::InModule for NpcInteractionBattleMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_procedure_result_type.rs new file mode 100644 index 00000000..383dce2a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_interaction_result_type::NpcInteractionResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcInteractionProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + +impl __sdk::InModule for NpcInteractionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_result_type.rs new file mode 100644 index 00000000..83624807 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_result_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_interaction_battle_mode_type::NpcInteractionBattleMode; +use super::npc_interaction_status_type::NpcInteractionStatus; +use super::npc_state_snapshot_type::NpcStateSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcInteractionResult { + pub npc_state: NpcStateSnapshot, + pub interaction_status: NpcInteractionStatus, + pub action_text: String, + pub result_text: String, + pub story_text: Option, + pub battle_mode: Option, + pub encounter_closed: bool, + pub affinity_changed: bool, + pub previous_affinity: i32, + pub next_affinity: i32, +} + +impl __sdk::InModule for NpcInteractionResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_status_type.rs new file mode 100644 index 00000000..8032abf9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_interaction_status_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum NpcInteractionStatus { + Previewed, + + Dialogue, + + Resolved, + + Recruited, + + BattlePending, + + Left, +} + +impl __sdk::InModule for NpcInteractionStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_relation_stance_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_relation_stance_type.rs new file mode 100644 index 00000000..48e8ac70 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_relation_stance_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum NpcRelationStance { + Hostile, + + Guarded, + + Neutral, + + Cooperative, + + Bonded, +} + +impl __sdk::InModule for NpcRelationStance { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_relation_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_relation_state_type.rs new file mode 100644 index 00000000..b6bbc07d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_relation_state_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_relation_stance_type::NpcRelationStance; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcRelationState { + pub affinity: i32, + pub stance: NpcRelationStance, +} + +impl __sdk::InModule for NpcRelationState { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_social_action_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_social_action_kind_type.rs new file mode 100644 index 00000000..9c6d2c1e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_social_action_kind_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum NpcSocialActionKind { + Chat, + + Help, + + Gift, + + Recruit, + + QuestAccept, +} + +impl __sdk::InModule for NpcSocialActionKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_stance_profile_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_stance_profile_type.rs new file mode 100644 index 00000000..73177673 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_stance_profile_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcStanceProfile { + pub trust: u8, + pub warmth: u8, + pub ideological_fit: u8, + pub fear_or_guard: u8, + pub loyalty: u8, + pub current_conflict_tag: Option, + pub recent_approvals: Vec, + pub recent_disapprovals: Vec, +} + +impl __sdk::InModule for NpcStanceProfile { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_state_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_procedure_result_type.rs new file mode 100644 index 00000000..c4e60bef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_state_snapshot_type::NpcStateSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcStateProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for NpcStateProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_state_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_snapshot_type.rs new file mode 100644 index 00000000..351b45ae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_snapshot_type.rs @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_relation_state_type::NpcRelationState; +use super::npc_stance_profile_type::NpcStanceProfile; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcStateSnapshot { + pub npc_state_id: String, + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub relation_state: NpcRelationState, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub stance_profile: NpcStanceProfile, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for NpcStateSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_state_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_table.rs new file mode 100644 index 00000000..26e7fcf5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::npc_relation_state_type::NpcRelationState; +use super::npc_stance_profile_type::NpcStanceProfile; +use super::npc_state_type::NpcState; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `npc_state`. +/// +/// Obtain a handle from the [`NpcStateTableAccess::npc_state`] method on [`super::RemoteTables`], +/// like `ctx.db.npc_state()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.npc_state().on_insert(...)`. +pub struct NpcStateTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `npc_state`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait NpcStateTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`NpcStateTableHandle`], which mediates access to the table `npc_state`. + fn npc_state(&self) -> NpcStateTableHandle<'_>; +} + +impl NpcStateTableAccess for super::RemoteTables { + fn npc_state(&self) -> NpcStateTableHandle<'_> { + NpcStateTableHandle { + imp: self.imp.get_table::("npc_state"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct NpcStateInsertCallbackId(__sdk::CallbackId); +pub struct NpcStateDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for NpcStateTableHandle<'ctx> { + type Row = NpcState; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = NpcStateInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> NpcStateInsertCallbackId { + NpcStateInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: NpcStateInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = NpcStateDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> NpcStateDeleteCallbackId { + NpcStateDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: NpcStateDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct NpcStateUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for NpcStateTableHandle<'ctx> { + type UpdateCallbackId = NpcStateUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> NpcStateUpdateCallbackId { + NpcStateUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: NpcStateUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `npc_state_id` unique index on the table `npc_state`, +/// which allows point queries on the field of the same name +/// via the [`NpcStateNpcStateIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.npc_state().npc_state_id().find(...)`. +pub struct NpcStateNpcStateIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> NpcStateTableHandle<'ctx> { + /// Get a handle on the `npc_state_id` unique index on the table `npc_state`. + pub fn npc_state_id(&self) -> NpcStateNpcStateIdUnique<'ctx> { + NpcStateNpcStateIdUnique { + imp: self.imp.get_unique_constraint::("npc_state_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> NpcStateNpcStateIdUnique<'ctx> { + /// Find the subscribed row whose `npc_state_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("npc_state"); + _table.add_unique_constraint::("npc_state_id", |row| &row.npc_state_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `NpcState`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait npc_stateQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `NpcState`. + fn npc_state(&self) -> __sdk::__query_builder::Table; +} + +impl npc_stateQueryTableAccess for __sdk::QueryTableAccessor { + fn npc_state(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("npc_state") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_type.rs new file mode 100644 index 00000000..1ddeec42 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_type.rs @@ -0,0 +1,122 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_relation_state_type::NpcRelationState; +use super::npc_stance_profile_type::NpcStanceProfile; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcState { + pub npc_state_id: String, + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub relation_state: NpcRelationState, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub stance_profile: NpcStanceProfile, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for NpcState { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `NpcState`. +/// +/// Provides typed access to columns for query building. +pub struct NpcStateCols { + pub npc_state_id: __sdk::__query_builder::Col, + pub runtime_session_id: __sdk::__query_builder::Col, + pub npc_id: __sdk::__query_builder::Col, + pub npc_name: __sdk::__query_builder::Col, + pub affinity: __sdk::__query_builder::Col, + pub relation_state: __sdk::__query_builder::Col, + pub help_used: __sdk::__query_builder::Col, + pub chatted_count: __sdk::__query_builder::Col, + pub gifts_given: __sdk::__query_builder::Col, + pub recruited: __sdk::__query_builder::Col, + pub trade_stock_signature: __sdk::__query_builder::Col>, + pub revealed_facts: __sdk::__query_builder::Col>, + pub known_attribute_rumors: __sdk::__query_builder::Col>, + pub first_meaningful_contact_resolved: __sdk::__query_builder::Col, + pub seen_backstory_chapter_ids: __sdk::__query_builder::Col>, + pub stance_profile: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for NpcState { + type Cols = NpcStateCols; + fn cols(table_name: &'static str) -> Self::Cols { + NpcStateCols { + npc_state_id: __sdk::__query_builder::Col::new(table_name, "npc_state_id"), + runtime_session_id: __sdk::__query_builder::Col::new(table_name, "runtime_session_id"), + npc_id: __sdk::__query_builder::Col::new(table_name, "npc_id"), + npc_name: __sdk::__query_builder::Col::new(table_name, "npc_name"), + affinity: __sdk::__query_builder::Col::new(table_name, "affinity"), + relation_state: __sdk::__query_builder::Col::new(table_name, "relation_state"), + help_used: __sdk::__query_builder::Col::new(table_name, "help_used"), + chatted_count: __sdk::__query_builder::Col::new(table_name, "chatted_count"), + gifts_given: __sdk::__query_builder::Col::new(table_name, "gifts_given"), + recruited: __sdk::__query_builder::Col::new(table_name, "recruited"), + trade_stock_signature: __sdk::__query_builder::Col::new( + table_name, + "trade_stock_signature", + ), + revealed_facts: __sdk::__query_builder::Col::new(table_name, "revealed_facts"), + known_attribute_rumors: __sdk::__query_builder::Col::new( + table_name, + "known_attribute_rumors", + ), + first_meaningful_contact_resolved: __sdk::__query_builder::Col::new( + table_name, + "first_meaningful_contact_resolved", + ), + seen_backstory_chapter_ids: __sdk::__query_builder::Col::new( + table_name, + "seen_backstory_chapter_ids", + ), + stance_profile: __sdk::__query_builder::Col::new(table_name, "stance_profile"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `NpcState`. +/// +/// Provides typed access to indexed columns for query building. +pub struct NpcStateIxCols { + pub npc_id: __sdk::__query_builder::IxCol, + pub npc_state_id: __sdk::__query_builder::IxCol, + pub runtime_session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for NpcState { + type IxCols = NpcStateIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + NpcStateIxCols { + npc_id: __sdk::__query_builder::IxCol::new(table_name, "npc_id"), + npc_state_id: __sdk::__query_builder::IxCol::new(table_name, "npc_state_id"), + runtime_session_id: __sdk::__query_builder::IxCol::new( + table_name, + "runtime_session_id", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for NpcState {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/npc_state_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_upsert_input_type.rs new file mode 100644 index 00000000..c31346e7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/npc_state_upsert_input_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_stance_profile_type::NpcStanceProfile; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct NpcStateUpsertInput { + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub stance_profile: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for NpcStateUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/player_progression_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_get_input_type.rs new file mode 100644 index 00000000..a4dd27f5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PlayerProgressionGetInput { + pub user_id: String, +} + +impl __sdk::InModule for PlayerProgressionGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_input_type.rs new file mode 100644 index 00000000..b5761753 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::player_progression_grant_source_type::PlayerProgressionGrantSource; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PlayerProgressionGrantInput { + pub user_id: String, + pub amount: u32, + pub source: PlayerProgressionGrantSource, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PlayerProgressionGrantInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_source_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_source_type.rs new file mode 100644 index 00000000..bf3ae257 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_grant_source_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum PlayerProgressionGrantSource { + Quest, + + HostileNpc, +} + +impl __sdk::InModule for PlayerProgressionGrantSource { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/player_progression_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_procedure_result_type.rs new file mode 100644 index 00000000..413c1208 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::player_progression_snapshot_type::PlayerProgressionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PlayerProgressionProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for PlayerProgressionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/player_progression_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_snapshot_type.rs new file mode 100644 index 00000000..1361178e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::player_progression_grant_source_type::PlayerProgressionGrantSource; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PlayerProgressionSnapshot { + pub user_id: String, + pub level: u32, + pub current_level_xp: u32, + pub total_xp: u32, + pub xp_to_next_level: u32, + pub pending_level_ups: u32, + pub last_granted_source: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PlayerProgressionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/player_progression_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_table.rs new file mode 100644 index 00000000..66961930 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::player_progression_grant_source_type::PlayerProgressionGrantSource; +use super::player_progression_type::PlayerProgression; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `player_progression`. +/// +/// Obtain a handle from the [`PlayerProgressionTableAccess::player_progression`] method on [`super::RemoteTables`], +/// like `ctx.db.player_progression()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.player_progression().on_insert(...)`. +pub struct PlayerProgressionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `player_progression`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PlayerProgressionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PlayerProgressionTableHandle`], which mediates access to the table `player_progression`. + fn player_progression(&self) -> PlayerProgressionTableHandle<'_>; +} + +impl PlayerProgressionTableAccess for super::RemoteTables { + fn player_progression(&self) -> PlayerProgressionTableHandle<'_> { + PlayerProgressionTableHandle { + imp: self + .imp + .get_table::("player_progression"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PlayerProgressionInsertCallbackId(__sdk::CallbackId); +pub struct PlayerProgressionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PlayerProgressionTableHandle<'ctx> { + type Row = PlayerProgression; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PlayerProgressionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PlayerProgressionInsertCallbackId { + PlayerProgressionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PlayerProgressionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PlayerProgressionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PlayerProgressionDeleteCallbackId { + PlayerProgressionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PlayerProgressionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PlayerProgressionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PlayerProgressionTableHandle<'ctx> { + type UpdateCallbackId = PlayerProgressionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PlayerProgressionUpdateCallbackId { + PlayerProgressionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PlayerProgressionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `user_id` unique index on the table `player_progression`, +/// which allows point queries on the field of the same name +/// via the [`PlayerProgressionUserIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.player_progression().user_id().find(...)`. +pub struct PlayerProgressionUserIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PlayerProgressionTableHandle<'ctx> { + /// Get a handle on the `user_id` unique index on the table `player_progression`. + pub fn user_id(&self) -> PlayerProgressionUserIdUnique<'ctx> { + PlayerProgressionUserIdUnique { + imp: self.imp.get_unique_constraint::("user_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PlayerProgressionUserIdUnique<'ctx> { + /// Find the subscribed row whose `user_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("player_progression"); + _table.add_unique_constraint::("user_id", |row| &row.user_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PlayerProgression`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait player_progressionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PlayerProgression`. + fn player_progression(&self) -> __sdk::__query_builder::Table; +} + +impl player_progressionQueryTableAccess for __sdk::QueryTableAccessor { + fn player_progression(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("player_progression") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/player_progression_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_type.rs new file mode 100644 index 00000000..c734d1c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/player_progression_type.rs @@ -0,0 +1,79 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::player_progression_grant_source_type::PlayerProgressionGrantSource; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PlayerProgression { + pub user_id: String, + pub level: u32, + pub current_level_xp: u32, + pub total_xp: u32, + pub xp_to_next_level: u32, + pub pending_level_ups: u32, + pub last_granted_source: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PlayerProgression { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PlayerProgression`. +/// +/// Provides typed access to columns for query building. +pub struct PlayerProgressionCols { + pub user_id: __sdk::__query_builder::Col, + pub level: __sdk::__query_builder::Col, + pub current_level_xp: __sdk::__query_builder::Col, + pub total_xp: __sdk::__query_builder::Col, + pub xp_to_next_level: __sdk::__query_builder::Col, + pub pending_level_ups: __sdk::__query_builder::Col, + pub last_granted_source: + __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PlayerProgression { + type Cols = PlayerProgressionCols; + fn cols(table_name: &'static str) -> Self::Cols { + PlayerProgressionCols { + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + level: __sdk::__query_builder::Col::new(table_name, "level"), + current_level_xp: __sdk::__query_builder::Col::new(table_name, "current_level_xp"), + total_xp: __sdk::__query_builder::Col::new(table_name, "total_xp"), + xp_to_next_level: __sdk::__query_builder::Col::new(table_name, "xp_to_next_level"), + pending_level_ups: __sdk::__query_builder::Col::new(table_name, "pending_level_ups"), + last_granted_source: __sdk::__query_builder::Col::new( + table_name, + "last_granted_source", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PlayerProgression`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PlayerProgressionIxCols { + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PlayerProgression { + type IxCols = PlayerProgressionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PlayerProgressionIxCols { + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PlayerProgression {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_table.rs new file mode 100644 index 00000000..c79f8b0c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_dashboard_state_type::ProfileDashboardState; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_dashboard_state`. +/// +/// Obtain a handle from the [`ProfileDashboardStateTableAccess::profile_dashboard_state`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_dashboard_state()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_dashboard_state().on_insert(...)`. +pub struct ProfileDashboardStateTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_dashboard_state`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileDashboardStateTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileDashboardStateTableHandle`], which mediates access to the table `profile_dashboard_state`. + fn profile_dashboard_state(&self) -> ProfileDashboardStateTableHandle<'_>; +} + +impl ProfileDashboardStateTableAccess for super::RemoteTables { + fn profile_dashboard_state(&self) -> ProfileDashboardStateTableHandle<'_> { + ProfileDashboardStateTableHandle { + imp: self + .imp + .get_table::("profile_dashboard_state"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileDashboardStateInsertCallbackId(__sdk::CallbackId); +pub struct ProfileDashboardStateDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileDashboardStateTableHandle<'ctx> { + type Row = ProfileDashboardState; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileDashboardStateInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileDashboardStateInsertCallbackId { + ProfileDashboardStateInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileDashboardStateInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileDashboardStateDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileDashboardStateDeleteCallbackId { + ProfileDashboardStateDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileDashboardStateDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileDashboardStateUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileDashboardStateTableHandle<'ctx> { + type UpdateCallbackId = ProfileDashboardStateUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileDashboardStateUpdateCallbackId { + ProfileDashboardStateUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileDashboardStateUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `user_id` unique index on the table `profile_dashboard_state`, +/// which allows point queries on the field of the same name +/// via the [`ProfileDashboardStateUserIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_dashboard_state().user_id().find(...)`. +pub struct ProfileDashboardStateUserIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileDashboardStateTableHandle<'ctx> { + /// Get a handle on the `user_id` unique index on the table `profile_dashboard_state`. + pub fn user_id(&self) -> ProfileDashboardStateUserIdUnique<'ctx> { + ProfileDashboardStateUserIdUnique { + imp: self.imp.get_unique_constraint::("user_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileDashboardStateUserIdUnique<'ctx> { + /// Find the subscribed row whose `user_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("profile_dashboard_state"); + _table.add_unique_constraint::("user_id", |row| &row.user_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileDashboardState`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_dashboard_stateQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileDashboardState`. + fn profile_dashboard_state(&self) -> __sdk::__query_builder::Table; +} + +impl profile_dashboard_stateQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_dashboard_state(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_dashboard_state") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_type.rs new file mode 100644 index 00000000..e5124048 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_dashboard_state_type.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileDashboardState { + pub user_id: String, + pub wallet_balance: u64, + pub total_play_time_ms: u64, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileDashboardState { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileDashboardState`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileDashboardStateCols { + pub user_id: __sdk::__query_builder::Col, + pub wallet_balance: __sdk::__query_builder::Col, + pub total_play_time_ms: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileDashboardState { + type Cols = ProfileDashboardStateCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileDashboardStateCols { + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + wallet_balance: __sdk::__query_builder::Col::new(table_name, "wallet_balance"), + total_play_time_ms: __sdk::__query_builder::Col::new(table_name, "total_play_time_ms"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileDashboardState`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileDashboardStateIxCols { + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileDashboardState { + type IxCols = ProfileDashboardStateIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileDashboardStateIxCols { + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileDashboardState {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_table.rs new file mode 100644 index 00000000..0f147bd5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_played_world_type::ProfilePlayedWorld; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_played_world`. +/// +/// Obtain a handle from the [`ProfilePlayedWorldTableAccess::profile_played_world`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_played_world()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_played_world().on_insert(...)`. +pub struct ProfilePlayedWorldTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_played_world`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfilePlayedWorldTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfilePlayedWorldTableHandle`], which mediates access to the table `profile_played_world`. + fn profile_played_world(&self) -> ProfilePlayedWorldTableHandle<'_>; +} + +impl ProfilePlayedWorldTableAccess for super::RemoteTables { + fn profile_played_world(&self) -> ProfilePlayedWorldTableHandle<'_> { + ProfilePlayedWorldTableHandle { + imp: self + .imp + .get_table::("profile_played_world"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfilePlayedWorldInsertCallbackId(__sdk::CallbackId); +pub struct ProfilePlayedWorldDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfilePlayedWorldTableHandle<'ctx> { + type Row = ProfilePlayedWorld; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfilePlayedWorldInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfilePlayedWorldInsertCallbackId { + ProfilePlayedWorldInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfilePlayedWorldInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfilePlayedWorldDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfilePlayedWorldDeleteCallbackId { + ProfilePlayedWorldDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfilePlayedWorldDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfilePlayedWorldUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfilePlayedWorldTableHandle<'ctx> { + type UpdateCallbackId = ProfilePlayedWorldUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfilePlayedWorldUpdateCallbackId { + ProfilePlayedWorldUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfilePlayedWorldUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `played_world_id` unique index on the table `profile_played_world`, +/// which allows point queries on the field of the same name +/// via the [`ProfilePlayedWorldPlayedWorldIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_played_world().played_world_id().find(...)`. +pub struct ProfilePlayedWorldPlayedWorldIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfilePlayedWorldTableHandle<'ctx> { + /// Get a handle on the `played_world_id` unique index on the table `profile_played_world`. + pub fn played_world_id(&self) -> ProfilePlayedWorldPlayedWorldIdUnique<'ctx> { + ProfilePlayedWorldPlayedWorldIdUnique { + imp: self.imp.get_unique_constraint::("played_world_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfilePlayedWorldPlayedWorldIdUnique<'ctx> { + /// Find the subscribed row whose `played_world_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("profile_played_world"); + _table.add_unique_constraint::("played_world_id", |row| &row.played_world_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfilePlayedWorld`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_played_worldQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfilePlayedWorld`. + fn profile_played_world(&self) -> __sdk::__query_builder::Table; +} + +impl profile_played_worldQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_played_world(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_played_world") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_type.rs new file mode 100644 index 00000000..3b94036c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_played_world_type.rs @@ -0,0 +1,84 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfilePlayedWorld { + pub played_world_id: String, + pub user_id: String, + pub world_key: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub world_type: Option, + pub world_title: String, + pub world_subtitle: String, + pub first_played_at: __sdk::Timestamp, + pub last_played_at: __sdk::Timestamp, + pub last_observed_play_time_ms: u64, +} + +impl __sdk::InModule for ProfilePlayedWorld { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfilePlayedWorld`. +/// +/// Provides typed access to columns for query building. +pub struct ProfilePlayedWorldCols { + pub played_world_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub world_key: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col>, + pub profile_id: __sdk::__query_builder::Col>, + pub world_type: __sdk::__query_builder::Col>, + pub world_title: __sdk::__query_builder::Col, + pub world_subtitle: __sdk::__query_builder::Col, + pub first_played_at: __sdk::__query_builder::Col, + pub last_played_at: __sdk::__query_builder::Col, + pub last_observed_play_time_ms: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfilePlayedWorld { + type Cols = ProfilePlayedWorldCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfilePlayedWorldCols { + played_world_id: __sdk::__query_builder::Col::new(table_name, "played_world_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + world_key: __sdk::__query_builder::Col::new(table_name, "world_key"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + world_type: __sdk::__query_builder::Col::new(table_name, "world_type"), + world_title: __sdk::__query_builder::Col::new(table_name, "world_title"), + world_subtitle: __sdk::__query_builder::Col::new(table_name, "world_subtitle"), + first_played_at: __sdk::__query_builder::Col::new(table_name, "first_played_at"), + last_played_at: __sdk::__query_builder::Col::new(table_name, "last_played_at"), + last_observed_play_time_ms: __sdk::__query_builder::Col::new( + table_name, + "last_observed_play_time_ms", + ), + } + } +} + +/// Indexed column accessor struct for the table `ProfilePlayedWorld`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfilePlayedWorldIxCols { + pub played_world_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfilePlayedWorld { + type IxCols = ProfilePlayedWorldIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfilePlayedWorldIxCols { + played_world_id: __sdk::__query_builder::IxCol::new(table_name, "played_world_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfilePlayedWorld {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_table.rs new file mode 100644 index 00000000..48705ae9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_wallet_ledger_type::ProfileWalletLedger; +use super::runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_wallet_ledger`. +/// +/// Obtain a handle from the [`ProfileWalletLedgerTableAccess::profile_wallet_ledger`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_wallet_ledger()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_ledger().on_insert(...)`. +pub struct ProfileWalletLedgerTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_wallet_ledger`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileWalletLedgerTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileWalletLedgerTableHandle`], which mediates access to the table `profile_wallet_ledger`. + fn profile_wallet_ledger(&self) -> ProfileWalletLedgerTableHandle<'_>; +} + +impl ProfileWalletLedgerTableAccess for super::RemoteTables { + fn profile_wallet_ledger(&self) -> ProfileWalletLedgerTableHandle<'_> { + ProfileWalletLedgerTableHandle { + imp: self + .imp + .get_table::("profile_wallet_ledger"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileWalletLedgerInsertCallbackId(__sdk::CallbackId); +pub struct ProfileWalletLedgerDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileWalletLedgerTableHandle<'ctx> { + type Row = ProfileWalletLedger; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileWalletLedgerInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletLedgerInsertCallbackId { + ProfileWalletLedgerInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileWalletLedgerInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileWalletLedgerDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletLedgerDeleteCallbackId { + ProfileWalletLedgerDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileWalletLedgerDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileWalletLedgerUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileWalletLedgerTableHandle<'ctx> { + type UpdateCallbackId = ProfileWalletLedgerUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileWalletLedgerUpdateCallbackId { + ProfileWalletLedgerUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileWalletLedgerUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `wallet_ledger_id` unique index on the table `profile_wallet_ledger`, +/// which allows point queries on the field of the same name +/// via the [`ProfileWalletLedgerWalletLedgerIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_ledger().wallet_ledger_id().find(...)`. +pub struct ProfileWalletLedgerWalletLedgerIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileWalletLedgerTableHandle<'ctx> { + /// Get a handle on the `wallet_ledger_id` unique index on the table `profile_wallet_ledger`. + pub fn wallet_ledger_id(&self) -> ProfileWalletLedgerWalletLedgerIdUnique<'ctx> { + ProfileWalletLedgerWalletLedgerIdUnique { + imp: self.imp.get_unique_constraint::("wallet_ledger_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileWalletLedgerWalletLedgerIdUnique<'ctx> { + /// Find the subscribed row whose `wallet_ledger_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("profile_wallet_ledger"); + _table.add_unique_constraint::("wallet_ledger_id", |row| &row.wallet_ledger_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileWalletLedger`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_wallet_ledgerQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileWalletLedger`. + fn profile_wallet_ledger(&self) -> __sdk::__query_builder::Table; +} + +impl profile_wallet_ledgerQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_wallet_ledger(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_wallet_ledger") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs new file mode 100644 index 00000000..78364d5a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs @@ -0,0 +1,69 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileWalletLedger { + pub wallet_ledger_id: String, + pub user_id: String, + pub amount_delta: i64, + pub balance_after: u64, + pub source_type: RuntimeProfileWalletLedgerSourceType, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileWalletLedger { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileWalletLedger`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileWalletLedgerCols { + pub wallet_ledger_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub amount_delta: __sdk::__query_builder::Col, + pub balance_after: __sdk::__query_builder::Col, + pub source_type: + __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileWalletLedger { + type Cols = ProfileWalletLedgerCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileWalletLedgerCols { + wallet_ledger_id: __sdk::__query_builder::Col::new(table_name, "wallet_ledger_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + amount_delta: __sdk::__query_builder::Col::new(table_name, "amount_delta"), + balance_after: __sdk::__query_builder::Col::new(table_name, "balance_after"), + source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileWalletLedger`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileWalletLedgerIxCols { + pub user_id: __sdk::__query_builder::IxCol, + pub wallet_ledger_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileWalletLedger { + type IxCols = ProfileWalletLedgerIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileWalletLedgerIxCols { + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + wallet_ledger_id: __sdk::__query_builder::IxCol::new(table_name, "wallet_ledger_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileWalletLedger {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs new file mode 100644 index 00000000..e5434aca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_publish_input_type::CustomWorldProfilePublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PublishCustomWorldProfileAndReturnArgs { + pub input: CustomWorldProfilePublishInput, +} + +impl __sdk::InModule for PublishCustomWorldProfileAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `publish_custom_world_profile_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait publish_custom_world_profile_and_return { + fn publish_custom_world_profile_and_return(&self, input: CustomWorldProfilePublishInput) { + self.publish_custom_world_profile_and_return_then(input, |_, _| {}); + } + + fn publish_custom_world_profile_and_return_then( + &self, + input: CustomWorldProfilePublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl publish_custom_world_profile_and_return for super::RemoteProcedures { + fn publish_custom_world_profile_and_return_then( + &self, + input: CustomWorldProfilePublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "publish_custom_world_profile_and_return", + PublishCustomWorldProfileAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_reducer.rs new file mode 100644 index 00000000..84e6b339 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_reducer.rs @@ -0,0 +1,71 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_profile_publish_input_type::CustomWorldProfilePublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct PublishCustomWorldProfileArgs { + pub input: CustomWorldProfilePublishInput, +} + +impl From for super::Reducer { + fn from(args: PublishCustomWorldProfileArgs) -> Self { + Self::PublishCustomWorldProfile { input: args.input } + } +} + +impl __sdk::InModule for PublishCustomWorldProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `publish_custom_world_profile`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait publish_custom_world_profile { + /// Request that the remote module invoke the reducer `publish_custom_world_profile` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`publish_custom_world_profile:publish_custom_world_profile_then`] to run a callback after the reducer completes. + fn publish_custom_world_profile( + &self, + input: CustomWorldProfilePublishInput, + ) -> __sdk::Result<()> { + self.publish_custom_world_profile_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `publish_custom_world_profile` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn publish_custom_world_profile_then( + &self, + input: CustomWorldProfilePublishInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl publish_custom_world_profile for super::RemoteReducers { + fn publish_custom_world_profile_then( + &self, + input: CustomWorldProfilePublishInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(PublishCustomWorldProfileArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs new file mode 100644 index 00000000..1eb935a2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_publish_world_input_type::CustomWorldPublishWorldInput; +use super::custom_world_publish_world_result_type::CustomWorldPublishWorldResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PublishCustomWorldWorldArgs { + pub input: CustomWorldPublishWorldInput, +} + +impl __sdk::InModule for PublishCustomWorldWorldArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `publish_custom_world_world`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait publish_custom_world_world { + fn publish_custom_world_world(&self, input: CustomWorldPublishWorldInput) { + self.publish_custom_world_world_then(input, |_, _| {}); + } + + fn publish_custom_world_world_then( + &self, + input: CustomWorldPublishWorldInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl publish_custom_world_world for super::RemoteProcedures { + fn publish_custom_world_world_then( + &self, + input: CustomWorldPublishWorldInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldPublishWorldResult>( + "publish_custom_world_world", + PublishCustomWorldWorldArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_completion_ack_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_completion_ack_input_type.rs new file mode 100644 index 00000000..678ec032 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_completion_ack_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestCompletionAckInput { + pub quest_id: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for QuestCompletionAckInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_hostile_npc_defeated_signal_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_hostile_npc_defeated_signal_type.rs new file mode 100644 index 00000000..d12363ff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_hostile_npc_defeated_signal_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestHostileNpcDefeatedSignal { + pub scene_id: Option, + pub hostile_npc_id: String, +} + +impl __sdk::InModule for QuestHostileNpcDefeatedSignal { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_item_delivered_signal_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_item_delivered_signal_type.rs new file mode 100644 index 00000000..4e1d0a5d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_item_delivered_signal_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestItemDeliveredSignal { + pub npc_id: String, + pub item_id: String, + pub quantity: u32, +} + +impl __sdk::InModule for QuestItemDeliveredSignal { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_log_event_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_log_event_kind_type.rs new file mode 100644 index 00000000..c3176590 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_log_event_kind_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestLogEventKind { + Accepted, + + Progressed, + + Completed, + + CompletionAcknowledged, + + TurnedIn, +} + +impl __sdk::InModule for QuestLogEventKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_log_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_log_table.rs new file mode 100644 index 00000000..b1ac8c78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_log_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::quest_log_event_kind_type::QuestLogEventKind; +use super::quest_log_type::QuestLog; +use super::quest_progress_signal_type::QuestProgressSignal; +use super::quest_signal_kind_type::QuestSignalKind; +use super::quest_status_type::QuestStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `quest_log`. +/// +/// Obtain a handle from the [`QuestLogTableAccess::quest_log`] method on [`super::RemoteTables`], +/// like `ctx.db.quest_log()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.quest_log().on_insert(...)`. +pub struct QuestLogTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `quest_log`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait QuestLogTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`QuestLogTableHandle`], which mediates access to the table `quest_log`. + fn quest_log(&self) -> QuestLogTableHandle<'_>; +} + +impl QuestLogTableAccess for super::RemoteTables { + fn quest_log(&self) -> QuestLogTableHandle<'_> { + QuestLogTableHandle { + imp: self.imp.get_table::("quest_log"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct QuestLogInsertCallbackId(__sdk::CallbackId); +pub struct QuestLogDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for QuestLogTableHandle<'ctx> { + type Row = QuestLog; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = QuestLogInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> QuestLogInsertCallbackId { + QuestLogInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: QuestLogInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = QuestLogDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> QuestLogDeleteCallbackId { + QuestLogDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: QuestLogDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct QuestLogUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for QuestLogTableHandle<'ctx> { + type UpdateCallbackId = QuestLogUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> QuestLogUpdateCallbackId { + QuestLogUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: QuestLogUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `log_id` unique index on the table `quest_log`, +/// which allows point queries on the field of the same name +/// via the [`QuestLogLogIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.quest_log().log_id().find(...)`. +pub struct QuestLogLogIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> QuestLogTableHandle<'ctx> { + /// Get a handle on the `log_id` unique index on the table `quest_log`. + pub fn log_id(&self) -> QuestLogLogIdUnique<'ctx> { + QuestLogLogIdUnique { + imp: self.imp.get_unique_constraint::("log_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> QuestLogLogIdUnique<'ctx> { + /// Find the subscribed row whose `log_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("quest_log"); + _table.add_unique_constraint::("log_id", |row| &row.log_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `QuestLog`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait quest_logQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `QuestLog`. + fn quest_log(&self) -> __sdk::__query_builder::Table; +} + +impl quest_logQueryTableAccess for __sdk::QueryTableAccessor { + fn quest_log(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("quest_log") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_log_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_log_type.rs new file mode 100644 index 00000000..d893857a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_log_type.rs @@ -0,0 +1,93 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_log_event_kind_type::QuestLogEventKind; +use super::quest_progress_signal_type::QuestProgressSignal; +use super::quest_signal_kind_type::QuestSignalKind; +use super::quest_status_type::QuestStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestLog { + pub log_id: String, + pub quest_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub event_kind: QuestLogEventKind, + pub status_after: QuestStatus, + pub signal_kind: Option, + pub signal: Option, + pub step_id: Option, + pub step_progress: Option, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for QuestLog { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `QuestLog`. +/// +/// Provides typed access to columns for query building. +pub struct QuestLogCols { + pub log_id: __sdk::__query_builder::Col, + pub quest_id: __sdk::__query_builder::Col, + pub runtime_session_id: __sdk::__query_builder::Col, + pub actor_user_id: __sdk::__query_builder::Col, + pub event_kind: __sdk::__query_builder::Col, + pub status_after: __sdk::__query_builder::Col, + pub signal_kind: __sdk::__query_builder::Col>, + pub signal: __sdk::__query_builder::Col>, + pub step_id: __sdk::__query_builder::Col>, + pub step_progress: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for QuestLog { + type Cols = QuestLogCols; + fn cols(table_name: &'static str) -> Self::Cols { + QuestLogCols { + log_id: __sdk::__query_builder::Col::new(table_name, "log_id"), + quest_id: __sdk::__query_builder::Col::new(table_name, "quest_id"), + runtime_session_id: __sdk::__query_builder::Col::new(table_name, "runtime_session_id"), + actor_user_id: __sdk::__query_builder::Col::new(table_name, "actor_user_id"), + event_kind: __sdk::__query_builder::Col::new(table_name, "event_kind"), + status_after: __sdk::__query_builder::Col::new(table_name, "status_after"), + signal_kind: __sdk::__query_builder::Col::new(table_name, "signal_kind"), + signal: __sdk::__query_builder::Col::new(table_name, "signal"), + step_id: __sdk::__query_builder::Col::new(table_name, "step_id"), + step_progress: __sdk::__query_builder::Col::new(table_name, "step_progress"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `QuestLog`. +/// +/// Provides typed access to indexed columns for query building. +pub struct QuestLogIxCols { + pub actor_user_id: __sdk::__query_builder::IxCol, + pub log_id: __sdk::__query_builder::IxCol, + pub quest_id: __sdk::__query_builder::IxCol, + pub runtime_session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for QuestLog { + type IxCols = QuestLogIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + QuestLogIxCols { + actor_user_id: __sdk::__query_builder::IxCol::new(table_name, "actor_user_id"), + log_id: __sdk::__query_builder::IxCol::new(table_name, "log_id"), + quest_id: __sdk::__query_builder::IxCol::new(table_name, "quest_id"), + runtime_session_id: __sdk::__query_builder::IxCol::new( + table_name, + "runtime_session_id", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for QuestLog {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_binding_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_binding_snapshot_type.rs new file mode 100644 index 00000000..730e7565 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_binding_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_narrative_origin_type::QuestNarrativeOrigin; +use super::quest_narrative_type_type::QuestNarrativeType; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestNarrativeBindingSnapshot { + pub origin: QuestNarrativeOrigin, + pub narrative_type: QuestNarrativeType, + pub dramatic_need: String, + pub issuer_goal: String, + pub player_hook: String, + pub world_reason: String, + pub followup_hooks: Vec, +} + +impl __sdk::InModule for QuestNarrativeBindingSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_origin_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_origin_type.rs new file mode 100644 index 00000000..34ce63f3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_origin_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestNarrativeOrigin { + AiCompiled, + + FallbackBuilder, +} + +impl __sdk::InModule for QuestNarrativeOrigin { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_type_type.rs new file mode 100644 index 00000000..ac5319cd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_narrative_type_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestNarrativeType { + Bounty, + + Escort, + + Investigation, + + Retrieval, + + Relationship, + + Trial, +} + +impl __sdk::InModule for QuestNarrativeType { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_npc_spar_completed_signal_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_npc_spar_completed_signal_type.rs new file mode 100644 index 00000000..a0f1f8bb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_npc_spar_completed_signal_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestNpcSparCompletedSignal { + pub npc_id: String, +} + +impl __sdk::InModule for QuestNpcSparCompletedSignal { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_npc_talk_completed_signal_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_npc_talk_completed_signal_type.rs new file mode 100644 index 00000000..39042f37 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_npc_talk_completed_signal_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestNpcTalkCompletedSignal { + pub npc_id: String, +} + +impl __sdk::InModule for QuestNpcTalkCompletedSignal { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_objective_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_objective_kind_type.rs new file mode 100644 index 00000000..665faa30 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_objective_kind_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestObjectiveKind { + DefeatHostileNpc, + + InspectTreasure, + + SparWithNpc, + + TalkToNpc, + + ReachScene, + + DeliverItem, +} + +impl __sdk::InModule for QuestObjectiveKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_objective_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_objective_snapshot_type.rs new file mode 100644 index 00000000..38682e68 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_objective_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_objective_kind_type::QuestObjectiveKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestObjectiveSnapshot { + pub kind: QuestObjectiveKind, + pub target_hostile_npc_id: Option, + pub target_npc_id: Option, + pub target_scene_id: Option, + pub target_item_id: Option, + pub required_count: u32, +} + +impl __sdk::InModule for QuestObjectiveSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_progress_signal_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_progress_signal_type.rs new file mode 100644 index 00000000..5764d273 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_progress_signal_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_hostile_npc_defeated_signal_type::QuestHostileNpcDefeatedSignal; +use super::quest_item_delivered_signal_type::QuestItemDeliveredSignal; +use super::quest_npc_spar_completed_signal_type::QuestNpcSparCompletedSignal; +use super::quest_npc_talk_completed_signal_type::QuestNpcTalkCompletedSignal; +use super::quest_scene_reached_signal_type::QuestSceneReachedSignal; +use super::quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub enum QuestProgressSignal { + HostileNpcDefeated(QuestHostileNpcDefeatedSignal), + + TreasureInspected(QuestTreasureInspectedSignal), + + NpcSparCompleted(QuestNpcSparCompletedSignal), + + NpcTalkCompleted(QuestNpcTalkCompletedSignal), + + SceneReached(QuestSceneReachedSignal), + + ItemDelivered(QuestItemDeliveredSignal), +} + +impl __sdk::InModule for QuestProgressSignal { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_record_input_type.rs new file mode 100644 index 00000000..d07da423 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_record_input_type.rs @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_narrative_binding_snapshot_type::QuestNarrativeBindingSnapshot; +use super::quest_reward_snapshot_type::QuestRewardSnapshot; +use super::quest_status_type::QuestStatus; +use super::quest_step_snapshot_type::QuestStepSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestRecordInput { + pub quest_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub issuer_npc_id: String, + pub issuer_npc_name: String, + pub scene_id: Option, + pub chapter_id: Option, + pub act_id: Option, + pub thread_id: Option, + pub contract_id: Option, + pub title: String, + pub description: String, + pub summary: String, + pub status: QuestStatus, + pub completion_notified: bool, + pub reward: QuestRewardSnapshot, + pub reward_text: String, + pub narrative_binding: QuestNarrativeBindingSnapshot, + pub steps: Vec, + pub active_step_id: Option, + pub visible_stage: u32, + pub hidden_flags: Vec, + pub discovered_fact_ids: Vec, + pub related_carrier_ids: Vec, + pub consequence_ids: Vec, + pub created_at_micros: i64, +} + +impl __sdk::InModule for QuestRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_record_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_record_table.rs new file mode 100644 index 00000000..07e6e918 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_record_table.rs @@ -0,0 +1,164 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::quest_narrative_binding_snapshot_type::QuestNarrativeBindingSnapshot; +use super::quest_objective_snapshot_type::QuestObjectiveSnapshot; +use super::quest_record_type::QuestRecord; +use super::quest_reward_snapshot_type::QuestRewardSnapshot; +use super::quest_status_type::QuestStatus; +use super::quest_step_snapshot_type::QuestStepSnapshot; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `quest_record`. +/// +/// Obtain a handle from the [`QuestRecordTableAccess::quest_record`] method on [`super::RemoteTables`], +/// like `ctx.db.quest_record()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.quest_record().on_insert(...)`. +pub struct QuestRecordTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `quest_record`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait QuestRecordTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`QuestRecordTableHandle`], which mediates access to the table `quest_record`. + fn quest_record(&self) -> QuestRecordTableHandle<'_>; +} + +impl QuestRecordTableAccess for super::RemoteTables { + fn quest_record(&self) -> QuestRecordTableHandle<'_> { + QuestRecordTableHandle { + imp: self.imp.get_table::("quest_record"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct QuestRecordInsertCallbackId(__sdk::CallbackId); +pub struct QuestRecordDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for QuestRecordTableHandle<'ctx> { + type Row = QuestRecord; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = QuestRecordInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> QuestRecordInsertCallbackId { + QuestRecordInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: QuestRecordInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = QuestRecordDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> QuestRecordDeleteCallbackId { + QuestRecordDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: QuestRecordDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct QuestRecordUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for QuestRecordTableHandle<'ctx> { + type UpdateCallbackId = QuestRecordUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> QuestRecordUpdateCallbackId { + QuestRecordUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: QuestRecordUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `quest_id` unique index on the table `quest_record`, +/// which allows point queries on the field of the same name +/// via the [`QuestRecordQuestIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.quest_record().quest_id().find(...)`. +pub struct QuestRecordQuestIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> QuestRecordTableHandle<'ctx> { + /// Get a handle on the `quest_id` unique index on the table `quest_record`. + pub fn quest_id(&self) -> QuestRecordQuestIdUnique<'ctx> { + QuestRecordQuestIdUnique { + imp: self.imp.get_unique_constraint::("quest_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> QuestRecordQuestIdUnique<'ctx> { + /// Find the subscribed row whose `quest_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("quest_record"); + _table.add_unique_constraint::("quest_id", |row| &row.quest_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `QuestRecord`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait quest_recordQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `QuestRecord`. + fn quest_record(&self) -> __sdk::__query_builder::Table; +} + +impl quest_recordQueryTableAccess for __sdk::QueryTableAccessor { + fn quest_record(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("quest_record") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_record_type.rs new file mode 100644 index 00000000..4243e91c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_record_type.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_narrative_binding_snapshot_type::QuestNarrativeBindingSnapshot; +use super::quest_objective_snapshot_type::QuestObjectiveSnapshot; +use super::quest_reward_snapshot_type::QuestRewardSnapshot; +use super::quest_status_type::QuestStatus; +use super::quest_step_snapshot_type::QuestStepSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestRecord { + pub quest_id: String, + pub runtime_session_id: String, + pub story_session_id: Option, + pub actor_user_id: String, + pub issuer_npc_id: String, + pub issuer_npc_name: String, + pub scene_id: Option, + pub chapter_id: Option, + pub act_id: Option, + pub thread_id: Option, + pub contract_id: Option, + pub title: String, + pub description: String, + pub summary: String, + pub objective: QuestObjectiveSnapshot, + pub progress: u32, + pub status: QuestStatus, + pub completion_notified: bool, + pub reward: QuestRewardSnapshot, + pub reward_text: String, + pub narrative_binding: QuestNarrativeBindingSnapshot, + pub steps: Vec, + pub active_step_id: Option, + pub visible_stage: u32, + pub hidden_flags: Vec, + pub discovered_fact_ids: Vec, + pub related_carrier_ids: Vec, + pub consequence_ids: Vec, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, + pub completed_at: Option<__sdk::Timestamp>, + pub turned_in_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for QuestRecord { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `QuestRecord`. +/// +/// Provides typed access to columns for query building. +pub struct QuestRecordCols { + pub quest_id: __sdk::__query_builder::Col, + pub runtime_session_id: __sdk::__query_builder::Col, + pub story_session_id: __sdk::__query_builder::Col>, + pub actor_user_id: __sdk::__query_builder::Col, + pub issuer_npc_id: __sdk::__query_builder::Col, + pub issuer_npc_name: __sdk::__query_builder::Col, + pub scene_id: __sdk::__query_builder::Col>, + pub chapter_id: __sdk::__query_builder::Col>, + pub act_id: __sdk::__query_builder::Col>, + pub thread_id: __sdk::__query_builder::Col>, + pub contract_id: __sdk::__query_builder::Col>, + pub title: __sdk::__query_builder::Col, + pub description: __sdk::__query_builder::Col, + pub summary: __sdk::__query_builder::Col, + pub objective: __sdk::__query_builder::Col, + pub progress: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub completion_notified: __sdk::__query_builder::Col, + pub reward: __sdk::__query_builder::Col, + pub reward_text: __sdk::__query_builder::Col, + pub narrative_binding: __sdk::__query_builder::Col, + pub steps: __sdk::__query_builder::Col>, + pub active_step_id: __sdk::__query_builder::Col>, + pub visible_stage: __sdk::__query_builder::Col, + pub hidden_flags: __sdk::__query_builder::Col>, + pub discovered_fact_ids: __sdk::__query_builder::Col>, + pub related_carrier_ids: __sdk::__query_builder::Col>, + pub consequence_ids: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, + pub completed_at: __sdk::__query_builder::Col>, + pub turned_in_at: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for QuestRecord { + type Cols = QuestRecordCols; + fn cols(table_name: &'static str) -> Self::Cols { + QuestRecordCols { + quest_id: __sdk::__query_builder::Col::new(table_name, "quest_id"), + runtime_session_id: __sdk::__query_builder::Col::new(table_name, "runtime_session_id"), + story_session_id: __sdk::__query_builder::Col::new(table_name, "story_session_id"), + actor_user_id: __sdk::__query_builder::Col::new(table_name, "actor_user_id"), + issuer_npc_id: __sdk::__query_builder::Col::new(table_name, "issuer_npc_id"), + issuer_npc_name: __sdk::__query_builder::Col::new(table_name, "issuer_npc_name"), + scene_id: __sdk::__query_builder::Col::new(table_name, "scene_id"), + chapter_id: __sdk::__query_builder::Col::new(table_name, "chapter_id"), + act_id: __sdk::__query_builder::Col::new(table_name, "act_id"), + thread_id: __sdk::__query_builder::Col::new(table_name, "thread_id"), + contract_id: __sdk::__query_builder::Col::new(table_name, "contract_id"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + description: __sdk::__query_builder::Col::new(table_name, "description"), + summary: __sdk::__query_builder::Col::new(table_name, "summary"), + objective: __sdk::__query_builder::Col::new(table_name, "objective"), + progress: __sdk::__query_builder::Col::new(table_name, "progress"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + completion_notified: __sdk::__query_builder::Col::new( + table_name, + "completion_notified", + ), + reward: __sdk::__query_builder::Col::new(table_name, "reward"), + reward_text: __sdk::__query_builder::Col::new(table_name, "reward_text"), + narrative_binding: __sdk::__query_builder::Col::new(table_name, "narrative_binding"), + steps: __sdk::__query_builder::Col::new(table_name, "steps"), + active_step_id: __sdk::__query_builder::Col::new(table_name, "active_step_id"), + visible_stage: __sdk::__query_builder::Col::new(table_name, "visible_stage"), + hidden_flags: __sdk::__query_builder::Col::new(table_name, "hidden_flags"), + discovered_fact_ids: __sdk::__query_builder::Col::new( + table_name, + "discovered_fact_ids", + ), + related_carrier_ids: __sdk::__query_builder::Col::new( + table_name, + "related_carrier_ids", + ), + consequence_ids: __sdk::__query_builder::Col::new(table_name, "consequence_ids"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"), + turned_in_at: __sdk::__query_builder::Col::new(table_name, "turned_in_at"), + } + } +} + +/// Indexed column accessor struct for the table `QuestRecord`. +/// +/// Provides typed access to indexed columns for query building. +pub struct QuestRecordIxCols { + pub actor_user_id: __sdk::__query_builder::IxCol, + pub issuer_npc_id: __sdk::__query_builder::IxCol, + pub quest_id: __sdk::__query_builder::IxCol, + pub runtime_session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for QuestRecord { + type IxCols = QuestRecordIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + QuestRecordIxCols { + actor_user_id: __sdk::__query_builder::IxCol::new(table_name, "actor_user_id"), + issuer_npc_id: __sdk::__query_builder::IxCol::new(table_name, "issuer_npc_id"), + quest_id: __sdk::__query_builder::IxCol::new(table_name, "quest_id"), + runtime_session_id: __sdk::__query_builder::IxCol::new( + table_name, + "runtime_session_id", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for QuestRecord {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_equipment_slot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_equipment_slot_type.rs new file mode 100644 index 00000000..43a942c4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_equipment_slot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestRewardEquipmentSlot { + Weapon, + + Armor, + + Relic, +} + +impl __sdk::InModule for QuestRewardEquipmentSlot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_intel_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_intel_type.rs new file mode 100644 index 00000000..9938686a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_intel_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestRewardIntel { + pub rumor_text: String, + pub unlocked_scene_id: Option, +} + +impl __sdk::InModule for QuestRewardIntel { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_rarity_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_rarity_type.rs new file mode 100644 index 00000000..7fc4f7ec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_rarity_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestRewardItemRarity { + Common, + + Uncommon, + + Rare, + + Epic, + + Legendary, +} + +impl __sdk::InModule for QuestRewardItemRarity { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_type.rs new file mode 100644 index 00000000..a62f9d93 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_item_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_reward_equipment_slot_type::QuestRewardEquipmentSlot; +use super::quest_reward_item_rarity_type::QuestRewardItemRarity; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestRewardItem { + pub item_id: String, + pub category: String, + pub name: String, + pub description: Option, + pub quantity: u32, + pub rarity: QuestRewardItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, +} + +impl __sdk::InModule for QuestRewardItem { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_snapshot_type.rs new file mode 100644 index 00000000..995e84ad --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_reward_snapshot_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_reward_intel_type::QuestRewardIntel; +use super::quest_reward_item_type::QuestRewardItem; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestRewardSnapshot { + pub affinity_bonus: i32, + pub currency: i64, + pub experience: Option, + pub items: Vec, + pub intel: Option, + pub story_hint: Option, +} + +impl __sdk::InModule for QuestRewardSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_scene_reached_signal_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_scene_reached_signal_type.rs new file mode 100644 index 00000000..723d9325 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_scene_reached_signal_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestSceneReachedSignal { + pub scene_id: String, +} + +impl __sdk::InModule for QuestSceneReachedSignal { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_signal_apply_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_signal_apply_input_type.rs new file mode 100644 index 00000000..d93ff928 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_signal_apply_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_progress_signal_type::QuestProgressSignal; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestSignalApplyInput { + pub quest_id: String, + pub signal: QuestProgressSignal, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for QuestSignalApplyInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_signal_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_signal_kind_type.rs new file mode 100644 index 00000000..04ea6661 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_signal_kind_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestSignalKind { + HostileNpcDefeated, + + TreasureInspected, + + NpcSparCompleted, + + NpcTalkCompleted, + + SceneReached, + + ItemDelivered, +} + +impl __sdk::InModule for QuestSignalKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_status_type.rs new file mode 100644 index 00000000..f5defbb7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_status_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum QuestStatus { + Active, + + ReadyToTurnIn, + + Completed, + + TurnedIn, + + Failed, + + Expired, +} + +impl __sdk::InModule for QuestStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_step_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_step_snapshot_type.rs new file mode 100644 index 00000000..0f27cb9e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_step_snapshot_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_objective_kind_type::QuestObjectiveKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestStepSnapshot { + pub step_id: String, + pub kind: QuestObjectiveKind, + pub target_hostile_npc_id: Option, + pub target_npc_id: Option, + pub target_scene_id: Option, + pub target_item_id: Option, + pub required_count: u32, + pub progress: u32, + pub title: String, + pub reveal_text: String, + pub complete_text: String, +} + +impl __sdk::InModule for QuestStepSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_treasure_inspected_signal_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_treasure_inspected_signal_type.rs new file mode 100644 index 00000000..51caed77 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_treasure_inspected_signal_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestTreasureInspectedSignal { + pub scene_id: Option, +} + +impl __sdk::InModule for QuestTreasureInspectedSignal { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/quest_turn_in_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/quest_turn_in_input_type.rs new file mode 100644 index 00000000..4f48a44a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/quest_turn_in_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct QuestTurnInInput { + pub quest_id: String, + pub turned_in_at_micros: i64, +} + +impl __sdk::InModule for QuestTurnInInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs new file mode 100644 index 00000000..ac8aa07d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::resolve_combat_action_input_type::ResolveCombatActionInput; +use super::resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ResolveCombatActionAndReturnArgs { + pub input: ResolveCombatActionInput, +} + +impl __sdk::InModule for ResolveCombatActionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `resolve_combat_action_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait resolve_combat_action_and_return { + fn resolve_combat_action_and_return(&self, input: ResolveCombatActionInput) { + self.resolve_combat_action_and_return_then(input, |_, _| {}); + } + + fn resolve_combat_action_and_return_then( + &self, + input: ResolveCombatActionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl resolve_combat_action_and_return for super::RemoteProcedures { + fn resolve_combat_action_and_return_then( + &self, + input: ResolveCombatActionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ResolveCombatActionProcedureResult>( + "resolve_combat_action_and_return", + ResolveCombatActionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_input_type.rs new file mode 100644 index 00000000..7dea1c21 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ResolveCombatActionInput { + pub battle_state_id: String, + pub function_id: String, + pub action_text: String, + pub base_damage: i32, + pub mana_cost: i32, + pub heal: i32, + pub mana_restore: i32, + pub counter_multiplier_basis_points: u32, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for ResolveCombatActionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_procedure_result_type.rs new file mode 100644 index 00000000..c7a13f85 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::resolve_combat_action_result_type::ResolveCombatActionResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ResolveCombatActionProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + +impl __sdk::InModule for ResolveCombatActionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_reducer.rs new file mode 100644 index 00000000..41340e2f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::resolve_combat_action_input_type::ResolveCombatActionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ResolveCombatActionArgs { + pub input: ResolveCombatActionInput, +} + +impl From for super::Reducer { + fn from(args: ResolveCombatActionArgs) -> Self { + Self::ResolveCombatAction { input: args.input } + } +} + +impl __sdk::InModule for ResolveCombatActionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `resolve_combat_action`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait resolve_combat_action { + /// Request that the remote module invoke the reducer `resolve_combat_action` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`resolve_combat_action:resolve_combat_action_then`] to run a callback after the reducer completes. + fn resolve_combat_action(&self, input: ResolveCombatActionInput) -> __sdk::Result<()> { + self.resolve_combat_action_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `resolve_combat_action` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn resolve_combat_action_then( + &self, + input: ResolveCombatActionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl resolve_combat_action for super::RemoteReducers { + fn resolve_combat_action_then( + &self, + input: ResolveCombatActionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ResolveCombatActionArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_result_type.rs new file mode 100644 index 00000000..b47e088c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::battle_state_snapshot_type::BattleStateSnapshot; +use super::combat_outcome_type::CombatOutcome; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ResolveCombatActionResult { + pub snapshot: BattleStateSnapshot, + pub damage_dealt: i32, + pub damage_taken: i32, + pub outcome: CombatOutcome, +} + +impl __sdk::InModule for ResolveCombatActionResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs new file mode 100644 index 00000000..ff4cca29 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; +use super::resolve_npc_battle_interaction_input_type::ResolveNpcBattleInteractionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ResolveNpcBattleInteractionAndReturnArgs { + pub input: ResolveNpcBattleInteractionInput, +} + +impl __sdk::InModule for ResolveNpcBattleInteractionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `resolve_npc_battle_interaction_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait resolve_npc_battle_interaction_and_return { + fn resolve_npc_battle_interaction_and_return(&self, input: ResolveNpcBattleInteractionInput) { + self.resolve_npc_battle_interaction_and_return_then(input, |_, _| {}); + } + + fn resolve_npc_battle_interaction_and_return_then( + &self, + input: ResolveNpcBattleInteractionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl resolve_npc_battle_interaction_and_return for super::RemoteProcedures { + fn resolve_npc_battle_interaction_and_return_then( + &self, + input: ResolveNpcBattleInteractionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, NpcBattleInteractionProcedureResult>( + "resolve_npc_battle_interaction_and_return", + ResolveNpcBattleInteractionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_input_type.rs new file mode 100644 index 00000000..e8eba7a6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_input_type.rs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::resolve_npc_interaction_input_type::ResolveNpcInteractionInput; +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +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, +} + +impl __sdk::InModule for ResolveNpcBattleInteractionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs new file mode 100644 index 00000000..aaba3d9c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_interaction_procedure_result_type::NpcInteractionProcedureResult; +use super::resolve_npc_interaction_input_type::ResolveNpcInteractionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ResolveNpcInteractionAndReturnArgs { + pub input: ResolveNpcInteractionInput, +} + +impl __sdk::InModule for ResolveNpcInteractionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `resolve_npc_interaction_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait resolve_npc_interaction_and_return { + fn resolve_npc_interaction_and_return(&self, input: ResolveNpcInteractionInput) { + self.resolve_npc_interaction_and_return_then(input, |_, _| {}); + } + + fn resolve_npc_interaction_and_return_then( + &self, + input: ResolveNpcInteractionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl resolve_npc_interaction_and_return for super::RemoteProcedures { + fn resolve_npc_interaction_and_return_then( + &self, + input: ResolveNpcInteractionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, NpcInteractionProcedureResult>( + "resolve_npc_interaction_and_return", + ResolveNpcInteractionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_input_type.rs new file mode 100644 index 00000000..9a4439ce --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ResolveNpcInteractionInput { + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub interaction_function_id: String, + pub release_npc_id: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for ResolveNpcInteractionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_reducer.rs new file mode 100644 index 00000000..52d213b5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::resolve_npc_interaction_input_type::ResolveNpcInteractionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ResolveNpcInteractionArgs { + pub input: ResolveNpcInteractionInput, +} + +impl From for super::Reducer { + fn from(args: ResolveNpcInteractionArgs) -> Self { + Self::ResolveNpcInteraction { input: args.input } + } +} + +impl __sdk::InModule for ResolveNpcInteractionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `resolve_npc_interaction`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait resolve_npc_interaction { + /// Request that the remote module invoke the reducer `resolve_npc_interaction` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`resolve_npc_interaction:resolve_npc_interaction_then`] to run a callback after the reducer completes. + fn resolve_npc_interaction(&self, input: ResolveNpcInteractionInput) -> __sdk::Result<()> { + self.resolve_npc_interaction_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `resolve_npc_interaction` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn resolve_npc_interaction_then( + &self, + input: ResolveNpcInteractionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl resolve_npc_interaction for super::RemoteReducers { + fn resolve_npc_interaction_then( + &self, + input: ResolveNpcInteractionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ResolveNpcInteractionArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs new file mode 100644 index 00000000..c1425649 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_state_procedure_result_type::NpcStateProcedureResult; +use super::resolve_npc_social_action_input_type::ResolveNpcSocialActionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ResolveNpcSocialActionAndReturnArgs { + pub input: ResolveNpcSocialActionInput, +} + +impl __sdk::InModule for ResolveNpcSocialActionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `resolve_npc_social_action_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait resolve_npc_social_action_and_return { + fn resolve_npc_social_action_and_return(&self, input: ResolveNpcSocialActionInput) { + self.resolve_npc_social_action_and_return_then(input, |_, _| {}); + } + + fn resolve_npc_social_action_and_return_then( + &self, + input: ResolveNpcSocialActionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl resolve_npc_social_action_and_return for super::RemoteProcedures { + fn resolve_npc_social_action_and_return_then( + &self, + input: ResolveNpcSocialActionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, NpcStateProcedureResult>( + "resolve_npc_social_action_and_return", + ResolveNpcSocialActionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_input_type.rs new file mode 100644 index 00000000..29487297 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_social_action_kind_type::NpcSocialActionKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ResolveNpcSocialActionInput { + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub action_kind: NpcSocialActionKind, + pub affinity_gain_override: Option, + pub note: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for ResolveNpcSocialActionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_reducer.rs new file mode 100644 index 00000000..28e5ce36 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::resolve_npc_social_action_input_type::ResolveNpcSocialActionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ResolveNpcSocialActionArgs { + pub input: ResolveNpcSocialActionInput, +} + +impl From for super::Reducer { + fn from(args: ResolveNpcSocialActionArgs) -> Self { + Self::ResolveNpcSocialAction { input: args.input } + } +} + +impl __sdk::InModule for ResolveNpcSocialActionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `resolve_npc_social_action`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait resolve_npc_social_action { + /// Request that the remote module invoke the reducer `resolve_npc_social_action` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`resolve_npc_social_action:resolve_npc_social_action_then`] to run a callback after the reducer completes. + fn resolve_npc_social_action(&self, input: ResolveNpcSocialActionInput) -> __sdk::Result<()> { + self.resolve_npc_social_action_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `resolve_npc_social_action` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn resolve_npc_social_action_then( + &self, + input: ResolveNpcSocialActionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl resolve_npc_social_action for super::RemoteReducers { + fn resolve_npc_social_action_then( + &self, + input: ResolveNpcSocialActionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ResolveNpcSocialActionArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs new file mode 100644 index 00000000..a224c122 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::treasure_record_procedure_result_type::TreasureRecordProcedureResult; +use super::treasure_resolve_input_type::TreasureResolveInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ResolveTreasureInteractionAndReturnArgs { + pub input: TreasureResolveInput, +} + +impl __sdk::InModule for ResolveTreasureInteractionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `resolve_treasure_interaction_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait resolve_treasure_interaction_and_return { + fn resolve_treasure_interaction_and_return(&self, input: TreasureResolveInput) { + self.resolve_treasure_interaction_and_return_then(input, |_, _| {}); + } + + fn resolve_treasure_interaction_and_return_then( + &self, + input: TreasureResolveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl resolve_treasure_interaction_and_return for super::RemoteProcedures { + fn resolve_treasure_interaction_and_return_then( + &self, + input: TreasureResolveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, TreasureRecordProcedureResult>( + "resolve_treasure_interaction_and_return", + ResolveTreasureInteractionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_reducer.rs new file mode 100644 index 00000000..942b377a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::treasure_resolve_input_type::TreasureResolveInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ResolveTreasureInteractionArgs { + pub input: TreasureResolveInput, +} + +impl From for super::Reducer { + fn from(args: ResolveTreasureInteractionArgs) -> Self { + Self::ResolveTreasureInteraction { input: args.input } + } +} + +impl __sdk::InModule for ResolveTreasureInteractionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `resolve_treasure_interaction`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait resolve_treasure_interaction { + /// Request that the remote module invoke the reducer `resolve_treasure_interaction` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`resolve_treasure_interaction:resolve_treasure_interaction_then`] to run a callback after the reducer completes. + fn resolve_treasure_interaction(&self, input: TreasureResolveInput) -> __sdk::Result<()> { + self.resolve_treasure_interaction_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `resolve_treasure_interaction` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn resolve_treasure_interaction_then( + &self, + input: TreasureResolveInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl resolve_treasure_interaction for super::RemoteReducers { + fn resolve_treasure_interaction_then( + &self, + input: TreasureResolveInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ResolveTreasureInteractionArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_kind_type.rs new file mode 100644 index 00000000..e082dc36 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_kind_type.rs @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RpgAgentDraftCardKind { + World, + + Camp, + + Faction, + + Character, + + Landmark, + + Thread, + + Chapter, + + SceneChapter, + + Carrier, + + SidequestSeed, +} + +impl __sdk::InModule for RpgAgentDraftCardKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_status_type.rs new file mode 100644 index 00000000..391625f5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_draft_card_status_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RpgAgentDraftCardStatus { + Suggested, + + Confirmed, + + Locked, + + Warning, +} + +impl __sdk::InModule for RpgAgentDraftCardStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_kind_type.rs new file mode 100644 index 00000000..fc54a7b1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_kind_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RpgAgentMessageKind { + Chat, + + Clarification, + + Summary, + + Checkpoint, + + Warning, + + ActionResult, +} + +impl __sdk::InModule for RpgAgentMessageKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_role_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_role_type.rs new file mode 100644 index 00000000..67e383f2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_message_role_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RpgAgentMessageRole { + User, + + Assistant, + + System, +} + +impl __sdk::InModule for RpgAgentMessageRole { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_status_type.rs new file mode 100644 index 00000000..555c0439 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_status_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RpgAgentOperationStatus { + Queued, + + Running, + + Completed, + + Failed, +} + +impl __sdk::InModule for RpgAgentOperationStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_type_type.rs new file mode 100644 index 00000000..c3e7b8ac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_operation_type_type.rs @@ -0,0 +1,40 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RpgAgentOperationType { + ProcessMessage, + + DraftFoundation, + + UpdateDraftCard, + + SyncResultProfile, + + GenerateCharacters, + + GenerateLandmarks, + + GenerateRoleAssets, + + SyncRoleAssets, + + GenerateSceneAssets, + + SyncSceneAssets, + + ExpandLongTail, + + PublishWorld, + + RevertCheckpoint, +} + +impl __sdk::InModule for RpgAgentOperationType { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_stage_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_stage_type.rs new file mode 100644 index 00000000..1a94e20f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rpg_agent_stage_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RpgAgentStage { + CollectingIntent, + + Clarifying, + + FoundationReview, + + ObjectRefining, + + VisualRefining, + + LongTailReview, + + ReadyToPublish, + + Published, + + Error, +} + +impl __sdk::InModule for RpgAgentStage { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_clear_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_clear_input_type.rs new file mode 100644 index 00000000..e5bc78f0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_clear_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeBrowseHistoryClearInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeBrowseHistoryClearInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_list_input_type.rs new file mode 100644 index 00000000..cdc31641 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeBrowseHistoryListInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeBrowseHistoryListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_procedure_result_type.rs new file mode 100644 index 00000000..3721358f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_browse_history_snapshot_type::RuntimeBrowseHistorySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeBrowseHistoryProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeBrowseHistoryProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_snapshot_type.rs new file mode 100644 index 00000000..9222eaed --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_snapshot_type.rs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_browse_history_theme_mode_type::RuntimeBrowseHistoryThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeBrowseHistorySnapshot { + pub browse_history_id: String, + pub user_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: RuntimeBrowseHistoryThemeMode, + pub author_display_name: String, + pub visited_at_micros: i64, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeBrowseHistorySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_sync_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_sync_input_type.rs new file mode 100644 index 00000000..0996e66e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_sync_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_browse_history_write_input_type::RuntimeBrowseHistoryWriteInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeBrowseHistorySyncInput { + pub user_id: String, + pub entries: Vec, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeBrowseHistorySyncInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_theme_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_theme_mode_type.rs new file mode 100644 index 00000000..fc8a1b11 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_theme_mode_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeBrowseHistoryThemeMode { + Martial, + + Arcane, + + Machina, + + Tide, + + Rift, + + Mythic, +} + +impl __sdk::InModule for RuntimeBrowseHistoryThemeMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_write_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_write_input_type.rs new file mode 100644 index 00000000..9a54bd81 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_browse_history_write_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeBrowseHistoryWriteInput { + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: Option, + pub summary_text: Option, + pub cover_image_src: Option, + pub theme_mode: Option, + pub author_display_name: Option, + pub visited_at: Option, +} + +impl __sdk::InModule for RuntimeBrowseHistoryWriteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_procedure_result_type.rs new file mode 100644 index 00000000..bf0e3103 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_inventory_state_snapshot_type::RuntimeInventoryStateSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeInventoryStateProcedureResult { + pub ok: bool, + pub snapshot: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeInventoryStateProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_query_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_query_input_type.rs new file mode 100644 index 00000000..e6b02ed3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_query_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeInventoryStateQueryInput { + pub runtime_session_id: String, + pub actor_user_id: String, +} + +impl __sdk::InModule for RuntimeInventoryStateQueryInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_snapshot_type.rs new file mode 100644 index 00000000..624e66c6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_inventory_state_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::inventory_slot_snapshot_type::InventorySlotSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeInventoryStateSnapshot { + pub runtime_session_id: String, + pub actor_user_id: String, + pub backpack_items: Vec, + pub equipment_items: Vec, +} + +impl __sdk::InModule for RuntimeInventoryStateSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_equipment_slot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_equipment_slot_type.rs new file mode 100644 index 00000000..b539d4f3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_equipment_slot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeItemEquipmentSlot { + Weapon, + + Armor, + + Relic, +} + +impl __sdk::InModule for RuntimeItemEquipmentSlot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_rarity_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_rarity_type.rs new file mode 100644 index 00000000..dc4250ab --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_rarity_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeItemRewardItemRarity { + Common, + + Uncommon, + + Rare, + + Epic, + + Legendary, +} + +impl __sdk::InModule for RuntimeItemRewardItemRarity { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_snapshot_type.rs new file mode 100644 index 00000000..4df1f248 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_item_reward_item_snapshot_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_item_equipment_slot_type::RuntimeItemEquipmentSlot; +use super::runtime_item_reward_item_rarity_type::RuntimeItemRewardItemRarity; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeItemRewardItemSnapshot { + pub item_id: String, + pub category: String, + pub item_name: String, + pub description: Option, + pub quantity: u32, + pub rarity: RuntimeItemRewardItemRarity, + pub tags: Vec, + pub stackable: bool, + pub stack_key: String, + pub equipment_slot_id: Option, +} + +impl __sdk::InModule for RuntimeItemRewardItemSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_platform_theme_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_platform_theme_type.rs new file mode 100644 index 00000000..cbb36c5d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_platform_theme_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimePlatformTheme { + Light, + + Dark, +} + +impl __sdk::InModule for RuntimePlatformTheme { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_get_input_type.rs new file mode 100644 index 00000000..47af88ec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileDashboardGetInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeProfileDashboardGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_procedure_result_type.rs new file mode 100644 index 00000000..fa80a719 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileDashboardProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileDashboardProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs new file mode 100644 index 00000000..a3e97b05 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileDashboardSnapshot { + pub user_id: String, + pub wallet_balance: u64, + pub total_play_time_ms: u64, + pub played_world_count: u32, + pub updated_at_micros: Option, +} + +impl __sdk::InModule for RuntimeProfileDashboardSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_get_input_type.rs new file mode 100644 index 00000000..825a6707 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfilePlayStatsGetInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeProfilePlayStatsGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_procedure_result_type.rs new file mode 100644 index 00000000..5572f438 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_play_stats_snapshot_type::RuntimeProfilePlayStatsSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfilePlayStatsProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfilePlayStatsProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_snapshot_type.rs new file mode 100644 index 00000000..472a99c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_play_stats_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_played_world_snapshot_type::RuntimeProfilePlayedWorldSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfilePlayStatsSnapshot { + pub user_id: String, + pub total_play_time_ms: u64, + pub played_works: Vec, + pub updated_at_micros: Option, +} + +impl __sdk::InModule for RuntimeProfilePlayStatsSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_played_world_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_played_world_snapshot_type.rs new file mode 100644 index 00000000..fa6c1d2c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_played_world_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfilePlayedWorldSnapshot { + pub played_world_id: String, + pub user_id: String, + pub world_key: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub world_type: Option, + pub world_title: String, + pub world_subtitle: String, + pub first_played_at_micros: i64, + pub last_played_at_micros: i64, + pub last_observed_play_time_ms: u64, +} + +impl __sdk::InModule for RuntimeProfilePlayedWorldSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs new file mode 100644 index 00000000..8513884e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletLedgerEntrySnapshot { + pub wallet_ledger_id: String, + pub user_id: String, + pub amount_delta: i64, + pub balance_after: u64, + pub source_type: RuntimeProfileWalletLedgerSourceType, + pub created_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileWalletLedgerEntrySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_list_input_type.rs new file mode 100644 index 00000000..e8a1c111 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletLedgerListInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeProfileWalletLedgerListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_procedure_result_type.rs new file mode 100644 index 00000000..1c0a2b05 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletLedgerProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileWalletLedgerProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs new file mode 100644 index 00000000..2b98f5ef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileWalletLedgerSourceType { + SnapshotSync, +} + +impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_get_input_type.rs new file mode 100644 index 00000000..1b83318a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeSettingGetInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeSettingGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_procedure_result_type.rs new file mode 100644 index 00000000..792e33d4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_setting_snapshot_type::RuntimeSettingSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeSettingProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeSettingProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_snapshot_type.rs new file mode 100644 index 00000000..5d92990f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_snapshot_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_platform_theme_type::RuntimePlatformTheme; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeSettingSnapshot { + pub user_id: String, + pub music_volume: f32, + pub platform_theme: RuntimePlatformTheme, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeSettingSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_table.rs new file mode 100644 index 00000000..dc1ed5bb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_table.rs @@ -0,0 +1,160 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::runtime_platform_theme_type::RuntimePlatformTheme; +use super::runtime_setting_type::RuntimeSetting; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `runtime_setting`. +/// +/// Obtain a handle from the [`RuntimeSettingTableAccess::runtime_setting`] method on [`super::RemoteTables`], +/// like `ctx.db.runtime_setting()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.runtime_setting().on_insert(...)`. +pub struct RuntimeSettingTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `runtime_setting`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait RuntimeSettingTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`RuntimeSettingTableHandle`], which mediates access to the table `runtime_setting`. + fn runtime_setting(&self) -> RuntimeSettingTableHandle<'_>; +} + +impl RuntimeSettingTableAccess for super::RemoteTables { + fn runtime_setting(&self) -> RuntimeSettingTableHandle<'_> { + RuntimeSettingTableHandle { + imp: self.imp.get_table::("runtime_setting"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct RuntimeSettingInsertCallbackId(__sdk::CallbackId); +pub struct RuntimeSettingDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for RuntimeSettingTableHandle<'ctx> { + type Row = RuntimeSetting; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = RuntimeSettingInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> RuntimeSettingInsertCallbackId { + RuntimeSettingInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: RuntimeSettingInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = RuntimeSettingDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> RuntimeSettingDeleteCallbackId { + RuntimeSettingDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: RuntimeSettingDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct RuntimeSettingUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for RuntimeSettingTableHandle<'ctx> { + type UpdateCallbackId = RuntimeSettingUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> RuntimeSettingUpdateCallbackId { + RuntimeSettingUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: RuntimeSettingUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `user_id` unique index on the table `runtime_setting`, +/// which allows point queries on the field of the same name +/// via the [`RuntimeSettingUserIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.runtime_setting().user_id().find(...)`. +pub struct RuntimeSettingUserIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> RuntimeSettingTableHandle<'ctx> { + /// Get a handle on the `user_id` unique index on the table `runtime_setting`. + pub fn user_id(&self) -> RuntimeSettingUserIdUnique<'ctx> { + RuntimeSettingUserIdUnique { + imp: self.imp.get_unique_constraint::("user_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> RuntimeSettingUserIdUnique<'ctx> { + /// Find the subscribed row whose `user_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("runtime_setting"); + _table.add_unique_constraint::("user_id", |row| &row.user_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `RuntimeSetting`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait runtime_settingQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `RuntimeSetting`. + fn runtime_setting(&self) -> __sdk::__query_builder::Table; +} + +impl runtime_settingQueryTableAccess for __sdk::QueryTableAccessor { + fn runtime_setting(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("runtime_setting") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_type.rs new file mode 100644 index 00000000..5a7b61b0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_type.rs @@ -0,0 +1,63 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_platform_theme_type::RuntimePlatformTheme; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeSetting { + pub user_id: String, + pub music_volume: f32, + pub platform_theme: RuntimePlatformTheme, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for RuntimeSetting { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `RuntimeSetting`. +/// +/// Provides typed access to columns for query building. +pub struct RuntimeSettingCols { + pub user_id: __sdk::__query_builder::Col, + pub music_volume: __sdk::__query_builder::Col, + pub platform_theme: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for RuntimeSetting { + type Cols = RuntimeSettingCols; + fn cols(table_name: &'static str) -> Self::Cols { + RuntimeSettingCols { + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + music_volume: __sdk::__query_builder::Col::new(table_name, "music_volume"), + platform_theme: __sdk::__query_builder::Col::new(table_name, "platform_theme"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `RuntimeSetting`. +/// +/// Provides typed access to indexed columns for query building. +pub struct RuntimeSettingIxCols { + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for RuntimeSetting { + type IxCols = RuntimeSettingIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + RuntimeSettingIxCols { + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for RuntimeSetting {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_upsert_input_type.rs new file mode 100644 index 00000000..7091bc8d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_setting_upsert_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_platform_theme_type::RuntimePlatformTheme; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeSettingUpsertInput { + pub user_id: String, + pub music_volume: f32, + pub platform_theme: RuntimePlatformTheme, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeSettingUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs new file mode 100644 index 00000000..5809736b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_start_input_type::AiTaskStartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct StartAiTaskArgs { + pub input: AiTaskStartInput, +} + +impl From for super::Reducer { + fn from(args: StartAiTaskArgs) -> Self { + Self::StartAiTask { input: args.input } + } +} + +impl __sdk::InModule for StartAiTaskArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `start_ai_task`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait start_ai_task { + /// Request that the remote module invoke the reducer `start_ai_task` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`start_ai_task:start_ai_task_then`] to run a callback after the reducer completes. + fn start_ai_task(&self, input: AiTaskStartInput) -> __sdk::Result<()> { + self.start_ai_task_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `start_ai_task` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn start_ai_task_then( + &self, + input: AiTaskStartInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl start_ai_task for super::RemoteReducers { + fn start_ai_task_then( + &self, + input: AiTaskStartInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(StartAiTaskArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs new file mode 100644 index 00000000..1d7b7582 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::ai_task_stage_start_input_type::AiTaskStageStartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct StartAiTaskStageArgs { + pub input: AiTaskStageStartInput, +} + +impl From for super::Reducer { + fn from(args: StartAiTaskStageArgs) -> Self { + Self::StartAiTaskStage { input: args.input } + } +} + +impl __sdk::InModule for StartAiTaskStageArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `start_ai_task_stage`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait start_ai_task_stage { + /// Request that the remote module invoke the reducer `start_ai_task_stage` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`start_ai_task_stage:start_ai_task_stage_then`] to run a callback after the reducer completes. + fn start_ai_task_stage(&self, input: AiTaskStageStartInput) -> __sdk::Result<()> { + self.start_ai_task_stage_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `start_ai_task_stage` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn start_ai_task_stage_then( + &self, + input: AiTaskStageStartInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl start_ai_task_stage for super::RemoteReducers { + fn start_ai_task_stage_then( + &self, + input: AiTaskStageStartInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(StartAiTaskStageArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_continue_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_continue_input_type.rs new file mode 100644 index 00000000..e0b3f730 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_continue_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StoryContinueInput { + pub story_session_id: String, + pub event_id: String, + pub narrative_text: String, + pub choice_function_id: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for StoryContinueInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_event_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_event_kind_type.rs new file mode 100644 index 00000000..29836b4a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_event_kind_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum StoryEventKind { + SessionStarted, + + StoryContinued, +} + +impl __sdk::InModule for StoryEventKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_event_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_event_snapshot_type.rs new file mode 100644 index 00000000..881799d5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_event_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_event_kind_type::StoryEventKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StoryEventSnapshot { + pub event_id: String, + pub story_session_id: String, + pub event_kind: StoryEventKind, + pub narrative_text: String, + pub choice_function_id: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for StoryEventSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_event_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_event_table.rs new file mode 100644 index 00000000..232a554e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_event_table.rs @@ -0,0 +1,160 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::story_event_kind_type::StoryEventKind; +use super::story_event_type::StoryEvent; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `story_event`. +/// +/// Obtain a handle from the [`StoryEventTableAccess::story_event`] method on [`super::RemoteTables`], +/// like `ctx.db.story_event()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.story_event().on_insert(...)`. +pub struct StoryEventTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `story_event`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait StoryEventTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`StoryEventTableHandle`], which mediates access to the table `story_event`. + fn story_event(&self) -> StoryEventTableHandle<'_>; +} + +impl StoryEventTableAccess for super::RemoteTables { + fn story_event(&self) -> StoryEventTableHandle<'_> { + StoryEventTableHandle { + imp: self.imp.get_table::("story_event"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct StoryEventInsertCallbackId(__sdk::CallbackId); +pub struct StoryEventDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for StoryEventTableHandle<'ctx> { + type Row = StoryEvent; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = StoryEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> StoryEventInsertCallbackId { + StoryEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: StoryEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = StoryEventDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> StoryEventDeleteCallbackId { + StoryEventDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: StoryEventDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct StoryEventUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for StoryEventTableHandle<'ctx> { + type UpdateCallbackId = StoryEventUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> StoryEventUpdateCallbackId { + StoryEventUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: StoryEventUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `event_id` unique index on the table `story_event`, +/// which allows point queries on the field of the same name +/// via the [`StoryEventEventIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.story_event().event_id().find(...)`. +pub struct StoryEventEventIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> StoryEventTableHandle<'ctx> { + /// Get a handle on the `event_id` unique index on the table `story_event`. + pub fn event_id(&self) -> StoryEventEventIdUnique<'ctx> { + StoryEventEventIdUnique { + imp: self.imp.get_unique_constraint::("event_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> StoryEventEventIdUnique<'ctx> { + /// Find the subscribed row whose `event_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("story_event"); + _table.add_unique_constraint::("event_id", |row| &row.event_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `StoryEvent`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait story_eventQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `StoryEvent`. + fn story_event(&self) -> __sdk::__query_builder::Table; +} + +impl story_eventQueryTableAccess for __sdk::QueryTableAccessor { + fn story_event(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("story_event") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_event_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_event_type.rs new file mode 100644 index 00000000..4d3bdd9f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_event_type.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_event_kind_type::StoryEventKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StoryEvent { + pub event_id: String, + pub story_session_id: String, + pub event_kind: StoryEventKind, + pub narrative_text: String, + pub choice_function_id: Option, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for StoryEvent { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `StoryEvent`. +/// +/// Provides typed access to columns for query building. +pub struct StoryEventCols { + pub event_id: __sdk::__query_builder::Col, + pub story_session_id: __sdk::__query_builder::Col, + pub event_kind: __sdk::__query_builder::Col, + pub narrative_text: __sdk::__query_builder::Col, + pub choice_function_id: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for StoryEvent { + type Cols = StoryEventCols; + fn cols(table_name: &'static str) -> Self::Cols { + StoryEventCols { + event_id: __sdk::__query_builder::Col::new(table_name, "event_id"), + story_session_id: __sdk::__query_builder::Col::new(table_name, "story_session_id"), + event_kind: __sdk::__query_builder::Col::new(table_name, "event_kind"), + narrative_text: __sdk::__query_builder::Col::new(table_name, "narrative_text"), + choice_function_id: __sdk::__query_builder::Col::new(table_name, "choice_function_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `StoryEvent`. +/// +/// Provides typed access to indexed columns for query building. +pub struct StoryEventIxCols { + pub event_id: __sdk::__query_builder::IxCol, + pub story_session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for StoryEvent { + type IxCols = StoryEventIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + StoryEventIxCols { + event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"), + story_session_id: __sdk::__query_builder::IxCol::new(table_name, "story_session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for StoryEvent {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_input_type.rs new file mode 100644 index 00000000..bebf3267 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StorySessionInput { + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + pub opening_summary: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for StorySessionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_procedure_result_type.rs new file mode 100644 index 00000000..4548d9cc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_event_snapshot_type::StoryEventSnapshot; +use super::story_session_snapshot_type::StorySessionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StorySessionProcedureResult { + pub ok: bool, + pub session: Option, + pub event: Option, + pub error_message: Option, +} + +impl __sdk::InModule for StorySessionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_snapshot_type.rs new file mode 100644 index 00000000..b8d2daf6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_snapshot_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_session_status_type::StorySessionStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StorySessionSnapshot { + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + pub opening_summary: Option, + pub latest_narrative_text: String, + pub latest_choice_function_id: Option, + pub status: StorySessionStatus, + pub version: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for StorySessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_state_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_state_input_type.rs new file mode 100644 index 00000000..d860b7db --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_state_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StorySessionStateInput { + pub story_session_id: String, +} + +impl __sdk::InModule for StorySessionStateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_state_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_state_procedure_result_type.rs new file mode 100644 index 00000000..cf2148d6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_state_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_event_snapshot_type::StoryEventSnapshot; +use super::story_session_snapshot_type::StorySessionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StorySessionStateProcedureResult { + pub ok: bool, + pub session: Option, + pub events: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for StorySessionStateProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_status_type.rs new file mode 100644 index 00000000..f04aae19 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_status_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum StorySessionStatus { + Active, + + Completed, + + Archived, +} + +impl __sdk::InModule for StorySessionStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_table.rs new file mode 100644 index 00000000..142f7fa1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_table.rs @@ -0,0 +1,160 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::story_session_status_type::StorySessionStatus; +use super::story_session_type::StorySession; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `story_session`. +/// +/// Obtain a handle from the [`StorySessionTableAccess::story_session`] method on [`super::RemoteTables`], +/// like `ctx.db.story_session()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.story_session().on_insert(...)`. +pub struct StorySessionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `story_session`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait StorySessionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`StorySessionTableHandle`], which mediates access to the table `story_session`. + fn story_session(&self) -> StorySessionTableHandle<'_>; +} + +impl StorySessionTableAccess for super::RemoteTables { + fn story_session(&self) -> StorySessionTableHandle<'_> { + StorySessionTableHandle { + imp: self.imp.get_table::("story_session"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct StorySessionInsertCallbackId(__sdk::CallbackId); +pub struct StorySessionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for StorySessionTableHandle<'ctx> { + type Row = StorySession; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = StorySessionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> StorySessionInsertCallbackId { + StorySessionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: StorySessionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = StorySessionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> StorySessionDeleteCallbackId { + StorySessionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: StorySessionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct StorySessionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for StorySessionTableHandle<'ctx> { + type UpdateCallbackId = StorySessionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> StorySessionUpdateCallbackId { + StorySessionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: StorySessionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `story_session_id` unique index on the table `story_session`, +/// which allows point queries on the field of the same name +/// via the [`StorySessionStorySessionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.story_session().story_session_id().find(...)`. +pub struct StorySessionStorySessionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> StorySessionTableHandle<'ctx> { + /// Get a handle on the `story_session_id` unique index on the table `story_session`. + pub fn story_session_id(&self) -> StorySessionStorySessionIdUnique<'ctx> { + StorySessionStorySessionIdUnique { + imp: self.imp.get_unique_constraint::("story_session_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> StorySessionStorySessionIdUnique<'ctx> { + /// Find the subscribed row whose `story_session_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("story_session"); + _table.add_unique_constraint::("story_session_id", |row| &row.story_session_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `StorySession`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait story_sessionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `StorySession`. + fn story_session(&self) -> __sdk::__query_builder::Table; +} + +impl story_sessionQueryTableAccess for __sdk::QueryTableAccessor { + fn story_session(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("story_session") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/story_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/story_session_type.rs new file mode 100644 index 00000000..f9534179 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/story_session_type.rs @@ -0,0 +1,97 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::story_session_status_type::StorySessionStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct StorySession { + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub world_profile_id: String, + pub initial_prompt: String, + pub opening_summary: Option, + pub latest_narrative_text: String, + pub latest_choice_function_id: Option, + pub status: StorySessionStatus, + pub version: u32, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for StorySession { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `StorySession`. +/// +/// Provides typed access to columns for query building. +pub struct StorySessionCols { + pub story_session_id: __sdk::__query_builder::Col, + pub runtime_session_id: __sdk::__query_builder::Col, + pub actor_user_id: __sdk::__query_builder::Col, + pub world_profile_id: __sdk::__query_builder::Col, + pub initial_prompt: __sdk::__query_builder::Col, + pub opening_summary: __sdk::__query_builder::Col>, + pub latest_narrative_text: __sdk::__query_builder::Col, + pub latest_choice_function_id: __sdk::__query_builder::Col>, + pub status: __sdk::__query_builder::Col, + pub version: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for StorySession { + type Cols = StorySessionCols; + fn cols(table_name: &'static str) -> Self::Cols { + StorySessionCols { + story_session_id: __sdk::__query_builder::Col::new(table_name, "story_session_id"), + runtime_session_id: __sdk::__query_builder::Col::new(table_name, "runtime_session_id"), + actor_user_id: __sdk::__query_builder::Col::new(table_name, "actor_user_id"), + world_profile_id: __sdk::__query_builder::Col::new(table_name, "world_profile_id"), + initial_prompt: __sdk::__query_builder::Col::new(table_name, "initial_prompt"), + opening_summary: __sdk::__query_builder::Col::new(table_name, "opening_summary"), + latest_narrative_text: __sdk::__query_builder::Col::new( + table_name, + "latest_narrative_text", + ), + latest_choice_function_id: __sdk::__query_builder::Col::new( + table_name, + "latest_choice_function_id", + ), + status: __sdk::__query_builder::Col::new(table_name, "status"), + version: __sdk::__query_builder::Col::new(table_name, "version"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `StorySession`. +/// +/// Provides typed access to indexed columns for query building. +pub struct StorySessionIxCols { + pub actor_user_id: __sdk::__query_builder::IxCol, + pub runtime_session_id: __sdk::__query_builder::IxCol, + pub story_session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for StorySession { + type IxCols = StorySessionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + StorySessionIxCols { + actor_user_id: __sdk::__query_builder::IxCol::new(table_name, "actor_user_id"), + runtime_session_id: __sdk::__query_builder::IxCol::new( + table_name, + "runtime_session_id", + ), + story_session_id: __sdk::__query_builder::IxCol::new(table_name, "story_session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for StorySession {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs new file mode 100644 index 00000000..c5e8def1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSubmitInput; +use super::custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SubmitCustomWorldAgentMessageArgs { + pub input: CustomWorldAgentMessageSubmitInput, +} + +impl __sdk::InModule for SubmitCustomWorldAgentMessageArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `submit_custom_world_agent_message`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait submit_custom_world_agent_message { + fn submit_custom_world_agent_message(&self, input: CustomWorldAgentMessageSubmitInput) { + self.submit_custom_world_agent_message_then(input, |_, _| {}); + } + + fn submit_custom_world_agent_message_then( + &self, + input: CustomWorldAgentMessageSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl submit_custom_world_agent_message for super::RemoteProcedures { + fn submit_custom_world_agent_message_then( + &self, + input: CustomWorldAgentMessageSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldAgentOperationProcedureResult>( + "submit_custom_world_agent_message", + SubmitCustomWorldAgentMessageArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/treasure_interaction_action_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/treasure_interaction_action_type.rs new file mode 100644 index 00000000..a9b61b0e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/treasure_interaction_action_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum TreasureInteractionAction { + Inspect, + + Leave, + + Secure, +} + +impl __sdk::InModule for TreasureInteractionAction { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_procedure_result_type.rs new file mode 100644 index 00000000..d0ac0175 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::treasure_record_snapshot_type::TreasureRecordSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct TreasureRecordProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for TreasureRecordProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_snapshot_type.rs new file mode 100644 index 00000000..8d69d10e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_snapshot_type.rs @@ -0,0 +1,33 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; +use super::treasure_interaction_action_type::TreasureInteractionAction; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct TreasureRecordSnapshot { + pub treasure_record_id: String, + pub runtime_session_id: String, + pub story_session_id: String, + pub actor_user_id: String, + pub encounter_id: String, + pub encounter_name: String, + pub scene_id: Option, + pub scene_name: Option, + pub action: TreasureInteractionAction, + pub reward_items: Vec, + pub reward_hp: u32, + pub reward_mana: u32, + pub reward_currency: u32, + pub story_hint: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for TreasureRecordSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_table.rs new file mode 100644 index 00000000..927e3256 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_table.rs @@ -0,0 +1,163 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; +use super::treasure_interaction_action_type::TreasureInteractionAction; +use super::treasure_record_type::TreasureRecord; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `treasure_record`. +/// +/// Obtain a handle from the [`TreasureRecordTableAccess::treasure_record`] method on [`super::RemoteTables`], +/// like `ctx.db.treasure_record()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.treasure_record().on_insert(...)`. +pub struct TreasureRecordTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `treasure_record`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait TreasureRecordTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`TreasureRecordTableHandle`], which mediates access to the table `treasure_record`. + fn treasure_record(&self) -> TreasureRecordTableHandle<'_>; +} + +impl TreasureRecordTableAccess for super::RemoteTables { + fn treasure_record(&self) -> TreasureRecordTableHandle<'_> { + TreasureRecordTableHandle { + imp: self.imp.get_table::("treasure_record"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct TreasureRecordInsertCallbackId(__sdk::CallbackId); +pub struct TreasureRecordDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for TreasureRecordTableHandle<'ctx> { + type Row = TreasureRecord; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = TreasureRecordInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> TreasureRecordInsertCallbackId { + TreasureRecordInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: TreasureRecordInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = TreasureRecordDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> TreasureRecordDeleteCallbackId { + TreasureRecordDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: TreasureRecordDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct TreasureRecordUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for TreasureRecordTableHandle<'ctx> { + type UpdateCallbackId = TreasureRecordUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> TreasureRecordUpdateCallbackId { + TreasureRecordUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: TreasureRecordUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `treasure_record_id` unique index on the table `treasure_record`, +/// which allows point queries on the field of the same name +/// via the [`TreasureRecordTreasureRecordIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.treasure_record().treasure_record_id().find(...)`. +pub struct TreasureRecordTreasureRecordIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> TreasureRecordTableHandle<'ctx> { + /// Get a handle on the `treasure_record_id` unique index on the table `treasure_record`. + pub fn treasure_record_id(&self) -> TreasureRecordTreasureRecordIdUnique<'ctx> { + TreasureRecordTreasureRecordIdUnique { + imp: self + .imp + .get_unique_constraint::("treasure_record_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> TreasureRecordTreasureRecordIdUnique<'ctx> { + /// Find the subscribed row whose `treasure_record_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("treasure_record"); + _table.add_unique_constraint::("treasure_record_id", |row| &row.treasure_record_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `TreasureRecord`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait treasure_recordQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `TreasureRecord`. + fn treasure_record(&self) -> __sdk::__query_builder::Table; +} + +impl treasure_recordQueryTableAccess for __sdk::QueryTableAccessor { + fn treasure_record(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("treasure_record") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_type.rs new file mode 100644 index 00000000..9bcd8f6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/treasure_record_type.rs @@ -0,0 +1,112 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; +use super::treasure_interaction_action_type::TreasureInteractionAction; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct TreasureRecord { + pub treasure_record_id: String, + pub runtime_session_id: String, + pub story_session_id: String, + pub actor_user_id: String, + pub encounter_id: String, + pub encounter_name: String, + pub scene_id: Option, + pub scene_name: Option, + pub action: TreasureInteractionAction, + pub reward_items: Vec, + pub reward_hp: u32, + pub reward_mana: u32, + pub reward_currency: u32, + pub story_hint: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for TreasureRecord { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `TreasureRecord`. +/// +/// Provides typed access to columns for query building. +pub struct TreasureRecordCols { + pub treasure_record_id: __sdk::__query_builder::Col, + pub runtime_session_id: __sdk::__query_builder::Col, + pub story_session_id: __sdk::__query_builder::Col, + pub actor_user_id: __sdk::__query_builder::Col, + pub encounter_id: __sdk::__query_builder::Col, + pub encounter_name: __sdk::__query_builder::Col, + pub scene_id: __sdk::__query_builder::Col>, + pub scene_name: __sdk::__query_builder::Col>, + pub action: __sdk::__query_builder::Col, + pub reward_items: + __sdk::__query_builder::Col>, + pub reward_hp: __sdk::__query_builder::Col, + pub reward_mana: __sdk::__query_builder::Col, + pub reward_currency: __sdk::__query_builder::Col, + pub story_hint: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for TreasureRecord { + type Cols = TreasureRecordCols; + fn cols(table_name: &'static str) -> Self::Cols { + TreasureRecordCols { + treasure_record_id: __sdk::__query_builder::Col::new(table_name, "treasure_record_id"), + runtime_session_id: __sdk::__query_builder::Col::new(table_name, "runtime_session_id"), + story_session_id: __sdk::__query_builder::Col::new(table_name, "story_session_id"), + actor_user_id: __sdk::__query_builder::Col::new(table_name, "actor_user_id"), + encounter_id: __sdk::__query_builder::Col::new(table_name, "encounter_id"), + encounter_name: __sdk::__query_builder::Col::new(table_name, "encounter_name"), + scene_id: __sdk::__query_builder::Col::new(table_name, "scene_id"), + scene_name: __sdk::__query_builder::Col::new(table_name, "scene_name"), + action: __sdk::__query_builder::Col::new(table_name, "action"), + reward_items: __sdk::__query_builder::Col::new(table_name, "reward_items"), + reward_hp: __sdk::__query_builder::Col::new(table_name, "reward_hp"), + reward_mana: __sdk::__query_builder::Col::new(table_name, "reward_mana"), + reward_currency: __sdk::__query_builder::Col::new(table_name, "reward_currency"), + story_hint: __sdk::__query_builder::Col::new(table_name, "story_hint"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `TreasureRecord`. +/// +/// Provides typed access to indexed columns for query building. +pub struct TreasureRecordIxCols { + pub actor_user_id: __sdk::__query_builder::IxCol, + pub encounter_id: __sdk::__query_builder::IxCol, + pub runtime_session_id: __sdk::__query_builder::IxCol, + pub story_session_id: __sdk::__query_builder::IxCol, + pub treasure_record_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for TreasureRecord { + type IxCols = TreasureRecordIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + TreasureRecordIxCols { + actor_user_id: __sdk::__query_builder::IxCol::new(table_name, "actor_user_id"), + encounter_id: __sdk::__query_builder::IxCol::new(table_name, "encounter_id"), + runtime_session_id: __sdk::__query_builder::IxCol::new( + table_name, + "runtime_session_id", + ), + story_session_id: __sdk::__query_builder::IxCol::new(table_name, "story_session_id"), + treasure_record_id: __sdk::__query_builder::IxCol::new( + table_name, + "treasure_record_id", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for TreasureRecord {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/treasure_resolve_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/treasure_resolve_input_type.rs new file mode 100644 index 00000000..ef1083df --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/treasure_resolve_input_type.rs @@ -0,0 +1,33 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; +use super::treasure_interaction_action_type::TreasureInteractionAction; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct TreasureResolveInput { + pub treasure_record_id: String, + pub runtime_session_id: String, + pub story_session_id: String, + pub actor_user_id: String, + pub encounter_id: String, + pub encounter_name: String, + pub scene_id: Option, + pub scene_name: Option, + pub action: TreasureInteractionAction, + pub reward_items: Vec, + pub reward_hp: u32, + pub reward_mana: u32, + pub reward_currency: u32, + pub story_hint: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for TreasureResolveInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/turn_in_quest_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/turn_in_quest_reducer.rs new file mode 100644 index 00000000..4306a47f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/turn_in_quest_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::quest_turn_in_input_type::QuestTurnInInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct TurnInQuestArgs { + pub input: QuestTurnInInput, +} + +impl From for super::Reducer { + fn from(args: TurnInQuestArgs) -> Self { + Self::TurnInQuest { input: args.input } + } +} + +impl __sdk::InModule for TurnInQuestArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `turn_in_quest`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait turn_in_quest { + /// Request that the remote module invoke the reducer `turn_in_quest` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`turn_in_quest:turn_in_quest_then`] to run a callback after the reducer completes. + fn turn_in_quest(&self, input: QuestTurnInInput) -> __sdk::Result<()> { + self.turn_in_quest_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `turn_in_quest` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn turn_in_quest_then( + &self, + input: QuestTurnInInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl turn_in_quest for super::RemoteReducers { + fn turn_in_quest_then( + &self, + input: QuestTurnInInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(TurnInQuestArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/unequip_inventory_item_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/unequip_inventory_item_input_type.rs new file mode 100644 index 00000000..227f40ec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/unequip_inventory_item_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct UnequipInventoryItemInput { + pub slot_id: String, +} + +impl __sdk::InModule for UnequipInventoryItemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs new file mode 100644 index 00000000..b87880a7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_unpublish_input_type::CustomWorldProfileUnpublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UnpublishCustomWorldProfileAndReturnArgs { + pub input: CustomWorldProfileUnpublishInput, +} + +impl __sdk::InModule for UnpublishCustomWorldProfileAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `unpublish_custom_world_profile_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait unpublish_custom_world_profile_and_return { + fn unpublish_custom_world_profile_and_return(&self, input: CustomWorldProfileUnpublishInput) { + self.unpublish_custom_world_profile_and_return_then(input, |_, _| {}); + } + + fn unpublish_custom_world_profile_and_return_then( + &self, + input: CustomWorldProfileUnpublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl unpublish_custom_world_profile_and_return for super::RemoteProcedures { + fn unpublish_custom_world_profile_and_return_then( + &self, + input: CustomWorldProfileUnpublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "unpublish_custom_world_profile_and_return", + UnpublishCustomWorldProfileAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_reducer.rs new file mode 100644 index 00000000..05274f62 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_reducer.rs @@ -0,0 +1,71 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_profile_unpublish_input_type::CustomWorldProfileUnpublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct UnpublishCustomWorldProfileArgs { + pub input: CustomWorldProfileUnpublishInput, +} + +impl From for super::Reducer { + fn from(args: UnpublishCustomWorldProfileArgs) -> Self { + Self::UnpublishCustomWorldProfile { input: args.input } + } +} + +impl __sdk::InModule for UnpublishCustomWorldProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `unpublish_custom_world_profile`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait unpublish_custom_world_profile { + /// Request that the remote module invoke the reducer `unpublish_custom_world_profile` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`unpublish_custom_world_profile:unpublish_custom_world_profile_then`] to run a callback after the reducer completes. + fn unpublish_custom_world_profile( + &self, + input: CustomWorldProfileUnpublishInput, + ) -> __sdk::Result<()> { + self.unpublish_custom_world_profile_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `unpublish_custom_world_profile` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn unpublish_custom_world_profile_then( + &self, + input: CustomWorldProfileUnpublishInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl unpublish_custom_world_profile for super::RemoteReducers { + fn unpublish_custom_world_profile_then( + &self, + input: CustomWorldProfileUnpublishInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(UnpublishCustomWorldProfileArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs new file mode 100644 index 00000000..abc39bfe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_progression_input_type::ChapterProgressionInput; +use super::chapter_progression_procedure_result_type::ChapterProgressionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertChapterProgressionAndReturnArgs { + pub input: ChapterProgressionInput, +} + +impl __sdk::InModule for UpsertChapterProgressionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_chapter_progression_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_chapter_progression_and_return { + fn upsert_chapter_progression_and_return(&self, input: ChapterProgressionInput) { + self.upsert_chapter_progression_and_return_then(input, |_, _| {}); + } + + fn upsert_chapter_progression_and_return_then( + &self, + input: ChapterProgressionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_chapter_progression_and_return for super::RemoteProcedures { + fn upsert_chapter_progression_and_return_then( + &self, + input: ChapterProgressionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ChapterProgressionProcedureResult>( + "upsert_chapter_progression_and_return", + UpsertChapterProgressionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_reducer.rs new file mode 100644 index 00000000..0cb4bb7a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::chapter_progression_input_type::ChapterProgressionInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct UpsertChapterProgressionArgs { + pub input: ChapterProgressionInput, +} + +impl From for super::Reducer { + fn from(args: UpsertChapterProgressionArgs) -> Self { + Self::UpsertChapterProgression { input: args.input } + } +} + +impl __sdk::InModule for UpsertChapterProgressionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `upsert_chapter_progression`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait upsert_chapter_progression { + /// Request that the remote module invoke the reducer `upsert_chapter_progression` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`upsert_chapter_progression:upsert_chapter_progression_then`] to run a callback after the reducer completes. + fn upsert_chapter_progression(&self, input: ChapterProgressionInput) -> __sdk::Result<()> { + self.upsert_chapter_progression_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `upsert_chapter_progression` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn upsert_chapter_progression_then( + &self, + input: ChapterProgressionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl upsert_chapter_progression for super::RemoteReducers { + fn upsert_chapter_progression_then( + &self, + input: ChapterProgressionInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(UpsertChapterProgressionArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs new file mode 100644 index 00000000..343e1807 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_upsert_input_type::CustomWorldProfileUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertCustomWorldProfileAndReturnArgs { + pub input: CustomWorldProfileUpsertInput, +} + +impl __sdk::InModule for UpsertCustomWorldProfileAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_custom_world_profile_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_custom_world_profile_and_return { + fn upsert_custom_world_profile_and_return(&self, input: CustomWorldProfileUpsertInput) { + self.upsert_custom_world_profile_and_return_then(input, |_, _| {}); + } + + fn upsert_custom_world_profile_and_return_then( + &self, + input: CustomWorldProfileUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_custom_world_profile_and_return for super::RemoteProcedures { + fn upsert_custom_world_profile_and_return_then( + &self, + input: CustomWorldProfileUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "upsert_custom_world_profile_and_return", + UpsertCustomWorldProfileAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_reducer.rs new file mode 100644 index 00000000..e343b491 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_reducer.rs @@ -0,0 +1,71 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_profile_upsert_input_type::CustomWorldProfileUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct UpsertCustomWorldProfileArgs { + pub input: CustomWorldProfileUpsertInput, +} + +impl From for super::Reducer { + fn from(args: UpsertCustomWorldProfileArgs) -> Self { + Self::UpsertCustomWorldProfile { input: args.input } + } +} + +impl __sdk::InModule for UpsertCustomWorldProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `upsert_custom_world_profile`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait upsert_custom_world_profile { + /// Request that the remote module invoke the reducer `upsert_custom_world_profile` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`upsert_custom_world_profile:upsert_custom_world_profile_then`] to run a callback after the reducer completes. + fn upsert_custom_world_profile( + &self, + input: CustomWorldProfileUpsertInput, + ) -> __sdk::Result<()> { + self.upsert_custom_world_profile_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `upsert_custom_world_profile` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn upsert_custom_world_profile_then( + &self, + input: CustomWorldProfileUpsertInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl upsert_custom_world_profile for super::RemoteReducers { + fn upsert_custom_world_profile_then( + &self, + input: CustomWorldProfileUpsertInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(UpsertCustomWorldProfileArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs new file mode 100644 index 00000000..b7f3a28d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_state_procedure_result_type::NpcStateProcedureResult; +use super::npc_state_upsert_input_type::NpcStateUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertNpcStateAndReturnArgs { + pub input: NpcStateUpsertInput, +} + +impl __sdk::InModule for UpsertNpcStateAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_npc_state_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_npc_state_and_return { + fn upsert_npc_state_and_return(&self, input: NpcStateUpsertInput) { + self.upsert_npc_state_and_return_then(input, |_, _| {}); + } + + fn upsert_npc_state_and_return_then( + &self, + input: NpcStateUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_npc_state_and_return for super::RemoteProcedures { + fn upsert_npc_state_and_return_then( + &self, + input: NpcStateUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, NpcStateProcedureResult>( + "upsert_npc_state_and_return", + UpsertNpcStateAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_reducer.rs new file mode 100644 index 00000000..f363770d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::npc_state_upsert_input_type::NpcStateUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct UpsertNpcStateArgs { + pub input: NpcStateUpsertInput, +} + +impl From for super::Reducer { + fn from(args: UpsertNpcStateArgs) -> Self { + Self::UpsertNpcState { input: args.input } + } +} + +impl __sdk::InModule for UpsertNpcStateArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `upsert_npc_state`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait upsert_npc_state { + /// Request that the remote module invoke the reducer `upsert_npc_state` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`upsert_npc_state:upsert_npc_state_then`] to run a callback after the reducer completes. + fn upsert_npc_state(&self, input: NpcStateUpsertInput) -> __sdk::Result<()> { + self.upsert_npc_state_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `upsert_npc_state` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn upsert_npc_state_then( + &self, + input: NpcStateUpsertInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl upsert_npc_state for super::RemoteReducers { + fn upsert_npc_state_then( + &self, + input: NpcStateUpsertInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(UpsertNpcStateArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs new file mode 100644 index 00000000..614a6d05 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_browse_history_procedure_result_type::RuntimeBrowseHistoryProcedureResult; +use super::runtime_browse_history_sync_input_type::RuntimeBrowseHistorySyncInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertPlatformBrowseHistoryAndReturnArgs { + pub input: RuntimeBrowseHistorySyncInput, +} + +impl __sdk::InModule for UpsertPlatformBrowseHistoryAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_platform_browse_history_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_platform_browse_history_and_return { + fn upsert_platform_browse_history_and_return(&self, input: RuntimeBrowseHistorySyncInput) { + self.upsert_platform_browse_history_and_return_then(input, |_, _| {}); + } + + fn upsert_platform_browse_history_and_return_then( + &self, + input: RuntimeBrowseHistorySyncInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_platform_browse_history_and_return for super::RemoteProcedures { + fn upsert_platform_browse_history_and_return_then( + &self, + input: RuntimeBrowseHistorySyncInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeBrowseHistoryProcedureResult>( + "upsert_platform_browse_history_and_return", + UpsertPlatformBrowseHistoryAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs new file mode 100644 index 00000000..119eab70 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_setting_procedure_result_type::RuntimeSettingProcedureResult; +use super::runtime_setting_upsert_input_type::RuntimeSettingUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertRuntimeSettingAndReturnArgs { + pub input: RuntimeSettingUpsertInput, +} + +impl __sdk::InModule for UpsertRuntimeSettingAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_runtime_setting_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_runtime_setting_and_return { + fn upsert_runtime_setting_and_return(&self, input: RuntimeSettingUpsertInput) { + self.upsert_runtime_setting_and_return_then(input, |_, _| {}); + } + + fn upsert_runtime_setting_and_return_then( + &self, + input: RuntimeSettingUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_runtime_setting_and_return for super::RemoteProcedures { + fn upsert_runtime_setting_and_return_then( + &self, + input: RuntimeSettingUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeSettingProcedureResult>( + "upsert_runtime_setting_and_return", + UpsertRuntimeSettingAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_table.rs new file mode 100644 index 00000000..2341921d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_table.rs @@ -0,0 +1,164 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::runtime_browse_history_theme_mode_type::RuntimeBrowseHistoryThemeMode; +use super::user_browse_history_type::UserBrowseHistory; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `user_browse_history`. +/// +/// Obtain a handle from the [`UserBrowseHistoryTableAccess::user_browse_history`] method on [`super::RemoteTables`], +/// like `ctx.db.user_browse_history()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.user_browse_history().on_insert(...)`. +pub struct UserBrowseHistoryTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `user_browse_history`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait UserBrowseHistoryTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`UserBrowseHistoryTableHandle`], which mediates access to the table `user_browse_history`. + fn user_browse_history(&self) -> UserBrowseHistoryTableHandle<'_>; +} + +impl UserBrowseHistoryTableAccess for super::RemoteTables { + fn user_browse_history(&self) -> UserBrowseHistoryTableHandle<'_> { + UserBrowseHistoryTableHandle { + imp: self + .imp + .get_table::("user_browse_history"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct UserBrowseHistoryInsertCallbackId(__sdk::CallbackId); +pub struct UserBrowseHistoryDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for UserBrowseHistoryTableHandle<'ctx> { + type Row = UserBrowseHistory; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = UserBrowseHistoryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> UserBrowseHistoryInsertCallbackId { + UserBrowseHistoryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: UserBrowseHistoryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = UserBrowseHistoryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> UserBrowseHistoryDeleteCallbackId { + UserBrowseHistoryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: UserBrowseHistoryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct UserBrowseHistoryUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for UserBrowseHistoryTableHandle<'ctx> { + type UpdateCallbackId = UserBrowseHistoryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> UserBrowseHistoryUpdateCallbackId { + UserBrowseHistoryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: UserBrowseHistoryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `browse_history_id` unique index on the table `user_browse_history`, +/// which allows point queries on the field of the same name +/// via the [`UserBrowseHistoryBrowseHistoryIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.user_browse_history().browse_history_id().find(...)`. +pub struct UserBrowseHistoryBrowseHistoryIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> UserBrowseHistoryTableHandle<'ctx> { + /// Get a handle on the `browse_history_id` unique index on the table `user_browse_history`. + pub fn browse_history_id(&self) -> UserBrowseHistoryBrowseHistoryIdUnique<'ctx> { + UserBrowseHistoryBrowseHistoryIdUnique { + imp: self + .imp + .get_unique_constraint::("browse_history_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> UserBrowseHistoryBrowseHistoryIdUnique<'ctx> { + /// Find the subscribed row whose `browse_history_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("user_browse_history"); + _table.add_unique_constraint::("browse_history_id", |row| &row.browse_history_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `UserBrowseHistory`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait user_browse_historyQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `UserBrowseHistory`. + fn user_browse_history(&self) -> __sdk::__query_builder::Table; +} + +impl user_browse_historyQueryTableAccess for __sdk::QueryTableAccessor { + fn user_browse_history(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("user_browse_history") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_type.rs new file mode 100644 index 00000000..3a6cf1ae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_browse_history_type.rs @@ -0,0 +1,92 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_browse_history_theme_mode_type::RuntimeBrowseHistoryThemeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct UserBrowseHistory { + pub browse_history_id: String, + pub user_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: RuntimeBrowseHistoryThemeMode, + pub author_display_name: String, + pub visited_at: __sdk::Timestamp, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for UserBrowseHistory { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `UserBrowseHistory`. +/// +/// Provides typed access to columns for query building. +pub struct UserBrowseHistoryCols { + pub browse_history_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub world_name: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col>, + pub theme_mode: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub visited_at: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for UserBrowseHistory { + type Cols = UserBrowseHistoryCols; + fn cols(table_name: &'static str) -> Self::Cols { + UserBrowseHistoryCols { + browse_history_id: __sdk::__query_builder::Col::new(table_name, "browse_history_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + world_name: __sdk::__query_builder::Col::new(table_name, "world_name"), + subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"), + summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + theme_mode: __sdk::__query_builder::Col::new(table_name, "theme_mode"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + visited_at: __sdk::__query_builder::Col::new(table_name, "visited_at"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `UserBrowseHistory`. +/// +/// Provides typed access to indexed columns for query building. +pub struct UserBrowseHistoryIxCols { + pub browse_history_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for UserBrowseHistory { + type IxCols = UserBrowseHistoryIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + UserBrowseHistoryIxCols { + browse_history_id: __sdk::__query_builder::IxCol::new(table_name, "browse_history_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for UserBrowseHistory {} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 578db830..712c0ed2 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -9,5 +9,15 @@ crate-type = ["cdylib"] [dependencies] log = { workspace = true } +module-ai = { path = "../module-ai", default-features = false, features = ["spacetime-types"] } module-assets = { path = "../module-assets", default-features = false, features = ["spacetime-types"] } +module-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] } +module-inventory = { path = "../module-inventory", default-features = false, features = ["spacetime-types"] } +module-custom-world = { path = "../module-custom-world", default-features = false, features = ["spacetime-types"] } +module-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] } +module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] } +module-quest = { path = "../module-quest", default-features = false, features = ["spacetime-types"] } +module-runtime = { path = "../module-runtime", default-features = false, features = ["spacetime-types"] } +module-runtime-item = { path = "../module-runtime-item", default-features = false, features = ["spacetime-types"] } +module-story = { path = "../module-story", default-features = false, features = ["spacetime-types"] } spacetimedb = { workspace = true, features = ["unstable"] } diff --git a/server-rs/crates/spacetime-module/README.md b/server-rs/crates/spacetime-module/README.md index fedca342..d8063e04 100644 --- a/server-rs/crates/spacetime-module/README.md +++ b/server-rs/crates/spacetime-module/README.md @@ -1,6 +1,6 @@ -# spacetime-module 主工程 crate 占位说明 +# spacetime-module 主工程 crate 说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. crate 职责 @@ -14,7 +14,7 @@ ## 2. 当前阶段说明 -当前阶段已落下第一批真实 schema 骨架,并已补齐本地 standalone 启动脚本,先把 SpacetimeDB 进程入口与首版资产对象表固定下来。 +当前阶段已落下第一批真实 schema 骨架,并已补齐本地 standalone 启动脚本,先把 SpacetimeDB 进程入口、M3/M4 基础表以及 `M5 custom world / agent` 首批表骨架固定下来。 后续与本 crate 直接相关的任务包括: @@ -32,12 +32,47 @@ 5. 面向 Axum 的 `asset_object` 确认持久化入口 6. `asset_entity_binding` 通用绑定表 7. 面向 Axum 的 `bind_asset_object_to_entity_and_return` 绑定 procedure +8. `runtime_setting` 表与 procedure +9. `npc_state`、`story_session`、`story_event` +10. `battle_state`、`treasure_record` +11. `quest_record`、`quest_log` +12. `M5` 首批 `custom_world_profile / session / agent / gallery` 表骨架 +13. `custom world library / publish / gallery` Stage 2 procedures +14. `published profile compile` Stage 3 procedure +15. `publish_world` Stage 4 串联 procedure +16. `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 首版 AI 真相表 +17. AI 任务最小 procedure / reducer: + - `create_ai_task` + - `create_ai_task_and_return` + - `start_ai_task` + - `start_ai_task_stage` + - `append_ai_text_chunk_and_return` + - `complete_ai_stage_and_return` + - `attach_ai_result_reference_and_return` + - `complete_ai_task_and_return` + - `fail_ai_task_and_return` + - `cancel_ai_task_and_return` +18. `turn_in_quest` 与 `resolve_combat_action(Victory)` 到 `player_progression / chapter_progression` 的最小经验联动 `asset_object` 的详细设计见: 1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md) 2. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md) +`M5 custom world / agent` 首批表设计见: + +1. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md) +2. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md) +3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_LIBRARY_GALLERY_STAGE2_DESIGN_2026-04-21.md) +4. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md) +5. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md) + +`module-ai` 的当前基座设计见: + +1. [../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md) +2. [../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md) +3. [../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md) + 当前身份透传设计依据: 1. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) @@ -54,3 +89,4 @@ 1. `spacetime-module` 只聚合状态模型,不直接承接 HTTP、Cookie、Header、OSS、短信、微信、LLM 等外部副作用。 2. 每个业务模块优先在自己的 `crates/module-*` 中定义状态与规则,再由主工程聚合。 3. 主工程不重新吞并各模块实现细节,避免回到单大包结构。 +4. `custom_world_asset_link` 仍等待 `M6 assets / OSS` 的对象槽位规则冻结后再补,不在本轮首批表骨架内提前硬落。 diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 16631139..3f540998 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -1,3 +1,13 @@ +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, @@ -5,7 +15,132 @@ use module_assets::{ INITIAL_ASSET_OBJECT_VERSION, validate_asset_entity_binding_fields, validate_asset_object_fields, }; -use spacetimedb::{ProcedureContext, ReducerContext, Table, Timestamp}; +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::{ + CustomWorldAgentMessageSnapshot, CustomWorldAgentMessageSubmitInput, + CustomWorldAgentOperationGetInput, CustomWorldAgentOperationProcedureResult, + CustomWorldAgentOperationSnapshot, CustomWorldAgentSessionCreateInput, + CustomWorldAgentSessionGetInput, CustomWorldAgentSessionProcedureResult, + CustomWorldAgentSessionSnapshot, CustomWorldDraftCardSnapshot, + CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot, + CustomWorldGalleryListResult, CustomWorldGenerationMode, CustomWorldLibraryDetailInput, + CustomWorldLibraryMutationResult, CustomWorldProfileListInput, + CustomWorldProfileListResult, CustomWorldProfilePublishInput, CustomWorldProfileSnapshot, + CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput, + CustomWorldPublicationStatus, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult, + CustomWorldPublishedProfileCompileInput, CustomWorldPublishedProfileCompileResult, + CustomWorldRoleAssetStatus, CustomWorldSessionStatus, CustomWorldThemeMode, + RpgAgentDraftCardKind, RpgAgentDraftCardStatus, RpgAgentMessageKind, RpgAgentMessageRole, + RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage, + build_custom_world_published_profile_compile_snapshot, 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_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, +}; +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, PROFILE_WALLET_LEDGER_LIST_LIMIT, + RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryListInput, + RuntimeBrowseHistoryProcedureResult, RuntimeBrowseHistorySnapshot, + RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode, RuntimePlatformTheme, + RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult, + RuntimeProfileDashboardSnapshot, RuntimeProfilePlayStatsGetInput, + RuntimeProfilePlayStatsProcedureResult, RuntimeProfilePlayStatsSnapshot, + RuntimeProfilePlayedWorldSnapshot, RuntimeProfileWalletLedgerEntrySnapshot, + RuntimeProfileWalletLedgerListInput, RuntimeProfileWalletLedgerProcedureResult, + RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput, RuntimeSettingProcedureResult, + RuntimeSettingSnapshot, RuntimeSettingUpsertInput, 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_wallet_ledger_list_input, + build_runtime_setting_get_input, build_runtime_setting_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 spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; + +// 这层输入只服务 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 = asset_object, @@ -51,17 +186,2025 @@ pub struct AssetEntityBinding { 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 = 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 = 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, + 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, + 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 双列主存储口径,默认对象 ID 前缀={},默认绑定 ID 前缀={},初始版本={}", + "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_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), + }, + } +} + +// 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_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()), + 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), + 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( @@ -120,6 +2263,533 @@ pub fn bind_asset_object_to_entity_and_return( } } +// 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), + }, + } +} + +// 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), + }, + } +} + +// 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), + }, + } +} + +#[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), + }, + } +} + +// 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, @@ -227,6 +2897,1782 @@ fn upsert_asset_object( 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, + 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, + 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), + 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, + 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 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) + .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); + + 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 + }); + + 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 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(), + 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()), + 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(), + 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(), + 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, @@ -325,3 +4771,624 @@ fn upsert_asset_entity_binding( 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_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(), + } +}