This commit is contained in:
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal file
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
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) quantity: i32,
|
||||
pub(crate) matcher: ForgeRequirementMatcher,
|
||||
}
|
||||
|
||||
pub(crate) enum ForgeRequirementMatcher {
|
||||
Named(&'static str),
|
||||
AnyMaterial,
|
||||
}
|
||||
|
||||
pub(crate) struct ForgeRecipeDefinition {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) name: &'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> {
|
||||
match recipe_id {
|
||||
"synthesis-refined-ingot" => Some(ForgeRecipeDefinition {
|
||||
id: "synthesis-refined-ingot",
|
||||
name: "压炼锭材",
|
||||
currency_cost: 18,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 3,
|
||||
matcher: ForgeRequirementMatcher::AnyMaterial,
|
||||
}],
|
||||
}),
|
||||
"forge-duelist-blade" => Some(ForgeRecipeDefinition {
|
||||
id: "forge-duelist-blade",
|
||||
name: "锻造 百炼追风剑",
|
||||
currency_cost: 72,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("快剑精粹"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition {
|
||||
if slot_id == Some("relic") {
|
||||
return ReforgeCostDefinition {
|
||||
currency_cost: 52,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("凝光纱"),
|
||||
}],
|
||||
};
|
||||
}
|
||||
ReforgeCostDefinition {
|
||||
currency_cost: 46,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
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::AnyMaterial => {
|
||||
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("材料"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
"forge-duelist-blade" => build_runtime_equipment_item(
|
||||
game_state,
|
||||
"百炼追风剑",
|
||||
"weapon",
|
||||
"epic",
|
||||
"为快剑与追身构筑准备的锻造兵刃。",
|
||||
"快剑",
|
||||
&["快剑", "突进", "追击"],
|
||||
&["快剑", "突进", "追击"],
|
||||
json!({
|
||||
"maxManaBonus": 10,
|
||||
"outgoingDamageBonus": 0.20
|
||||
}),
|
||||
),
|
||||
_ => 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}")
|
||||
}
|
||||
Reference in New Issue
Block a user