use serde_json::{Map, Value, json}; use crate::{ ensure_json_object, first_hostile_npc_string_field, read_array_field, read_bool_field, read_field, read_i32_field, read_object_field, read_optional_string_field, write_i32_field, }; /// 这批 helper 只负责 runtime story 的纯快照读写。 /// /// 目标是先把 encounter / inventory / equipment 的基础状态工具从 `api-server` /// 边界模块里收口出来,后续 battle / forge / equipment 规则迁移时直接复用。 pub fn ensure_inventory_action_available( game_state: &Value, missing_character_message: &str, battle_locked_message: &str, ) -> Result<(), String> { if read_field(game_state, "playerCharacter").is_none() { return Err(missing_character_message.to_string()); } if read_bool_field(game_state, "inBattle").unwrap_or(false) { return Err(battle_locked_message.to_string()); } Ok(()) } pub fn battle_mode_text(value: &str) -> &'static str { if value == "spar" { "切磋" } else { "战斗" } } pub fn current_encounter_name(game_state: &Value) -> String { read_object_field(game_state, "currentEncounter") .and_then(|encounter| { read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) }) .unwrap_or_else(|| "对方".to_string()) } pub fn current_encounter_name_from_battle(game_state: &Value) -> String { read_object_field(game_state, "currentEncounter") .and_then(|encounter| { read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) }) .or_else(|| first_hostile_npc_string_field(game_state, "name")) .unwrap_or_else(|| "眼前的敌人".to_string()) } pub fn current_encounter_id(game_state: &Value) -> Option { read_object_field(game_state, "currentEncounter") .and_then(|encounter| read_optional_string_field(encounter, "id")) } pub fn find_player_inventory_entry<'a>(game_state: &'a Value, item_id: &str) -> Option<&'a Value> { read_array_field(game_state, "playerInventory") .into_iter() .find(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) } pub fn read_player_inventory_values(game_state: &Value) -> Vec { read_array_field(game_state, "playerInventory") .into_iter() .cloned() .collect() } pub fn write_player_inventory_values(game_state: &mut Value, items: Vec) { ensure_json_object(game_state).insert("playerInventory".to_string(), Value::Array(items)); } pub fn read_inventory_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 has_giftable_player_inventory(game_state: &Value) -> bool { read_array_field(game_state, "playerInventory") .into_iter() .any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0) } pub fn clone_inventory_item_with_quantity(item: &Value, quantity: i32) -> Value { let mut next_item = item.clone(); if let Some(entry) = next_item.as_object_mut() { entry.insert("quantity".to_string(), json!(quantity.max(1))); } next_item } pub fn normalize_equipped_item(item: &Value) -> Value { clone_inventory_item_with_quantity(item, 1) } pub fn add_inventory_items_to_list(mut base: Vec, additions: Vec) -> Vec { for addition in additions { let add_id = read_optional_string_field(&addition, "id"); let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1); if let Some(add_id) = add_id { if let Some(existing) = base.iter_mut().find(|item| { read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()) }) { let next_quantity = read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity; if let Some(existing_object) = existing.as_object_mut() { existing_object.insert("quantity".to_string(), json!(next_quantity)); } continue; } } base.push(addition); } base } pub fn remove_inventory_item_from_list( mut base: Vec, item_id: &str, quantity: i32, ) -> Vec { if quantity <= 0 { return base; } let Some(index) = base .iter() .position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id)) else { return base; }; let current_quantity = read_i32_field(&base[index], "quantity").unwrap_or(0).max(0); let next_quantity = current_quantity - quantity; if next_quantity <= 0 { base.remove(index); return base; } if let Some(item) = base[index].as_object_mut() { item.insert("quantity".to_string(), json!(next_quantity)); } base } pub fn read_player_equipment_item(game_state: &Value, slot_id: &str) -> Option { read_field(game_state, "playerEquipment") .and_then(|equipment| read_field(equipment, slot_id)) .filter(|item| !item.is_null()) .cloned() } pub fn write_player_equipment_item(game_state: &mut Value, slot_id: &str, item: Option) { let root = ensure_json_object(game_state); let equipment = root .entry("playerEquipment".to_string()) .or_insert_with(|| { json!({ "weapon": null, "armor": null, "relic": null, }) }); if !equipment.is_object() { *equipment = json!({ "weapon": null, "armor": null, "relic": null, }); } equipment .as_object_mut() .expect("playerEquipment should be object") .insert(slot_id.to_string(), item.unwrap_or(Value::Null)); } pub fn equipment_slot_label(slot_id: &str) -> &'static str { match slot_id { "weapon" => "武器", "armor" => "护甲", "relic" => "饰品", _ => "装备", } } pub fn normalize_equipment_slot_id(slot_id: &str) -> Option<&'static str> { let normalized = slot_id.trim().to_ascii_lowercase(); match normalized.as_str() { "weapon" => Some("weapon"), "armor" => Some("armor"), "relic" | "accessory" => Some("relic"), _ => { // 支持历史 payload 里直接传中文槽位名或物品类别文案的情况。 if slot_id.contains("武器") || slot_id.contains('剑') || slot_id.contains('弓') || slot_id.contains('刀') || slot_id.contains("拳套") || slot_id.contains("战刃") || slot_id.contains('枪') || slot_id.contains('刃') { return Some("weapon"); } if slot_id.contains("护甲") || slot_id.contains('甲') || slot_id.contains("护臂") || slot_id.contains('衣') || slot_id.contains('袍') || slot_id.contains('铠') { return Some("armor"); } if slot_id.contains("饰品") || slot_id.contains("护符") || slot_id.contains("徽章") || slot_id.contains('玉') || slot_id.contains('珠') || slot_id.contains('坠') || slot_id.contains('铃') || slot_id.contains('盘') || slot_id.contains('令') || slot_id.contains('匣') { return Some("relic"); } None } } } pub fn resolve_equipment_slot_for_item(item: &Value) -> Option<&'static str> { if let Some(slot_id) = read_optional_string_field(item, "equipmentSlotId") { return match slot_id.as_str() { "weapon" => Some("weapon"), "armor" => Some("armor"), "relic" => Some("relic"), _ => None, }; } let tags = read_array_field(item, "tags") .into_iter() .filter_map(|tag| tag.as_str().map(|value| value.to_string())) .collect::>(); if tags.iter().any(|tag| tag == "weapon") { return Some("weapon"); } if tags.iter().any(|tag| tag == "armor") { return Some("armor"); } if tags.iter().any(|tag| tag == "relic") { return Some("relic"); } let category_text = read_optional_string_field(item, "category").unwrap_or_default(); let name_text = read_inventory_item_name(item); let mixed_text = format!("{category_text} {name_text}"); if mixed_text.contains("武器") || mixed_text.contains("剑") || mixed_text.contains("刀") { return Some("weapon"); } if mixed_text.contains("护甲") || mixed_text.contains("甲") || mixed_text.contains("袍") { return Some("armor"); } if mixed_text.contains("饰品") || mixed_text.contains("护符") || mixed_text.contains("玉") { return Some("relic"); } None } pub fn item_rarity_key(item: &Value) -> String { read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string()) } pub fn equipment_bonus_fallbacks(slot_id: &str, rarity: &str) -> (i32, i32, f64, f64) { match slot_id { "weapon" => { let outgoing = match rarity { "uncommon" => 0.10, "rare" => 0.14, "epic" => 0.20, "legendary" => 0.28, _ => 0.06, }; (0, 0, outgoing, 1.0) } "armor" => { let hp = match rarity { "uncommon" => 22, "rare" => 32, "epic" => 44, "legendary" => 58, _ => 14, }; let incoming = match rarity { "uncommon" => 0.94, "rare" => 0.90, "epic" => 0.86, "legendary" => 0.80, _ => 0.97, }; (hp, 0, 0.0, incoming) } _ => { let mana = match rarity { "uncommon" => 18, "rare" => 28, "epic" => 40, "legendary" => 54, _ => 10, }; let outgoing = match rarity { "uncommon" => 0.04, "rare" => 0.06, "epic" => 0.09, "legendary" => 0.12, _ => 0.02, }; (0, mana, outgoing, 1.0) } } } pub fn equipment_item_bonuses(item: &Value, slot_id: &str) -> (i32, i32, f64, f64) { let rarity = item_rarity_key(item); let fallback = equipment_bonus_fallbacks(slot_id, rarity.as_str()); let stat_profile = read_field(item, "statProfile"); let hp_bonus = stat_profile .and_then(|profile| read_i32_field(profile, "maxHpBonus")) .unwrap_or(fallback.0); let mana_bonus = stat_profile .and_then(|profile| read_i32_field(profile, "maxManaBonus")) .unwrap_or(fallback.1); let outgoing_bonus = stat_profile .and_then(|profile| read_field(profile, "outgoingDamageBonus")) .and_then(Value::as_f64) .unwrap_or(fallback.2); let incoming_multiplier = stat_profile .and_then(|profile| read_field(profile, "incomingDamageMultiplier")) .and_then(Value::as_f64) .unwrap_or(fallback.3); (hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier) } pub fn read_equipment_total_bonuses(game_state: &Value) -> (i32, i32, f64, f64) { let equipment = read_field(game_state, "playerEquipment"); let mut hp_bonus = 0; let mut mana_bonus = 0; let mut outgoing_bonus = 0.0; let mut incoming_multiplier = 1.0; for slot_id in ["weapon", "armor", "relic"] { let Some(item) = equipment.and_then(|value| read_field(value, slot_id)) else { continue; }; if item.is_null() { continue; } let (slot_hp, slot_mana, slot_outgoing, slot_incoming) = equipment_item_bonuses(item, slot_id); hp_bonus += slot_hp; mana_bonus += slot_mana; outgoing_bonus += slot_outgoing; incoming_multiplier *= slot_incoming; } (hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier) } pub fn apply_equipment_loadout_to_state(game_state: &mut Value) { let (hp_bonus, mana_bonus, _outgoing_bonus, _incoming_multiplier) = read_equipment_total_bonuses(game_state); let current_max_hp = read_i32_field(game_state, "playerMaxHp") .unwrap_or(1) .max(1); let current_max_mana = read_i32_field(game_state, "playerMaxMana") .unwrap_or(1) .max(1); let current_hp = read_i32_field(game_state, "playerHp").unwrap_or(current_max_hp); let base_max_hp = current_max_hp .saturating_sub(read_runtime_equipment_bonus_cache(game_state, "maxHpBonus")) .max(1); let base_max_mana = current_max_mana .saturating_sub(read_runtime_equipment_bonus_cache( game_state, "maxManaBonus", )) .max(1); let next_max_hp = base_max_hp.saturating_add(hp_bonus).max(1); let next_max_mana = base_max_mana.saturating_add(mana_bonus).max(1); write_i32_field(game_state, "playerMaxHp", next_max_hp); write_i32_field(game_state, "playerHp", current_hp.min(next_max_hp)); write_i32_field(game_state, "playerMaxMana", next_max_mana); write_i32_field(game_state, "playerMana", next_max_mana); write_runtime_equipment_bonus_cache(game_state, "maxHpBonus", hp_bonus); write_runtime_equipment_bonus_cache(game_state, "maxManaBonus", mana_bonus); } pub fn read_runtime_equipment_bonus_cache(game_state: &Value, key: &str) -> i32 { read_field(game_state, "runtimeEquipmentBonusCache") .and_then(|cache| read_i32_field(cache, key)) .unwrap_or(0) } pub fn write_runtime_equipment_bonus_cache(game_state: &mut Value, key: &str, value: i32) { let root = ensure_json_object(game_state); let cache = root .entry("runtimeEquipmentBonusCache".to_string()) .or_insert_with(|| Value::Object(Map::new())); if !cache.is_object() { *cache = Value::Object(Map::new()); } cache .as_object_mut() .expect("runtimeEquipmentBonusCache should be object") .insert(key.to_string(), json!(value)); } pub fn build_current_build_toast(game_state: &Value) -> String { let (_hp_bonus, _mana_bonus, outgoing_bonus, _incoming_multiplier) = read_equipment_total_bonuses(game_state); let build_multiplier = (1.0 + outgoing_bonus).max(1.0); format!("当前 Build 倍率 x{build_multiplier:.2}") }