This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -0,0 +1,903 @@
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
}