Files
Genarrative/server-rs/crates/module-runtime-story/src/post_battle.rs
2026-05-22 03:14:11 +08:00

1122 lines
44 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_custom_scene_preset, 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,
resolve_custom_runtime_scene_id, 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;
}
let original_scene_act_state = current_scene_act_state(game_state);
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,
original_scene_act_state,
));
}
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>,
original_scene_act_state: Option<Value>,
) -> 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, original_scene_act_state.as_ref())
};
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_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,
}
}
pub 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,
}),
);
}
pub fn ensure_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 current_act_id = current_scene_act_state(game_state)
.and_then(|state| read_optional_string_field(&state, "currentActId"));
let focus_npc_id = resolve_active_scene_act_focus_npc_id(
profile,
scene_id.as_deref(),
current_act_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),
);
}
pub fn ensure_scene_act_state(game_state: &mut Value) {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return;
}
let Some(scene_id) = read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "id"))
else {
return;
};
let Some(act_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str())
else {
return;
};
write_current_scene_act_state(game_state, act_state);
}
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 forward_scene_id = read_optional_string_field(current_scene, "forwardSceneId");
let mut option_scene_ids = Vec::new();
let mut options = Vec::new();
for connection in read_array_field(current_scene, "connections") {
let Some(scene_id) = read_optional_string_field(connection, "sceneId") else {
continue;
};
if current_scene_id.as_deref() == Some(scene_id.as_str())
|| option_scene_ids.iter().any(|id| id == scene_id.as_str())
{
continue;
}
let relative_position = read_optional_string_field(connection, "relativePosition")
.unwrap_or_else(|| "forward".to_string());
options.push(build_scene_travel_option(
game_state,
scene_id.as_str(),
relative_position.as_str(),
));
option_scene_ids.push(scene_id);
}
for scene_id in read_array_field(current_scene, "connectedSceneIds")
.into_iter()
.filter_map(|scene_id| scene_id.as_str().map(str::to_string))
.chain(forward_scene_id.clone())
{
// 中文注释bootstrap 生成的旧快照常只有 connectedSceneIds / forwardSceneId
// 没有展开 connections这里也要生成旅行 action避免战后只剩默认 idle 选项循环。
if current_scene_id.as_deref() == Some(scene_id.as_str())
|| option_scene_ids.iter().any(|id| id == scene_id.as_str())
{
continue;
}
let relative_position = if forward_scene_id.as_deref() == Some(scene_id.as_str()) {
"forward"
} else {
"portal"
};
options.push(build_scene_travel_option(
game_state,
scene_id.as_str(),
relative_position,
));
option_scene_ids.push(scene_id);
}
if options.is_empty() {
options.push(build_static_runtime_story_option(
"idle_explore_forward",
"继续向前探索",
"story",
));
}
options
}
fn build_scene_travel_option(
game_state: &Value,
scene_id: &str,
relative_position: &str,
) -> RuntimeStoryOptionView {
let scene_name =
resolve_scene_name(game_state, scene_id).unwrap_or_else(|| scene_id.to_string());
RuntimeStoryOptionView {
payload: Some(json!({ "targetSceneId": scene_id })),
..build_static_runtime_story_option(
"idle_travel_next_scene",
format!("{},前往{}", direction_text(relative_position), scene_name).as_str(),
"story",
)
}
}
pub fn resolve_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option<Value> {
let normalized_scene_id = scene_id.trim();
if normalized_scene_id.is_empty() {
return None;
}
if let Some(profile) = read_object_field(game_state, "customWorldProfile")
&& let Some(scene) = build_custom_scene_preset(
profile,
resolve_custom_runtime_scene_id(profile, normalized_scene_id).as_str(),
)
{
return Some(scene);
}
resolve_builtin_runtime_scene_preset(game_state, normalized_scene_id)
}
pub fn resolve_forward_scene_id(game_state: &Value) -> Option<String> {
read_object_field(game_state, "currentScenePreset").and_then(|scene| {
read_optional_string_field(scene, "forwardSceneId")
.or_else(|| {
read_array_field(scene, "connections")
.into_iter()
.find_map(|connection| read_optional_string_field(connection, "sceneId"))
})
.or_else(|| {
read_array_field(scene, "connectedSceneIds")
.into_iter()
.find_map(|scene_id| scene_id.as_str().map(str::to_string))
})
})
}
fn resolve_builtin_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option<Value> {
let template = builtin_runtime_scene_template(scene_id)?;
Some(json!({
"id": template.id,
"name": template.name,
"description": template.description,
"imageSrc": read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "imageSrc"))
.unwrap_or_default(),
"connectedSceneIds": template.connected_scene_ids,
"connections": template.connections,
"forwardSceneId": template.forward_scene_id,
"treasureHints": template.treasure_hints,
"npcs": [],
}))
}
fn builtin_runtime_scene_template(scene_id: &str) -> Option<RuntimeScene> {
let is_xianxia = matches!(
scene_id,
"xianxia-cloud-gate"
| "xianxia-floating-isle"
| "xianxia-celestial-corridor"
| "xianxia-star-vessel"
);
if is_xianxia {
return Some(RuntimeScene {
id: scene_id.to_string(),
name: match scene_id {
"xianxia-floating-isle" => "浮空灵岛",
"xianxia-celestial-corridor" => "天门长廊",
"xianxia-star-vessel" => "星槎泊台",
_ => XIANXIA_FIRST_SCENE_NAME,
}
.to_string(),
description: match scene_id {
"xianxia-floating-isle" => "浮岛边缘灵雾翻涌,远处有阵纹一明一暗。",
"xianxia-celestial-corridor" => "长廊悬在云海上方,符光沿石柱缓慢游走。",
"xianxia-star-vessel" => "星槎泊在云海边缘,船身仍有星砂微光。",
_ => XIANXIA_FIRST_SCENE_DESCRIPTION,
}
.to_string(),
image_src: String::new(),
connected_scene_ids: vec![
"xianxia-cloud-gate".to_string(),
"xianxia-floating-isle".to_string(),
"xianxia-celestial-corridor".to_string(),
]
.into_iter()
.filter(|id| id != scene_id)
.collect(),
connections: vec![json!({
"sceneId": if scene_id == "xianxia-cloud-gate" { "xianxia-celestial-corridor" } else { "xianxia-cloud-gate" },
"relativePosition": if scene_id == "xianxia-cloud-gate" { "forward" } else { "back" },
"summary": "沿主路继续移动"
})],
forward_scene_id: Some(if scene_id == "xianxia-cloud-gate" {
"xianxia-celestial-corridor".to_string()
} else {
"xianxia-cloud-gate".to_string()
}),
treasure_hints: vec!["云阶边缘的灵光残痕".to_string()],
npcs: Vec::new(),
});
}
Some(RuntimeScene {
id: scene_id.to_string(),
name: match scene_id {
"wuxia-mountain-gate" => "山门石阶",
"wuxia-mist-woods" => "迷雾竹林",
"wuxia-ferry-bridge" => "渡口断桥",
_ => WUXIA_FIRST_SCENE_NAME,
}
.to_string(),
description: match scene_id {
"wuxia-mountain-gate" => "山门石阶覆着苔痕,旧旗在风里压得很低。",
"wuxia-mist-woods" => "迷雾在竹林间翻卷,脚下泥印很快又被雾水抹平。",
"wuxia-ferry-bridge" => "渡口断桥横在冷水上,桥边灯笼只剩半截残光。",
_ => WUXIA_FIRST_SCENE_DESCRIPTION,
}
.to_string(),
image_src: String::new(),
connected_scene_ids: vec![
"wuxia-bamboo-road".to_string(),
"wuxia-mountain-gate".to_string(),
"wuxia-mist-woods".to_string(),
]
.into_iter()
.filter(|id| id != scene_id)
.collect(),
connections: vec![json!({
"sceneId": if scene_id == "wuxia-bamboo-road" { "wuxia-mountain-gate" } else { "wuxia-bamboo-road" },
"relativePosition": if scene_id == "wuxia-bamboo-road" { "forward" } else { "back" },
"summary": "沿主路继续移动"
})],
forward_scene_id: Some(if scene_id == "wuxia-bamboo-road" {
"wuxia-mountain-gate".to_string()
} else {
"wuxia-bamboo-road".to_string()
}),
treasure_hints: vec!["路边半埋的旧物".to_string()],
npcs: Vec::new(),
})
}
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,
current_act_state_override: Option<&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 = current_act_state_override
.cloned()
.or_else(|| 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>,
current_act_id: Option<&str>,
) -> Option<String> {
let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?;
let acts = read_array_field(chapter, "acts");
let act_state = current_act_id
.and_then(|act_id| {
acts.iter()
.copied()
.find(|act| read_optional_string_field(act, "id").as_deref() == Some(act_id))
})
.or_else(|| 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
}