use serde_json::{Value, to_value}; use shared_contracts::{ runtime_story::RuntimeStoryOptionView, story::{ StoryEventPayload, StoryRuntimeActorProjection, StoryRuntimeInventoryProjection, StoryRuntimeOptionProjection, StoryRuntimeProjectionResponse, StoryRuntimeStatusProjection, StorySessionPayload, }, }; use crate::{ current_encounter_id, read_bool_field, read_i32_field, read_optional_string_field, view_model::build_runtime_story_inventory, }; pub struct StoryRuntimeProjectionSource { pub story_session: StorySessionPayload, pub story_events: Vec, pub game_state: Value, pub options: Vec, pub server_version: u32, pub current_narrative_text: Option, pub action_result_text: Option, pub toast: Option, } /// 将领域快照折成前端可直接消费的新 story runtime 投影。 pub fn build_story_runtime_projection( source: StoryRuntimeProjectionSource, ) -> StoryRuntimeProjectionResponse { let inventory = build_runtime_story_inventory(&source.game_state); StoryRuntimeProjectionResponse { story_session: source.story_session, story_events: source.story_events, server_version: source.server_version, game_state: source.game_state.clone(), actor: StoryRuntimeActorProjection { hp: read_i32_field(&source.game_state, "playerHp").unwrap_or(0), max_hp: read_i32_field(&source.game_state, "playerMaxHp").unwrap_or(1), mana: read_i32_field(&source.game_state, "playerMana").unwrap_or(0), max_mana: read_i32_field(&source.game_state, "playerMaxMana").unwrap_or(1), currency: inventory.player_currency, currency_text: inventory.currency_text.clone(), }, inventory: StoryRuntimeInventoryProjection { backpack_items: inventory .backpack_items .into_iter() .map(|item| to_value(item).expect("runtime inventory item should serialize")) .collect(), equipment_slots: inventory .equipment_slots .into_iter() .map(|slot| to_value(slot).expect("runtime equipment slot should serialize")) .collect(), forge_recipes: inventory .forge_recipes .into_iter() .map(|recipe| to_value(recipe).expect("runtime forge recipe should serialize")) .collect(), }, options: source .options .into_iter() .map(build_story_runtime_option_projection) .collect(), status: StoryRuntimeStatusProjection { in_battle: read_bool_field(&source.game_state, "inBattle").unwrap_or(false), npc_interaction_active: read_bool_field(&source.game_state, "npcInteractionActive") .unwrap_or(false), current_encounter_id: current_encounter_id(&source.game_state), current_npc_battle_mode: read_optional_string_field( &source.game_state, "currentNpcBattleMode", ), current_npc_battle_outcome: read_optional_string_field( &source.game_state, "currentNpcBattleOutcome", ), }, current_narrative_text: source.current_narrative_text, action_result_text: source.action_result_text, toast: source.toast, } } fn build_story_runtime_option_projection( option: RuntimeStoryOptionView, ) -> StoryRuntimeOptionProjection { let disabled = option.disabled.unwrap_or(false); StoryRuntimeOptionProjection { function_id: option.function_id, action_text: option.action_text, detail_text: option.detail_text, scope: option.scope, payload: option.payload, enabled: !disabled, reason: option.reason, } } #[cfg(test)] mod tests { use serde_json::json; use super::*; fn story_session() -> StorySessionPayload { 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("npc_chat".to_string()), status: "active".to_string(), version: 3, created_at: "1.000000Z".to_string(), updated_at: "3.000000Z".to_string(), } } #[test] fn projection_builds_frontend_ready_story_runtime_shape() { let projection = build_story_runtime_projection(StoryRuntimeProjectionSource { story_session: story_session(), story_events: vec![StoryEventPayload { event_id: "storyevt_1".to_string(), story_session_id: "storysess_1".to_string(), event_kind: "story_continued".to_string(), narrative_text: "篝火仍然亮着。".to_string(), choice_function_id: Some("npc_chat".to_string()), created_at: "3.000000Z".to_string(), }], game_state: json!({ "worldType": "WUXIA", "playerCharacter": { "id": "hero-1", "name": "沈砺" }, "playerHp": 28, "playerMaxHp": 40, "playerMana": 12, "playerMaxMana": 20, "playerCurrency": 80, "playerInventory": [{ "id": "potion-1", "category": "消耗品", "name": "疗伤药", "quantity": 2, "rarity": "common", "tags": ["healing"] }], "playerEquipment": { "weapon": null, "armor": null, "relic": null }, "currentEncounter": { "id": "npc_firekeeper", "npcName": "守火人" }, "inBattle": false, "npcInteractionActive": true }), options: vec![RuntimeStoryOptionView { function_id: "npc_chat".to_string(), action_text: "继续交谈".to_string(), detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), scope: "npc".to_string(), interaction: None, payload: Some(json!({ "npcId": "npc_firekeeper" })), disabled: None, reason: None, }], server_version: 3, current_narrative_text: Some("守火人示意你继续说。".to_string()), action_result_text: None, toast: Some("关系有所变化。".to_string()), }); assert_eq!(projection.story_session.story_session_id, "storysess_1"); assert_eq!(projection.game_state["worldType"], json!("WUXIA")); assert_eq!(projection.actor.hp, 28); assert_eq!(projection.actor.currency_text, "80 铜钱"); assert_eq!(projection.inventory.backpack_items.len(), 1); assert_eq!(projection.options[0].function_id, "npc_chat"); assert!(projection.options[0].enabled); assert_eq!( projection.status.current_encounter_id.as_deref(), Some("npc_firekeeper") ); assert_eq!(projection.toast.as_deref(), Some("关系有所变化。")); } }