use serde_json::{Map, Value, json}; use shared_contracts::{ runtime_story::{ RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation, RuntimeStoryViewModel, }, story::{ResolveStoryRuntimeActionRequest, StoryRuntimeSnapshotPayload}, }; use crate::{ CONTINUE_ADVENTURE_FUNCTION_ID, StoryResolution, add_inventory_items_to_list, append_story_history, apply_equipment_loadout_to_state, build_battle_runtime_story_options, build_current_build_toast, build_npc_gift_result_text, build_runtime_story_option_from_story_option, build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option, clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_name, ensure_json_object, find_player_inventory_entry, normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, npc_purchase_price, project_story_engine_after_action, read_array_field, read_bool_field, read_field, read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, read_player_equipment_item, read_player_inventory_values, read_runtime_session_id, read_u32_field, recruit_companion_to_party, remove_inventory_item_from_list, resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item, resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution, write_bool_field, write_i32_field, write_null_field, write_player_equipment_item, write_player_inventory_values, write_runtime_npc_interaction_view, write_string_field, write_u32_field, }; const NPC_RECRUIT_AFFINITY: i32 = 60; pub struct StoryRuntimeActionResolveInput { pub story_session_id: String, pub runtime_session_id: String, pub snapshot: StoryRuntimeSnapshotPayload, pub request: ResolveStoryRuntimeActionRequest, } #[derive(Debug)] pub struct StoryRuntimeActionResolveOutput { pub server_version: u32, pub narrative_text: String, pub choice_function_id: Option, pub view_model: RuntimeStoryViewModel, pub presentation: RuntimeStoryPresentation, pub patches: Vec, pub snapshot: StoryRuntimeSnapshotPayload, } /// 结算 story-session scoped runtime action。 /// /// 中文注释:该函数不访问 HTTP、AppState 或 SpacetimeDB,只消费服务端快照并输出下一版快照。 pub fn resolve_story_runtime_action( input: StoryRuntimeActionResolveInput, ) -> Result { validate_action_request(&input.request)?; let requested_story_session_id = normalize_required_string(input.story_session_id.as_str()) .ok_or_else(|| "storySessionId 不能为空".to_string())?; let requested_runtime_session_id = normalize_required_string(input.runtime_session_id.as_str()) .ok_or_else(|| "runtimeSessionId 不能为空".to_string())?; let function_id = normalize_required_string(input.request.function_id.as_str()) .ok_or_else(|| "action.functionId 不能为空".to_string())?; let mut snapshot = input.snapshot; let current_snapshot_runtime_id = read_runtime_session_id(&snapshot.game_state).ok_or_else(|| { "runtime snapshot 缺少 runtimeSessionId,无法执行 story action".to_string() })?; if current_snapshot_runtime_id != requested_runtime_session_id { return Err("runtime snapshot 与 story session 不匹配".to_string()); } ensure_json_object(&mut snapshot.game_state).insert( "storySessionId".to_string(), Value::String(requested_story_session_id), ); let previous_game_state = snapshot.game_state.clone(); let current_story_before = snapshot.current_story.clone(); let mut game_state = snapshot.game_state.clone(); let request = runtime_action_request_from_story_request(&requested_runtime_session_id, input.request); let mut resolution = resolve_runtime_story_choice_action( &mut game_state, current_story_before.as_ref(), &request, function_id.as_str(), )?; 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_runtime_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 history_result_text = resolution.result_text.clone(); let saved_current_story = resolution .saved_current_story .take() .unwrap_or_else(|| build_current_story(story_text.as_str(), &options)); append_story_history( &mut game_state, resolution.action_text.as_str(), history_result_text.as_str(), ); project_story_engine_after_action( &previous_game_state, &mut game_state, resolution.action_text.as_str(), history_result_text.as_str(), function_id.as_str(), resolution .battle .as_ref() .and_then(|battle| battle.outcome.as_deref()), ); let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { action_text: resolution.action_text.clone(), result_text: history_result_text.clone(), }]; patches.extend(resolution.patches); snapshot.saved_at = None; snapshot.game_state = game_state; snapshot.current_story = Some(saved_current_story); write_runtime_npc_interaction_view(&mut snapshot.game_state); let view_model = build_runtime_story_view_model(&snapshot.game_state, &options); let presentation = RuntimeStoryPresentation { action_text: resolution.action_text, result_text: resolution.result_text, story_text: story_text.clone(), options, toast: resolution.toast, battle: resolution.battle, }; Ok(StoryRuntimeActionResolveOutput { server_version, narrative_text: story_text, choice_function_id: Some(function_id), view_model, presentation, patches, snapshot, }) } fn validate_action_request(request: &ResolveStoryRuntimeActionRequest) -> Result<(), String> { if normalize_required_string(request.function_id.as_str()).is_none() { return Err("action.functionId 不能为空".to_string()); } if normalize_required_string(request.action_text.as_str()).is_none() { return Err("actionText 不能为空".to_string()); } Ok(()) } fn runtime_action_request_from_story_request( runtime_session_id: &str, request: ResolveStoryRuntimeActionRequest, ) -> RuntimeStoryActionRequest { let mut payload = request.payload.unwrap_or_else(|| json!({})); if let Some(object) = payload.as_object_mut() { object .entry("optionText".to_string()) .or_insert_with(|| Value::String(request.action_text.clone())); } else { payload = json!({ "optionText": request.action_text }); } RuntimeStoryActionRequest { session_id: runtime_session_id.to_string(), client_version: request.client_version, action: RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: request.function_id, target_id: request.target_id, payload: Some(payload), }, } } 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), "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), "你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。", )) } "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_preview_talk" => resolve_npc_preview_talk_action(game_state, request), "npc_chat" | "story_opening_camp_dialogue" => resolve_npc_chat_action(game_state, request), "npc_help" => resolve_npc_help_action(game_state, request), "npc_recruit" => resolve_npc_recruit_action(game_state, request), "npc_trade" => resolve_npc_trade_action(game_state, request), "npc_gift" => resolve_npc_gift_action(game_state, request), "npc_fight" | "npc_spar" => { resolve_npc_battle_start_action(game_state, request, function_id) } "npc_chat_quest_offer_view" => resolve_npc_quest_offer_view_action(game_state, request), "npc_chat_quest_offer_replace" => { resolve_npc_quest_offer_replace_action(game_state, request) } "npc_chat_quest_offer_abandon" => { resolve_npc_quest_offer_abandon_action(game_state, request) } "npc_quest_accept" => resolve_npc_quest_accept_action(current_story, game_state, request), "npc_quest_turn_in" => resolve_npc_quest_turn_in_action(game_state, request), "equipment_equip" => resolve_equipment_equip_action(game_state, request), "equipment_unequip" => resolve_equipment_unequip_action(game_state, request), "forge_craft" => resolve_forge_craft_action(game_state, request), "forge_dismantle" => resolve_forge_dismantle_action(game_state, request), "forge_reforge" => resolve_forge_reforge_action(game_state, request), "battle_attack_basic" | "battle_use_skill" | "battle_all_in_crush" | "battle_escape_breakout" | "battle_feint_step" | "battle_finisher_window" | "battle_guard_break" | "battle_probe_pressure" | "battle_recover_breath" | "inventory_use" => resolve_battle_action(game_state, request, function_id), _ => Err(format!("暂不支持的 runtime action:{function_id}")), } } fn resolve_continue_adventure_action( current_story: Option<&Value>, ) -> Result { let deferred_options = current_story .map(|story| { read_array_field(story, "deferredOptions") .into_iter() .filter_map(build_runtime_story_option_from_story_option) .collect::>() }) .unwrap_or_default(); let options = (!deferred_options.is_empty()).then_some(deferred_options); Ok(StoryResolution { action_text: "继续推进冒险".to_string(), result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(), story_text: None, presentation_options: options, saved_current_story: None, patches: Vec::new(), battle: None, toast: None, }) } fn resolve_npc_preview_talk_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; write_bool_field(game_state, "npcInteractionActive", true); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0); increment_npc_state_i32_field(state, "chattedCount", 1); write_bool_field(state, "firstMeaningfulContactResolved", true); adjust_npc_affinity_state(state, 2, "npc_chat"); let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity); write_runtime_npc_interaction_view(game_state); Ok(StoryResolution { action_text: resolve_action_text(&format!("转向{}", npc.name), request), result_text: format!( "你把注意力转向 {name},这一轮交流已经正式接上。", name = npc.name ), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![ RuntimeStoryPatch::NpcAffinityChanged { npc_id: npc.id, previous_affinity, next_affinity, }, build_status_patch(game_state), ], battle: None, toast: Some("已进入角色交互".to_string()), }) } fn resolve_npc_chat_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; write_bool_field(game_state, "npcInteractionActive", true); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0); increment_npc_state_i32_field(state, "chattedCount", 1); write_bool_field(state, "firstMeaningfulContactResolved", true); adjust_npc_affinity_state(state, 4, "npc_chat"); let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity); write_runtime_npc_interaction_view(game_state); Ok(StoryResolution { action_text: resolve_action_text(&format!("和{}继续交谈", npc.name), request), result_text: format!( "{name} 接住了你的话头,态度比刚才更松动了一些。", name = npc.name ), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![ RuntimeStoryPatch::NpcAffinityChanged { npc_id: npc.id, previous_affinity, next_affinity, }, build_status_patch(game_state), ], battle: None, toast: Some(format!("{} 好感 +4", npc.name)), }) } fn resolve_npc_help_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; write_bool_field(game_state, "npcInteractionActive", true); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0); let help_used = read_bool_field(state, "helpUsed").unwrap_or(false); if help_used { return Err(format!("{} 本轮已经帮过你。", npc.name)); } write_bool_field(state, "helpUsed", true); adjust_npc_affinity_state(state, 8, "npc_help"); let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity); Ok(StoryResolution { action_text: resolve_action_text(&format!("请求{}援手", npc.name), request), result_text: format!("{name} 衡量片刻后点头帮你搭了一把手。", name = npc.name), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![ RuntimeStoryPatch::NpcAffinityChanged { npc_id: npc.id, previous_affinity, next_affinity, }, build_status_patch(game_state), ], battle: None, toast: Some(format!("{} 好感 +8", npc.name)), }) } fn resolve_npc_recruit_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; write_bool_field(game_state, "npcInteractionActive", true); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); let affinity = read_i32_field(state, "affinity").unwrap_or(0); if affinity < NPC_RECRUIT_AFFINITY { return Err(format!("{} 当前好感不足,暂时不能招募入队。", npc.name)); } let release_npc_id = request .action .payload .as_ref() .and_then(|payload| read_optional_string_field(payload, "releaseNpcId")); let released_name = recruit_companion_to_party( game_state, npc.id.as_str(), affinity, release_npc_id.as_deref(), )?; let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); write_bool_field(state, "recruited", true); adjust_npc_stance_for_action(state, "npc_recruit", 0, true); let release_text = released_name .map(|name| format!(",{name} 暂时离队")) .unwrap_or_default(); Ok(StoryResolution { action_text: resolve_action_text(&format!("邀请{}入队", npc.name), request), result_text: format!("{} 正式答应与你同行{release_text}。", npc.name), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: Some(format!("{} 已加入队伍", npc.name)), }) } fn resolve_npc_trade_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; let payload = request .action .payload .as_ref() .ok_or_else(|| "npc_trade 缺少 payload".to_string())?; let mode = read_optional_string_field(payload, "mode") .ok_or_else(|| "npc_trade 缺少 mode".to_string())?; let item_id = read_optional_string_field(payload, "itemId") .ok_or_else(|| "npc_trade 缺少 itemId".to_string())?; let quantity = read_i32_field(payload, "quantity").unwrap_or(1).max(1); let affinity = { let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); read_i32_field(state, "affinity").unwrap_or(0) }; let (item_name, total_price, result_text) = if mode == "buy" { let item = { let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); find_npc_inventory_item(state, item_id.as_str()) .ok_or_else(|| "NPC 库存中没有目标物品。".to_string())? }; let stock = read_i32_field(&item, "quantity").unwrap_or(0).max(0); if stock < quantity { return Err("NPC 库存不足。".to_string()); } let unit_price = npc_purchase_price(&item, affinity); let total_price = unit_price.saturating_mul(quantity); let currency = read_i32_field(game_state, "playerCurrency") .unwrap_or(0) .max(0); if currency < total_price { return Err("当前钱币不足。".to_string()); } let item_name = read_inventory_item_name(&item); let next_npc_inventory = { let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); remove_inventory_item_from_list( read_npc_inventory_values(state), item_id.as_str(), quantity, ) }; let next_player_inventory = add_inventory_items_to_list( read_player_inventory_values(game_state), vec![clone_inventory_item_with_quantity(&item, quantity)], ); write_i32_field(game_state, "playerCurrency", currency - total_price); write_player_inventory_values(game_state, next_player_inventory); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); write_npc_inventory_values(state, next_npc_inventory); ( item_name.clone(), total_price, format!( "你从 {} 手中买下{}{},花费 {}。", npc.name, item_name, trade_quantity_suffix_local(quantity), total_price ), ) } else if mode == "sell" { let item = find_player_inventory_entry(game_state, item_id.as_str()) .cloned() .ok_or_else(|| "背包里没有目标物品。".to_string())?; let owned = read_i32_field(&item, "quantity").unwrap_or(0).max(0); if owned < quantity { return Err("背包数量不足。".to_string()); } let unit_price = npc_buyback_price(&item, affinity); let total_price = unit_price.saturating_mul(quantity); let item_name = read_inventory_item_name(&item); let currency = read_i32_field(game_state, "playerCurrency") .unwrap_or(0) .max(0); let next_player_inventory = remove_inventory_item_from_list( read_player_inventory_values(game_state), item_id.as_str(), quantity, ); let next_npc_inventory = { let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); add_inventory_items_to_list( read_npc_inventory_values(state), vec![clone_inventory_item_with_quantity(&item, quantity)], ) }; write_i32_field(game_state, "playerCurrency", currency + total_price); write_player_inventory_values(game_state, next_player_inventory); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); write_npc_inventory_values(state, next_npc_inventory); ( item_name.clone(), total_price, format!( "你把{}{}交给 {},换回 {}。", item_name, trade_quantity_suffix_local(quantity), npc.name, total_price ), ) } else { return Err("npc_trade.mode 只支持 buy 或 sell".to_string()); }; write_runtime_npc_interaction_view(game_state); Ok(StoryResolution { action_text: resolve_action_text(&format!("与{}交易", npc.name), request), result_text, story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: Some(format!("{item_name} 交易完成,金额 {total_price}")), }) } fn resolve_npc_gift_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; let item_id = request .action .payload .as_ref() .and_then(|payload| read_optional_string_field(payload, "itemId")) .ok_or_else(|| "npc_gift 缺少 itemId".to_string())?; let item = find_player_inventory_entry(game_state, item_id.as_str()) .cloned() .ok_or_else(|| "背包里没有目标礼物。".to_string())?; if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 { return Err("背包里没有目标礼物。".to_string()); } write_player_inventory_values( game_state, remove_inventory_item_from_list( read_player_inventory_values(game_state), item_id.as_str(), 1, ), ); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0); let affinity_gain = resolve_npc_gift_affinity_gain(&item); increment_npc_state_i32_field(state, "giftsGiven", 1); adjust_npc_affinity_state(state, affinity_gain, "npc_gift"); let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity); let result_text = build_npc_gift_result_text(npc.name.as_str(), &item, affinity_gain, next_affinity); write_runtime_npc_interaction_view(game_state); Ok(StoryResolution { action_text: resolve_action_text( &format!("赠送{}", read_inventory_item_name(&item)), request, ), result_text, story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![ RuntimeStoryPatch::NpcAffinityChanged { npc_id: npc.id, previous_affinity, next_affinity, }, build_status_patch(game_state), ], battle: None, toast: Some(format!("{} 好感 +{}", npc.name, affinity_gain)), }) } fn resolve_npc_battle_start_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, function_id: &str, ) -> Result { let npc = current_npc_context(game_state)?; let mode = if function_id == "npc_spar" { "spar" } else { "fight" }; let target_hp = if mode == "spar" { 12 } else { read_i32_field(&npc.encounter, "targetMaxHp") .or_else(|| read_i32_field(&npc.encounter, "maxHp")) .unwrap_or_else(|| { 80 + read_npc_affinity(game_state, npc.id.as_str(), npc.name.as_str()).max(0) }) .max(12) }; let mut encounter = npc.encounter.clone(); write_bool_field(&mut encounter, "hostile", true); write_i32_field(&mut encounter, "hp", target_hp); write_i32_field(&mut encounter, "maxHp", target_hp); let hostile = json!({ "id": format!("npc-opponent-{}", npc.id), "name": npc.name, "action": if mode == "spar" { "抱拳行礼,准备点到为止地切磋武艺" } else { "摆开架势,随时准备出手" }, "description": read_optional_string_field(&encounter, "npcDescription").unwrap_or_default(), "animation": "idle", "xMeters": read_number_field(&encounter, "xMeters").unwrap_or(3.2), "yOffset": 0, "facing": "left", "attackRange": 1.8, "speed": 7, "hp": target_hp, "maxHp": target_hp, "renderKind": "npc", "levelProfile": read_field(&encounter, "levelProfile").cloned(), "experienceReward": read_i32_field(&encounter, "experienceReward").unwrap_or(0).max(0), "encounter": encounter }); ensure_json_object(game_state).insert("currentEncounter".to_string(), encounter); ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), json!([hostile])); 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", mode); write_null_field(game_state, "currentNpcBattleOutcome"); Ok(StoryResolution { action_text: resolve_action_text( if mode == "spar" { "点到为止切磋" } else { "与对方战斗" }, request, ), result_text: if mode == "spar" { format!("{} 与你拉开架势,这场切磋正式开始。", npc.name) } else { format!("{} 已经进入战斗姿态,你必须立刻应对。", npc.name) }, story_text: None, presentation_options: Some(build_battle_runtime_story_options(game_state)), 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: Some(0), damage_taken: Some(0), outcome: Some("ongoing".to_string()), }), toast: Some( if mode == "spar" { "切磋开始" } else { "战斗开始" } .to_string(), ), }) } fn resolve_npc_quest_offer_view_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; Ok(StoryResolution { action_text: resolve_action_text("查看委托", request), result_text: format!("你把 {} 提到的委托细节重新梳理了一遍。", npc.name), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: None, }) } fn resolve_npc_quest_offer_replace_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); adjust_npc_affinity_state(state, 2, "npc_chat"); Ok(StoryResolution { action_text: resolve_action_text("更换委托", request), result_text: format!( "{} 收回原本的委托,换成了一件更适合你眼下处境的事。", npc.name ), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: Some("委托已更换".to_string()), }) } fn resolve_npc_quest_offer_abandon_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; Ok(StoryResolution { action_text: resolve_action_text("暂不接下委托", request), result_text: format!( "你暂时没有接下 {} 提出的委托,对方也没有继续强求。", npc.name ), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: Some("已暂缓委托".to_string()), }) } fn resolve_npc_quest_accept_action( current_story: Option<&Value>, game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_context(game_state)?; let quest = request .action .payload .as_ref() .and_then(|payload| read_field(payload, "quest").cloned()) .or_else(|| pending_quest_offer_quest(current_story)) .unwrap_or_else(|| build_fallback_npc_quest(&npc)); upsert_runtime_quest(game_state, quest); let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0); adjust_npc_affinity_state(state, 5, "npc_quest_accept"); let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity); increment_runtime_stat_local(game_state, "questsAccepted", 1); Ok(StoryResolution { action_text: resolve_action_text(&format!("接下{}的委托", npc.name), request), result_text: format!( "你接下了 {} 交出的委托,这条线索已经写入当前任务。", npc.name ), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![ RuntimeStoryPatch::NpcAffinityChanged { npc_id: npc.id, previous_affinity, next_affinity, }, build_status_patch(game_state), ], battle: None, toast: Some("已接取委托".to_string()), }) } fn resolve_npc_quest_turn_in_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { let npc = current_npc_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(|| active_quest_id_for_npc(game_state, npc.id.as_str(), npc.name.as_str())) .ok_or_else(|| "未找到可交付的委托。".to_string())?; mark_quest_turned_in(game_state, quest_id.as_str())?; let state = ensure_npc_state(game_state, npc.id.as_str(), npc.name.as_str()); let previous_affinity = read_i32_field(state, "affinity").unwrap_or(0); adjust_npc_affinity_state(state, 10, "npc_help"); let next_affinity = read_i32_field(state, "affinity").unwrap_or(previous_affinity); Ok(StoryResolution { action_text: resolve_action_text("交付委托", request), result_text: format!("你把委托结果交给 {},这件事暂时收束。", npc.name), story_text: None, presentation_options: Some(build_npc_interaction_options(npc.id.as_str())), saved_current_story: None, patches: vec![ RuntimeStoryPatch::NpcAffinityChanged { npc_id: npc.id, previous_affinity, next_affinity, }, build_status_patch(game_state), ], battle: None, toast: Some("委托已交付".to_string()), }) } fn resolve_equipment_equip_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return Err("战斗中无法更换装备。".to_string()); } let item_id = request .action .payload .as_ref() .and_then(|payload| read_optional_string_field(payload, "itemId")) .or_else(|| request.action.target_id.clone()) .ok_or_else(|| "equipment_equip 缺少 itemId".to_string())?; let item = find_player_inventory_entry(game_state, item_id.as_str()) .cloned() .ok_or_else(|| "背包里没有目标装备。".to_string())?; if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 { return Err("背包里没有目标装备。".to_string()); } let slot_id = resolve_equipment_slot_for_item(&item).ok_or_else(|| "该物品不能装备。".to_string())?; let previous_item = read_player_equipment_item(game_state, slot_id); let mut next_inventory = remove_inventory_item_from_list( read_player_inventory_values(game_state), item_id.as_str(), 1, ); if let Some(previous_item) = previous_item { next_inventory = add_inventory_items_to_list(next_inventory, vec![previous_item]); } write_player_inventory_values(game_state, next_inventory); write_player_equipment_item( game_state, slot_id, Some(clone_inventory_item_with_quantity(&item, 1)), ); apply_equipment_loadout_to_state(game_state); Ok(StoryResolution { action_text: resolve_action_text( &format!("装备{}", read_inventory_item_name(&item)), request, ), result_text: format!( "你换上了{},当前 Build 已经重新结算。", read_inventory_item_name(&item) ), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: Some(build_current_build_toast(game_state)), }) } fn resolve_equipment_unequip_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, ) -> Result { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return Err("战斗中无法更换装备。".to_string()); } let slot_id = request .action .payload .as_ref() .and_then(|payload| read_optional_string_field(payload, "slotId")) .or_else(|| request.action.target_id.clone()) .and_then(|value| normalize_equipment_slot_id(value.as_str()).map(str::to_string)) .ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?; let item = read_player_equipment_item(game_state, slot_id.as_str()) .ok_or_else(|| "目标装备槽当前为空。".to_string())?; write_player_equipment_item(game_state, slot_id.as_str(), None); write_player_inventory_values( game_state, add_inventory_items_to_list(read_player_inventory_values(game_state), vec![item.clone()]), ); apply_equipment_loadout_to_state(game_state); Ok(StoryResolution { action_text: resolve_action_text( &format!("卸下{}", read_inventory_item_name(&item)), request, ), result_text: format!( "你卸下了{},当前 Build 已经重新结算。", read_inventory_item_name(&item) ), story_text: None, presentation_options: None, saved_current_story: None, patches: vec![build_status_patch(game_state)], battle: None, toast: Some(build_current_build_toast(game_state)), }) } #[derive(Clone, Debug)] struct CurrentNpcContext { id: String, name: String, encounter: Value, } fn current_npc_context(game_state: &Value) -> Result { let encounter = read_object_field(game_state, "currentEncounter") .ok_or_else(|| "当前没有可交互角色。".to_string())?; let name = read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) .unwrap_or_else(|| "当前角色".to_string()); let id = read_optional_string_field(encounter, "id").unwrap_or_else(|| name.clone()); Ok(CurrentNpcContext { id, name, encounter: encounter.clone(), }) } fn ensure_npc_state<'a>(game_state: &'a mut Value, npc_id: &str, npc_name: &str) -> &'a mut Value { 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 state_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() }; states .entry(state_key) .or_insert_with(|| build_default_npc_state(npc_name)) } fn build_default_npc_state(npc_name: &str) -> Value { let _ = npc_name; let affinity = 18; json!({ "affinity": affinity, "chattedCount": 0, "helpUsed": false, "giftsGiven": 0, "inventory": [], "recruited": false, "relationState": relation_state_value(affinity), "revealedFacts": [], "knownAttributeRumors": [], "firstMeaningfulContactResolved": false, "seenBackstoryChapterIds": [], "tradeStockSignature": Value::Null, "stanceProfile": initial_stance_profile_value(affinity, false, false, "") }) } fn read_npc_affinity(game_state: &mut Value, npc_id: &str, npc_name: &str) -> i32 { let state = ensure_npc_state(game_state, npc_id, npc_name); read_i32_field(state, "affinity").unwrap_or(0) } fn adjust_npc_affinity_state(state: &mut Value, delta: i32, action: &str) { let previous = read_i32_field(state, "affinity").unwrap_or(0); let next = previous.saturating_add(delta).clamp(-40, 100); write_i32_field(state, "affinity", next); ensure_json_object(state).insert("relationState".to_string(), relation_state_value(next)); adjust_npc_stance_for_action(state, action, delta, false); } fn relation_state_value(affinity: i32) -> Value { json!({ "affinity": affinity, "stance": relation_stance_key(affinity) }) } fn relation_stance_key(affinity: i32) -> &'static str { if affinity < 0 { "hostile" } else if affinity < 15 { "guarded" } else if affinity < 30 { "neutral" } else if affinity < 60 { "cooperative" } else { "bonded" } } fn initial_stance_profile_value( affinity: i32, recruited: bool, hostile: bool, role_text: &str, ) -> Value { json!({ "trust": clamp_stance_metric(42 + affinity * 55 / 100 + if recruited { 14 } else { 0 } - if hostile { 18 } else { 0 }), "warmth": clamp_stance_metric(36 + affinity / 2 + if recruited { 14 } else { 0 }), "ideologicalFit": clamp_stance_metric(48 + affinity / 4), "fearOrGuard": clamp_stance_metric(62 - affinity * 55 / 100 + if hostile { 18 } else { 0 }), "loyalty": clamp_stance_metric(24 + affinity * 35 / 100 + if recruited { 26 } else { 0 }), "currentConflictTag": infer_current_conflict_tag(role_text), "recentApprovals": [], "recentDisapprovals": [] }) } fn infer_current_conflict_tag(role_text: &str) -> Value { if role_text.contains("旧案") || role_text.contains("调查") || role_text.contains("追查") { json!("旧案") } else if role_text.contains('守') || role_text.contains('卫') || role_text.contains('巡') { json!("守线") } else if role_text.contains('商') || role_text.contains('摊') || role_text.contains("军需") { json!("交易") } else { Value::Null } } fn adjust_npc_stance_for_action( state: &mut Value, action: &str, affinity_gain: i32, recruited: bool, ) { let affinity = read_i32_field(state, "affinity").unwrap_or(0); let role_text = read_optional_string_field(state, "role").unwrap_or_default(); let stance = ensure_json_object(state) .entry("stanceProfile".to_string()) .or_insert_with(|| { initial_stance_profile_value(affinity, recruited, affinity < 0, role_text.as_str()) }); if !stance.is_object() { *stance = initial_stance_profile_value(affinity, recruited, affinity < 0, role_text.as_str()); } let stance = stance .as_object_mut() .expect("stanceProfile should be object"); match action { "npc_chat" => { bump_stance_metric(stance, "trust", 6 + affinity_gain * 2); bump_stance_metric(stance, "warmth", 4 + affinity_gain * 2); bump_stance_metric(stance, "fearOrGuard", -5 - affinity_gain); append_stance_note( stance, "recentApprovals", "你愿意先从眼前局势和试探开始说话。", ); } "npc_help" => { bump_stance_metric(stance, "trust", 12); bump_stance_metric(stance, "warmth", 6); bump_stance_metric(stance, "fearOrGuard", -8); append_stance_note(stance, "recentApprovals", "你在对方需要的时候搭了手。"); } "npc_gift" => { bump_stance_metric(stance, "trust", 6 + affinity_gain); bump_stance_metric(stance, "warmth", 10 + affinity_gain * 2); bump_stance_metric(stance, "fearOrGuard", -4); append_stance_note( stance, "recentApprovals", "你给出的东西回应了对方眼下的处境。", ); } "npc_recruit" => { bump_stance_metric(stance, "trust", 8); bump_stance_metric(stance, "warmth", 6); bump_stance_metric(stance, "loyalty", 18); bump_stance_metric(stance, "fearOrGuard", -10); append_stance_note(stance, "recentApprovals", "你正式把对方纳入了同行关系。"); } "npc_quest_accept" => { bump_stance_metric(stance, "trust", 7); bump_stance_metric(stance, "ideologicalFit", 5); bump_stance_metric(stance, "loyalty", 4); append_stance_note(stance, "recentApprovals", "你接住了对方主动交出来的事。"); } _ => {} } } fn bump_stance_metric(stance: &mut Map, key: &str, delta: i32) { let current = stance .get(key) .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) .unwrap_or(50); stance.insert(key.to_string(), json!(clamp_stance_metric(current + delta))); } fn clamp_stance_metric(value: i32) -> i32 { value.clamp(0, 100) } fn append_stance_note(stance: &mut Map, key: &str, note: &str) { let mut notes = stance .get(key) .and_then(Value::as_array) .cloned() .unwrap_or_default(); notes.push(Value::String(note.to_string())); let keep_from = notes.len().saturating_sub(3); stance.insert( key.to_string(), Value::Array(notes.into_iter().skip(keep_from).collect()), ); } fn increment_npc_state_i32_field(state: &mut Value, key: &str, delta: i32) { let previous = read_i32_field(state, key).unwrap_or(0); write_i32_field(state, key, previous.saturating_add(delta).max(0)); } fn build_npc_interaction_options(npc_id: &str) -> Vec { vec![ build_npc_option("npc_chat", "继续交谈", npc_id, "chat"), build_npc_option("npc_help", "请求援手", npc_id, "help"), build_npc_option("npc_trade", "交易", npc_id, "trade"), build_npc_option("npc_gift", "赠礼", npc_id, "gift"), build_npc_option("npc_recruit", "邀请入队", npc_id, "recruit"), build_npc_option("npc_spar", "点到为止切磋", npc_id, "spar"), build_npc_option("npc_fight", "与对方战斗", npc_id, "fight"), build_npc_option("npc_leave", "离开当前角色", npc_id, "leave"), ] } fn find_npc_inventory_item(state: &Value, item_id: &str) -> Option { read_array_field(state, "inventory") .into_iter() .find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id)) .cloned() } fn read_npc_inventory_values(state: &Value) -> Vec { read_array_field(state, "inventory") .into_iter() .cloned() .collect() } fn write_npc_inventory_values(state: &mut Value, items: Vec) { ensure_json_object(state).insert("inventory".to_string(), Value::Array(items)); } fn trade_quantity_suffix_local(quantity: i32) -> String { if quantity > 1 { format!(" x{quantity}") } else { String::new() } } fn read_number_field(value: &Value, key: &str) -> Option { read_field(value, key).and_then(Value::as_f64) } fn pending_quest_offer_quest(current_story: Option<&Value>) -> Option { current_story .and_then(|story| read_object_field(story, "npcChatState")) .and_then(|chat| read_object_field(chat, "pendingQuestOffer")) .and_then(|offer| read_field(offer, "quest")) .cloned() } fn build_fallback_npc_quest(npc: &CurrentNpcContext) -> Value { json!({ "id": format!("quest:npc:{}:fallback", npc.id), "issuerNpcId": npc.id, "issuerNpcName": npc.name, "title": format!("{}的委托", npc.name), "description": format!("处理 {} 交出的眼前事务。", npc.name), "summary": format!("完成 {} 的委托。", npc.name), "objective": { "kind": "talk_to_npc", "targetNpcId": npc.id, "requiredCount": 1 }, "progress": 0, "status": "active", "reward": { "affinityBonus": 10, "currency": 60, "experience": 30, "items": [] } }) } fn upsert_runtime_quest(game_state: &mut Value, quest: Value) { let quest_id = read_optional_string_field(&quest, "id").unwrap_or_else(|| { format!( "quest:runtime:{}", read_array_field(game_state, "quests").len() + 1 ) }); let mut quest = quest; if let Some(object) = quest.as_object_mut() { object.insert("id".to_string(), Value::String(quest_id.clone())); object .entry("status".to_string()) .or_insert_with(|| Value::String("active".to_string())); } 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 items = quests.as_array_mut().expect("quests should be array"); if let Some(existing) = items .iter_mut() .find(|item| read_optional_string_field(item, "id").as_deref() == Some(quest_id.as_str())) { *existing = quest; } else { items.push(quest); } } fn active_quest_id_for_npc(game_state: &Value, npc_id: &str, npc_name: &str) -> Option { read_array_field(game_state, "quests") .into_iter() .find(|quest| { let status = read_optional_string_field(quest, "status").unwrap_or_default(); let issuer_matches = read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(npc_id) || read_optional_string_field(quest, "issuerNpcName").as_deref() == Some(npc_name); issuer_matches && status != "turned_in" && status != "failed" && status != "expired" }) .and_then(|quest| read_optional_string_field(quest, "id")) } fn mark_quest_turned_in(game_state: &mut Value, quest_id: &str) -> Result<(), String> { let root = ensure_json_object(game_state); let quests = root .entry("quests".to_string()) .or_insert_with(|| Value::Array(Vec::new())); let Some(items) = quests.as_array_mut() else { return Err("当前任务列表非法。".to_string()); }; let Some(quest) = items .iter_mut() .find(|quest| read_optional_string_field(quest, "id").as_deref() == Some(quest_id)) else { return Err("未找到可交付的委托。".to_string()); }; ensure_json_object(quest).insert("status".to_string(), Value::String("turned_in".to_string())); Ok(()) } fn increment_runtime_stat_local(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.saturating_add(delta).max(0)), ); } fn build_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 }) } pub fn build_runtime_story_options( current_story: Option<&Value>, game_state: &Value, ) -> Vec { if let Some(story) = current_story { let prefers_deferred = read_field(story, "displayMode") .and_then(Value::as_str) .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_fallback_runtime_story_options(game_state: &Value) -> Vec { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return build_battle_runtime_story_options(game_state); } if let Some(encounter) = read_object_field(game_state, "currentEncounter") { let npc_id = read_optional_string_field(encounter, "id") .or_else(|| read_optional_string_field(encounter, "npcName")) .unwrap_or_else(|| "npc_current".to_string()); if read_bool_field(game_state, "npcInteractionActive").unwrap_or(false) { return vec![ build_npc_option("npc_chat", "继续交谈", &npc_id, "chat"), build_npc_option("npc_help", "请求援手", &npc_id, "help"), build_npc_option("npc_spar", "点到为止切磋", &npc_id, "spar"), build_npc_option("npc_fight", "与对方战斗", &npc_id, "fight"), build_npc_option("npc_leave", "离开当前角色", &npc_id, "leave"), ]; } return vec![ build_npc_option("npc_preview_talk", "转向眼前角色", &npc_id, "chat"), build_npc_option("npc_fight", "与对方战斗", &npc_id, "fight"), build_npc_option("npc_leave", "离开当前角色", &npc_id, "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(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"), ] } fn build_npc_option( function_id: &str, action_text: &str, npc_id: &str, action: &str, ) -> RuntimeStoryOptionView { RuntimeStoryOptionView { interaction: Some( shared_contracts::runtime_story::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") } } pub fn build_runtime_story_state_response_parts( requested_session_id: &str, client_version: Option, mut snapshot: StoryRuntimeSnapshotPayload, ) -> ( u32, RuntimeStoryViewModel, RuntimeStoryPresentation, Vec, StoryRuntimeSnapshotPayload, ) { write_runtime_npc_interaction_view(&mut snapshot.game_state); 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); let view_model = build_runtime_story_view_model(&snapshot.game_state, &options); let presentation = RuntimeStoryPresentation { action_text: String::new(), result_text: String::new(), story_text, options, toast: None, battle: None::, }; let _ = session_id; ( server_version, view_model, presentation, Vec::new(), snapshot, ) } 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() } #[cfg(test)] mod tests { use serde_json::json; use super::*; #[test] fn story_session_scoped_action_updates_snapshot_version() { let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { story_session_id: "storysess-1".to_string(), runtime_session_id: "runtime-1".to_string(), snapshot: StoryRuntimeSnapshotPayload { saved_at: None, bottom_tab: "adventure".to_string(), game_state: json!({ "runtimeSessionId": "runtime-1", "runtimeActionVersion": 1, "playerHp": 30, "playerMaxHp": 40, "playerMana": 10, "playerMaxMana": 20, "playerCurrency": 0, "playerInventory": [], "playerEquipment": { "weapon": null, "armor": null, "relic": null }, "inBattle": false, "npcInteractionActive": false, "storyHistory": [] }), current_story: None, }, request: ResolveStoryRuntimeActionRequest { story_session_id: "storysess-1".to_string(), client_version: Some(1), function_id: "idle_rest_focus".to_string(), action_text: "原地调息".to_string(), target_id: None, payload: None, }, }) .expect("action should resolve"); assert_eq!(output.server_version, 2); assert_eq!( output.snapshot.game_state["storySessionId"], json!("storysess-1") ); assert_eq!(output.snapshot.game_state["runtimeActionVersion"], json!(2)); assert_eq!(output.presentation.action_text, "原地调息"); assert!(output.snapshot.current_story.is_some()); } #[test] fn story_session_scoped_action_rejects_runtime_session_mismatch() { let error = resolve_story_runtime_action(StoryRuntimeActionResolveInput { story_session_id: "storysess-1".to_string(), runtime_session_id: "runtime-expected".to_string(), snapshot: StoryRuntimeSnapshotPayload { saved_at: None, bottom_tab: "adventure".to_string(), game_state: json!({ "runtimeSessionId": "runtime-other" }), current_story: None, }, request: ResolveStoryRuntimeActionRequest { story_session_id: "storysess-1".to_string(), client_version: None, function_id: "idle_call_out".to_string(), action_text: "主动出声试探".to_string(), target_id: None, payload: None, }, }) .expect_err("mismatch should fail"); assert!(error.contains("不匹配")); } }