use serde_json::{Map, Value, json}; use shared_contracts::runtime_story::{ RuntimeNpcGiftItemView, RuntimeNpcGiftView, RuntimeNpcInteractionView, RuntimeNpcTradeItemView, RuntimeNpcTradeView, }; use crate::{ MAX_TASK5_COMPANIONS, ensure_json_object, item_rarity_key, normalize_required_string, read_array_field, read_bool_field, read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, read_required_string_field, }; pub fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 { let rarity_score = match item_rarity_key(item).as_str() { "legendary" => 5, "epic" => 4, "rare" => 3, "uncommon" => 2, _ => 1, }; let tags = read_array_field(item, "tags") .into_iter() .filter_map(|tag| tag.as_str().map(|value| value.to_string())) .collect::>(); let mana_bonus = if tags.iter().any(|tag| tag == "mana") { 3 } else { 0 }; let healing_bonus = if tags.iter().any(|tag| tag == "healing") { 3 } else { 0 }; (4 + rarity_score * 3 + mana_bonus + healing_bonus).min(24) } pub fn build_npc_gift_result_text( npc_name: &str, item: &Value, affinity_gain: i32, next_affinity: i32, ) -> String { let shift_text = if affinity_gain >= 12 { "态度一下子软化了许多" } else if affinity_gain >= 8 { "态度明显和缓下来" } else if affinity_gain >= 5 { "态度比先前亲近了一些" } else { "态度略微放松了些" }; let affinity_text = if next_affinity >= 90 { "对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。" } else if next_affinity >= 60 { "对你已经建立起稳固信任,愿意进一步合作。" } else if next_affinity >= 30 { "对你的态度明显友善了许多,也更愿意正常交流。" } else if next_affinity >= 15 { "戒备开始松动,愿意试探性地配合你的节奏。" } else if next_affinity >= 0 { "仍保持明显距离,只会给出谨慎而有限的回应。" } else { "关系已经降到冰点,对你几乎不再保留善意。" }; format!( "{}收下了{},{}。{}", npc_name, read_inventory_item_name(item), shift_text, affinity_text ) } fn inventory_item_value(item: &Value) -> i32 { if let Some(explicit_value) = read_i32_field(item, "value") { return explicit_value.max(8); } let rarity_base = match item_rarity_key(item).as_str() { "legendary" => 168, "epic" => 92, "rare" => 48, "uncommon" => 24, _ => 12, }; let category = read_optional_string_field(item, "category").unwrap_or_default(); let tags = read_array_field(item, "tags") .into_iter() .filter_map(|tag| tag.as_str().map(|value| value.to_string())) .collect::>(); let mut value = rarity_base; if tags.iter().any(|tag| tag == "weapon") { value += 14; } if tags.iter().any(|tag| tag == "armor") { value += 12; } if tags.iter().any(|tag| tag == "relic") { value += 16; } if tags.iter().any(|tag| tag == "mana") { value += 8; } if tags.iter().any(|tag| tag == "healing") { value += 8; } if tags.iter().any(|tag| tag == "material") { value += 4; } if category.contains("专属") { value += 10; } value.max(8) } fn discount_tier_for_affinity(affinity: i32) -> i32 { if affinity >= 90 { 3 } else if affinity >= 60 { 2 } else if affinity >= 30 { 1 } else { 0 } } pub fn npc_purchase_price(item: &Value, affinity: i32) -> i32 { let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08; (f64::from(inventory_item_value(item)) * discount_multiplier) .round() .max(6.0) as i32 } pub fn npc_buyback_price(item: &Value, affinity: i32) -> i32 { let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06; (f64::from(inventory_item_value(item)) * buyback_multiplier) .round() .max(4.0) as i32 } pub fn trade_quantity_suffix(quantity: i32) -> String { if quantity > 1 { format!(" x{quantity}") } else { String::new() } } fn currency_name_for_world(world_type: Option<&str>) -> String { match world_type { Some("XIANXIA") => "灵石", Some("WUXIA") => "铜钱", _ => "钱币", } .to_string() } fn read_runtime_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)) } fn read_item_id(item: &Value) -> Option { read_required_string_field(item, "id") } fn sanitize_item_for_view(item: &Value) -> Value { let mut item = item.clone(); if let Some(object) = item.as_object_mut() { object.retain(|key, _| key != "__internal"); } item } fn build_trade_item_view(params: BuildTradeItemViewParams<'_>) -> RuntimeNpcTradeItemView { let quantity = read_i32_field(params.item, "quantity").unwrap_or(0).max(0); let unit_price = match params.mode { "buy" => npc_purchase_price(params.item, params.affinity), _ => npc_buyback_price(params.item, params.affinity), }; let mut reason = None; if quantity <= 0 { reason = Some(if params.mode == "buy" { "NPC 库存不足。".to_string() } else { "背包数量不足。".to_string() }); } else if params.mode == "buy" && params.player_currency < unit_price { reason = Some("当前钱币不足。".to_string()); } RuntimeNpcTradeItemView { item_id: params.item_id.to_string(), item: sanitize_item_for_view(params.item), mode: params.mode.to_string(), unit_price, max_quantity: quantity, can_submit: reason.is_none(), reason, } } struct BuildTradeItemViewParams<'a> { item_id: &'a str, item: &'a Value, mode: &'a str, affinity: i32, player_currency: i32, } /// 编译 NPC 交易 / 送礼展示用 view。 /// /// 中文注释:这份 view 只服务前端展示与按钮状态,正式结算仍会在 /// `resolve_npc_trade_action` / `resolve_npc_gift_action` 中重新校验。 pub fn build_runtime_npc_interaction_view(game_state: &Value) -> Option { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return None; } if !read_bool_field(game_state, "npcInteractionActive").unwrap_or(false) { return None; } let encounter = read_object_field(game_state, "currentEncounter")?; if read_required_string_field(encounter, "kind").as_deref() != Some("npc") { return None; } let npc_name = read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) .unwrap_or_else(|| "当前角色".to_string()); let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); let npc_state = read_runtime_npc_state(game_state, npc_id.as_str(), npc_name.as_str())?; let affinity = read_i32_field(npc_state, "affinity").unwrap_or(0); let player_currency = read_i32_field(game_state, "playerCurrency") .unwrap_or(0) .max(0); let currency_name = currency_name_for_world(read_optional_string_field(game_state, "worldType").as_deref()); let buy_items = read_array_field(npc_state, "inventory") .into_iter() .filter_map(|item| { let item_id = read_item_id(item)?; Some(build_trade_item_view(BuildTradeItemViewParams { item_id: item_id.as_str(), item, mode: "buy", affinity, player_currency, })) }) .collect::>(); let sell_items = read_array_field(game_state, "playerInventory") .into_iter() .filter_map(|item| { let item_id = read_item_id(item)?; Some(build_trade_item_view(BuildTradeItemViewParams { item_id: item_id.as_str(), item, mode: "sell", affinity, player_currency, })) }) .collect::>(); let gift_items = read_array_field(game_state, "playerInventory") .into_iter() .filter_map(|item| { let item_id = read_item_id(item)?; let quantity = read_i32_field(item, "quantity").unwrap_or(0).max(0); let reason = if quantity <= 0 { Some("背包里没有这件可赠送的物品。".to_string()) } else { None }; Some(RuntimeNpcGiftItemView { item_id, item: sanitize_item_for_view(item), affinity_gain: resolve_npc_gift_affinity_gain(item), can_submit: reason.is_none(), reason, }) }) .collect::>(); Some(RuntimeNpcInteractionView { npc_id, npc_name, player_currency, currency_name, trade: RuntimeNpcTradeView { buy_items, sell_items, }, gift: RuntimeNpcGiftView { items: gift_items }, }) } /// 将 NPC 交互 view 写入快照 JSON,方便旧前端在 hydrated snapshot 上直接读取。 pub fn write_runtime_npc_interaction_view(game_state: &mut Value) { let view = build_runtime_npc_interaction_view(game_state); let root = ensure_json_object(game_state); match view { Some(view) => { let value = serde_json::to_value(view).unwrap_or_else(|_| Value::Object(Map::new())); root.insert("runtimeNpcInteraction".to_string(), value); } None => { root.remove("runtimeNpcInteraction"); } } } fn add_companion_if_absent( game_state: &mut Value, npc_id: &str, character_id: Option, joined_at_affinity: i32, ) { let root = ensure_json_object(game_state); let companions = root .entry("companions".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !companions.is_array() { *companions = Value::Array(Vec::new()); } let items = companions .as_array_mut() .expect("companions should be array"); if items .iter() .any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)) { return; } items.push(json!({ "npcId": npc_id, "characterId": character_id, "joinedAtAffinity": joined_at_affinity, })); } fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option { let root = ensure_json_object(game_state); let companions = root .entry("companions".to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !companions.is_array() { *companions = Value::Array(Vec::new()); } let items = companions .as_array_mut() .expect("companions should be array"); let index = items.iter().position(|item| { read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id) })?; Some(items.remove(index)) } /// 当前主链先只维护一个轻量队伍名单,继续复用既有前端的满员换队语义。 pub fn recruit_companion_to_party( game_state: &mut Value, npc_id: &str, joined_at_affinity: i32, release_npc_id: Option<&str>, ) -> Result, String> { let companion_count = read_array_field(game_state, "companions").len(); if companion_count < MAX_TASK5_COMPANIONS { add_companion_if_absent(game_state, npc_id, None, joined_at_affinity); return Ok(None); } let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else { return Err("队伍已满时必须明确指定一名离队同伴".to_string()); }; let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str()) .ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?; let released_name = read_optional_string_field(&released_companion, "displayName") .or_else(|| read_optional_string_field(&released_companion, "name")) .or_else(|| read_optional_string_field(&released_companion, "npcName")) .unwrap_or(release_npc_id); add_companion_if_absent(game_state, npc_id, None, joined_at_affinity); Ok(Some(released_name)) }