item DB extension

This commit is contained in:
2026-01-12 03:02:45 +00:00
parent ebee7fd19c
commit 8438dabf0b
12 changed files with 1192 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
-- Undo the add_item_images migration
-- Note: SQLite doesn't support DROP COLUMN in ALTER TABLE
-- The icon columns will remain but can be set to NULL
-- To truly revert, you would need to recreate the table without the image columns

View File

@@ -0,0 +1,6 @@
-- Add item icon columns (WebP format)
-- These store the processed WebP images at different resolutions
ALTER TABLE items ADD COLUMN icon_large BLOB; -- 256x256 WebP
ALTER TABLE items ADD COLUMN icon_medium BLOB; -- 64x64 WebP
ALTER TABLE items ADD COLUMN icon_small BLOB; -- 16x16 WebP

View File

@@ -0,0 +1,6 @@
-- Undo the add_item_stats migration
DROP INDEX IF EXISTS idx_item_stats_type_value;
DROP INDEX IF EXISTS idx_item_stats_value;
DROP INDEX IF EXISTS idx_item_stats_stat_type;
DROP TABLE IF EXISTS item_stats;

View File

@@ -0,0 +1,15 @@
-- Create item_stats table for normalized stat storage
CREATE TABLE item_stats (
item_id INTEGER NOT NULL,
stat_type TEXT NOT NULL,
value REAL NOT NULL,
PRIMARY KEY (item_id, stat_type),
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);
-- Create indexes for querying
CREATE INDEX idx_item_stats_stat_type ON item_stats(stat_type);
CREATE INDEX idx_item_stats_value ON item_stats(value);
-- Index for finding items by stat value ranges
CREATE INDEX idx_item_stats_type_value ON item_stats(stat_type, value);

View File

@@ -0,0 +1,68 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
// Check items with images
#[derive(Queryable)]
struct ItemImageInfo {
id: Option<i32>,
name: String,
icon_large: Option<Vec<u8>>,
icon_medium: Option<Vec<u8>>,
icon_small: Option<Vec<u8>>,
}
use cursebreaker_parser::schema::items::dsl::*;
// Count items with images
let all_items: Vec<ItemImageInfo> = items
.select((id, name, icon_large, icon_medium, icon_small))
.load(&mut conn)?;
let items_with_images = all_items.iter().filter(|item| item.icon_large.is_some()).count();
let items_without_images = all_items.len() - items_with_images;
println!("✅ Image Statistics:\n");
println!(" Total items: {}", all_items.len());
println!(" Items with icons: {}", items_with_images);
println!(" Items without icons: {}", items_without_images);
// Show sample images with sizes
println!("\n📸 Sample items with icons:\n");
for (i, item) in all_items.iter().filter(|item| item.icon_large.is_some()).take(5).enumerate() {
let large_size = item.icon_large.as_ref().map(|v| v.len()).unwrap_or(0);
let medium_size = item.icon_medium.as_ref().map(|v| v.len()).unwrap_or(0);
let small_size = item.icon_small.as_ref().map(|v| v.len()).unwrap_or(0);
let total_size = large_size + medium_size + small_size;
println!(
" {}. {} (ID: {})",
i + 1,
item.name,
item.id.unwrap_or(0)
);
println!(
" Large (256px): {:.1} KB | Medium (64px): {:.1} KB | Small (16px): {:.1} KB | Total: {:.1} KB",
large_size as f64 / 1024.0,
medium_size as f64 / 1024.0,
small_size as f64 / 1024.0,
total_size as f64 / 1024.0
);
}
// Calculate total storage used by images
let total_storage: usize = all_items.iter().map(|item| {
item.icon_large.as_ref().map(|v| v.len()).unwrap_or(0) +
item.icon_medium.as_ref().map(|v| v.len()).unwrap_or(0) +
item.icon_small.as_ref().map(|v| v.len()).unwrap_or(0)
}).sum();
println!("\n💾 Total image storage: {:.2} MB", total_storage as f64 / 1024.0 / 1024.0);
Ok(())
}

View File

