item DB extension
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
68
cursebreaker-parser/src/bin/verify-images.rs
Normal file
68
cursebreaker-parser/src/bin/verify-images.rs
Normal 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(())
|
||||
}
|
||||
131
cursebreaker-parser/src/bin/verify-stats.rs
Normal file
131
cursebreaker-parser/src/bin/verify-stats.rs
Normal 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(())
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user