Files
Genarrative/server-rs/crates/module-runtime-story-compat/src/prompt_context.rs
2026-04-28 19:36:39 +08:00

940 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
pub observe_signs_requested: bool,
pub recent_action_result: Option<String>,
pub opening_camp_background: Option<String>,
pub opening_camp_dialogue: Option<String>,
}
/// 基于后端持久化的运行时快照生成 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::<Vec<_>>();
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::<Vec<_>>()
} 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<String, Value>,
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<String, Value>,
game_state: &Value,
encounter: Option<&Value>,
npc_state: Option<&Value>,
encounter_narrative_profile: Option<&Value>,
affinity: Option<i32>,
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<String, Value>,
game_state: &Value,
story_engine_memory: Option<&Value>,
chapter_state: Option<&Value>,
journey_beat: Option<&Value>,
active_thread_ids: Vec<String>,
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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
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<String> {
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<String> {
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::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|entry| read_optional_string_field(entry, "text"))
.collect::<Vec<_>>()
.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<String> {
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::<Vec<_>>()
.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::<Vec<_>>()
.join("");
(!text.trim().is_empty()).then_some(text)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let text = chapter_summary
.into_iter()
.chain(chronicle_lines)
.collect::<Vec<_>>()
.join("\n");
(!text.trim().is_empty()).then_some(text)
}
fn build_party_relationship_notes(game_state: &Value) -> Option<String> {
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<String> {
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<String> {
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<String> {
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<String> {
read_optional_string_field(value, "npcName")
.or_else(|| read_optional_string_field(value, "name"))
}
fn read_string_array(value: Option<&Value>) -> Vec<String> {
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::<Vec<_>>()
})
.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("最新世界变化"))
);
}
}