删除后端未使用的历史 helper、mapper、handler 和 re-export 将仅测试使用的导入、常量和辅助函数收口到 cfg(test) 补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约 验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试
1090 lines
41 KiB
Rust
1090 lines
41 KiB
Rust
use serde_json::{Value, json};
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
|
||
pub world_type: &'a str,
|
||
pub character: &'a Value,
|
||
pub encounter: &'a Value,
|
||
pub monsters: Vec<Value>,
|
||
pub history: Vec<Value>,
|
||
pub context: Value,
|
||
pub topic: &'a str,
|
||
pub result_summary: &'a str,
|
||
pub requested_option: Value,
|
||
pub available_options: Vec<Value>,
|
||
}
|
||
|
||
pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
|
||
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct CharacterChatPromptParams<'a> {
|
||
pub world_type: &'a str,
|
||
pub player_character: &'a Value,
|
||
pub target_character: &'a Value,
|
||
pub story_history: &'a [Value],
|
||
pub context: &'a Value,
|
||
pub conversation_history: &'a [Value],
|
||
pub conversation_summary: &'a str,
|
||
pub previous_summary: &'a str,
|
||
pub player_message: &'a str,
|
||
pub target_status: &'a Value,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct NpcRecruitDialoguePromptParams<'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 invitation_text: &'a str,
|
||
pub recruit_summary: &'a str,
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_reply_system_prompt() -> &'static str {
|
||
"你是像素动作 RPG 中正在与玩家私下交谈的同行角色。只输出这名角色此刻会说的话,只允许中文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。"
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_suggestions_system_prompt() -> &'static str {
|
||
"你要为玩家生成 3 条下一句可直接发送的中文回复建议。只输出 3 行纯文本,不要序号、引号、Markdown 或解释。三条建议要分别偏关心、追问、轻松拉近关系。"
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_summary_system_prompt() -> &'static str {
|
||
"你要把玩家与该角色的聊天沉淀成一段后续剧情可用的关系摘要。只输出一段中文摘要,不要标题、Markdown、JSON 或解释。"
|
||
}
|
||
|
||
pub(crate) fn build_npc_recruit_dialogue_system_prompt() -> &'static str {
|
||
"你是角色扮演 RPG 的招募剧情对话编剧。只输出纯中文对话正文,不要输出解释、代码、Markdown、JSON 或额外说明。最后一行必须由对方明确答应加入队伍。"
|
||
}
|
||
|
||
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
|
||
npc_name: &str,
|
||
params: RuntimeNpcDialoguePromptParams<'_>,
|
||
) -> String {
|
||
let state_prompt = json!({
|
||
"worldType": params.world_type,
|
||
"character": params.character,
|
||
"encounter": params.encounter,
|
||
"monsters": params.monsters,
|
||
"history": params.history,
|
||
"context": params.context,
|
||
"topic": params.topic,
|
||
"resultSummary": params.result_summary,
|
||
"requestedOption": params.requested_option,
|
||
"availableOptions": params.available_options,
|
||
})
|
||
.to_string();
|
||
|
||
format!(
|
||
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{state_prompt}"
|
||
)
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_reply_user_prompt(
|
||
params: CharacterChatPromptParams<'_>,
|
||
) -> String {
|
||
json!({
|
||
"worldType": params.world_type,
|
||
"playerCharacter": params.player_character,
|
||
"targetCharacter": params.target_character,
|
||
"storyHistory": params.story_history,
|
||
"context": params.context,
|
||
"conversationHistory": params.conversation_history,
|
||
"conversationSummary": params.conversation_summary,
|
||
"playerMessage": params.player_message,
|
||
"targetStatus": params.target_status,
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_suggestions_user_prompt(
|
||
params: CharacterChatPromptParams<'_>,
|
||
) -> String {
|
||
json!({
|
||
"worldType": params.world_type,
|
||
"playerCharacter": params.player_character,
|
||
"targetCharacter": params.target_character,
|
||
"storyHistory": params.story_history,
|
||
"context": params.context,
|
||
"conversationHistory": params.conversation_history,
|
||
"conversationSummary": params.conversation_summary,
|
||
"targetStatus": params.target_status,
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_summary_user_prompt(
|
||
params: CharacterChatPromptParams<'_>,
|
||
) -> String {
|
||
json!({
|
||
"worldType": params.world_type,
|
||
"playerCharacter": params.player_character,
|
||
"targetCharacter": params.target_character,
|
||
"storyHistory": params.story_history,
|
||
"context": params.context,
|
||
"conversationHistory": params.conversation_history,
|
||
"previousSummary": params.previous_summary,
|
||
"targetStatus": params.target_status,
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
pub(crate) fn build_npc_recruit_dialogue_user_prompt(
|
||
npc_name: &str,
|
||
params: NpcRecruitDialoguePromptParams<'_>,
|
||
) -> String {
|
||
let state_prompt = json!({
|
||
"worldType": params.world_type,
|
||
"character": params.character,
|
||
"encounter": params.encounter,
|
||
"monsters": params.monsters,
|
||
"history": params.history,
|
||
"context": params.context,
|
||
"invitationText": params.invitation_text,
|
||
"recruitSummary": params.recruit_summary,
|
||
})
|
||
.to_string();
|
||
|
||
format!(
|
||
"请基于以下运行时状态,把“邀请 {npc_name} 入队”这件事写成 4 到 6 行可直接展示的中文对话。最后一行必须由 {npc_name} 明确答应加入。\n{state_prompt}"
|
||
)
|
||
}
|
||
|
||
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#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。
|
||
只输出 JSON,不要输出 Markdown 或解释。
|
||
JSON 结构:
|
||
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
|
||
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
|
||
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
|
||
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
|
||
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。
|
||
- 非敌对聊天 shouldEndChat 必须为 false。
|
||
- 敌对聊天可以随时 shouldEndChat=true。
|
||
- 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 shouldEndChat=true。
|
||
- 敌对 NPC 已聊天轮次达到 4 轮或以上时,本轮结束后会超过 4 轮,应倾向立即 shouldEndChat=true。
|
||
- shouldEndChat=true 时 terminationReason 使用 hostile_breakoff,suggestions 与 functionSuggestions 可以为空。"#;
|
||
|
||
#[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_hostile_model_chat = chat_directive
|
||
.and_then(|record| read_string(record.get("terminationMode")))
|
||
.as_deref()
|
||
== Some("hostile_model")
|
||
|| chat_directive
|
||
.and_then(|record| read_bool(record.get("isHostileChat")))
|
||
.unwrap_or(false);
|
||
let is_player_exit_turn = chat_directive
|
||
.and_then(|record| read_string(record.get("terminationReason")))
|
||
.as_deref()
|
||
== Some("player_exit");
|
||
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_hostile_model_chat {
|
||
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
|
||
} else {
|
||
None
|
||
},
|
||
if is_hostile_model_chat {
|
||
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
|
||
} else {
|
||
None
|
||
},
|
||
if is_hostile_model_chat {
|
||
Some("如果玩家刚才的话被 NPC 感知为负面发言,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,本轮回复应倾向写成最后通牒、驱逐前警告或战斗前狠话。".to_string())
|
||
} else {
|
||
None
|
||
},
|
||
if is_hostile_model_chat && chatted_count >= 4.0 {
|
||
Some(format!(
|
||
"敌对聊天已持续 {} 轮,本轮结束后会超过 4 轮;回复应明显倾向立即收束,像开战前最后一句狠话,而不是继续闲聊。",
|
||
format_prompt_number(chatted_count)
|
||
))
|
||
} else {
|
||
None
|
||
},
|
||
if is_player_exit_turn {
|
||
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
|
||
} 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::<Vec<_>>()
|
||
.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);
|
||
let chat_directive = payload.chat_directive.and_then(as_record);
|
||
let is_hostile_model_chat = chat_directive
|
||
.and_then(|record| read_string(record.get("terminationMode")))
|
||
.as_deref()
|
||
== Some("hostile_model")
|
||
|| chat_directive
|
||
.and_then(|record| read_bool(record.get("isHostileChat")))
|
||
.unwrap_or(false);
|
||
let is_player_exit_turn = chat_directive
|
||
.and_then(|record| read_string(record.get("terminationReason")))
|
||
.as_deref()
|
||
== Some("player_exit");
|
||
let chatted_count = as_record(payload.npc_state)
|
||
.and_then(|record| read_number(record.get("chattedCount")))
|
||
.unwrap_or(0.0);
|
||
let function_options_block = chat_directive
|
||
.and_then(|record| record.get("functionOptions"))
|
||
.map(describe_function_options)
|
||
.filter(|text| !text.trim().is_empty());
|
||
|
||
[
|
||
Some(build_npc_dialogue_prompt_base(payload)),
|
||
Some(describe_npc_conversation_history(
|
||
conversation_history,
|
||
encounter.npc_name.as_str(),
|
||
)),
|
||
combat_context_block,
|
||
function_options_block,
|
||
if payload.npc_initiates_conversation {
|
||
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
|
||
} else {
|
||
Some(format!("玩家刚刚说:{}", payload.player_message))
|
||
},
|
||
Some(format!("NPC 刚刚回复:{npc_reply}")),
|
||
if is_hostile_model_chat {
|
||
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())
|
||
} else {
|
||
Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string())
|
||
},
|
||
if is_hostile_model_chat {
|
||
Some(format!(
|
||
"敌对聊天判定:已聊天轮次为 {}。若玩家刚才的话可被 NPC 感知为负面发言,或已聊天轮次达到 4 轮及以上,本轮应倾向 shouldEndChat=true,并使用 terminationReason=hostile_breakoff。",
|
||
format_prompt_number(chatted_count)
|
||
))
|
||
} else {
|
||
None
|
||
},
|
||
if is_player_exit_turn {
|
||
Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string())
|
||
} else {
|
||
None
|
||
},
|
||
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
|
||
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
|
||
Some("只输出 JSON:{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.filter(|text| !text.trim().is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("\n\n")
|
||
}
|
||
|
||
pub(crate) fn build_deterministic_npc_reply(
|
||
npc_name: &str,
|
||
player_message: &str,
|
||
npc_initiates_conversation: bool,
|
||
) -> String {
|
||
// LLM 不可用时仍由后端返回稳定中文对白,保证相遇和点击聊天链路不断。
|
||
if npc_initiates_conversation {
|
||
return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”");
|
||
}
|
||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||
}
|
||
|
||
pub(crate) fn build_deterministic_hostile_breakoff_reply(
|
||
npc_name: &str,
|
||
player_message: &str,
|
||
) -> String {
|
||
// 中文注释:当模型不可用而敌对聊天必须中止时,兜底文案也保持“战斗前狠话”的语气。
|
||
let player_signal = player_message.trim();
|
||
if player_signal.is_empty() {
|
||
return format!("{npc_name}冷声说道:“话已经够多了。再往前一步,就别指望还能全身而退。”");
|
||
}
|
||
format!(
|
||
"{npc_name}冷声说道:“{player_signal}?话已经够多了。再往前一步,就别指望还能全身而退。”"
|
||
)
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_reply_fallback(
|
||
target_character: &Value,
|
||
player_message: &str,
|
||
conversation_summary: &str,
|
||
) -> String {
|
||
let target_name =
|
||
read_name_field(target_character, "name").unwrap_or_else(|| "对方".to_string());
|
||
let focus = if player_message.trim().is_empty() {
|
||
"我听见你刚才的话了。".to_string()
|
||
} else if player_message.trim().ends_with('。') {
|
||
player_message.trim().to_string()
|
||
} else {
|
||
format!("{}。", player_message.trim())
|
||
};
|
||
|
||
if conversation_summary.trim().is_empty() {
|
||
format!("{focus}我会认真回答你。既然你愿意直接来问,我们就把这件事说清楚。")
|
||
} else {
|
||
format!("{focus}{target_name}显然记得你们之前谈过的事,所以这次回答也比先前更直接。")
|
||
}
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_suggestions_fallback(target_character: &Value) -> String {
|
||
let target_name = read_name_field(target_character, "name").unwrap_or_else(|| "你".to_string());
|
||
[
|
||
"我想先听你把真正担心的事说出来。".to_string(),
|
||
format!("{target_name},这件事你还瞒了我什么?"),
|
||
"先别谈别的,我想多了解你一点。".to_string(),
|
||
]
|
||
.join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_character_chat_summary_fallback(
|
||
target_character: &Value,
|
||
conversation_history: &[Value],
|
||
previous_summary: &str,
|
||
) -> String {
|
||
let target_name =
|
||
read_name_field(target_character, "name").unwrap_or_else(|| "这名角色".to_string());
|
||
let latest_turns = conversation_history
|
||
.iter()
|
||
.rev()
|
||
.take(4)
|
||
.collect::<Vec<_>>()
|
||
.into_iter()
|
||
.rev()
|
||
.filter_map(|item| {
|
||
let record = as_record(item)?;
|
||
let speaker =
|
||
read_string(record.get("speaker")).unwrap_or_else(|| "character".to_string());
|
||
let text = read_string(record.get("text"))?;
|
||
Some(format!(
|
||
"{}:{}",
|
||
if speaker == "player" {
|
||
"玩家"
|
||
} else {
|
||
target_name.as_str()
|
||
},
|
||
text
|
||
))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
|
||
let current = if latest_turns.is_empty() {
|
||
format!("{target_name}愿意继续私下交谈,对玩家的态度正在慢慢松动。")
|
||
} else {
|
||
format!("{target_name}在私下交谈中比先前更愿意回应。最近交流:{latest_turns}")
|
||
};
|
||
|
||
if previous_summary.trim().is_empty() {
|
||
current
|
||
} else {
|
||
format!("{} {}", previous_summary.trim(), current)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn build_npc_chat_dialogue_fallback(encounter: &Value, topic: &str) -> String {
|
||
let npc_name = read_name_field(encounter, "npcName")
|
||
.or_else(|| read_name_field(encounter, "name"))
|
||
.unwrap_or_else(|| "对方".to_string());
|
||
[
|
||
format!(
|
||
"你:{}。我想先听听你的看法。",
|
||
if topic.trim().is_empty() {
|
||
"这件事我还没看透"
|
||
} else {
|
||
topic.trim()
|
||
}
|
||
),
|
||
format!("{npc_name}:你问得并不随意,看来是真想弄清这里的底细。"),
|
||
"你:前面的局势我还没看透。你若知道什么,就别只说一半。".to_string(),
|
||
format!("{npc_name}:我能告诉你的,是这里近来一直不太平。接下来多留神些。"),
|
||
]
|
||
.join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_npc_recruit_dialogue_fallback(encounter: &Value) -> String {
|
||
let npc_name = read_name_field(encounter, "npcName")
|
||
.or_else(|| read_name_field(encounter, "name"))
|
||
.unwrap_or_else(|| "对方".to_string());
|
||
[
|
||
"你:这不是客套。我是真心希望你能加入队伍,和我一起走下去。".to_string(),
|
||
format!("{npc_name}:你这番话够坦诚,我听得出你不是随口一提。"),
|
||
"你:前路不会轻松,但我还是希望你能与我并肩同行。".to_string(),
|
||
format!("{npc_name}:好,我答应你。从现在起,我便与你结伴同行。"),
|
||
]
|
||
.join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_deterministic_chat_suggestions(
|
||
npc_name: &str,
|
||
player_message: &str,
|
||
) -> Vec<String> {
|
||
// 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。
|
||
vec![
|
||
format!("{npc_name},我想先听你说"),
|
||
"这件事哪里不对劲".to_string(),
|
||
if player_message.contains('帮') || player_message.contains('忙') {
|
||
"先别绕,说清代价".to_string()
|
||
} else {
|
||
"你是不是还瞒着我".to_string()
|
||
},
|
||
]
|
||
}
|
||
|
||
pub(crate) fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec<String> {
|
||
let topic = player_message.trim().chars().take(8).collect::<String>();
|
||
let topic = if topic.is_empty() {
|
||
"刚才那句".to_string()
|
||
} else {
|
||
topic
|
||
};
|
||
|
||
vec![
|
||
"我愿意先听你说完".to_string(),
|
||
format!("这事和{topic}有关吗"),
|
||
"你别再避重就轻".to_string(),
|
||
]
|
||
}
|
||
|
||
pub(crate) fn build_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec<Value> {
|
||
read_function_options(chat_directive)
|
||
.into_iter()
|
||
.filter(|option| {
|
||
read_string_field(option, "functionId")
|
||
.as_deref()
|
||
.is_some_and(|function_id| function_id != "npc_chat")
|
||
})
|
||
.take(2)
|
||
.filter_map(|option| {
|
||
let function_id = read_string_field(option, "functionId")?;
|
||
let action_text = read_string_field(option, "actionText")?;
|
||
Some(json!({
|
||
"functionId": function_id,
|
||
"actionText": action_text,
|
||
}))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn describe_function_options(value: &Value) -> String {
|
||
let lines = value
|
||
.as_array()
|
||
.map(|items| {
|
||
items
|
||
.iter()
|
||
.take(8)
|
||
.filter_map(|item| {
|
||
let record = as_record(item)?;
|
||
let function_id = read_string(record.get("functionId"))?;
|
||
let action_text = read_string(record.get("actionText"))?;
|
||
let detail_text = read_string(record.get("detailText"));
|
||
let action = read_string(record.get("action"));
|
||
Some(format!(
|
||
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
|
||
action.unwrap_or_else(|| "unknown".to_string()),
|
||
detail_text.unwrap_or_else(|| "无".to_string()),
|
||
))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
if lines.is_empty() {
|
||
return String::new();
|
||
}
|
||
|
||
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions:".to_string()];
|
||
result.extend(lines);
|
||
result.join("\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::<Vec<_>>()
|
||
.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, Value>>) -> 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::<Vec<_>>()
|
||
.into_iter()
|
||
.rev()
|
||
.filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text"))))
|
||
.collect::<Vec<_>>();
|
||
|
||
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::<Vec<_>>()
|
||
.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::<Vec<_>>();
|
||
|
||
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<String> {
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>()
|
||
.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::<Vec<_>>();
|
||
|
||
if lines.is_empty() {
|
||
"当前敌对目标:无。".to_string()
|
||
} else {
|
||
let mut result = vec!["当前敌对目标:".to_string()];
|
||
result.extend(lines);
|
||
result.join("\n")
|
||
}
|
||
}
|
||
|
||
fn read_function_options(chat_directive: Option<&Value>) -> Vec<&Value> {
|
||
chat_directive
|
||
.and_then(|directive| directive.get("functionOptions"))
|
||
.and_then(Value::as_array)
|
||
.map(|items| items.iter().collect::<Vec<_>>())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn read_string_field(value: &Value, field: &str) -> Option<String> {
|
||
value
|
||
.get(field)
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|text| !text.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn read_name_field(value: &Value, field: &str) -> Option<String> {
|
||
value
|
||
.get(field)
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|text| !text.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn read_string(value: Option<&Value>) -> Option<String> {
|
||
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<f64> {
|
||
value
|
||
.and_then(Value::as_f64)
|
||
.filter(|number| number.is_finite())
|
||
}
|
||
|
||
fn read_bool(value: Option<&Value>) -> Option<bool> {
|
||
value.and_then(Value::as_bool)
|
||
}
|
||
|
||
fn read_string_array(value: &Value) -> Vec<String> {
|
||
value
|
||
.as_array()
|
||
.map(|items| {
|
||
items
|
||
.iter()
|
||
.filter_map(|item| read_string(Some(item)))
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn as_record(value: &Value) -> Option<&serde_json::Map<String, Value>> {
|
||
value.as_object()
|
||
}
|
||
|
||
fn format_prompt_number(value: f64) -> String {
|
||
if value.fract() == 0.0 {
|
||
format!("{}", value as i64)
|
||
} else {
|
||
value.to_string()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn hostile_prompt_input(npc_state: Value) -> NpcChatTurnPromptInput<'static> {
|
||
NpcChatTurnPromptInput {
|
||
world_type: "CUSTOM",
|
||
character: Box::leak(Box::new(Value::Null)),
|
||
encounter: Box::leak(Box::new(Value::Null)),
|
||
monsters: &[],
|
||
history: &[],
|
||
context: Box::leak(Box::new(Value::Null)),
|
||
conversation_history: &[],
|
||
dialogue: &[],
|
||
combat_context: None,
|
||
player_message: "少废话,让开。",
|
||
npc_state: Box::leak(Box::new(npc_state)),
|
||
npc_initiates_conversation: false,
|
||
chat_directive: Some(Box::leak(Box::new(json!({
|
||
"terminationMode": "hostile_model",
|
||
"isHostileChat": true,
|
||
})))),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_reply_prompt_mentions_final_threat_after_four_turns() {
|
||
let input = hostile_prompt_input(json!({
|
||
"affinity": -12,
|
||
"chattedCount": 4,
|
||
}));
|
||
let prompt = build_npc_chat_turn_reply_prompt(&input);
|
||
|
||
assert!(prompt.contains("已聊天轮次:4"));
|
||
assert!(prompt.contains("战斗前狠话"));
|
||
assert!(prompt.contains("本轮结束后会超过 4 轮"));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_suggestion_prompt_mentions_should_end_chat_signals() {
|
||
let input = hostile_prompt_input(json!({
|
||
"affinity": -12,
|
||
"chattedCount": 4,
|
||
}));
|
||
let prompt = build_npc_chat_turn_suggestion_prompt(&input, "再往前一步,就别想回头。");
|
||
|
||
assert!(prompt.contains("shouldEndChat=true"));
|
||
assert!(prompt.contains("terminationReason=hostile_breakoff"));
|
||
assert!(prompt.contains("已聊天轮次为 4"));
|
||
}
|
||
}
|