Files
Genarrative/server-rs/crates/module-runtime-story/src/post_battle.rs

904 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<RuntimeStoryOptionView>,
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<RuntimeStoryOptionView>,
) -> Option<PostBattleFinalization> {
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<RuntimeStoryOptionView> {
build_scene_travel_options(game_state)
}
fn finalize_victory_or_spar(
game_state: &mut Value,
result_text: &str,
fallback_options: Vec<RuntimeStoryOptionView>,
) -> 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<RuntimeStoryOptionView>,
) -> 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::<Vec<_>>(),
"streaming": false
})
}
fn build_deferred_current_story(
text: &str,
deferred_options: &[RuntimeStoryOptionView],
deferred_act_state: Option<Value>,
) -> 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::<Vec<_>>(),
"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::<Vec<_>>(),
),
);
}
}
story
}
#[derive(Clone, Debug)]
struct RuntimeScene {
id: String,
name: String,
description: String,
image_src: String,
connected_scene_ids: Vec<String>,
connections: Vec<Value>,
forward_scene_id: Option<String>,
treasure_hints: Vec<String>,
npcs: Vec<Value>,
}
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::<Vec<_>>()
} else {
Vec::new()
};
let connected_scene_ids = connections
.iter()
.filter_map(|connection| read_optional_string_field(connection, "sceneId"))
.collect::<Vec<_>>();
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<String> {
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<RuntimeStoryOptionView> {
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::<Vec<_>>();
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<String> {
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<Value> {
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<Value> {
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<Value> {
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<String> {
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<String> {
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<Value> {
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<Value> {
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<String> {
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<String>, value: String) -> Vec<String> {
if !values.iter().any(|entry| entry == &value) {
values.push(value);
}
values
}
fn dedupe_strings(values: Vec<String>) -> Vec<String> {
let mut result = Vec::new();
for value in values {
if !value.trim().is_empty() && !result.iter().any(|entry| entry == &value) {
result.push(value);
}
}
result
}