M4 runtime story Rust migration wrap-up
This commit is contained in:
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal file
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
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}")
|
||||
}
|
||||
Reference in New Issue
Block a user