1570 lines
56 KiB
Rust
1570 lines
56 KiB
Rust
use serde_json::{Map, Value, json};
|
||
|
||
use crate::{
|
||
ensure_json_object, format_now_rfc3339, read_array_field, read_bool_field, read_field,
|
||
read_i32_field, read_object_field, read_optional_string_field,
|
||
};
|
||
|
||
const CHAPTER_STAGE_OPENING: &str = "opening";
|
||
const CHAPTER_STAGE_EXPANSION: &str = "expansion";
|
||
const CHAPTER_STAGE_TURNING_POINT: &str = "turning_point";
|
||
const CHAPTER_STAGE_CLIMAX: &str = "climax";
|
||
const CHAPTER_STAGE_AFTERMATH: &str = "aftermath";
|
||
|
||
/// 将运行时动作结算后的叙事记忆投影到正式快照。
|
||
///
|
||
/// 中文注释:这里是从前端 story engine hook 迁出的最小确定性状态机。
|
||
/// 它只依赖动作前后 JSON 快照和本轮 functionId,不访问 HTTP、LLM 或外部资源。
|
||
pub fn project_story_engine_after_action(
|
||
previous_state: &Value,
|
||
game_state: &mut Value,
|
||
action_text: &str,
|
||
result_text: &str,
|
||
function_id: &str,
|
||
battle_outcome: Option<&str>,
|
||
) {
|
||
let mut memory = read_object_field(game_state, "storyEngineMemory")
|
||
.cloned()
|
||
.unwrap_or_else(|| Value::Object(Map::new()));
|
||
ensure_memory_defaults(&mut memory);
|
||
|
||
let signals = collect_story_signals(
|
||
previous_state,
|
||
game_state,
|
||
&memory,
|
||
action_text,
|
||
function_id,
|
||
battle_outcome,
|
||
);
|
||
apply_thread_signal_updates(game_state, &mut memory, &signals);
|
||
ensure_scene_chapter_state(game_state, &mut memory);
|
||
|
||
let previous_chapter = read_object_field(game_state, "chapterState")
|
||
.or_else(|| read_object_field(&memory, "currentChapter"))
|
||
.cloned();
|
||
let chapter_state =
|
||
resolve_current_chapter_state(game_state, &memory, previous_chapter.as_ref());
|
||
ensure_json_object(game_state).insert("chapterState".to_string(), chapter_state.clone());
|
||
ensure_json_object(&mut memory).insert("currentChapter".to_string(), chapter_state.clone());
|
||
|
||
let journey_beat = resolve_current_journey_beat(game_state, &memory, &chapter_state);
|
||
let journey_beat_id = read_optional_string_field(&journey_beat, "id")
|
||
.unwrap_or_else(|| "journey:default".to_string());
|
||
let memory_root = ensure_json_object(&mut memory);
|
||
memory_root.insert("currentJourneyBeatId".to_string(), json!(journey_beat_id));
|
||
memory_root.insert("currentJourneyBeat".to_string(), journey_beat.clone());
|
||
|
||
let new_mutations = resolve_world_mutations(game_state, &memory, &signals, &chapter_state);
|
||
let world_mutations = append_world_mutations(&memory, new_mutations);
|
||
ensure_json_object(&mut memory).insert(
|
||
"worldMutations".to_string(),
|
||
Value::Array(world_mutations.clone()),
|
||
);
|
||
apply_world_mutations_to_game_state(game_state, &world_mutations);
|
||
|
||
let reactions = build_companion_reactions(game_state, &signals, action_text);
|
||
apply_companion_reactions_to_stance(game_state, &reactions);
|
||
append_recent_companion_reactions(&mut memory, reactions);
|
||
|
||
let chronicle = append_chronicle_entries(&memory, &chapter_state, &world_mutations);
|
||
ensure_json_object(&mut memory)
|
||
.insert("chronicle".to_string(), Value::Array(chronicle.clone()));
|
||
ensure_json_object(&mut memory).insert(
|
||
"continueGameDigest".to_string(),
|
||
Value::String(build_continue_digest(
|
||
&chapter_state,
|
||
result_text,
|
||
&chronicle,
|
||
)),
|
||
);
|
||
ensure_json_object(&mut memory).insert(
|
||
"saveMigrationManifest".to_string(),
|
||
json!({
|
||
"version": "story-engine-backend-v1",
|
||
"requiredTransforms": [],
|
||
"backwardCompatible": true
|
||
}),
|
||
);
|
||
|
||
ensure_json_object(game_state).insert("storyEngineMemory".to_string(), memory);
|
||
}
|
||
|
||
fn ensure_memory_defaults(memory: &mut Value) {
|
||
let root = ensure_json_object(memory);
|
||
ensure_array_field(root, "discoveredFactIds");
|
||
ensure_array_field(root, "inferredFactIds");
|
||
ensure_array_field(root, "activeThreadIds");
|
||
ensure_array_field(root, "resolvedScarIds");
|
||
ensure_array_field(root, "recentCarrierIds");
|
||
ensure_array_field(root, "openedSceneChapterIds");
|
||
ensure_array_field(root, "recentSignalIds");
|
||
ensure_array_field(root, "recentCompanionReactions");
|
||
ensure_array_field(root, "worldMutations");
|
||
ensure_array_field(root, "chronicle");
|
||
ensure_array_field(root, "factionTensionStates");
|
||
ensure_array_field(root, "consequenceLedger");
|
||
ensure_array_field(root, "companionResolutions");
|
||
ensure_array_field(root, "narrativeCodex");
|
||
root.entry("currentSceneActState".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("currentChapter".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("currentJourneyBeatId".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("currentJourneyBeat".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("currentCampEvent".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("currentSetpieceDirective".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("continueGameDigest".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("campaignState".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("actState".to_string()).or_insert(Value::Null);
|
||
root.entry("endingState".to_string()).or_insert(Value::Null);
|
||
root.entry("authorialConstraintPack".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("branchBudgetStatus".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("narrativeQaReport".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("releaseGateReport".to_string())
|
||
.or_insert(Value::Null);
|
||
root.entry("playerStyleProfile".to_string())
|
||
.or_insert(Value::Null);
|
||
}
|
||
|
||
fn ensure_array_field(root: &mut Map<String, Value>, key: &str) {
|
||
if !root.get(key).is_some_and(Value::is_array) {
|
||
root.insert(key.to_string(), Value::Array(Vec::new()));
|
||
}
|
||
}
|
||
|
||
fn collect_story_signals(
|
||
previous_state: &Value,
|
||
next_state: &Value,
|
||
memory: &Value,
|
||
action_text: &str,
|
||
function_id: &str,
|
||
battle_outcome: Option<&str>,
|
||
) -> Vec<Value> {
|
||
let mut signals = Vec::new();
|
||
let active_thread_ids = read_string_array_field(memory, "activeThreadIds");
|
||
let previous_scene_id = current_scene_id(previous_state);
|
||
let next_scene_id = current_scene_id(next_state);
|
||
|
||
if previous_scene_id != next_scene_id {
|
||
if let Some(scene_id) = previous_scene_id.as_deref() {
|
||
signals.push(build_signal(
|
||
"leave_scene",
|
||
scene_id,
|
||
json!({
|
||
"sceneId": scene_id,
|
||
"threadIds": active_thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
if let Some(scene_id) = next_scene_id.as_deref() {
|
||
signals.push(build_signal(
|
||
"enter_scene",
|
||
scene_id,
|
||
json!({
|
||
"sceneId": scene_id,
|
||
"threadIds": active_thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
}
|
||
|
||
if function_id == "idle_observe_signs" {
|
||
let key = next_scene_id.as_deref().unwrap_or("scene");
|
||
signals.push(build_signal(
|
||
"inspect_scene",
|
||
key,
|
||
json!({
|
||
"sceneId": next_scene_id,
|
||
"threadIds": active_thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
|
||
if is_talk_signal(function_id, action_text, next_state) {
|
||
let actor_id =
|
||
current_encounter_id(next_state).or_else(|| current_encounter_id(previous_state));
|
||
let key = actor_id.as_deref().unwrap_or(action_text);
|
||
signals.push(build_signal(
|
||
"talk_to_actor",
|
||
key,
|
||
json!({
|
||
"actorId": actor_id,
|
||
"threadIds": active_thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
|
||
if function_id == "npc_gift" {
|
||
let actor_id =
|
||
current_encounter_id(previous_state).or_else(|| current_encounter_id(next_state));
|
||
signals.push(build_signal(
|
||
"give_item",
|
||
action_text,
|
||
json!({
|
||
"actorId": actor_id,
|
||
"threadIds": active_thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
|
||
if function_id == "npc_quest_accept" {
|
||
let actor_id =
|
||
current_encounter_id(previous_state).or_else(|| current_encounter_id(next_state));
|
||
signals.push(build_signal(
|
||
"accept_contract",
|
||
action_text,
|
||
json!({
|
||
"actorId": actor_id,
|
||
"threadIds": active_thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
|
||
if is_battle_win_signal(function_id, previous_state, next_state, battle_outcome) {
|
||
let key = next_scene_id.as_deref().unwrap_or("battle");
|
||
signals.push(build_signal(
|
||
"win_battle",
|
||
key,
|
||
json!({
|
||
"sceneId": next_scene_id,
|
||
"threadIds": active_thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
|
||
for item in find_new_inventory_items(previous_state, next_state) {
|
||
let item_id = read_optional_string_field(&item, "id").unwrap_or_else(|| "item".to_string());
|
||
let thread_ids = read_field(&item, "runtimeMetadata")
|
||
.and_then(|metadata| read_field(metadata, "storyFingerprint"))
|
||
.map(|fingerprint| read_string_array_field(fingerprint, "relatedThreadIds"))
|
||
.filter(|ids| !ids.is_empty())
|
||
.unwrap_or_else(|| active_thread_ids.clone());
|
||
signals.push(build_signal(
|
||
"obtain_carrier",
|
||
item_id.as_str(),
|
||
json!({
|
||
"carrierId": item_id,
|
||
"threadIds": thread_ids,
|
||
}),
|
||
));
|
||
}
|
||
|
||
dedupe_value_objects_by_id(signals, 12)
|
||
}
|
||
|
||
fn build_signal(signal_type: &str, key: &str, extra: Value) -> Value {
|
||
let mut signal = extra.as_object().cloned().unwrap_or_default();
|
||
signal.insert(
|
||
"id".to_string(),
|
||
Value::String(format!("{signal_type}:{key}")),
|
||
);
|
||
signal.insert(
|
||
"signalType".to_string(),
|
||
Value::String(signal_type.to_string()),
|
||
);
|
||
Value::Object(signal)
|
||
}
|
||
|
||
fn is_talk_signal(function_id: &str, action_text: &str, next_state: &Value) -> bool {
|
||
matches!(
|
||
function_id,
|
||
"npc_chat"
|
||
| "npc_preview_talk"
|
||
| "npc_help"
|
||
| "story_opening_camp_dialogue"
|
||
| "npc_chat_quest_offer_view"
|
||
| "npc_quest_accept"
|
||
| "npc_quest_turn_in"
|
||
) || read_object_field(next_state, "currentEncounter")
|
||
.and_then(|encounter| read_optional_string_field(encounter, "kind"))
|
||
.as_deref()
|
||
== Some("npc")
|
||
|| action_text.contains('聊')
|
||
|| action_text.contains('问')
|
||
|| action_text.contains("试探")
|
||
}
|
||
|
||
fn is_battle_win_signal(
|
||
function_id: &str,
|
||
previous_state: &Value,
|
||
next_state: &Value,
|
||
battle_outcome: Option<&str>,
|
||
) -> bool {
|
||
if !function_id.starts_with("battle_") && function_id != "inventory_use" {
|
||
return false;
|
||
}
|
||
if let Some(outcome) = battle_outcome {
|
||
// 中文注释:战斗终局已经由 battle resolver 明确给出时,
|
||
// story engine 必须信任该结果,避免败北复活清空战斗态后被误判为胜利信号。
|
||
return matches!(outcome, "victory" | "spar_complete");
|
||
}
|
||
let previous_battle = read_bool_field(previous_state, "inBattle").unwrap_or(false);
|
||
let next_battle = read_bool_field(next_state, "inBattle").unwrap_or(false);
|
||
let outcome = read_optional_string_field(next_state, "currentNpcBattleOutcome");
|
||
matches!(
|
||
outcome.as_deref(),
|
||
Some("fight_victory") | Some("spar_complete")
|
||
) && previous_battle
|
||
&& !next_battle
|
||
}
|
||
|
||
fn find_new_inventory_items(previous_state: &Value, next_state: &Value) -> Vec<Value> {
|
||
let previous_ids = read_array_field(previous_state, "playerInventory")
|
||
.into_iter()
|
||
.filter_map(|item| read_optional_string_field(item, "id"))
|
||
.collect::<std::collections::HashSet<_>>();
|
||
|
||
read_array_field(next_state, "playerInventory")
|
||
.into_iter()
|
||
.filter(|item| {
|
||
read_optional_string_field(item, "id").is_some_and(|id| !previous_ids.contains(&id))
|
||
})
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
fn apply_thread_signal_updates(game_state: &mut Value, memory: &mut Value, signals: &[Value]) {
|
||
if signals.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let active_thread_ids = dedupe_strings(
|
||
read_string_array_field(memory, "activeThreadIds")
|
||
.into_iter()
|
||
.chain(
|
||
signals
|
||
.iter()
|
||
.flat_map(|signal| read_string_array_field(signal, "threadIds")),
|
||
)
|
||
.collect(),
|
||
8,
|
||
);
|
||
let recent_signal_ids = dedupe_strings(
|
||
read_string_array_field(memory, "recentSignalIds")
|
||
.into_iter()
|
||
.chain(
|
||
signals
|
||
.iter()
|
||
.filter_map(|signal| read_optional_string_field(signal, "id")),
|
||
)
|
||
.collect(),
|
||
12,
|
||
);
|
||
let root = ensure_json_object(memory);
|
||
root.insert("activeThreadIds".to_string(), json!(active_thread_ids));
|
||
root.insert("recentSignalIds".to_string(), json!(recent_signal_ids));
|
||
|
||
update_quests_from_signals(game_state, signals);
|
||
}
|
||
|
||
fn update_quests_from_signals(game_state: &mut Value, signals: &[Value]) {
|
||
let signal_thread_ids = signals
|
||
.iter()
|
||
.flat_map(|signal| read_string_array_field(signal, "threadIds"))
|
||
.collect::<Vec<_>>();
|
||
if signal_thread_ids.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let root = ensure_json_object(game_state);
|
||
let quests = root
|
||
.entry("quests".to_string())
|
||
.or_insert_with(|| json!([]));
|
||
let Some(items) = quests.as_array_mut() else {
|
||
*quests = Value::Array(Vec::new());
|
||
return;
|
||
};
|
||
|
||
for quest in items {
|
||
let Some(quest_object) = quest.as_object_mut() else {
|
||
continue;
|
||
};
|
||
let quest_thread_id = quest_object
|
||
.get("threadId")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string);
|
||
if !quest_thread_id
|
||
.as_ref()
|
||
.is_some_and(|thread_id| signal_thread_ids.iter().any(|id| id == thread_id))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let next_visible_stage = quest_object
|
||
.get("visibleStage")
|
||
.and_then(Value::as_i64)
|
||
.unwrap_or(0)
|
||
.saturating_add(i64::try_from(signals.len()).unwrap_or(0))
|
||
.min(12);
|
||
quest_object.insert("visibleStage".to_string(), json!(next_visible_stage));
|
||
let discovered = dedupe_strings(
|
||
read_string_array_from_object(quest_object, "discoveredFactIds")
|
||
.into_iter()
|
||
.chain(signal_thread_ids.clone())
|
||
.collect(),
|
||
12,
|
||
);
|
||
quest_object.insert("discoveredFactIds".to_string(), json!(discovered));
|
||
}
|
||
}
|
||
|
||
fn ensure_scene_chapter_state(game_state: &mut Value, memory: &mut Value) {
|
||
let current_scene = read_optional_string_field(game_state, "currentScene");
|
||
let world_type = read_optional_string_field(game_state, "worldType");
|
||
let Some(scene) = read_object_field(game_state, "currentScenePreset").cloned() else {
|
||
return;
|
||
};
|
||
let Some(scene_id) = read_optional_string_field(&scene, "id") else {
|
||
return;
|
||
};
|
||
if current_scene.as_deref() != Some("Story") || world_type.is_none() {
|
||
return;
|
||
}
|
||
|
||
let opened = dedupe_strings(
|
||
read_string_array_field(memory, "openedSceneChapterIds")
|
||
.into_iter()
|
||
.chain(std::iter::once(scene_id.clone()))
|
||
.collect(),
|
||
64,
|
||
);
|
||
ensure_json_object(memory).insert("openedSceneChapterIds".to_string(), json!(opened));
|
||
|
||
if let Some(scene_act_state) =
|
||
build_initial_scene_act_runtime_state(game_state, memory, scene_id.as_str())
|
||
{
|
||
ensure_json_object(memory).insert("currentSceneActState".to_string(), scene_act_state);
|
||
}
|
||
|
||
if has_live_scene_chapter_quest(game_state, scene_id.as_str()) {
|
||
return;
|
||
}
|
||
|
||
let quest = build_scene_chapter_quest(game_state, &scene, scene_id.as_str());
|
||
let root = ensure_json_object(game_state);
|
||
let quests = root
|
||
.entry("quests".to_string())
|
||
.or_insert_with(|| json!([]));
|
||
if !quests.is_array() {
|
||
*quests = Value::Array(Vec::new());
|
||
}
|
||
quests
|
||
.as_array_mut()
|
||
.expect("quests should be array")
|
||
.push(quest);
|
||
}
|
||
|
||
fn has_live_scene_chapter_quest(game_state: &Value, scene_id: &str) -> bool {
|
||
let chapter_id = build_scene_chapter_id(scene_id);
|
||
read_array_field(game_state, "quests")
|
||
.into_iter()
|
||
.any(|quest| {
|
||
read_optional_string_field(quest, "chapterId").as_deref() == Some(chapter_id.as_str())
|
||
&& !matches!(
|
||
read_optional_string_field(quest, "status").as_deref(),
|
||
Some("turned_in") | Some("failed") | Some("expired")
|
||
)
|
||
})
|
||
}
|
||
|
||
fn build_scene_chapter_quest(game_state: &Value, scene: &Value, scene_id: &str) -> Value {
|
||
let scene_name =
|
||
read_optional_string_field(scene, "name").unwrap_or_else(|| "当前区域".to_string());
|
||
let scene_description = read_optional_string_field(scene, "description")
|
||
.unwrap_or_else(|| format!("{scene_name} 的局势正在变化。"));
|
||
let (issuer_npc_id, issuer_npc_name) = resolve_scene_chapter_issuer(scene);
|
||
let chapter_id = build_scene_chapter_id(scene_id);
|
||
let quest_id = format!("quest:chapter:{scene_id}");
|
||
let title = compact_title(format!("{scene_name}异动").as_str(), "查明异动");
|
||
let world_type = read_optional_string_field(game_state, "worldType");
|
||
let currency = if world_type.as_deref() == Some("XIANXIA") {
|
||
54
|
||
} else {
|
||
72
|
||
};
|
||
|
||
json!({
|
||
"id": quest_id,
|
||
"issuerNpcId": issuer_npc_id,
|
||
"issuerNpcName": issuer_npc_name,
|
||
"sceneId": scene_id,
|
||
"chapterId": chapter_id,
|
||
"actId": read_field(game_state, "storyEngineMemory")
|
||
.and_then(|memory| read_field(memory, "actState"))
|
||
.and_then(|act| read_optional_string_field(act, "id")),
|
||
"threadId": Value::Null,
|
||
"contractId": Value::Null,
|
||
"title": title,
|
||
"description": format!("{scene_description} 这一章需要先把现场线索和压力接住。"),
|
||
"summary": format!("在 {scene_name} 接住这一章的线索并完成收束"),
|
||
"objective": {
|
||
"kind": "talk_to_npc",
|
||
"targetNpcId": issuer_npc_id,
|
||
"requiredCount": 1
|
||
},
|
||
"progress": 0,
|
||
"status": "active",
|
||
"completionNotified": false,
|
||
"reward": {
|
||
"affinityBonus": 12,
|
||
"currency": currency,
|
||
"experience": 40,
|
||
"items": []
|
||
},
|
||
"rewardText": format!("完成后可获得好感 +12、赏金 {currency}、经验 +40。"),
|
||
"narrativeBinding": {
|
||
"origin": "fallback_builder",
|
||
"narrativeType": "investigation",
|
||
"dramaticNeed": format!("{scene_name} 的异常已经足以独立成章。"),
|
||
"issuerGoal": format!("查清 {scene_name} 当前没有说透的异动。"),
|
||
"playerHook": format!("你已经进入 {scene_name},这一章现在就落在你面前。"),
|
||
"worldReason": format!("{scene_name} 的线索和残痕正在把局势往前推。"),
|
||
"followupHooks": [format!("{scene_name} 的这一章收束后,下一段去向会更明确。")]
|
||
},
|
||
"steps": [
|
||
{
|
||
"id": format!("{quest_id}:opening"),
|
||
"title": "确认现场异样",
|
||
"kind": "talk_to_npc",
|
||
"targetNpcId": issuer_npc_id,
|
||
"requiredCount": 1,
|
||
"progress": 0,
|
||
"revealText": format!("先在 {scene_name} 确认眼前异样,不要让这一章从开口处滑过去。"),
|
||
"completeText": format!("{scene_name} 的表层线索已经确认,可以继续推进收束。")
|
||
},
|
||
{
|
||
"id": format!("{quest_id}:resolve"),
|
||
"title": "收束当前章节",
|
||
"kind": "reach_scene",
|
||
"targetSceneId": scene_id,
|
||
"requiredCount": 1,
|
||
"progress": 0,
|
||
"revealText": format!("继续推进 {scene_name} 的线索,把这一章推向收束。"),
|
||
"completeText": format!("{scene_name} 的这一章已经完成收束。")
|
||
}
|
||
],
|
||
"activeStepId": format!("{quest_id}:opening"),
|
||
"visibleStage": 0,
|
||
"hiddenFlags": [],
|
||
"discoveredFactIds": [],
|
||
"relatedCarrierIds": [],
|
||
"consequenceIds": []
|
||
})
|
||
}
|
||
|
||
fn resolve_scene_chapter_issuer(scene: &Value) -> (String, String) {
|
||
let npc = read_array_field(scene, "npcs")
|
||
.into_iter()
|
||
.find(|npc| !read_bool_field(npc, "hostile").unwrap_or(false))
|
||
.or_else(|| read_array_field(scene, "npcs").into_iter().next());
|
||
let npc_id = npc
|
||
.and_then(|value| read_optional_string_field(value, "id"))
|
||
.unwrap_or_else(|| "scene-guide".to_string());
|
||
let npc_name = npc
|
||
.and_then(|value| {
|
||
read_optional_string_field(value, "name")
|
||
.or_else(|| read_optional_string_field(value, "npcName"))
|
||
})
|
||
.unwrap_or_else(|| {
|
||
read_optional_string_field(scene, "name").unwrap_or_else(|| "现场线索".to_string())
|
||
});
|
||
(npc_id, npc_name)
|
||
}
|
||
|
||
fn build_initial_scene_act_runtime_state(
|
||
game_state: &Value,
|
||
memory: &Value,
|
||
scene_id: &str,
|
||
) -> Option<Value> {
|
||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||
let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?;
|
||
let chapter_id = read_optional_string_field(chapter, "id")?;
|
||
let acts = read_array_field(chapter, "acts");
|
||
let first_act = acts.first().copied()?;
|
||
let first_act_id = read_optional_string_field(first_act, "id")?;
|
||
|
||
if let Some(runtime_state) = read_object_field(memory, "currentSceneActState") {
|
||
if read_optional_string_field(runtime_state, "chapterId").as_deref()
|
||
== Some(chapter_id.as_str())
|
||
{
|
||
let current_act_id = read_optional_string_field(runtime_state, "currentActId");
|
||
if current_act_id.as_ref().is_some_and(|act_id| {
|
||
acts.iter().any(|act| {
|
||
read_optional_string_field(act, "id").as_deref() == Some(act_id.as_str())
|
||
})
|
||
}) {
|
||
return Some(json!({
|
||
"sceneId": read_optional_string_field(runtime_state, "sceneId").unwrap_or_else(|| scene_id.to_string()),
|
||
"chapterId": chapter_id,
|
||
"currentActId": current_act_id,
|
||
"currentActIndex": read_i32_field(runtime_state, "currentActIndex").unwrap_or(0).max(0),
|
||
"completedActIds": read_string_array_field(runtime_state, "completedActIds"),
|
||
"visitedActIds": read_string_array_field(runtime_state, "visitedActIds"),
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
Some(json!({
|
||
"sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()),
|
||
"chapterId": chapter_id,
|
||
"currentActId": first_act_id,
|
||
"currentActIndex": 0,
|
||
"completedActIds": [],
|
||
"visitedActIds": [first_act_id]
|
||
}))
|
||
}
|
||
|
||
fn resolve_scene_chapter_blueprint<'a>(profile: &'a Value, scene_id: &str) -> Option<&'a Value> {
|
||
read_array_field(profile, "sceneChapterBlueprints")
|
||
.into_iter()
|
||
.find(|chapter| {
|
||
read_optional_string_field(chapter, "sceneId").as_deref() == Some(scene_id)
|
||
|| read_string_array_field(chapter, "linkedLandmarkIds")
|
||
.iter()
|
||
.any(|id| id == scene_id)
|
||
|| read_array_field(chapter, "acts").into_iter().any(|act| {
|
||
read_optional_string_field(act, "sceneId").as_deref() == Some(scene_id)
|
||
})
|
||
})
|
||
}
|
||
|
||
fn resolve_current_chapter_state(
|
||
game_state: &Value,
|
||
memory: &Value,
|
||
previous_chapter: Option<&Value>,
|
||
) -> Value {
|
||
let active_thread_ids = read_string_array_field(memory, "activeThreadIds");
|
||
let scene_id = current_scene_id(game_state);
|
||
let scene_name = read_object_field(game_state, "currentScenePreset")
|
||
.and_then(|scene| read_optional_string_field(scene, "name"))
|
||
.unwrap_or_else(|| "当前区域".to_string());
|
||
|
||
if let Some((quest, chapter_id)) = scene_id.as_deref().and_then(|id| {
|
||
find_scene_chapter_quest(game_state, id).map(|quest| (quest, build_scene_chapter_id(id)))
|
||
}) {
|
||
let stage = derive_chapter_stage_from_quest(quest);
|
||
let theme = read_optional_string_field(quest, "title")
|
||
.or_else(|| resolve_profile_theme(game_state, &active_thread_ids))
|
||
.unwrap_or_else(|| "旅程推进".to_string());
|
||
let primary_thread_ids = dedupe_strings(
|
||
read_optional_string_field(quest, "threadId")
|
||
.into_iter()
|
||
.chain(active_thread_ids.clone())
|
||
.collect(),
|
||
3,
|
||
);
|
||
return json!({
|
||
"id": chapter_id,
|
||
"title": format!("{scene_name}·{}", stage_label(stage)),
|
||
"theme": theme,
|
||
"primaryThreadIds": primary_thread_ids,
|
||
"stage": stage,
|
||
"chapterSummary": build_scene_chapter_summary(scene_name.as_str(), quest, stage),
|
||
"sceneId": scene_id,
|
||
"chapterQuestId": read_optional_string_field(quest, "id"),
|
||
});
|
||
}
|
||
|
||
let stage = resolve_freeform_chapter_stage(game_state, memory, previous_chapter);
|
||
let theme = resolve_profile_theme(game_state, &active_thread_ids)
|
||
.unwrap_or_else(|| "旅程推进".to_string());
|
||
let title = format!("{theme}·{}", stage_label(stage));
|
||
let chapter_id = if previous_chapter
|
||
.and_then(|chapter| read_optional_string_field(chapter, "stage"))
|
||
.as_deref()
|
||
== Some(stage)
|
||
&& previous_chapter
|
||
.and_then(|chapter| read_optional_string_field(chapter, "theme"))
|
||
.as_deref()
|
||
== Some(theme.as_str())
|
||
{
|
||
previous_chapter
|
||
.and_then(|chapter| read_optional_string_field(chapter, "id"))
|
||
.unwrap_or_else(|| build_freeform_chapter_id(&active_thread_ids, stage))
|
||
} else {
|
||
build_freeform_chapter_id(&active_thread_ids, stage)
|
||
};
|
||
|
||
json!({
|
||
"id": chapter_id,
|
||
"title": title,
|
||
"theme": theme,
|
||
"primaryThreadIds": dedupe_strings(active_thread_ids, 3),
|
||
"stage": stage,
|
||
"chapterSummary": format!("{title} 当前围绕 {theme} 推进。"),
|
||
"sceneId": Value::Null,
|
||
"chapterQuestId": Value::Null,
|
||
})
|
||
}
|
||
|
||
fn find_scene_chapter_quest<'a>(game_state: &'a Value, scene_id: &str) -> Option<&'a Value> {
|
||
let chapter_id = build_scene_chapter_id(scene_id);
|
||
read_array_field(game_state, "quests")
|
||
.into_iter()
|
||
.find(|quest| {
|
||
read_optional_string_field(quest, "chapterId").as_deref() == Some(chapter_id.as_str())
|
||
&& !matches!(
|
||
read_optional_string_field(quest, "status").as_deref(),
|
||
Some("failed") | Some("expired")
|
||
)
|
||
})
|
||
}
|
||
|
||
fn derive_chapter_stage_from_quest(quest: &Value) -> &'static str {
|
||
match read_optional_string_field(quest, "status").as_deref() {
|
||
Some("turned_in") => return CHAPTER_STAGE_AFTERMATH,
|
||
Some("ready_to_turn_in") | Some("completed") => return CHAPTER_STAGE_CLIMAX,
|
||
_ => {}
|
||
}
|
||
|
||
let steps = read_array_field(quest, "steps");
|
||
let active_step_id = read_optional_string_field(quest, "activeStepId");
|
||
let active_step_index = active_step_id
|
||
.as_deref()
|
||
.and_then(|id| {
|
||
steps
|
||
.iter()
|
||
.position(|step| read_optional_string_field(step, "id").as_deref() == Some(id))
|
||
})
|
||
.unwrap_or(0);
|
||
match active_step_index {
|
||
0 => CHAPTER_STAGE_OPENING,
|
||
1 => CHAPTER_STAGE_EXPANSION,
|
||
_ => CHAPTER_STAGE_TURNING_POINT,
|
||
}
|
||
}
|
||
|
||
fn build_scene_chapter_summary(scene_name: &str, quest: &Value, stage: &str) -> String {
|
||
let quest_description = read_optional_string_field(quest, "description")
|
||
.or_else(|| read_optional_string_field(quest, "summary"))
|
||
.unwrap_or_else(|| "这一章仍在推进中。".to_string());
|
||
match stage {
|
||
CHAPTER_STAGE_OPENING => format!("{scene_name} 的这一章刚刚开启。{quest_description}"),
|
||
CHAPTER_STAGE_EXPANSION => format!("{scene_name} 的压力正在展开。{quest_description}"),
|
||
CHAPTER_STAGE_TURNING_POINT => {
|
||
format!("{scene_name} 的线索正在改写当前判断。{quest_description}")
|
||
}
|
||
CHAPTER_STAGE_CLIMAX => {
|
||
format!("{scene_name} 的核心矛盾已经被推到最后一步,只差正式收束。")
|
||
}
|
||
CHAPTER_STAGE_AFTERMATH => {
|
||
format!("{scene_name} 这一章已经完成收束,余波和下一段去向正在显形。")
|
||
}
|
||
_ => format!("{scene_name} 的这一章仍在推进中。"),
|
||
}
|
||
}
|
||
|
||
fn resolve_freeform_chapter_stage(
|
||
game_state: &Value,
|
||
memory: &Value,
|
||
previous_chapter: Option<&Value>,
|
||
) -> &'static str {
|
||
let score = i32::try_from(read_string_array_field(memory, "recentSignalIds").len())
|
||
.unwrap_or(0)
|
||
+ i32::try_from(read_array_field(memory, "chronicle").len()).unwrap_or(0)
|
||
+ i32::try_from(read_string_array_field(memory, "activeThreadIds").len()).unwrap_or(0);
|
||
if score >= 12 {
|
||
CHAPTER_STAGE_AFTERMATH
|
||
} else if score >= 9 {
|
||
CHAPTER_STAGE_CLIMAX
|
||
} else if score >= 6 {
|
||
CHAPTER_STAGE_TURNING_POINT
|
||
} else if score >= 3 {
|
||
CHAPTER_STAGE_EXPANSION
|
||
} else if read_object_field(game_state, "chapterState")
|
||
.and_then(|chapter| read_optional_string_field(chapter, "stage"))
|
||
.or_else(|| {
|
||
previous_chapter.and_then(|chapter| read_optional_string_field(chapter, "stage"))
|
||
})
|
||
.as_deref()
|
||
== Some(CHAPTER_STAGE_AFTERMATH)
|
||
{
|
||
CHAPTER_STAGE_AFTERMATH
|
||
} else {
|
||
CHAPTER_STAGE_OPENING
|
||
}
|
||
}
|
||
|
||
fn resolve_profile_theme(game_state: &Value, active_thread_ids: &[String]) -> Option<String> {
|
||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||
if let Some(thread_title) = first_thread_title(profile, active_thread_ids) {
|
||
return Some(thread_title);
|
||
}
|
||
read_object_field(profile, "themePack")
|
||
.and_then(|theme_pack| read_optional_string_field(theme_pack, "displayName"))
|
||
.or_else(|| read_optional_string_field(profile, "summary"))
|
||
}
|
||
|
||
fn first_thread_title(profile: &Value, active_thread_ids: &[String]) -> Option<String> {
|
||
let story_graph = read_object_field(profile, "storyGraph")?;
|
||
let threads = read_array_field(story_graph, "visibleThreads")
|
||
.into_iter()
|
||
.chain(read_array_field(story_graph, "hiddenThreads"))
|
||
.collect::<Vec<_>>();
|
||
active_thread_ids.iter().find_map(|thread_id| {
|
||
threads.iter().find_map(|thread| {
|
||
(read_optional_string_field(thread, "id").as_deref() == Some(thread_id.as_str()))
|
||
.then(|| read_optional_string_field(thread, "title"))
|
||
.flatten()
|
||
})
|
||
})
|
||
}
|
||
|
||
fn resolve_current_journey_beat(
|
||
game_state: &Value,
|
||
memory: &Value,
|
||
chapter_state: &Value,
|
||
) -> Value {
|
||
let chapter_id = read_optional_string_field(chapter_state, "id")
|
||
.unwrap_or_else(|| "chapter:default".to_string());
|
||
let chapter_title = read_optional_string_field(chapter_state, "title")
|
||
.unwrap_or_else(|| "当前章节".to_string());
|
||
let stage = read_optional_string_field(chapter_state, "stage")
|
||
.unwrap_or_else(|| CHAPTER_STAGE_OPENING.to_string());
|
||
let beat_type = match stage.as_str() {
|
||
CHAPTER_STAGE_OPENING => "approach",
|
||
CHAPTER_STAGE_EXPANSION => "investigation",
|
||
CHAPTER_STAGE_TURNING_POINT => "conflict",
|
||
CHAPTER_STAGE_CLIMAX => "climax",
|
||
CHAPTER_STAGE_AFTERMATH => "recovery",
|
||
_ => "approach",
|
||
};
|
||
let stored_beat_id = read_optional_string_field(memory, "currentJourneyBeatId");
|
||
let id = stored_beat_id.unwrap_or_else(|| format!("{chapter_id}:{beat_type}"));
|
||
let current_scene_id = current_scene_id(game_state).into_iter().collect::<Vec<_>>();
|
||
let emotional_goal = if beat_type == "climax" {
|
||
"把冲突推到最前台。"
|
||
} else if beat_type == "recovery" {
|
||
"让角色和世界消化刚发生的后果。"
|
||
} else {
|
||
"让线索、关系和压力继续叠加。"
|
||
};
|
||
|
||
json!({
|
||
"id": id,
|
||
"beatType": beat_type,
|
||
"title": format!("{chapter_title}·当前段落"),
|
||
"triggerThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"),
|
||
"recommendedSceneIds": current_scene_id,
|
||
"emotionalGoal": emotional_goal,
|
||
})
|
||
}
|
||
|
||
fn resolve_world_mutations(
|
||
game_state: &Value,
|
||
memory: &Value,
|
||
signals: &[Value],
|
||
chapter_state: &Value,
|
||
) -> Vec<Value> {
|
||
let mut mutations = Vec::new();
|
||
let current_scene_id = current_scene_id(game_state);
|
||
let active_thread_ids = read_string_array_field(memory, "activeThreadIds");
|
||
let chapter_stage = read_optional_string_field(chapter_state, "stage");
|
||
|
||
if let Some(scene_id) = current_scene_id.as_deref() {
|
||
let chapter_title = read_optional_string_field(chapter_state, "title")
|
||
.unwrap_or_else(|| "当前章节".to_string());
|
||
mutations.push(json!({
|
||
"id": format!("mutation:scene:{scene_id}:{}", chapter_stage.as_deref().unwrap_or(CHAPTER_STAGE_OPENING)),
|
||
"mutationType": "scene_text",
|
||
"targetId": scene_id,
|
||
"reason": format!("{chapter_title}正在改写这片地界的表面气氛。"),
|
||
"relatedThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"),
|
||
}));
|
||
}
|
||
|
||
if current_scene_id.is_some()
|
||
&& signals.iter().any(|signal| {
|
||
read_optional_string_field(signal, "signalType").as_deref() == Some("win_battle")
|
||
})
|
||
{
|
||
let scene_id = current_scene_id.as_deref().unwrap_or("scene");
|
||
mutations.push(json!({
|
||
"id": format!("mutation:pressure:{scene_id}:battle"),
|
||
"mutationType": "enemy_pressure",
|
||
"targetId": scene_id,
|
||
"reason": "这一带的敌意正在因交锋结果重新聚拢。",
|
||
"relatedThreadIds": dedupe_strings(active_thread_ids.clone(), 4),
|
||
}));
|
||
}
|
||
|
||
if signals.iter().any(|signal| {
|
||
read_optional_string_field(signal, "signalType").as_deref() == Some("obtain_carrier")
|
||
}) {
|
||
let scene_id = current_scene_id.as_deref().unwrap_or("scene");
|
||
mutations.push(json!({
|
||
"id": format!("mutation:attitude:{scene_id}:carrier"),
|
||
"mutationType": "npc_attitude",
|
||
"targetId": scene_id,
|
||
"reason": "关键载体已经落到你手里,相关角色的口风会开始变化。",
|
||
"relatedThreadIds": dedupe_strings(active_thread_ids.clone(), 4),
|
||
}));
|
||
}
|
||
|
||
if chapter_stage.as_deref() == Some(CHAPTER_STAGE_CLIMAX) {
|
||
if let Some(scene_id) = current_scene_id.as_deref() {
|
||
mutations.push(json!({
|
||
"id": format!("mutation:route:{scene_id}:climax"),
|
||
"mutationType": "route_unlock",
|
||
"targetId": scene_id,
|
||
"reason": "章节高潮逼近,新的通路或对峙点开始显影。",
|
||
"relatedThreadIds": read_string_array_field(chapter_state, "primaryThreadIds"),
|
||
}));
|
||
}
|
||
}
|
||
|
||
dedupe_value_objects_by_id(mutations, 8)
|
||
}
|
||
|
||
fn append_world_mutations(memory: &Value, additions: Vec<Value>) -> Vec<Value> {
|
||
dedupe_value_objects_by_id(
|
||
read_array_field(memory, "worldMutations")
|
||
.into_iter()
|
||
.cloned()
|
||
.chain(additions)
|
||
.collect(),
|
||
24,
|
||
)
|
||
}
|
||
|
||
fn apply_world_mutations_to_game_state(game_state: &mut Value, mutations: &[Value]) {
|
||
let Some(current_scene_id) = current_scene_id(game_state) else {
|
||
return;
|
||
};
|
||
let relevant = mutations
|
||
.iter()
|
||
.filter(|mutation| {
|
||
read_optional_string_field(mutation, "targetId").as_deref()
|
||
== Some(current_scene_id.as_str())
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if relevant.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let latest_scene_reason = relevant
|
||
.iter()
|
||
.rev()
|
||
.find(|mutation| {
|
||
read_optional_string_field(mutation, "mutationType").as_deref() == Some("scene_text")
|
||
})
|
||
.and_then(|mutation| read_optional_string_field(mutation, "reason"));
|
||
let latest_attitude_reason = relevant
|
||
.iter()
|
||
.rev()
|
||
.find(|mutation| {
|
||
read_optional_string_field(mutation, "mutationType").as_deref() == Some("npc_attitude")
|
||
})
|
||
.and_then(|mutation| read_optional_string_field(mutation, "reason"));
|
||
let pressure_count = relevant
|
||
.iter()
|
||
.filter(|mutation| {
|
||
read_optional_string_field(mutation, "mutationType").as_deref()
|
||
== Some("enemy_pressure")
|
||
})
|
||
.count();
|
||
let pressure_level = match pressure_count {
|
||
count if count >= 3 => "extreme",
|
||
2 => "high",
|
||
1 => "medium",
|
||
_ => "low",
|
||
};
|
||
|
||
let root = ensure_json_object(game_state);
|
||
let Some(scene) = root
|
||
.get_mut("currentScenePreset")
|
||
.and_then(Value::as_object_mut)
|
||
else {
|
||
return;
|
||
};
|
||
let mutation_text = [latest_scene_reason.clone(), latest_attitude_reason.clone()]
|
||
.into_iter()
|
||
.flatten()
|
||
.filter(|text| !text.trim().is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
if !mutation_text.is_empty() {
|
||
scene.insert(
|
||
"mutationStateText".to_string(),
|
||
Value::String(mutation_text),
|
||
);
|
||
}
|
||
scene.insert(
|
||
"currentPressureLevel".to_string(),
|
||
Value::String(pressure_level.to_string()),
|
||
);
|
||
|
||
if let Some(reason) = latest_scene_reason {
|
||
let description = scene
|
||
.get("description")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.to_string();
|
||
if !description.contains(reason.as_str()) {
|
||
let next_description = [description.as_str(), reason.as_str()]
|
||
.into_iter()
|
||
.filter(|text| !text.trim().is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
scene.insert("description".to_string(), Value::String(next_description));
|
||
}
|
||
}
|
||
|
||
if let Some(attitude_reason) = latest_attitude_reason {
|
||
if let Some(npcs) = scene.get_mut("npcs").and_then(Value::as_array_mut) {
|
||
for npc in npcs {
|
||
if read_bool_field(npc, "hostile").unwrap_or(false) {
|
||
continue;
|
||
}
|
||
let description =
|
||
read_optional_string_field(npc, "description").unwrap_or_default();
|
||
if description.contains(attitude_reason.as_str()) {
|
||
continue;
|
||
}
|
||
if let Some(npc_object) = npc.as_object_mut() {
|
||
let next_description = [description.as_str(), attitude_reason.as_str()]
|
||
.into_iter()
|
||
.filter(|text| !text.trim().is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
npc_object.insert("description".to_string(), Value::String(next_description));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn build_companion_reactions(
|
||
game_state: &Value,
|
||
signals: &[Value],
|
||
action_text: &str,
|
||
) -> Vec<Value> {
|
||
let signal_types = signals
|
||
.iter()
|
||
.filter_map(|signal| read_optional_string_field(signal, "signalType"))
|
||
.collect::<Vec<_>>();
|
||
let related_thread_ids = dedupe_strings(
|
||
signals
|
||
.iter()
|
||
.flat_map(|signal| read_string_array_field(signal, "threadIds"))
|
||
.collect(),
|
||
4,
|
||
);
|
||
let companions = read_array_field(game_state, "companions")
|
||
.into_iter()
|
||
.chain(read_array_field(game_state, "roster"))
|
||
.take(2)
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
let reaction_type = resolve_reaction_type(action_text, &signal_types);
|
||
|
||
companions
|
||
.into_iter()
|
||
.enumerate()
|
||
.filter_map(|(index, companion)| {
|
||
let character_id = read_optional_string_field(&companion, "characterId")?;
|
||
Some(json!({
|
||
"id": format!("reaction:{character_id}:{}:{}", signal_types.len(), index + 1),
|
||
"characterId": character_id,
|
||
"reactionType": reaction_type,
|
||
"reason": build_reaction_reason(action_text, reaction_type),
|
||
"relatedThreadIds": related_thread_ids,
|
||
"createdAt": format_now_rfc3339(),
|
||
}))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn resolve_reaction_type<'a>(action_text: &str, signal_types: &[String]) -> &'a str {
|
||
if action_text.contains("强行")
|
||
|| action_text.contains("掠夺")
|
||
|| action_text.contains("恶意")
|
||
|| action_text.contains("开战")
|
||
|| action_text.contains("威胁")
|
||
{
|
||
"disapprove"
|
||
} else if signal_types
|
||
.iter()
|
||
.any(|signal| signal == "accept_contract")
|
||
|| action_text.contains('帮')
|
||
|| action_text.contains('援')
|
||
|| action_text.contains("调查")
|
||
{
|
||
"approve"
|
||
} else if signal_types
|
||
.iter()
|
||
.any(|signal| signal == "obtain_carrier" || signal == "inspect_scene")
|
||
{
|
||
"curious"
|
||
} else if action_text.contains('礼') || action_text.contains('赠') || action_text.contains('送')
|
||
{
|
||
"concern"
|
||
} else {
|
||
"silence"
|
||
}
|
||
}
|
||
|
||
fn build_reaction_reason(action_text: &str, reaction_type: &str) -> String {
|
||
match reaction_type {
|
||
"approve" => format!("同行角色觉得你这一步接得住局势:{action_text}"),
|
||
"disapprove" => format!("同行角色对这一步明显有保留:{action_text}"),
|
||
"concern" => format!("同行角色觉得你这一步可能会牵出额外代价:{action_text}"),
|
||
"curious" => format!("同行角色被这一步新露出的线索勾住了注意力:{action_text}"),
|
||
_ => format!("同行角色暂时没有正面插话,但显然记住了这一步:{action_text}"),
|
||
}
|
||
}
|
||
|
||
fn apply_companion_reactions_to_stance(game_state: &mut Value, reactions: &[Value]) {
|
||
if reactions.is_empty() {
|
||
return;
|
||
}
|
||
let companions = read_array_field(game_state, "companions")
|
||
.into_iter()
|
||
.chain(read_array_field(game_state, "roster"))
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
let root = ensure_json_object(game_state);
|
||
let Some(npc_states) = root.get_mut("npcStates").and_then(Value::as_object_mut) else {
|
||
return;
|
||
};
|
||
|
||
for reaction in reactions {
|
||
let Some(character_id) = read_optional_string_field(reaction, "characterId") else {
|
||
continue;
|
||
};
|
||
let Some(companion) = companions.iter().find(|companion| {
|
||
read_optional_string_field(companion, "characterId").as_deref()
|
||
== Some(character_id.as_str())
|
||
}) else {
|
||
continue;
|
||
};
|
||
let Some(npc_id) = read_optional_string_field(companion, "npcId") else {
|
||
continue;
|
||
};
|
||
let Some(stance) = npc_states
|
||
.get_mut(npc_id.as_str())
|
||
.and_then(|state| state.as_object_mut())
|
||
.and_then(|state| state.get_mut("stanceProfile"))
|
||
.and_then(Value::as_object_mut)
|
||
else {
|
||
continue;
|
||
};
|
||
let reaction_type =
|
||
read_optional_string_field(reaction, "reactionType").unwrap_or_default();
|
||
adjust_stance_for_reaction(stance, reaction, reaction_type.as_str());
|
||
}
|
||
}
|
||
|
||
fn adjust_stance_for_reaction(
|
||
stance: &mut Map<String, Value>,
|
||
reaction: &Value,
|
||
reaction_type: &str,
|
||
) {
|
||
match reaction_type {
|
||
"approve" => {
|
||
bump_stance_number(stance, "trust", 2);
|
||
bump_stance_number(stance, "loyalty", 1);
|
||
append_stance_note(stance, "recentApprovals", reaction);
|
||
}
|
||
"disapprove" => {
|
||
bump_stance_number(stance, "fearOrGuard", 3);
|
||
append_stance_note(stance, "recentDisapprovals", reaction);
|
||
}
|
||
"concern" => {
|
||
bump_stance_number(stance, "fearOrGuard", 2);
|
||
append_stance_note(stance, "recentDisapprovals", reaction);
|
||
}
|
||
"curious" => {
|
||
bump_stance_number(stance, "ideologicalFit", 1);
|
||
append_stance_note(stance, "recentApprovals", reaction);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn bump_stance_number(stance: &mut Map<String, Value>, key: &str, delta: i32) {
|
||
let next = stance
|
||
.get(key)
|
||
.and_then(Value::as_i64)
|
||
.unwrap_or(0)
|
||
.saturating_add(i64::from(delta))
|
||
.clamp(0, 100);
|
||
stance.insert(key.to_string(), json!(next));
|
||
}
|
||
|
||
fn append_stance_note(stance: &mut Map<String, Value>, key: &str, reaction: &Value) {
|
||
let mut notes = stance
|
||
.get(key)
|
||
.and_then(Value::as_array)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
if let Some(reason) = read_optional_string_field(reaction, "reason") {
|
||
notes.push(Value::String(reason));
|
||
}
|
||
let keep_from = notes.len().saturating_sub(3);
|
||
stance.insert(
|
||
key.to_string(),
|
||
Value::Array(notes.into_iter().skip(keep_from).collect()),
|
||
);
|
||
}
|
||
|
||
fn append_recent_companion_reactions(memory: &mut Value, reactions: Vec<Value>) {
|
||
if reactions.is_empty() {
|
||
return;
|
||
}
|
||
let mut recent = read_array_field(memory, "recentCompanionReactions")
|
||
.into_iter()
|
||
.cloned()
|
||
.chain(reactions)
|
||
.collect::<Vec<_>>();
|
||
let keep_from = recent.len().saturating_sub(6);
|
||
recent = recent.into_iter().skip(keep_from).collect();
|
||
ensure_json_object(memory).insert("recentCompanionReactions".to_string(), Value::Array(recent));
|
||
}
|
||
|
||
fn append_chronicle_entries(
|
||
memory: &Value,
|
||
chapter_state: &Value,
|
||
world_mutations: &[Value],
|
||
) -> Vec<Value> {
|
||
let now = format_now_rfc3339();
|
||
let mut entries = read_array_field(memory, "chronicle")
|
||
.into_iter()
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
if let Some(chapter_id) = read_optional_string_field(chapter_state, "id") {
|
||
entries.push(json!({
|
||
"id": format!("chronicle:chapter:{chapter_id}"),
|
||
"category": "chapter",
|
||
"title": read_optional_string_field(chapter_state, "title").unwrap_or_else(|| "当前章节".to_string()),
|
||
"summary": read_optional_string_field(chapter_state, "chapterSummary").unwrap_or_default(),
|
||
"relatedIds": read_string_array_field(chapter_state, "primaryThreadIds"),
|
||
"createdAt": now,
|
||
}));
|
||
}
|
||
for mutation in world_mutations.iter().rev().take(4) {
|
||
let Some(mutation_id) = read_optional_string_field(mutation, "id") else {
|
||
continue;
|
||
};
|
||
entries.push(json!({
|
||
"id": format!("chronicle:world_event:{mutation_id}"),
|
||
"category": "world_event",
|
||
"title": read_optional_string_field(mutation, "reason").unwrap_or_else(|| "世界状态变化".to_string()),
|
||
"summary": format!(
|
||
"{} 影响了 {}",
|
||
read_optional_string_field(mutation, "mutationType").unwrap_or_else(|| "world_event".to_string()),
|
||
read_optional_string_field(mutation, "targetId").unwrap_or_else(|| "scene".to_string())
|
||
),
|
||
"relatedIds": read_string_array_field(mutation, "relatedThreadIds"),
|
||
"createdAt": format_now_rfc3339(),
|
||
}));
|
||
}
|
||
|
||
dedupe_value_objects_by_id(entries, 18)
|
||
}
|
||
|
||
fn build_continue_digest(chapter_state: &Value, result_text: &str, chronicle: &[Value]) -> String {
|
||
let chapter_summary = read_optional_string_field(chapter_state, "chapterSummary")
|
||
.unwrap_or_else(|| "当前章节仍在推进。".to_string());
|
||
let recent = chronicle
|
||
.iter()
|
||
.rev()
|
||
.take(3)
|
||
.filter_map(|entry| {
|
||
Some(format!(
|
||
"- {}:{}",
|
||
read_optional_string_field(entry, "title")?,
|
||
read_optional_string_field(entry, "summary").unwrap_or_default()
|
||
))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
[chapter_summary, result_text.to_string(), recent]
|
||
.into_iter()
|
||
.filter(|text| !text.trim().is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn current_scene_id(game_state: &Value) -> Option<String> {
|
||
read_object_field(game_state, "currentScenePreset")
|
||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||
}
|
||
|
||
fn current_encounter_id(game_state: &Value) -> Option<String> {
|
||
read_object_field(game_state, "currentEncounter").and_then(|encounter| {
|
||
read_optional_string_field(encounter, "id")
|
||
.or_else(|| read_optional_string_field(encounter, "npcName"))
|
||
})
|
||
}
|
||
|
||
fn build_scene_chapter_id(scene_id: &str) -> String {
|
||
format!("chapter:scene:{scene_id}")
|
||
}
|
||
|
||
fn build_freeform_chapter_id(active_thread_ids: &[String], stage: &str) -> String {
|
||
let key = if active_thread_ids.is_empty() {
|
||
"default".to_string()
|
||
} else {
|
||
active_thread_ids
|
||
.iter()
|
||
.take(2)
|
||
.cloned()
|
||
.collect::<Vec<_>>()
|
||
.join("+")
|
||
};
|
||
format!("chapter:{key}:{stage}")
|
||
}
|
||
|
||
fn stage_label(stage: &str) -> &'static str {
|
||
match stage {
|
||
CHAPTER_STAGE_OPENING => "序章",
|
||
CHAPTER_STAGE_EXPANSION => "展开",
|
||
CHAPTER_STAGE_TURNING_POINT => "转折",
|
||
CHAPTER_STAGE_CLIMAX => "高潮",
|
||
CHAPTER_STAGE_AFTERMATH => "余波",
|
||
_ => "推进",
|
||
}
|
||
}
|
||
|
||
fn compact_title(raw: &str, fallback: &str) -> String {
|
||
let cleaned = raw
|
||
.replace(['《', '》', '「', '」', '“', '”', '"', '\''], "")
|
||
.split([
|
||
',', '。', '!', '?', ';', ':', ',', '.', '!', '?', ';', ':',
|
||
])
|
||
.next()
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.to_string();
|
||
if cleaned.is_empty() {
|
||
fallback.to_string()
|
||
} else if cleaned.chars().count() > 12 {
|
||
cleaned.chars().take(10).collect()
|
||
} else {
|
||
cleaned
|
||
}
|
||
}
|
||
|
||
fn read_string_array_field(value: &Value, key: &str) -> Vec<String> {
|
||
read_field(value, key)
|
||
.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()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn read_string_array_from_object(value: &Map<String, Value>, key: &str) -> Vec<String> {
|
||
value
|
||
.get(key)
|
||
.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()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn dedupe_strings(values: Vec<String>, limit: usize) -> Vec<String> {
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut result = Vec::new();
|
||
for value in values {
|
||
let trimmed = value.trim();
|
||
if trimmed.is_empty() || !seen.insert(trimmed.to_string()) {
|
||
continue;
|
||
}
|
||
result.push(trimmed.to_string());
|
||
if result.len() >= limit {
|
||
break;
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
fn dedupe_value_objects_by_id(values: Vec<Value>, limit: usize) -> Vec<Value> {
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut result = Vec::new();
|
||
for value in values {
|
||
let id = read_optional_string_field(&value, "id").unwrap_or_else(|| value.to_string());
|
||
if !seen.insert(id) {
|
||
continue;
|
||
}
|
||
result.push(value);
|
||
}
|
||
let keep_from = result.len().saturating_sub(limit);
|
||
result.into_iter().skip(keep_from).collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn story_engine_projector_creates_scene_chapter_and_world_mutation() {
|
||
let previous_state = json!({
|
||
"worldType": "WUXIA",
|
||
"currentScene": "Story",
|
||
"storyHistory": [],
|
||
"quests": [],
|
||
"currentScenePreset": {
|
||
"id": "scene-bridge",
|
||
"name": "断桥口",
|
||
"description": "风从桥下吹上来。",
|
||
"npcs": [{
|
||
"id": "npc-guide",
|
||
"name": "沈七",
|
||
"hostile": false,
|
||
"description": "腰间挂着药囊的行商"
|
||
}]
|
||
},
|
||
"storyEngineMemory": {
|
||
"activeThreadIds": ["thread-bridge"]
|
||
}
|
||
});
|
||
let mut next_state = previous_state.clone();
|
||
|
||
project_story_engine_after_action(
|
||
&previous_state,
|
||
&mut next_state,
|
||
"观察周围迹象",
|
||
"你读出桥边留下的新痕。",
|
||
"idle_observe_signs",
|
||
None,
|
||
);
|
||
|
||
assert_eq!(
|
||
next_state["chapterState"]["id"],
|
||
json!("chapter:scene:scene-bridge")
|
||
);
|
||
assert_eq!(
|
||
next_state["storyEngineMemory"]["currentChapter"]["stage"],
|
||
json!("opening")
|
||
);
|
||
assert_eq!(
|
||
next_state["quests"][0]["chapterId"],
|
||
json!("chapter:scene:scene-bridge")
|
||
);
|
||
assert!(
|
||
next_state["currentScenePreset"]["mutationStateText"]
|
||
.as_str()
|
||
.is_some_and(|text| text.contains("断桥口"))
|
||
);
|
||
assert!(
|
||
next_state["storyEngineMemory"]["worldMutations"]
|
||
.as_array()
|
||
.is_some_and(|items| !items.is_empty())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn story_engine_projector_records_battle_pressure_mutation() {
|
||
let previous_state = json!({
|
||
"worldType": "WUXIA",
|
||
"currentScene": "Story",
|
||
"inBattle": true,
|
||
"quests": [],
|
||
"playerInventory": [],
|
||
"currentScenePreset": {
|
||
"id": "scene-bridge",
|
||
"name": "断桥口",
|
||
"description": "风从桥下吹上来。"
|
||
},
|
||
"storyEngineMemory": {
|
||
"activeThreadIds": []
|
||
}
|
||
});
|
||
let mut next_state = previous_state.clone();
|
||
next_state["inBattle"] = Value::Bool(false);
|
||
|
||
project_story_engine_after_action(
|
||
&previous_state,
|
||
&mut next_state,
|
||
"普通攻击",
|
||
"敌人倒下。",
|
||
"battle_attack_basic",
|
||
Some("victory"),
|
||
);
|
||
|
||
assert_eq!(
|
||
next_state["currentScenePreset"]["currentPressureLevel"],
|
||
json!("medium")
|
||
);
|
||
assert!(
|
||
next_state["storyEngineMemory"]["worldMutations"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.any(|mutation| mutation["mutationType"] == json!("enemy_pressure"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn story_engine_projector_does_not_record_defeat_as_battle_win() {
|
||
let previous_state = json!({
|
||
"worldType": "CUSTOM",
|
||
"currentScene": "Story",
|
||
"inBattle": true,
|
||
"quests": [],
|
||
"playerInventory": [],
|
||
"currentScenePreset": {
|
||
"id": "custom-scene-camp",
|
||
"name": "回潮营地",
|
||
"description": "潮雾暂时压住脚步。"
|
||
},
|
||
"storyEngineMemory": {
|
||
"activeThreadIds": []
|
||
}
|
||
});
|
||
let mut next_state = previous_state.clone();
|
||
next_state["inBattle"] = Value::Bool(false);
|
||
|
||
project_story_engine_after_action(
|
||
&previous_state,
|
||
&mut next_state,
|
||
"普通攻击",
|
||
"你在交锋中倒下,随后重新醒来。",
|
||
"battle_attack_basic",
|
||
Some("defeat"),
|
||
);
|
||
|
||
assert!(
|
||
next_state["storyEngineMemory"]["recentSignalIds"]
|
||
.as_array()
|
||
.is_none_or(|items| {
|
||
items
|
||
.iter()
|
||
.all(|signal| signal != "win_battle:custom-scene-camp")
|
||
})
|
||
);
|
||
assert!(
|
||
next_state["storyEngineMemory"]["worldMutations"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.all(|mutation| mutation["mutationType"] != json!("enemy_pressure"))
|
||
);
|
||
}
|
||
}
|