This commit is contained in:
2026-04-28 20:25:37 +08:00
parent f0471a4f8d
commit 0f013b6eee
45 changed files with 1117 additions and 1047 deletions

View File

@@ -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");

View File

@@ -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 {