904 lines
36 KiB
Rust
904 lines
36 KiB
Rust
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
|
||
}
|