use serde_json::json; use shared_contracts::runtime_story::{ RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch, }; use crate::{ 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 { json!({ "inBattle": true, "npcInteractionActive": false, "playerHp": 4, "playerMaxHp": 40, "playerMana": 10, "playerMaxMana": 10, "playerSkillCooldowns": {}, "runtimeStats": { "hostileNpcsDefeated": 0, "itemsUsed": 0, "questsAccepted": 0, "scenesTraveled": 0, "playTimeMs": 0, "lastPlayTickAt": null }, "currentNpcBattleMode": "fight", "currentNpcBattleOutcome": null, "currentEncounter": { "kind": "npc", "id": "npc_bandit_01", "npcName": "断桥匪首", "hostile": true, "hp": 8, "experienceReward": 24 }, "sceneHostileNpcs": [{ "id": "npc_bandit_01", "name": "断桥匪首", "hp": 8, "maxHp": 80, "experienceReward": 24 }] }) } fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequest { RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(0), action: RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: function_id.to_string(), target_id: None, payload: Some(json!({ "optionText": option_text })), }, } } fn build_runtime_action_request( function_id: &str, action_text: &str, payload: Option, ) -> 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, ) -> 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", "全力压制"); let mut game_state = build_battle_fixture(); let resolution = resolve_battle_action(&mut game_state, &request, "battle_all_in_crush") .expect("battle action should resolve"); assert_eq!(read_i32_field(&game_state, "playerHp"), Some(0)); assert_eq!( read_optional_string_field(&game_state, "currentNpcBattleOutcome"), Some("fight_defeat".to_string()) ); assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false)); assert!(resolution.result_text.contains("败北")); assert!(matches!( resolution.patches.first(), Some(RuntimeStoryPatch::BattleResolved { outcome, .. }) if outcome == "defeat" )); assert_eq!( resolution.patches.get(1), Some(&build_status_patch(&game_state)) ); assert_eq!( resolution.battle.and_then(|battle| battle.outcome), 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") ); }