use crate::image_processor::ImageProcessor; use crate::item_loader::{ calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory, }; use crate::types::Item; use crate::xml_parsers::{parse_items_xml, XmlParseError}; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; /// A database for managing game items loaded from XML files #[derive(Debug, Clone)] pub struct ItemDatabase { items: Vec, items_by_id: HashMap, items_by_name: HashMap>, stackable_item_ids: HashSet, storage_item_ids: HashSet, } impl ItemDatabase { /// Create a new empty ItemDatabase pub fn new() -> Self { Self { 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 (basic loading without advanced features) pub fn load_from_xml>(path: P) -> Result { let items = parse_items_xml(path)?; let mut db = Self::new(); db.add_items(items); 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.type_id, index); // Add to name index (can have multiple items with same name) self.items_by_name .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 .get(&id) .and_then(|&index| self.items.get(index)) } /// Get items by name (returns all items with matching name) pub fn get_by_name(&self, name: &str) -> Vec<&Item> { self.items_by_name .get(name) .map(|indices| { indices .iter() .filter_map(|&index| self.items.get(index)) .collect() }) .unwrap_or_default() } /// Get all items pub fn all_items(&self) -> &[Item] { &self.items } /// Get items by category pub fn get_by_category(&self, category: &str) -> Vec<&Item> { 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/item type pub fn get_by_slot(&self, slot: &str) -> Vec<&Item> { 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> { 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 pub fn len(&self) -> usize { self.items.len() } /// Check if database is empty pub fn is_empty(&self) -> bool { self.items.is_empty() } /// Serialize items to JSON for SQL storage #[cfg(feature = "diesel")] pub fn to_json(&self) -> Result { serde_json::to_string(&self.items) } /// Prepare items for SQL insertion (deprecated - use save_to_db instead) #[deprecated(note = "Use save_to_db() to save directly to SQLite database")] pub fn prepare_for_sql(&self) -> Vec<(i32, String, String)> { self.items .iter() .map(|item| { let json = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string()); (item.type_id, item.item_name.clone(), json) }) .collect() } /// Save all items to SQLite database pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result { use crate::schema::{items, crafting_recipes, crafting_recipe_items}; use diesel::replace_into; conn.transaction::<_, diesel::result::Error, _>(|conn| { let mut count = 0; for item in &self.items { let json = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string()); // Insert/replace item with all columns replace_into(items::table) .values(( items::id.eq(item.type_id), items::name.eq(&item.item_name), items::data.eq(json), items::item_type.eq(item.item_type.to_string()), items::level.eq(item.level), items::price.eq(item.price), items::max_stack.eq(item.max_stack), items::storage_size.eq(item.storage_size), items::skill.eq(match item.skill { crate::types::SkillType::None => "none", crate::types::SkillType::Swordsmanship => "swordsmanship", crate::types::SkillType::Archery => "archery", crate::types::SkillType::Magic => "magic", crate::types::SkillType::Defence => "defence", crate::types::SkillType::Mining => "mining", crate::types::SkillType::Woodcutting => "woodcutting", crate::types::SkillType::Fishing => "fishing", crate::types::SkillType::Cooking => "cooking", crate::types::SkillType::Carpentry => "carpentry", crate::types::SkillType::Blacksmithy => "blacksmithy", crate::types::SkillType::Tailoring => "tailoring", crate::types::SkillType::Alchemy => "alchemy", }), items::tool.eq(match item.tool { crate::types::Tool::None => "none", crate::types::Tool::Pickaxe => "pickaxe", crate::types::Tool::Hatchet => "hatchet", crate::types::Tool::Scythe => "scythe", crate::types::Tool::Hammer => "hammer", crate::types::Tool::Shears => "shears", crate::types::Tool::FishingRod => "fishingrod", }), items::description.eq(&item.description), items::two_handed.eq(item.two_handed as i32), items::undroppable.eq(item.undroppable as i32), items::undroppable_on_death.eq(item.undroppable_on_death as i32), items::unequip_destroy.eq(item.unequip_destroy as i32), items::generate_icon.eq(item.generate_icon as i32), items::hide_milestone.eq(item.hide_milestone as i32), items::cannot_craft_exceptional.eq(item.cannot_craft_exceptional as i32), items::storage_all_items.eq(item.storage_all_items as i32), items::ability_id.eq(item.ability_id), items::special_ability.eq(item.special_ability), items::learn_ability_id.eq(item.learn_ability_id), items::book_id.eq(item.book_id), items::swap_item.eq(item.swap_item), )) .execute(conn)?; // Save crafting recipes for this item for recipe in &item.crafting_recipes { use diesel::prelude::*; // Insert recipe diesel::insert_into(crafting_recipes::table) .values(( crafting_recipes::product_item_id.eq(item.type_id), crafting_recipes::skill.eq(match recipe.skill { crate::types::SkillType::None => "none", crate::types::SkillType::Swordsmanship => "swordsmanship", crate::types::SkillType::Archery => "archery", crate::types::SkillType::Magic => "magic", crate::types::SkillType::Defence => "defence", crate::types::SkillType::Mining => "mining", crate::types::SkillType::Woodcutting => "woodcutting", crate::types::SkillType::Fishing => "fishing", crate::types::SkillType::Cooking => "cooking", crate::types::SkillType::Carpentry => "carpentry", crate::types::SkillType::Blacksmithy => "blacksmithy", crate::types::SkillType::Tailoring => "tailoring", crate::types::SkillType::Alchemy => "alchemy", }), crafting_recipes::level.eq(recipe.level), crafting_recipes::workbench_id.eq(recipe.workbench_id), crafting_recipes::xp.eq(recipe.xp), crafting_recipes::unlocked_by_default.eq(recipe.unlocked_by_default as i32), crafting_recipes::checks.eq(recipe.checks.as_ref()), )) .execute(conn)?; // Get the recipe_id we just inserted let recipe_id: i32 = diesel::select(diesel::dsl::sql::( "last_insert_rowid()" )) .get_result(conn)?; // Insert recipe items (ingredients) for ingredient in &recipe.items { diesel::insert_into(crafting_recipe_items::table) .values(( crafting_recipe_items::recipe_id.eq(recipe_id), crafting_recipe_items::item_id.eq(ingredient.item_id), crafting_recipe_items::amount.eq(ingredient.amount), )) .execute(conn)?; } } count += 1; } Ok(count) }) } /// Save all items to SQLite database with icon processing /// /// # Arguments /// * `conn` - Database connection /// * `icon_path` - Path to the ItemIcons directory (e.g., "CBAssets/Data/Textures/ItemIcons") /// /// # Returns /// Tuple of (items_saved, images_processed) pub fn save_to_db_with_images>( &self, conn: &mut SqliteConnection, icon_path: P, ) -> Result<(usize, usize), diesel::result::Error> { use crate::schema::items; use diesel::replace_into; let icon_base_path = icon_path.as_ref(); let processor = ImageProcessor::new(85.0); // 85% WebP quality let mut images_processed = 0; conn.transaction::<_, diesel::result::Error, _>(|conn| { let mut count = 0; for item in &self.items { let json = serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string()); // Process item icon if it exists let (icon_large, icon_medium, icon_small) = Self::process_item_icon(&processor, icon_base_path, item.type_id); if icon_large.is_some() { images_processed += 1; } // Insert/replace item with all columns including images replace_into(items::table) .values(( items::id.eq(item.type_id), items::name.eq(&item.item_name), items::data.eq(json), items::item_type.eq(item.item_type.to_string()), items::level.eq(item.level), items::price.eq(item.price), items::max_stack.eq(item.max_stack), items::storage_size.eq(item.storage_size), items::skill.eq(match item.skill { crate::types::SkillType::None => "none", crate::types::SkillType::Swordsmanship => "swordsmanship", crate::types::SkillType::Archery => "archery", crate::types::SkillType::Magic => "magic", crate::types::SkillType::Defence => "defence", crate::types::SkillType::Mining => "mining", crate::types::SkillType::Woodcutting => "woodcutting", crate::types::SkillType::Fishing => "fishing", crate::types::SkillType::Cooking => "cooking", crate::types::SkillType::Carpentry => "carpentry", crate::types::SkillType::Blacksmithy => "blacksmithy", crate::types::SkillType::Tailoring => "tailoring", crate::types::SkillType::Alchemy => "alchemy", }), items::tool.eq(match item.tool { crate::types::Tool::None => "none", crate::types::Tool::Pickaxe => "pickaxe", crate::types::Tool::Hatchet => "hatchet", crate::types::Tool::Scythe => "scythe", crate::types::Tool::Hammer => "hammer", crate::types::Tool::Shears => "shears", crate::types::Tool::FishingRod => "fishingrod", }), items::description.eq(&item.description), items::two_handed.eq(item.two_handed as i32), items::undroppable.eq(item.undroppable as i32), items::undroppable_on_death.eq(item.undroppable_on_death as i32), items::unequip_destroy.eq(item.unequip_destroy as i32), items::generate_icon.eq(item.generate_icon as i32), items::hide_milestone.eq(item.hide_milestone as i32), items::cannot_craft_exceptional.eq(item.cannot_craft_exceptional as i32), items::storage_all_items.eq(item.storage_all_items as i32), items::ability_id.eq(item.ability_id), items::special_ability.eq(item.special_ability), items::learn_ability_id.eq(item.learn_ability_id), items::book_id.eq(item.book_id), items::swap_item.eq(item.swap_item), items::icon_large.eq(icon_large.as_ref()), items::icon_medium.eq(icon_medium.as_ref()), items::icon_small.eq(icon_small.as_ref()), )) .execute(conn)?; // Save crafting recipes for this item (same as before) for recipe in &item.crafting_recipes { use diesel::prelude::*; diesel::insert_into(crate::schema::crafting_recipes::table) .values(( crate::schema::crafting_recipes::product_item_id.eq(item.type_id), crate::schema::crafting_recipes::skill.eq(match recipe.skill { crate::types::SkillType::None => "none", crate::types::SkillType::Swordsmanship => "swordsmanship", crate::types::SkillType::Archery => "archery", crate::types::SkillType::Magic => "magic", crate::types::SkillType::Defence => "defence", crate::types::SkillType::Mining => "mining", crate::types::SkillType::Woodcutting => "woodcutting", crate::types::SkillType::Fishing => "fishing", crate::types::SkillType::Cooking => "cooking", crate::types::SkillType::Carpentry => "carpentry", crate::types::SkillType::Blacksmithy => "blacksmithy", crate::types::SkillType::Tailoring => "tailoring", crate::types::SkillType::Alchemy => "alchemy", }), crate::schema::crafting_recipes::level.eq(recipe.level), crate::schema::crafting_recipes::workbench_id.eq(recipe.workbench_id), crate::schema::crafting_recipes::xp.eq(recipe.xp), crate::schema::crafting_recipes::unlocked_by_default.eq(recipe.unlocked_by_default as i32), crate::schema::crafting_recipes::checks.eq(recipe.checks.as_ref()), )) .execute(conn)?; let recipe_id: i32 = diesel::select(diesel::dsl::sql::( "last_insert_rowid()" )) .get_result(conn)?; for ingredient in &recipe.items { diesel::insert_into(crate::schema::crafting_recipe_items::table) .values(( crate::schema::crafting_recipe_items::recipe_id.eq(recipe_id), crate::schema::crafting_recipe_items::item_id.eq(ingredient.item_id), crate::schema::crafting_recipe_items::amount.eq(ingredient.amount), )) .execute(conn)?; } } // Save item stats for stat in &item.stats { let stat_type_str = match stat.stat_type { crate::types::StatType::None => "none", crate::types::StatType::Health => "health", crate::types::StatType::Mana => "mana", crate::types::StatType::HealthRegen => "health_regen", crate::types::StatType::ManaRegen => "mana_regen", crate::types::StatType::DamagePhysical => "damage_physical", crate::types::StatType::DamageMagical => "damage_magical", crate::types::StatType::DamageRanged => "damage_ranged", crate::types::StatType::AccuracyPhysical => "accuracy_physical", crate::types::StatType::AccuracyMagical => "accuracy_magical", crate::types::StatType::AccuracyRanged => "accuracy_ranged", crate::types::StatType::ResistancePhysical => "resistance_physical", crate::types::StatType::ResistanceMagical => "resistance_magical", crate::types::StatType::ResistanceRanged => "resistance_ranged", crate::types::StatType::Critical => "critical", crate::types::StatType::Healing => "healing", crate::types::StatType::MovementSpeed => "movement_speed", crate::types::StatType::DamageVsBeasts => "damage_vs_beasts", crate::types::StatType::DamageVsUndead => "damage_vs_undead", crate::types::StatType::CritterSlaying => "critter_slaying", }; diesel::insert_into(crate::schema::item_stats::table) .values(( crate::schema::item_stats::item_id.eq(item.type_id), crate::schema::item_stats::stat_type.eq(stat_type_str), crate::schema::item_stats::value.eq(stat.value), )) .execute(conn)?; } count += 1; } Ok((count, images_processed)) }) } /// Helper function to process a single item icon /// Returns (large, medium, small) WebP blobs fn process_item_icon( processor: &ImageProcessor, icon_base_path: &Path, item_id: i32, ) -> (Option>, Option>, Option>) { // Try both lowercase and uppercase extensions (Linux is case-sensitive) let lowercase = icon_base_path.join(format!("{}.png", item_id)); let uppercase = icon_base_path.join(format!("{}.PNG", item_id)); let icon_file = if lowercase.exists() { lowercase } else if uppercase.exists() { uppercase } else { return (None, None, None); }; // Process image at 3 sizes: 256, 64, 16 match processor.process_image(&icon_file, &[256, 64, 16], None, None) { Ok(processed) => ( processed.get(256).cloned(), processed.get(64).cloned(), processed.get(16).cloned(), ), Err(e) => { log::warn!("Failed to process icon for item {}: {}", item_id, e); (None, None, None) } } } /// Load all items from SQLite database pub fn load_from_db(conn: &mut SqliteConnection) -> Result { use crate::schema::items::dsl::*; #[derive(Queryable)] #[allow(dead_code)] struct ItemRecord { id: Option, name: String, data: String, item_type: String, level: i32, price: i32, max_stack: i32, storage_size: i32, skill: String, tool: String, description: String, two_handed: i32, undroppable: i32, undroppable_on_death: i32, unequip_destroy: i32, generate_icon: i32, hide_milestone: i32, cannot_craft_exceptional: i32, storage_all_items: i32, ability_id: i32, special_ability: i32, learn_ability_id: i32, book_id: i32, swap_item: i32, icon_large: Option>, icon_medium: Option>, icon_small: Option>, } let records = items.load::(conn)?; let mut loaded_items = Vec::new(); for record in records { // Load from JSON data column (contains complete item info including crafting recipes) if let Ok(item) = serde_json::from_str::(&record.data) { loaded_items.push(item); } } let mut db = Self::new(); db.add_items(loaded_items); Ok(db) } } impl Default for ItemDatabase { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_item_database_basic() { let mut db = ItemDatabase::new(); assert!(db.is_empty()); assert_eq!(db.len(), 0); } }