use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use module_runtime::RuntimeSnapshotRecord; use platform_llm::{LlmMessage, LlmTextRequest}; use serde_json::{Map, Value, json}; use shared_contracts::runtime_story::{ RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel, RuntimeStoryViewModel, }; use shared_kernel::{format_rfc3339, 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, }; const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure"; const MAX_TASK5_COMPANIONS: usize = 2; struct StoryResolution { action_text: String, result_text: String, story_text: Option, presentation_options: Option>, saved_current_story: Option, patches: Vec, battle: Option, toast: Option, } struct CurrentEncounterNpcQuestContext { npc_id: String, npc_name: String, } struct PendingQuestOfferContext { dialogue: Vec, turn_count: i32, custom_input_placeholder: String, quest: Value, quest_id: String, intro_text: Option, } 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 story_text = resolution .story_text .clone() .unwrap_or_else(|| resolution.result_text.clone()); let saved_current_story = resolution .saved_current_story .take() .unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options)); append_story_history( &mut game_state, resolution.action_text.as_str(), resolution.result_text.as_str(), ); let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { action_text: resolution.action_text.clone(), result_text: resolution.result_text.clone(), }]; 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 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); build_runtime_story_action_response(RuntimeStoryActionResponseParts { requested_session_id: session_id, server_version, snapshot, action_text: String::new(), result_text: String::new(), story_text, options, patches: Vec::new(), toast: None, battle: None, }) } struct RuntimeStoryActionResponseParts { requested_session_id: String, server_version: u32, snapshot: RuntimeStorySnapshotPayload, action_text: String, result_text: String, story_text: String, options: Vec, patches: Vec, toast: Option, battle: Option, } fn build_runtime_story_action_response( parts: RuntimeStoryActionResponseParts, ) -> RuntimeStoryActionResponse { let session_id = read_runtime_session_id(&parts.snapshot.game_state) .unwrap_or_else(|| parts.requested_session_id); RuntimeStoryActionResponse { session_id, server_version: parts.server_version, view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options), presentation: RuntimeStoryPresentation { action_text: parts.action_text, result_text: parts.result_text, story_text: parts.story_text, options: parts.options, toast: parts.toast, battle: parts.battle, }, patches: parts.patches, snapshot: parts.snapshot, } } fn build_runtime_story_view_model( game_state: &Value, options: &[RuntimeStoryOptionView], ) -> RuntimeStoryViewModel { RuntimeStoryViewModel { player: RuntimeStoryPlayerViewModel { hp: read_i32_field(game_state, "playerHp").unwrap_or(0), max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1), mana: read_i32_field(game_state, "playerMana").unwrap_or(0), max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1), }, encounter: build_runtime_story_encounter(game_state), companions: build_runtime_story_companions(game_state), available_options: options.to_vec(), status: RuntimeStoryStatusViewModel { in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false), npc_interaction_active: read_bool_field(game_state, "npcInteractionActive") .unwrap_or(false), current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), current_npc_battle_outcome: read_optional_string_field( game_state, "currentNpcBattleOutcome", ), }, } } fn resolve_runtime_story_choice_action( game_state: &mut Value, current_story: Option<&Value>, request: &RuntimeStoryActionRequest, function_id: &str, ) -> Result { 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_recruit" => resolve_npc_recruit_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" => resolve_battle_action(game_state, request, function_id), "treasure_secure" | "treasure_inspect" | "treasure_leave" => { resolve_treasure_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 resolve_npc_preview_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc_name = current_encounter_name(game_state); write_bool_field(game_state, "npcInteractionActive", true); 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)], battle: None, toast: None, }) } fn resolve_npc_affinity_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, default_action_text: &str, affinity_delta: i32, fallback_result_text: &str, ) -> Result { write_bool_field(game_state, "npcInteractionActive", true); let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map( |(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged { npc_id, previous_affinity, next_affinity, }, ); let mut patches = Vec::new(); if let Some(patch) = affinity_patch { patches.push(patch); } patches.push(build_status_patch(game_state)); Ok(StoryResolution { action_text: resolve_action_text(default_action_text, request), result_text: fallback_result_text.to_string(), story_text: None, presentation_options: None, saved_current_story: None, patches, battle: None, toast: None, }) } fn resolve_npc_chat_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0); let affinity_gain = (6 - chatted_count).max(2); let result_text = format!( "{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。", current_encounter_name(game_state), affinity_gain ); let mut resolution = resolve_npc_affinity_action( game_state, request, "继续交谈", affinity_gain, result_text.as_str(), )?; write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1)); write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state)); Ok(resolution) } fn resolve_npc_help_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) { return Err("当前 NPC 的一次性援手已经用完了".to_string()); } restore_player_resource(game_state, 10, 8); write_current_npc_state_bool_field(game_state, "helpUsed", true); resolve_npc_affinity_action( game_state, request, &format!("向{}请求援手", current_encounter_name(game_state)), 4, &format!( "{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。", current_encounter_name(game_state) ), ) } fn resolve_pending_quest_offer_view_action( game_state: &mut Value, current_story: Option<&Value>, request: &RuntimeStoryActionRequest, ) -> Result { let encounter = current_encounter_npc_quest_context(game_state)?; let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) .ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?; Ok(StoryResolution { action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request), result_text: pending_offer.intro_text.clone().unwrap_or_else(|| { build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest) }), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![], battle: None, toast: None, }) } fn resolve_pending_quest_offer_replace_action( game_state: &mut Value, current_story: Option<&Value>, request: &RuntimeStoryActionRequest, ) -> Result { let encounter = current_encounter_npc_quest_context(game_state)?; let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) .ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?; let next_quest = build_next_pending_quest_offer( game_state, encounter.npc_id.as_str(), encounter.npc_name.as_str(), Some(pending_offer.quest_id.as_str()), ); let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest); let dialogue = append_dialogue_turns( pending_offer.dialogue.as_slice(), vec![ json!({ "speaker": "player", "text": "能不能换一份更适合眼下局势的委托?" }), json!({ "speaker": "npc", "speakerName": encounter.npc_name, "text": quest_text, }), ], ); let options = build_pending_quest_offer_options(encounter.npc_id.as_str()); let saved_current_story = build_pending_quest_offer_story( dialogue, encounter.npc_id.as_str(), encounter.npc_name.as_str(), pending_offer.turn_count, pending_offer.custom_input_placeholder.as_str(), Some(next_quest.clone()), options.as_slice(), ); Ok(StoryResolution { action_text: resolve_action_text(&format!("请{}更换委托", encounter.npc_name), request), result_text: quest_text.clone(), story_text: Some(quest_text), presentation_options: Some(options), saved_current_story: Some(saved_current_story), patches: vec![], battle: None, toast: None, }) } fn resolve_pending_quest_offer_abandon_action( game_state: &mut Value, current_story: Option<&Value>, request: &RuntimeStoryActionRequest, ) -> Result { let encounter = current_encounter_npc_quest_context(game_state)?; let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) .ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?; let npc_reply = format!( "{}点了点头,没有继续强求,只把这份委托暂时收了回去。", encounter.npc_name ); let dialogue = append_dialogue_turns( pending_offer.dialogue.as_slice(), vec![ json!({ "speaker": "player", "text": "这件事我先不接,咱们还是先聊别的。" }), json!({ "speaker": "npc", "speakerName": encounter.npc_name, "text": npc_reply, }), ], ); let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str()); let saved_current_story = build_pending_quest_offer_story( dialogue, encounter.npc_id.as_str(), encounter.npc_name.as_str(), pending_offer.turn_count, pending_offer.custom_input_placeholder.as_str(), None, options.as_slice(), ); Ok(StoryResolution { action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request), result_text: npc_reply.clone(), story_text: Some(npc_reply), presentation_options: Some(options), saved_current_story: Some(saved_current_story), patches: vec![], battle: None, toast: None, }) } fn resolve_pending_quest_accept_action( game_state: &mut Value, current_story: Option<&Value>, request: &RuntimeStoryActionRequest, ) -> Result { let encounter = current_encounter_npc_quest_context(game_state)?; let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str()) .ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?; if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() { return Err("当前角色已经有未结清的委托。".to_string()); } let quest = pending_offer.quest.clone(); push_quest_record(game_state, &quest); increment_runtime_stat(game_state, "questsAccepted", 1); write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); let reply_text = first_quest_reveal_text(&quest) .map(|text| format!("那就拜托你了。{text}")) .unwrap_or_else(|| { format!( "那就拜托你了。{}", read_optional_string_field(&quest, "summary") .unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string()) ) }); let dialogue = append_dialogue_turns( pending_offer.dialogue.as_slice(), vec![ json!({ "speaker": "player", "text": "这件事我愿意接下,你把关键要点交给我。" }), json!({ "speaker": "npc", "speakerName": encounter.npc_name, "text": reply_text, }), ], ); let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str()); let saved_current_story = build_pending_quest_offer_story( dialogue, encounter.npc_id.as_str(), encounter.npc_name.as_str(), pending_offer.turn_count, pending_offer.custom_input_placeholder.as_str(), None, options.as_slice(), ); Ok(StoryResolution { action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request), result_text: build_quest_accept_result_text(&quest), story_text: Some( saved_current_story["text"] .as_str() .unwrap_or_default() .to_string(), ), presentation_options: Some(options), saved_current_story: Some(saved_current_story), patches: vec![], battle: None, toast: None, }) } fn resolve_pending_quest_turn_in_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let encounter = current_encounter_npc_quest_context(game_state)?; let quest_id = request .action .payload .as_ref() .and_then(|payload| read_optional_string_field(payload, "questId")) .or_else(|| request.action.target_id.clone()) .or_else(|| { find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()) .and_then(|quest| read_optional_string_field(quest, "id")) }) .ok_or_else(|| "当前没有可交付的委托。".to_string())?; let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?; let previous_affinity = read_current_npc_affinity(game_state); let affinity_bonus = read_field(&turned_in, "reward") .and_then(|reward| read_i32_field(reward, "affinityBonus")) .unwrap_or(0); let next_affinity = previous_affinity.saturating_add(affinity_bonus); write_current_npc_state_i32_field(game_state, "affinity", next_affinity); write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); apply_quest_turn_in_rewards(game_state, &turned_in); Ok(StoryResolution { action_text: resolve_action_text(&format!("向{}交付委托", encounter.npc_name), request), result_text: build_quest_turn_in_result_text(&turned_in), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![RuntimeStoryPatch::NpcAffinityChanged { npc_id: encounter.npc_id, previous_affinity, next_affinity, }], battle: None, toast: None, }) } fn resolve_npc_battle_entry_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, function_id: &str, ) -> Result { let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string()); let npc_name = current_encounter_name(game_state); let battle_mode = if function_id == "npc_spar" { "spar" } else { "fight" }; write_bool_field(game_state, "inBattle", true); write_bool_field(game_state, "npcInteractionActive", false); write_string_field(game_state, "currentBattleNpcId", npc_id.as_str()); write_string_field(game_state, "currentNpcBattleMode", battle_mode); write_null_field(game_state, "currentNpcBattleOutcome"); Ok(StoryResolution { action_text: resolve_action_text( if battle_mode == "spar" { "点到为止切磋" } else { "与对方战斗" }, request, ), result_text: format!( "{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。", battle_mode_text(battle_mode) ), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: Some(RuntimeBattlePresentation { target_id: Some(npc_id), target_name: Some(npc_name), damage_dealt: None, damage_taken: None, outcome: Some("ongoing".to_string()), }), toast: None, }) } fn resolve_npc_recruit_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string()); let npc_name = current_encounter_name(game_state); let current_affinity = read_current_npc_affinity(game_state); if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) { return Err("当前 NPC 已经处于已招募状态".to_string()); } if current_affinity < 60 { return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string()); } let release_npc_id = request .action .payload .as_ref() .and_then(|payload| read_optional_string_field(payload, "releaseNpcId")); let released_companion_name = recruit_companion_to_party( game_state, npc_id.as_str(), npc_name.as_str(), release_npc_id.as_deref(), )?; let affinity_patch = set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| { RuntimeStoryPatch::NpcAffinityChanged { npc_id: npc_id.clone(), previous_affinity, next_affinity, } }); write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true); write_bool_field(game_state, "npcInteractionActive", false); clear_encounter_only(game_state); write_null_field(game_state, "currentNpcBattleMode"); write_null_field(game_state, "currentNpcBattleOutcome"); write_bool_field(game_state, "inBattle", false); let mut patches = Vec::new(); if let Some(patch) = affinity_patch { patches.push(patch); } patches.push(build_status_patch(game_state)); patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None }); Ok(StoryResolution { action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request), result_text: match released_companion_name { Some(released_name) => format!( "{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。" ), None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"), }, story_text: None, presentation_options: None, saved_current_story: None, patches, battle: None, toast: Some(format!("{npc_name} 已加入队伍")), }) } fn resolve_battle_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, function_id: &str, ) -> Result { let target_id = current_encounter_id(game_state) .or_else(|| first_hostile_npc_string_field(game_state, "id")) .unwrap_or_else(|| "battle_target".to_string()); let target_name = current_encounter_name_from_battle(game_state); let battle_mode = read_optional_string_field(game_state, "currentNpcBattleMode") .unwrap_or_else(|| "fight".to_string()); if function_id == "battle_escape_breakout" { clear_encounter_state(game_state); return Ok(StoryResolution { action_text: resolve_action_text("强行脱离战斗", request), result_text: "你抓住空隙强行脱离战斗,把这一轮危险先甩在身后。".to_string(), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![ RuntimeStoryPatch::BattleResolved { function_id: function_id.to_string(), target_id: Some(target_id.clone()), damage_dealt: Some(0), damage_taken: Some(0), outcome: "escaped".to_string(), }, build_status_patch(game_state), RuntimeStoryPatch::EncounterChanged { encounter_id: None }, ], battle: Some(RuntimeBattlePresentation { target_id: Some(target_id), target_name: Some(target_name), damage_dealt: Some(0), damage_taken: Some(0), outcome: Some("escaped".to_string()), }), toast: Some("已脱离战斗".to_string()), }); } let (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) = battle_action_numbers(function_id); spend_player_mana(game_state, mana_cost); restore_player_resource(game_state, heal, mana_restore); apply_player_damage(game_state, damage_taken); let target_hp = apply_target_damage(game_state, damage_dealt); let outcome = if target_hp <= 0 { if battle_mode == "spar" { "spar_complete" } else { "victory" } } else { "ongoing" }; if outcome != "ongoing" { write_bool_field(game_state, "inBattle", false); write_bool_field(game_state, "npcInteractionActive", false); write_null_field(game_state, "currentNpcBattleMode"); write_string_field( game_state, "currentNpcBattleOutcome", if outcome == "spar_complete" { "spar_complete" } else { "fight_victory" }, ); if outcome == "victory" { clear_encounter_only(game_state); } } let mut patches = vec![ RuntimeStoryPatch::BattleResolved { function_id: function_id.to_string(), target_id: Some(target_id.clone()), damage_dealt: Some(damage_dealt), damage_taken: Some(damage_taken), outcome: outcome.to_string(), }, build_status_patch(game_state), ]; if outcome == "victory" { patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None }); } Ok(StoryResolution { action_text: resolve_action_text(action_text, request), result_text: if outcome == "ongoing" { result_text.to_string() } else if outcome == "spar_complete" { format!("{target_name} 收住了最后一击,这场切磋已经分出结果。") } else { format!("{target_name} 被你压制下去,眼前的战斗已经结束。") }, story_text: None, presentation_options: None, saved_current_story: None, patches, battle: Some(RuntimeBattlePresentation { target_id: Some(target_id), target_name: Some(target_name), damage_dealt: Some(damage_dealt), damage_taken: Some(damage_taken), outcome: Some(outcome.to_string()), }), toast: None, }) } fn resolve_treasure_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, function_id: &str, ) -> Result { match function_id { "treasure_secure" => { 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: Some("已收取宝箱".to_string()), }) } "treasure_inspect" => Ok(simple_story_resolution( game_state, resolve_action_text("仔细检查", request), "你没有急着伸手,而是绕着目标重新检查机关、痕迹和可能的埋伏。", )), "treasure_leave" => { 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, }) } _ => Err(format!("暂不支持的 treasure action:{function_id}")), } } fn simple_story_resolution( game_state: &Value, action_text: String, result_text: &str, ) -> StoryResolution { StoryResolution { action_text, result_text: result_text.to_string(), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: None, } } async fn build_runtime_story_ai_response( state: &AppState, payload: RuntimeStoryAiRequest, initial: bool, ) -> RuntimeStoryAiResponse { let options = build_ai_response_options(&payload); let fallback = build_ai_fallback_story_text(&payload, initial); let story_text = generate_ai_story_text(state, &payload, initial) .await .filter(|text| !text.trim().is_empty()) .unwrap_or(fallback); RuntimeStoryAiResponse { story_text, options, encounter: None, } } async fn generate_ai_story_text( state: &AppState, payload: &RuntimeStoryAiRequest, initial: bool, ) -> Option { let llm_client = state.llm_client()?; let system_prompt = if initial { "你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。" } else { "你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。" }; let user_prompt = json!({ "worldType": payload.world_type, "character": payload.character, "monsters": payload.monsters, "history": payload.history, "choice": payload.choice, "context": payload.context, "availableOptions": payload.request_options.available_options, }) .to_string(); let mut request = LlmTextRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]); request.max_tokens = Some(700); llm_client .request_text(request) .await .ok() .map(|response| response.content.trim().to_string()) .filter(|text| !text.is_empty()) } fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec { let source = if payload.request_options.available_options.is_empty() { &payload.request_options.option_catalog } else { &payload.request_options.available_options }; let options = source .iter() .filter_map(normalize_ai_story_option) .collect::>(); if !options.is_empty() { return options; } vec![ build_ai_story_option_value("idle_observe_signs", "观察周围迹象"), build_ai_story_option_value("idle_explore_forward", "继续向前探索"), build_ai_story_option_value("idle_rest_focus", "原地调息"), ] } fn normalize_ai_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()); let mut option = value.as_object()?.clone(); option.insert("functionId".to_string(), Value::String(function_id)); option.insert("actionText".to_string(), Value::String(action_text.clone())); option .entry("text".to_string()) .or_insert_with(|| Value::String(action_text)); Some(Value::Object(option)) } fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value { json!({ "functionId": function_id, "actionText": action_text, "text": action_text, "visuals": { "playerAnimation": "idle", "playerMoveMeters": 0, "playerOffsetY": 0, "playerFacing": "right", "scrollWorld": false, "monsterChanges": [] } }) } fn build_ai_fallback_story_text(payload: &RuntimeStoryAiRequest, initial: bool) -> String { let character_name = read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "你".to_string()); let scene_name = read_optional_string_field(&payload.context, "sceneName") .or_else(|| read_optional_string_field(&payload.context, "scene")) .unwrap_or_else(|| "当前区域".to_string()); if initial { return format!( "{character_name} 在 {scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。" ); } let choice = normalize_required_string(payload.choice.as_str()) .unwrap_or_else(|| "继续推进".to_string()); format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。") } 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") .or_else(|| read_required_string_field(encounter, "name")) .unwrap_or_else(|| "当前遭遇".to_string()); 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") .or_else(|| read_field(value, "payload")) .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); let npc_id = read_required_string_field(encounter, "id") .unwrap_or_else(|| "npc_current".to_string()); if let Some(active_quest) = find_active_quest_for_issuer(game_state, npc_id.as_str()) { if read_optional_string_field(active_quest, "status") .is_some_and(|status| status == "completed") { return vec![ build_npc_runtime_story_option_with_quest( "npc_quest_turn_in", &format!("向{}交付委托", current_encounter_name(game_state)), &npc_id, "quest_turn_in", read_optional_string_field(active_quest, "id"), ), build_npc_runtime_story_option( "npc_leave", "离开当前角色", &npc_id, "leave", ), ]; } } if interaction_active { return vec![ build_npc_runtime_story_option("npc_chat", "继续交谈", &npc_id, "chat"), build_npc_runtime_story_option("npc_help", "请求援手", &npc_id, "help"), build_npc_runtime_story_option( "npc_recruit", "邀请同行", &npc_id, "recruit", ), build_npc_runtime_story_option("npc_spar", "点到为止切磋", &npc_id, "spar"), build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"), build_npc_runtime_story_option( "npc_leave", "离开当前角色", &npc_id, "leave", ), ]; } return vec![ build_npc_runtime_story_option( "npc_preview_talk", "转向眼前角色", &npc_id, "chat", ), build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"), build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"), ]; } Some("treasure") => { return vec![ build_treasure_runtime_story_option("treasure_secure", "直接收取", "secure"), build_treasure_runtime_story_option("treasure_inspect", "仔细检查", "inspect"), build_treasure_runtime_story_option("treasure_leave", "先记下位置", "leave"), ]; } _ => {} } } 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(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "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 build_npc_runtime_story_option( function_id: &str, action_text: &str, npc_id: &str, action: &str, ) -> RuntimeStoryOptionView { RuntimeStoryOptionView { interaction: Some(RuntimeStoryOptionInteraction::Npc { npc_id: npc_id.to_string(), action: action.to_string(), quest_id: None, }), ..build_static_runtime_story_option(function_id, action_text, "npc") } } fn build_npc_runtime_story_option_with_payload( function_id: &str, action_text: &str, npc_id: &str, action: &str, payload: Value, ) -> RuntimeStoryOptionView { RuntimeStoryOptionView { payload: Some(payload), ..build_npc_runtime_story_option(function_id, action_text, npc_id, action) } } fn build_npc_runtime_story_option_with_quest( function_id: &str, action_text: &str, npc_id: &str, action: &str, quest_id: Option, ) -> RuntimeStoryOptionView { RuntimeStoryOptionView { interaction: Some(RuntimeStoryOptionInteraction::Npc { npc_id: npc_id.to_string(), action: action.to_string(), quest_id, }), ..build_static_runtime_story_option(function_id, action_text, "npc") } } fn build_treasure_runtime_story_option( function_id: &str, action_text: &str, action: &str, ) -> RuntimeStoryOptionView { RuntimeStoryOptionView { interaction: Some(RuntimeStoryOptionInteraction::Treasure { action: action.to_string(), }), ..build_static_runtime_story_option(function_id, action_text, "story") } } fn current_encounter_npc_quest_context( game_state: &Value, ) -> Result { let encounter = read_object_field(game_state, "currentEncounter") .ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?; let kind = read_required_string_field(encounter, "kind") .ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?; if kind != "npc" { return Err("当前不在可结算的 NPC 委托态。".to_string()); } let npc_name = read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) .unwrap_or_else(|| "当前角色".to_string()); let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none() { return Err("当前 NPC 状态不存在,无法处理委托。".to_string()); } Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name }) } fn read_pending_quest_offer_context( current_story: Option<&Value>, npc_key: &str, ) -> Option { let current_story = current_story?; let npc_chat_state = read_object_field(current_story, "npcChatState")?; let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?; let quest = read_object_field(pending_offer, "quest")?.clone(); let quest_id = read_optional_string_field(&quest, "id")?; let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId"); let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId"); if pending_npc_id .as_deref() .is_some_and(|value| value != npc_key) { return None; } if issuer_npc_id .as_deref() .is_some_and(|value| value != npc_key) { return None; } Some(PendingQuestOfferContext { dialogue: read_array_field(current_story, "dialogue") .into_iter() .cloned() .collect(), turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0), custom_input_placeholder: read_optional_string_field( npc_chat_state, "customInputPlaceholder", ) .unwrap_or_else(|| "输入你想对 TA 说的话".to_string()), quest, quest_id, intro_text: read_optional_string_field(pending_offer, "introText"), }) } fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String { let summary_text = read_optional_string_field(quest, "summary") .or_else(|| read_optional_string_field(quest, "description")) .unwrap_or_default(); if summary_text.is_empty() { return format!( "{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。" ); } format!( "{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}" ) } fn append_dialogue_turns(existing: &[Value], additions: Vec) -> Vec { let mut dialogue = existing.to_vec(); dialogue.extend(additions); dialogue } fn build_pending_quest_offer_options(npc_id: &str) -> Vec { vec![ build_npc_runtime_story_option_with_payload( "npc_chat_quest_offer_view", "查看任务", npc_id, "quest_offer_view", json!({ "npcChatQuestOfferAction": "view" }), ), build_npc_runtime_story_option_with_payload( "npc_chat_quest_offer_replace", "更换任务", npc_id, "quest_offer_replace", json!({ "npcChatQuestOfferAction": "replace" }), ), build_npc_runtime_story_option_with_payload( "npc_chat_quest_offer_abandon", "放弃任务", npc_id, "quest_offer_abandon", json!({ "npcChatQuestOfferAction": "abandon" }), ), ] } fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec { vec![ build_npc_runtime_story_option( "npc_chat", "那先继续聊聊你刚才没说完的部分", npc_id, "chat", ), build_npc_runtime_story_option( "npc_chat", "除了委托,你对眼前局势还有什么判断", npc_id, "chat", ), build_npc_runtime_story_option( "npc_chat", "先把这附近真正危险的地方说清楚", npc_id, "chat", ), ] } fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec { vec![ build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"), build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"), build_npc_runtime_story_option( "npc_chat", "除了这份委托,你还想提醒我什么", npc_id, "chat", ), ] } fn build_pending_quest_offer_story( dialogue: Vec, npc_id: &str, npc_name: &str, turn_count: i32, custom_input_placeholder: &str, pending_quest: Option, options: &[RuntimeStoryOptionView], ) -> Value { json!({ "text": dialogue .iter() .filter_map(|entry| read_optional_string_field(entry, "text")) .collect::>() .join("\n"), "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), "displayMode": "dialogue", "dialogue": dialogue, "streaming": false, "npcChatState": { "npcId": npc_id, "npcName": npc_name, "turnCount": turn_count, "customInputPlaceholder": custom_input_placeholder, "pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })), } }) } fn build_next_pending_quest_offer( game_state: &Value, npc_id: &str, npc_name: &str, previous_quest_id: Option<&str>, ) -> Value { let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") { "quest-bridge-replaced" } else { "quest-generated-replaced" }; let title = if next_id == "quest-bridge-replaced" { "断桥夜巡" } else { "新的临时委托" }; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); json!({ "id": next_id, "issuerNpcId": npc_id, "issuerNpcName": npc_name, "sceneId": scene_id, "title": title, "description": format!("{title}的详细说明。"), "summary": format!("{title}的简要目标。"), "objective": { "kind": "inspect_treasure", "requiredCount": 1 }, "progress": 0, "status": "active", "reward": { "affinityBonus": 6, "currency": 30, "items": [] }, "rewardText": "完成后可以领取报酬。", "steps": [{ "id": format!("{next_id}-step-1"), "title": "查清线索", "kind": "inspect_treasure", "requiredCount": 1, "progress": 0, "revealText": "先去断桥口附近看看留下了什么痕迹。", "completeText": "线索已经查清。" }], "activeStepId": format!("{next_id}-step-1") }) } fn find_active_quest_for_issuer<'a>( game_state: &'a Value, issuer_npc_id: &str, ) -> Option<&'a Value> { read_array_field(game_state, "quests") .into_iter() .find(|quest| { read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id) && read_optional_string_field(quest, "status") .is_some_and(|status| status != "turned_in") }) } fn push_quest_record(game_state: &mut Value, quest: &Value) { let root = ensure_json_object(game_state); let quests = root .entry("quests".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !quests.is_array() { *quests = Value::Array(Vec::new()); } quests .as_array_mut() .expect("quests should be array") .push(quest.clone()); } fn first_quest_reveal_text(quest: &Value) -> Option { read_array_field(quest, "steps") .first() .and_then(|step| read_optional_string_field(step, "revealText")) } fn build_quest_accept_result_text(quest: &Value) -> String { let issuer_name = read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string()); let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string()); format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。") } fn turn_in_quest_record( game_state: &mut Value, issuer_npc_id: &str, quest_id: &str, ) -> Result { let root = ensure_json_object(game_state); let quests = root .entry("quests".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !quests.is_array() { *quests = Value::Array(Vec::new()); } let quests = quests.as_array_mut().expect("quests should be array"); let Some(index) = quests.iter().position(|quest| { read_optional_string_field(quest, "id").as_deref() == Some(quest_id) && read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id) }) else { return Err("当前没有可交付的委托。".to_string()); }; let mut turned_in = quests[index].clone(); if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") { return Err("这份委托还没有达到可交付状态。".to_string()); } if let Some(object) = turned_in.as_object_mut() { object.insert("status".to_string(), Value::String("turned_in".to_string())); object.insert("completionNotified".to_string(), Value::Bool(true)); if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) { for step in steps.iter_mut() { let required_count = read_i32_field(step, "requiredCount").unwrap_or(0); if let Some(step_object) = step.as_object_mut() { step_object.insert("progress".to_string(), json!(required_count.max(0))); } } } } quests[index] = turned_in.clone(); Ok(turned_in) } fn build_quest_turn_in_result_text(quest: &Value) -> String { let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string()); let reward_text = read_optional_string_field(quest, "rewardText") .unwrap_or_else(|| "报酬已经结清。".to_string()); format!("你已经完成并交付了「{title}」。{reward_text}") } fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) { let Some(reward) = read_field(quest, "reward") else { return; }; let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0); if currency > 0 { add_player_currency(game_state, currency); } let reward_items = read_array_field(reward, "items") .into_iter() .cloned() .collect::>(); if !reward_items.is_empty() { add_player_inventory_items(game_state, reward_items); } let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0); if experience > 0 { grant_player_progression_experience(game_state, experience, "quest"); } } 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 build_legacy_current_story(story_text: &str, options: &[RuntimeStoryOptionView]) -> Value { json!({ "text": story_text, "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), "streaming": false }) } fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value { json!({ "functionId": option.function_id, "actionText": option.action_text, "text": option.action_text, "detailText": option.detail_text, "visuals": { "playerAnimation": "idle", "playerMoveMeters": 0, "playerOffsetY": 0, "playerFacing": "right", "scrollWorld": false, "monsterChanges": [] }, "interaction": option.interaction, "runtimePayload": option.payload, "disabled": option.disabled, "disabledReason": option.reason, }) } 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 resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String { request .action .payload .as_ref() .and_then(|payload| read_optional_string_field(payload, "optionText")) .unwrap_or_else(|| default_text.to_string()) } fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch { RuntimeStoryPatch::StatusChanged { in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false), npc_interaction_active: read_bool_field(game_state, "npcInteractionActive") .unwrap_or(false), current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), current_npc_battle_outcome: read_optional_string_field( game_state, "currentNpcBattleOutcome", ), } } fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) { let max_hp = read_i32_field(game_state, "playerMaxHp") .unwrap_or(1) .max(1); let max_mana = read_i32_field(game_state, "playerMaxMana") .unwrap_or(0) .max(0); let hp = read_i32_field(game_state, "playerHp").unwrap_or(max_hp); let mana = read_i32_field(game_state, "playerMana").unwrap_or(max_mana); write_i32_field(game_state, "playerHp", (hp + hp_restore).clamp(0, max_hp)); write_i32_field( game_state, "playerMana", (mana + mana_restore).clamp(0, max_mana), ); } fn spend_player_mana(game_state: &mut Value, mana_cost: i32) { if mana_cost <= 0 { return; } let mana = read_i32_field(game_state, "playerMana").unwrap_or(0); write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0)); } fn apply_player_damage(game_state: &mut Value, damage: i32) { if damage <= 0 { return; } let hp = read_i32_field(game_state, "playerHp").unwrap_or(1); write_i32_field(game_state, "playerHp", (hp - damage).max(1)); } fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 { let target_hp = read_object_field(game_state, "currentEncounter") .and_then(|encounter| { read_i32_field(encounter, "hp") .or_else(|| read_i32_field(encounter, "currentHp")) .or_else(|| read_i32_field(encounter, "targetHp")) }) .or_else(|| { read_array_field(game_state, "sceneHostileNpcs") .first() .and_then(|target| read_i32_field(target, "hp")) }) .unwrap_or(24); let next_hp = target_hp - damage.max(0); write_current_encounter_i32_field(game_state, "hp", next_hp); write_first_hostile_npc_i32_field(game_state, "hp", next_hp); next_hp } fn battle_action_numbers( function_id: &str, ) -> (i32, i32, i32, i32, i32, &'static str, &'static str) { match function_id { "battle_recover_breath" => ( 0, 0, 8, 6, 0, "恢复", "你先稳住呼吸,把状态从危险边缘拉回一点。", ), "battle_use_skill" => ( 14, 4, 0, 0, 4, "施放技能", "你调动灵力打出一记更重的攻势,同时也承受了对方的反扑。", ), "battle_all_in_crush" => ( 22, 8, 0, 0, 6, "全力压制", "你把这一轮节奏全部压上去,试图用最强硬的方式打穿对方防线。", ), "battle_feint_step" => ( 6, 2, 0, 0, 0, "佯攻换位", "你用一次短促佯攻换开角度,虽然伤害有限,但避开了更重的反击。", ), "battle_finisher_window" => ( 18, 3, 0, 0, 3, "抓住终结窗口", "你抓住破绽打出决定性的一击,战斗天平明显向你倾斜。", ), "battle_guard_break" => ( 12, 5, 0, 0, 2, "破开防守", "你顶住压力破开对方防守,为后续行动争到更直接的窗口。", ), "battle_probe_pressure" => ( 5, 1, 0, 0, 0, "试探压迫", "你没有贸然压上,而是用轻攻测试对方反应。", ), _ => ( 10, 4, 0, 0, 0, "普通攻击", "你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。", ), } } fn battle_mode_text(value: &str) -> &'static str { if value == "spar" { "切磋" } else { "战斗" } } fn current_encounter_name(game_state: &Value) -> String { read_object_field(game_state, "currentEncounter") .and_then(|encounter| { read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) }) .unwrap_or_else(|| "对方".to_string()) } fn current_encounter_name_from_battle(game_state: &Value) -> String { read_object_field(game_state, "currentEncounter") .and_then(|encounter| { read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) }) .or_else(|| first_hostile_npc_string_field(game_state, "name")) .unwrap_or_else(|| "眼前的敌人".to_string()) } fn current_encounter_id(game_state: &Value) -> Option { read_object_field(game_state, "currentEncounter") .and_then(|encounter| read_optional_string_field(encounter, "id")) } fn adjust_current_npc_affinity(game_state: &mut Value, delta: i32) -> Option<(String, i32, i32)> { let npc_id = current_encounter_id(game_state)?; let npc_name = current_encounter_name(game_state); let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); let previous_affinity = state .get("affinity") .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) .unwrap_or(0); let next_affinity = (previous_affinity + delta).clamp(-100, 100); state.insert("affinity".to_string(), json!(next_affinity)); state .entry("recruited".to_string()) .or_insert(Value::Bool(false)); Some((npc_id, previous_affinity, next_affinity)) } fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option { let npc_id = current_encounter_id(game_state)?; let npc_name = current_encounter_name(game_state); resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) .and_then(|state| read_i32_field(state, key)) } fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option { let npc_id = current_encounter_id(game_state)?; let npc_name = current_encounter_name(game_state); resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) .and_then(|state| read_bool_field(state, key)) } fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) { let Some(npc_id) = current_encounter_id(game_state) else { return; }; let npc_name = current_encounter_name(game_state); let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); state.insert(key.to_string(), json!(value)); } fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) { let Some(npc_id) = current_encounter_id(game_state) else { return; }; let npc_name = current_encounter_name(game_state); let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); state.insert(key.to_string(), Value::Bool(value)); } fn set_current_npc_recruited(game_state: &mut Value, recruited: bool) -> Option<(i32, i32)> { let npc_id = current_encounter_id(game_state)?; let npc_name = current_encounter_name(game_state); let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str()); let previous_affinity = state .get("affinity") .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) .unwrap_or(0); let next_affinity = previous_affinity.max(60); state.insert("affinity".to_string(), json!(next_affinity)); state.insert("recruited".to_string(), Value::Bool(recruited)); Some((previous_affinity, next_affinity)) } fn read_current_npc_affinity(game_state: &Value) -> i32 { let Some(npc_id) = current_encounter_id(game_state) else { return 0; }; let npc_name = current_encounter_name(game_state); resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()) .and_then(|state| read_i32_field(state, "affinity")) .unwrap_or(0) } fn ensure_npc_state_object<'a>( game_state: &'a mut Value, npc_id: &str, npc_name: &str, ) -> &'a mut Map { let root = ensure_json_object(game_state); let npc_states = root .entry("npcStates".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !npc_states.is_object() { *npc_states = Value::Object(Map::new()); } let states = npc_states .as_object_mut() .expect("npcStates should be object"); let existing_key = if states.contains_key(npc_id) { npc_id.to_string() } else if states.contains_key(npc_name) { npc_name.to_string() } else { npc_id.to_string() }; let state = states .entry(existing_key) .or_insert_with(|| Value::Object(Map::new())); if !state.is_object() { *state = Value::Object(Map::new()); } state.as_object_mut().expect("npc state should be object") } fn add_companion_if_absent( game_state: &mut Value, npc_id: &str, character_id: Option, joined_at_affinity: i32, ) { let root = ensure_json_object(game_state); let companions = root .entry("companions".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !companions.is_array() { *companions = Value::Array(Vec::new()); } let items = companions .as_array_mut() .expect("companions should be array"); if items .iter() .any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)) { return; } items.push(json!({ "npcId": npc_id, "characterId": character_id, "joinedAtAffinity": joined_at_affinity, })); } fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option { let root = ensure_json_object(game_state); let companions = root .entry("companions".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !companions.is_array() { *companions = Value::Array(Vec::new()); } let items = companions .as_array_mut() .expect("companions should be array"); let index = items.iter().position(|item| { read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id) })?; Some(items.remove(index)) } fn recruit_companion_to_party( game_state: &mut Value, npc_id: &str, _npc_name: &str, release_npc_id: Option<&str>, ) -> Result, String> { let companion_count = read_array_field(game_state, "companions").len(); if companion_count < MAX_TASK5_COMPANIONS { add_companion_if_absent( game_state, npc_id, None, read_current_npc_affinity(game_state), ); return Ok(None); } let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else { return Err("队伍已满时必须明确指定一名离队同伴".to_string()); }; let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str()) .ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?; let released_name = read_optional_string_field(&released_companion, "displayName") .or_else(|| read_optional_string_field(&released_companion, "name")) .or_else(|| read_optional_string_field(&released_companion, "npcName")) .unwrap_or_else(|| release_npc_id.clone()); add_companion_if_absent( game_state, npc_id, None, read_current_npc_affinity(game_state), ); Ok(Some(released_name)) } fn clear_encounter_state(game_state: &mut Value) { clear_encounter_only(game_state); write_bool_field(game_state, "inBattle", false); write_bool_field(game_state, "npcInteractionActive", false); write_null_field(game_state, "currentNpcBattleMode"); } fn clear_encounter_only(game_state: &mut Value) { write_null_field(game_state, "currentEncounter"); let root = ensure_json_object(game_state); root.insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); } fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) { let root = ensure_json_object(game_state); let story_history = root .entry("storyHistory".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !story_history.is_array() { *story_history = Value::Array(Vec::new()); } let entries = story_history .as_array_mut() .expect("storyHistory should be array"); entries.push(json!({ "text": action_text, "historyRole": "action", })); entries.push(json!({ "text": result_text, "historyRole": "result", })); } fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) { let root = ensure_json_object(game_state); let stats = root .entry("runtimeStats".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !stats.is_object() { *stats = Value::Object(Map::new()); } let stats = stats .as_object_mut() .expect("runtimeStats should be object"); let previous = stats .get(key) .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) .unwrap_or(0); stats.insert(key.to_string(), json!((previous + delta).max(0))); } fn add_player_currency(game_state: &mut Value, delta: i32) { let previous = read_i32_field(game_state, "playerCurrency").unwrap_or(0); write_i32_field( game_state, "playerCurrency", previous.saturating_add(delta.max(0)), ); } fn add_player_inventory_items(game_state: &mut Value, additions: Vec) { if additions.is_empty() { return; } let root = ensure_json_object(game_state); let inventory = root .entry("playerInventory".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !inventory.is_array() { *inventory = Value::Array(Vec::new()); } let items = inventory .as_array_mut() .expect("playerInventory should be array"); items.extend(additions); } fn grant_player_progression_experience(game_state: &mut Value, amount: i32, source: &str) { if amount <= 0 { return; } let root = ensure_json_object(game_state); let progression = root .entry("playerProgression".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !progression.is_object() { *progression = Value::Object(Map::new()); } let progression = progression .as_object_mut() .expect("playerProgression should be object"); let previous_total_xp = progression .get("totalXp") .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) .unwrap_or(0) .max(0); let next_total_xp = previous_total_xp.saturating_add(amount); let level = resolve_progression_level(next_total_xp); let current_level_xp = next_total_xp.saturating_sub(cumulative_xp_required(level)); let xp_to_next_level = if level >= MAX_PLAYER_LEVEL { 0 } else { xp_to_next_level_for(level) }; progression.insert("level".to_string(), json!(level)); progression.insert("currentLevelXp".to_string(), json!(current_level_xp.max(0))); progression.insert("totalXp".to_string(), json!(next_total_xp)); progression.insert("xpToNextLevel".to_string(), json!(xp_to_next_level.max(0))); progression.insert("pendingLevelUps".to_string(), json!(0)); progression.insert( "lastGrantedSource".to_string(), Value::String(source.to_string()), ); } const MAX_PLAYER_LEVEL: i32 = 20; fn xp_to_next_level_for(level: i32) -> i32 { if level >= MAX_PLAYER_LEVEL { 0 } else { let scale = (level - 1).max(0); 60 + 20 * scale + 8 * scale * scale } } fn cumulative_xp_required(level: i32) -> i32 { let mut total = 0; let capped_level = level.clamp(1, MAX_PLAYER_LEVEL); for current_level in 1..capped_level { total += xp_to_next_level_for(current_level); } total } fn resolve_progression_level(total_xp: i32) -> i32 { let normalized_total_xp = total_xp.max(0); let mut resolved_level = 1; for level in 2..=MAX_PLAYER_LEVEL { if normalized_total_xp < cumulative_xp_required(level) { break; } resolved_level = level; } resolved_level } fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) { let root = ensure_json_object(game_state); let Some(encounter) = root.get_mut("currentEncounter") else { return; }; if let Some(encounter) = encounter.as_object_mut() { encounter.insert(key.to_string(), json!(value)); } } fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) { let root = ensure_json_object(game_state); let Some(hostiles) = root.get_mut("sceneHostileNpcs") else { return; }; let Some(first) = hostiles.as_array_mut().and_then(|items| items.first_mut()) else { return; }; if let Some(first) = first.as_object_mut() { first.insert(key.to_string(), json!(value)); } } fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option { read_array_field(game_state, "sceneHostileNpcs") .first() .and_then(|target| read_optional_string_field(target, key)) } 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 write_i32_field(value: &mut Value, key: &str, field_value: i32) { ensure_json_object(value).insert(key.to_string(), json!(field_value)); } fn write_u32_field(value: &mut Value, key: &str, field_value: u32) { ensure_json_object(value).insert(key.to_string(), json!(field_value)); } fn write_bool_field(value: &mut Value, key: &str, field_value: bool) { ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value)); } fn write_string_field(value: &mut Value, key: &str, field_value: &str) { ensure_json_object(value).insert(key.to_string(), Value::String(field_value.to_string())); } fn write_null_field(value: &mut Value, key: &str) { ensure_json_object(value).insert(key.to_string(), Value::Null); } fn ensure_json_object(value: &mut Value) -> &mut Map { if !value.is_object() { *value = Value::Object(Map::new()); } value.as_object_mut().expect("value should be object") } 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 format_now_rfc3339() -> String { format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) } 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)) } #[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 super::*; 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": { "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_get_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/story/state/runtime-main") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn runtime_story_action_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/actions/resolve") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": "runtime-main", "action": { "type": "story_choice", "functionId": "idle_rest_focus" } }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn runtime_story_routes_resolve_through_rust_route_boundary() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let snapshot_payload = json!({ "bottomTab": "adventure", "gameState": build_runtime_story_boundary_game_state_fixture(), "currentStory": { "text": "巡路人看着你,像在等一句开口。", "options": [] } }); let put_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/runtime/save/snapshot") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from(snapshot_payload.to_string())) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(put_response.status(), StatusCode::OK); let state_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/runtime/story/state/runtime-main") .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!(state_response.status(), StatusCode::OK); let state_payload: Value = serde_json::from_slice( &state_response .into_body() .collect() .await .expect("body should collect") .to_bytes(), ) .expect("response should be json"); assert!( state_payload["data"]["viewModel"]["availableOptions"] .as_array() .is_some_and(|options| options .iter() .any(|option| { option["functionId"] == json!("npc_chat") })) ); let action_response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/story/actions/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": 0, "action": { "type": "story_choice", "functionId": "npc_chat" } }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(action_response.status(), StatusCode::OK); let action_payload: Value = serde_json::from_slice( &action_response .into_body() .collect() .await .expect("body should collect") .to_bytes(), ) .expect("response should be json"); assert_eq!(action_payload["data"]["serverVersion"], json!(1)); assert_eq!( action_payload["data"]["viewModel"]["encounter"]["affinity"], json!(52) ); } #[tokio::test] async fn runtime_story_action_resolve_rejects_client_version_conflict() { 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/save/snapshot") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "bottomTab": "adventure", "gameState": { "runtimeSessionId": "runtime-main", "runtimeActionVersion": 5, "playerHp": 20, "playerMaxHp": 30, "playerMana": 4, "playerMaxMana": 12, "storyHistory": [] }, "currentStory": { "text": "旧局势仍然悬着。", "options": [] } }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(put_response.status(), StatusCode::OK); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/story/actions/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": 4, "action": { "type": "story_choice", "functionId": "idle_rest_focus" } }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::CONFLICT); let payload: Value = serde_json::from_slice( &response .into_body() .collect() .await .expect("body should collect") .to_bytes(), ) .expect("response should be json"); assert_eq!(payload["error"]["details"]["clientVersion"], json!(4)); assert_eq!(payload["error"]["details"]["serverVersion"], json!(5)); } #[tokio::test] async fn runtime_story_initial_returns_fallback_without_llm() { 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/initial") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "worldType": "martial", "character": { "name": "林迟" }, "monsters": [], "context": { "sceneName": "旧驿道" }, "requestOptions": { "availableOptions": [{ "functionId": "idle_observe_signs", "actionText": "观察周围迹象" }] } }) .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"]["options"][0]["functionId"], json!("idle_observe_signs") ); assert!( payload["data"]["storyText"] .as_str() .is_some_and(|text| text.contains("林迟")) ); } #[test] fn runtime_story_state_compiler_prefers_dialogue_deferred_options() { let response = build_runtime_story_state_response( "runtime-main", Some(7), RuntimeStorySnapshotPayload { saved_at: None, bottom_tab: "adventure".to_string(), game_state: json!({ "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 }] }), current_story: Some(json!({ "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" } }] })), }, ); assert_eq!(response.session_id, "runtime-main"); assert_eq!(response.server_version, 7); assert_eq!( response .view_model .encounter .as_ref() .expect("encounter should exist") .npc_name, "守火人" ); assert_eq!( response.view_model.available_options[0].function_id, "npc_chat" ); assert!(matches!( response.presentation.options[0].interaction, Some(RuntimeStoryOptionInteraction::Npc { .. }) )); } #[test] fn runtime_story_action_resolution_updates_version_and_history() { let request = RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(3), action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "idle_rest_focus".to_string(), target_id: None, payload: Some(json!({ "optionText": "原地调息" })), }, snapshot: None, }; let mut game_state = json!({ "runtimeSessionId": "runtime-main", "runtimeActionVersion": 3, "playerHp": 10, "playerMaxHp": 30, "playerMana": 2, "playerMaxMana": 12, "storyHistory": [] }); let resolution = resolve_runtime_story_choice_action(&mut game_state, None, &request, "idle_rest_focus") .expect("action should resolve"); let next_version = read_u32_field(&game_state, "runtimeActionVersion") .unwrap_or(3) .saturating_add(1); write_u32_field(&mut game_state, "runtimeActionVersion", next_version); append_story_history( &mut game_state, resolution.action_text.as_str(), resolution.result_text.as_str(), ); assert_eq!(read_i32_field(&game_state, "playerHp"), Some(18)); assert_eq!(read_i32_field(&game_state, "playerMana"), Some(8)); assert_eq!(read_u32_field(&game_state, "runtimeActionVersion"), Some(4)); assert_eq!( read_array_field(&game_state, "storyHistory") .first() .and_then(|entry| read_optional_string_field(entry, "historyRole")), Some("action".to_string()) ); } #[test] fn runtime_story_npc_help_is_one_shot_and_restores_resources() { let request = RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(0), action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "npc_help".to_string(), target_id: None, payload: Some(json!({ "optionText": "请求援手" })), }, snapshot: None, }; let mut game_state = build_runtime_story_boundary_game_state_fixture(); write_i32_field(&mut game_state, "playerHp", 20); write_i32_field(&mut game_state, "playerMana", 4); let first = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help") .expect("first help should resolve"); assert!(first.result_text.contains("及时支援")); assert_eq!(read_i32_field(&game_state, "playerHp"), Some(30)); assert_eq!(read_i32_field(&game_state, "playerMana"), Some(12)); assert_eq!( read_current_npc_state_bool_field(&game_state, "helpUsed"), Some(true) ); let second = resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help"); match second { Ok(_) => panic!("second help should be rejected"), Err(error) => assert_eq!(error, "当前 NPC 的一次性援手已经用完了"), } } #[test] fn runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full() { let request = RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(0), action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "npc_recruit".to_string(), target_id: None, payload: Some(json!({ "optionText": "邀请同行" })), }, snapshot: None, }; let mut low_affinity_state = build_runtime_story_boundary_game_state_fixture(); let error = resolve_runtime_story_choice_action( &mut low_affinity_state, None, &request, "npc_recruit", ); match error { Ok(_) => panic!("low affinity recruit should be rejected"), Err(message) => assert_eq!(message, "当前关系还没达到招募阈值,暂时不能邀请入队"), } let mut full_party_state = build_runtime_story_boundary_game_state_fixture(); write_current_npc_state_i32_field(&mut full_party_state, "affinity", 60); let root = ensure_json_object(&mut full_party_state); root.insert( "companions".to_string(), json!([ { "npcId": "npc-ally-1", "characterId": "char-ally-1", "joinedAtAffinity": 64, "npcName": "旧同伴甲" }, { "npcId": "npc-ally-2", "characterId": "char-ally-2", "joinedAtAffinity": 61, "npcName": "旧同伴乙" } ]), ); let full_party_error = resolve_runtime_story_choice_action( &mut full_party_state, None, &request, "npc_recruit", ); match full_party_error { Ok(_) => panic!("full party recruit should require release target"), Err(message) => assert_eq!(message, "队伍已满时必须明确指定一名离队同伴"), } let request_with_release = RuntimeStoryActionRequest { action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { payload: Some(json!({ "optionText": "邀请同行", "releaseNpcId": "npc-ally-1" })), ..request.action.clone() }, ..request }; let resolution = resolve_runtime_story_choice_action( &mut full_party_state, None, &request_with_release, "npc_recruit", ) .expect("recruit with release target should resolve"); assert!(resolution.result_text.contains("旧同伴甲")); assert_eq!(read_array_field(&full_party_state, "companions").len(), 2); assert!( read_array_field(&full_party_state, "companions") .iter() .any(|entry| { read_optional_string_field(entry, "npcId").as_deref() == Some("npc_merchant_01") }) ); assert_eq!( read_field(&full_party_state, "currentEncounter"), Some(&Value::Null) ); } #[test] fn runtime_story_quest_offer_replace_updates_pending_offer_and_payload() { let request = RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(0), action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "npc_chat_quest_offer_replace".to_string(), target_id: None, payload: Some(json!({ "optionText": "更换任务" })), }, snapshot: None, }; let mut game_state = build_runtime_story_boundary_game_state_fixture(); let current_story = build_runtime_story_pending_quest_offer_fixture( build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), ); let resolution = resolve_runtime_story_choice_action( &mut game_state, Some(¤t_story), &request, "npc_chat_quest_offer_replace", ) .expect("quest replace should resolve"); let saved_current_story = resolution .saved_current_story .expect("quest replace should save current story"); let pending_quest = read_field(&saved_current_story, "npcChatState") .and_then(|state| read_field(state, "pendingQuestOffer")) .and_then(|offer| read_field(offer, "quest")) .expect("pending quest should exist after replace"); assert_eq!( read_optional_string_field(pending_quest, "id"), Some("quest-bridge-replaced".to_string()) ); let options = resolution .presentation_options .expect("quest replace should expose options"); assert_eq!(options.len(), 3); assert_eq!( options[1].payload.as_ref().and_then(|payload| { read_optional_string_field(payload, "npcChatQuestOfferAction") }), Some("replace".to_string()) ); } #[test] fn runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options() { let request = RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(0), action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "npc_chat_quest_offer_abandon".to_string(), target_id: None, payload: Some(json!({ "optionText": "放弃任务" })), }, snapshot: None, }; let mut game_state = build_runtime_story_boundary_game_state_fixture(); let current_story = build_runtime_story_pending_quest_offer_fixture( build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"), ); let resolution = resolve_runtime_story_choice_action( &mut game_state, Some(¤t_story), &request, "npc_chat_quest_offer_abandon", ) .expect("quest abandon should resolve"); let saved_current_story = resolution .saved_current_story .expect("quest abandon should save current story"); assert_eq!( read_field(&saved_current_story, "npcChatState") .and_then(|state| read_field(state, "pendingQuestOffer")), Some(&Value::Null) ); let options = resolution .presentation_options .expect("quest abandon should expose follow-up chat options"); assert_eq!(options.len(), 3); assert!( options .iter() .all(|option| option.function_id == "npc_chat") ); assert_eq!(options[0].action_text, "那先继续聊聊你刚才没说完的部分"); } #[test] fn runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story() { let request = RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(0), action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "npc_quest_accept".to_string(), target_id: None, payload: Some(json!({ "optionText": "接受任务" })), }, snapshot: None, }; let mut game_state = build_runtime_story_boundary_game_state_fixture(); let pending_quest = build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"); let current_story = build_runtime_story_pending_quest_offer_fixture(pending_quest.clone()); let resolution = resolve_runtime_story_choice_action( &mut game_state, Some(¤t_story), &request, "npc_quest_accept", ) .expect("quest accept should resolve"); let quests = read_array_field(&game_state, "quests"); assert_eq!(quests.len(), 1); assert_eq!( read_optional_string_field(quests[0], "id"), read_optional_string_field(&pending_quest, "id") ); assert_eq!( read_field(&game_state, "runtimeStats") .and_then(|stats| read_i32_field(stats, "questsAccepted")), Some(1) ); let saved_current_story = resolution .saved_current_story .expect("quest accept should save current story"); assert_eq!( read_field(&saved_current_story, "npcChatState") .and_then(|state| read_field(state, "pendingQuestOffer")), Some(&Value::Null) ); assert_eq!( resolution .presentation_options .expect("quest accept should expose follow-up options") .len(), 3 ); } #[test] fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() { let request = RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(0), action: shared_contracts::runtime_story::RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "npc_quest_turn_in".to_string(), target_id: None, payload: Some(json!({ "optionText": "交付任务", "questId": "quest-bridge-complete" })), }, snapshot: None, }; let mut game_state = build_runtime_story_boundary_game_state_fixture(); let mut completed_quest = build_runtime_story_boundary_quest_fixture("quest-bridge-complete", "断桥夜巡"); if let Some(quest) = completed_quest.as_object_mut() { quest.insert("status".to_string(), Value::String("completed".to_string())); quest.insert( "reward".to_string(), json!({ "affinityBonus": 6, "currency": 30, "experience": 24, "items": [{ "id": "reward-med-1", "category": "补给", "name": "回气散", "quantity": 1, "tags": [] }] }), ); } push_quest_record(&mut game_state, &completed_quest); let resolution = resolve_runtime_story_choice_action( &mut game_state, None, &request, "npc_quest_turn_in", ) .expect("quest turn in should resolve"); let quests = read_array_field(&game_state, "quests"); assert_eq!(quests.len(), 1); assert_eq!( read_optional_string_field(quests[0], "status"), Some("turned_in".to_string()) ); assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(120)); assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1); assert_eq!( read_field(&game_state, "playerProgression") .and_then(|progression| read_i32_field(progression, "totalXp")), Some(24) ); assert_eq!( read_current_npc_state_i32_field(&game_state, "affinity"), Some(52) ); assert!(resolution.patches.iter().any(|patch| matches!( patch, RuntimeStoryPatch::NpcAffinityChanged { previous_affinity: 46, next_affinity: 52, .. } ))); } 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") } fn build_runtime_story_boundary_game_state_fixture() -> Value { serde_json::from_str( r#"{ "worldType": "WUXIA", "runtimeSessionId": "runtime-main", "runtimeActionVersion": 0, "playerCharacter": { "id": "hero-story", "title": "试剑客", "description": "站在桥口的人。", "personality": "谨慎", "attributes": { "strength": 8, "spirit": 6 }, "skills": [] }, "runtimeStats": { "playTimeMs": 0, "lastPlayTickAt": null, "hostileNpcsDefeated": 0, "questsAccepted": 0, "itemsUsed": 0, "scenesTraveled": 0 }, "currentScene": "test-scene", "storyHistory": [], "characterChats": {}, "animationState": "idle", "currentEncounter": { "kind": "npc", "id": "npc_merchant_01", "npcName": "沈七", "npcDescription": "腰间挂着药囊的行商", "context": "受伤行商", "hostile": false }, "npcInteractionActive": true, "currentScenePreset": null, "sceneHostileNpcs": [], "playerX": 0, "playerOffsetY": 0, "playerFacing": "right", "playerActionMode": "idle", "scrollWorld": false, "inBattle": false, "playerHp": 31, "playerMaxHp": 40, "playerMana": 9, "playerMaxMana": 16, "playerSkillCooldowns": {}, "activeBuildBuffs": [], "activeCombatEffects": [], "playerCurrency": 90, "playerInventory": [], "playerEquipment": { "weapon": null, "armor": null, "relic": null }, "npcStates": { "npc_merchant_01": { "affinity": 46, "chattedCount": 0, "helpUsed": false, "giftsGiven": 0, "inventory": [], "recruited": false } }, "quests": [], "roster": [], "companions": [], "currentNpcBattleMode": null, "currentNpcBattleOutcome": null, "sparReturnEncounter": null, "sparPlayerHpBefore": null, "sparPlayerMaxHpBefore": null, "sparStoryHistoryBefore": null, "playerProgression": { "level": 1, "currentLevelXp": 0, "totalXp": 0, "xpToNextLevel": 60, "pendingLevelUps": 0, "lastGrantedSource": null } }"#, ) .expect("runtime story boundary game state fixture should parse") } fn build_runtime_story_boundary_quest_fixture(quest_id: &str, title: &str) -> Value { json!({ "id": quest_id, "issuerNpcId": "npc_merchant_01", "issuerNpcName": "沈七", "sceneId": "scene-bridge", "title": title, "description": format!("{title}的详细说明。"), "summary": format!("{title}的简要目标。"), "objective": { "kind": "inspect_treasure", "requiredCount": 1 }, "progress": 0, "status": "active", "reward": { "affinityBonus": 6, "currency": 30, "items": [] }, "rewardText": "完成后可以领取报酬。", "steps": [{ "id": format!("{quest_id}-step-1"), "title": "查清线索", "kind": "inspect_treasure", "requiredCount": 1, "progress": 0, "revealText": "先去断桥口附近看看留下了什么痕迹。", "completeText": "线索已经查清。" }], "activeStepId": format!("{quest_id}-step-1") }) } fn build_runtime_story_pending_quest_offer_fixture(quest: Value) -> Value { json!({ "text": "沈七终于把真正的委托说了出来。", "options": [], "displayMode": "dialogue", "dialogue": [{ "speaker": "npc", "speakerName": "沈七", "text": "这件事我只想托给你。" }], "npcChatState": { "npcId": "npc_merchant_01", "npcName": "沈七", "turnCount": 2, "customInputPlaceholder": "输入你想对 TA 说的话", "pendingQuestOffer": { "quest": quest } } }) } }