use serde_json::{Value, json}; use shared_contracts::runtime_story::{ RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryEquipmentSlotView, RuntimeStoryForgeRecipeView, RuntimeStoryForgeRequirementView, RuntimeStoryInventoryActionView, RuntimeStoryInventoryItemActionsView, RuntimeStoryInventoryItemView, RuntimeStoryInventoryViewModel, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel, RuntimeStoryStatusViewModel, RuntimeStoryViewModel, }; use crate::{ battle::inventory_item_has_usable_effect, build_runtime_npc_interaction_view, equipment_slot_label, read_array_field, read_bool_field, read_field, read_i32_field, read_object_field, read_optional_string_field, read_player_equipment_item, read_player_inventory_values, read_required_string_field, remove_inventory_item_from_list, resolve_equipment_slot_for_item, }; use super::forge::{ apply_forge_requirements_if_possible, count_matching_forge_requirement, forge_recipe_definitions, format_currency_text, reforge_cost_definition, }; /// 运行时故事 view-model 只依赖快照 JSON 与共享 contract,可脱离 HTTP 层独立编译。 pub fn build_runtime_story_view_model( game_state: &Value, options: &[RuntimeStoryOptionView], ) -> RuntimeStoryViewModel { RuntimeStoryViewModel { player: RuntimeStoryPlayerViewModel { hp: read_i32_field(game_state, "playerHp").unwrap_or(0), max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1), mana: read_i32_field(game_state, "playerMana").unwrap_or(0), max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1), }, encounter: build_runtime_story_encounter(game_state), companions: build_runtime_story_companions(game_state), inventory: build_runtime_story_inventory(game_state), available_options: options.to_vec(), status: RuntimeStoryStatusViewModel { in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false), npc_interaction_active: read_bool_field(game_state, "npcInteractionActive") .unwrap_or(false), current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), current_npc_battle_outcome: read_optional_string_field( game_state, "currentNpcBattleOutcome", ), }, npc_interaction: build_runtime_npc_interaction_view(game_state), } } pub fn build_runtime_story_inventory(game_state: &Value) -> RuntimeStoryInventoryViewModel { let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0); let world_type = read_optional_string_field(game_state, "worldType"); let in_battle = read_bool_field(game_state, "inBattle").unwrap_or(false); let inventory_items = read_player_inventory_values(game_state); RuntimeStoryInventoryViewModel { player_currency, currency_text: format_currency_text(player_currency, world_type.as_deref()), in_battle, backpack_items: inventory_items .iter() .map(|item| build_inventory_item_view(game_state, item)) .collect(), equipment_slots: ["weapon", "armor", "relic"] .into_iter() .map(|slot_id| build_equipment_slot_view(game_state, slot_id)) .collect(), forge_recipes: forge_recipe_definitions() .into_iter() .map(|recipe| { let requirements = recipe .requirements .iter() .map(|requirement| RuntimeStoryForgeRequirementView { id: requirement.id.to_string(), label: requirement.label.to_string(), quantity: requirement.quantity, owned: count_matching_forge_requirement( inventory_items.as_slice(), requirement, ), }) .collect::>(); let disabled_reason = forge_recipe_disabled_reason( game_state, player_currency, requirements.as_slice(), recipe.currency_cost, ); let can_craft = disabled_reason.is_none(); RuntimeStoryForgeRecipeView { id: recipe.id.to_string(), name: recipe.name.to_string(), kind: recipe.kind.to_string(), description: recipe.description.to_string(), result_label: recipe.result_label.to_string(), currency_cost: recipe.currency_cost, currency_text: format_currency_text( recipe.currency_cost, world_type.as_deref(), ), requirements, can_craft, disabled_reason: disabled_reason.clone(), action: build_inventory_action( "forge_craft", format!("制作{}", recipe.result_label), Some(json!({ "recipeId": recipe.id })), can_craft, disabled_reason, ), } }) .collect(), } } fn build_inventory_item_view(game_state: &Value, item: &Value) -> RuntimeStoryInventoryItemView { RuntimeStoryInventoryItemView { item: item.clone(), actions: RuntimeStoryInventoryItemActionsView { use_item: build_use_item_action(game_state, item), equip: build_equip_item_action(game_state, item), dismantle: build_dismantle_item_action(game_state, item), reforge: build_reforge_item_action(game_state, item), }, } } fn build_equipment_slot_view(game_state: &Value, slot_id: &str) -> RuntimeStoryEquipmentSlotView { let item = read_player_equipment_item(game_state, slot_id); let item_name = item .as_ref() .and_then(|value| read_optional_string_field(value, "name")) .unwrap_or_else(|| equipment_slot_label(slot_id).to_string()); let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { item.is_none() .then(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id))) }); let enabled = disabled_reason.is_none(); RuntimeStoryEquipmentSlotView { slot_id: slot_id.to_string(), label: equipment_slot_label(slot_id).to_string(), item, unequip: build_inventory_action( "equipment_unequip", format!("卸下{item_name}"), Some(json!({ "slotId": slot_id })), enabled, disabled_reason, ), } } fn build_use_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView { let item_id = read_optional_string_field(item, "id"); let item_name = read_item_name(item); let disabled_reason = if read_field(game_state, "playerCharacter").is_none() { Some("缺少玩家角色,无法使用物品。".to_string()) } else if !read_bool_field(game_state, "inBattle").unwrap_or(false) { Some("当前物品使用需要在战斗动作中结算。".to_string()) } else if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { Some("物品数量不足。".to_string()) } else if !inventory_item_has_usable_effect(item) { Some("该物品当前没有可直接使用的效果。".to_string()) } else { None }; let enabled = disabled_reason.is_none(); build_inventory_action( "inventory_use", format!("使用{item_name}"), item_id.map(|item_id| json!({ "itemId": item_id })), enabled, disabled_reason, ) } fn build_equip_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView { let item_id = read_optional_string_field(item, "id"); let item_name = read_item_name(item); let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { Some("物品数量不足。".to_string()) } else if resolve_equipment_slot_for_item(item).is_none() { Some("该物品不能装备。".to_string()) } else { None } }); let enabled = disabled_reason.is_none(); build_inventory_action( "equipment_equip", format!("装备{item_name}"), item_id.map(|item_id| json!({ "itemId": item_id })), enabled, disabled_reason, ) } fn build_dismantle_item_action( game_state: &Value, item: &Value, ) -> RuntimeStoryInventoryActionView { let item_id = read_optional_string_field(item, "id"); let item_name = read_item_name(item); let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { Some("物品数量不足。".to_string()) } else if resolve_equipment_slot_for_item(item).is_none() && read_field(item, "buildProfile").is_none() { Some("该物品不能拆解。".to_string()) } else { None } }); let enabled = disabled_reason.is_none(); build_inventory_action( "forge_dismantle", format!("拆解{item_name}"), item_id.map(|item_id| json!({ "itemId": item_id })), enabled, disabled_reason, ) } fn build_reforge_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView { let item_id = read_optional_string_field(item, "id"); let item_name = read_item_name(item); let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| { let Some(slot_id) = resolve_equipment_slot_for_item(item) else { return Some("该物品不能重铸。".to_string()); }; if read_i32_field(item, "quantity").unwrap_or(0) <= 0 { return Some("物品数量不足。".to_string()); } if read_field(item, "buildProfile").is_none() { return Some("该物品不能重铸。".to_string()); } let cost = reforge_cost_definition(Some(slot_id)); let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0); if player_currency < cost.currency_cost { return Some("货币不足。".to_string()); } let Some(item_id) = read_optional_string_field(item, "id") else { return Some("目标物品缺少 id。".to_string()); }; let base_inventory = remove_inventory_item_from_list( read_player_inventory_values(game_state), item_id.as_str(), 1, ); if apply_forge_requirements_if_possible( base_inventory.as_slice(), cost.requirements.as_slice(), ) .is_none() { return Some("材料不足。".to_string()); } None }); let enabled = disabled_reason.is_none(); build_inventory_action( "forge_reforge", format!("重铸{item_name}"), item_id.map(|item_id| json!({ "itemId": item_id })), enabled, disabled_reason, ) } fn forge_recipe_disabled_reason( game_state: &Value, player_currency: i32, requirements: &[RuntimeStoryForgeRequirementView], currency_cost: i32, ) -> Option { inventory_non_battle_gate_reason(game_state).or_else(|| { if player_currency < currency_cost { Some("货币不足。".to_string()) } else if requirements .iter() .any(|requirement| requirement.owned < requirement.quantity) { Some("材料不足。".to_string()) } else { None } }) } fn inventory_non_battle_gate_reason(game_state: &Value) -> Option { if read_field(game_state, "playerCharacter").is_none() { return Some("缺少玩家角色,无法操作背包。".to_string()); } if read_bool_field(game_state, "inBattle").unwrap_or(false) { return Some("战斗中无法执行该操作。".to_string()); } None } fn build_inventory_action( function_id: &str, action_text: String, payload: Option, enabled: bool, reason: Option, ) -> RuntimeStoryInventoryActionView { RuntimeStoryInventoryActionView { function_id: function_id.to_string(), action_text, payload, enabled, reason: if enabled { None } else { reason }, } } fn read_item_name(item: &Value) -> String { read_optional_string_field(item, "name") .or_else(|| read_optional_string_field(item, "id")) .unwrap_or_else(|| "未命名物品".to_string()) } pub fn build_runtime_story_companions(game_state: &Value) -> Vec { read_array_field(game_state, "companions") .into_iter() .filter_map(|entry| { let npc_id = read_required_string_field(entry, "npcId")?; Some(RuntimeStoryCompanionViewModel { npc_id, character_id: read_optional_string_field(entry, "characterId"), joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0), }) }) .collect() } pub fn build_runtime_story_encounter(game_state: &Value) -> Option { let encounter = read_object_field(game_state, "currentEncounter")?; let npc_name = read_required_string_field(encounter, "npcName") .or_else(|| read_required_string_field(encounter, "name")) .unwrap_or_else(|| "当前遭遇".to_string()); let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name); Some(RuntimeStoryEncounterViewModel { id: encounter_id, kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()), npc_name, hostile: read_bool_field(encounter, "hostile").unwrap_or(false), affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")), recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")), interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false), battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"), }) } pub fn resolve_current_encounter_npc_state<'a>( game_state: &'a Value, encounter_id: &str, npc_name: &str, ) -> Option<&'a Value> { let npc_states = read_object_field(game_state, "npcStates")?; npc_states .get(encounter_id) .or_else(|| npc_states.get(npc_name)) } #[cfg(test)] mod tests { use super::*; fn base_game_state() -> Value { json!({ "worldType": "WUXIA", "playerCharacter": { "id": "hero-1", "name": "沈砺" }, "playerCurrency": 90, "playerInventory": [ { "id": "scrap-a", "category": "材料", "name": "旧铜片", "quantity": 2, "rarity": "common", "tags": ["material", "工巧"] }, { "id": "scrap-b", "category": "材料", "name": "风化铁片", "quantity": 1, "rarity": "common", "tags": ["material", "守御"] }, { "id": "duelist-blade", "category": "武器", "name": "百炼追风剑", "quantity": 1, "rarity": "epic", "tags": ["weapon", "快剑", "突进"], "equipmentSlotId": "weapon", "buildProfile": { "role": "快剑", "tags": ["快剑", "突进"], "forgeRank": 1 } }, { "id": "refined-ingot", "category": "材料", "name": "精炼锭材", "quantity": 1, "rarity": "rare", "tags": ["material", "工巧", "守御"] } ], "playerEquipment": { "weapon": null, "armor": null, "relic": null }, "inBattle": false, "npcInteractionActive": false, "companions": [] }) } #[test] fn inventory_view_compiles_forge_recipe_availability_on_server() { let view = build_runtime_story_inventory(&base_game_state()); let refined = view .forge_recipes .iter() .find(|recipe| recipe.id == "synthesis-refined-ingot") .expect("refined ingot recipe should exist"); assert!(refined.can_craft); assert_eq!(refined.requirements[0].owned, 4); assert!(refined.action.enabled); let blade = view .backpack_items .iter() .find(|item| { read_optional_string_field(&item.item, "id").as_deref() == Some("duelist-blade") }) .expect("blade item view should exist"); assert!(blade.actions.equip.enabled); assert!(blade.actions.dismantle.enabled); assert!(blade.actions.reforge.enabled); assert!(!blade.actions.use_item.enabled); } #[test] fn inventory_view_reports_disabled_reasons_for_locked_actions() { let mut state = base_game_state(); state .as_object_mut() .expect("state should be object") .insert("inBattle".to_string(), Value::Bool(true)); let view = build_runtime_story_inventory(&state); let refined = view .forge_recipes .iter() .find(|recipe| recipe.id == "synthesis-refined-ingot") .expect("recipe should exist"); assert!(!refined.can_craft); assert_eq!( refined.disabled_reason.as_deref(), Some("战斗中无法执行该操作。") ); let weapon_slot = view .equipment_slots .iter() .find(|slot| slot.slot_id == "weapon") .expect("weapon slot should exist"); assert!(!weapon_slot.unequip.enabled); assert_eq!( weapon_slot.unequip.reason.as_deref(), Some("战斗中无法执行该操作。") ); } }