use serde_json::{Map, Value, json}; use crate::{ current_encounter_id, current_encounter_name, read_array_field, read_bool_field, read_field, read_i32_field, read_object_field, read_optional_string_field, }; #[derive(Clone, Debug, Default)] pub struct RuntimeStoryPromptContextExtras { pub pending_scene_encounter: bool, pub last_function_id: Option, pub observe_signs_requested: bool, pub recent_action_result: Option, pub opening_camp_background: Option, pub opening_camp_dialogue: Option, } /// 基于后端持久化的运行时快照生成 LLM 所需 prompt context。 /// 前端只能提交 session / choice 等轻量请求参数,正式上下文统一在这里投影。 pub fn build_runtime_story_prompt_context( game_state: &Value, extras: RuntimeStoryPromptContextExtras, ) -> Value { let scene = read_object_field(game_state, "currentScenePreset"); let encounter = read_object_field(game_state, "currentEncounter"); let npc_state = encounter.and_then(|_encounter| { let npc_name = current_encounter_name(game_state); let npc_id = current_encounter_id(game_state).unwrap_or_else(|| npc_name.clone()); read_object_field(game_state, "npcStates").and_then(|states| { states .get(npc_id.as_str()) .or_else(|| states.get(npc_name.as_str())) }) }); let conversation_situation = infer_conversation_situation(game_state, &extras); let conversation_pressure = infer_conversation_pressure(game_state, conversation_situation); let encounter_narrative_profile = resolve_encounter_narrative_profile(game_state, encounter); let story_engine_memory = read_object_field(game_state, "storyEngineMemory"); let chapter_state = read_field(game_state, "chapterState") .or_else(|| story_engine_memory.and_then(|memory| read_field(memory, "currentChapter"))); let journey_beat = story_engine_memory.and_then(|memory| read_field(memory, "currentJourneyBeat")); let active_thread_ids = read_string_array( story_engine_memory.and_then(|memory| read_field(memory, "activeThreadIds")), ) .into_iter() .take(4) .collect::>(); let active_thread_ids = if active_thread_ids.is_empty() { read_string_array( encounter_narrative_profile.and_then(|profile| read_field(profile, "relatedThreadIds")), ) .into_iter() .take(4) .collect::>() } else { active_thread_ids }; let recruited = npc_state .and_then(|state| read_bool_field(state, "recruited")) .unwrap_or(false); let affinity = npc_state.and_then(|state| read_i32_field(state, "affinity")); let disclosure = affinity.map(|value| disclosure_stage(value, recruited)); let mut context = Map::new(); insert_base_context(&mut context, game_state, scene, &extras); insert_encounter_context( &mut context, game_state, encounter, npc_state, encounter_narrative_profile, affinity, disclosure, recruited, ); insert_narrative_context( &mut context, game_state, story_engine_memory, chapter_state, journey_beat, active_thread_ids, conversation_situation, conversation_pressure, ); context.insert( "openingCampBackground".to_string(), extras.opening_camp_background.into(), ); context.insert( "openingCampDialogue".to_string(), extras.opening_camp_dialogue.into(), ); Value::Object(context) } fn insert_base_context( context: &mut Map, game_state: &Value, scene: Option<&Value>, extras: &RuntimeStoryPromptContextExtras, ) { context.insert( "playerHp".to_string(), read_i32_field(game_state, "playerHp").unwrap_or(0).into(), ); context.insert( "playerMaxHp".to_string(), read_i32_field(game_state, "playerMaxHp") .unwrap_or(1) .max(1) .into(), ); context.insert( "playerMana".to_string(), read_i32_field(game_state, "playerMana").unwrap_or(0).into(), ); context.insert( "playerMaxMana".to_string(), read_i32_field(game_state, "playerMaxMana") .unwrap_or(1) .max(1) .into(), ); context.insert( "inBattle".to_string(), read_bool_field(game_state, "inBattle") .unwrap_or(false) .into(), ); context.insert( "playerX".to_string(), read_i32_field(game_state, "playerX").unwrap_or(0).into(), ); context.insert( "playerFacing".to_string(), read_optional_string_field(game_state, "playerFacing") .unwrap_or_else(|| "right".to_string()) .into(), ); context.insert( "playerAnimation".to_string(), read_optional_string_field(game_state, "animationState") .unwrap_or_else(|| "idle".to_string()) .into(), ); context.insert( "skillCooldowns".to_string(), read_field(game_state, "playerSkillCooldowns") .cloned() .unwrap_or_else(|| json!({})), ); context.insert( "sceneId".to_string(), scene .and_then(|scene| read_optional_string_field(scene, "id")) .into(), ); context.insert( "sceneName".to_string(), scene .and_then(|scene| read_optional_string_field(scene, "name")) .or_else(|| read_optional_string_field(game_state, "currentScene")) .into(), ); context.insert( "sceneDescription".to_string(), build_scene_description(game_state, extras.observe_signs_requested).into(), ); context.insert( "pendingSceneEncounter".to_string(), extras.pending_scene_encounter.into(), ); context.insert( "lastFunctionId".to_string(), extras.last_function_id.clone().into(), ); context.insert( "observeSignsRequested".to_string(), extras.observe_signs_requested.into(), ); context.insert( "recentActionResult".to_string(), extras.recent_action_result.clone().into(), ); context.insert( "lastObserveSignsReport".to_string(), resolve_last_observe_report(game_state, scene).into(), ); } #[allow(clippy::too_many_arguments)] fn insert_encounter_context( context: &mut Map, game_state: &Value, encounter: Option<&Value>, npc_state: Option<&Value>, encounter_narrative_profile: Option<&Value>, affinity: Option, disclosure: Option<&'static str>, recruited: bool, ) { context.insert( "encounterKind".to_string(), encounter .and_then(|encounter| read_optional_string_field(encounter, "kind")) .into(), ); context.insert( "encounterName".to_string(), encounter.and_then(read_encounter_name).into(), ); context.insert( "encounterDescription".to_string(), encounter .and_then(|encounter| { read_optional_string_field(encounter, "npcDescription") .or_else(|| read_optional_string_field(encounter, "description")) }) .into(), ); context.insert( "encounterContext".to_string(), encounter .and_then(|encounter| read_optional_string_field(encounter, "context")) .into(), ); context.insert( "encounterId".to_string(), current_encounter_id(game_state).into(), ); context.insert( "encounterCharacterId".to_string(), encounter .and_then(|encounter| read_optional_string_field(encounter, "characterId")) .into(), ); context.insert( "encounterGender".to_string(), encounter .and_then(|encounter| read_optional_string_field(encounter, "gender")) .into(), ); context.insert( "encounterCustomProfile".to_string(), encounter.cloned().unwrap_or(Value::Null), ); context.insert("encounterAffinity".to_string(), affinity.into()); context.insert( "encounterAffinityText".to_string(), affinity.map(describe_npc_affinity).into(), ); context.insert( "encounterStanceProfile".to_string(), npc_state .and_then(|state| read_field(state, "stanceProfile")) .cloned() .unwrap_or(Value::Null), ); context.insert( "encounterConversationStyle".to_string(), encounter .and_then(|encounter| read_field(encounter, "conversationStyle")) .cloned() .unwrap_or_else(default_conversation_style), ); context.insert("encounterDisclosureStage".to_string(), disclosure.into()); context.insert( "encounterWarmthStage".to_string(), affinity.map(|value| warmth_stage(value, recruited)).into(), ); context.insert( "encounterAnswerMode".to_string(), disclosure.map(answer_mode).into(), ); context.insert( "encounterAllowedTopics".to_string(), disclosure.map(allowed_topics).into(), ); context.insert( "encounterBlockedTopics".to_string(), disclosure.map(blocked_topics).into(), ); context.insert( "isFirstMeaningfulContact".to_string(), is_first_meaningful_contact(npc_state).into(), ); context.insert( "firstContactRelationStance".to_string(), first_contact_relation_stance(npc_state).into(), ); context.insert( "encounterNarrativeProfile".to_string(), encounter_narrative_profile.cloned().unwrap_or(Value::Null), ); context.insert( "encounterRelationshipSummary".to_string(), encounter .and_then(|encounter| read_optional_string_field(encounter, "characterId")) .and_then(|character_id| read_character_chat_summary(game_state, character_id.as_str())) .into(), ); } #[allow(clippy::too_many_arguments)] fn insert_narrative_context( context: &mut Map, game_state: &Value, story_engine_memory: Option<&Value>, chapter_state: Option<&Value>, journey_beat: Option<&Value>, active_thread_ids: Vec, conversation_situation: &str, conversation_pressure: &str, ) { context.insert( "conversationSituation".to_string(), conversation_situation.into(), ); context.insert( "conversationPressure".to_string(), conversation_pressure.into(), ); context.insert( "recentSharedEvent".to_string(), build_recent_shared_event(game_state) .unwrap_or_else(|| describe_conversation_situation(conversation_situation).to_string()) .into(), ); context.insert( "talkPriority".to_string(), describe_conversation_talk_priority(conversation_situation).into(), ); context.insert("visibilitySlice".to_string(), Value::Null); context.insert("sceneNarrativeDirective".to_string(), Value::Null); context.insert( "campaignState".to_string(), read_field(game_state, "campaignState") .or_else(|| story_engine_memory.and_then(|memory| read_field(memory, "campaignState"))) .cloned() .unwrap_or(Value::Null), ); context.insert( "actState".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "actState")) .cloned() .unwrap_or(Value::Null), ); context.insert( "chapterState".to_string(), chapter_state.cloned().unwrap_or(Value::Null), ); context.insert( "journeyBeat".to_string(), journey_beat.cloned().unwrap_or(Value::Null), ); context.insert("goalStack".to_string(), Value::Null); context.insert( "currentCampEvent".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "currentCampEvent")) .cloned() .unwrap_or(Value::Null), ); context.insert( "setpieceDirective".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "currentSetpieceDirective")) .cloned() .unwrap_or(Value::Null), ); context.insert("activeScenarioPack".to_string(), Value::Null); context.insert("activeCampaignPack".to_string(), Value::Null); context.insert( "knowledgeFacts".to_string(), read_object_field(game_state, "customWorldProfile") .and_then(|profile| read_field(profile, "knowledgeFacts")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert("activeThreadIds".to_string(), active_thread_ids.into()); context.insert( "companionArcStates".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "companionArcStates")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert( "companionResolutions".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "companionResolutions")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert( "consequenceLedger".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "consequenceLedger")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert( "authorialConstraintPack".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "authorialConstraintPack")) .cloned() .unwrap_or(Value::Null), ); context.insert( "playerStyleProfile".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "playerStyleProfile")) .cloned() .unwrap_or(Value::Null), ); context.insert( "recentCompanionReactions".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "recentCompanionReactions")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert("recentCarrierEchoes".to_string(), json!([])); context.insert( "recentWorldMutations".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "worldMutations")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert( "recentFactionTensionStates".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "factionTensionStates")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert( "recentChronicleSummary".to_string(), build_recent_chronicle_summary(game_state).into(), ); context.insert( "narrativeQaReport".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "narrativeQaReport")) .cloned() .unwrap_or(Value::Null), ); context.insert( "releaseGateReport".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "releaseGateReport")) .cloned() .unwrap_or(Value::Null), ); context.insert( "simulationRunResults".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "simulationRunResults")) .cloned() .unwrap_or_else(|| json!([])), ); context.insert( "branchBudgetPressure".to_string(), story_engine_memory .and_then(|memory| read_field(memory, "branchBudgetStatus")) .and_then(|status| read_optional_string_field(status, "pressure")) .into(), ); context.insert( "partyRelationshipNotes".to_string(), build_party_relationship_notes(game_state).into(), ); context.insert( "customWorldProfile".to_string(), read_field(game_state, "customWorldProfile") .cloned() .unwrap_or(Value::Null), ); } fn build_scene_description(game_state: &Value, observe_signs_requested: bool) -> String { let scene = read_object_field(game_state, "currentScenePreset"); let base = scene .and_then(|scene| read_optional_string_field(scene, "description")) .or_else(|| read_optional_string_field(game_state, "sceneDescription")) .unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()); let mutation_text = scene.and_then(|scene| read_optional_string_field(scene, "mutationStateText")); let pressure_text = scene .and_then(|scene| read_optional_string_field(scene, "currentPressureLevel")) .and_then(|level| describe_scene_pressure_level(level.as_str()).map(str::to_string)); let entity_catalog = if observe_signs_requested { Some(build_scene_entity_catalog_text(scene)) } else { None }; [ Some(base), mutation_text.map(|text| format!("最新世界变化:{text}")), pressure_text.map(|text| format!("当前区域压力等级:{text}")), entity_catalog, ] .into_iter() .flatten() .filter(|text| !text.trim().is_empty()) .collect::>() .join("\n") } fn build_scene_entity_catalog_text(scene: Option<&Value>) -> String { let Some(scene) = scene else { return "当前可观察实体池:暂无显式实体。".to_string(); }; let npc_names = read_array_field(scene, "npcs") .into_iter() .filter_map(read_encounter_name) .take(8) .collect::>(); let treasure_hints = read_array_field(scene, "treasureHints") .into_iter() .filter_map(|item| { read_optional_string_field(item, "title") .or_else(|| read_optional_string_field(item, "name")) .or_else(|| read_optional_string_field(item, "hint")) }) .take(6) .collect::>(); let mut lines = vec!["当前可观察实体池:".to_string()]; if !npc_names.is_empty() { lines.push(format!("- 角色:{}", npc_names.join("、"))); } if !treasure_hints.is_empty() { lines.push(format!("- 线索/物件:{}", treasure_hints.join("、"))); } if lines.len() == 1 { lines.push("- 暂无显式实体。".to_string()); } lines.join("\n") } fn resolve_last_observe_report(game_state: &Value, scene: Option<&Value>) -> Option { let current_scene_id = scene.and_then(|scene| read_optional_string_field(scene, "id")); let last_scene_id = read_optional_string_field(game_state, "lastObserveSignsSceneId"); if current_scene_id.is_some() && current_scene_id == last_scene_id { return read_optional_string_field(game_state, "lastObserveSignsReport"); } None } fn infer_conversation_situation( game_state: &Value, extras: &RuntimeStoryPromptContextExtras, ) -> &'static str { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return "shared_danger_coordination"; } if extras.last_function_id.as_deref() == Some("story_opening_camp_dialogue") { return "camp_first_contact"; } let encounter = read_object_field(game_state, "currentEncounter"); if encounter .and_then(|encounter| read_optional_string_field(encounter, "specialBehavior")) .as_deref() == Some("camp_companion") && extras .opening_camp_dialogue .as_deref() .is_some_and(|text| !text.trim().is_empty()) { return "camp_followup"; } let recent_text = recent_story_text(game_state, 6); if contains_any( recent_text.as_str(), &["击败", "怪物", "战斗", "切磋", "交手", "脱身"], ) { return "post_battle_breath"; } if extras.last_function_id.as_deref() == Some("npc_chat") { return "private_followup"; } "first_contact_cautious" } fn infer_conversation_pressure(game_state: &Value, situation: &str) -> &'static str { let hp = read_i32_field(game_state, "playerHp").unwrap_or(0); let max_hp = read_i32_field(game_state, "playerMaxHp") .unwrap_or(1) .max(1); if read_bool_field(game_state, "inBattle").unwrap_or(false) || hp * 100 < max_hp * 35 { return "high"; } match situation { "post_battle_breath" | "shared_danger_coordination" => "medium", "camp_first_contact" | "camp_followup" => "low", _ => "medium", } } fn build_recent_shared_event(game_state: &Value) -> Option { let recent_text = recent_story_text(game_state, 6); if contains_any( recent_text.as_str(), &["击败", "怪物", "战斗", "切磋", "交手", "脱身"], ) { return Some("你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。".to_string()); } if contains_any(recent_text.as_str(), &["携手", "相助", "帮你", "并肩"]) { return Some("你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。".to_string()); } None } fn describe_conversation_situation(situation: &str) -> &'static str { match situation { "camp_first_contact" => { "这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。" } "camp_followup" => "营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。", "post_battle_breath" => "一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。", "shared_danger_coordination" => "危险还没过去,对话应当短、准、直接,优先服务眼前判断。", "private_followup" => "这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。", _ => "双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。", } } fn describe_conversation_talk_priority(situation: &str) -> &'static str { match situation { "camp_first_contact" => "优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。", "camp_followup" => "先接住上一轮还没说透的话头,再决定要不要继续往下追问。", "post_battle_breath" => "先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。", "shared_danger_coordination" => "先说最有用的判断、危险和下一步,不要扩成大段背景说明。", "private_followup" => "承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。", _ => "先试探态度和现场判断,不要急着把来意和秘密一次摊开。", } } fn recent_story_text(game_state: &Value, limit: usize) -> String { read_array_field(game_state, "storyHistory") .into_iter() .rev() .take(limit) .collect::>() .into_iter() .rev() .filter_map(|entry| read_optional_string_field(entry, "text")) .collect::>() .join("\n") } fn resolve_encounter_narrative_profile<'a>( game_state: &'a Value, encounter: Option<&'a Value>, ) -> Option<&'a Value> { let encounter = encounter?; if let Some(profile) = read_field(encounter, "narrativeProfile") { return Some(profile); } let profile = read_object_field(game_state, "customWorldProfile")?; let encounter_id = read_optional_string_field(encounter, "id"); let encounter_name = read_encounter_name(encounter); ["storyNpcs", "playableNpcs"] .into_iter() .flat_map(|field| read_array_field(profile, field)) .find(|npc| { let npc_id = read_optional_string_field(npc, "id"); let npc_name = read_optional_string_field(npc, "name"); npc_id.is_some() && npc_id == encounter_id || npc_name.is_some() && npc_name == encounter_name }) .and_then(|npc| read_field(npc, "narrativeProfile")) } fn build_recent_chronicle_summary(game_state: &Value) -> Option { let memory = read_object_field(game_state, "storyEngineMemory"); let chapter_summary = read_field(game_state, "chapterState") .or_else(|| memory.and_then(|memory| read_field(memory, "currentChapter"))) .and_then(|chapter| read_optional_string_field(chapter, "chapterSummary")); let chronicle_lines = memory .and_then(|memory| read_field(memory, "chronicle")) .and_then(Value::as_array) .map(|entries| { entries .iter() .rev() .take(4) .collect::>() .into_iter() .rev() .filter_map(|entry| { let title = read_optional_string_field(entry, "title").unwrap_or_default(); let summary = read_optional_string_field(entry, "summary").unwrap_or_default(); let text = [title, summary] .into_iter() .filter(|text| !text.trim().is_empty()) .collect::>() .join(":"); (!text.trim().is_empty()).then_some(text) }) .collect::>() }) .unwrap_or_default(); let text = chapter_summary .into_iter() .chain(chronicle_lines) .collect::>() .join("\n"); (!text.trim().is_empty()).then_some(text) } fn build_party_relationship_notes(game_state: &Value) -> Option { let mut lines = Vec::new(); for (field, role_label) in [("companions", "当前同行"), ("roster", "营地待命")] { for companion in read_array_field(game_state, field) { let Some(character_id) = read_optional_string_field(companion, "characterId") else { continue; }; let Some(summary) = read_character_chat_summary(game_state, character_id.as_str()) else { continue; }; let name = resolve_character_name(game_state, character_id.as_str()) .unwrap_or_else(|| character_id.clone()); lines.push(format!("- {name}({role_label}):{summary}")); } } (!lines.is_empty()).then_some(lines.join("\n")) } fn resolve_character_name(game_state: &Value, character_id: &str) -> Option { let profile = read_object_field(game_state, "customWorldProfile")?; ["playableNpcs", "storyNpcs"] .into_iter() .flat_map(|field| read_array_field(profile, field)) .find(|npc| read_optional_string_field(npc, "id").as_deref() == Some(character_id)) .and_then(|npc| read_optional_string_field(npc, "name")) } fn read_character_chat_summary(game_state: &Value, character_id: &str) -> Option { read_object_field(game_state, "characterChats") .and_then(|chats| chats.get(character_id)) .and_then(|record| read_optional_string_field(record, "summary")) .filter(|text| !text.trim().is_empty()) } fn is_first_meaningful_contact(npc_state: Option<&Value>) -> bool { let Some(npc_state) = npc_state else { return false; }; !read_bool_field(npc_state, "firstMeaningfulContactResolved").unwrap_or(false) && read_i32_field(npc_state, "chattedCount").unwrap_or(0) <= 0 } fn first_contact_relation_stance(npc_state: Option<&Value>) -> Option { let npc_state = npc_state?; read_object_field(npc_state, "relationState") .and_then(|state| read_optional_string_field(state, "stance")) .filter(|stance| { matches!( stance.as_str(), "guarded" | "neutral" | "cooperative" | "bonded" ) }) } fn disclosure_stage(affinity: i32, recruited: bool) -> &'static str { if recruited || affinity >= 50 { "deep" } else if affinity >= 30 { "honest" } else if affinity >= 15 { "partial" } else { "guarded" } } fn warmth_stage(affinity: i32, recruited: bool) -> &'static str { if recruited || affinity >= 50 { "warm" } else if affinity >= 30 { "cooperative" } else if affinity >= 15 { "neutral" } else { "distant" } } fn answer_mode(stage: &str) -> &'static str { match stage { "deep" => "candid", "honest" => "true_but_incomplete", "partial" => "half_truth", _ => "situational_only", } } fn allowed_topics(stage: &str) -> Vec<&'static str> { match stage { "guarded" => vec!["眼前危险", "现场判断", "对玩家的态度", "模糊钩子"], "partial" => vec!["眼前危险", "表层理由", "试探性解释", "有限背景"], "honest" => vec!["真实动机的轮廓", "旧事碎片", "真正目标的一部分"], _ => vec!["真实来历", "真正目标", "旧事恩怨", "未说完的核心问题"], } } fn blocked_topics(stage: &str) -> Vec<&'static str> { match stage { "guarded" => vec!["完整来历", "真正目标", "旧事全貌"], "partial" => vec!["完整来历", "旧事全貌"], "honest" => vec!["把全部底牌一次说完"], _ => Vec::new(), } } fn describe_npc_affinity(affinity: i32) -> String { if affinity >= 90 { "高度信赖,言谈间明显亲近。".to_string() } else if affinity >= 60 { "已经建立稳固信任,愿意进一步合作。".to_string() } else if affinity >= 30 { "态度明显友善,也更愿意正常交流。".to_string() } else if affinity >= 15 { "戒备开始松动,愿意试探性配合。".to_string() } else if affinity >= 0 { "仍保持明显距离,只会给出谨慎而有限的回应。".to_string() } else { "关系降到冰点,对玩家几乎不保留善意。".to_string() } } fn default_conversation_style() -> Value { json!({ "guardStyle": "measured", "warmStyle": "steady", "truthStyle": "fragmented", }) } fn describe_scene_pressure_level(value: &str) -> Option<&'static str> { match value { "low" => Some("低"), "medium" => Some("中"), "high" => Some("高"), "extreme" => Some("极高"), _ => None, } } fn read_encounter_name(value: &Value) -> Option { read_optional_string_field(value, "npcName") .or_else(|| read_optional_string_field(value, "name")) } fn read_string_array(value: Option<&Value>) -> Vec { value .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|text| !text.is_empty()) .map(ToOwned::to_owned) .collect::>() }) .unwrap_or_default() } fn contains_any(text: &str, keywords: &[&str]) -> bool { keywords.iter().any(|keyword| text.contains(keyword)) } #[cfg(test)] mod tests { use super::*; #[test] fn prompt_context_projects_npc_directive_from_server_state() { let context = build_runtime_story_prompt_context( &json!({ "worldType": "WUXIA", "playerHp": 20, "playerMaxHp": 100, "playerMana": 6, "playerMaxMana": 20, "inBattle": false, "currentScenePreset": { "id": "scene-1", "name": "旧驿道", "description": "山风压着尘土。", "mutationStateText": "路边新添了打斗痕迹。", "currentPressureLevel": "high" }, "currentEncounter": { "id": "npc-1", "kind": "npc", "npcName": "守路人", "npcDescription": "守在路口的人。" }, "npcStates": { "npc-1": { "affinity": 18, "chattedCount": 0, "recruited": false, "firstMeaningfulContactResolved": false, "relationState": { "stance": "guarded" } } }, "storyHistory": [{ "text": "你刚从一场战斗里脱身。", "historyRole": "result" }] }), RuntimeStoryPromptContextExtras { last_function_id: Some("npc_chat".to_string()), ..RuntimeStoryPromptContextExtras::default() }, ); assert_eq!(context["sceneName"], json!("旧驿道")); assert_eq!(context["encounterDisclosureStage"], json!("partial")); assert_eq!(context["conversationPressure"], json!("high")); assert_eq!(context["firstContactRelationStance"], json!("guarded")); assert!( context["sceneDescription"] .as_str() .is_some_and(|text| text.contains("最新世界变化")) ); } }