use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BeginStorySessionRequest { pub runtime_session_id: String, pub world_profile_id: String, pub initial_prompt: String, #[serde(default)] pub opening_summary: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ContinueStoryRequest { pub story_session_id: String, pub narrative_text: String, #[serde(default)] pub choice_function_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct StorySessionPayload { pub story_session_id: String, pub runtime_session_id: String, pub actor_user_id: String, pub world_profile_id: String, pub initial_prompt: String, #[serde(default)] pub opening_summary: Option, pub latest_narrative_text: String, #[serde(default)] pub latest_choice_function_id: Option, pub status: String, pub version: u32, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct StoryEventPayload { pub event_id: String, pub story_session_id: String, pub event_kind: String, pub narrative_text: String, #[serde(default)] pub choice_function_id: Option, pub created_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct StorySessionMutationResponse { pub story_session: StorySessionPayload, pub story_event: StoryEventPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct StorySessionStateResponse { pub story_session: StorySessionPayload, pub story_events: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryRuntimeProjectionRequest { pub story_session_id: String, #[serde(default)] pub client_version: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryRuntimeActorProjection { pub hp: i32, pub max_hp: i32, pub mana: i32, pub max_mana: i32, pub currency: i32, pub currency_text: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryRuntimeInventoryProjection { pub backpack_items: Vec, pub equipment_slots: Vec, pub forge_recipes: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryRuntimeOptionProjection { 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 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 StoryRuntimeStatusProjection { pub in_battle: bool, pub npc_interaction_active: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_encounter_id: Option, #[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 StoryRuntimeProjectionResponse { pub story_session: StorySessionPayload, pub story_events: Vec, pub server_version: u32, pub actor: StoryRuntimeActorProjection, pub inventory: StoryRuntimeInventoryProjection, pub options: Vec, pub status: StoryRuntimeStatusProjection, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_narrative_text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub action_result_text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub toast: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct StoryBattleRewardItemRequest { pub item_id: String, pub category: String, pub item_name: String, #[serde(default)] pub description: Option, pub quantity: u32, pub rarity: String, #[serde(default)] pub tags: Vec, pub stackable: bool, #[serde(default)] pub stack_key: String, #[serde(default)] pub equipment_slot_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreateStoryBattleRequest { pub story_session_id: String, pub runtime_session_id: String, #[serde(default)] pub chapter_id: Option, pub target_npc_id: String, pub target_name: String, pub battle_mode: String, pub player_hp: i32, pub player_max_hp: i32, pub player_mana: i32, pub player_max_mana: i32, pub target_hp: i32, pub target_max_hp: i32, #[serde(default)] pub experience_reward: u32, #[serde(default)] pub reward_items: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreateStoryNpcBattleRequest { pub story_session_id: String, pub runtime_session_id: String, pub npc_id: String, pub npc_name: String, pub interaction_function_id: String, #[serde(default)] pub release_npc_id: Option, #[serde(default)] pub battle_state_id: Option, pub player_hp: i32, pub player_max_hp: i32, pub player_mana: i32, pub player_max_mana: i32, pub target_hp: i32, pub target_max_hp: i32, #[serde(default)] pub experience_reward: u32, #[serde(default)] pub reward_items: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ResolveStoryBattleRequest { pub battle_state_id: String, pub function_id: String, pub action_text: String, pub base_damage: i32, pub mana_cost: i32, pub heal: i32, pub mana_restore: i32, pub counter_multiplier_basis_points: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryBattleRewardItemPayload { pub item_id: String, pub category: String, pub item_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub quantity: u32, pub rarity: String, pub tags: Vec, pub stackable: bool, pub stack_key: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub equipment_slot_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryBattleStatePayload { pub battle_state_id: String, pub story_session_id: String, pub runtime_session_id: String, pub actor_user_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub chapter_id: Option, pub target_npc_id: String, pub target_name: String, pub battle_mode: String, pub status: String, pub player_hp: i32, pub player_max_hp: i32, pub player_mana: i32, pub player_max_mana: i32, pub target_hp: i32, pub target_max_hp: i32, pub experience_reward: u32, pub reward_items: Vec, pub turn_index: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_action_function_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_action_text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_result_text: Option, pub last_damage_dealt: i32, pub last_damage_taken: i32, pub last_outcome: String, pub version: u32, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryBattleStateResponse { pub battle_state: StoryBattleStatePayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryCombatActionPayload { pub damage_dealt: i32, pub damage_taken: i32, pub outcome: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ResolveStoryBattleResponse { pub battle_state: StoryBattleStatePayload, pub combat: StoryCombatActionPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryNpcStanceProfilePayload { pub trust: u8, pub warmth: u8, pub ideological_fit: u8, pub fear_or_guard: u8, pub loyalty: u8, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_conflict_tag: Option, pub recent_approvals: Vec, pub recent_disapprovals: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryNpcStatePayload { pub npc_state_id: String, pub runtime_session_id: String, pub npc_id: String, pub npc_name: String, pub affinity: i32, pub relation_stance: String, pub help_used: bool, pub chatted_count: u32, pub gifts_given: u32, pub recruited: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub trade_stock_signature: Option, pub revealed_facts: Vec, pub known_attribute_rumors: Vec, pub first_meaningful_contact_resolved: bool, pub seen_backstory_chapter_ids: Vec, pub stance_profile: StoryNpcStanceProfilePayload, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StoryNpcInteractionPayload { pub npc_state: StoryNpcStatePayload, pub interaction_status: String, pub action_text: String, pub result_text: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub story_text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub battle_mode: Option, pub encounter_closed: bool, pub affinity_changed: bool, pub previous_affinity: i32, pub next_affinity: i32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreateStoryNpcBattleResponse { pub npc_interaction: StoryNpcInteractionPayload, pub battle_state: StoryBattleStatePayload, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn continue_story_request_uses_camel_case_fields() { let payload = serde_json::to_value(ContinueStoryRequest { story_session_id: "storysess_1".to_string(), narrative_text: "继续前进".to_string(), choice_function_id: Some("npc_chat".to_string()), }) .expect("payload should serialize"); assert_eq!( payload, json!({ "storySessionId": "storysess_1", "narrativeText": "继续前进", "choiceFunctionId": "npc_chat" }) ); } #[test] fn story_session_mutation_response_uses_camel_case_fields() { let payload = serde_json::to_value(StorySessionMutationResponse { story_session: StorySessionPayload { story_session_id: "storysess_1".to_string(), runtime_session_id: "runtime_1".to_string(), actor_user_id: "user_1".to_string(), world_profile_id: "profile_1".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "篝火正在燃烧。".to_string(), latest_choice_function_id: Some("talk".to_string()), status: "active".to_string(), version: 1, created_at: "1.000000Z".to_string(), updated_at: "1.000000Z".to_string(), }, story_event: StoryEventPayload { event_id: "storyevt_1".to_string(), story_session_id: "storysess_1".to_string(), event_kind: "session_started".to_string(), narrative_text: "篝火正在燃烧。".to_string(), choice_function_id: Some("talk".to_string()), created_at: "1.000000Z".to_string(), }, }) .expect("payload should serialize"); assert_eq!( payload["storySession"]["storySessionId"], json!("storysess_1") ); assert_eq!(payload["storyEvent"]["eventKind"], json!("session_started")); assert_eq!(payload["storyEvent"]["choiceFunctionId"], json!("talk")); } #[test] fn story_session_state_response_uses_camel_case_fields() { let payload = serde_json::to_value(StorySessionStateResponse { story_session: StorySessionPayload { story_session_id: "storysess_1".to_string(), runtime_session_id: "runtime_1".to_string(), actor_user_id: "user_1".to_string(), world_profile_id: "profile_1".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "你看见篝火边有人招手。".to_string(), latest_choice_function_id: Some("talk_to_npc".to_string()), status: "active".to_string(), version: 2, created_at: "1.000000Z".to_string(), updated_at: "2.000000Z".to_string(), }, story_events: vec![StoryEventPayload { event_id: "storyevt_2".to_string(), story_session_id: "storysess_1".to_string(), event_kind: "story_continued".to_string(), narrative_text: "你看见篝火边有人招手。".to_string(), choice_function_id: Some("talk_to_npc".to_string()), created_at: "2.000000Z".to_string(), }], }) .expect("payload should serialize"); assert_eq!( payload["storySession"]["latestChoiceFunctionId"], json!("talk_to_npc") ); assert_eq!( payload["storyEvents"][0]["eventKind"], json!("story_continued") ); } #[test] fn story_runtime_projection_response_uses_new_story_runtime_contract() { let payload = serde_json::to_value(StoryRuntimeProjectionResponse { story_session: StorySessionPayload { story_session_id: "storysess_1".to_string(), runtime_session_id: "runtime_1".to_string(), actor_user_id: "user_1".to_string(), world_profile_id: "profile_1".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "你看见篝火边有人招手。".to_string(), latest_choice_function_id: Some("talk_to_npc".to_string()), status: "active".to_string(), version: 2, created_at: "1.000000Z".to_string(), updated_at: "2.000000Z".to_string(), }, story_events: vec![StoryEventPayload { event_id: "storyevt_2".to_string(), story_session_id: "storysess_1".to_string(), event_kind: "story_continued".to_string(), narrative_text: "你看见篝火边有人招手。".to_string(), choice_function_id: Some("talk_to_npc".to_string()), created_at: "2.000000Z".to_string(), }], server_version: 2, actor: StoryRuntimeActorProjection { hp: 32, max_hp: 40, mana: 18, max_mana: 20, currency: 80, currency_text: "80 铜钱".to_string(), }, inventory: StoryRuntimeInventoryProjection { backpack_items: vec![json!({ "id": "potion-1", "name": "疗伤药" })], equipment_slots: vec![json!({ "slotId": "weapon", "label": "武器" })], forge_recipes: Vec::new(), }, options: vec![StoryRuntimeOptionProjection { function_id: "npc_chat".to_string(), action_text: "继续交谈".to_string(), detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), scope: "npc".to_string(), payload: Some(json!({ "npcId": "npc_camp_firekeeper" })), enabled: true, reason: None, }], status: StoryRuntimeStatusProjection { in_battle: false, npc_interaction_active: true, current_encounter_id: Some("npc_camp_firekeeper".to_string()), current_npc_battle_mode: None, current_npc_battle_outcome: None, }, current_narrative_text: Some("守火人示意你继续说。".to_string()), action_result_text: None, toast: None, }) .expect("payload should serialize"); assert_eq!( payload["storySession"]["storySessionId"], json!("storysess_1") ); assert_eq!(payload["serverVersion"], json!(2)); assert_eq!(payload["actor"]["maxHp"], json!(40)); assert_eq!( payload["inventory"]["backpackItems"][0]["name"], json!("疗伤药") ); assert_eq!(payload["options"][0]["functionId"], json!("npc_chat")); assert!(payload.get("snapshot").is_none()); assert!(payload.get("viewModel").is_none()); assert!(payload.get("presentation").is_none()); } #[test] fn story_battle_responses_use_story_contract_shape() { let battle_state = StoryBattleStatePayload { battle_state_id: "battle_1".to_string(), story_session_id: "storysess_1".to_string(), runtime_session_id: "runtime_1".to_string(), actor_user_id: "user_1".to_string(), chapter_id: None, target_npc_id: "npc_wolf".to_string(), target_name: "黑爪狼".to_string(), battle_mode: "fight".to_string(), status: "active".to_string(), player_hp: 28, player_max_hp: 40, player_mana: 12, player_max_mana: 20, target_hp: 18, target_max_hp: 30, experience_reward: 12, reward_items: vec![StoryBattleRewardItemPayload { item_id: "wolf-fang".to_string(), category: "material".to_string(), item_name: "狼牙".to_string(), description: None, quantity: 1, rarity: "common".to_string(), tags: vec!["beast".to_string()], stackable: true, stack_key: "wolf-fang".to_string(), equipment_slot_id: None, }], turn_index: 1, last_action_function_id: Some("battle_attack_basic".to_string()), last_action_text: Some("普通攻击".to_string()), last_result_text: Some("你击中了黑爪狼。".to_string()), last_damage_dealt: 10, last_damage_taken: 3, last_outcome: "ongoing".to_string(), version: 2, created_at: "1.000000Z".to_string(), updated_at: "2.000000Z".to_string(), }; let payload = serde_json::to_value(ResolveStoryBattleResponse { battle_state, combat: StoryCombatActionPayload { damage_dealt: 10, damage_taken: 3, outcome: "ongoing".to_string(), }, }) .expect("payload should serialize"); assert_eq!(payload["battleState"]["battleStateId"], json!("battle_1")); assert_eq!( payload["battleState"]["rewardItems"][0]["itemName"], json!("狼牙") ); assert_eq!(payload["combat"]["damageDealt"], json!(10)); assert!(payload["battleState"].get("chapterId").is_none()); } }