@@ -0,0 +1,131 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
// Count total stats
use cursebreaker_parser::schema::item_stats::dsl::*;
use diesel::dsl::count_star;
let total_stats: i64 = item_stats.select(count_star()).first(&mut conn)?;
println!("✅ Item Stats Statistics:\n");
println!(" Total stat entries: {}", total_stats);
// Get stats breakdown by type
#[derive(Queryable)]
struct StatTypeCount {
stat_type: String,
count: i64,
}
let stats_by_type: Vec<StatTypeCount> = item_stats
.group_by(stat_type)
.select((stat_type, count_star()))
.order_by(count_star().desc())
.load(&mut conn)?;
println!("\n📊 Stats breakdown by type:\n");
for stat in &stats_by_type {
println!(" {}: {} items", stat.stat_type, stat.count);
}
// Find items with the most stats
#[derive(Queryable)]
struct ItemStatCount {
item_id: i32,
stat_count: i64,
}
let items_with_most_stats: Vec<ItemStatCount> = item_stats
.group_by(item_id)
.select((item_id, count_star()))
.order_by(count_star().desc())
.limit(5)
.load(&mut conn)?;
println!("\n🏆 Items with most stats:\n");
for item_stat in items_with_most_stats {
// Get item name
use cursebreaker_parser::schema::items;
let item_name: String = items::table
.filter(items::id.eq(item_stat.item_id))
.select(items::name)
.first(&mut conn)?;
println!(" {} (ID: {}) - {} stats", item_name, item_stat.item_id, item_stat.stat_count);
// Get the actual stats for this item
#[derive(Queryable)]
struct ItemStatDetail {
stat_type: String,
value: f32,
}
let stats: Vec<ItemStatDetail> = item_stats
.filter(item_id.eq(item_stat.item_id))
.select((stat_type, value))
.load(&mut conn)?;
for stat in stats {
println!(" {}: {}", stat.stat_type, stat.value);
}
println!();
}
// Show some example stat queries
println!("📈 Example queries:\n");
// Items with high physical damage
#[derive(Queryable)]
struct ItemWithStat {
item_id: i32,
value: f32,
}
let high_damage_items: Vec<ItemWithStat> = item_stats
.filter(stat_type.eq("damage_physical"))
.filter(value.gt(50.0))
.select((item_id, value))
.order_by(value.desc())
.limit(5)
.load(&mut conn)?;
if !high_damage_items.is_empty() {
println!(" Items with Physical Damage > 50:");
for item in high_damage_items {
use cursebreaker_parser::schema::items;
let item_name: String = items::table
.filter(items::id.eq(item.item_id))
.select(items::name)
.first(&mut conn)?;
println!(" {} - {:.1} damage", item_name, item.value);
}
}
// Items with health bonuses
let health_items: Vec<ItemWithStat> = item_stats
.filter(stat_type.eq("health"))
.filter(value.gt(0.0))
.select((item_id, value))
.order_by(value.desc())
.limit(5)
.load(&mut conn)?;
if !health_items.is_empty() {
println!("\n Items with Health bonuses:");
for item in health_items {
use cursebreaker_parser::schema::items;
let item_name: String = items::table
.filter(items::id.eq(item.item_id))
.select(items::name)
.first(&mut conn)?;
println!(" {} - {:.0} health", item_name, item.value);
}
}
Ok(())
}

View File

@@ -69,8 +69,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
match item_db.save_to_db(&mut conn) {
Ok(count) => info!("✅ Saved {} items to database", count),
// Process and save items with icons
let icon_path = format!("{}/Data/Textures/ItemIcons", cb_assets_path);
info!("📸 Processing item icons from: {}", icon_path);
match item_db.save_to_db_with_images(&mut conn, &icon_path) {
Ok((items_count, images_count)) => {
info!("✅ Saved {} items to database", items_count);
info!("✅ Processed {} item icons (256px, 64px, 16px)", images_count);
}
Err(e) => warn!("⚠️ Failed to save items: {}", e),
}

View File

@@ -1,3 +1,4 @@
use crate::image_processor::ImageProcessor;
use crate::item_loader::{
calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory,
};
@@ -6,7 +7,7 @@ use crate::xml_parser::{parse_items_xml, XmlParseError};
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::path::{Path, PathBuf};
/// A database for managing game items loaded from XML files
#[derive(Debug, Clone)]
@@ -332,6 +333,209 @@ impl ItemDatabase {
})
}
/// 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>>) {
let icon_file = icon_base_path.join(format!("{}.png", item_id));
if !icon_file.exists() {
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::*;
@@ -363,6 +567,9 @@ impl ItemDatabase {
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)?;

View File

@@ -38,6 +38,14 @@ diesel::table! {
}
}
diesel::table! {
item_stats (item_id, stat_type) {
item_id -> Integer,
stat_type -> Text,
value -> Float,
}
}
diesel::table! {
items (id) {
id -> Nullable<Integer>,
@@ -64,6 +72,9 @@ diesel::table! {
learn_ability_id -> Integer,
book_id -> Integer,
swap_item -> Integer,
icon_large -> Nullable<Binary>,
icon_medium -> Nullable<Binary>,
icon_small -> Nullable<Binary>,
}
}
@@ -147,12 +158,14 @@ diesel::table! {
diesel::joinable!(crafting_recipe_items -> crafting_recipes (recipe_id));
diesel::joinable!(crafting_recipe_items -> items (item_id));
diesel::joinable!(crafting_recipes -> items (product_item_id));
diesel::joinable!(item_stats -> items (item_id));
diesel::allow_tables_to_appear_in_same_query!(
crafting_recipe_items,
crafting_recipes,
fast_travel_locations,
harvestables,
item_stats,
items,
loot_tables,
maps,