use serde::{Deserialize, Serialize}; use serde_json::Value; #[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, } #[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 { #[serde(default)] pub session_id: Option, #[serde(default)] pub client_version: Option, #[serde(default)] pub world_type: String, #[serde(default)] pub character: Value, #[serde(default)] pub monsters: Vec, #[serde(default)] pub history: Vec, #[serde(default)] pub choice: String, #[serde(default)] pub context: Value, #[serde(default)] pub request_options: RuntimeStoryAiRequestOptions, #[serde(default)] pub last_function_id: Option, #[serde(default)] pub observe_signs_requested: bool, #[serde(default)] pub recent_action_result: Option, } #[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 RuntimeStoryInventoryActionView { pub function_id: String, pub action_text: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub payload: Option, pub enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryInventoryItemActionsView { #[serde(rename = "use")] pub use_item: RuntimeStoryInventoryActionView, pub equip: RuntimeStoryInventoryActionView, pub dismantle: RuntimeStoryInventoryActionView, pub reforge: RuntimeStoryInventoryActionView, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryInventoryItemView { pub item: Value, pub actions: RuntimeStoryInventoryItemActionsView, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryEquipmentSlotView { pub slot_id: String, pub label: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub item: Option, pub unequip: RuntimeStoryInventoryActionView, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryForgeRequirementView { pub id: String, pub label: String, pub quantity: i32, pub owned: i32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryForgeRecipeView { pub id: String, pub name: String, pub kind: String, pub description: String, pub result_label: String, pub currency_cost: i32, pub currency_text: String, pub requirements: Vec, pub can_craft: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled_reason: Option, pub action: RuntimeStoryInventoryActionView, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeStoryInventoryViewModel { pub player_currency: i32, pub currency_text: String, pub in_battle: bool, pub backpack_items: Vec, pub equipment_slots: Vec, pub forge_recipes: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeNpcTradeItemView { pub item_id: String, pub item: Value, pub mode: String, pub unit_price: i32, pub max_quantity: i32, pub can_submit: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeNpcGiftItemView { pub item_id: String, pub item: Value, pub affinity_gain: i32, pub can_submit: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeNpcTradeView { pub buy_items: Vec, pub sell_items: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeNpcGiftView { pub items: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeNpcInteractionView { pub npc_id: String, pub npc_name: String, pub player_currency: i32, pub currency_name: String, pub trade: RuntimeNpcTradeView, pub gift: RuntimeNpcGiftView, } #[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 inventory: RuntimeStoryInventoryViewModel, pub available_options: Vec, pub status: RuntimeStoryStatusViewModel, #[serde(default, skip_serializing_if = "Option::is_none")] pub npc_interaction: Option, } #[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, }, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[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": "继续交谈" })), }, }) .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")); } #[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_ai_request_accepts_session_only_payload() { let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({ "sessionId": "runtime-main", "clientVersion": 3, "choice": "继续向前", "lastFunctionId": "idle_explore_forward", "requestOptions": { "optionCatalog": [{ "functionId": "idle_observe_signs", "actionText": "观察周围迹象" }] } })) .expect("payload should deserialize"); assert_eq!(payload.session_id.as_deref(), Some("runtime-main")); assert_eq!(payload.client_version, Some(3)); assert_eq!(payload.world_type, ""); assert_eq!(payload.context, Value::Null); assert_eq!( payload.last_function_id.as_deref(), Some("idle_explore_forward") ); assert_eq!(payload.request_options.option_catalog.len(), 1); } #[test] fn runtime_story_presentation_uses_camel_case_fields() { let 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, }], inventory: RuntimeStoryInventoryViewModel { player_currency: 80, currency_text: "80 铜钱".to_string(), in_battle: false, backpack_items: vec![RuntimeStoryInventoryItemView { item: json!({ "id": "potion-1", "name": "疗伤药", "category": "消耗品", "quantity": 2, "rarity": "common", "tags": ["healing"] }), actions: RuntimeStoryInventoryItemActionsView { use_item: RuntimeStoryInventoryActionView { function_id: "inventory_use".to_string(), action_text: "使用疗伤药".to_string(), payload: Some(json!({ "itemId": "potion-1" })), enabled: true, reason: None, }, equip: RuntimeStoryInventoryActionView { function_id: "equipment_equip".to_string(), action_text: "装备疗伤药".to_string(), payload: Some(json!({ "itemId": "potion-1" })), enabled: false, reason: Some("该物品不能装备。".to_string()), }, dismantle: RuntimeStoryInventoryActionView { function_id: "forge_dismantle".to_string(), action_text: "拆解疗伤药".to_string(), payload: Some(json!({ "itemId": "potion-1" })), enabled: false, reason: Some("该物品不能拆解。".to_string()), }, reforge: RuntimeStoryInventoryActionView { function_id: "forge_reforge".to_string(), action_text: "重铸疗伤药".to_string(), payload: Some(json!({ "itemId": "potion-1" })), enabled: false, reason: Some("该物品不能重铸。".to_string()), }, }, }], equipment_slots: vec![RuntimeStoryEquipmentSlotView { slot_id: "weapon".to_string(), label: "武器".to_string(), item: None, unequip: RuntimeStoryInventoryActionView { function_id: "equipment_unequip".to_string(), action_text: "卸下武器".to_string(), payload: Some(json!({ "slotId": "weapon" })), enabled: false, reason: Some("武器位当前没有装备。".to_string()), }, }], forge_recipes: vec![RuntimeStoryForgeRecipeView { id: "synthesis-refined-ingot".to_string(), name: "压炼锭材".to_string(), kind: "synthesis".to_string(), description: "把零散残片和基础材料压成稳定可用的金属锭材。".to_string(), result_label: "精炼锭材".to_string(), currency_cost: 18, currency_text: "18 铜钱".to_string(), requirements: vec![RuntimeStoryForgeRequirementView { id: "material:any".to_string(), label: "任意材料".to_string(), quantity: 3, owned: 0, }], can_craft: false, disabled_reason: Some("材料不足。".to_string()), action: RuntimeStoryInventoryActionView { function_id: "forge_craft".to_string(), action_text: "制作精炼锭材".to_string(), payload: Some(json!({ "recipeId": "synthesis-refined-ingot" })), enabled: false, reason: Some("材料不足。".to_string()), }, }], }, 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, }, npc_interaction: Some(RuntimeNpcInteractionView { npc_id: "npc_camp_firekeeper".to_string(), npc_name: "守火人".to_string(), player_currency: 80, currency_name: "铜钱".to_string(), trade: RuntimeNpcTradeView { buy_items: vec![RuntimeNpcTradeItemView { item_id: "npc-potion".to_string(), item: json!({ "id": "npc-potion", "name": "疗伤药", "category": "消耗品", "quantity": 2, "rarity": "common", "tags": ["healing"] }), mode: "buy".to_string(), unit_price: 20, max_quantity: 2, can_submit: true, reason: None, }], sell_items: Vec::new(), }, gift: RuntimeNpcGiftView { items: vec![RuntimeNpcGiftItemView { item_id: "potion-1".to_string(), item: json!({ "id": "potion-1", "name": "疗伤药", "category": "消耗品", "quantity": 2, "rarity": "common", "tags": ["healing"] }), affinity_gain: 10, can_submit: true, reason: None, }], }, }), }; let 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, }; let patches = vec![RuntimeStoryPatch::StatusChanged { in_battle: false, npc_interaction_active: true, current_npc_battle_mode: None, current_npc_battle_outcome: None, }]; let payload = serde_json::to_value(json!({ "viewModel": view_model, "presentation": presentation, "patches": patches })) .expect("payload should serialize"); assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40)); assert_eq!( payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"], json!("npc_camp_firekeeper") ); assert_eq!( payload["viewModel"]["inventory"]["backpackItems"][0]["actions"]["use"]["functionId"], json!("inventory_use") ); assert_eq!( payload["viewModel"]["inventory"]["forgeRecipes"][0]["canCraft"], json!(false) ); assert_eq!( payload["presentation"]["storyText"], json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。") ); assert_eq!(payload["patches"][0]["type"], json!("status_changed")); } }