394 lines
13 KiB
Rust
394 lines
13 KiB
Rust
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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<String> {
|
||
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<RuntimeNpcInteractionView> {
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
|
||
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<String>,
|
||
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<Value> {
|
||
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<Option<String>, 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))
|
||
}
|