use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStorySnapshotPayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub saved_at: Option, pub bottom_tab: String, pub game_state: Value, #[serde(default)] pub current_story: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryStateResolveRequest { pub session_id: String, #[serde(default)] pub client_version: Option, #[serde(default)] pub snapshot: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryChoiceAction { #[serde(rename = "type")] pub action_type: String, pub function_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub payload: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryActionRequest { pub session_id: String, #[serde(default)] pub client_version: Option, pub action: RuntimeStoryChoiceAction, #[serde(default)] pub snapshot: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryAiRequestOptions { #[serde(default)] pub available_options: Vec, #[serde(default)] pub option_catalog: Vec, } impl Default for RuntimeStoryAiRequestOptions { fn default() -> Self { Self { available_options: Vec::new(), option_catalog: Vec::new(), } } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryAiRequest { pub world_type: String, pub character: Value, #[serde(default)] pub monsters: Vec, #[serde(default)] pub history: Vec, #[serde(default)] pub choice: String, pub context: Value, #[serde(default)] pub request_options: RuntimeStoryAiRequestOptions, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryAiResponse { pub story_text: String, pub options: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub encounter: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryOptionView { pub function_id: String, pub action_text: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub detail_text: Option, pub scope: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub interaction: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub payload: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum RuntimeStoryOptionInteraction { #[serde(rename_all = "camelCase")] Npc { npc_id: String, action: String, #[serde(default, skip_serializing_if = "Option::is_none")] quest_id: Option, }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryPlayerViewModel { pub hp: i32, pub max_hp: i32, pub mana: i32, pub max_mana: i32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryCompanionViewModel { pub npc_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub character_id: Option, pub joined_at_affinity: i32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryEncounterViewModel { pub id: String, pub kind: String, pub npc_name: String, pub hostile: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub affinity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub recruited: Option, pub interaction_active: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub battle_mode: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryStatusViewModel { pub in_battle: bool, pub npc_interaction_active: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_npc_battle_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_npc_battle_outcome: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeBattlePresentation { #[serde(default, skip_serializing_if = "Option::is_none")] pub target_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub damage_dealt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub damage_taken: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub outcome: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryViewModel { pub player: RuntimeStoryPlayerViewModel, #[serde(default, skip_serializing_if = "Option::is_none")] pub encounter: Option, pub companions: Vec, pub available_options: Vec, pub status: RuntimeStoryStatusViewModel, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryPresentation { pub action_text: String, pub result_text: String, pub story_text: String, pub options: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub toast: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub battle: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum RuntimeStoryPatch { #[serde(rename_all = "camelCase")] StoryHistoryAppend { action_text: String, result_text: String, }, #[serde(rename_all = "camelCase")] NpcAffinityChanged { npc_id: String, previous_affinity: i32, next_affinity: i32, }, #[serde(rename_all = "camelCase")] BattleResolved { function_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] target_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] damage_dealt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] damage_taken: Option, outcome: String, }, #[serde(rename_all = "camelCase")] StatusChanged { in_battle: bool, npc_interaction_active: bool, #[serde(default, skip_serializing_if = "Option::is_none")] current_npc_battle_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] current_npc_battle_outcome: Option, }, #[serde(rename_all = "camelCase")] EncounterChanged { #[serde(default, skip_serializing_if = "Option::is_none")] encounter_id: Option, }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryActionResponse { pub session_id: String, pub server_version: u32, pub view_model: RuntimeStoryViewModel, pub presentation: RuntimeStoryPresentation, pub patches: Vec, pub snapshot: RuntimeStorySnapshotPayload, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn runtime_story_state_resolve_request_accepts_missing_saved_at() { let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({ "sessionId": "runtime-main", "clientVersion": 7, "snapshot": { "bottomTab": "adventure", "gameState": { "runtimeSessionId": "runtime-main" }, "currentStory": { "text": "营地里的火光还没有熄灭。" } } })) .expect("payload should deserialize"); assert_eq!(payload.session_id, "runtime-main"); assert_eq!(payload.client_version, Some(7)); assert_eq!( payload.snapshot.expect("snapshot should exist").saved_at, None ); } #[test] fn runtime_story_action_request_uses_camel_case_fields() { let payload = serde_json::to_value(RuntimeStoryActionRequest { session_id: "runtime-main".to_string(), client_version: Some(8), action: RuntimeStoryChoiceAction { action_type: "story_choice".to_string(), function_id: "npc_chat".to_string(), target_id: Some("npc_camp_firekeeper".to_string()), payload: Some(json!({ "optionText": "继续交谈" })), }, snapshot: Some(RuntimeStorySnapshotPayload { saved_at: Some("2026-04-22T12:00:00.000Z".to_string()), bottom_tab: "adventure".to_string(), game_state: json!({ "runtimeSessionId": "runtime-main" }), current_story: None, }), }) .expect("payload should serialize"); assert_eq!(payload["sessionId"], json!("runtime-main")); assert_eq!(payload["clientVersion"], json!(8)); assert_eq!(payload["action"]["type"], json!("story_choice")); assert_eq!(payload["action"]["functionId"], json!("npc_chat")); assert_eq!(payload["action"]["targetId"], json!("npc_camp_firekeeper")); assert_eq!( payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z") ); } #[test] fn runtime_story_ai_request_defaults_optional_arrays() { let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({ "worldType": "martial", "character": { "name": "林迟" }, "context": { "scene": "camp" } })) .expect("payload should deserialize"); assert_eq!(payload.world_type, "martial"); assert!(payload.monsters.is_empty()); assert!(payload.history.is_empty()); assert!(payload.request_options.available_options.is_empty()); } #[test] fn runtime_story_action_response_uses_camel_case_fields() { let payload = serde_json::to_value(RuntimeStoryActionResponse { session_id: "runtime-main".to_string(), server_version: 8, view_model: RuntimeStoryViewModel { player: RuntimeStoryPlayerViewModel { hp: 32, max_hp: 40, mana: 18, max_mana: 20, }, encounter: Some(RuntimeStoryEncounterViewModel { id: "npc_camp_firekeeper".to_string(), kind: "npc".to_string(), npc_name: "守火人".to_string(), hostile: false, affinity: Some(12), recruited: Some(false), interaction_active: true, battle_mode: None, }), companions: vec![RuntimeStoryCompanionViewModel { npc_id: "npc_companion_001".to_string(), character_id: Some("char_companion_001".to_string()), joined_at_affinity: 64, }], available_options: vec![RuntimeStoryOptionView { function_id: "npc_chat".to_string(), action_text: "继续交谈".to_string(), detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), scope: "npc".to_string(), interaction: Some(RuntimeStoryOptionInteraction::Npc { npc_id: "npc_camp_firekeeper".to_string(), action: "chat".to_string(), quest_id: None, }), payload: Some(json!({ "note": "server-runtime-test" })), disabled: None, reason: None, }], status: RuntimeStoryStatusViewModel { in_battle: false, npc_interaction_active: true, current_npc_battle_mode: None, current_npc_battle_outcome: None, }, }, presentation: RuntimeStoryPresentation { action_text: "".to_string(), result_text: "".to_string(), story_text: "守火人抬眼看了你一瞬,示意你把想问的话继续说完。".to_string(), options: vec![RuntimeStoryOptionView { function_id: "npc_chat".to_string(), action_text: "继续交谈".to_string(), detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), scope: "npc".to_string(), interaction: Some(RuntimeStoryOptionInteraction::Npc { npc_id: "npc_camp_firekeeper".to_string(), action: "chat".to_string(), quest_id: None, }), payload: Some(json!({ "note": "server-runtime-test" })), disabled: None, reason: None, }], toast: None, battle: None, }, patches: vec![RuntimeStoryPatch::StatusChanged { in_battle: false, npc_interaction_active: true, current_npc_battle_mode: None, current_npc_battle_outcome: None, }], snapshot: RuntimeStorySnapshotPayload { saved_at: Some("2026-04-22T12:00:00.000Z".to_string()), bottom_tab: "adventure".to_string(), game_state: json!({ "runtimeSessionId": "runtime-main" }), current_story: Some(json!({ "text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。" })), }, }) .expect("payload should serialize"); assert_eq!(payload["sessionId"], json!("runtime-main")); assert_eq!(payload["serverVersion"], json!(8)); assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40)); assert_eq!( payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"], json!("npc_camp_firekeeper") ); assert_eq!( payload["presentation"]["storyText"], json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。") ); assert_eq!(payload["patches"][0]["type"], json!("status_changed")); assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure")); } }