418 lines
15 KiB
Rust
418 lines
15 KiB
Rust
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 compat 的纯快照读写。
|
|
///
|
|
/// 目标是先把 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<String> {
|
|
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<Value> {
|
|
read_array_field(game_state, "playerInventory")
|
|
.into_iter()
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
pub fn write_player_inventory_values(game_state: &mut Value, items: Vec<Value>) {
|
|
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<Value>, additions: Vec<Value>) -> Vec<Value> {
|
|
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<Value>,
|
|
item_id: &str,
|
|
quantity: i32,
|
|
) -> Vec<Value> {
|
|
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<Value> {
|
|
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<Value>) {
|
|
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::<Vec<_>>();
|
|
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}")
|
|
}
|