From db45105d9064571774f611f7570a8d4e8ad77a0f Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 7 Jan 2026 14:49:10 +0000 Subject: [PATCH] improved items --- cursebreaker-parser/src/item_database.rs | 140 ++- cursebreaker-parser/src/item_loader.rs | 1217 ++++++++++++++++++++++ cursebreaker-parser/src/lib.rs | 40 +- cursebreaker-parser/src/types/item.rs | 745 ++++++++++--- cursebreaker-parser/src/types/mod.rs | 23 +- cursebreaker-parser/src/xml_parser.rs | 100 +- 6 files changed, 2039 insertions(+), 226 deletions(-) create mode 100644 cursebreaker-parser/src/item_loader.rs diff --git a/cursebreaker-parser/src/item_database.rs b/cursebreaker-parser/src/item_database.rs index 66b3e52..33feac6 100644 --- a/cursebreaker-parser/src/item_database.rs +++ b/cursebreaker-parser/src/item_database.rs @@ -1,6 +1,9 @@ +use crate::item_loader::{ + calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory, +}; use crate::types::Item; use crate::xml_parser::{parse_items_xml, XmlParseError}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; /// A database for managing game items loaded from XML files @@ -9,6 +12,8 @@ pub struct ItemDatabase { items: Vec, items_by_id: HashMap, items_by_name: HashMap>, + stackable_item_ids: HashSet, + storage_item_ids: HashSet, } impl ItemDatabase { @@ -18,10 +23,12 @@ impl ItemDatabase { items: Vec::new(), items_by_id: HashMap::new(), items_by_name: HashMap::new(), + stackable_item_ids: HashSet::new(), + storage_item_ids: HashSet::new(), } } - /// Load items from an XML file + /// Load items from an XML file (basic loading without advanced features) pub fn load_from_xml>(path: P) -> Result { let items = parse_items_xml(path)?; let mut db = Self::new(); @@ -29,22 +36,68 @@ impl ItemDatabase { Ok(db) } + /// Load items from a directory with full support for: + /// - Multiple XML files + /// - Stats generation + /// - Crafting recipe generation + /// - Exceptional items + /// - Banknotes + /// - Price calculation + pub fn load_from_directory>(dir: P) -> Result { + let mut items = load_items_from_directory(dir)?; + + // Generate exceptional items + let exceptional = generate_exceptional_items(&items); + items.extend(exceptional); + + // Generate banknotes + let banknotes = generate_banknotes(&items); + items.extend(banknotes); + + // Calculate prices + calculate_prices(&mut items); + + let mut db = Self::new(); + db.add_items(items); + Ok(db) + } + /// Add items to the database pub fn add_items(&mut self, items: Vec) { for item in items { let index = self.items.len(); - self.items_by_id.insert(item.id, index); + self.items_by_id.insert(item.type_id, index); // Add to name index (can have multiple items with same name) self.items_by_name - .entry(item.name.clone()) + .entry(item.item_name.clone()) .or_insert_with(Vec::new) .push(index); + // Track stackable items + if item.is_stackable() { + self.stackable_item_ids.insert(item.type_id); + } + + // Track storage items + if item.is_storage_item() { + self.storage_item_ids.insert(item.type_id); + } + self.items.push(item); } } + /// Check if an item is stackable by ID + pub fn is_stackable(&self, type_id: i32) -> bool { + self.stackable_item_ids.contains(&type_id) + } + + /// Check if an item is a storage item by ID + pub fn is_storage_item(&self, type_id: i32) -> bool { + self.storage_item_ids.contains(&type_id) + } + /// Get an item by ID pub fn get_by_id(&self, id: i32) -> Option<&Item> { self.items_by_id @@ -72,41 +125,62 @@ impl ItemDatabase { /// Get items by category pub fn get_by_category(&self, category: &str) -> Vec<&Item> { - self.items - .iter() - .filter(|item| { - item.category - .as_ref() - .map(|c| c == category) - .unwrap_or(false) - }) - .collect() + use crate::types::ItemCategory; + use std::str::FromStr; + + if let Ok(cat) = ItemCategory::from_str(category) { + self.items + .iter() + .filter(|item| item.has_category(cat)) + .collect() + } else { + Vec::new() + } } - /// Get items by slot + /// Get items by slot/item type pub fn get_by_slot(&self, slot: &str) -> Vec<&Item> { - self.items - .iter() - .filter(|item| { - item.slot - .as_ref() - .map(|s| s == slot) - .unwrap_or(false) - }) - .collect() + use crate::types::ItemType; + use std::str::FromStr; + + if let Ok(item_type) = ItemType::from_str(slot) { + self.items + .iter() + .filter(|item| item.item_type == item_type) + .collect() + } else { + Vec::new() + } } /// Get items by skill requirement pub fn get_by_skill(&self, skill: &str) -> Vec<&Item> { - self.items - .iter() - .filter(|item| { - item.skill - .as_ref() - .map(|s| s == skill) - .unwrap_or(false) - }) - .collect() + use crate::types::SkillType; + use std::str::FromStr; + + if let Ok(skill_type) = SkillType::from_str(skill) { + self.items + .iter() + .filter(|item| item.skill == skill_type) + .collect() + } else { + Vec::new() + } + } + + /// Get items by tool type + pub fn get_by_tool(&self, tool: &str) -> Vec<&Item> { + use crate::types::Tool; + use std::str::FromStr; + + if let Ok(tool_type) = Tool::from_str(tool) { + self.items + .iter() + .filter(|item| item.tool == tool_type) + .collect() + } else { + Vec::new() + } } /// Get number of items in database @@ -132,7 +206,7 @@ impl ItemDatabase { .iter() .map(|item| { let json = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string()); - (item.id, item.name.clone(), json) + (item.type_id, item.item_name.clone(), json) }) .collect() } diff --git a/cursebreaker-parser/src/item_loader.rs b/cursebreaker-parser/src/item_loader.rs new file mode 100644 index 0000000..409b3ad --- /dev/null +++ b/cursebreaker-parser/src/item_loader.rs @@ -0,0 +1,1217 @@ +use crate::types::{ + AnimationSet, CraftingRecipe, CraftingRecipeItem, CustomItemName, GenerateRule, Item, + ItemCategory, ItemType, ItemXpBoost, PermanentStatBoost, SkillType, Stat, StatType, + Tool, MAX_STACK, +}; +use crate::xml_parser::XmlParseError; +use quick_xml::events::Event; +use quick_xml::reader::Reader; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +/// Load items from multiple XML files in a directory +pub fn load_items_from_directory>(dir: P) -> Result, XmlParseError> { + let dir_path = dir.as_ref(); + + // Find all Items*.xml files + let mut files: Vec = std::fs::read_dir(dir_path)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("Items") && n.ends_with(".xml")) + .unwrap_or(false) + }) + .collect(); + + // Sort so that Items.xml (the main file) comes last + files.sort_by(|a, b| { + let a_name = a.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let b_name = b.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if a_name == "Items.xml" { + std::cmp::Ordering::Greater + } else if b_name == "Items.xml" { + std::cmp::Ordering::Less + } else { + a_name.cmp(b_name) + } + }); + + let mut all_items = Vec::new(); + let mut loaded_ids = HashSet::new(); + + for file_path in files { + let file_items = parse_items_xml_enhanced(&file_path)?; + for item in file_items { + // Only add if we haven't loaded this ID yet + if loaded_ids.insert(item.type_id) { + all_items.push(item); + } + } + } + + Ok(all_items) +} + +/// Parse items XML file with full support for all features +fn parse_items_xml_enhanced>(path: P) -> Result, XmlParseError> { + let file = File::open(path)?; + let buf_reader = BufReader::new(file); + let mut reader = Reader::from_reader(buf_reader); + reader.config_mut().trim_text(true); + + let mut items = Vec::new(); + let mut buf = Vec::new(); + let mut current_item: Option = None; + let mut current_stats = Vec::new(); + let mut generate_rules = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + match e.name().as_ref() { + b"item" => { + let attrs = parse_attributes(&e)?; + let id = get_attr_int(&attrs, "id")?; + let name = get_attr_string(&attrs, "name")?; + + let mut item = Item::new(id, name); + + // Parse all attributes + item.comment = get_attr_string_opt(&attrs, "comment").unwrap_or_default(); + item.use_text = get_attr_string_opt(&attrs, "usetext").unwrap_or_default(); + item.description = get_attr_string_opt(&attrs, "description") + .unwrap_or_default() + .replace("\\n", "\n"); + item.effect_string = + get_attr_string_opt(&attrs, "effect").unwrap_or_default(); + item.using_item_model = + get_attr_string_opt(&attrs, "usingitemmodel").unwrap_or_default(); + item.max_stack = get_attr_int_opt(&attrs, "maxstack").unwrap_or(1); + item.price = get_attr_int_opt(&attrs, "price").unwrap_or(0); + item.level = get_attr_int_opt(&attrs, "level").unwrap_or(0); + item.handmodel = get_attr_string_opt(&attrs, "handmodel") + .unwrap_or_else(|| item.type_id.to_string()); + item.special_ability = get_attr_int_opt(&attrs, "special").unwrap_or(0); + item.swap_item = get_attr_int_opt(&attrs, "swap").unwrap_or(0); + item.food_level = get_attr_int_opt(&attrs, "foodlevel").unwrap_or(0); + item.unequip_destroy = + get_attr_bool_opt(&attrs, "unequipdestroy").unwrap_or(false); + item.cannot_craft_exceptional = + get_attr_bool_opt(&attrs, "noexceptional").unwrap_or(false); + item.food_time = get_attr_int_opt(&attrs, "foodtime").unwrap_or(0); + item.food_frequency = + get_attr_int_opt(&attrs, "foodfrequency").unwrap_or(0); + item.food_amount = get_attr_int_opt(&attrs, "foodamount").unwrap_or(0); + + if let Some(skill_str) = get_attr_string_opt(&attrs, "skill") { + item.skill = SkillType::from_str(&skill_str).unwrap_or(SkillType::None); + } + + item.ability_id = get_attr_int_opt(&attrs, "abilityid").unwrap_or(0); + item.drop_sfx = get_attr_int_opt(&attrs, "dropsfx").unwrap_or(0); + item.pickup_sfx = get_attr_int_opt(&attrs, "pickupsfx").unwrap_or(0); + item.learn_ability_id = + get_attr_int_opt(&attrs, "learnabilityid").unwrap_or(0); + item.book_id = get_attr_int_opt(&attrs, "book").unwrap_or(0); + item.ground_model = get_attr_int_opt(&attrs, "groundmodel") + .unwrap_or(item.type_id); + item.copy_model = get_attr_int_opt(&attrs, "copymodel").unwrap_or(0); + + if let Some(slot_str) = get_attr_string_opt(&attrs, "slot") { + item.item_type = + ItemType::from_str(&slot_str).unwrap_or(ItemType::Resource); + } + + item.hide_milestone = + get_attr_bool_opt(&attrs, "hidemilestone").unwrap_or(false); + item.generate_icon = + get_attr_bool_opt(&attrs, "generateicon").unwrap_or(false); + item.storage_all_items = + get_attr_bool_opt(&attrs, "storageall").unwrap_or(false); + + if let Some(storage_str) = get_attr_string_opt(&attrs, "storageitem") { + item.storage_items = parse_int_list(&storage_str); + } + + item.storage_size = get_attr_int_opt(&attrs, "storagesize").unwrap_or(0); + + if get_attr_bool_opt(&attrs, "stackable").unwrap_or(false) { + if item.max_stack == 1 { + item.max_stack = MAX_STACK; + } + } + + if let Some(tool_str) = get_attr_string_opt(&attrs, "tool") { + item.tool = Tool::from_str(&tool_str).unwrap_or(Tool::None); + } + + item.two_handed = get_attr_bool_opt(&attrs, "twohanded").unwrap_or(false); + item.undroppable = get_attr_bool_opt(&attrs, "undroppable").unwrap_or(false); + item.undroppable_on_death = + get_attr_bool_opt(&attrs, "undroppableondeath").unwrap_or(false); + + // Parse categories + if let Some(cat_str) = get_attr_string_opt(&attrs, "category") { + item.item_categories = parse_categories(&cat_str); + } + + // Parse old-style crafting if present + if let Some(crafting_items) = get_attr_string_opt(&attrs, "craftingitems") { + item.has_crafting = true; + let recipe = parse_old_crafting_recipe( + &attrs, + &crafting_items, + item.type_id, + item.level, + ); + item.crafting_recipes.push(recipe); + } + + current_item = Some(item); + } + b"anim" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + let anim = AnimationSet { + weapon_attack: get_attr_int_opt(&attrs, "weaponattack").unwrap_or(0), + idle: get_attr_int_opt(&attrs, "idle").unwrap_or(0), + walk: get_attr_int_opt(&attrs, "walk").unwrap_or(0), + run: get_attr_int_opt(&attrs, "run").unwrap_or(0), + takehit: get_attr_int_opt(&attrs, "takehit").unwrap_or(0), + use_anim: get_attr_int_opt(&attrs, "use").unwrap_or(0), + }; + if let Some(ref mut item) = current_item { + item.animations = Some(anim); + } + } + b"stat" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + let stats = parse_stat_attrs(&attrs); + current_stats.extend(stats); + } + b"crafting" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + if let Some(ref mut item) = current_item { + item.has_crafting = true; + let recipe = parse_new_crafting_recipe(&attrs, item.type_id, item.level); + item.crafting_recipes.push(recipe); + } + } + b"xpboost" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + if let Some(ref mut item) = current_item { + if let Some(skill_str) = get_attr_string_opt(&attrs, "skill") { + let skill = SkillType::from_str(&skill_str).unwrap_or(SkillType::None); + let amount = get_attr_int_opt(&attrs, "amount").unwrap_or(0); + let multiplier = 1.0 + (amount as f32 * 0.01); + item.item_xp_boosts.push(ItemXpBoost { + skill_type: skill, + multiplier, + }); + } + } + } + b"permanentstatboost" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + if let Some(ref mut item) = current_item { + if let Some(stat_str) = get_attr_string_opt(&attrs, "stat") { + let stat = StatType::from_str(&stat_str).unwrap_or(StatType::None); + let amount = get_attr_int_opt(&attrs, "amount").unwrap_or(0); + item.permanent_stat_boosts.push(PermanentStatBoost { stat, amount }); + } + } + } + b"customname" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + if let Some(ref mut item) = current_item { + let checks = get_attr_string_opt(&attrs, "checks").unwrap_or_default(); + let name = get_attr_string_opt(&attrs, "name").unwrap_or_default(); + item.custom_item_names + .push(CustomItemName { checks, item_name: name }); + } + } + b"customdescription" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + if let Some(ref mut item) = current_item { + let checks = get_attr_string_opt(&attrs, "checks").unwrap_or_default(); + let name = get_attr_string_opt(&attrs, "name") + .unwrap_or_default() + .replace("\\n", "\n"); + item.custom_item_descriptions + .push(CustomItemName { checks, item_name: name }); + } + } + b"generate" if current_item.is_some() => { + let attrs = parse_attributes(&e)?; + let gen_stats = get_attr_string_opt(&attrs, "generatestats"); + let gen_crafting = get_attr_bool_opt(&attrs, "generatecrafting").unwrap_or(false); + + generate_rules.push(GenerateRule { + generate_stats: gen_stats, + generate_crafting: gen_crafting, + }); + } + _ => {} + } + } + Ok(Event::End(e)) => { + if e.name().as_ref() == b"item" { + if let Some(mut item) = current_item.take() { + // Apply generate rules + for rule in &generate_rules { + if let Some(ref stats_str) = rule.generate_stats { + let generated_stats = + generate_stats(stats_str, item.level, &mut item); + current_stats.extend(generated_stats); + } + if rule.generate_crafting { + if let Some(ref stats_str) = rule.generate_stats { + if let Some(recipe) = generate_crafting_recipe(stats_str, &item) { + item.has_crafting = true; + item.crafting_recipes.push(recipe); + } + } + } + } + + item.stats = current_stats.clone(); + + // Ensure level is at least 1 + if item.level == 0 { + item.level = 1; + } + + items.push(item); + current_stats.clear(); + generate_rules.clear(); + } + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(XmlParseError::XmlError(e)), + _ => {} + } + buf.clear(); + } + + Ok(items) +} + +/// Generate stats based on generatestats string +fn generate_stats(stats_str: &str, _level: i32, item: &mut Item) -> Vec { + let mut stats = Vec::new(); + + // Parse material and type + let (material, gen_type) = if stats_str.contains('-') { + let parts: Vec<&str> = stats_str.split('-').collect(); + (parts[0], parts.get(1).copied().unwrap_or("")) + } else { + ("", stats_str) + }; + + // Calculate base and extra level from material and type + let base_level = get_material_level(material); + let extra_level = get_type_extra_level(gen_type); + + // Update item level if not set + if item.level == 0 { + item.level = base_level + extra_level; + } + + let level = item.level.max(1); + + // Generate stats based on type + match gen_type { + // Armor types + "platehelmet" | "helmet" => { + if item.skill == SkillType::None { + item.skill = SkillType::Defence; + } + stats.push(Stat { + stat_type: StatType::Health, + value: 6.0 + level as f32, + }); + stats.push(Stat { + stat_type: StatType::ResistancePhysical, + value: 6.0 + level as f32 * 0.6, + }); + stats.push(Stat { + stat_type: StatType::ResistanceRanged, + value: 6.0 + level as f32 * 0.6, + }); + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: -20.0, + }); + } + "shield" | "kiteshield" => { + if item.skill == SkillType::None { + item.skill = SkillType::Defence; + } + stats.push(Stat { + stat_type: StatType::Health, + value: 3.0 + level as f32, + }); + stats.push(Stat { + stat_type: StatType::ResistancePhysical, + value: 6.0 + level as f32 * 0.6, + }); + stats.push(Stat { + stat_type: StatType::ResistanceRanged, + value: 6.0 + level as f32 * 0.6, + }); + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: -20.0, + }); + } + "platearmor" | "armor" => { + if item.skill == SkillType::None { + item.skill = SkillType::Defence; + } + stats.push(Stat { + stat_type: StatType::Health, + value: 6.0 + level as f32 * 1.5, + }); + stats.push(Stat { + stat_type: StatType::ResistancePhysical, + value: 6.0 + level as f32 * 1.2, + }); + stats.push(Stat { + stat_type: StatType::ResistanceRanged, + value: 6.0 + level as f32 * 1.2, + }); + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: -30.0, + }); + } + "leatherarmor" | "studdedleatherarmor" | "woodenshield" => { + if item.skill == SkillType::None { + item.skill = SkillType::Defence; + } + stats.push(Stat { + stat_type: StatType::Health, + value: 6.0 + level as f32 * 0.8, + }); + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: 6.0 + level as f32 * 1.2, + }); + stats.push(Stat { + stat_type: StatType::ResistancePhysical, + value: 2.0 + level as f32 * 0.4, + }); + stats.push(Stat { + stat_type: StatType::ResistanceRanged, + value: 2.0 + level as f32 * 0.4, + }); + } + "leatherhood" | "studdedleatherhood" => { + if item.skill == SkillType::None { + item.skill = SkillType::Defence; + } + stats.push(Stat { + stat_type: StatType::Health, + value: 4.0 + level as f32 * 0.5, + }); + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: 6.0 + level as f32 * 0.8, + }); + stats.push(Stat { + stat_type: StatType::ResistancePhysical, + value: 2.0 + level as f32 * 0.2, + }); + stats.push(Stat { + stat_type: StatType::ResistanceRanged, + value: 2.0 + level as f32 * 0.2, + }); + } + // Weapons + "dagger" => { + add_category_if_missing(item, ItemCategory::Blade); + add_category_if_missing(item, ItemCategory::Dagger); + stats.push(Stat { + stat_type: StatType::AccuracyPhysical, + value: level as f32 * 3.0, + }); + stats.push(Stat { + stat_type: StatType::DamagePhysical, + value: level as f32 / 7.0, + }); + } + "bow" => { + add_category_if_missing(item, ItemCategory::Bow); + item.two_handed = true; + stats.push(Stat { + stat_type: StatType::AccuracyRanged, + value: level as f32 * 3.0, + }); + stats.push(Stat { + stat_type: StatType::DamageRanged, + value: 1.0 + level as f32 / 7.0, + }); + } + "crossbow" => { + add_category_if_missing(item, ItemCategory::Crossbow); + item.two_handed = true; + stats.push(Stat { + stat_type: StatType::AccuracyRanged, + value: level as f32 * 2.0, + }); + stats.push(Stat { + stat_type: StatType::DamageRanged, + value: 1.0 + level as f32 / 3.0, + }); + } + "leatherbracelet" | "studdedleatherbracelet" => { + if item.skill == SkillType::None { + item.skill = SkillType::Defence; + } + stats.push(Stat { + stat_type: StatType::AccuracyRanged, + value: 2.0 + level as f32 * 1.5, + }); + stats.push(Stat { + stat_type: StatType::Health, + value: 2.0 + level as f32 * 0.5, + }); + } + "broadsword" => { + add_category_if_missing(item, ItemCategory::Blade); + stats.push(Stat { + stat_type: StatType::AccuracyPhysical, + value: level as f32 * 2.0, + }); + stats.push(Stat { + stat_type: StatType::DamagePhysical, + value: 2.0 + level as f32 / 3.0, + }); + } + "greatsword" | "battleaxe" => { + item.two_handed = true; + add_category_if_missing(item, ItemCategory::Blade); + if gen_type == "battleaxe" { + add_category_if_missing(item, ItemCategory::Battleaxe); + } + stats.push(Stat { + stat_type: StatType::AccuracyPhysical, + value: level as f32 * 3.0, + }); + stats.push(Stat { + stat_type: StatType::DamagePhysical, + value: 4.0 + level as f32 / 3.0, + }); + } + "spear" => { + item.two_handed = true; + stats.push(Stat { + stat_type: StatType::AccuracyPhysical, + value: 2.0 + level as f32 * 3.0, + }); + stats.push(Stat { + stat_type: StatType::DamagePhysical, + value: 2.0 + level as f32 / 3.0, + }); + } + "hammer" => { + add_category_if_missing(item, ItemCategory::Warhammer); + stats.push(Stat { + stat_type: StatType::DamagePhysical, + value: 4.0 + level as f32 / 2.0, + }); + } + "morningstar" => { + add_category_if_missing(item, ItemCategory::Morningstar); + stats.push(Stat { + stat_type: StatType::DamagePhysical, + value: 2.0 + level as f32 / 2.0, + }); + stats.push(Stat { + stat_type: StatType::AccuracyPhysical, + value: 1.0 + level as f32, + }); + } + "wand" => { + if item.skill == SkillType::None { + item.skill = SkillType::Magic; + } + add_category_if_missing(item, ItemCategory::Wand); + stats.push(Stat { + stat_type: StatType::AccuracyMagical, + value: level as f32 * 4.0, + }); + } + "staff" => { + if item.skill == SkillType::None { + item.skill = SkillType::Magic; + } + add_category_if_missing(item, ItemCategory::Staff); + item.two_handed = true; + stats.push(Stat { + stat_type: StatType::AccuracyMagical, + value: level as f32 * 4.0, + }); + stats.push(Stat { + stat_type: StatType::DamageMagical, + value: 2.0 + level as f32 / 7.0, + }); + } + "wizardrobe" | "grandwizardrobe" => { + if item.skill == SkillType::None { + item.skill = SkillType::Magic; + } + stats.push(Stat { + stat_type: StatType::Health, + value: 6.0 + level as f32 * 0.5, + }); + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: 2.0 + level as f32 * 0.6, + }); + stats.push(Stat { + stat_type: StatType::Mana, + value: 14.0 + level as f32 * 3.0, + }); + stats.push(Stat { + stat_type: StatType::ManaRegen, + value: 2.0 + level as f32 * 0.4, + }); + } + "wizardhat" | "grandwizardhat" => { + if item.skill == SkillType::None { + item.skill = SkillType::Magic; + } + stats.push(Stat { + stat_type: StatType::Health, + value: 2.0 + level as f32 * 0.3, + }); + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: 2.0 + level as f32 * 0.4, + }); + stats.push(Stat { + stat_type: StatType::Mana, + value: 10.0 + level as f32 * 1.5, + }); + stats.push(Stat { + stat_type: StatType::ManaRegen, + value: 2.0 + level as f32 * 0.2, + }); + } + _ => {} + } + + stats +} + +fn get_material_level(material: &str) -> i32 { + match material { + // Metals + "copper" => 0, + "iron" => 20, + "imbersteel" => 60, + "titanium" => 80, + // Wood + "spruce" => 0, + "oak" => 20, + "evark" => 60, + "deadwood" => 80, + // Leather + "sheep" => 0, + "troll" => 20, + "ogre" => 60, + "demon" => 80, + // Tailor + "wool" => 0, + "cotton" => 20, + "flax" => 60, + "jute" => 80, + _ => 0, + } +} + +fn get_type_extra_level(gen_type: &str) -> i32 { + match gen_type { + // Blacksmith + "dagger" => 0, + "broadsword" => 3, + "battleaxe" => 4, + "greatsword" => 5, + "morningstar" => 5, + "hammer" => 6, + "spear" => 8, + // Carpenter + "bow" => 0, + "staff" => 1, + "wand" => 3, + "crossbow" => 6, + "woodenshield" => 8, + // Tailor + "wizardhat" => 0, + "wizardrobe" => 8, + "grandwizardhat" => 12, + "grandwizardrobe" => 15, + "leatherhood" => 0, + "leatherbracelet" => 4, + "leatherarmor" => 8, + "studdedleatherhood" => 12, + "studdedleatherbracelet" => 13, + "studdedleatherarmor" => 15, + // Anvil + "helmet" => 0, + "shield" => 4, + "armor" => 8, + "platehelmet" => 12, + "kiteshield" => 13, + "platearmor" => 15, + _ => 0, + } +} + +fn add_category_if_missing(item: &mut Item, category: ItemCategory) { + if !item.item_categories.contains(&category) { + item.item_categories.push(category); + } +} + +/// Generate crafting recipe based on generatestats string +fn generate_crafting_recipe(stats_str: &str, item: &Item) -> Option { + let (material, gen_type) = if stats_str.contains('-') { + let parts: Vec<&str> = stats_str.split('-').collect(); + (parts[0], parts.get(1).copied().unwrap_or("")) + } else { + return None; + }; + + // Get crafting item ID and amount + let (crafting_item_id, workbench_id, skill) = match material { + // Metals + "copper" => (161, 1, SkillType::Blacksmithy), + "iron" => (227, 1, SkillType::Blacksmithy), + "imbersteel" => (231, 1, SkillType::Blacksmithy), + "titanium" => (232, 1, SkillType::Blacksmithy), + // Wood + "spruce" => (61, 4, SkillType::Carpentry), + "oak" => (62, 4, SkillType::Carpentry), + "evark" => (63, 4, SkillType::Carpentry), + "deadwood" => (64, 4, SkillType::Carpentry), + // Leather + "sheep" => (93, 5, SkillType::Tailoring), + "troll" => (312, 5, SkillType::Tailoring), + "ogre" => (313, 5, SkillType::Tailoring), + "demon" => (314, 5, SkillType::Tailoring), + // Tailor + "wool" => (125, 5, SkillType::Tailoring), + "cotton" => (126, 5, SkillType::Tailoring), + "flax" => (128, 5, SkillType::Tailoring), + "jute" => (127, 5, SkillType::Tailoring), + _ => return None, + }; + + let crafting_amount = get_crafting_amount(gen_type); + + let mut items = vec![CraftingRecipeItem { + item_id: crafting_item_id, + amount: crafting_amount, + }]; + + // Add extra crafting items based on material and type + let extra_items = get_extra_crafting_items(material, gen_type); + items.extend(extra_items); + + Some(CraftingRecipe { + product: item.type_id, + level: item.level.max(1), + skill, + workbench_id, + items, + unlocked_by_default: true, + xp: 0, + checks: None, + }) +} + +fn get_crafting_amount(gen_type: &str) -> i32 { + match gen_type { + // Anvil + "dagger" => 2, + "broadsword" => 4, + "greatsword" => 5, + "battleaxe" => 4, + "hammer" => 4, + "spear" => 4, + "helmet" => 3, + "shield" => 4, + "armor" => 5, + "platehelmet" => 5, + "kiteshield" => 6, + "platearmor" => 7, + // Carpenter + "bow" => 4, + "staff" => 4, + "woodenshield" => 4, + "crossbow" => 5, + "wand" => 4, + // Tailor + "wizardhat" => 3, + "wizardrobe" => 5, + "grandwizardhat" => 5, + "grandwizardrobe" => 7, + "leatherhood" => 3, + "leatherbracelet" => 4, + "leatherarmor" => 5, + "studdedleatherhood" => 5, + "studdedleatherbracelet" => 6, + "studdedleatherarmor" => 7, + _ => 1, + } +} + +fn get_extra_crafting_items(material: &str, gen_type: &str) -> Vec { + let mut items = Vec::new(); + + let is_weapon = matches!( + gen_type, + "dagger" | "broadsword" | "hammer" | "spear" | "greatsword" | "battleaxe" | "mace" | "bow" | "crossbow" | "staff" | "wand" + ); + let tier1_armor = matches!( + gen_type, + "woodenshield" | "shield" | "armor" | "helmet" | "wizardhat" | "wizardrobe" | "leatherarmor" | "leatherhood" | "leatherbracelet" + ); + let tier2_armor = matches!( + gen_type, + "kiteshield" | "platearmor" | "platehelmet" | "grandwizardhat" | "grandwizardrobe" | "studdedleatherarmor" | "studdedleatherhood" | "studdedleatherbracelet" + ); + let is_armor = tier1_armor || tier2_armor; + + match material { + "copper" if tier2_armor => items.push(CraftingRecipeItem { item_id: 605, amount: 1 }), + "iron" => { + if is_armor { + items.push(CraftingRecipeItem { item_id: 423, amount: 1 }); + } + if tier2_armor { + items.push(CraftingRecipeItem { item_id: 620, amount: 1 }); + } + if is_weapon { + items.push(CraftingRecipeItem { item_id: 424, amount: 1 }); + } + } + "imbersteel" => { + if is_armor { + items.push(CraftingRecipeItem { item_id: 622, amount: 1 }); + } + if tier2_armor { + items.push(CraftingRecipeItem { item_id: 623, amount: 1 }); + } + } + "wool" if tier2_armor => items.push(CraftingRecipeItem { item_id: 603, amount: 1 }), + "cotton" => { + if is_armor { + items.push(CraftingRecipeItem { item_id: 604, amount: 1 }); + } + if tier2_armor { + items.push(CraftingRecipeItem { item_id: 621, amount: 1 }); + } + } + "flax" => { + if is_armor { + items.push(CraftingRecipeItem { item_id: 626, amount: 1 }); + } + if tier2_armor { + items.push(CraftingRecipeItem { item_id: 627, amount: 1 }); + } + } + "sheep" if tier2_armor => items.push(CraftingRecipeItem { item_id: 207, amount: 1 }), + "troll" => { + if is_armor { + items.push(CraftingRecipeItem { item_id: 423, amount: 1 }); + } + if tier2_armor { + items.push(CraftingRecipeItem { item_id: 619, amount: 1 }); + } + } + "ogre" => { + if is_armor { + items.push(CraftingRecipeItem { item_id: 624, amount: 1 }); + } + if tier2_armor { + items.push(CraftingRecipeItem { item_id: 625, amount: 1 }); + } + } + "oak" => { + if is_weapon || is_armor { + items.push(CraftingRecipeItem { item_id: 632, amount: 1 }); + } + } + "evark" if is_weapon => items.push(CraftingRecipeItem { item_id: 633, amount: 1 }), + _ => {} + } + + items +} + +// ============================================================================ +// Post-processing: Generate exceptional items and banknotes +// ============================================================================ + +pub fn generate_exceptional_items(items: &[Item]) -> Vec { + let mut exceptional_items = Vec::new(); + + for item in items { + if !item.has_crafting || item.cannot_craft_exceptional { + continue; + } + + if !matches!( + item.item_type, + ItemType::Armor + | ItemType::Head + | ItemType::Weapon + | ItemType::Shield + | ItemType::Trinket + | ItemType::Bracelet + ) { + continue; + } + + let mut exceptional = item.clone(); + exceptional.type_id = item.type_id + 100_000; + exceptional.item_name = format!("Exceptional {}", item.item_name); + exceptional.has_crafting = false; + exceptional.hide_milestone = true; + exceptional.price = (item.price as f32 * 1.25).round() as i32; + + // Boost stats by 25% + for stat in &mut exceptional.stats { + stat.value = (stat.value * 1.25).round(); + } + + exceptional_items.push(exceptional); + } + + exceptional_items +} + +pub fn generate_banknotes(items: &[Item]) -> Vec { + let mut banknotes = Vec::new(); + + for item in items { + let mut note = Item::new( + item.type_id + 1_000_000, + format!("{} (Banknote)", item.item_name), + ); + note.item_type = ItemType::Resource; + note.tool = Tool::None; + note.max_stack = MAX_STACK; + note.price = item.price; + note.has_crafting = false; + note.hide_milestone = true; + note.description = + "An official note of sale drafted by a legitimate banker.".to_string(); + + banknotes.push(note); + } + + banknotes +} + +// ============================================================================ +// Price calculation +// ============================================================================ + +pub fn calculate_prices(items: &mut [Item]) { + // First pass: Items with custom prices + for item in items.iter_mut() { + if item.price > 0 { + item.setup_price = true; + } + } + + // Second pass: Items without crafting + for item in items.iter_mut() { + if !item.setup_price && item.price == 0 && !item.has_crafting { + item.setup_price = true; + if item.is_equippable() { + item.price = item.level * 30; + } else { + item.price = item.level * 5; + } + } + } + + // Third pass: Items with crafting (iterative until all prices are set) + let mut iters = 0; + loop { + let mut all_done = true; + + for i in 0..items.len() { + if items[i].setup_price { + continue; + } + + if !items[i].has_crafting || items[i].crafting_recipes.is_empty() { + continue; + } + + let mut all_materials_priced = true; + let mut new_price = 0; + + // Calculate price based on first recipe + let recipe = &items[i].crafting_recipes[0]; + for craft_item in &recipe.items { + if let Some(mat_item) = items.iter().find(|it| it.type_id == craft_item.item_id) { + if !mat_item.setup_price { + all_materials_priced = false; + break; + } + new_price += craft_item.amount * mat_item.price; + } else { + all_materials_priced = false; + break; + } + } + + if all_materials_priced { + new_price += items[i].level * 10; + if recipe.skill == SkillType::Carpentry { + new_price = (new_price as f32 * 1.5) as i32; + } + items[i].price = new_price; + items[i].setup_price = true; + } else { + all_done = false; + } + } + + if all_done { + break; + } + + iters += 1; + if iters > 10 { + break; + } + } + + // Fourth pass: Exceptional items and banknotes + let mut exceptional_prices = Vec::new(); + let mut banknote_prices = Vec::new(); + + for item in items.iter() { + // Exceptional items (100000-199999) + if item.type_id > 100_000 && item.type_id < 200_000 { + let base_id = item.type_id - 100_000; + exceptional_prices.push((item.type_id, base_id)); + } + // Noted exceptional (1100000+) + else if item.type_id > 1_100_000 { + let base_id = item.type_id - 1_100_000; + banknote_prices.push((item.type_id, base_id, 1.25)); + } + // Regular banknotes (1000000+) + else if item.type_id > 1_000_000 { + let base_id = item.type_id - 1_000_000; + banknote_prices.push((item.type_id, base_id, 1.0)); + } + } + + // Collect exceptional prices first + let mut exceptional_price_map = HashMap::new(); + for (exc_id, base_id) in exceptional_prices { + if let Some(base_item) = items.iter().find(|it| it.type_id == base_id) { + let new_price = (base_item.price as f32 * 1.25).round() as i32; + exceptional_price_map.insert(exc_id, new_price); + } + } + + // Apply exceptional prices + for (exc_id, new_price) in exceptional_price_map { + if let Some(exc_item) = items.iter_mut().find(|it| it.type_id == exc_id) { + exc_item.price = new_price; + } + } + + // Collect banknote prices first + let mut banknote_price_map = HashMap::new(); + for (note_id, base_id, multiplier) in banknote_prices { + if let Some(base_item) = items.iter().find(|it| it.type_id == base_id) { + let new_price = (base_item.price as f32 * multiplier).round() as i32; + banknote_price_map.insert(note_id, new_price); + } + } + + // Apply banknote prices + for (note_id, new_price) in banknote_price_map { + if let Some(note_item) = items.iter_mut().find(|it| it.type_id == note_id) { + note_item.price = new_price; + } + } +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +fn parse_attributes( + element: &quick_xml::events::BytesStart, +) -> Result, XmlParseError> { + let mut attrs = HashMap::new(); + for attr in element.attributes() { + let attr = attr?; + let key = String::from_utf8_lossy(attr.key.as_ref()).to_string(); + let value = attr.unescape_value()?.to_string(); + attrs.insert(key, value); + } + Ok(attrs) +} + +fn get_attr_int(attrs: &HashMap, key: &str) -> Result { + attrs + .get(key) + .ok_or_else(|| XmlParseError::MissingAttribute(key.to_string()))? + .parse() + .map_err(|_| XmlParseError::InvalidAttribute(key.to_string())) +} + +fn get_attr_string(attrs: &HashMap, key: &str) -> Result { + attrs + .get(key) + .ok_or_else(|| XmlParseError::MissingAttribute(key.to_string())) + .map(|s| s.clone()) +} + +fn get_attr_int_opt(attrs: &HashMap, key: &str) -> Option { + attrs.get(key).and_then(|v| v.parse().ok()) +} + +fn get_attr_string_opt(attrs: &HashMap, key: &str) -> Option { + attrs.get(key).map(|s| s.clone()) +} + +fn get_attr_bool_opt(attrs: &HashMap, key: &str) -> Option { + attrs.get(key).map(|_| true) +} + +fn parse_int_list(s: &str) -> Vec { + s.split(',') + .filter_map(|part| part.trim().parse().ok()) + .collect() +} + +fn parse_categories(s: &str) -> Vec { + s.split(',') + .filter_map(|cat| ItemCategory::from_str(cat.trim()).ok()) + .collect() +} + +fn parse_stat_attrs(attrs: &HashMap) -> Vec { + let mut stats = Vec::new(); + + for (key, value) in attrs { + if let Ok(stat_type) = StatType::from_str(key) { + if stat_type == StatType::None { + continue; + } + + // Handle percentage-based stats + let stat_value = if value.ends_with('%') { + // Percentage stats are handled differently - we'll skip them for now + // as they require item level context + continue; + } else { + value.parse().unwrap_or(0.0) + }; + + stats.push(Stat { stat_type, value: stat_value }); + } + } + + stats +} + +fn parse_old_crafting_recipe( + attrs: &HashMap, + crafting_items: &str, + product_id: i32, + level: i32, +) -> CraftingRecipe { + let workbench_id = get_attr_int_opt(attrs, "workbench").unwrap_or(0); + let skill_str = get_attr_string_opt(attrs, "craftingskill").unwrap_or_default(); + let skill = SkillType::from_str(&skill_str).unwrap_or(SkillType::None); + let unlocked = get_attr_bool_opt(attrs, "craftingunlocked").unwrap_or(true); + + let items = crafting_items + .split(',') + .filter_map(|item_str| { + let parts: Vec<&str> = item_str.split('-').collect(); + if parts.len() == 2 { + let item_id = parts[0].parse().ok()?; + let amount = parts[1].parse().ok()?; + Some(CraftingRecipeItem { item_id, amount }) + } else { + None + } + }) + .collect(); + + CraftingRecipe { + product: product_id, + level: level.max(1), + skill, + workbench_id, + items, + unlocked_by_default: unlocked, + xp: 0, + checks: None, + } +} + +fn parse_new_crafting_recipe( + attrs: &HashMap, + product_id: i32, + level: i32, +) -> CraftingRecipe { + let workbench_id = get_attr_int_opt(attrs, "workbench").unwrap_or(0); + let skill_str = get_attr_string_opt(attrs, "craftingskill").unwrap_or_default(); + let skill = SkillType::from_str(&skill_str).unwrap_or(SkillType::None); + let unlocked = get_attr_bool_opt(attrs, "craftingunlocked").unwrap_or(true); + let xp = get_attr_int_opt(attrs, "xp").unwrap_or(0); + let checks = get_attr_string_opt(attrs, "checks"); + + let items = if let Some(crafting_items) = get_attr_string_opt(attrs, "craftingitems") { + crafting_items + .split(',') + .filter_map(|item_str| { + let parts: Vec<&str> = item_str.split('-').collect(); + if parts.len() == 2 { + let item_id = parts[0].parse().ok()?; + let amount = parts[1].parse().ok()?; + Some(CraftingRecipeItem { item_id, amount }) + } else { + None + } + }) + .collect() + } else { + Vec::new() + }; + + CraftingRecipe { + product: product_id, + level: level.max(1), + skill, + workbench_id, + items, + unlocked_by_default: unlocked, + xp, + checks, + } +} diff --git a/cursebreaker-parser/src/lib.rs b/cursebreaker-parser/src/lib.rs index 8ef8b64..b726315 100644 --- a/cursebreaker-parser/src/lib.rs +++ b/cursebreaker-parser/src/lib.rs @@ -51,6 +51,7 @@ pub mod types; mod xml_parser; +mod item_loader; mod item_database; mod npc_database; mod quest_database; @@ -63,10 +64,39 @@ pub use quest_database::QuestDatabase; pub use harvestable_database::HarvestableDatabase; pub use loot_database::LootDatabase; pub use types::{ - Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule, InteractableResource, - Npc, NpcStat, NpcLevel, RightClick, BarkGroup, Bark, QuestMarker, NpcAnimationSet, - Quest, QuestPhase, QuestReward, - Harvestable, HarvestableDrop, - LootTable, LootDrop, + // Items + Item, + ItemStat, + CraftingRecipe, + CraftingRecipeItem, + AnimationSet, + GenerateRule, + ItemType, + ItemCategory, + Tool, + SkillType, + StatType, + Stat, + ItemXpBoost, + PermanentStatBoost, + CustomItemName, + MAX_STACK, + // Other types + InteractableResource, + Npc, + NpcStat, + NpcLevel, + RightClick, + BarkGroup, + Bark, + QuestMarker, + NpcAnimationSet, + Quest, + QuestPhase, + QuestReward, + Harvestable, + HarvestableDrop, + LootTable, + LootDrop, }; pub use xml_parser::XmlParseError; diff --git a/cursebreaker-parser/src/types/item.rs b/cursebreaker-parser/src/types/item.rs index 6bbc288..389b392 100644 --- a/cursebreaker-parser/src/types/item.rs +++ b/cursebreaker-parser/src/types/item.rs @@ -1,158 +1,649 @@ use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +// Constants +pub const MAX_STACK: i32 = 2_100_000_000; // 2.1 billion + +// ============================================================================ +// Enums +// ============================================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ItemType { + Weapon, + Shield, + Armor, + Head, + Resource, + Consumable, + Trinket, + Bracelet, +} + +impl ItemType { + pub fn is_equippable(&self) -> bool { + matches!( + self, + ItemType::Armor + | ItemType::Shield + | ItemType::Weapon + | ItemType::Head + | ItemType::Trinket + | ItemType::Bracelet + ) + } + + pub fn to_string(&self) -> &'static str { + match self { + ItemType::Shield => "Offhand", + ItemType::Weapon => "weapon", + ItemType::Armor => "armor", + ItemType::Head => "head", + ItemType::Resource => "resource", + ItemType::Consumable => "consumable", + ItemType::Trinket => "trinket", + ItemType::Bracelet => "bracelet", + } + } +} + +impl FromStr for ItemType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "armor" => Ok(ItemType::Armor), + "weapon" => Ok(ItemType::Weapon), + "shield" => Ok(ItemType::Shield), + "resource" => Ok(ItemType::Resource), + "consumable" => Ok(ItemType::Consumable), + "head" => Ok(ItemType::Head), + "trinket" => Ok(ItemType::Trinket), + "bracelet" => Ok(ItemType::Bracelet), + _ => Ok(ItemType::Resource), // Default fallback + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ItemCategory { + None, + Bone, + Bow, + Crossbow, + Constructable, + Torch, + Blacksmithhammer, + Questitem, + HeavyArmor, + Warhammer, + Shield, + Hatchet, + Blade, + Armor, + Pickaxe, + Fish, + Fishingrod, + Shears, + Hammer, + Battleaxe, + Morningstar, + Wand, + Staff, + Dagger, +} + +impl ItemCategory { + pub fn to_string(&self) -> &'static str { + match self { + ItemCategory::Fishingrod => "fishing rod", + ItemCategory::None => "none", + ItemCategory::Bone => "bone", + ItemCategory::Bow => "bow", + ItemCategory::Crossbow => "crossbow", + ItemCategory::Constructable => "constructable", + ItemCategory::Torch => "torch", + ItemCategory::Blacksmithhammer => "blacksmithhammer", + ItemCategory::Questitem => "questitem", + ItemCategory::HeavyArmor => "heavyArmor", + ItemCategory::Warhammer => "warhammer", + ItemCategory::Shield => "shield", + ItemCategory::Hatchet => "hatchet", + ItemCategory::Blade => "blade", + ItemCategory::Armor => "armor", + ItemCategory::Pickaxe => "pickaxe", + ItemCategory::Fish => "fish", + ItemCategory::Shears => "shears", + ItemCategory::Hammer => "hammer", + ItemCategory::Battleaxe => "battleaxe", + ItemCategory::Morningstar => "morningstar", + ItemCategory::Wand => "wand", + ItemCategory::Staff => "staff", + ItemCategory::Dagger => "dagger", + } + } +} + +impl FromStr for ItemCategory { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "none" => Ok(ItemCategory::None), + "bone" => Ok(ItemCategory::Bone), + "bow" => Ok(ItemCategory::Bow), + "crossbow" => Ok(ItemCategory::Crossbow), + "constructable" => Ok(ItemCategory::Constructable), + "torch" => Ok(ItemCategory::Torch), + "blacksmithhammer" => Ok(ItemCategory::Blacksmithhammer), + "questitem" => Ok(ItemCategory::Questitem), + "heavyarmor" => Ok(ItemCategory::HeavyArmor), + "warhammer" => Ok(ItemCategory::Warhammer), + "shield" => Ok(ItemCategory::Shield), + "hatchet" => Ok(ItemCategory::Hatchet), + "blade" => Ok(ItemCategory::Blade), + "armor" => Ok(ItemCategory::Armor), + "pickaxe" => Ok(ItemCategory::Pickaxe), + "fish" => Ok(ItemCategory::Fish), + "fishingrod" => Ok(ItemCategory::Fishingrod), + "shears" => Ok(ItemCategory::Shears), + "hammer" => Ok(ItemCategory::Hammer), + "battleaxe" => Ok(ItemCategory::Battleaxe), + "morningstar" => Ok(ItemCategory::Morningstar), + "wand" => Ok(ItemCategory::Wand), + "staff" => Ok(ItemCategory::Staff), + "dagger" => Ok(ItemCategory::Dagger), + _ => Ok(ItemCategory::None), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Tool { + None, + Pickaxe, + Hatchet, + Scythe, + Hammer, + Shears, + FishingRod, +} + +impl FromStr for Tool { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "none" | "" => Ok(Tool::None), + "pickaxe" => Ok(Tool::Pickaxe), + "hatchet" => Ok(Tool::Hatchet), + "scythe" => Ok(Tool::Scythe), + "hammer" => Ok(Tool::Hammer), + "shears" => Ok(Tool::Shears), + "fishingrod" => Ok(Tool::FishingRod), + _ => Ok(Tool::None), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SkillType { + None, + Swordsmanship, + Archery, + Magic, + Defence, + Mining, + Woodcutting, + Fishing, + Cooking, + Carpentry, + Blacksmithy, + Tailoring, + Alchemy, +} + +impl FromStr for SkillType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "none" | "" => Ok(SkillType::None), + "swordsmanship" => Ok(SkillType::Swordsmanship), + "archery" => Ok(SkillType::Archery), + "magic" => Ok(SkillType::Magic), + "defence" => Ok(SkillType::Defence), + "mining" => Ok(SkillType::Mining), + "woodcutting" => Ok(SkillType::Woodcutting), + "fishing" => Ok(SkillType::Fishing), + "cooking" => Ok(SkillType::Cooking), + "carpentry" => Ok(SkillType::Carpentry), + "blacksmithy" => Ok(SkillType::Blacksmithy), + "tailoring" => Ok(SkillType::Tailoring), + "alchemy" => Ok(SkillType::Alchemy), + _ => Ok(SkillType::None), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StatType { + None, + Health, + Mana, + HealthRegen, + ManaRegen, + DamagePhysical, + DamageMagical, + DamageRanged, + AccuracyPhysical, + AccuracyMagical, + AccuracyRanged, + ResistancePhysical, + ResistanceMagical, + ResistanceRanged, + Critical, + Healing, + MovementSpeed, + DamageVsBeasts, + DamageVsUndead, + CritterSlaying, +} + +impl FromStr for StatType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "health" => Ok(StatType::Health), + "mana" => Ok(StatType::Mana), + "healthregen" => Ok(StatType::HealthRegen), + "manaregen" => Ok(StatType::ManaRegen), + "damagephysical" => Ok(StatType::DamagePhysical), + "damagemagical" => Ok(StatType::DamageMagical), + "damageranged" => Ok(StatType::DamageRanged), + "accuracyphysical" => Ok(StatType::AccuracyPhysical), + "accuracymagical" => Ok(StatType::AccuracyMagical), + "accuracyranged" => Ok(StatType::AccuracyRanged), + "resistancephysical" => Ok(StatType::ResistancePhysical), + "resistancemagical" => Ok(StatType::ResistanceMagical), + "resistanceranged" => Ok(StatType::ResistanceRanged), + "critical" => Ok(StatType::Critical), + "healing" => Ok(StatType::Healing), + "movementspeed" => Ok(StatType::MovementSpeed), + "damagevsbeasts" => Ok(StatType::DamageVsBeasts), + "damagevsundead" => Ok(StatType::DamageVsUndead), + "critterslaying" => Ok(StatType::CritterSlaying), + _ => Ok(StatType::None), + } + } +} + +// ============================================================================ +// Nested Structs +// ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Item { - // Required fields - pub id: i32, - pub name: String, - - // Optional basic attributes - pub level: Option, - pub description: Option, - pub price: Option, - pub slot: Option, - pub category: Option, - pub skill: Option, - pub tool: Option, - - // Item behavior - pub stackable: Option, - pub maxstack: Option, - pub abilityid: Option, - pub swap: Option, - pub twohanded: Option, - - // Food/consumable properties - pub foodamount: Option, - pub foodfrequency: Option, - pub foodtime: Option, - pub foodlevel: Option, - - // Crafting - pub craftingskill: Option, - pub workbench: Option, - pub craftingitems: Option, - - // Visual/audio - pub handmodel: Option, - pub groundmodel: Option, - pub usingitemmodel: Option, - pub dropsfx: Option, - pub pickupsfx: Option, - pub hitgfx: Option, - pub attackanimations: Option, - pub attackanimationspeed: Option, - pub attackhitsounds: Option, - - // Storage - pub storageitem: Option, - pub storagesize: Option, - - // Other flags - pub hidemilestone: Option, - pub generateicon: Option, - pub comment: Option, - - // Nested elements - pub stats: Vec, - pub crafting_recipes: Vec, - pub animations: Option, - pub generate_rules: Vec, +pub struct Stat { + pub stat_type: StatType, + pub value: f32, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ItemStat { - // Damage stats - pub damagephysical: Option, - pub damagemagical: Option, - pub damageranged: Option, +pub struct ItemXpBoost { + pub skill_type: SkillType, + pub multiplier: f32, +} - // Accuracy stats - pub accuracyphysical: Option, - pub accuracymagical: Option, - pub accuracyranged: Option, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermanentStatBoost { + pub stat: StatType, + pub amount: i32, +} - // Resistance stats - pub resistancephysical: Option, - pub resistancemagical: Option, - pub resistanceranged: Option, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomItemName { + pub checks: String, + pub item_name: String, +} - // Core stats - pub health: Option, - pub mana: Option, - pub manaregen: Option, - pub healing: Option, - - // Harvesting stats - pub harvestingspeedwoodcutting: Option, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CraftingRecipeItem { + pub item_id: i32, + pub amount: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CraftingRecipe { - pub workbench: Option, - pub craftingitems: Option, - pub craftingskill: Option, + pub product: i32, + pub level: i32, + pub skill: SkillType, + pub workbench_id: i32, + pub items: Vec, + pub unlocked_by_default: bool, + pub xp: i32, pub checks: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnimationSet { - pub idle: Option, - pub walk: Option, - pub run: Option, - pub weaponattack: Option, - pub takehit: Option, + pub idle: i32, + pub walk: i32, + pub run: i32, + pub takehit: i32, + pub use_anim: i32, + pub weapon_attack: i32, +} + +impl Default for AnimationSet { + fn default() -> Self { + Self { + idle: 0, + walk: 0, + run: 0, + takehit: 0, + use_anim: 0, + weapon_attack: 0, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateRule { - pub generatestats: Option, - pub generatecrafting: Option, - pub generateicon: Option, + pub generate_stats: Option, + pub generate_crafting: bool, +} + +// ============================================================================ +// Main Item Struct +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + // Core identification + pub type_id: i32, + pub item_name: String, + + // Item classification + pub item_type: ItemType, + pub item_categories: Vec, + pub level: i32, + + // Flags + pub undroppable: bool, + pub undroppable_on_death: bool, + pub two_handed: bool, + pub unequip_destroy: bool, + pub generate_icon: bool, + pub hide_milestone: bool, + pub cannot_craft_exceptional: bool, + pub storage_all_items: bool, + + // Visual/UI + pub comment: String, + pub description: String, + pub effect_string: String, + pub use_text: String, + + // Models and resources + pub using_item_model: String, + pub handmodel: String, + pub ground_model: i32, + pub copy_model: i32, + + // Audio + pub drop_sfx: i32, + pub pickup_sfx: i32, + + // Stacking and storage + pub max_stack: i32, + pub storage_items: Vec, + pub storage_size: i32, + + // Abilities and skills + pub ability_id: i32, + pub special_ability: i32, + pub learn_ability_id: i32, + pub skill: SkillType, + pub tool: Tool, + + // Economy + pub price: i32, + #[serde(skip)] + pub setup_price: bool, + + // Food properties + pub food_level: i32, + pub food_time: i32, + pub food_frequency: i32, + pub food_amount: i32, + + // Crafting + pub crafting_recipes: Vec, + pub has_crafting: bool, + + // Stats and bonuses + pub stats: Vec, + pub item_xp_boosts: Vec, + pub permanent_stat_boosts: Vec, + + // Other + pub book_id: i32, + pub swap_item: i32, + pub visibility_xml_checks: Vec, + pub custom_item_names: Vec, + pub custom_item_descriptions: Vec, + + // Animation IDs + pub animations: Option, } impl Item { - pub fn new(id: i32, name: String) -> Self { + pub fn new(type_id: i32, name: String) -> Self { Self { - id, - name, - level: None, - description: None, - price: None, - slot: None, - category: None, - skill: None, - tool: None, - stackable: None, - maxstack: None, - abilityid: None, - swap: None, - twohanded: None, - foodamount: None, - foodfrequency: None, - foodtime: None, - foodlevel: None, - craftingskill: None, - workbench: None, - craftingitems: None, - handmodel: None, - groundmodel: None, - usingitemmodel: None, - dropsfx: None, - pickupsfx: None, - hitgfx: None, - attackanimations: None, - attackanimationspeed: None, - attackhitsounds: None, - storageitem: None, - storagesize: None, - hidemilestone: None, - generateicon: None, - comment: None, - stats: Vec::new(), + type_id, + item_name: name, + item_type: ItemType::Resource, + item_categories: vec![ItemCategory::None], + level: 1, + undroppable: false, + undroppable_on_death: false, + two_handed: false, + unequip_destroy: false, + generate_icon: false, + hide_milestone: false, + cannot_craft_exceptional: false, + storage_all_items: false, + comment: String::new(), + description: String::new(), + effect_string: String::new(), + use_text: String::new(), + using_item_model: String::new(), + handmodel: String::new(), + ground_model: 0, + copy_model: 0, + drop_sfx: 0, + pickup_sfx: 0, + max_stack: 1, + storage_items: Vec::new(), + storage_size: 0, + ability_id: 0, + special_ability: 0, + learn_ability_id: 0, + skill: SkillType::None, + tool: Tool::None, + price: 0, + setup_price: false, + food_level: 0, + food_time: 0, + food_frequency: 0, + food_amount: 0, crafting_recipes: Vec::new(), + has_crafting: false, + stats: Vec::new(), + item_xp_boosts: Vec::new(), + permanent_stat_boosts: Vec::new(), + book_id: 0, + swap_item: 0, + visibility_xml_checks: Vec::new(), + custom_item_names: Vec::new(), + custom_item_descriptions: Vec::new(), animations: None, - generate_rules: Vec::new(), + } + } + + pub fn is_equippable(&self) -> bool { + self.item_type.is_equippable() + } + + pub fn has_category(&self, category: ItemCategory) -> bool { + self.item_categories.contains(&category) + } + + pub fn get_item_name(&self) -> &str { + &self.item_name + } + + pub fn is_stackable(&self) -> bool { + self.max_stack > 1 + } + + pub fn is_storage_item(&self) -> bool { + self.storage_size > 0 || self.storage_all_items + } + + /// Get the equip sound ID based on item type and category + pub fn get_equip_sound(&self) -> i32 { + if self.has_category(ItemCategory::Blade) { + return 845; + } + if self.has_category(ItemCategory::HeavyArmor) || self.has_category(ItemCategory::Armor) { + // Pick random from 846-851 + return 846; + } + + match self.item_type { + ItemType::Weapon => 46, + ItemType::Shield => 47, + ItemType::Head => 46, + ItemType::Armor => 46, + ItemType::Trinket | ItemType::Bracelet => 479, + _ => 0, } } } + +// ============================================================================ +// Legacy compatibility structs (for existing parser) +// ============================================================================ + +/// Legacy ItemStat struct for backwards compatibility with existing XML parser +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ItemStat { + pub damagephysical: Option, + pub damagemagical: Option, + pub damageranged: Option, + pub accuracyphysical: Option, + pub accuracymagical: Option, + pub accuracyranged: Option, + pub resistancephysical: Option, + pub resistancemagical: Option, + pub resistanceranged: Option, + pub health: Option, + pub mana: Option, + pub manaregen: Option, + pub healing: Option, + pub harvestingspeedwoodcutting: Option, +} + +impl ItemStat { + /// Convert legacy ItemStat to new Stat vec + pub fn to_stats(&self) -> Vec { + let mut stats = Vec::new(); + + if let Some(v) = self.damagephysical { + stats.push(Stat { + stat_type: StatType::DamagePhysical, + value: v as f32, + }); + } + if let Some(v) = self.damagemagical { + stats.push(Stat { + stat_type: StatType::DamageMagical, + value: v as f32, + }); + } + if let Some(v) = self.damageranged { + stats.push(Stat { + stat_type: StatType::DamageRanged, + value: v as f32, + }); + } + if let Some(v) = self.accuracyphysical { + stats.push(Stat { + stat_type: StatType::AccuracyPhysical, + value: v as f32, + }); + } + if let Some(v) = self.accuracymagical { + stats.push(Stat { + stat_type: StatType::AccuracyMagical, + value: v as f32, + }); + } + if let Some(v) = self.accuracyranged { + stats.push(Stat { + stat_type: StatType::AccuracyRanged, + value: v as f32, + }); + } + if let Some(v) = self.resistancephysical { + stats.push(Stat { + stat_type: StatType::ResistancePhysical, + value: v as f32, + }); + } + if let Some(v) = self.resistancemagical { + stats.push(Stat { + stat_type: StatType::ResistanceMagical, + value: v as f32, + }); + } + if let Some(v) = self.resistanceranged { + stats.push(Stat { + stat_type: StatType::ResistanceRanged, + value: v as f32, + }); + } + if let Some(v) = self.health { + stats.push(Stat { + stat_type: StatType::Health, + value: v as f32, + }); + } + if let Some(v) = self.mana { + stats.push(Stat { + stat_type: StatType::Mana, + value: v as f32, + }); + } + if let Some(v) = self.manaregen { + stats.push(Stat { + stat_type: StatType::ManaRegen, + value: v as f32, + }); + } + if let Some(v) = self.healing { + stats.push(Stat { + stat_type: StatType::Healing, + value: v as f32, + }); + } + + stats + } +} diff --git a/cursebreaker-parser/src/types/mod.rs b/cursebreaker-parser/src/types/mod.rs index 830e80b..7e2353e 100644 --- a/cursebreaker-parser/src/types/mod.rs +++ b/cursebreaker-parser/src/types/mod.rs @@ -6,7 +6,28 @@ mod harvestable; mod loot; pub use interactable_resource::InteractableResource; -pub use item::{Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule}; +pub use item::{ + // Main types + Item, + ItemStat, + CraftingRecipe, + CraftingRecipeItem, + AnimationSet, + GenerateRule, + // Enums + ItemType, + ItemCategory, + Tool, + SkillType, + StatType, + // Nested structs + Stat, + ItemXpBoost, + PermanentStatBoost, + CustomItemName, + // Constants + MAX_STACK, +}; pub use npc::{Npc, NpcStat, NpcLevel, RightClick, BarkGroup, Bark, QuestMarker, NpcAnimationSet}; pub use quest::{Quest, QuestPhase, QuestReward}; pub use harvestable::{Harvestable, HarvestableDrop}; diff --git a/cursebreaker-parser/src/xml_parser.rs b/cursebreaker-parser/src/xml_parser.rs index c13c18f..e06922c 100644 --- a/cursebreaker-parser/src/xml_parser.rs +++ b/cursebreaker-parser/src/xml_parser.rs @@ -1,5 +1,5 @@ use crate::types::{ - Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule, + Item, ItemStat, AnimationSet, Npc, NpcStat, NpcLevel, RightClick, BarkGroup, QuestMarker, NpcAnimationSet, Quest, QuestPhase, QuestReward, Harvestable, HarvestableDrop, @@ -60,40 +60,32 @@ pub fn parse_items_xml>(path: P) -> Result, XmlParseErr let mut item = Item::new(id, name); - // Parse optional attributes - if let Some(v) = attrs.get("level") { item.level = v.parse().ok(); } - if let Some(v) = attrs.get("description") { item.description = Some(v.clone()); } - if let Some(v) = attrs.get("price") { item.price = v.parse().ok(); } - if let Some(v) = attrs.get("slot") { item.slot = Some(v.clone()); } - if let Some(v) = attrs.get("category") { item.category = Some(v.clone()); } - if let Some(v) = attrs.get("skill") { item.skill = Some(v.clone()); } - if let Some(v) = attrs.get("tool") { item.tool = Some(v.clone()); } - if let Some(v) = attrs.get("stackable") { item.stackable = v.parse().ok(); } - if let Some(v) = attrs.get("maxstack") { item.maxstack = v.parse().ok(); } - if let Some(v) = attrs.get("abilityid") { item.abilityid = v.parse().ok(); } - if let Some(v) = attrs.get("swap") { item.swap = v.parse().ok(); } - if let Some(v) = attrs.get("twohanded") { item.twohanded = v.parse().ok(); } - if let Some(v) = attrs.get("foodamount") { item.foodamount = v.parse().ok(); } - if let Some(v) = attrs.get("foodfrequency") { item.foodfrequency = v.parse().ok(); } - if let Some(v) = attrs.get("foodtime") { item.foodtime = v.parse().ok(); } - if let Some(v) = attrs.get("foodlevel") { item.foodlevel = v.parse().ok(); } - if let Some(v) = attrs.get("craftingskill") { item.craftingskill = Some(v.clone()); } - if let Some(v) = attrs.get("workbench") { item.workbench = v.parse().ok(); } - if let Some(v) = attrs.get("craftingitems") { item.craftingitems = Some(v.clone()); } - if let Some(v) = attrs.get("handmodel") { item.handmodel = Some(v.clone()); } - if let Some(v) = attrs.get("groundmodel") { item.groundmodel = Some(v.clone()); } - if let Some(v) = attrs.get("usingitemmodel") { item.usingitemmodel = Some(v.clone()); } - if let Some(v) = attrs.get("dropsfx") { item.dropsfx = Some(v.clone()); } - if let Some(v) = attrs.get("pickupsfx") { item.pickupsfx = Some(v.clone()); } - if let Some(v) = attrs.get("hitgfx") { item.hitgfx = Some(v.clone()); } - if let Some(v) = attrs.get("attackanimations") { item.attackanimations = Some(v.clone()); } - if let Some(v) = attrs.get("attackanimationspeed") { item.attackanimationspeed = Some(v.clone()); } - if let Some(v) = attrs.get("attackhitsounds") { item.attackhitsounds = Some(v.clone()); } - if let Some(v) = attrs.get("storageitem") { item.storageitem = Some(v.clone()); } - if let Some(v) = attrs.get("storagesize") { item.storagesize = v.parse().ok(); } - if let Some(v) = attrs.get("hidemilestone") { item.hidemilestone = v.parse().ok(); } - if let Some(v) = attrs.get("generateicon") { item.generateicon = v.parse().ok(); } - if let Some(v) = attrs.get("comment") { item.comment = Some(v.clone()); } + // Note: This is a legacy simple parser. For full functionality, + // use the item_loader module's load_items_from_directory function. + + // Parse optional attributes using defaults for new structure + if let Some(v) = attrs.get("level") { item.level = v.parse().unwrap_or(1); } + if let Some(v) = attrs.get("description") { item.description = v.clone(); } + if let Some(v) = attrs.get("price") { item.price = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("maxstack") { item.max_stack = v.parse().unwrap_or(1); } + if let Some(v) = attrs.get("abilityid") { item.ability_id = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("swap") { item.swap_item = v.parse().unwrap_or(0); } + if attrs.get("twohanded").is_some() { item.two_handed = true; } + if let Some(v) = attrs.get("foodamount") { item.food_amount = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("foodfrequency") { item.food_frequency = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("foodtime") { item.food_time = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("foodlevel") { item.food_level = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("handmodel") { item.handmodel = v.clone(); } + if let Some(v) = attrs.get("groundmodel") { + item.ground_model = v.parse().unwrap_or(item.type_id); + } + if let Some(v) = attrs.get("usingitemmodel") { item.using_item_model = v.clone(); } + if let Some(v) = attrs.get("dropsfx") { item.drop_sfx = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("pickupsfx") { item.pickup_sfx = v.parse().unwrap_or(0); } + if let Some(v) = attrs.get("storagesize") { item.storage_size = v.parse().unwrap_or(0); } + if attrs.get("hidemilestone").is_some() { item.hide_milestone = true; } + if attrs.get("generateicon").is_some() { item.generate_icon = true; } + if let Some(v) = attrs.get("comment") { item.comment = v.clone(); } current_item = Some(item); } @@ -101,44 +93,32 @@ pub fn parse_items_xml>(path: P) -> Result, XmlParseErr if let Some(ref mut item) = current_item { let attrs = parse_attributes(&e)?; let stat = parse_stat(&attrs); - item.stats.push(stat); + // Convert legacy ItemStat to new Stat vec + let stats = stat.to_stats(); + item.stats.extend(stats); } } b"crafting" => { - if let Some(ref mut item) = current_item { - let attrs = parse_attributes(&e)?; - let recipe = CraftingRecipe { - workbench: attrs.get("workbench").and_then(|v| v.parse().ok()), - craftingitems: attrs.get("craftingitems").cloned(), - craftingskill: attrs.get("craftingskill").cloned(), - checks: attrs.get("checks").cloned(), - }; - item.crafting_recipes.push(recipe); - } + // Crafting recipes are now handled by item_loader for full functionality + // This is kept for backwards compatibility but doesn't fully populate the new structure } b"anim" => { if let Some(ref mut item) = current_item { let attrs = parse_attributes(&e)?; let anim = AnimationSet { - idle: attrs.get("idle").cloned(), - walk: attrs.get("walk").cloned(), - run: attrs.get("run").cloned(), - weaponattack: attrs.get("weaponattack").cloned(), - takehit: attrs.get("takehit").cloned(), + idle: attrs.get("idle").and_then(|v| v.parse().ok()).unwrap_or(0), + walk: attrs.get("walk").and_then(|v| v.parse().ok()).unwrap_or(0), + run: attrs.get("run").and_then(|v| v.parse().ok()).unwrap_or(0), + weapon_attack: attrs.get("weaponattack").and_then(|v| v.parse().ok()).unwrap_or(0), + takehit: attrs.get("takehit").and_then(|v| v.parse().ok()).unwrap_or(0), + use_anim: attrs.get("use").and_then(|v| v.parse().ok()).unwrap_or(0), }; item.animations = Some(anim); } } b"generate" => { - if let Some(ref mut item) = current_item { - let attrs = parse_attributes(&e)?; - let rule = GenerateRule { - generatestats: attrs.get("generatestats").cloned(), - generatecrafting: attrs.get("generatecrafting").and_then(|v| v.parse().ok()), - generateicon: attrs.get("generateicon").and_then(|v| v.parse().ok()), - }; - item.generate_rules.push(rule); - } + // Generate rules are now handled by item_loader for full functionality + // This is kept for backwards compatibility but doesn't process them } _ => {} }