Files
Genarrative/server-rs/crates/module-runtime-story/src/game_state.rs

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 的纯快照读写。
///
/// 目标是先把 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}")
}