Files
cursebreaker-parser-rust/cursebreaker-parser/src/databases/item_database.rs
2026-01-26 13:05:57 +00:00

614 lines
26 KiB
Rust

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<Item>,
items_by_id: HashMap<i32, usize>,
items_by_name: HashMap<String, Vec<usize>>,
stackable_item_ids: HashSet<i32>,
storage_item_ids: HashSet<i32>,
}
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<P: AsRef<Path>>(path: P) -> Result<Self, XmlParseError> {
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<P: AsRef<Path>>(dir: P) -> Result<Self, XmlParseError> {
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<Item>) {
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<String, serde_json::Error> {
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<usize, diesel::result::Error> {
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::<diesel::sql_types::Integer>(
"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<P: AsRef<Path>>(
&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::<diesel::sql_types::Integer>(
"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<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) {
// 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<Self, diesel::result::Error> {
use crate::schema::items::dsl::*;
#[derive(Queryable)]
#[allow(dead_code)]
struct ItemRecord {
id: Option<i32>,
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<Vec<u8>>,
icon_medium: Option<Vec<u8>>,
icon_small: Option<Vec<u8>>,
}
let records = items.load::<ItemRecord>(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::<Item>(&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);
}
}