推进 server-rs DDD 分层与新接口接线

This commit is contained in:
Codex
2026-04-29 15:46:16 +08:00
parent 9d3fcfae77
commit f82775b852
89 changed files with 3657 additions and 9636 deletions

View File

@@ -0,0 +1,939 @@
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("最新世界变化"))
);
}
}