fix: preserve rpg custom world detail profiles
This commit is contained in:
@@ -5,8 +5,8 @@ use shared_contracts::runtime_story::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field,
|
||||
read_optional_string_field,
|
||||
StoryRuntimeActionResolveInput, battle::resolve_battle_action, build_status_patch,
|
||||
read_bool_field, read_i32_field, read_optional_string_field, resolve_story_runtime_action,
|
||||
};
|
||||
|
||||
fn build_battle_fixture() -> serde_json::Value {
|
||||
@@ -61,6 +61,115 @@ fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequ
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_action_request(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
payload: Option<serde_json::Value>,
|
||||
) -> shared_contracts::story::ResolveStoryRuntimeActionRequest {
|
||||
shared_contracts::story::ResolveStoryRuntimeActionRequest {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
client_version: Some(1),
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
target_id: None,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_custom_world_profile_with_two_landmarks() -> serde_json::Value {
|
||||
json!({
|
||||
"id": "profile-1",
|
||||
"name": "雾桥旧约",
|
||||
"summary": "雾桥边的旧约正在复苏。",
|
||||
"camp": {
|
||||
"id": "camp-1",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connections": [
|
||||
{
|
||||
"targetLandmarkId": "landmark-1",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿桥面继续前进"
|
||||
},
|
||||
{
|
||||
"targetLandmarkId": "landmark-2",
|
||||
"relativePosition": "right",
|
||||
"summary": "转入雾中支路"
|
||||
}
|
||||
]
|
||||
},
|
||||
"landmarks": [
|
||||
{
|
||||
"id": "landmark-1",
|
||||
"name": "断桥口",
|
||||
"description": "桥口挂着旧灯。"
|
||||
},
|
||||
{
|
||||
"id": "landmark-2",
|
||||
"name": "雾中渡",
|
||||
"description": "渡口只有潮声。"
|
||||
}
|
||||
],
|
||||
"storyNpcs": [
|
||||
{
|
||||
"id": "npc-bridge",
|
||||
"name": "桥影",
|
||||
"description": "桥下逼来的敌影",
|
||||
"initialAffinity": -20
|
||||
},
|
||||
{
|
||||
"id": "npc-ferryman",
|
||||
"name": "摆渡人",
|
||||
"description": "守着雾中渡的人",
|
||||
"initialAffinity": 0
|
||||
}
|
||||
],
|
||||
"sceneChapterBlueprints": [
|
||||
{
|
||||
"id": "chapter-camp",
|
||||
"sceneId": "camp-1",
|
||||
"linkedLandmarkIds": ["camp-1"],
|
||||
"acts": [
|
||||
{
|
||||
"id": "act-camp-1",
|
||||
"sceneId": "camp-1",
|
||||
"oppositeNpcId": "npc-bridge"
|
||||
},
|
||||
{
|
||||
"id": "act-camp-2",
|
||||
"sceneId": "camp-1",
|
||||
"oppositeNpcId": "npc-ferryman"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chapter-landmark-1",
|
||||
"sceneId": "landmark-1",
|
||||
"linkedLandmarkIds": ["landmark-1"],
|
||||
"acts": [
|
||||
{
|
||||
"id": "act-landmark-1",
|
||||
"sceneId": "landmark-1",
|
||||
"oppositeNpcId": "npc-ferryman"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn build_story_runtime_snapshot(
|
||||
game_state: serde_json::Value,
|
||||
current_story: Option<serde_json::Value>,
|
||||
) -> shared_contracts::story::StoryRuntimeSnapshotPayload {
|
||||
shared_contracts::story::StoryRuntimeSnapshotPayload {
|
||||
saved_at: None,
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state,
|
||||
current_story,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
|
||||
let request = build_request("battle_all_in_crush", "全力压制");
|
||||
@@ -89,3 +198,210 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
|
||||
Some("defeat".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_battle_action_persists_post_battle_continue_story() {
|
||||
let mut game_state = build_battle_fixture();
|
||||
game_state["runtimeSessionId"] = json!("runtime-1");
|
||||
game_state["currentScene"] = json!("Story");
|
||||
game_state["worldType"] = json!("CUSTOM");
|
||||
game_state["playerHp"] = json!(30);
|
||||
game_state["customWorldProfile"] = build_custom_world_profile_with_two_landmarks();
|
||||
game_state["currentScenePreset"] = json!({
|
||||
"id": "custom-scene-camp",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"],
|
||||
"forwardSceneId": "custom-scene-landmark-1",
|
||||
"treasureHints": [],
|
||||
"npcs": []
|
||||
});
|
||||
game_state["storyEngineMemory"] = json!({
|
||||
"currentSceneActState": {
|
||||
"sceneId": "camp-1",
|
||||
"chapterId": "chapter-camp",
|
||||
"currentActId": "act-camp-1",
|
||||
"currentActIndex": 0,
|
||||
"completedActIds": [],
|
||||
"visitedActIds": ["act-camp-1"]
|
||||
}
|
||||
});
|
||||
|
||||
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
runtime_session_id: "runtime-1".to_string(),
|
||||
snapshot: build_story_runtime_snapshot(game_state, None),
|
||||
request: build_runtime_action_request("battle_all_in_crush", "全力压制", None),
|
||||
})
|
||||
.expect("terminal battle should resolve");
|
||||
|
||||
assert_eq!(
|
||||
output.presentation.battle.unwrap().outcome.as_deref(),
|
||||
Some("victory")
|
||||
);
|
||||
assert_eq!(
|
||||
output.presentation.options[0].function_id,
|
||||
"story_continue_adventure"
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.current_story.as_ref().unwrap()["options"][0]["functionId"],
|
||||
json!("story_continue_adventure")
|
||||
);
|
||||
assert!(
|
||||
output.snapshot.current_story.as_ref().unwrap()["deferredOptions"]
|
||||
.as_array()
|
||||
.is_some_and(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item["functionId"] == json!("idle_travel_next_scene"))
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.current_story.as_ref().unwrap()["deferredRuntimeState"]["storyEngineMemory"]
|
||||
["currentSceneActState"]["currentActId"],
|
||||
json!("act-camp-2")
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"],
|
||||
json!("act-camp-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_travel_next_scene_changes_scene_from_target_payload() {
|
||||
let game_state = json!({
|
||||
"runtimeSessionId": "runtime-1",
|
||||
"runtimeActionVersion": 1,
|
||||
"currentScene": "Story",
|
||||
"worldType": "CUSTOM",
|
||||
"customWorldProfile": build_custom_world_profile_with_two_landmarks(),
|
||||
"playerHp": 30,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 10,
|
||||
"playerMaxMana": 20,
|
||||
"playerCurrency": 0,
|
||||
"playerInventory": [],
|
||||
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||||
"runtimeStats": {
|
||||
"hostileNpcsDefeated": 0,
|
||||
"itemsUsed": 0,
|
||||
"questsAccepted": 0,
|
||||
"scenesTraveled": 0,
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": null
|
||||
},
|
||||
"currentScenePreset": {
|
||||
"id": "custom-scene-camp",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"],
|
||||
"connections": [
|
||||
{
|
||||
"sceneId": "custom-scene-landmark-1",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿桥面继续前进"
|
||||
}
|
||||
],
|
||||
"forwardSceneId": "custom-scene-landmark-1",
|
||||
"treasureHints": [],
|
||||
"npcs": []
|
||||
},
|
||||
"currentEncounter": null,
|
||||
"npcInteractionActive": false,
|
||||
"sceneHostileNpcs": [],
|
||||
"inBattle": false,
|
||||
"storyHistory": [],
|
||||
"storyEngineMemory": {}
|
||||
});
|
||||
|
||||
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
runtime_session_id: "runtime-1".to_string(),
|
||||
snapshot: build_story_runtime_snapshot(game_state, None),
|
||||
request: build_runtime_action_request(
|
||||
"idle_travel_next_scene",
|
||||
"向前走,前往断桥口",
|
||||
Some(json!({ "targetSceneId": "custom-scene-landmark-1" })),
|
||||
),
|
||||
})
|
||||
.expect("travel action should resolve");
|
||||
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["currentScenePreset"]["id"],
|
||||
json!("custom-scene-landmark-1")
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["runtimeStats"]["scenesTraveled"],
|
||||
json!(1)
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["currentEncounter"]["id"],
|
||||
json!("npc-ferryman")
|
||||
);
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"],
|
||||
json!("act-landmark-1")
|
||||
);
|
||||
assert!(output.presentation.options.iter().any(|option| {
|
||||
option.function_id == "idle_travel_next_scene"
|
||||
|| option.function_id == "idle_explore_forward"
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_travel_next_scene_normalizes_custom_landmark_id_payload() {
|
||||
let game_state = json!({
|
||||
"runtimeSessionId": "runtime-1",
|
||||
"runtimeActionVersion": 1,
|
||||
"currentScene": "Story",
|
||||
"worldType": "CUSTOM",
|
||||
"customWorldProfile": build_custom_world_profile_with_two_landmarks(),
|
||||
"playerHp": 30,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 10,
|
||||
"playerMaxMana": 20,
|
||||
"playerCurrency": 0,
|
||||
"playerInventory": [],
|
||||
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||||
"runtimeStats": {
|
||||
"hostileNpcsDefeated": 0,
|
||||
"itemsUsed": 0,
|
||||
"questsAccepted": 0,
|
||||
"scenesTraveled": 0,
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": null
|
||||
},
|
||||
"currentScenePreset": {
|
||||
"id": "custom-scene-camp",
|
||||
"name": "雾桥营地",
|
||||
"description": "营火压着雾气。",
|
||||
"connectedSceneIds": ["landmark-1", "landmark-2"],
|
||||
"forwardSceneId": "landmark-2",
|
||||
"treasureHints": [],
|
||||
"npcs": []
|
||||
},
|
||||
"currentEncounter": null,
|
||||
"npcInteractionActive": false,
|
||||
"sceneHostileNpcs": [],
|
||||
"inBattle": false,
|
||||
"storyHistory": [],
|
||||
"storyEngineMemory": {}
|
||||
});
|
||||
|
||||
let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput {
|
||||
story_session_id: "storysess-1".to_string(),
|
||||
runtime_session_id: "runtime-1".to_string(),
|
||||
snapshot: build_story_runtime_snapshot(game_state, None),
|
||||
request: build_runtime_action_request(
|
||||
"idle_travel_next_scene",
|
||||
"前往雾中渡",
|
||||
Some(json!({ "targetSceneId": "landmark-2" })),
|
||||
),
|
||||
})
|
||||
.expect("raw custom landmark id should resolve");
|
||||
|
||||
assert_eq!(
|
||||
output.snapshot.game_state["currentScenePreset"]["id"],
|
||||
json!("custom-scene-landmark-2")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ pub use options::{
|
||||
build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope,
|
||||
};
|
||||
pub use post_battle::{
|
||||
finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options,
|
||||
clear_post_battle_state, ensure_scene_act_state, ensure_scene_encounter_preview,
|
||||
finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_forward_scene_id,
|
||||
resolve_post_battle_story_options, resolve_runtime_scene_preset,
|
||||
};
|
||||
pub use projection::{StoryRuntimeProjectionSource, build_story_runtime_projection};
|
||||
pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context};
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -14,16 +14,18 @@ use crate::{
|
||||
build_current_build_toast, build_npc_gift_result_text,
|
||||
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
|
||||
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
|
||||
clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_name,
|
||||
ensure_json_object, find_player_inventory_entry, normalize_equipment_slot_id,
|
||||
normalize_required_string, npc_buyback_price, npc_purchase_price,
|
||||
clear_encounter_state, clear_post_battle_state, clone_inventory_item_with_quantity,
|
||||
current_encounter_name, ensure_json_object, ensure_scene_act_state,
|
||||
ensure_scene_encounter_preview, finalize_post_battle_resolution, find_player_inventory_entry,
|
||||
normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, npc_purchase_price,
|
||||
project_story_engine_after_action, read_array_field, read_bool_field, read_field,
|
||||
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
|
||||
read_player_equipment_item, read_player_inventory_values, read_runtime_session_id,
|
||||
read_u32_field, recruit_companion_to_party, remove_inventory_item_from_list,
|
||||
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
|
||||
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||||
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
|
||||
resolve_forward_scene_id, resolve_npc_gift_affinity_gain, resolve_post_battle_story_options,
|
||||
resolve_runtime_scene_preset, restore_player_resource, simple_story_resolution,
|
||||
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
|
||||
write_player_inventory_values, write_runtime_npc_interaction_view, write_string_field,
|
||||
write_u32_field,
|
||||
@@ -97,23 +99,7 @@ pub fn resolve_story_runtime_action(
|
||||
requested_runtime_session_id.as_str(),
|
||||
);
|
||||
|
||||
let mut options = resolution
|
||||
.presentation_options
|
||||
.take()
|
||||
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
|
||||
if options.is_empty() {
|
||||
options = build_fallback_runtime_story_options(&game_state);
|
||||
}
|
||||
|
||||
let story_text = resolution
|
||||
.story_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.result_text.clone());
|
||||
let history_result_text = resolution.result_text.clone();
|
||||
let saved_current_story = resolution
|
||||
.saved_current_story
|
||||
.take()
|
||||
.unwrap_or_else(|| build_current_story(story_text.as_str(), &options));
|
||||
|
||||
append_story_history(
|
||||
&mut game_state,
|
||||
@@ -132,6 +118,37 @@ pub fn resolve_story_runtime_action(
|
||||
.and_then(|battle| battle.outcome.as_deref()),
|
||||
);
|
||||
|
||||
if let Some(post_battle) = finalize_post_battle_resolution(
|
||||
&mut game_state,
|
||||
history_result_text.as_str(),
|
||||
resolution
|
||||
.battle
|
||||
.as_ref()
|
||||
.and_then(|battle| battle.outcome.as_deref()),
|
||||
Vec::new(),
|
||||
) {
|
||||
resolution.story_text = Some(post_battle.story_text);
|
||||
resolution.presentation_options = Some(post_battle.presentation_options);
|
||||
resolution.saved_current_story = Some(post_battle.saved_current_story);
|
||||
}
|
||||
|
||||
let mut options = resolution
|
||||
.presentation_options
|
||||
.take()
|
||||
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
|
||||
if options.is_empty() {
|
||||
options = build_fallback_runtime_story_options(&game_state);
|
||||
}
|
||||
|
||||
let story_text = resolution
|
||||
.story_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.result_text.clone());
|
||||
let saved_current_story = resolution
|
||||
.saved_current_story
|
||||
.take()
|
||||
.unwrap_or_else(|| build_current_story(story_text.as_str(), &options));
|
||||
|
||||
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
|
||||
action_text: resolution.action_text.clone(),
|
||||
result_text: history_result_text.clone(),
|
||||
@@ -212,11 +229,10 @@ fn resolve_runtime_story_choice_action(
|
||||
resolve_action_text("主动出声试探", request),
|
||||
"你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。",
|
||||
)),
|
||||
"idle_explore_forward" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("继续向前探索", request),
|
||||
"你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。",
|
||||
)),
|
||||
"idle_explore_forward" => resolve_idle_explore_forward_action(game_state, request),
|
||||
"idle_travel_next_scene" | "camp_travel_home_scene" => {
|
||||
resolve_idle_travel_next_scene_action(game_state, request)
|
||||
}
|
||||
"idle_observe_signs" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("观察周围迹象", request),
|
||||
@@ -309,6 +325,62 @@ fn resolve_continue_adventure_action(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_idle_explore_forward_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
// 中文注释:探索前进是战后继续链路的一环,必须在后端清掉战斗态并生成下一段遭遇预览。
|
||||
// 前端只播放表现动画,不能只靠本地状态把同一组 idle 选项重新展示一遍。
|
||||
clear_post_battle_state(game_state);
|
||||
ensure_scene_encounter_preview(game_state);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("继续向前探索", request),
|
||||
result_text: "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: Some(resolve_post_battle_story_options(game_state)),
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_idle_travel_next_scene_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
// 中文注释:切场景会改变 currentScenePreset、章节 act 状态和运行统计,
|
||||
// 这些都是 runtime 快照真相,不能只在前端播放退场/进场动画。
|
||||
let payload = request.action.payload.as_ref();
|
||||
let target_scene_id = payload
|
||||
.and_then(|payload| read_optional_string_field(payload, "targetSceneId"))
|
||||
.or_else(|| resolve_forward_scene_id(game_state))
|
||||
.ok_or_else(|| "idle_travel_next_scene 缺少 targetSceneId".to_string())?;
|
||||
let next_scene = resolve_runtime_scene_preset(game_state, target_scene_id.as_str())
|
||||
.ok_or_else(|| format!("未找到目标场景:{target_scene_id}"))?;
|
||||
let next_scene_name =
|
||||
read_optional_string_field(&next_scene, "name").unwrap_or_else(|| target_scene_id.clone());
|
||||
|
||||
clear_post_battle_state(game_state);
|
||||
ensure_json_object(game_state).insert("currentScenePreset".to_string(), next_scene);
|
||||
write_i32_field(game_state, "playerX", 0);
|
||||
write_string_field(game_state, "playerFacing", "right");
|
||||
ensure_scene_act_state(game_state);
|
||||
ensure_scene_encounter_preview(game_state);
|
||||
increment_runtime_stat_local(game_state, "scenesTraveled", 1);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("前往{next_scene_name}"), request),
|
||||
result_text: format!("你离开当前区域,抵达了{next_scene_name}。"),
|
||||
story_text: None,
|
||||
presentation_options: Some(resolve_post_battle_story_options(game_state)),
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_npc_preview_talk_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
|
||||
Reference in New Issue
Block a user