use serde_json::Value; pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。 你只输出这名 NPC 此刻会对玩家说的一轮回复。 只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 - 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。 回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#; pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。 只输出纯文本,共 3 行,每行 1 条。 不要加编号、项目符号、Markdown、JSON 或额外说明。 三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。"#; #[derive(Debug)] pub(crate) struct NpcChatTurnPromptInput<'a> { pub world_type: &'a str, pub character: &'a Value, pub encounter: &'a Value, pub monsters: &'a [Value], pub history: &'a [Value], pub context: &'a Value, pub conversation_history: &'a [Value], pub dialogue: &'a [Value], pub combat_context: Option<&'a Value>, pub player_message: &'a str, pub npc_state: &'a Value, pub npc_initiates_conversation: bool, pub chat_directive: Option<&'a Value>, } pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String { let encounter = describe_encounter(payload.encounter); let context = as_record(payload.context); let npc_state = as_record(payload.npc_state); let chat_directive = payload.chat_directive.and_then(as_record); let conversation_history = if !payload.conversation_history.is_empty() { payload.conversation_history } else { payload.dialogue }; let opening_camp_background = context.and_then(|record| read_string(record.get("openingCampBackground"))); let opening_camp_dialogue = context.and_then(|record| read_string(record.get("openingCampDialogue"))); let allowed_topics = context .and_then(|record| record.get("encounterAllowedTopics")) .map(read_string_array) .unwrap_or_default(); let blocked_topics = context .and_then(|record| record.get("encounterBlockedTopics")) .map(read_string_array) .unwrap_or_default(); let is_first_meaningful_contact = context .and_then(|record| read_bool(record.get("isFirstMeaningfulContact"))) .unwrap_or(false); let affinity = npc_state .and_then(|record| read_number(record.get("affinity"))) .unwrap_or(0.0); let chatted_count = npc_state .and_then(|record| read_number(record.get("chattedCount"))) .unwrap_or(0.0); let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason"))); let turn_limit = chat_directive .and_then(|record| read_number(record.get("turnLimit"))) .unwrap_or(0.0) .max(0.0); let remaining_turns = chat_directive .and_then(|record| read_number(record.get("remainingTurns"))) .unwrap_or(0.0) .max(0.0); let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode"))); let is_limited_negative_affinity_chat = limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0; let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close") || chat_directive .and_then(|record| read_bool(record.get("forceExitAfterTurn"))) .unwrap_or(false); let has_npc_reply_in_history = conversation_history.iter().any(|item| { as_record(item) .and_then(|turn| read_string(turn.get("speaker"))) .is_some_and(|speaker| speaker == "npc") }); let is_first_npc_spoken_turn = is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0; let first_contact_relation_stance = describe_first_contact_relation_stance( context.and_then(|record| record.get("firstContactRelationStance")), ); let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); [ Some(build_npc_dialogue_prompt_base(payload)), Some(describe_npc_conversation_history( conversation_history, encounter.npc_name.as_str(), )), combat_context_block, opening_camp_background.map(|text| format!("营地开场背景:{text}")), opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")), Some(format!("当前关系值:{}", format_prompt_number(affinity))), Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))), if is_first_npc_spoken_turn { Some(format!( "当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。", encounter.npc_name )) } else { None }, if is_first_npc_spoken_turn { Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string()) } else { None }, if is_first_npc_spoken_turn { Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string()) } else { None }, if payload.npc_initiates_conversation { Some(format!( "当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。", encounter.npc_name )) } else { None }, if allowed_topics.is_empty() { None } else { Some(format!("当前更适合先谈:{}", allowed_topics.join("、"))) }, if blocked_topics.is_empty() { None } else { Some(format!("当前避免直接说破:{}", blocked_topics.join("、"))) }, if is_limited_negative_affinity_chat { Some(format!( "当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。", format_prompt_number(turn_limit) )) } else { None }, if is_limited_negative_affinity_chat { Some(format!( "在你回复完这一轮之后,还剩 {} 轮可以继续聊。", format_prompt_number(remaining_turns) )) } else { None }, if is_limited_negative_affinity_chat && !is_foreshadow_close_turn { Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string()) } else { None }, if is_foreshadow_close_turn { Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string()) } else { None }, if is_foreshadow_close_turn { Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string()) } else { None }, if is_foreshadow_close_turn { Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string()) } else { None }, if payload.npc_initiates_conversation { Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string()) } else { Some(format!("玩家刚刚说:{}", payload.player_message.trim())) }, if payload.npc_initiates_conversation { Some(format!( "现在请只写 {} 主动开口时会说的话。", encounter.npc_name )) } else { Some(format!( "现在请只写 {} 这一轮会回复玩家的话。", encounter.npc_name )) }, ] .into_iter() .flatten() .filter(|text| !text.trim().is_empty()) .collect::>() .join("\n\n") } pub(crate) fn build_npc_chat_turn_suggestion_prompt( payload: &NpcChatTurnPromptInput<'_>, npc_reply: &str, ) -> String { let encounter = describe_encounter(payload.encounter); let conversation_history = if !payload.conversation_history.is_empty() { payload.conversation_history } else { payload.dialogue }; let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); [ Some(build_npc_dialogue_prompt_base(payload)), Some(describe_npc_conversation_history( conversation_history, encounter.npc_name.as_str(), )), combat_context_block, Some(format!("玩家刚刚说:{}", payload.player_message)), Some(format!("NPC 刚刚回复:{npc_reply}")), Some("请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。".to_string()), Some("每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。".to_string()), Some("每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。".to_string()), ] .into_iter() .flatten() .filter(|text| !text.trim().is_empty()) .collect::>() .join("\n\n") } fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String { let encounter = describe_encounter(payload.encounter); [ format!("世界:{}", describe_world(payload.world_type)), describe_scene_context(payload.context), describe_character("玩家 / ", payload.character), encounter.block, describe_monsters(payload.monsters), describe_story_history(payload.history), ] .into_iter() .filter(|text| !text.trim().is_empty()) .collect::>() .join("\n\n") } struct EncounterDescription { npc_name: String, block: String, } fn describe_encounter(encounter: &Value) -> EncounterDescription { let record = as_record(encounter); let npc_name = record .and_then(|item| read_string(item.get("npcName"))) .unwrap_or_else(|| "眼前角色".to_string()); let context_text = record .and_then(|item| read_string(item.get("context"))) .or_else(|| record.and_then(|item| read_string(item.get("npcDescription")))) .unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string()); EncounterDescription { npc_name: npc_name.clone(), block: format!("当前对象:{npc_name}\n对象背景:{context_text}"), } } fn describe_first_contact_relation_stance(value: Option<&Value>) -> String { match value.and_then(|item| item.as_str()).map(str::trim) { Some("guarded") => "戒备试探".to_string(), Some("neutral") => "正常交流但仍不熟".to_string(), Some("cooperative") => "已有善意,先确认合作节奏".to_string(), Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(), _ => "第一次真正接触".to_string(), } } fn describe_world(world_type: &str) -> String { match world_type { "WUXIA" => "边城模板".to_string(), "XIANXIA" => "灵潮模板".to_string(), "CUSTOM" => "自定义世界".to_string(), value if !value.trim().is_empty() => value.to_string(), _ => "未知世界".to_string(), } } fn describe_stats(label: &str, record: Option<&serde_json::Map>) -> String { let hp = record .and_then(|item| read_number(item.get("hp"))) .unwrap_or(0.0); let max_hp = record .and_then(|item| read_number(item.get("maxHp"))) .unwrap_or(hp) .max(1.0); let mana = record .and_then(|item| read_number(item.get("mana"))) .unwrap_or(0.0); let max_mana = record .and_then(|item| read_number(item.get("maxMana"))) .unwrap_or(mana) .max(1.0); format!( "{label}生命 {}/{},灵力 {}/{}", format_prompt_number(hp), format_prompt_number(max_hp), format_prompt_number(mana), format_prompt_number(max_mana) ) } fn describe_character(label: &str, value: &Value) -> String { let record = as_record(value); let name = record .and_then(|item| read_string(item.get("name"))) .unwrap_or_else(|| "未知角色".to_string()); let title = record .and_then(|item| read_string(item.get("title"))) .unwrap_or_else(|| "未知称号".to_string()); let description = record .and_then(|item| read_string(item.get("description"))) .unwrap_or_else(|| "暂无额外描述".to_string()); let personality = record .and_then(|item| read_string(item.get("personality"))) .unwrap_or_else(|| "性格信息未显式提供".to_string()); [ format!("{label}姓名:{name}"), format!("{label}称号:{title}"), format!("{label}描述:{description}"), format!("{label}性格:{personality}"), ] .join("\n") } fn describe_story_history(history: &[Value]) -> String { if history.is_empty() { return "近期剧情:暂无。".to_string(); } let lines = history .iter() .rev() .take(4) .collect::>() .into_iter() .rev() .filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text")))) .collect::>(); if lines.is_empty() { "近期剧情:暂无。".to_string() } else { let mut result = vec!["近期剧情:".to_string()]; result.extend(lines.into_iter().map(|line| format!("- {line}"))); result.join("\n") } } fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String { if history.is_empty() { return "当前聊天记录:暂无。".to_string(); } let lines = history .iter() .rev() .take(10) .collect::>() .into_iter() .rev() .filter_map(|item| { let record = as_record(item)?; let speaker = read_string(record.get("speaker")); let speaker_name = read_string(record.get("speakerName")); let text = read_string(record.get("text"))?; match speaker.as_deref() { Some("player") => Some(format!("- 玩家:{text}")), Some("npc") => Some(format!( "- {}:{text}", speaker_name.unwrap_or_else(|| npc_name.to_string()) )), Some("system") => Some(format!("- 系统提示:{text}")), _ => Some(format!( "- {}:{text}", speaker_name.unwrap_or_else(|| "同伴".to_string()) )), } }) .collect::>(); if lines.is_empty() { "当前聊天记录:暂无。".to_string() } else { let mut result = vec!["当前聊天记录:".to_string()]; result.extend(lines); result.join("\n") } } fn describe_npc_combat_context(combat_context: &Value) -> Option { let record = as_record(combat_context)?; let summary = read_string(record.get("summary")); let battle_outcome = read_string(record.get("battleOutcome")); let log_lines = record .get("logLines") .map(read_string_array) .unwrap_or_default() .into_iter() .take(6) .collect::>(); if summary.is_none() && log_lines.is_empty() { return None; } let outcome_text = match battle_outcome.as_deref() { Some("spar_complete") => Some("切磋刚刚结束。".to_string()), Some("victory") => Some("战斗刚刚分出胜负。".to_string()), _ => None, }; let mut lines = vec!["刚刚结束的交锋:".to_string()]; if let Some(text) = outcome_text { lines.push(text); } if let Some(text) = summary { lines.push(format!("- 结果摘要:{text}")); } if !log_lines.is_empty() { lines.push("- 战斗日志:".to_string()); lines.extend(log_lines.into_iter().map(|line| format!(" - {line}"))); } Some(lines.join("\n")) } fn describe_scene_context(context: &Value) -> String { let record = as_record(context); let scene_name = record .and_then(|item| read_string(item.get("sceneName"))) .unwrap_or_else(|| "当前区域".to_string()); let scene_description = record .and_then(|item| read_string(item.get("sceneDescription"))) .unwrap_or_else(|| "周围气氛仍未完全安定。".to_string()); let in_battle = if record .and_then(|item| read_bool(item.get("inBattle"))) .unwrap_or(false) { "战斗中" } else { "非战斗" }; let custom_world_profile = record .and_then(|item| item.get("customWorldProfile")) .and_then(as_record); let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name"))); let custom_world_summary = custom_world_profile.and_then(|item| read_string(item.get("summary"))); [ Some(format!( "世界补充:{}", custom_world_name.unwrap_or_else(|| "无".to_string()) )), custom_world_summary.map(|text| format!("世界摘要:{text}")), Some(format!("场景:{scene_name}")), Some(format!("场景描述:{scene_description}")), Some(format!("当前状态:{in_battle}")), Some(describe_stats("玩家", record)), ] .into_iter() .flatten() .collect::>() .join("\n") } fn describe_monsters(monsters: &[Value]) -> String { if monsters.is_empty() { return "当前敌对目标:无。".to_string(); } let lines = monsters .iter() .take(4) .filter_map(|item| { let record = as_record(item)?; let name = read_string(record.get("name")) .or_else(|| read_string(record.get("npcName"))) .or_else(|| read_string(record.get("id")))?; let hp = read_number(record.get("hp")).unwrap_or(0.0); let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0); Some(format!( "- {name}(生命 {}/{})", format_prompt_number(hp), format_prompt_number(max_hp) )) }) .collect::>(); if lines.is_empty() { "当前敌对目标:无。".to_string() } else { let mut result = vec!["当前敌对目标:".to_string()]; result.extend(lines); result.join("\n") } } fn read_string(value: Option<&Value>) -> Option { value .and_then(Value::as_str) .map(str::trim) .filter(|text| !text.is_empty()) .map(ToOwned::to_owned) } fn read_number(value: Option<&Value>) -> Option { value .and_then(Value::as_f64) .filter(|number| number.is_finite()) } fn read_bool(value: Option<&Value>) -> Option { value.and_then(Value::as_bool) } fn read_string_array(value: &Value) -> Vec { value .as_array() .map(|items| { items .iter() .filter_map(|item| read_string(Some(item))) .collect::>() }) .unwrap_or_default() } fn as_record(value: &Value) -> Option<&serde_json::Map> { value.as_object() } fn format_prompt_number(value: f64) -> String { if value.fract() == 0.0 { format!("{}", value as i64) } else { value.to_string() } }