940 lines
33 KiB
Rust
940 lines
33 KiB
Rust
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("最新世界变化"))
|
||
);
|
||
}
|
||
}
|