use serde_json::{Value, json}; use shared_contracts::runtime_story::RuntimeStoryOptionView; use crate::{ CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option, build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field, read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field, write_i32_field, write_null_field, write_string_field, }; const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road"; const WUXIA_FIRST_SCENE_NAME: &str = "竹林古道"; const WUXIA_FIRST_SCENE_DESCRIPTION: &str = "风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。"; const XIANXIA_FIRST_SCENE_ID: &str = "xianxia-cloud-gate"; const XIANXIA_FIRST_SCENE_NAME: &str = "云海仙门"; const XIANXIA_FIRST_SCENE_DESCRIPTION: &str = "云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。"; #[derive(Clone, Debug)] pub struct PostBattleFinalization { pub story_text: String, pub presentation_options: Vec, pub saved_current_story: Value, } /// 战斗终局统一由后端收口,前端只负责播放 presentation。 pub fn finalize_post_battle_resolution( game_state: &mut Value, result_text: &str, outcome: Option<&str>, fallback_options: Vec, ) -> Option { let outcome = outcome?; if !is_terminal_battle_outcome(outcome) { return None; } if outcome == "defeat" { return Some(finalize_defeat_revive(game_state, fallback_options)); } if outcome == "victory" || outcome == "spar_complete" { return Some(finalize_victory_or_spar( game_state, result_text, fallback_options, )); } None } pub fn is_terminal_battle_outcome(outcome: &str) -> bool { matches!(outcome, "victory" | "spar_complete" | "defeat") } /// 后端战斗后故事选项只返回可展示 DTO,不再让前端重算章节推进结果。 pub fn resolve_post_battle_story_options(game_state: &Value) -> Vec { build_scene_travel_options(game_state) } fn finalize_victory_or_spar( game_state: &mut Value, result_text: &str, fallback_options: Vec, ) -> PostBattleFinalization { clear_post_battle_state(game_state); let is_last_act = is_current_scene_act_last(game_state); let next_act_state = if is_last_act { None } else { resolve_next_scene_act_runtime_state(game_state) }; if let Some(next_act_state) = next_act_state { write_current_scene_act_state(game_state, next_act_state); } let deferred_options = if fallback_options.is_empty() { build_scene_travel_options(game_state) } else { fallback_options }; let options = if is_last_act { deferred_options.clone() } else { vec![continue_adventure_option()] }; let saved_current_story = if is_last_act { build_plain_current_story(result_text, &deferred_options) } else { build_deferred_current_story( result_text, &deferred_options, current_scene_act_state(game_state), ) }; PostBattleFinalization { story_text: result_text.to_string(), presentation_options: options, saved_current_story, } } fn finalize_defeat_revive( game_state: &mut Value, _fallback_options: Vec, ) -> PostBattleFinalization { let first_scene = resolve_first_scene(game_state); write_first_scene(game_state, &first_scene); write_null_field(game_state, "currentEncounter"); write_bool_field(game_state, "npcInteractionActive", false); ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); write_i32_field(game_state, "playerX", 0); write_string_field(game_state, "playerFacing", "right"); let player_max_hp = read_i32_field(game_state, "playerMaxHp") .unwrap_or(1) .max(1); let player_max_mana = read_i32_field(game_state, "playerMaxMana") .unwrap_or(0) .max(0); write_i32_field(game_state, "playerHp", player_max_hp); write_i32_field(game_state, "playerMana", player_max_mana); write_bool_field(game_state, "inBattle", false); write_null_field(game_state, "currentBattleNpcId"); write_null_field(game_state, "currentNpcBattleMode"); write_null_field(game_state, "currentNpcBattleOutcome"); write_null_field(game_state, "sparReturnEncounter"); write_null_field(game_state, "sparPlayerHpBefore"); write_null_field(game_state, "sparPlayerMaxHpBefore"); write_null_field(game_state, "sparStoryHistoryBefore"); write_string_field(game_state, "animationState", "idle"); write_string_field(game_state, "playerActionMode", "idle"); ensure_json_object(game_state) .insert("activeCombatEffects".to_string(), Value::Array(Vec::new())); write_bool_field(game_state, "scrollWorld", false); if let Some(first_act_state) = build_initial_scene_act_runtime_state(game_state, &first_scene.id) { write_current_scene_act_state(game_state, first_act_state); } ensure_first_scene_encounter_preview(game_state); let story_text = if first_scene.name.is_empty() { "你在战斗中倒下,随后重新醒来。".to_string() } else { format!("你在战斗中倒下,随后在{}重新醒来。", first_scene.name) }; // 中文注释:败北复活后的正式选项必须基于复活后的首场景重新生成, // 不能沿用战斗结算前旧场景的 fallback options。 let deferred_options = build_scene_travel_options(game_state); let saved_current_story = build_death_current_story(story_text.as_str(), &deferred_options); PostBattleFinalization { story_text, presentation_options: vec![continue_adventure_option()], saved_current_story, } } fn clear_post_battle_state(game_state: &mut Value) { write_null_field(game_state, "currentEncounter"); write_bool_field(game_state, "npcInteractionActive", false); ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); write_bool_field(game_state, "inBattle", false); write_null_field(game_state, "currentBattleNpcId"); write_null_field(game_state, "currentNpcBattleMode"); write_null_field(game_state, "currentNpcBattleOutcome"); write_null_field(game_state, "sparReturnEncounter"); write_null_field(game_state, "sparPlayerHpBefore"); write_null_field(game_state, "sparPlayerMaxHpBefore"); write_null_field(game_state, "sparStoryHistoryBefore"); write_string_field(game_state, "animationState", "idle"); write_string_field(game_state, "playerActionMode", "idle"); ensure_json_object(game_state) .insert("activeCombatEffects".to_string(), Value::Array(Vec::new())); write_bool_field(game_state, "scrollWorld", false); } fn continue_adventure_option() -> RuntimeStoryOptionView { build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续前进", "story") } fn build_plain_current_story(text: &str, options: &[RuntimeStoryOptionView]) -> Value { json!({ "text": text, "options": options.iter().map(build_story_option_from_runtime_option).collect::>(), "streaming": false }) } fn build_deferred_current_story( text: &str, deferred_options: &[RuntimeStoryOptionView], deferred_act_state: Option, ) -> Value { let mut story = json!({ "text": text, "options": vec![build_story_option_from_runtime_option(&continue_adventure_option())], "deferredOptions": deferred_options .iter() .map(build_story_option_from_runtime_option) .collect::>(), "streaming": false }); if let Some(deferred_act_state) = deferred_act_state { if let Some(object) = story.as_object_mut() { object.insert( "deferredRuntimeState".to_string(), json!({ "storyEngineMemory": { "currentSceneActState": deferred_act_state } }), ); } } story } fn build_death_current_story(text: &str, deferred_options: &[RuntimeStoryOptionView]) -> Value { let mut story = json!({ "text": text, "options": vec![build_story_option_from_runtime_option(&continue_adventure_option())], "streaming": false }); if !deferred_options.is_empty() { if let Some(object) = story.as_object_mut() { object.insert( "deferredOptions".to_string(), Value::Array( deferred_options .iter() .map(build_story_option_from_runtime_option) .collect::>(), ), ); } } story } #[derive(Clone, Debug)] struct RuntimeScene { id: String, name: String, description: String, image_src: String, connected_scene_ids: Vec, connections: Vec, forward_scene_id: Option, treasure_hints: Vec, npcs: Vec, } fn resolve_first_scene(game_state: &Value) -> RuntimeScene { if let Some(profile) = read_object_field(game_state, "customWorldProfile") { return build_custom_first_scene(profile); } match read_optional_string_field(game_state, "worldType").as_deref() { Some("XIANXIA") => RuntimeScene { id: XIANXIA_FIRST_SCENE_ID.to_string(), name: XIANXIA_FIRST_SCENE_NAME.to_string(), description: XIANXIA_FIRST_SCENE_DESCRIPTION.to_string(), image_src: read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "imageSrc")) .unwrap_or_default(), connected_scene_ids: vec![ "xianxia-floating-isle".to_string(), "xianxia-celestial-corridor".to_string(), "xianxia-star-vessel".to_string(), ], connections: vec![ json!({ "sceneId": "xianxia-celestial-corridor", "relativePosition": "forward", "summary": "沿主路继续深入前方区域" }), json!({ "sceneId": "xianxia-floating-isle", "relativePosition": "left", "summary": "这里分出一条支路" }), json!({ "sceneId": "xianxia-star-vessel", "relativePosition": "right", "summary": "这里还能转向另一条路" }), ], forward_scene_id: Some("xianxia-celestial-corridor".to_string()), treasure_hints: vec![ "云阶尽头的灵符匣".to_string(), "门阙阴影里的玉牌".to_string(), ], npcs: Vec::new(), }, _ => RuntimeScene { id: WUXIA_FIRST_SCENE_ID.to_string(), name: WUXIA_FIRST_SCENE_NAME.to_string(), description: WUXIA_FIRST_SCENE_DESCRIPTION.to_string(), image_src: read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "imageSrc")) .unwrap_or_default(), connected_scene_ids: vec![ "wuxia-mountain-gate".to_string(), "wuxia-mist-woods".to_string(), "wuxia-ferry-bridge".to_string(), ], connections: vec![ json!({ "sceneId": "wuxia-mountain-gate", "relativePosition": "forward", "summary": "沿主路继续深入前方区域" }), json!({ "sceneId": "wuxia-mist-woods", "relativePosition": "left", "summary": "这里分出一条支路" }), json!({ "sceneId": "wuxia-ferry-bridge", "relativePosition": "right", "summary": "这里还能转向另一条路" }), ], forward_scene_id: Some("wuxia-mountain-gate".to_string()), treasure_hints: vec!["竹根旁半埋的刀鞘".to_string(), "倒竹间的旧药囊".to_string()], npcs: Vec::new(), }, } } fn build_custom_first_scene(profile: &Value) -> RuntimeScene { let camp = read_object_field(profile, "camp"); let scene_id = camp .and_then(|camp| read_optional_string_field(camp, "id")) .unwrap_or_else(|| "custom-scene-camp".to_string()); let scene_name = camp .and_then(|camp| read_optional_string_field(camp, "name")) .or_else(|| read_optional_string_field(profile, "name").map(|name| format!("{name}营地"))) .unwrap_or_else(|| "开局营地".to_string()); let description = camp .and_then(|camp| read_optional_string_field(camp, "description")) .or_else(|| read_optional_string_field(profile, "summary")) .unwrap_or_else(|| "你重新回到了旅途起点。".to_string()); let connections = if let Some(camp) = camp { read_array_field(camp, "connections") .into_iter() .filter_map(|connection| { let target_landmark_id = read_optional_string_field(connection, "targetLandmarkId")?; let scene_id = custom_landmark_runtime_scene_id(profile, target_landmark_id.as_str())?; Some(json!({ "sceneId": scene_id, "relativePosition": read_optional_string_field(connection, "relativePosition") .unwrap_or_else(|| "forward".to_string()), "summary": read_optional_string_field(connection, "summary").unwrap_or_default() })) }) .collect::>() } else { Vec::new() }; let connected_scene_ids = connections .iter() .filter_map(|connection| read_optional_string_field(connection, "sceneId")) .collect::>(); let forward_scene_id = connections .iter() .find(|connection| { read_optional_string_field(connection, "relativePosition").as_deref() == Some("forward") }) .and_then(|connection| read_optional_string_field(connection, "sceneId")) .or_else(|| connected_scene_ids.first().cloned()); RuntimeScene { id: "custom-scene-camp".to_string(), name: scene_name, description, image_src: camp .and_then(|camp| read_optional_string_field(camp, "imageSrc")) .unwrap_or_default(), connected_scene_ids, connections, forward_scene_id, treasure_hints: vec![format!( "{}地图残页", read_optional_string_field(profile, "name").unwrap_or_else(|| "当前世界".to_string()) )], npcs: build_custom_scene_npcs_for_scene(profile, scene_id.as_str()), } } fn custom_landmark_runtime_scene_id(profile: &Value, landmark_id: &str) -> Option { read_array_field(profile, "landmarks") .into_iter() .position(|landmark| { read_optional_string_field(landmark, "id").as_deref() == Some(landmark_id) }) .map(|index| format!("custom-scene-landmark-{}", index + 1)) } fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) { ensure_json_object(game_state).insert( "currentScenePreset".to_string(), json!({ "id": scene.id, "name": scene.name, "description": scene.description, "imageSrc": scene.image_src, "connectedSceneIds": scene.connected_scene_ids, "connections": scene.connections, "forwardSceneId": scene.forward_scene_id, "treasureHints": scene.treasure_hints, "npcs": scene.npcs, }), ); } fn ensure_first_scene_encounter_preview(game_state: &mut Value) { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return; } if !read_array_field(game_state, "sceneHostileNpcs").is_empty() || read_field(game_state, "currentEncounter").is_some_and(|value| !value.is_null()) { return; } let Some(profile) = read_object_field(game_state, "customWorldProfile") else { return; }; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref()); let Some(focus_npc_id) = focus_npc_id else { return; }; let Some(npc) = find_custom_world_role(profile, focus_npc_id.as_str()) else { return; }; ensure_json_object(game_state).insert( "currentEncounter".to_string(), build_encounter_from_role(&npc, 12.0), ); } fn build_scene_travel_options(game_state: &Value) -> Vec { let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else { return vec![build_static_runtime_story_option( "idle_explore_forward", "继续向前探索", "story", )]; }; let current_scene_id = read_optional_string_field(current_scene, "id"); let mut options = read_array_field(current_scene, "connections") .into_iter() .filter_map(|connection| { let scene_id = read_optional_string_field(connection, "sceneId")?; if current_scene_id.as_deref() == Some(scene_id.as_str()) { return None; } let relative_position = read_optional_string_field(connection, "relativePosition") .unwrap_or_else(|| "forward".to_string()); let scene_name = resolve_scene_name(game_state, scene_id.as_str()) .unwrap_or_else(|| scene_id.clone()); Some(RuntimeStoryOptionView { payload: Some(json!({ "targetSceneId": scene_id })), ..build_static_runtime_story_option( "idle_travel_next_scene", format!( "{},前往{}", direction_text(relative_position.as_str()), scene_name ) .as_str(), "story", ) }) }) .collect::>(); if options.is_empty() { options.push(build_static_runtime_story_option( "idle_explore_forward", "继续向前探索", "story", )); } options } fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option { if read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")) .as_deref() == Some(scene_id) { return read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "name")); } let profile = read_object_field(game_state, "customWorldProfile")?; if scene_id == "custom-scene-camp" || read_object_field(profile, "camp") .and_then(|camp| read_optional_string_field(camp, "id")) .as_deref() == Some(scene_id) { return read_object_field(profile, "camp") .and_then(|camp| read_optional_string_field(camp, "name")) .or_else(|| { read_optional_string_field(profile, "name").map(|name| format!("{name}营地")) }); } read_array_field(profile, "landmarks") .into_iter() .enumerate() .find_map(|(index, landmark)| { let runtime_id = format!("custom-scene-landmark-{}", index + 1); if runtime_id == scene_id || read_optional_string_field(landmark, "id").as_deref() == Some(scene_id) { read_optional_string_field(landmark, "name") } else { None } }) } fn direction_text(relative_position: &str) -> &'static str { match relative_position { "north" => "向北走", "south" => "向南走", "east" => "向东走", "west" => "向西走", "left" => "向左走", "right" => "向右走", "back" => "往回走", "up" => "向上走", "down" => "向下走", "inside" => "向内走", "outside" => "向外走", "portal" => "穿过通路", _ => "向前走", } } fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { let profile = read_object_field(game_state, "customWorldProfile")?; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); let scene_id_text = scene_id.as_deref()?; let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id_text))?; let acts = read_array_field(chapter, "acts"); if acts.is_empty() { return None; } let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?; let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); let current_index = acts .iter() .position(|act| { read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref() }) .unwrap_or_else(|| { read_i32_field(&runtime_state, "currentActIndex") .unwrap_or(0) .clamp(0, acts.len().saturating_sub(1) as i32) as usize }); let active_act = acts[current_index]; let next_act = acts.get(current_index + 1)?; let active_act_id = read_optional_string_field(active_act, "id")?; let next_act_id = read_optional_string_field(next_act, "id")?; let completed = append_unique_string( read_string_array_field(&runtime_state, "completedActIds"), active_act_id, ); let visited = append_unique_string( read_string_array_field(&runtime_state, "visitedActIds"), next_act_id.clone(), ); Some(json!({ "sceneId": read_optional_string_field(chapter, "sceneId") .unwrap_or_else(|| scene_id_text.to_string()), "chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(), "currentActId": next_act_id, "currentActIndex": current_index + 1, "completedActIds": completed, "visitedActIds": visited, })) } fn current_scene_act_state(game_state: &Value) -> Option { read_object_field(game_state, "storyEngineMemory") .and_then(|memory| read_object_field(memory, "currentSceneActState")) .cloned() } fn is_current_scene_act_last(game_state: &Value) -> bool { let Some(profile) = read_object_field(game_state, "customWorldProfile") else { return false; }; let Some(scene_id) = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")) else { return false; }; let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id.as_str())) else { return false; }; let acts = read_array_field(chapter, "acts"); if acts.is_empty() { return false; } let Some(runtime_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str()) else { return false; }; let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); let current_index = acts .iter() .position(|act| { read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref() }) .unwrap_or_else(|| { read_i32_field(&runtime_state, "currentActIndex") .unwrap_or(0) .clamp(0, acts.len().saturating_sub(1) as i32) as usize }); current_index + 1 >= acts.len() } fn write_current_scene_act_state(game_state: &mut Value, act_state: Value) { let root = ensure_json_object(game_state); let memory = root .entry("storyEngineMemory".to_string()) .or_insert_with(|| { json!({ "discoveredFactIds": [], "activeThreadIds": [], "resolvedScarIds": [], "recentCarrierIds": [] }) }); if !memory.is_object() { *memory = json!({ "discoveredFactIds": [], "activeThreadIds": [], "resolvedScarIds": [], "recentCarrierIds": [] }); } memory .as_object_mut() .expect("storyEngineMemory should be object") .insert("currentSceneActState".to_string(), act_state); } fn build_initial_scene_act_runtime_state(game_state: &Value, scene_id: &str) -> Option { let profile = read_object_field(game_state, "customWorldProfile")?; let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id))?; let acts = read_array_field(chapter, "acts"); if acts.is_empty() { return None; } let runtime_state = current_scene_act_state(game_state); if let Some(runtime_state) = runtime_state { let chapter_id = read_optional_string_field(chapter, "id"); let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); if read_optional_string_field(&runtime_state, "chapterId") == chapter_id && acts.iter().any(|act| { read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref() }) { return Some(json!({ "sceneId": read_optional_string_field(&runtime_state, "sceneId") .unwrap_or_else(|| read_optional_string_field(chapter, "sceneId").unwrap_or_default()), "chapterId": read_optional_string_field(&runtime_state, "chapterId").unwrap_or_default(), "currentActId": current_act_id.unwrap_or_default(), "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"), })); } } let first_act = acts[0]; let first_act_id = read_optional_string_field(first_act, "id")?; Some(json!({ "sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()), "chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(), "currentActId": first_act_id, "currentActIndex": 0, "completedActIds": [], "visitedActIds": [read_optional_string_field(first_act, "id").unwrap_or_default()], })) } fn resolve_scene_chapter_blueprint<'a>( profile: &'a Value, scene_id: Option<&str>, ) -> Option<&'a Value> { let scene_id = scene_id?; read_array_field(profile, "sceneChapterBlueprints") .into_iter() .find(|chapter| does_scene_match_chapter(profile, scene_id, chapter)) } fn does_scene_match_chapter(profile: &Value, scene_id: &str, chapter: &Value) -> bool { let aliases = resolve_scene_aliases(profile, scene_id); let mut chapter_scene_ids = Vec::new(); if let Some(value) = read_optional_string_field(chapter, "sceneId") { chapter_scene_ids.push(value); } chapter_scene_ids.extend(read_string_array_field(chapter, "linkedLandmarkIds")); for act in read_array_field(chapter, "acts") { if let Some(value) = read_optional_string_field(act, "sceneId") { chapter_scene_ids.push(value); } } aliases .iter() .any(|alias| chapter_scene_ids.iter().any(|id| id == alias)) } fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec { let mut aliases = vec![scene_id.to_string()]; let camp_id = read_object_field(profile, "camp") .and_then(|camp| read_optional_string_field(camp, "id")) .unwrap_or_else(|| "custom-scene-camp".to_string()); if scene_id == "custom-scene-camp" || scene_id == camp_id { aliases.push(camp_id); aliases.push("custom-scene-camp".to_string()); } for (index, landmark) in read_array_field(profile, "landmarks") .into_iter() .enumerate() { let runtime_scene_id = format!("custom-scene-landmark-{}", index + 1); if scene_id == runtime_scene_id || read_optional_string_field(landmark, "id").as_deref() == Some(scene_id) { aliases.push(runtime_scene_id); if let Some(id) = read_optional_string_field(landmark, "id") { aliases.push(id); } } } dedupe_strings(aliases) } fn resolve_active_scene_act_focus_npc_id( profile: &Value, scene_id: Option<&str>, ) -> Option { let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?; let act_state = read_array_field(chapter, "acts").first().copied()?; read_optional_string_field(act_state, "oppositeNpcId") .or_else(|| read_optional_string_field(act_state, "primaryNpcId")) .or_else(|| { read_array_field(act_state, "encounterNpcIds") .first() .and_then(|id| id.as_str().map(str::to_string)) }) } fn build_custom_scene_npcs_for_scene(profile: &Value, scene_id: &str) -> Vec { let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id)) else { return Vec::new(); }; let Some(first_act) = read_array_field(chapter, "acts").first().copied() else { return Vec::new(); }; let mut role_ids = Vec::new(); if let Some(id) = read_optional_string_field(first_act, "primaryNpcId") { role_ids.push(id); } if let Some(id) = read_optional_string_field(first_act, "oppositeNpcId") { role_ids.push(id); } role_ids.extend(read_string_array_field(first_act, "encounterNpcIds")); dedupe_strings(role_ids) .into_iter() .filter_map(|role_id| find_custom_world_role(profile, role_id.as_str())) .map(|role| build_scene_npc_from_role(&role)) .collect() } fn find_custom_world_role(profile: &Value, role_id: &str) -> Option { read_array_field(profile, "storyNpcs") .into_iter() .chain(read_array_field(profile, "playableNpcs")) .find(|role| { read_optional_string_field(role, "id").as_deref() == Some(role_id) || read_optional_string_field(role, "name").as_deref() == Some(role_id) || read_optional_string_field(role, "title").as_deref() == Some(role_id) }) .cloned() } fn build_scene_npc_from_role(role: &Value) -> Value { json!({ "id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())), "name": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()), "description": read_optional_string_field(role, "description").unwrap_or_default(), "avatar": read_optional_string_field(role, "name") .and_then(|name| name.chars().next().map(|ch| ch.to_string())) .unwrap_or_else(|| "角".to_string()), "role": read_optional_string_field(role, "role").unwrap_or_default(), "title": read_optional_string_field(role, "title"), "characterId": read_optional_string_field(role, "id"), "initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0), "hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0, "functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"], "recruitable": true, "backstory": read_optional_string_field(role, "backstory"), "personality": read_optional_string_field(role, "personality"), "motivation": read_optional_string_field(role, "motivation"), "combatStyle": read_optional_string_field(role, "combatStyle"), "relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])), "tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])), "backstoryReveal": read_field(role, "backstoryReveal").cloned(), "skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])), "initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])), "imageSrc": read_optional_string_field(role, "imageSrc"), "visual": read_field(role, "visual").cloned(), "narrativeProfile": read_field(role, "narrativeProfile").cloned(), "levelProfile": read_field(role, "levelProfile").cloned(), }) } fn build_encounter_from_role(role: &Value, x_meters: f64) -> Value { json!({ "id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())), "kind": "npc", "characterId": read_optional_string_field(role, "id"), "npcName": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()), "npcDescription": read_optional_string_field(role, "description").unwrap_or_default(), "npcAvatar": read_optional_string_field(role, "name") .and_then(|name| name.chars().next().map(|ch| ch.to_string())) .unwrap_or_else(|| "角".to_string()), "context": read_optional_string_field(role, "role").unwrap_or_default(), "xMeters": x_meters, "initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0), "hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0, "title": read_optional_string_field(role, "title"), "backstory": read_optional_string_field(role, "backstory"), "personality": read_optional_string_field(role, "personality"), "motivation": read_optional_string_field(role, "motivation"), "combatStyle": read_optional_string_field(role, "combatStyle"), "relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])), "tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])), "backstoryReveal": read_field(role, "backstoryReveal").cloned(), "skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])), "initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])), "imageSrc": read_optional_string_field(role, "imageSrc"), "visual": read_field(role, "visual").cloned(), "narrativeProfile": read_field(role, "narrativeProfile").cloned(), "levelProfile": read_field(role, "levelProfile").cloned(), }) } 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(|item| !item.is_empty()) .map(str::to_string) .collect() }) .unwrap_or_default() } fn append_unique_string(mut values: Vec, value: String) -> Vec { if !values.iter().any(|entry| entry == &value) { values.push(value); } values } fn dedupe_strings(values: Vec) -> Vec { let mut result = Vec::new(); for value in values { if !value.trim().is_empty() && !result.iter().any(|entry| entry == &value) { result.push(value); } } result }