推进 server-rs DDD 分层与新接口接线
This commit is contained in:
939
server-rs/crates/module-runtime-story/src/prompt_context.rs
Normal file
939
server-rs/crates/module-runtime-story/src/prompt_context.rs
Normal 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("最新世界变化"))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user