Files
Genarrative/server-rs/crates/module-runtime-story-compat/src/forge.rs
2026-04-28 19:36:39 +08:00

598 lines
20 KiB
Rust

use serde_json::{Value, json};
use crate::{
equipment_slot_label, item_rarity_key, read_array_field, read_field, read_i32_field,
read_inventory_item_name, read_optional_string_field, read_u32_field,
remove_inventory_item_from_list, resolve_equipment_slot_for_item,
};
/// 这批定义只服务 runtime story compat 的确定性锻造链。
///
/// 当前仍然保持旧快照态结算口径,不引入 HTTP / AppState / 持久化边界。
pub(crate) struct ForgeRequirementDefinition {
pub(crate) id: &'static str,
pub(crate) label: &'static str,
pub(crate) quantity: i32,
pub(crate) matcher: ForgeRequirementMatcher,
}
#[derive(Clone, Copy)]
pub(crate) enum ForgeRequirementMatcher {
Named(&'static str),
TaggedMaterial(&'static str),
AnyMaterial,
}
pub(crate) struct ForgeRecipeDefinition {
pub(crate) id: &'static str,
pub(crate) name: &'static str,
pub(crate) kind: &'static str,
pub(crate) description: &'static str,
pub(crate) result_label: &'static str,
pub(crate) currency_cost: i32,
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
}
pub(crate) struct ReforgeCostDefinition {
pub(crate) currency_cost: i32,
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
}
pub(crate) fn forge_recipe_definition(recipe_id: &str) -> Option<ForgeRecipeDefinition> {
forge_recipe_definitions()
.into_iter()
.find(|recipe| recipe.id == recipe_id)
}
pub(crate) fn forge_recipe_definitions() -> Vec<ForgeRecipeDefinition> {
vec![
ForgeRecipeDefinition {
id: "synthesis-refined-ingot",
name: "压炼锭材",
kind: "synthesis",
description: "把零散残片和基础材料压成稳定可用的金属锭材。",
result_label: "精炼锭材",
currency_cost: 18,
requirements: vec![ForgeRequirementDefinition {
id: "material:any",
label: "任意材料",
quantity: 3,
matcher: ForgeRequirementMatcher::AnyMaterial,
}],
},
ForgeRecipeDefinition {
id: "synthesis-condensed-silk",
name: "凝光纺丝",
kind: "synthesis",
description: "用灵性残材与粉末纺出适合饰品锻造的凝光纱。",
result_label: "凝光纱",
currency_cost: 24,
requirements: vec![
ForgeRequirementDefinition {
id: "material:any",
label: "任意材料",
quantity: 2,
matcher: ForgeRequirementMatcher::AnyMaterial,
},
ForgeRequirementDefinition {
id: "tag:mana",
label: "含法力标签材料",
quantity: 1,
matcher: ForgeRequirementMatcher::TaggedMaterial("mana"),
},
],
},
ForgeRecipeDefinition {
id: "forge-duelist-blade",
name: "锻造 百炼追风剑",
kind: "forge",
description: "围绕快剑、突进、追击构筑的轻灵主武器。",
result_label: "百炼追风剑",
currency_cost: 72,
requirements: vec![
ForgeRequirementDefinition {
id: "name:精炼锭材",
label: "精炼锭材",
quantity: 2,
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
},
ForgeRequirementDefinition {
id: "name:快剑精粹",
label: "快剑精粹",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("快剑精粹"),
},
ForgeRequirementDefinition {
id: "name:突进精粹",
label: "突进精粹",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("突进精粹"),
},
],
},
ForgeRecipeDefinition {
id: "forge-ward-armor",
name: "锻造 镇岳护甲",
kind: "forge",
description: "面向前排承压的护甲,适合守御与护体构筑。",
result_label: "镇岳护甲",
currency_cost: 78,
requirements: vec![
ForgeRequirementDefinition {
id: "name:精炼锭材",
label: "精炼锭材",
quantity: 2,
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
},
ForgeRequirementDefinition {
id: "name:守御精粹",
label: "守御精粹",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("守御精粹"),
},
ForgeRequirementDefinition {
id: "name:护体精粹",
label: "护体精粹",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("护体精粹"),
},
],
},
ForgeRecipeDefinition {
id: "forge-thunder-relic",
name: "锻造 雷纹灵坠",
kind: "forge",
description: "为法修、雷法、过载 build 提供资源与爆发补强。",
result_label: "雷纹灵坠",
currency_cost: 88,
requirements: vec![
ForgeRequirementDefinition {
id: "name:凝光纱",
label: "凝光纱",
quantity: 2,
matcher: ForgeRequirementMatcher::Named("凝光纱"),
},
ForgeRequirementDefinition {
id: "name:法力精粹",
label: "法力精粹",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("法力精粹"),
},
ForgeRequirementDefinition {
id: "name:雷法精粹",
label: "雷法精粹",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("雷法精粹"),
},
],
},
]
}
pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition {
if slot_id == Some("relic") {
return ReforgeCostDefinition {
currency_cost: 52,
requirements: vec![ForgeRequirementDefinition {
id: "name:凝光纱",
label: "凝光纱",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("凝光纱"),
}],
};
}
ReforgeCostDefinition {
currency_cost: 46,
requirements: vec![ForgeRequirementDefinition {
id: "name:精炼锭材",
label: "精炼锭材",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
}],
}
}
fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinition) -> bool {
match requirement.matcher {
ForgeRequirementMatcher::Named(name) => {
read_optional_string_field(item, "name").as_deref() == Some(name)
}
ForgeRequirementMatcher::TaggedMaterial(tag) => {
is_material_item(item)
&& read_array_field(item, "tags")
.into_iter()
.filter_map(Value::as_str)
.any(|item_tag| forge_tag_matches(item_tag, tag))
}
ForgeRequirementMatcher::AnyMaterial => is_material_item(item),
}
}
pub(crate) fn count_matching_forge_requirement(
inventory: &[Value],
requirement: &ForgeRequirementDefinition,
) -> i32 {
inventory
.iter()
.filter(|item| forge_requirement_matches(item, requirement))
.map(|item| read_i32_field(item, "quantity").unwrap_or(0).max(0))
.sum()
}
pub(crate) fn apply_forge_requirements_if_possible(
inventory: &[Value],
requirements: &[ForgeRequirementDefinition],
) -> Option<Vec<Value>> {
let mut next_inventory = inventory.to_vec();
for requirement in requirements {
let mut remaining = requirement.quantity.max(0);
let snapshot = next_inventory.clone();
for item in snapshot {
if remaining <= 0 {
break;
}
if !forge_requirement_matches(&item, requirement) {
continue;
}
let item_id = read_optional_string_field(&item, "id")?;
let item_quantity = read_i32_field(&item, "quantity").unwrap_or(0).max(0);
let consumed = remaining.min(item_quantity);
next_inventory =
remove_inventory_item_from_list(next_inventory, item_id.as_str(), consumed);
remaining -= consumed;
}
if remaining > 0 {
return None;
}
}
Some(next_inventory)
}
fn is_material_item(item: &Value) -> bool {
read_array_field(item, "tags")
.into_iter()
.filter_map(Value::as_str)
.any(|tag| tag == "material")
|| read_optional_string_field(item, "category")
.is_some_and(|category| category.contains("材料"))
}
fn forge_tag_matches(item_tag: &str, expected_tag: &str) -> bool {
item_tag == expected_tag || (expected_tag == "mana" && item_tag == "法力")
}
pub fn build_runtime_material_item(
game_state: &Value,
name: &str,
quantity: i32,
tags: &[&str],
rarity: &str,
) -> Value {
let mut all_tags = vec!["material".to_string()];
all_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
json!({
"id": generate_runtime_item_id(game_state, format!("forge-material:{name}").as_str()),
"category": "材料",
"name": name,
"quantity": quantity.max(1),
"rarity": rarity,
"tags": all_tags,
"buildProfile": {
"role": "工巧",
"tags": tags,
"synergy": tags,
"forgeRank": 0
}
})
}
pub fn build_runtime_equipment_item(
game_state: &Value,
name: &str,
slot_id: &str,
rarity: &str,
description: &str,
role: &str,
tags: &[&str],
synergy: &[&str],
stat_profile: Value,
) -> Value {
let slot_tag = match slot_id {
"weapon" => "weapon",
"armor" => "armor",
_ => "relic",
};
let mut next_tags = vec![slot_tag.to_string()];
next_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
json!({
"id": generate_runtime_item_id(game_state, format!("forge-equip:{name}").as_str()),
"category": equipment_slot_label(slot_id),
"name": name,
"description": description,
"quantity": 1,
"rarity": rarity,
"tags": next_tags,
"equipmentSlotId": slot_id,
"statProfile": stat_profile,
"buildProfile": {
"role": role,
"tags": tags,
"synergy": synergy,
"forgeRank": 1
}
})
}
pub(crate) fn build_forge_recipe_result_item(
game_state: &Value,
recipe_id: &str,
_world_type: Option<&str>,
) -> Value {
match recipe_id {
"synthesis-refined-ingot" => {
build_runtime_material_item(game_state, "精炼锭材", 1, &["工巧", "守御"], "rare")
}
"synthesis-condensed-silk" => {
build_runtime_material_item(game_state, "凝光纱", 1, &["工巧", "法力"], "rare")
}
"forge-duelist-blade" => build_runtime_equipment_item(
game_state,
"百炼追风剑",
"weapon",
"epic",
"为快剑与追身构筑准备的锻造兵刃。",
"快剑",
&["快剑", "突进", "追击"],
&["快剑", "突进", "追击"],
json!({
"maxManaBonus": 10,
"outgoingDamageBonus": 0.20
}),
),
"forge-ward-armor" => build_runtime_equipment_item(
game_state,
"镇岳护甲",
"armor",
"epic",
"厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。",
"守御",
&["守御", "护体", "先锋"],
&["守御", "护体", "先锋"],
json!({
"maxHpBonus": 56,
"maxManaBonus": 8,
"outgoingDamageBonus": 0.08,
"incomingDamageMultiplier": 0.84
}),
),
"forge-thunder-relic" => build_runtime_equipment_item(
game_state,
"雷纹灵坠",
"relic",
"epic",
"内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。",
"法修",
&["法修", "雷法", "过载"],
&["法修", "雷法", "过载"],
json!({
"maxHpBonus": 8,
"maxManaBonus": 42,
"outgoingDamageBonus": 0.14,
"incomingDamageMultiplier": 0.92
}),
),
_ => build_runtime_material_item(game_state, "临时锻造产物", 1, &["工巧"], "common"),
}
}
fn build_tag_essence_item(game_state: &Value, tag: &str) -> Value {
build_runtime_material_item(
game_state,
format!("{tag}精粹").as_str(),
1,
&[tag, "工巧"],
"rare",
)
}
pub(crate) fn build_dismantle_outputs(game_state: &Value, item: &Value) -> Option<Vec<Value>> {
let slot_id = resolve_equipment_slot_for_item(item);
if slot_id.is_none() && read_field(item, "buildProfile").is_none() {
return None;
}
let rarity_scale = match item_rarity_key(item).as_str() {
"legendary" => 5,
"epic" => 4,
"rare" => 3,
"uncommon" => 2,
_ => 1,
};
let mut outputs = Vec::new();
match slot_id {
Some("weapon") => outputs.push(build_runtime_material_item(
game_state,
"武器残片",
rarity_scale,
&["工巧", "重击"],
"uncommon",
)),
Some("armor") => outputs.push(build_runtime_material_item(
game_state,
"甲片",
rarity_scale,
&["工巧", "守御"],
"uncommon",
)),
Some("relic") => outputs.push(build_runtime_material_item(
game_state,
"灵饰碎片",
rarity_scale,
&["工巧", "法力"],
"uncommon",
)),
_ => outputs.push(build_runtime_material_item(
game_state,
"零散材料",
((rarity_scale + 1) / 2).max(1),
&["工巧"],
"uncommon",
)),
}
let mut build_tags = read_field(item, "buildProfile")
.map(|profile| {
let mut tags = read_array_field(profile, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>();
if let Some(role) = read_optional_string_field(profile, "role") {
tags.push(role);
}
tags
})
.unwrap_or_default();
build_tags.sort();
build_tags.dedup();
let tag_limit = if item_rarity_key(item) == "legendary" {
3
} else {
2
};
for tag in build_tags.into_iter().take(tag_limit) {
outputs.push(build_tag_essence_item(game_state, tag.as_str()));
}
Some(outputs)
}
pub(crate) fn build_reforged_item(game_state: &Value, item: &Value) -> Option<Value> {
let slot_id = resolve_equipment_slot_for_item(item)?;
let build_profile = read_field(item, "buildProfile")?;
let mut next_tags = read_array_field(build_profile, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>();
let extra_tag = match slot_id {
"weapon" => "追击",
"armor" => "护体",
_ => "法力",
};
next_tags.push(extra_tag.to_string());
next_tags.sort();
next_tags.dedup();
next_tags.truncate(3);
let source_name = read_inventory_item_name(item);
let next_name = if source_name.contains('·') && source_name.contains("重铸") {
source_name.clone()
} else {
format!("{source_name}·重铸")
};
let stat_profile = read_field(item, "statProfile");
let outgoing_damage_bonus = stat_profile
.and_then(|profile| read_field(profile, "outgoingDamageBonus"))
.and_then(Value::as_f64)
.unwrap_or(0.0);
let incoming_damage_multiplier = stat_profile
.and_then(|profile| read_field(profile, "incomingDamageMultiplier"))
.and_then(Value::as_f64);
let current_forge_rank = read_i32_field(build_profile, "forgeRank").unwrap_or(0);
let mut tags = read_array_field(item, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>();
tags.sort();
tags.dedup();
Some(json!({
"id": generate_runtime_item_id(game_state, format!("reforge:{source_name}").as_str()),
"category": read_optional_string_field(item, "category")
.unwrap_or_else(|| equipment_slot_label(slot_id).to_string()),
"name": next_name,
"description": read_optional_string_field(item, "description"),
"quantity": 1,
"rarity": item_rarity_key(item),
"tags": tags,
"equipmentSlotId": slot_id,
"statProfile": {
"maxHpBonus": stat_profile
.and_then(|profile| read_i32_field(profile, "maxHpBonus"))
.unwrap_or(0) + if slot_id == "armor" { 10 } else { 4 },
"maxManaBonus": stat_profile
.and_then(|profile| read_i32_field(profile, "maxManaBonus"))
.unwrap_or(0) + if slot_id == "relic" { 10 } else { 4 },
"outgoingDamageBonus": ((outgoing_damage_bonus + 0.03) * 1000.0).round() / 1000.0,
"incomingDamageMultiplier": if let Some(multiplier) = incoming_damage_multiplier {
(((multiplier - 0.03).max(0.72)) * 1000.0).round() / 1000.0
} else if slot_id == "armor" {
0.94
} else {
0.97
}
},
"buildProfile": {
"role": read_optional_string_field(build_profile, "role"),
"tags": next_tags,
"synergy": read_array_field(build_profile, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.chain(std::iter::once(extra_tag.to_string()))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>(),
"forgeRank": current_forge_rank + 1
}
}))
}
pub(crate) fn build_forge_success_text(
action: &str,
recipe_name: Option<&str>,
source_item_name: Option<&str>,
created_item_name: Option<&str>,
output_names: &[String],
currency_text: Option<String>,
) -> String {
match action {
"craft" => format!(
"你在工坊中完成了{},获得了{}{}",
recipe_name.unwrap_or("目标配方"),
created_item_name.unwrap_or("目标物品"),
currency_text
.map(|text| format!(",并支付了{text}"))
.unwrap_or_default()
),
"reforge" => format!(
"你消耗材料重新淬炼了{},最终得到{}{}",
source_item_name.unwrap_or("目标物品"),
created_item_name.unwrap_or("重铸产物"),
currency_text
.map(|text| format!(",并支付了{text}"))
.unwrap_or_default()
),
_ => format!(
"你拆解了{},回收出{}",
source_item_name.unwrap_or("目标物品"),
output_names.join("")
),
}
}
pub fn format_currency_text(value: i32, world_type: Option<&str>) -> String {
let currency_name = match world_type {
Some("XIANXIA") => "灵石",
Some("WUXIA") => "铜钱",
_ => "钱币",
};
format!("{value} {currency_name}")
}
fn generate_runtime_item_id(game_state: &Value, prefix: &str) -> String {
let version = read_u32_field(game_state, "runtimeActionVersion").unwrap_or(0);
let inventory_len = read_array_field(game_state, "playerInventory").len();
format!("{prefix}:{version}:{inventory_len}")
}