fix: preserve rpg custom world detail profiles

This commit is contained in:
kdletters
2026-05-22 03:14:11 +08:00
parent a9d23a8a44
commit d74457faa2
19 changed files with 2726 additions and 109 deletions

View File

@@ -2,10 +2,11 @@ use serde_json::{Value, json};
use shared_contracts::runtime_story::RuntimeStoryOptionView;
use crate::{
CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option,
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, write_bool_field,
write_i32_field, write_null_field, write_string_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";
@@ -36,6 +37,8 @@ pub fn finalize_post_battle_resolution(
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));
}
@@ -45,6 +48,7 @@ pub fn finalize_post_battle_resolution(
game_state,
result_text,
fallback_options,
original_scene_act_state,
));
}
@@ -64,13 +68,14 @@ 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)
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);
@@ -141,7 +146,7 @@ fn finalize_defeat_revive(
{
write_current_scene_act_state(game_state, first_act_state);
}
ensure_first_scene_encounter_preview(game_state);
ensure_scene_encounter_preview(game_state);
let story_text = if first_scene.name.is_empty() {
"你在战斗中倒下,随后重新醒来。".to_string()
@@ -160,7 +165,7 @@ fn finalize_defeat_revive(
}
}
fn clear_post_battle_state(game_state: &mut Value) {
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()));
@@ -421,7 +426,7 @@ fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) {
);
}
fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
pub fn ensure_scene_encounter_preview(game_state: &mut Value) {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return;
}
@@ -436,7 +441,13 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
};
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 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;
};
@@ -450,6 +461,22 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
);
}
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(
@@ -459,32 +486,53 @@ fn build_scene_travel_options(game_state: &Value) -> Vec<RuntimeStoryOptionView>
)];
};
let current_scene_id = read_optional_string_field(current_scene, "id");
let mut options = read_array_field(current_scene, "connections")
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(|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<_>>();
.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(
@@ -497,6 +545,163 @@ fn build_scene_travel_options(game_state: &Value) -> Vec<RuntimeStoryOptionView>
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"))
@@ -553,7 +758,10 @@ fn direction_text(relative_position: &str) -> &'static str {
}
}
fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option<Value> {
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"));
@@ -563,7 +771,9 @@ fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option<Value> {
if acts.is_empty() {
return None;
}
let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?;
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()
@@ -762,9 +972,17 @@ fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec<String> {
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 act_state = read_array_field(chapter, "acts").first().copied()?;
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(|| {