use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use module_npc::{ NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile, build_relation_state as build_module_npc_relation_state, }; use module_runtime::RuntimeSnapshotRecord; use module_runtime_story_compat::{ CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload, PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution, add_player_currency, add_player_inventory_items, append_story_history, apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options, build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text, build_runtime_story_option_from_story_option, build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option, clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_id, current_encounter_name, current_world_type, ensure_inventory_action_available, ensure_json_object, equipment_slot_label, find_player_inventory_entry, format_currency_text, format_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory, increment_runtime_stat, normalize_equipment_slot_id, normalize_equipped_item, normalize_required_string, npc_buyback_price, npc_purchase_price, read_array_field, read_bool_field, read_field, read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, read_player_equipment_item, read_required_string_field, read_runtime_session_id, read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text, resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item, resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution, trade_quantity_suffix, write_bool_field, write_i32_field, write_null_field, write_player_equipment_item, write_string_field, write_u32_field, }; use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map, Value, json}; use shared_contracts::runtime_story::{ RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, }; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; #[path = "compat/ai.rs"] mod ai; #[path = "compat/equipment_actions.rs"] mod equipment_actions; #[path = "compat/game_state.rs"] mod game_state; #[path = "compat/npc_actions.rs"] mod npc_actions; #[path = "compat/presentation.rs"] mod presentation; #[path = "compat/quest_actions.rs"] mod quest_actions; use self::{ ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*, }; #[cfg(test)] #[path = "compat/tests.rs"] mod tests; 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 = resolve_snapshot_for_request( &state, &request_context, authenticated.claims().user_id().to_string(), payload.snapshot, ) .await?; validate_client_version( &request_context, payload.client_version, &snapshot.game_state, "运行时版本已变化,请先同步最新快照后再读取状态", )?; Ok(json_success_body( Some(&request_context), build_runtime_story_state_response(&session_id, payload.client_version, snapshot), )) } pub async fn get_runtime_story_state( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let session_id = normalize_required_string(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 = resolve_snapshot_for_request( &state, &request_context, authenticated.claims().user_id().to_string(), None, ) .await?; Ok(json_success_body( Some(&request_context), build_runtime_story_state_response(&session_id, None, snapshot), )) } pub async fn resolve_runtime_story_action( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let requested_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 function_id = normalize_required_string(payload.action.function_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": "action.functionId", "message": "functionId 不能为空", })), ) })?; if payload.action.action_type.trim() != "story_choice" { return Err(runtime_story_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-story", "field": "action.type", "message": "runtime story 当前只支持 story_choice 动作", })), )); } let mut snapshot = resolve_snapshot_for_request( &state, &request_context, authenticated.claims().user_id().to_string(), payload.snapshot.clone(), ) .await?; validate_client_version( &request_context, payload.client_version, &snapshot.game_state, "运行时版本已变化,请先同步最新快照后再提交动作", )?; let current_story_before = snapshot.current_story.clone(); let mut game_state = snapshot.game_state.clone(); let mut resolution = resolve_runtime_story_choice_action( &mut game_state, current_story_before.as_ref(), &payload, &function_id, ) .map_err(|message| { runtime_story_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-story", "message": message, })), ) })?; let server_version = read_u32_field(&game_state, "runtimeActionVersion") .unwrap_or(0) .saturating_add(1); write_u32_field(&mut game_state, "runtimeActionVersion", server_version); write_string_field( &mut game_state, "runtimeSessionId", requested_session_id.as_str(), ); let mut options = resolution .presentation_options .take() .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); if options.is_empty() { options = build_fallback_runtime_story_options(&game_state); } let mut story_text = resolution .story_text .clone() .unwrap_or_else(|| resolution.result_text.clone()); let mut history_result_text = resolution.result_text.clone(); let mut saved_current_story = resolution .saved_current_story .take() .unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options)); if let Some(generated_payload) = generate_action_story_payload( &state, &game_state, &payload, &function_id, resolution.action_text.as_str(), resolution.result_text.as_str(), &options, resolution.battle.as_ref(), ) .await { story_text = generated_payload.story_text; history_result_text = generated_payload.history_result_text; options = generated_payload.presentation_options; saved_current_story = generated_payload.saved_current_story; } append_story_history( &mut game_state, resolution.action_text.as_str(), history_result_text.as_str(), ); let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { action_text: resolution.action_text.clone(), result_text: history_result_text, }]; patches.extend(resolution.patches); snapshot.saved_at = Some(format_now_rfc3339()); snapshot.game_state = game_state; snapshot.current_story = Some(saved_current_story); let persisted = persist_runtime_story_snapshot( &state, &request_context, authenticated.claims().user_id().to_string(), snapshot, ) .await?; let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted); Ok(json_success_body( Some(&request_context), build_runtime_story_action_response(RuntimeStoryActionResponseParts { requested_session_id, server_version, snapshot: persisted_snapshot, action_text: resolution.action_text, result_text: resolution.result_text, story_text, options, patches, toast: resolution.toast, battle: resolution.battle, }), )) } pub async fn generate_runtime_story_initial( State(state): State, Extension(request_context): Extension, Extension(_authenticated): Extension, Json(payload): Json, ) -> Result, Response> { Ok(json_success_body( Some(&request_context), build_runtime_story_ai_response(&state, payload, true).await, )) } pub async fn generate_runtime_story_continue( State(state): State, Extension(request_context): Extension, Extension(_authenticated): Extension, Json(payload): Json, ) -> Result, Response> { Ok(json_success_body( Some(&request_context), build_runtime_story_ai_response(&state, payload, false).await, )) } async fn resolve_snapshot_for_request( state: &AppState, request_context: &RequestContext, user_id: String, snapshot: Option, ) -> Result { if let Some(snapshot) = snapshot { let record = persist_runtime_story_snapshot(state, request_context, user_id, snapshot).await?; return Ok(runtime_snapshot_payload_from_record(&record)); } let record = state .get_runtime_snapshot_record(user_id) .await .map_err(|error| { runtime_story_error_response(request_context, map_runtime_story_client_error(error)) })? .ok_or_else(|| { runtime_story_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-story", "message": "运行时快照不存在,请先初始化并保存一次游戏", })), ) })?; Ok(runtime_snapshot_payload_from_record(&record)) } async fn persist_runtime_story_snapshot( state: &AppState, request_context: &RequestContext, user_id: String, snapshot: RuntimeStorySnapshotPayload, ) -> Result { validate_snapshot_payload(&snapshot).map_err(|message| { runtime_story_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-story", "message": message, })), ) })?; let now = OffsetDateTime::now_utc(); let saved_at = snapshot .saved_at .as_deref() .and_then(|value| normalize_required_string(value)) .map(|value| parse_rfc3339(value.as_str())) .transpose() .map_err(|error| { runtime_story_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-story", "field": "snapshot.savedAt", "message": format!("savedAt 非法: {error}"), })), ) })? .unwrap_or(now); state .put_runtime_snapshot_record( user_id, offset_datetime_to_unix_micros(saved_at), snapshot.bottom_tab, snapshot.game_state, snapshot.current_story, offset_datetime_to_unix_micros(now), ) .await .map_err(|error| { runtime_story_error_response(request_context, map_runtime_story_client_error(error)) }) } fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> { if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() { return Err("snapshot.bottomTab 不能为空".to_string()); } if !snapshot.game_state.is_object() { return Err("snapshot.gameState 必须是 JSON object".to_string()); } if snapshot .current_story .as_ref() .is_some_and(|current_story| !current_story.is_object()) { return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string()); } Ok(()) } fn runtime_snapshot_payload_from_record( record: &RuntimeSnapshotRecord, ) -> RuntimeStorySnapshotPayload { RuntimeStorySnapshotPayload { saved_at: Some(record.saved_at.clone()), bottom_tab: record.bottom_tab.clone(), game_state: record.game_state.clone(), current_story: record.current_story.clone(), } } fn validate_client_version( request_context: &RequestContext, client_version: Option, game_state: &Value, message: &str, ) -> Result<(), Response> { let Some(client_version) = client_version else { return Ok(()); }; let Some(server_version) = read_u32_field(game_state, "runtimeActionVersion") else { return Ok(()); }; if client_version == server_version { return Ok(()); } Err(runtime_story_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-story", "message": message, "clientVersion": client_version, "serverVersion": server_version, })), )) } fn resolve_runtime_story_choice_action( game_state: &mut Value, current_story: Option<&Value>, request: &RuntimeStoryActionRequest, function_id: &str, ) -> Result { ensure_runtime_story_bridge_state(game_state); match function_id { CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story), "story_opening_camp_dialogue" => resolve_npc_affinity_action( game_state, request, "交换开场判断", 2, "你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。", ), "camp_travel_home_scene" => { clear_encounter_state(game_state); Ok(StoryResolution { action_text: resolve_action_text("返回营地", request), result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![ build_status_patch(game_state), RuntimeStoryPatch::EncounterChanged { encounter_id: None }, ], battle: None, toast: None, }) } "idle_call_out" => Ok(simple_story_resolution( game_state, resolve_action_text("主动出声试探", request), "你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。", )), "idle_explore_forward" => Ok(simple_story_resolution( game_state, resolve_action_text("继续向前探索", request), "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。", )), "idle_observe_signs" => Ok(simple_story_resolution( game_state, resolve_action_text("观察周围迹象", request), "你先压住动作,把风向、脚印和气味这些细节重新读了一遍。", )), "idle_rest_focus" => { restore_player_resource(game_state, 8, 6); Ok(simple_story_resolution( game_state, resolve_action_text("原地调息", request), "你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。", )) } "idle_travel_next_scene" => { clear_encounter_state(game_state); increment_runtime_stat(game_state, "scenesTraveled", 1); Ok(StoryResolution { action_text: resolve_action_text("前往相邻场景", request), result_text: "你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。".to_string(), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![ build_status_patch(game_state), RuntimeStoryPatch::EncounterChanged { encounter_id: None }, ], battle: None, toast: None, }) } "npc_preview_talk" => resolve_npc_preview_action(game_state, request), "npc_chat" => resolve_npc_chat_action(game_state, request), "npc_help" => resolve_npc_help_action(game_state, request), "npc_chat_quest_offer_view" => { resolve_pending_quest_offer_view_action(game_state, current_story, request) } "npc_chat_quest_offer_replace" => { resolve_pending_quest_offer_replace_action(game_state, current_story, request) } "npc_chat_quest_offer_abandon" => { resolve_pending_quest_offer_abandon_action(game_state, current_story, request) } "npc_quest_accept" => { resolve_pending_quest_accept_action(game_state, current_story, request) } "npc_quest_turn_in" => resolve_pending_quest_turn_in_action(game_state, request), "npc_leave" => { let npc_name = current_encounter_name(game_state); clear_encounter_state(game_state); Ok(StoryResolution { action_text: resolve_action_text("离开当前角色", request), result_text: format!("你结束了与 {npc_name} 的这一轮接触,把注意力重新放回旅途。"), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![ build_status_patch(game_state), RuntimeStoryPatch::EncounterChanged { encounter_id: None }, ], battle: None, toast: None, }) } "npc_fight" | "npc_spar" => { resolve_npc_battle_entry_action(game_state, request, function_id) } "npc_trade" => resolve_npc_trade_action(game_state, request), "npc_gift" => resolve_npc_gift_action(game_state, request), "npc_recruit" => resolve_npc_recruit_action(game_state, request), "equipment_equip" => resolve_equipment_equip_action(game_state, request), "equipment_unequip" => resolve_equipment_unequip_action(game_state, request), "forge_craft" => resolve_forge_craft_action(game_state, request), "forge_dismantle" => resolve_forge_dismantle_action(game_state, request), "forge_reforge" => resolve_forge_reforge_action(game_state, request), "battle_attack_basic" | "battle_use_skill" | "battle_all_in_crush" | "battle_escape_breakout" | "battle_feint_step" | "battle_finisher_window" | "battle_guard_break" | "battle_probe_pressure" | "battle_recover_breath" | "inventory_use" => resolve_battle_action(game_state, request, function_id), _ => Err(format!("暂不支持的 runtime action:{function_id}")), } } fn resolve_continue_adventure_action( current_story: Option<&Value>, ) -> Result { let deferred_options = current_story .map(|story| { read_array_field(story, "deferredOptions") .into_iter() .filter_map(build_runtime_story_option_from_story_option) .collect::>() }) .unwrap_or_default(); let options = (!deferred_options.is_empty()).then_some(deferred_options); Ok(StoryResolution { action_text: "继续推进冒险".to_string(), result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(), story_text: None, presentation_options: options, saved_current_story: None, patches: Vec::new(), battle: None, toast: None, }) } fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"), _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), })) } fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) }