1
This commit is contained in:
@@ -678,22 +678,7 @@ fn resolve_runtime_story_choice_action(
|
||||
2,
|
||||
"你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。",
|
||||
),
|
||||
"camp_travel_home_scene" => {
|
||||
clear_encounter_state(game_state);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("返回营地", request),
|
||||
result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
"camp_travel_home_scene" => resolve_camp_travel_home_scene_action(game_state, request),
|
||||
"idle_call_out" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("主动出声试探", request),
|
||||
@@ -854,6 +839,557 @@ fn resolve_idle_travel_next_scene_action(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_camp_travel_home_scene_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let target_scene = resolve_camp_travel_target_scene(game_state, request)
|
||||
.ok_or_else(|| "无法解析离营后的目标场景".to_string())?;
|
||||
let target_scene_name =
|
||||
read_optional_string_field(&target_scene, "name").unwrap_or_else(|| "前方场景".to_string());
|
||||
let companion_name = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
})
|
||||
.unwrap_or_else(|| "同伴".to_string());
|
||||
|
||||
ensure_json_object(game_state).insert("currentScenePreset".to_string(), target_scene);
|
||||
reset_scene_travel_runtime_state(game_state);
|
||||
increment_runtime_stat(game_state, "scenesTraveled", 1);
|
||||
ensure_scene_encounter_preview(game_state);
|
||||
|
||||
let encounter_id = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "id"));
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("前往{target_scene_name}"), request),
|
||||
result_text: format!(
|
||||
"你和{companion_name}离开营地,正式踏入{target_scene_name},把冒险推进到新的现场。"
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_camp_travel_target_scene(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Option<Value> {
|
||||
resolve_payload_target_scene(game_state, request)
|
||||
.or_else(|| resolve_character_home_scene(game_state))
|
||||
.or_else(|| resolve_current_scene_forward_scene(game_state))
|
||||
.or_else(|| resolve_default_first_adventure_scene(game_state))
|
||||
}
|
||||
|
||||
fn resolve_payload_target_scene(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Option<Value> {
|
||||
// 中文注释:旧前端如果补传 targetSceneId,后端可以接收;
|
||||
// 但正式主链不依赖前端,缺省时仍由服务端自行解析目标场景。
|
||||
let target_scene_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "targetSceneId"))
|
||||
.or_else(|| request.action.target_id.clone())?;
|
||||
resolve_scene_preset_by_id(game_state, target_scene_id.as_str())
|
||||
}
|
||||
|
||||
fn resolve_character_home_scene(game_state: &Value) -> Option<Value> {
|
||||
let character_id = read_object_field(game_state, "playerCharacter")
|
||||
.and_then(|character| read_optional_string_field(character, "id"));
|
||||
let world_type = current_world_type(game_state);
|
||||
let Some(character_id) = character_id else {
|
||||
return None;
|
||||
};
|
||||
if world_type.as_deref() == Some("CUSTOM") {
|
||||
return resolve_custom_character_home_scene(game_state, character_id.as_str());
|
||||
}
|
||||
|
||||
let scene_id = match (character_id.as_str(), world_type.as_deref()) {
|
||||
("sword-princess", Some("XIANXIA")) => "xianxia-celestial-corridor",
|
||||
("sword-princess", _) => "wuxia-palace-court",
|
||||
("archer-hero", Some("XIANXIA")) => "xianxia-star-vessel",
|
||||
("archer-hero", _) => "wuxia-border-camp",
|
||||
("girl-hero", Some("XIANXIA")) => "xianxia-waterfall-cliff",
|
||||
("girl-hero", _) => "wuxia-rain-street",
|
||||
("punch-hero", Some("XIANXIA")) => "xianxia-molten-realm",
|
||||
("punch-hero", _) => "wuxia-forge-works",
|
||||
("fighter-4", Some("XIANXIA")) => "xianxia-thunder-altar",
|
||||
("fighter-4", _) => "wuxia-mountain-gate",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
resolve_builtin_scene_preset(world_type.as_deref().unwrap_or("WUXIA"), scene_id)
|
||||
}
|
||||
|
||||
fn resolve_custom_character_home_scene(game_state: &Value, character_id: &str) -> Option<Value> {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
let role_id = find_custom_world_role_id_by_reference(profile, character_id)
|
||||
.or_else(|| {
|
||||
read_object_field(game_state, "playerCharacter")
|
||||
.and_then(|character| read_optional_string_field(character, "name"))
|
||||
.and_then(|name| find_custom_world_role_id_by_reference(profile, name.as_str()))
|
||||
})
|
||||
.unwrap_or_else(|| character_id.to_string());
|
||||
|
||||
read_array_field(profile, "landmarks")
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, landmark)| {
|
||||
read_array_field(landmark, "sceneNpcIds")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|npc_id| custom_role_references_equal(profile, npc_id, role_id.as_str()))
|
||||
.then(|| {
|
||||
bootstrap::build_custom_scene_preset(
|
||||
profile,
|
||||
format!("custom-scene-landmark-{}", index + 1).as_str(),
|
||||
)
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_current_scene_forward_scene(game_state: &Value) -> Option<Value> {
|
||||
let current_scene = read_object_field(game_state, "currentScenePreset")?;
|
||||
let current_scene_id = read_optional_string_field(current_scene, "id");
|
||||
read_optional_string_field(current_scene, "forwardSceneId")
|
||||
.or_else(|| {
|
||||
read_array_field(current_scene, "connectedSceneIds")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.find(|scene_id| Some(*scene_id) != current_scene_id.as_deref())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| {
|
||||
read_array_field(current_scene, "connections")
|
||||
.into_iter()
|
||||
.find_map(|connection| {
|
||||
read_optional_string_field(connection, "sceneId")
|
||||
.filter(|scene_id| Some(scene_id.as_str()) != current_scene_id.as_deref())
|
||||
})
|
||||
})
|
||||
.and_then(|scene_id| resolve_scene_preset_by_id(game_state, scene_id.as_str()))
|
||||
}
|
||||
|
||||
fn resolve_default_first_adventure_scene(game_state: &Value) -> Option<Value> {
|
||||
if current_world_type(game_state).as_deref() == Some("CUSTOM") {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
if !read_array_field(profile, "landmarks").is_empty() {
|
||||
return bootstrap::build_custom_scene_preset(profile, "custom-scene-landmark-1");
|
||||
}
|
||||
return bootstrap::build_custom_scene_preset(profile, "custom-scene-camp");
|
||||
}
|
||||
|
||||
resolve_builtin_scene_preset(
|
||||
current_world_type(game_state).as_deref().unwrap_or("WUXIA"),
|
||||
if current_world_type(game_state).as_deref() == Some("XIANXIA") {
|
||||
"xianxia-cloud-gate"
|
||||
} else {
|
||||
"wuxia-bamboo-road"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_scene_preset_by_id(game_state: &Value, scene_id: &str) -> Option<Value> {
|
||||
if current_world_type(game_state).as_deref() == Some("CUSTOM") {
|
||||
return read_object_field(game_state, "customWorldProfile")
|
||||
.and_then(|profile| bootstrap::build_custom_scene_preset(profile, scene_id));
|
||||
}
|
||||
|
||||
resolve_builtin_scene_preset(
|
||||
current_world_type(game_state).as_deref().unwrap_or("WUXIA"),
|
||||
scene_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn reset_scene_travel_runtime_state(game_state: &mut Value) {
|
||||
clear_encounter_state(game_state);
|
||||
write_i32_field(game_state, "playerX", 0);
|
||||
write_i32_field(game_state, "playerOffsetY", 0);
|
||||
write_string_field(game_state, "playerFacing", "right");
|
||||
write_string_field(game_state, "animationState", "idle");
|
||||
write_string_field(game_state, "playerActionMode", "idle");
|
||||
write_bool_field(game_state, "scrollWorld", false);
|
||||
write_null_field(game_state, "lastObserveSignsSceneId");
|
||||
write_null_field(game_state, "lastObserveSignsReport");
|
||||
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");
|
||||
ensure_json_object(game_state).insert("activeCombatEffects".to_string(), Value::Array(vec![]));
|
||||
}
|
||||
|
||||
fn resolve_builtin_scene_preset(world_type: &str, scene_id: &str) -> Option<Value> {
|
||||
let scene = builtin_scene_definition(world_type, scene_id)?;
|
||||
Some(build_builtin_scene_preset_from_definition(
|
||||
world_type, scene,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_builtin_scene_preset_from_definition(
|
||||
world_type: &str,
|
||||
scene: BuiltinSceneDefinition,
|
||||
) -> Value {
|
||||
let connections =
|
||||
build_builtin_scene_connections(&scene.connected_scene_ids, scene.forward_scene_id);
|
||||
let narrative_residues = scene
|
||||
.treasure_hints
|
||||
.iter()
|
||||
.take(2)
|
||||
.enumerate()
|
||||
.map(|(index, hint)| {
|
||||
json!({
|
||||
"id": format!("residue:{}:{}", scene.id, index + 1),
|
||||
"title": format!("{}的残痕 {}", scene.name, index + 1),
|
||||
"visibleClue": hint,
|
||||
"linkedFactIds": [],
|
||||
"linkedThreadIds": []
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"id": scene.id,
|
||||
"name": scene.name,
|
||||
"description": scene.description,
|
||||
"imageSrc": "",
|
||||
"worldType": world_type,
|
||||
"forwardSceneId": scene.forward_scene_id,
|
||||
"connectedSceneIds": scene.connected_scene_ids,
|
||||
"connections": connections,
|
||||
"npcs": [build_builtin_scene_npc(scene.npc_id, scene.npc_name, scene.npc_role, scene.npc_avatar, scene.npc_description)],
|
||||
"treasureHints": scene.treasure_hints,
|
||||
"narrativeResidues": narrative_residues
|
||||
})
|
||||
}
|
||||
|
||||
fn build_builtin_scene_connections(
|
||||
connected_scene_ids: &[&str],
|
||||
forward_scene_id: &str,
|
||||
) -> Vec<Value> {
|
||||
connected_scene_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, scene_id)| {
|
||||
let relative_position = if *scene_id == forward_scene_id {
|
||||
"forward"
|
||||
} else if index % 2 == 0 {
|
||||
"left"
|
||||
} else {
|
||||
"right"
|
||||
};
|
||||
json!({
|
||||
"sceneId": scene_id,
|
||||
"relativePosition": relative_position,
|
||||
"summary": if relative_position == "forward" {
|
||||
"沿主路继续深入前方区域"
|
||||
} else {
|
||||
"这里分出一条支路"
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_builtin_scene_npc(
|
||||
id: &str,
|
||||
name: &str,
|
||||
role: &str,
|
||||
avatar: &str,
|
||||
description: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"avatar": avatar,
|
||||
"role": role,
|
||||
"gender": "unknown",
|
||||
"initialAffinity": 18,
|
||||
"hostile": false,
|
||||
"functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"]
|
||||
})
|
||||
}
|
||||
|
||||
struct BuiltinSceneDefinition {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
connected_scene_ids: Vec<&'static str>,
|
||||
forward_scene_id: &'static str,
|
||||
treasure_hints: Vec<&'static str>,
|
||||
npc_id: &'static str,
|
||||
npc_name: &'static str,
|
||||
npc_role: &'static str,
|
||||
npc_avatar: &'static str,
|
||||
npc_description: &'static str,
|
||||
}
|
||||
|
||||
fn builtin_scene_definition(world_type: &str, scene_id: &str) -> Option<BuiltinSceneDefinition> {
|
||||
match (world_type, scene_id) {
|
||||
(_, "wuxia-bamboo-road") => Some(BuiltinSceneDefinition {
|
||||
id: "wuxia-bamboo-road",
|
||||
name: "竹林古道",
|
||||
description: "风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。",
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-mountain-gate",
|
||||
"wuxia-mist-woods",
|
||||
"wuxia-ferry-bridge",
|
||||
],
|
||||
forward_scene_id: "wuxia-mountain-gate",
|
||||
treasure_hints: vec!["竹根旁半埋的刀鞘", "倒竹间的旧药囊"],
|
||||
npc_id: "wuxia-npc-bamboo-woodcutter",
|
||||
npc_name: "樵夫老周",
|
||||
npc_role: "樵夫",
|
||||
npc_avatar: "樵",
|
||||
npc_description: "常在竹海边缘砍柴,对附近路数和兽踪了如指掌。",
|
||||
}),
|
||||
(_, "wuxia-mountain-gate") => Some(BuiltinSceneDefinition {
|
||||
id: "wuxia-mountain-gate",
|
||||
name: "山门石阶",
|
||||
description: "青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。",
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-temple-forecourt",
|
||||
"wuxia-border-camp",
|
||||
"wuxia-bamboo-road",
|
||||
],
|
||||
forward_scene_id: "wuxia-temple-forecourt",
|
||||
treasure_hints: vec!["裂缝里的铜钥", "石狮座下遗落的令牌"],
|
||||
npc_id: "wuxia-npc-gate-disciple",
|
||||
npc_name: "守山弟子",
|
||||
npc_role: "门派弟子",
|
||||
npc_avatar: "守",
|
||||
npc_description: "一直盯着石阶尽头的动静,像在等某位重要来客。",
|
||||
}),
|
||||
(_, "wuxia-rain-street") => Some(BuiltinSceneDefinition {
|
||||
id: "wuxia-rain-street",
|
||||
name: "雨夜长街",
|
||||
description: "长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。",
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-ferry-bridge",
|
||||
"wuxia-palace-court",
|
||||
"wuxia-ruined-village",
|
||||
],
|
||||
forward_scene_id: "wuxia-ferry-bridge",
|
||||
treasure_hints: vec!["灯檐下浸湿的布包", "排水沟边翻起的账册残页"],
|
||||
npc_id: "wuxia-npc-night-vendor",
|
||||
npc_name: "夜灯摊主",
|
||||
npc_role: "摊主",
|
||||
npc_avatar: "灯",
|
||||
npc_description: "深夜仍在街口守着灯摊,见过太多不该见的人。",
|
||||
}),
|
||||
(_, "wuxia-border-camp") => Some(BuiltinSceneDefinition {
|
||||
id: "wuxia-border-camp",
|
||||
name: "边关营地",
|
||||
description: "营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。",
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-ferry-bridge",
|
||||
"wuxia-mountain-gate",
|
||||
"wuxia-ruined-village",
|
||||
],
|
||||
forward_scene_id: "wuxia-rain-street",
|
||||
treasure_hints: vec!["废营帐里的箭囊", "火盆旁埋着的军需匣"],
|
||||
npc_id: "wuxia-npc-quartermaster",
|
||||
npc_name: "军需官",
|
||||
npc_role: "营地官",
|
||||
npc_avatar: "营",
|
||||
npc_description: "管着兵器和粮草,对各路来客始终保持戒心。",
|
||||
}),
|
||||
(_, "wuxia-forge-works") => Some(BuiltinSceneDefinition {
|
||||
id: "wuxia-forge-works",
|
||||
name: "铸坊工场",
|
||||
description: "火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。",
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-mine-depths",
|
||||
"wuxia-palace-court",
|
||||
"wuxia-border-camp",
|
||||
],
|
||||
forward_scene_id: "wuxia-palace-court",
|
||||
treasure_hints: vec!["淬火池旁的铁匣", "风箱后压着的旧兵谱"],
|
||||
npc_id: "wuxia-npc-blacksmith",
|
||||
npc_name: "老铸匠",
|
||||
npc_role: "铸匠",
|
||||
npc_avatar: "铸",
|
||||
npc_description: "看一眼兵器缺口就知道你刚从什么地方杀出来。",
|
||||
}),
|
||||
(_, "wuxia-palace-court") => Some(BuiltinSceneDefinition {
|
||||
id: "wuxia-palace-court",
|
||||
name: "宫苑内庭",
|
||||
description: "回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。",
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-forge-works",
|
||||
"wuxia-rain-street",
|
||||
"wuxia-crypt-passage",
|
||||
],
|
||||
forward_scene_id: "wuxia-rain-street",
|
||||
treasure_hints: vec!["回廊暗格里的香囊", "花圃石座下的旧金牌"],
|
||||
npc_id: "wuxia-npc-maid",
|
||||
npc_name: "旧宫侍女",
|
||||
npc_role: "宫人",
|
||||
npc_avatar: "侍",
|
||||
npc_description: "嘴上说得少,却总知道哪条回廊最近不该过去。",
|
||||
}),
|
||||
("XIANXIA", "xianxia-cloud-gate") => Some(BuiltinSceneDefinition {
|
||||
id: "xianxia-cloud-gate",
|
||||
name: "云海仙门",
|
||||
description: "云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。",
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-floating-isle",
|
||||
"xianxia-celestial-corridor",
|
||||
"xianxia-star-vessel",
|
||||
],
|
||||
forward_scene_id: "xianxia-celestial-corridor",
|
||||
treasure_hints: vec!["云阶尽头的灵符匣", "门阙阴影里的玉牌"],
|
||||
npc_id: "xianxia-npc-gate-attendant",
|
||||
npc_name: "守门灵官",
|
||||
npc_role: "门官",
|
||||
npc_avatar: "门",
|
||||
npc_description: "站在门阙侧旁观来者,像在等一份迟迟未到的回报。",
|
||||
}),
|
||||
("XIANXIA", "xianxia-celestial-corridor") => Some(BuiltinSceneDefinition {
|
||||
id: "xianxia-celestial-corridor",
|
||||
name: "天宫长廊",
|
||||
description: "廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。",
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-cloud-gate",
|
||||
"xianxia-thunder-altar",
|
||||
"xianxia-ancient-ruins",
|
||||
],
|
||||
forward_scene_id: "xianxia-thunder-altar",
|
||||
treasure_hints: vec!["廊柱暗槽里的玉简", "风铃后藏着的封签"],
|
||||
npc_id: "xianxia-npc-palace-page",
|
||||
npc_name: "抄经侍者",
|
||||
npc_role: "侍者",
|
||||
npc_avatar: "卷",
|
||||
npc_description: "抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。",
|
||||
}),
|
||||
("XIANXIA", "xianxia-star-vessel") => Some(BuiltinSceneDefinition {
|
||||
id: "xianxia-star-vessel",
|
||||
name: "星舟甲板",
|
||||
description: "甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。",
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-thunder-altar",
|
||||
"xianxia-cloud-gate",
|
||||
"xianxia-floating-isle",
|
||||
],
|
||||
forward_scene_id: "xianxia-floating-isle",
|
||||
treasure_hints: vec!["舵台后的星图匣", "甲板缝里卡着的灵罗盘"],
|
||||
npc_id: "xianxia-npc-helmsman",
|
||||
npc_name: "星舟舵手",
|
||||
npc_role: "舵手",
|
||||
npc_avatar: "舟",
|
||||
npc_description: "守着老旧星舟的航线图,对高空中的异动异常敏感。",
|
||||
}),
|
||||
("XIANXIA", "xianxia-waterfall-cliff") => Some(BuiltinSceneDefinition {
|
||||
id: "xianxia-waterfall-cliff",
|
||||
name: "飞瀑仙崖",
|
||||
description: "瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。",
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-sacred-tree",
|
||||
"xianxia-molten-realm",
|
||||
"xianxia-floating-isle",
|
||||
],
|
||||
forward_scene_id: "xianxia-cloud-gate",
|
||||
treasure_hints: vec!["瀑幕后闪着光的石匣", "崖边藤上挂着的护身铃"],
|
||||
npc_id: "xianxia-npc-cliff-scout",
|
||||
npc_name: "崖巡女修",
|
||||
npc_role: "巡修",
|
||||
npc_avatar: "崖",
|
||||
npc_description: "长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。",
|
||||
}),
|
||||
("XIANXIA", "xianxia-molten-realm") => Some(BuiltinSceneDefinition {
|
||||
id: "xianxia-molten-realm",
|
||||
name: "熔岩秘境",
|
||||
description: "热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。",
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-thunder-altar",
|
||||
"xianxia-waterfall-cliff",
|
||||
"xianxia-jade-cavern",
|
||||
],
|
||||
forward_scene_id: "xianxia-waterfall-cliff",
|
||||
treasure_hints: vec!["熔岩边冷却的矿匣", "焦岩后藏着的火纹石"],
|
||||
npc_id: "xianxia-npc-fire-forger",
|
||||
npc_name: "熔炉匠修",
|
||||
npc_role: "炼匠",
|
||||
npc_avatar: "炉",
|
||||
npc_description: "在热浪里锻器不歇,见惯灵火失控的后果。",
|
||||
}),
|
||||
("XIANXIA", "xianxia-thunder-altar") => Some(BuiltinSceneDefinition {
|
||||
id: "xianxia-thunder-altar",
|
||||
name: "雷殿祭坛",
|
||||
description: "祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。",
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-celestial-corridor",
|
||||
"xianxia-molten-realm",
|
||||
"xianxia-star-vessel",
|
||||
],
|
||||
forward_scene_id: "xianxia-star-vessel",
|
||||
treasure_hints: vec!["祭坛角落的雷纹匣", "断碑背面的青铜铃"],
|
||||
npc_id: "xianxia-npc-thunder-keeper",
|
||||
npc_name: "祭雷守使",
|
||||
npc_role: "守使",
|
||||
npc_avatar: "雷",
|
||||
npc_description: "总站在祭坛边缘看天,像在确认下一道雷会落到哪里。",
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_custom_world_role_id_by_reference(profile: &Value, reference: &str) -> Option<String> {
|
||||
let normalized_reference = normalize_custom_role_reference(reference);
|
||||
if normalized_reference.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
read_array_field(profile, "storyNpcs")
|
||||
.into_iter()
|
||||
.chain(read_array_field(profile, "playableNpcs"))
|
||||
.find(|role| custom_role_aliases(role).contains(&normalized_reference))
|
||||
.and_then(|role| read_optional_string_field(role, "id"))
|
||||
}
|
||||
|
||||
fn custom_role_references_equal(profile: &Value, left: &str, right: &str) -> bool {
|
||||
let left = find_custom_world_role_id_by_reference(profile, left)
|
||||
.unwrap_or_else(|| left.trim().to_string());
|
||||
let right = find_custom_world_role_id_by_reference(profile, right)
|
||||
.unwrap_or_else(|| right.trim().to_string());
|
||||
!left.trim().is_empty() && left == right
|
||||
}
|
||||
|
||||
fn custom_role_aliases(role: &Value) -> Vec<String> {
|
||||
[
|
||||
read_optional_string_field(role, "id"),
|
||||
read_optional_string_field(role, "name"),
|
||||
read_optional_string_field(role, "title"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|value| normalize_custom_role_reference(value.as_str()))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_custom_role_reference(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|ch| ch.is_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_next_scene_preset(game_state: &Value) -> Option<Value> {
|
||||
let current_scene = read_object_field(game_state, "currentScenePreset")?;
|
||||
let current_scene_id = read_optional_string_field(current_scene, "id");
|
||||
|
||||
@@ -2141,6 +2141,176 @@ async fn runtime_story_route_boundary_projects_story_engine_state() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_route_boundary_camp_travel_home_scene_is_server_owned() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let mut game_state = build_runtime_story_boundary_game_state_fixture();
|
||||
let root = ensure_json_object(&mut game_state);
|
||||
root.insert("worldType".to_string(), json!("WUXIA"));
|
||||
root.insert(
|
||||
"playerCharacter".to_string(),
|
||||
json!({
|
||||
"id": "sword-princess",
|
||||
"name": "青璃",
|
||||
"title": "试剑客",
|
||||
"description": "准备离营的角色。",
|
||||
"personality": "谨慎",
|
||||
"attributes": {
|
||||
"strength": 8,
|
||||
"spirit": 6
|
||||
},
|
||||
"skills": []
|
||||
}),
|
||||
);
|
||||
root.insert(
|
||||
"currentScenePreset".to_string(),
|
||||
json!({
|
||||
"id": "wuxia-border-camp",
|
||||
"name": "边关营地",
|
||||
"description": "营火未熄。",
|
||||
"imageSrc": "",
|
||||
"connectedSceneIds": ["wuxia-palace-court"],
|
||||
"connections": [{
|
||||
"sceneId": "wuxia-palace-court",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿旧宫线索离营"
|
||||
}],
|
||||
"forwardSceneId": "wuxia-palace-court",
|
||||
"treasureHints": [],
|
||||
"npcs": []
|
||||
}),
|
||||
);
|
||||
root.insert(
|
||||
"currentEncounter".to_string(),
|
||||
json!({
|
||||
"kind": "npc",
|
||||
"id": "npc-camp-companion",
|
||||
"npcName": "营地同伴",
|
||||
"npcDescription": "准备一起出发的同伴",
|
||||
"npcAvatar": "伴",
|
||||
"context": "营地",
|
||||
"hostile": false
|
||||
}),
|
||||
);
|
||||
root.insert(
|
||||
"runtimeStats".to_string(),
|
||||
json!({
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": null,
|
||||
"hostileNpcsDefeated": 0,
|
||||
"questsAccepted": 0,
|
||||
"itemsUsed": 0,
|
||||
"scenesTraveled": 2
|
||||
}),
|
||||
);
|
||||
seed_runtime_story_snapshot(
|
||||
&state,
|
||||
game_state,
|
||||
Some(json!({
|
||||
"text": "营地对话已经结束。",
|
||||
"options": []
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let app = build_router(state);
|
||||
|
||||
let action_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/actions/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"clientVersion": 0,
|
||||
"action": {
|
||||
"type": "story_choice",
|
||||
"functionId": "camp_travel_home_scene",
|
||||
"payload": {
|
||||
"optionText": "前往宫苑内庭"
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(action_response.status(), StatusCode::OK);
|
||||
let action_payload: Value = serde_json::from_slice(
|
||||
&action_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes(),
|
||||
)
|
||||
.expect("response should be json");
|
||||
let action_state = &action_payload["data"]["snapshot"]["gameState"];
|
||||
|
||||
assert_eq!(
|
||||
action_state["currentScenePreset"]["id"],
|
||||
json!("wuxia-palace-court")
|
||||
);
|
||||
assert_eq!(action_state["runtimeStats"]["scenesTraveled"], json!(3));
|
||||
assert_eq!(action_state["inBattle"], json!(false));
|
||||
assert_eq!(action_state["npcInteractionActive"], json!(false));
|
||||
assert_eq!(action_state["sceneHostileNpcs"], json!([]));
|
||||
assert_eq!(
|
||||
action_state["currentEncounter"]["id"],
|
||||
json!("wuxia-npc-maid")
|
||||
);
|
||||
assert_eq!(
|
||||
action_state["storyHistory"]
|
||||
.as_array()
|
||||
.expect("story history should be array")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
action_payload["data"]["presentation"]["resultText"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("宫苑内庭"))
|
||||
);
|
||||
|
||||
let state_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/story/state/runtime-main")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(state_response.status(), StatusCode::OK);
|
||||
let state_payload: Value = serde_json::from_slice(
|
||||
&state_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes(),
|
||||
)
|
||||
.expect("response should be json");
|
||||
assert_eq!(
|
||||
state_payload["data"]["snapshot"]["gameState"]["currentScenePreset"]["id"],
|
||||
json!("wuxia-palace-court")
|
||||
);
|
||||
assert_eq!(
|
||||
state_payload["data"]["snapshot"]["gameState"]["currentEncounter"]["id"],
|
||||
json!("wuxia-npc-maid")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_story_npc_help_is_one_shot_and_restores_resources() {
|
||||
let request = RuntimeStoryActionRequest {
|
||||
|
||||
Reference in New Issue
Block a user