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); // 中文注释:NPC 战斗入口只是把当前 NPC 切入战斗结算, // 不能顺手触发“首进场景章节任务”。否则玩家点战斗会误以为被系统自动接了任务。 if should_update_scene_chapter_state(function_id) { 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, key: &str) { if !root.get(key).is_some_and(Value::is_array) { root.insert(key.to_string(), Value::Array(Vec::new())); } } fn should_update_scene_chapter_state(function_id: &str) -> bool { !matches!(function_id, "npc_fight" | "npc_spar") } fn collect_story_signals( previous_state: &Value, next_state: &Value, memory: &Value, action_text: &str, function_id: &str, battle_outcome: Option<&str>, ) -> Vec { 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 { let previous_ids = read_array_field(previous_state, "playerInventory") .into_iter() .filter_map(|item| read_optional_string_field(item, "id")) .collect::>(); 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::>(); 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 { 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 { 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 { 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::>(); 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::>(); 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 { 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) -> Vec { 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::>(); 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::>() .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::>() .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::>() .join(" "); npc_object.insert("description".to_string(), Value::String(next_description)); } } } } } fn build_companion_reactions( game_state: &Value, signals: &[Value], action_text: &str, ) -> Vec { let signal_types = signals .iter() .filter_map(|signal| read_optional_string_field(signal, "signalType")) .collect::>(); 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::>(); 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::>(); 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, 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, 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, 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) { if reactions.is_empty() { return; } let mut recent = read_array_field(memory, "recentCompanionReactions") .into_iter() .cloned() .chain(reactions) .collect::>(); 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 { let now = format_now_rfc3339(); let mut entries = read_array_field(memory, "chronicle") .into_iter() .cloned() .collect::>(); 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::>() .join("\n"); [chapter_summary, result_text.to_string(), recent] .into_iter() .filter(|text| !text.trim().is_empty()) .collect::>() .join("\n") } fn current_scene_id(game_state: &Value) -> Option { read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")) } fn current_encounter_id(game_state: &Value) -> Option { 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::>() .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 { 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, key: &str) -> Vec { 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, limit: usize) -> Vec { 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, limit: usize) -> Vec { 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")) ); } #[test] fn story_engine_projector_does_not_create_chapter_quest_on_npc_battle_entry() { let previous_state = json!({ "worldType": "WUXIA", "currentScene": "Story", "storyHistory": [], "quests": [], "currentScenePreset": { "id": "scene-bridge", "name": "断桥口", "description": "风从桥下吹上来。", "npcs": [{ "id": "npc-guide", "name": "沈七", "hostile": false }] }, "currentEncounter": { "kind": "npc", "id": "npc-guide", "npcName": "沈七" }, "storyEngineMemory": { "activeThreadIds": ["thread-bridge"] } }); let mut next_state = previous_state.clone(); next_state["inBattle"] = Value::Bool(true); next_state["currentEncounter"] = Value::Null; project_story_engine_after_action( &previous_state, &mut next_state, "与沈七战斗", "沈七已经进入战斗节奏。", "npc_fight", Some("ongoing"), ); assert!( next_state["quests"] .as_array() .is_some_and(|items| items.is_empty()) ); assert_eq!(next_state["chapterState"]["chapterQuestId"], Value::Null); } }