resource icons DB
This commit is contained in:
@@ -1,317 +0,0 @@
|
||||
# Item Images in Database - Implementation Summary
|
||||
|
||||
## ✅ Completed Successfully
|
||||
|
||||
Successfully added WebP image processing and storage for item icons in the database.
|
||||
|
||||
---
|
||||
|
||||
## What Was Added
|
||||
|
||||
### 1. Database Migration: `add_item_images`
|
||||
|
||||
**New Columns in `items` Table:**
|
||||
- `icon_large` (BLOB) - 256x256 WebP image
|
||||
- `icon_medium` (BLOB) - 64x64 WebP image
|
||||
- `icon_small` (BLOB) - 16x16 WebP image
|
||||
|
||||
All columns are nullable (items without icons will have NULL values).
|
||||
|
||||
### 2. Image Processing in `ItemDatabase`
|
||||
|
||||
**New Method: `save_to_db_with_images()`**
|
||||
```rust
|
||||
pub fn save_to_db_with_images<P: AsRef<Path>>(
|
||||
&self,
|
||||
conn: &mut SqliteConnection,
|
||||
icon_path: P,
|
||||
) -> Result<(usize, usize), diesel::result::Error>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Processes PNG icons from the ItemIcons directory
|
||||
- Converts to WebP format (85% quality)
|
||||
- Generates 3 sizes: 256px, 64px, 16px
|
||||
- Returns tuple: (items_saved, images_processed)
|
||||
- Handles missing icons gracefully (stores NULL)
|
||||
- Uses transactions for data consistency
|
||||
|
||||
**Helper Method: `process_item_icon()`**
|
||||
- Loads PNG file by item ID (e.g., "33.png" for Copper Ore)
|
||||
- Processes image at 3 resolutions using `ImageProcessor`
|
||||
- Returns tuple of WebP blobs
|
||||
- Logs warnings for failed conversions
|
||||
|
||||
### 3. Updated xml-parser
|
||||
|
||||
The xml-parser now:
|
||||
- Locates ItemIcons directory from CB_ASSETS_PATH
|
||||
- Calls `save_to_db_with_images()` instead of `save_to_db()`
|
||||
- Reports both item count and processed image count
|
||||
|
||||
---
|
||||
|
||||
## Results
|
||||
|
||||
### Processing Statistics
|
||||
|
||||
```
|
||||
✅ Total items: 1360
|
||||
✅ Items with icons: 1228
|
||||
✅ Items without icons: 132 (no source PNG available)
|
||||
💾 Total image storage: 9.03 MB
|
||||
```
|
||||
|
||||
### Sample Item Icons
|
||||
|
||||
```
|
||||
Copper Ore (ID: 33)
|
||||
Large (256px): 9.6 KB | Medium (64px): 2.4 KB | Small (16px): 0.4 KB
|
||||
Total per item: 12.4 KB
|
||||
|
||||
Iron Ore (ID: 34)
|
||||
Large (256px): 3.3 KB | Medium (64px): 1.8 KB | Small (16px): 0.4 KB
|
||||
Total per item: 5.5 KB
|
||||
```
|
||||
|
||||
### Storage Efficiency
|
||||
|
||||
**WebP Compression Benefits:**
|
||||
- Original PNG (256x256, RGBA): ~20-40 KB per icon
|
||||
- WebP Large (256px): ~3-10 KB (70-80% reduction)
|
||||
- WebP Medium (64px): ~1-2 KB
|
||||
- WebP Small (16px): ~0.3-0.5 KB
|
||||
|
||||
**Total storage for 1228 items with 3 sizes each:**
|
||||
- Only 9.03 MB for all processed images
|
||||
- Average ~7.4 KB per item (all 3 sizes combined)
|
||||
- Excellent compression with minimal quality loss
|
||||
|
||||
---
|
||||
|
||||
## Code Changes
|
||||
|
||||
### Files Modified
|
||||
|
||||
1. **`migrations/2026-01-11-135041-0000_add_item_images/up.sql`**
|
||||
- Added 3 BLOB columns to items table
|
||||
|
||||
2. **`src/schema.rs`**
|
||||
- Auto-regenerated with new columns
|
||||
|
||||
3. **`src/databases/item_database.rs`**
|
||||
- Added `save_to_db_with_images()` method
|
||||
- Added `process_item_icon()` helper
|
||||
- Updated `load_from_db()` ItemRecord struct
|
||||
|
||||
4. **`src/bin/xml-parser.rs`**
|
||||
- Updated to use `save_to_db_with_images()`
|
||||
- Added icon path construction
|
||||
|
||||
### Files Created
|
||||
|
||||
1. **`src/bin/verify-images.rs`**
|
||||
- Verification tool to check image storage
|
||||
- Shows statistics and sample image sizes
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Saving Items with Images
|
||||
|
||||
```rust
|
||||
use cursebreaker_parser::ItemDatabase;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
|
||||
let item_db = ItemDatabase::load_from_xml("items.xml")?;
|
||||
let mut conn = SqliteConnection::establish("database.db")?;
|
||||
|
||||
// Process and save with images
|
||||
let icon_path = "/path/to/CBAssets/Data/Textures/ItemIcons";
|
||||
let (items_count, images_count) = item_db.save_to_db_with_images(&mut conn, icon_path)?;
|
||||
|
||||
println!("Saved {} items with {} icons", items_count, images_count);
|
||||
```
|
||||
|
||||
### Retrieving Images from Database
|
||||
|
||||
```sql
|
||||
-- Get item with images
|
||||
SELECT id, name, icon_large, icon_medium, icon_small
|
||||
FROM items
|
||||
WHERE id = 33; -- Copper Ore
|
||||
|
||||
-- Find items with icons
|
||||
SELECT id, name
|
||||
FROM items
|
||||
WHERE icon_large IS NOT NULL;
|
||||
|
||||
-- Get just the small icon for quick previews
|
||||
SELECT id, name, icon_small
|
||||
FROM items;
|
||||
```
|
||||
|
||||
### Serving Images in Web API
|
||||
|
||||
For your interactive map and wiki:
|
||||
|
||||
1. **Small icons (16px)** - List views, tooltips, inventory grids
|
||||
2. **Medium icons (64px)** - Item cards, search results, crafting UI
|
||||
3. **Large icons (256px)** - Detail pages, zoomed views, high-res displays
|
||||
|
||||
**Example API endpoint:**
|
||||
```rust
|
||||
// Get item icon by size
|
||||
async fn get_item_icon(item_id: i32, size: String) -> Result<Vec<u8>> {
|
||||
let item = query_item(item_id)?;
|
||||
|
||||
let icon_data = match size.as_str() {
|
||||
"small" => item.icon_small,
|
||||
"medium" => item.icon_medium,
|
||||
"large" | _ => item.icon_large,
|
||||
};
|
||||
|
||||
match icon_data {
|
||||
Some(data) => Ok(data),
|
||||
None => Err("Icon not found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Serve as image/webp
|
||||
// Response headers: Content-Type: image/webp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Efficient Storage** - WebP compression saves ~70-80% space
|
||||
✅ **Multiple Resolutions** - Responsive design ready
|
||||
✅ **Self-Contained** - No external file dependencies
|
||||
✅ **Fast Delivery** - No filesystem lookups, direct from DB
|
||||
✅ **Transactional** - Images saved atomically with item data
|
||||
✅ **Graceful Degradation** - Missing icons handled automatically
|
||||
✅ **Web-Optimized** - WebP format supported by all modern browsers
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Processing Time
|
||||
- ~1228 images processed during save
|
||||
- Each image generates 3 sizes (256px, 64px, 16px)
|
||||
- Total: ~3,684 WebP encodings
|
||||
- Processing time: ~5-10 seconds on modern hardware
|
||||
|
||||
### Database Size Impact
|
||||
- Before images: ~130 MB (JSON data + columns)
|
||||
- After images: ~139 MB (+9 MB for all icons)
|
||||
- Only 7% increase in database size
|
||||
- All icons for all sizes included
|
||||
|
||||
### Query Performance
|
||||
- Small overhead when selecting items (if loading all columns)
|
||||
- Minimal impact if only selecting needed columns
|
||||
- Consider separate queries for image data vs metadata
|
||||
|
||||
**Optimization Tips:**
|
||||
```sql
|
||||
-- Fast: Get metadata only
|
||||
SELECT id, name, item_type, level, price
|
||||
FROM items;
|
||||
|
||||
-- Slower: Include large images unnecessarily
|
||||
SELECT *
|
||||
FROM items;
|
||||
|
||||
-- Optimal: Get specific image size when needed
|
||||
SELECT id, name, icon_small
|
||||
FROM items;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Optional Additions:
|
||||
|
||||
1. **Lazy Loading** - Separate images table with FK to items
|
||||
```sql
|
||||
CREATE TABLE item_images (
|
||||
item_id INTEGER PRIMARY KEY,
|
||||
icon_large BLOB,
|
||||
icon_medium BLOB,
|
||||
icon_small BLOB,
|
||||
FOREIGN KEY (item_id) REFERENCES items(id)
|
||||
);
|
||||
```
|
||||
|
||||
2. **Image Metadata** - Track source info
|
||||
- Original file path
|
||||
- Processing timestamp
|
||||
- Source dimensions
|
||||
- Compression ratio
|
||||
|
||||
3. **Additional Sizes** - More resolution options
|
||||
- 512px for retina displays
|
||||
- 32px for medium-sized UI elements
|
||||
- 8px for tiny thumbnails
|
||||
|
||||
4. **Image Variants** - Different visual styles
|
||||
- Grayscale for disabled/locked items
|
||||
- Outlined versions for specific UI needs
|
||||
- Colored overlays for rarity/quality
|
||||
|
||||
---
|
||||
|
||||
## Migration Management
|
||||
|
||||
**To rollback (columns stay but can be cleared):**
|
||||
```bash
|
||||
diesel migration revert
|
||||
```
|
||||
|
||||
**To reprocess images after icon updates:**
|
||||
```bash
|
||||
# Just rerun the xml-parser - it uses REPLACE INTO
|
||||
cargo run --bin xml-parser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Verify Images
|
||||
|
||||
```bash
|
||||
# Check image statistics
|
||||
cargo run --bin verify-images
|
||||
|
||||
# Extract and view an image
|
||||
sqlite3 cursebreaker.db "SELECT hex(icon_large) FROM items WHERE id=33;" | xxd -r -p > copper_ore.webp
|
||||
# View with: xdg-open copper_ore.webp
|
||||
```
|
||||
|
||||
### Performance Test
|
||||
|
||||
```bash
|
||||
# Time the full processing
|
||||
time cargo run --bin xml-parser
|
||||
|
||||
# Should complete in 5-15 seconds for 1228 images
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ready for Production
|
||||
|
||||
Your item images are now:
|
||||
- ✅ Stored in the database
|
||||
- ✅ Optimally compressed
|
||||
- ✅ Available in 3 sizes
|
||||
- ✅ Ready for your interactive map and wiki
|
||||
- ✅ Fast to query and serve
|
||||
- ✅ Properly handled when missing
|
||||
|
||||
The database now contains all the data needed for a fully functional item system with visual assets!
|
||||
@@ -1,243 +0,0 @@
|
||||
# Items Table Expansion - Migration Plan
|
||||
|
||||
## Goal
|
||||
Expand the items table to support efficient queries for an interactive map and media wiki server.
|
||||
|
||||
## Database Design Strategy
|
||||
|
||||
We'll use a **hybrid approach**:
|
||||
1. **Commonly queried fields** → Direct columns in `items` table
|
||||
2. **One-to-many/many-to-many relationships** → Separate normalized tables
|
||||
3. **Complex nested data** → Keep in JSON `data` column
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Add Core Columns to Items Table
|
||||
|
||||
### New Columns for `items` table:
|
||||
```sql
|
||||
ALTER TABLE items ADD COLUMN item_type TEXT NOT NULL DEFAULT 'resource';
|
||||
ALTER TABLE items ADD COLUMN level INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE items ADD COLUMN price INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN max_stack INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE items ADD COLUMN skill TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE items ADD COLUMN tool TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Boolean flags (stored as INTEGER: 0=false, 1=true)
|
||||
ALTER TABLE items ADD COLUMN two_handed INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN undroppable INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN undroppable_on_death INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN unequip_destroy INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN generate_icon INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN hide_milestone INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN cannot_craft_exceptional INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN storage_all_items INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- IDs for relationships
|
||||
ALTER TABLE items ADD COLUMN ability_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN special_ability INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN learn_ability_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN book_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN swap_item INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN storage_size INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
**Use Case:** Direct filtering and sorting
|
||||
- "Show all items with level > 50"
|
||||
- "Show all weapons"
|
||||
- "Show stackable items"
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create Related Tables
|
||||
|
||||
### 2.1 Item Categories (many-to-many)
|
||||
```sql
|
||||
CREATE TABLE item_categories (
|
||||
item_id INTEGER NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
PRIMARY KEY (item_id, category),
|
||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_categories_category ON item_categories(category);
|
||||
```
|
||||
|
||||
**Use Case:**
|
||||
- "Show all bows"
|
||||
- "Show all heavy armor"
|
||||
|
||||
### 2.2 Item Stats (one-to-many)
|
||||
```sql
|
||||
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 INDEX idx_item_stats_stat_type ON item_stats(stat_type);
|
||||
CREATE INDEX idx_item_stats_value ON item_stats(value);
|
||||
```
|
||||
|
||||
**Use Case:**
|
||||
- "Show all items with Health > 100"
|
||||
- "Show all items with DamagePhysical"
|
||||
|
||||
### 2.3 Crafting Recipes (normalized)
|
||||
```sql
|
||||
CREATE TABLE crafting_recipes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_item_id INTEGER NOT NULL,
|
||||
skill TEXT NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
workbench_id INTEGER NOT NULL,
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
unlocked_by_default INTEGER NOT NULL DEFAULT 1,
|
||||
checks TEXT, -- nullable, for conditional recipes
|
||||
FOREIGN KEY (product_item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_crafting_recipes_product ON crafting_recipes(product_item_id);
|
||||
CREATE INDEX idx_crafting_recipes_skill ON crafting_recipes(skill);
|
||||
CREATE INDEX idx_crafting_recipes_level ON crafting_recipes(level);
|
||||
|
||||
CREATE TABLE crafting_recipe_items (
|
||||
recipe_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
PRIMARY KEY (recipe_id, item_id),
|
||||
FOREIGN KEY (recipe_id) REFERENCES crafting_recipes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (item_id) REFERENCES items(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_crafting_recipe_items_item ON crafting_recipe_items(item_id);
|
||||
```
|
||||
|
||||
**Use Case:**
|
||||
- "Show all recipes that use Copper Ore"
|
||||
- "Show all Blacksmithy recipes"
|
||||
- "What can I craft with these items?"
|
||||
|
||||
### 2.4 Item Storage (for storage_items vec)
|
||||
```sql
|
||||
CREATE TABLE item_storage_allowed (
|
||||
storage_item_id INTEGER NOT NULL,
|
||||
allowed_item_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (storage_item_id, allowed_item_id),
|
||||
FOREIGN KEY (storage_item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (allowed_item_id) REFERENCES items(id)
|
||||
);
|
||||
```
|
||||
|
||||
**Use Case:**
|
||||
- "What items can be stored in this container?"
|
||||
|
||||
### 2.5 Item XP Boosts
|
||||
```sql
|
||||
CREATE TABLE item_xp_boosts (
|
||||
item_id INTEGER NOT NULL,
|
||||
skill_type TEXT NOT NULL,
|
||||
multiplier REAL NOT NULL,
|
||||
PRIMARY KEY (item_id, skill_type),
|
||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### 2.6 Permanent Stat Boosts
|
||||
```sql
|
||||
CREATE TABLE item_permanent_stat_boosts (
|
||||
item_id INTEGER NOT NULL,
|
||||
stat_type TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
PRIMARY KEY (item_id, stat_type),
|
||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Keep JSON Data Column
|
||||
|
||||
The `data` column stays as-is for:
|
||||
- Complete item retrieval without joins
|
||||
- Fields rarely queried (animations, models, audio, etc.)
|
||||
- Custom names/descriptions with checks
|
||||
- Future flexibility
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create Migration File
|
||||
- Create `migrations/2026-01-11-expand-items/up.sql`
|
||||
- Create `migrations/2026-01-11-expand-items/down.sql`
|
||||
|
||||
### Step 2: Update Rust Schema
|
||||
- Run `diesel migration generate expand_items`
|
||||
- Update `src/schema.rs` (diesel will auto-generate)
|
||||
|
||||
### Step 3: Update `save_to_db()` in `item_database.rs`
|
||||
- Insert item columns
|
||||
- Insert related records (categories, stats, recipes, etc.)
|
||||
- Use transactions for consistency
|
||||
|
||||
### Step 4: Update `load_from_db()` in `item_database.rs`
|
||||
- Load items with all related data
|
||||
- Reconstruct full Item struct from columns + related tables
|
||||
|
||||
### Step 5: Add Query Helper Methods
|
||||
```rust
|
||||
// Examples:
|
||||
pub fn search_by_name(&self, query: &str) -> Vec<&Item>
|
||||
pub fn filter_by_level_range(&self, min: i32, max: i32) -> Vec<&Item>
|
||||
pub fn filter_by_stat(&self, stat_type: StatType, min_value: f32) -> Vec<&Item>
|
||||
pub fn get_items_using_ingredient(&self, ingredient_id: i32) -> Vec<&Item>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
✅ **Efficient Queries** - No JSON parsing for common filters
|
||||
✅ **Flexible** - JSON fallback for complex data
|
||||
✅ **Maintainable** - Clear relationships between entities
|
||||
✅ **Scalable** - Can add indexes as needed
|
||||
✅ **Wiki-Friendly** - Easy joins for "Used In" sections
|
||||
|
||||
---
|
||||
|
||||
## Query Examples for Wiki/Map
|
||||
|
||||
```sql
|
||||
-- Find all level 50+ weapons
|
||||
SELECT * FROM items WHERE item_type = 'weapon' AND level >= 50;
|
||||
|
||||
-- Find items that give health bonuses
|
||||
SELECT i.* FROM items i
|
||||
JOIN item_stats s ON i.id = s.item_id
|
||||
WHERE s.stat_type = 'health' AND s.value > 0;
|
||||
|
||||
-- Find all recipes using Copper Ore (id=33)
|
||||
SELECT i.name, r.level, r.skill FROM crafting_recipes r
|
||||
JOIN crafting_recipe_items ri ON r.id = ri.recipe_id
|
||||
JOIN items i ON r.product_item_id = i.id
|
||||
WHERE ri.item_id = 33;
|
||||
|
||||
-- Find all bows
|
||||
SELECT i.* FROM items i
|
||||
JOIN item_categories c ON i.id = c.item_id
|
||||
WHERE c.category = 'bow';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
Would you like me to:
|
||||
1. ✅ Generate the migration files?
|
||||
2. ✅ Update the `save_to_db()` and `load_from_db()` methods?
|
||||
3. ✅ Add query helper methods?
|
||||
4. ⚠️ Keep it simple and just add Phase 1 columns first?
|
||||
@@ -1,298 +0,0 @@
|
||||
# Database Schema Refactoring Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully refactored the minimap tiles system to use a single unified table with a `zoom` column instead of separate tables and multiple WebP columns.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Database Schema
|
||||
|
||||
#### Before:
|
||||
```sql
|
||||
-- Two separate tables
|
||||
CREATE TABLE minimap_tiles (
|
||||
webp_512 BLOB,
|
||||
webp_256 BLOB,
|
||||
webp_128 BLOB,
|
||||
webp_64 BLOB,
|
||||
webp_512_size INTEGER,
|
||||
webp_256_size INTEGER,
|
||||
webp_128_size INTEGER,
|
||||
webp_64_size INTEGER,
|
||||
...
|
||||
);
|
||||
|
||||
CREATE TABLE merged_tiles (
|
||||
zoom_level INTEGER,
|
||||
webp_data BLOB,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
#### After:
|
||||
```sql
|
||||
-- Single unified table
|
||||
CREATE TABLE minimap_tiles (
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
zoom INTEGER NOT NULL, -- 0, 1, or 2
|
||||
width INTEGER NOT NULL, -- Always 512
|
||||
height INTEGER NOT NULL, -- Always 512
|
||||
image BLOB NOT NULL, -- Single WebP column
|
||||
image_size INTEGER NOT NULL,
|
||||
original_file_size INTEGER, -- Only for zoom=2
|
||||
source_path TEXT NOT NULL,
|
||||
processed_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(x, y, zoom)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Data Model (`minimap_models.rs`)
|
||||
|
||||
**Before:**
|
||||
```rust
|
||||
pub struct MinimapTileRecord {
|
||||
pub webp_512: Vec<u8>,
|
||||
pub webp_256: Vec<u8>,
|
||||
pub webp_128: Vec<u8>,
|
||||
pub webp_64: Vec<u8>,
|
||||
pub webp_512_size: i32,
|
||||
// ... more fields
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```rust
|
||||
pub struct MinimapTileRecord {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub zoom: i32, // NEW: Zoom level (0, 1, 2)
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub image: Vec<u8>, // UNIFIED: Single image column
|
||||
pub image_size: i32,
|
||||
pub original_file_size: Option<i32>,
|
||||
pub source_path: String,
|
||||
pub processed_at: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ImageProcessor (`image_processor.rs`)
|
||||
|
||||
**Added new methods:**
|
||||
|
||||
```rust
|
||||
impl ImageProcessor {
|
||||
/// Encode image to lossless WebP
|
||||
pub fn encode_webp_lossless(img: &RgbaImage)
|
||||
-> Result<Vec<u8>, ImageProcessingError>
|
||||
|
||||
/// Create a black tile of specified size
|
||||
pub fn create_black_tile(size: u32) -> RgbaImage
|
||||
|
||||
/// Merge multiple tiles into a single image
|
||||
pub fn merge_tiles(
|
||||
tiles: &HashMap<(i32, i32), Vec<u8>>,
|
||||
grid_x: i32,
|
||||
grid_y: i32,
|
||||
tile_size: u32,
|
||||
output_size: u32,
|
||||
) -> Result<RgbaImage, ImageProcessingError>
|
||||
}
|
||||
```
|
||||
|
||||
### 4. MinimapDatabase (`minimap_database.rs`)
|
||||
|
||||
**Completely rewritten to:**
|
||||
- Process all PNG files at zoom level 2 (original, lossless WebP)
|
||||
- Automatically generate zoom level 1 tiles (2×2 merged)
|
||||
- Automatically generate zoom level 0 tiles (4×4 merged)
|
||||
- Store all zoom levels in a single `minimap_tiles` table
|
||||
|
||||
**Key method:**
|
||||
```rust
|
||||
pub fn load_from_directory() -> Result<usize, MinimapDatabaseError> {
|
||||
// Step 1: Load all PNGs → zoom level 2
|
||||
// Step 2: Generate zoom level 1 (2×2 merged)
|
||||
// Step 3: Generate zoom level 0 (4×4 merged)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Image Parser (`image-parser.rs`)
|
||||
|
||||
**Simplified significantly:**
|
||||
- No longer needs to be run separately from merge process
|
||||
- Single command now generates **all** zoom levels
|
||||
- Updated output to show statistics per zoom level
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd cursebreaker-parser
|
||||
cargo run --bin image-parser --release
|
||||
```
|
||||
|
||||
**Output includes:**
|
||||
- Tile counts per zoom level (0, 1, 2)
|
||||
- Storage size per zoom level
|
||||
- Total compression ratio
|
||||
- Map bounds
|
||||
|
||||
### 6. Map Server (`cursebreaker-map/src/main.rs`)
|
||||
|
||||
**Updated to use new schema:**
|
||||
|
||||
**Before:**
|
||||
```rust
|
||||
// Queried merged_tiles table
|
||||
use cursebreaker_parser::schema::merged_tiles::dsl::*;
|
||||
merged_tiles
|
||||
.filter(zoom_level.eq(z))
|
||||
.select(webp_data)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```rust
|
||||
// Queries unified minimap_tiles table
|
||||
use cursebreaker_parser::schema::minimap_tiles::dsl::*;
|
||||
minimap_tiles
|
||||
.filter(zoom.eq(z))
|
||||
.select(image)
|
||||
```
|
||||
|
||||
### 7. Removed Files
|
||||
|
||||
- ❌ **`merge-tiles.rs`** - No longer needed (merged into image-parser)
|
||||
- ❌ **`merged_tiles` table** - Replaced by unified minimap_tiles
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Simpler Schema**: One table instead of two
|
||||
2. **Cleaner Code**: Single column for images instead of multiple
|
||||
3. **Single Command**: One tool (`image-parser`) generates all zoom levels
|
||||
4. **Maintainability**: Easier to understand and modify
|
||||
5. **Consistency**: All tiles stored in the same way
|
||||
|
||||
## Migration Path
|
||||
|
||||
### For Existing Databases:
|
||||
|
||||
1. **Run migration** (automatically done):
|
||||
```bash
|
||||
diesel migration run
|
||||
```
|
||||
This will:
|
||||
- Drop old `minimap_tiles` and `merged_tiles` tables
|
||||
- Create new unified `minimap_tiles` table
|
||||
|
||||
2. **Regenerate tiles**:
|
||||
```bash
|
||||
cd cursebreaker-parser
|
||||
cargo run --bin image-parser --release
|
||||
```
|
||||
|
||||
3. **Start map server**:
|
||||
```bash
|
||||
cd ../cursebreaker-map
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Zoom Level Mapping
|
||||
|
||||
| Zoom | Description | Merge Factor | Typical Count |
|
||||
|------|-------------|--------------|---------------|
|
||||
| 0 | Most zoomed out | 4×4 | ~31 tiles |
|
||||
| 1 | Medium zoom | 2×2 | ~105 tiles |
|
||||
| 2 | Full detail (original) | 1×1 | ~345 tiles |
|
||||
|
||||
### Coordinate System
|
||||
|
||||
- **Zoom 2**: Uses original tile coordinates (e.g., x=5, y=10)
|
||||
- **Zoom 1**: Uses divided coordinates (e.g., x=2, y=5 for 2×2 grid starting at 4,10)
|
||||
- **Zoom 0**: Uses divided coordinates (e.g., x=1, y=2 for 4×4 grid starting at 4,8)
|
||||
|
||||
### WebP Encoding
|
||||
|
||||
All tiles use **lossless WebP** compression:
|
||||
- No quality loss
|
||||
- Smaller than PNG
|
||||
- Faster to decode than PNG
|
||||
- Browser-native format
|
||||
|
||||
## Testing
|
||||
|
||||
After running the refactored system:
|
||||
|
||||
1. **Check tile counts**:
|
||||
```sql
|
||||
SELECT zoom, COUNT(*) as count
|
||||
FROM minimap_tiles
|
||||
GROUP BY zoom;
|
||||
```
|
||||
Expected: ~31 for zoom 0, ~105 for zoom 1, ~345 for zoom 2
|
||||
|
||||
2. **Verify storage**:
|
||||
```sql
|
||||
SELECT
|
||||
zoom,
|
||||
COUNT(*) as tiles,
|
||||
SUM(image_size) / 1048576 as mb
|
||||
FROM minimap_tiles
|
||||
GROUP BY zoom;
|
||||
```
|
||||
|
||||
3. **Test map viewer**:
|
||||
- Open `http://127.0.0.1:3000`
|
||||
- Zoom in/out to verify all levels load correctly
|
||||
- Check browser DevTools network tab for tile requests
|
||||
|
||||
## Performance
|
||||
|
||||
Tile generation time:
|
||||
- **Old approach**: Run image-parser (~30s) + run merge-tiles (~90s) = ~2 minutes total
|
||||
- **New approach**: Run image-parser once (~90s) = ~1.5 minutes total
|
||||
- **Improvement**: Simpler workflow, one less step
|
||||
|
||||
Database storage:
|
||||
- Similar total size (~111 MB)
|
||||
- Cleaner schema with single image column
|
||||
- Indexed by (zoom, x, y) for fast queries
|
||||
|
||||
## Files Modified
|
||||
|
||||
```
|
||||
cursebreaker-parser/
|
||||
├── migrations/
|
||||
│ └── 2026-01-10-122732-0000_restructure_minimap_tiles/
|
||||
│ ├── up.sql # NEW
|
||||
│ └── down.sql # NEW
|
||||
├── src/
|
||||
│ ├── bin/
|
||||
│ │ ├── image-parser.rs # MODIFIED
|
||||
│ │ └── merge-tiles.rs # DELETED
|
||||
│ ├── databases/
|
||||
│ │ └── minimap_database.rs # REWRITTEN
|
||||
│ ├── image_processor.rs # MODIFIED (added merge methods)
|
||||
│ ├── types/
|
||||
│ │ └── minimap_models.rs # MODIFIED (new schema)
|
||||
│ └── schema.rs # REGENERATED
|
||||
└── Cargo.toml # MODIFIED (removed merge-tiles bin)
|
||||
|
||||
cursebreaker-map/
|
||||
└── src/
|
||||
└── main.rs # MODIFIED (use new schema)
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
⚠️ **Breaking Changes:**
|
||||
- Old database will be wiped by migration
|
||||
- Must re-run `image-parser` to regenerate tiles
|
||||
- `merge-tiles` command no longer exists
|
||||
|
||||
✅ **No Breaking Changes:**
|
||||
- Map viewer API unchanged (`/api/tiles/:z/:x/:y`)
|
||||
- Frontend code unchanged
|
||||
- Tile coordinates same at each zoom level
|
||||
@@ -1,297 +0,0 @@
|
||||
# Items Table Expansion - Implementation Summary
|
||||
|
||||
## ✅ Completed: Balanced Approach
|
||||
|
||||
Successfully expanded the items database schema with commonly queried columns and crafting recipe normalization.
|
||||
|
||||
---
|
||||
|
||||
## What Was Added
|
||||
|
||||
### 1. New Columns in `items` Table
|
||||
|
||||
**Item Classification:**
|
||||
- `item_type` (TEXT) - weapon, armor, resource, consumable, etc.
|
||||
- `level` (INTEGER) - item level requirement
|
||||
- `price` (INTEGER) - base item price
|
||||
- `max_stack` (INTEGER) - maximum stack size
|
||||
- `storage_size` (INTEGER) - if item is a container, how many slots
|
||||
- `skill` (TEXT) - related skill (swordsmanship, mining, etc.)
|
||||
- `tool` (TEXT) - tool type (pickaxe, hatchet, etc.)
|
||||
- `description` (TEXT) - item description for search
|
||||
|
||||
**Boolean Flags:**
|
||||
- `two_handed` (INTEGER 0/1)
|
||||
- `undroppable` (INTEGER 0/1)
|
||||
- `undroppable_on_death` (INTEGER 0/1)
|
||||
- `unequip_destroy` (INTEGER 0/1)
|
||||
- `generate_icon` (INTEGER 0/1)
|
||||
- `hide_milestone` (INTEGER 0/1)
|
||||
- `cannot_craft_exceptional` (INTEGER 0/1)
|
||||
- `storage_all_items` (INTEGER 0/1)
|
||||
|
||||
**Ability/Item IDs:**
|
||||
- `ability_id` (INTEGER)
|
||||
- `special_ability` (INTEGER)
|
||||
- `learn_ability_id` (INTEGER)
|
||||
- `book_id` (INTEGER)
|
||||
- `swap_item` (INTEGER)
|
||||
|
||||
**Indexes Created:**
|
||||
- `idx_items_type` - for filtering by item type
|
||||
- `idx_items_level` - for level range queries
|
||||
- `idx_items_price` - for price range queries
|
||||
- `idx_items_skill` - for skill-related queries
|
||||
|
||||
### 2. New Tables for Crafting Recipes
|
||||
|
||||
**`crafting_recipes` Table:**
|
||||
- `id` (INTEGER PRIMARY KEY AUTOINCREMENT)
|
||||
- `product_item_id` (INTEGER) - FK to items(id)
|
||||
- `skill` (TEXT) - required skill
|
||||
- `level` (INTEGER) - required level
|
||||
- `workbench_id` (INTEGER) - required workbench
|
||||
- `xp` (INTEGER) - XP gained from crafting
|
||||
- `unlocked_by_default` (INTEGER 0/1)
|
||||
- `checks` (TEXT, nullable) - conditional requirements
|
||||
|
||||
**Indexes:**
|
||||
- `idx_crafting_recipes_product` - find recipes for an item
|
||||
- `idx_crafting_recipes_skill` - find recipes by skill
|
||||
- `idx_crafting_recipes_level` - find recipes by level
|
||||
- `idx_crafting_recipes_workbench` - find recipes by workbench
|
||||
|
||||
**`crafting_recipe_items` Table:**
|
||||
- `recipe_id` (INTEGER) - FK to crafting_recipes(id)
|
||||
- `item_id` (INTEGER) - FK to items(id) for ingredient
|
||||
- `amount` (INTEGER) - quantity required
|
||||
|
||||
**Indexes:**
|
||||
- `idx_crafting_recipe_items_item` - find recipes using this ingredient
|
||||
|
||||
### 3. Preserved JSON Data Column
|
||||
|
||||
The full `data` column remains for:
|
||||
- Complete item retrieval without joins
|
||||
- Complex nested data (stats, animations, custom names)
|
||||
- Future flexibility
|
||||
- Backwards compatibility
|
||||
|
||||
---
|
||||
|
||||
## Example Queries for Your Wiki/Map
|
||||
|
||||
### Basic Filtering
|
||||
|
||||
```sql
|
||||
-- Find all weapons above level 50
|
||||
SELECT id, name, item_type, level, price
|
||||
FROM items
|
||||
WHERE item_type = 'weapon' AND level > 50;
|
||||
|
||||
-- Find stackable items
|
||||
SELECT id, name, max_stack
|
||||
FROM items
|
||||
WHERE max_stack > 1;
|
||||
|
||||
-- Find two-handed weapons
|
||||
SELECT id, name, item_type, level
|
||||
FROM items
|
||||
WHERE two_handed = 1 AND item_type = 'weapon';
|
||||
|
||||
-- Find mining tools
|
||||
SELECT id, name, tool, level
|
||||
FROM items
|
||||
WHERE tool = 'pickaxe';
|
||||
```
|
||||
|
||||
### Crafting Queries
|
||||
|
||||
```sql
|
||||
-- Find all recipes that use Copper Ore (id=33)
|
||||
SELECT
|
||||
i.id,
|
||||
i.name AS product,
|
||||
r.skill,
|
||||
r.level,
|
||||
ri.amount AS copper_needed
|
||||
FROM crafting_recipes r
|
||||
JOIN crafting_recipe_items ri ON r.id = ri.recipe_id
|
||||
JOIN items i ON r.product_item_id = i.id
|
||||
WHERE ri.item_id = 33;
|
||||
|
||||
-- Find all Blacksmithy recipes
|
||||
SELECT
|
||||
i.name AS product,
|
||||
r.level,
|
||||
r.xp
|
||||
FROM crafting_recipes r
|
||||
JOIN items i ON r.product_item_id = i.id
|
||||
WHERE r.skill = 'blacksmithy'
|
||||
ORDER BY r.level;
|
||||
|
||||
-- Get recipe details with all ingredients
|
||||
SELECT
|
||||
prod.name AS product,
|
||||
ing.name AS ingredient,
|
||||
ri.amount
|
||||
FROM crafting_recipes r
|
||||
JOIN items prod ON r.product_item_id = prod.id
|
||||
JOIN crafting_recipe_items ri ON r.id = ri.recipe_id
|
||||
JOIN items ing ON ri.item_id = ing.id
|
||||
WHERE prod.id = 100; -- Replace with actual product ID
|
||||
```
|
||||
|
||||
### Combined Queries
|
||||
|
||||
```sql
|
||||
-- Find expensive high-level consumables
|
||||
SELECT id, name, level, price, description
|
||||
FROM items
|
||||
WHERE item_type = 'consumable'
|
||||
AND level >= 30
|
||||
AND price > 1000
|
||||
ORDER BY price DESC;
|
||||
|
||||
-- Find storage containers by size
|
||||
SELECT id, name, storage_size, storage_all_items
|
||||
FROM items
|
||||
WHERE storage_size > 0
|
||||
ORDER BY storage_size DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Changes
|
||||
|
||||
### Files Modified
|
||||
|
||||
1. **`migrations/2026-01-11-133543-0000_expand_items/up.sql`**
|
||||
- Added ALTER TABLE statements for new columns
|
||||
- Created crafting_recipes and crafting_recipe_items tables
|
||||
- Added indexes
|
||||
|
||||
2. **`src/schema.rs`**
|
||||
- Auto-regenerated by diesel with new schema
|
||||
|
||||
3. **`src/databases/item_database.rs`**
|
||||
- Updated `save_to_db()` - now populates all new columns and crafting tables
|
||||
- Updated `load_from_db()` - updated struct to match new schema
|
||||
- Uses transactions for data consistency
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Saving Items:**
|
||||
- Each item is saved with all scalar fields as direct columns
|
||||
- Enums (ItemType, SkillType, Tool) are converted to strings
|
||||
- Booleans are stored as integers (0/1)
|
||||
- Crafting recipes are inserted into separate tables
|
||||
- Uses a transaction to ensure all-or-nothing saves
|
||||
- Uses `replace_into` for items to handle updates
|
||||
|
||||
2. **Loading Items:**
|
||||
- Items are loaded from the JSON `data` column (complete info)
|
||||
- Could be extended to join crafting tables if needed
|
||||
- Fast for simple ID lookups
|
||||
|
||||
---
|
||||
|
||||
## Testing Results
|
||||
|
||||
```
|
||||
✅ Sample items with expanded columns:
|
||||
|
||||
0 - Null (Type: resource, Level: 1, Price: 0, MaxStack: 1, Skill: none)
|
||||
33 - Copper Ore (Type: resource, Level: 1, Price: 0, MaxStack: 1, Skill: none)
|
||||
34 - Iron Ore (Type: resource, Level: 10, Price: 0, MaxStack: 1, Skill: none)
|
||||
61 - Spruce Log (Type: resource, Level: 1, Price: 10, MaxStack: 1, Skill: none)
|
||||
62 - Oak Log (Type: resource, Level: 10, Price: 0, MaxStack: 1, Skill: none)
|
||||
|
||||
✅ Successfully saved 1360 items to database
|
||||
```
|
||||
|
||||
All items are properly stored with expanded columns!
|
||||
|
||||
---
|
||||
|
||||
## Next Steps / Future Enhancements
|
||||
|
||||
### Optional Phase 2 Additions (when needed):
|
||||
|
||||
1. **Item Categories Table** (if you need to filter by categories often):
|
||||
```sql
|
||||
CREATE TABLE item_categories (
|
||||
item_id INTEGER NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
PRIMARY KEY (item_id, category),
|
||||
FOREIGN KEY (item_id) REFERENCES items(id)
|
||||
);
|
||||
```
|
||||
|
||||
2. **Item Stats Table** (if you need to query by specific stats):
|
||||
```sql
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
3. **Query Helper Methods** in `ItemDatabase`:
|
||||
```rust
|
||||
pub fn filter_by_type(&self, item_type: ItemType) -> Vec<&Item>
|
||||
pub fn filter_by_level_range(&self, min: i32, max: i32) -> Vec<&Item>
|
||||
pub fn find_recipes_using(&self, ingredient_id: i32) -> Vec<CraftingRecipe>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits Achieved
|
||||
|
||||
✅ **Efficient Filtering** - Can filter/search without parsing JSON
|
||||
✅ **Wiki-Ready** - Easy to generate "Used In" sections for recipes
|
||||
✅ **Map Integration** - Fast queries for map markers/filters
|
||||
✅ **Flexible** - JSON fallback for complex data
|
||||
✅ **Indexed** - Fast queries on common fields
|
||||
✅ **Maintainable** - Clear schema with relationships
|
||||
|
||||
---
|
||||
|
||||
## Migration Management
|
||||
|
||||
**To rollback the migration:**
|
||||
```bash
|
||||
diesel migration revert --database-url=cursebreaker.db --migration-dir=cursebreaker-parser/migrations
|
||||
```
|
||||
|
||||
**To rerun after changes:**
|
||||
```bash
|
||||
diesel migration redo --database-url=cursebreaker.db --migration-dir=cursebreaker-parser/migrations
|
||||
```
|
||||
|
||||
**To regenerate schema.rs:**
|
||||
```bash
|
||||
cd cursebreaker-parser && diesel print-schema --database-url=../cursebreaker.db > src/schema.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Size
|
||||
|
||||
- Before: ~130MB (with just JSON data)
|
||||
- After: ~130MB (minimal increase, new columns use default values where not set)
|
||||
- Crafting tables will add ~1-5MB when populated with recipe data
|
||||
|
||||
---
|
||||
|
||||
## Ready for Production
|
||||
|
||||
The implementation is complete and tested! Your interactive map and media wiki can now:
|
||||
|
||||
- Filter items by type, level, price, skill, etc.
|
||||
- Show crafting recipes with ingredients
|
||||
- Find which recipes use specific items
|
||||
- Query items efficiently without parsing JSON
|
||||
- Scale to handle many concurrent queries
|
||||
@@ -1,412 +0,0 @@
|
||||
# Item Stats in Database - Implementation Summary
|
||||
|
||||
## ✅ Completed Successfully
|
||||
|
||||
Successfully added normalized item stats storage to the database for efficient querying and filtering.
|
||||
|
||||
---
|
||||
|
||||
## What Was Added
|
||||
|
||||
### 1. Database Migration: `add_item_stats`
|
||||
|
||||
**New Table: `item_stats`**
|
||||
```sql
|
||||
CREATE TABLE item_stats (
|
||||
item_id INTEGER NOT NULL,
|
||||
stat_type TEXT NOT NULL,
|
||||
value REAL NOT NULL, -- Float/REAL for precise stat values
|
||||
PRIMARY KEY (item_id, stat_type),
|
||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**Indexes Created:**
|
||||
- `idx_item_stats_stat_type` - Find all items with a specific stat
|
||||
- `idx_item_stats_value` - Range queries on stat values
|
||||
- `idx_item_stats_type_value` - Combined queries (type + value range)
|
||||
|
||||
**Composite Primary Key:**
|
||||
- `(item_id, stat_type)` - Ensures each item can only have one value per stat type
|
||||
- Prevents duplicate stats for the same item
|
||||
|
||||
### 2. Supported Stat Types
|
||||
|
||||
All 20 stat types from the game:
|
||||
|
||||
**Damage Stats:**
|
||||
- `damage_physical` - Physical Damage
|
||||
- `damage_magical` - Magical Damage
|
||||
- `damage_ranged` - Ranged Damage
|
||||
|
||||
**Accuracy Stats:**
|
||||
- `accuracy_physical` - Physical Accuracy
|
||||
- `accuracy_magical` - Magical Accuracy
|
||||
- `accuracy_ranged` - Ranged Accuracy
|
||||
|
||||
**Resistance Stats:**
|
||||
- `resistance_physical` - Physical Resistance
|
||||
- `resistance_magical` - Magical Resistance
|
||||
- `resistance_ranged` - Ranged Resistance
|
||||
|
||||
**Health & Mana:**
|
||||
- `health` - Health
|
||||
- `mana` - Mana
|
||||
- `health_regen` - Health Regeneration
|
||||
- `mana_regen` - Mana Regeneration
|
||||
|
||||
**Special Stats:**
|
||||
- `critical` - Critical
|
||||
- `healing` - Healing
|
||||
- `movement_speed` - Movement Speed
|
||||
|
||||
**Enemy Type Damage:**
|
||||
- `critter_slaying` - Damage against Critters
|
||||
- `damage_vs_beasts` - Damage against Beasts
|
||||
- `damage_vs_undead` - Damage against Undead
|
||||
|
||||
### 3. Code Changes
|
||||
|
||||
**`src/databases/item_database.rs`**
|
||||
- Updated `save_to_db_with_images()` to also save stats
|
||||
- Iterates through `item.stats` vec and inserts each stat
|
||||
- Maps `StatType` enum to string representation
|
||||
- Uses same transaction as item and recipe saving
|
||||
|
||||
---
|
||||
|
||||
## Results from Test Data
|
||||
|
||||
### Statistics
|
||||
|
||||
```
|
||||
✅ Total stat entries: 84
|
||||
📊 Most common stats:
|
||||
- health: 20 items
|
||||
- resistance_magical: 9 items
|
||||
- resistance_physical: 9 items
|
||||
- accuracy_magical: 7 items
|
||||
- mana: 7 items
|
||||
- mana_regen: 7 items
|
||||
```
|
||||
|
||||
### Example Items with Stats
|
||||
|
||||
**The Bad Ring (ID: 73)** - 12 stats (testing item)
|
||||
- All stats maxed at 1000 (accuracy, damage, health, mana, resistances)
|
||||
|
||||
**Ring of the High Mage (ID: 394)** - 5 stats
|
||||
- Health: 50
|
||||
- Mana: 50
|
||||
- Mana Regen: 2
|
||||
- Magical Resistance: 35
|
||||
- Physical Resistance: 15
|
||||
|
||||
**Crown of the Tyrant** - High health bonus
|
||||
- Health: 100
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### SQL Queries
|
||||
|
||||
```sql
|
||||
-- Find all items with health bonuses
|
||||
SELECT i.id, i.name, s.value as health
|
||||
FROM items i
|
||||
JOIN item_stats s ON i.id = s.item_id
|
||||
WHERE s.stat_type = 'health'
|
||||
ORDER BY s.value DESC;
|
||||
|
||||
-- Find weapons with high physical damage
|
||||
SELECT i.id, i.name, s.value as damage
|
||||
FROM items i
|
||||
JOIN item_stats s ON i.id = s.item_id
|
||||
WHERE i.item_type = 'weapon'
|
||||
AND s.stat_type = 'damage_physical'
|
||||
AND s.value > 50
|
||||
ORDER BY s.value DESC;
|
||||
|
||||
-- Find items with multiple resistance types
|
||||
SELECT i.id, i.name, COUNT(*) as resistance_count
|
||||
FROM items i
|
||||
JOIN item_stats s ON i.id = s.item_id
|
||||
WHERE s.stat_type LIKE 'resistance_%'
|
||||
GROUP BY i.id, i.name
|
||||
HAVING COUNT(*) >= 2;
|
||||
|
||||
-- Find balanced items (have both offense and defense)
|
||||
SELECT i.id, i.name
|
||||
FROM items i
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM item_stats
|
||||
WHERE item_id = i.id AND stat_type LIKE 'damage_%'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM item_stats
|
||||
WHERE item_id = i.id AND stat_type LIKE 'resistance_%'
|
||||
);
|
||||
|
||||
-- Get all stats for a specific item
|
||||
SELECT stat_type, value
|
||||
FROM item_stats
|
||||
WHERE item_id = 394
|
||||
ORDER BY stat_type;
|
||||
|
||||
-- Find items within a stat range
|
||||
SELECT i.name, s.value
|
||||
FROM items i
|
||||
JOIN item_stats s ON i.id = s.item_id
|
||||
WHERE s.stat_type = 'health'
|
||||
AND s.value BETWEEN 50 AND 100;
|
||||
```
|
||||
|
||||
### Rust Queries
|
||||
|
||||
```rust
|
||||
use diesel::prelude::*;
|
||||
use cursebreaker_parser::schema::{items, item_stats};
|
||||
|
||||
// Find items with high health
|
||||
let high_health_items = item_stats::table
|
||||
.inner_join(items::table)
|
||||
.filter(item_stats::stat_type.eq("health"))
|
||||
.filter(item_stats::value.gt(100.0))
|
||||
.select((items::name, item_stats::value))
|
||||
.load::<(String, f32)>(&mut conn)?;
|
||||
|
||||
// Get all stats for an item
|
||||
let item_id = 394;
|
||||
let stats = item_stats::table
|
||||
.filter(item_stats::item_id.eq(item_id))
|
||||
.select((item_stats::stat_type, item_stats::value))
|
||||
.load::<(String, f32)>(&mut conn)?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web API Examples
|
||||
|
||||
For your interactive map and wiki:
|
||||
|
||||
### 1. Filter Items by Stat
|
||||
|
||||
```rust
|
||||
// GET /api/items?stat=health&min=50&max=100
|
||||
pub async fn filter_items_by_stat(
|
||||
stat: String,
|
||||
min: f32,
|
||||
max: f32,
|
||||
) -> Result<Vec<Item>> {
|
||||
let items = item_stats::table
|
||||
.inner_join(items::table)
|
||||
.filter(item_stats::stat_type.eq(stat))
|
||||
.filter(item_stats::value.between(min, max))
|
||||
.select(items::all_columns)
|
||||
.load(&mut conn)?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Item Comparison Tool
|
||||
|
||||
```rust
|
||||
// GET /api/items/compare?ids=394,340,179
|
||||
pub async fn compare_items(item_ids: Vec<i32>) -> Result<ItemComparison> {
|
||||
let stats = item_stats::table
|
||||
.filter(item_stats::item_id.eq_any(item_ids))
|
||||
.load::<(i32, String, f32)>(&mut conn)?;
|
||||
|
||||
// Group by item and return comparison table
|
||||
Ok(build_comparison(stats))
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Best Items for Stat
|
||||
|
||||
```rust
|
||||
// GET /api/items/best?stat=damage_physical&limit=10
|
||||
pub async fn best_items_for_stat(
|
||||
stat: String,
|
||||
limit: i64,
|
||||
) -> Result<Vec<ItemWithStat>> {
|
||||
let items = item_stats::table
|
||||
.inner_join(items::table)
|
||||
.filter(item_stats::stat_type.eq(stat))
|
||||
.order_by(item_stats::value.desc())
|
||||
.limit(limit)
|
||||
.select((items::all_columns, item_stats::value))
|
||||
.load(&mut conn)?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Item Build Suggestions
|
||||
|
||||
```rust
|
||||
// GET /api/builds?focus=mage
|
||||
pub async fn suggest_build(focus: String) -> Result<Build> {
|
||||
// For mage build: high mana, mana_regen, magical damage/accuracy
|
||||
let stats_to_prioritize = match focus.as_str() {
|
||||
"mage" => vec!["mana", "mana_regen", "damage_magical", "accuracy_magical"],
|
||||
"warrior" => vec!["health", "damage_physical", "resistance_physical"],
|
||||
"archer" => vec!["damage_ranged", "accuracy_ranged", "critical"],
|
||||
_ => vec![]
|
||||
};
|
||||
|
||||
// Find best items for each slot with those stats
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Efficient Filtering** - Query items by stat without parsing JSON
|
||||
✅ **Range Queries** - Find items within stat value ranges
|
||||
✅ **Comparative Analysis** - Easy item comparisons
|
||||
✅ **Build Optimization** - Find best items for specific builds
|
||||
✅ **Indexed** - Fast queries on stat types and values
|
||||
✅ **Normalized** - No data duplication, consistent values
|
||||
✅ **Type-Safe** - Composite PK prevents duplicate stats
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Query Optimization
|
||||
|
||||
**Fast Queries** (uses indexes):
|
||||
```sql
|
||||
-- Single stat lookup
|
||||
WHERE stat_type = 'health' -- Uses idx_item_stats_stat_type
|
||||
|
||||
-- Range query
|
||||
WHERE value > 50 -- Uses idx_item_stats_value
|
||||
|
||||
-- Combined
|
||||
WHERE stat_type = 'health' AND value > 50 -- Uses idx_item_stats_type_value
|
||||
```
|
||||
|
||||
**Slower Queries** (may need optimization):
|
||||
```sql
|
||||
-- Multiple stat requirements (requires multiple joins)
|
||||
-- Consider caching or denormalizing for frequently accessed combinations
|
||||
```
|
||||
|
||||
### Storage Impact
|
||||
|
||||
- 84 stat entries currently
|
||||
- Average ~3-4 stats per item with stats
|
||||
- Minimal storage: ~8 bytes per entry (int + text + float)
|
||||
- Total: < 1 KB for current data
|
||||
|
||||
As more items with stats are added, storage remains efficient due to normalization.
|
||||
|
||||
---
|
||||
|
||||
## Wiki Feature Ideas
|
||||
|
||||
### 1. Item Comparison Tool
|
||||
Show side-by-side comparison of multiple items with all their stats.
|
||||
|
||||
### 2. Best-in-Slot Lists
|
||||
- Best weapons for physical damage
|
||||
- Best armor for magical resistance
|
||||
- Best accessories for health/mana
|
||||
|
||||
### 3. Build Guides
|
||||
- "Mage Build" - show items with high mana/magical stats
|
||||
- "Tank Build" - show items with high health/resistances
|
||||
- "DPS Build" - show items with high damage/critical
|
||||
|
||||
### 4. Stat Distribution Charts
|
||||
- Visualize stat value distributions
|
||||
- Show which stats are rare vs common
|
||||
- Identify stat "sweet spots"
|
||||
|
||||
### 5. Upgrade Paths
|
||||
- Show progression of items by stat values
|
||||
- "From 50 health → 100 health → 150 health"
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Optional Additions:
|
||||
|
||||
1. **Stat Ranges Table** - For items with variable stats
|
||||
```sql
|
||||
CREATE TABLE item_stat_ranges (
|
||||
item_id INTEGER,
|
||||
stat_type TEXT,
|
||||
min_value REAL,
|
||||
max_value REAL,
|
||||
PRIMARY KEY (item_id, stat_type)
|
||||
);
|
||||
```
|
||||
|
||||
2. **Derived Stats** - Calculate total stats for equipment sets
|
||||
```sql
|
||||
-- Sum up stats across multiple equipped items
|
||||
SELECT stat_type, SUM(value) as total
|
||||
FROM item_stats
|
||||
WHERE item_id IN (/* equipped item IDs */)
|
||||
GROUP BY stat_type;
|
||||
```
|
||||
|
||||
3. **Stat Scaling** - Track how stats scale with item level
|
||||
```sql
|
||||
SELECT i.level, AVG(s.value) as avg_damage
|
||||
FROM items i
|
||||
JOIN item_stats s ON i.id = s.item_id
|
||||
WHERE s.stat_type = 'damage_physical'
|
||||
GROUP BY i.level;
|
||||
```
|
||||
|
||||
4. **Stat Categories** - Group related stats
|
||||
```sql
|
||||
CREATE TABLE stat_categories (
|
||||
stat_type TEXT PRIMARY KEY,
|
||||
category TEXT -- 'offense', 'defense', 'utility', etc.
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Run the verification script anytime:
|
||||
```bash
|
||||
cargo run --bin verify-stats
|
||||
```
|
||||
|
||||
Shows:
|
||||
- Total stat entries
|
||||
- Breakdown by stat type
|
||||
- Items with most stats
|
||||
- Example queries (high damage, health bonuses)
|
||||
|
||||
---
|
||||
|
||||
## Ready for Production
|
||||
|
||||
Your item stats are now:
|
||||
- ✅ Stored in normalized table
|
||||
- ✅ Efficiently queryable
|
||||
- ✅ Properly indexed
|
||||
- ✅ Ready for filtering and comparison
|
||||
- ✅ Available for build optimization
|
||||
- ✅ Perfect for wiki features
|
||||
|
||||
The database now contains complete item data with:
|
||||
- ✅ Metadata (type, level, price, etc.)
|
||||
- ✅ Images (3 sizes, WebP)
|
||||
- ✅ Crafting recipes (normalized)
|
||||
- ✅ **Stats (normalized, queryable)**
|
||||
|
||||
Your interactive map and wiki have everything needed for a rich item browsing experience! 🎮
|
||||
@@ -1,228 +0,0 @@
|
||||
# Tile Merging Optimization Summary
|
||||
|
||||
## Problem
|
||||
|
||||
The initial implementation loaded 345-510 individual tile images at every zoom level, resulting in:
|
||||
- Slow initial page load
|
||||
- Many HTTP requests overwhelming the browser
|
||||
- Poor user experience when zooming
|
||||
|
||||
## Solution
|
||||
|
||||
Implemented a tile merging system that combines adjacent tiles at lower zoom levels:
|
||||
|
||||
### Zoom Level 0 (Most Zoomed Out)
|
||||
- **Merge Factor**: 4×4 tiles merged into single 512px images
|
||||
- **Total Tiles**: 31 images
|
||||
- **Reduction**: 91% fewer HTTP requests vs zoom level 2
|
||||
- **Use Case**: Fast initial load and overview
|
||||
|
||||
### Zoom Level 1 (Medium Zoom)
|
||||
- **Merge Factor**: 2×2 tiles merged into single 512px images
|
||||
- **Total Tiles**: 105 images
|
||||
- **Reduction**: 70% fewer HTTP requests vs zoom level 2
|
||||
- **Use Case**: Balanced detail and performance
|
||||
|
||||
### Zoom Level 2 (Most Zoomed In)
|
||||
- **Merge Factor**: 1×1 (no merging)
|
||||
- **Total Tiles**: 345 images
|
||||
- **Use Case**: Full detail viewing
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema (`merged_tiles` table)
|
||||
|
||||
```sql
|
||||
CREATE TABLE merged_tiles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
x INTEGER NOT NULL, -- Tile coordinate at this zoom
|
||||
y INTEGER NOT NULL, -- Tile coordinate at this zoom
|
||||
zoom_level INTEGER NOT NULL, -- 0, 1, or 2
|
||||
merge_factor INTEGER NOT NULL,-- 1, 4, or 16
|
||||
width INTEGER NOT NULL, -- Always 512
|
||||
height INTEGER NOT NULL, -- Always 512
|
||||
webp_data BLOB NOT NULL, -- Lossless WebP
|
||||
webp_size INTEGER NOT NULL,
|
||||
processed_at TIMESTAMP NOT NULL,
|
||||
source_tiles TEXT NOT NULL, -- Tracking info
|
||||
UNIQUE(zoom_level, x, y)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Tile Merger Tool (`merge-tiles`)
|
||||
|
||||
Located at: `cursebreaker-parser/src/bin/merge-tiles.rs`
|
||||
|
||||
**What it does:**
|
||||
- Reads all tiles from `minimap_tiles` table
|
||||
- For zoom level 2: Re-encodes each tile as lossless WebP
|
||||
- For zoom level 1: Merges 2×2 tile grids, resizes each to 256px, combines into 512px image
|
||||
- For zoom level 0: Merges 4×4 tile grids, resizes each to 128px, combines into 512px image
|
||||
- Missing tiles are filled with black pixels
|
||||
- Stores all merged tiles in `merged_tiles` table
|
||||
|
||||
**Performance:**
|
||||
- Processes 345 original tiles → 481 merged tiles (all zoom levels)
|
||||
- Takes ~1.5 minutes to run
|
||||
- Total storage: ~111 MB (lossless WebP)
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd cursebreaker-parser
|
||||
cargo run --bin merge-tiles --release
|
||||
```
|
||||
|
||||
### 3. Backend Changes
|
||||
|
||||
**File**: `cursebreaker-map/src/main.rs`
|
||||
|
||||
**Changes:**
|
||||
- Updated `get_tile()` to query `merged_tiles` table instead of `minimap_tiles`
|
||||
- Changed tile coordinate parameter from `u32` to `i32` to match database zoom levels
|
||||
- Simplified logic - no need to select different blob columns based on zoom
|
||||
|
||||
### 4. Frontend Changes
|
||||
|
||||
**File**: `cursebreaker-map/static/map.js`
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
```javascript
|
||||
// Map Leaflet zoom levels to database zoom levels
|
||||
if (currentZoom === 0) {
|
||||
dbZoom = 0;
|
||||
mergeFactor = 4; // 4×4 tiles per merged tile
|
||||
} else if (currentZoom === 1) {
|
||||
dbZoom = 1;
|
||||
mergeFactor = 2; // 2×2 tiles per merged tile
|
||||
} else {
|
||||
dbZoom = 2;
|
||||
mergeFactor = 1; // 1×1 (no merging)
|
||||
}
|
||||
|
||||
// Calculate merged tile coordinates
|
||||
const minMergedX = Math.floor(bounds.min_x / mergeFactor);
|
||||
const maxMergedX = Math.floor(bounds.max_x / mergeFactor);
|
||||
|
||||
// Calculate pixel bounds for merged tiles
|
||||
const pixelMinX = mergedX * mergeFactor * tileSize;
|
||||
const pixelMinY = mergedY * mergeFactor * tileSize;
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
| Metric | Before | After | Improvement |
|
||||
|--------|--------|-------|-------------|
|
||||
| Initial Load (Zoom 0) | 345 requests | 31 requests | **91% faster** |
|
||||
| Medium Zoom (Zoom 1) | 345 requests | 105 requests | **70% faster** |
|
||||
| Full Detail (Zoom 2) | 345 requests | 345 requests | Baseline |
|
||||
|
||||
### Storage
|
||||
|
||||
- **Total Size**: 111.30 MB (all zoom levels)
|
||||
- **Compression**: Lossless WebP
|
||||
- **Quality**: No quality loss from merging (lossless throughout)
|
||||
|
||||
### User Experience
|
||||
|
||||
✅ **Fast initial load** - Only 31 tiles at lowest zoom
|
||||
✅ **Smooth zooming** - Progressive detail with 3 zoom levels
|
||||
✅ **No gaps** - Missing tiles filled with black
|
||||
✅ **High quality** - Lossless compression preserves all detail
|
||||
✅ **Responsive** - Dramatic reduction in HTTP requests
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Why Lossless WebP?
|
||||
|
||||
- User requested no lossy compression for quality preservation
|
||||
- WebP lossless is more efficient than PNG
|
||||
- No generation of compression artifacts
|
||||
- Maintains perfect quality for merged tiles
|
||||
|
||||
### Missing Tiles
|
||||
|
||||
- Handled by filling with black (Rgba `[0, 0, 0, 255]`)
|
||||
- Alternative options: transparent, gray, or skip entirely
|
||||
- Current approach ensures consistent tile grid
|
||||
|
||||
### Merge Factor Choice
|
||||
|
||||
- 4×4 for zoom 0: Aggressive merging for fast overview
|
||||
- 2×2 for zoom 1: Balance between detail and performance
|
||||
- 1×1 for zoom 2: Full original quality
|
||||
|
||||
These factors were chosen based on:
|
||||
- Map dimensions (30×17 tiles)
|
||||
- Typical web map conventions
|
||||
- Performance testing
|
||||
|
||||
## Future Optimizations
|
||||
|
||||
Potential improvements:
|
||||
|
||||
1. **On-Demand Generation**: Generate merged tiles on first request instead of pre-generating
|
||||
2. **Cache Headers**: Add HTTP caching headers for browser caching
|
||||
3. **Progressive Loading**: Load center tiles first, then edges
|
||||
4. **Tile Prioritization**: Load visible tiles before off-screen tiles
|
||||
5. **WebP Quality Tuning**: Test near-lossless modes for even smaller files
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
cursebreaker-parser/
|
||||
├── migrations/
|
||||
│ └── 2026-01-10-120919-0000_create_merged_tiles/
|
||||
│ ├── up.sql # NEW: Create merged_tiles table
|
||||
│ └── down.sql # NEW: Drop merged_tiles table
|
||||
├── src/
|
||||
│ ├── bin/
|
||||
│ │ └── merge-tiles.rs # NEW: Tile merging tool
|
||||
│ └── schema.rs # MODIFIED: Regenerated with merged_tiles
|
||||
└── Cargo.toml # MODIFIED: Added chrono, merge-tiles bin
|
||||
|
||||
cursebreaker-map/
|
||||
├── src/
|
||||
│ └── main.rs # MODIFIED: Serve merged tiles
|
||||
├── static/
|
||||
│ └── map.js # MODIFIED: Request merged tiles
|
||||
└── README.md # MODIFIED: Updated documentation
|
||||
|
||||
cursebreaker.db # MODIFIED: Added merged_tiles table + data
|
||||
```
|
||||
|
||||
## Running the Optimized Map
|
||||
|
||||
### First Time Setup
|
||||
```bash
|
||||
# Generate merged tiles (one time, ~1.5 minutes)
|
||||
cd cursebreaker-parser
|
||||
cargo run --bin merge-tiles --release
|
||||
|
||||
# Start server
|
||||
cd ../cursebreaker-map
|
||||
cargo run --release
|
||||
|
||||
# Open http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
### Subsequent Runs
|
||||
```bash
|
||||
# Just start server (merged tiles persisted in DB)
|
||||
cd cursebreaker-map
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
To verify the optimization is working:
|
||||
|
||||
1. Open browser DevTools → Network tab
|
||||
2. Load the map
|
||||
3. Check number of tile requests:
|
||||
- Zoom 0: Should see ~31 requests to `/api/tiles/0/...`
|
||||
- Zoom 1: Should see ~105 requests to `/api/tiles/1/...`
|
||||
- Zoom 2: Should see ~345 requests to `/api/tiles/2/...`
|
||||
4. Console should show: `Loading X merged tiles`
|
||||
@@ -45,7 +45,12 @@ The project provides multiple binaries to handle different parsing tasks. This a
|
||||
2. **scene-parser** - Parses Unity scenes and extracts world resource locations
|
||||
- Slow execution (Unity project initialization)
|
||||
- Extracts InteractableResource components and their positions
|
||||
- Saves to `world_resources` table (item_id and 2D coordinates)
|
||||
- Saves to `world_resources` table (harvestable_id and 2D coordinates)
|
||||
- Processes item icons for harvestables:
|
||||
- Looks up the first item drop for each harvestable from `harvestable_drops` table
|
||||
- Loads the icon from `Data/Textures/ItemIcons/{item_id}.png`
|
||||
- Applies white outline (1px) and resizes to 64x64
|
||||
- Converts to WebP and stores in `resource_icons` table
|
||||
- Run this when scene files change
|
||||
```bash
|
||||
cargo run --bin scene-parser
|
||||
@@ -87,6 +92,11 @@ The project provides multiple binaries to handle different parsing tasks. This a
|
||||
cargo run --bin verify-stats
|
||||
```
|
||||
|
||||
9. **verify-resource-icons** - Verifies resource icons for harvestables
|
||||
```bash
|
||||
cargo run --bin verify-resource-icons
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
Build specific binaries for release:
|
||||
@@ -185,6 +195,39 @@ for resource in copper_ore {
|
||||
|
||||
See `examples/query_world_resources.rs` for a complete example.
|
||||
|
||||
### Querying Resource Icons
|
||||
|
||||
```rust
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
|
||||
// Connect to database
|
||||
let mut conn = SqliteConnection::establish("../cursebreaker.db")?;
|
||||
|
||||
// Define the structure
|
||||
#[derive(Queryable, Debug)]
|
||||
struct ResourceIcon {
|
||||
item_id: i32, // Harvestable ID
|
||||
name: String, // Harvestable name
|
||||
icon_64: Vec<u8>, // WebP image data (64x64 with white border)
|
||||
}
|
||||
|
||||
// Query icon for a specific harvestable
|
||||
use cursebreaker_parser::schema::resource_icons::dsl::*;
|
||||
|
||||
let copper_icon = resource_icons
|
||||
.filter(item_id.eq(2)) // Harvestable ID for Copper Ore
|
||||
.first::<ResourceIcon>(&mut conn)?;
|
||||
|
||||
println!("Found icon for: {}", copper_icon.name);
|
||||
println!("Icon size: {} bytes (WebP format)", copper_icon.icon_64.len());
|
||||
|
||||
// Save to file if needed
|
||||
std::fs::write("copper_ore.webp", &copper_icon.icon_64)?;
|
||||
```
|
||||
|
||||
See `examples/resource_icons_example.rs` for a complete example.
|
||||
|
||||
### Additional Databases
|
||||
|
||||
Similar APIs are available for other game data types:
|
||||
@@ -227,6 +270,7 @@ The project includes several example programs demonstrating different aspects of
|
||||
- **game_data_demo.rs** - Comprehensive demo loading and querying all game data types (Items, NPCs, Quests, Harvestables, Loot)
|
||||
- **item_database_demo.rs** - Focused on item database operations
|
||||
- **query_world_resources.rs** - Querying world resource locations from the database
|
||||
- **resource_icons_example.rs** - Querying processed harvestable icons with white borders
|
||||
- **fast_travel_example.rs** - Working with fast travel locations
|
||||
- **maps_example.rs** - Map data handling
|
||||
- **player_houses_example.rs** - Player house management
|
||||
@@ -252,7 +296,8 @@ cursebreaker-parser/
|
||||
│ │ ├── verify-db.rs # Database verification
|
||||
│ │ ├── verify-expanded-db.rs # Expanded database verification
|
||||
│ │ ├── verify-images.rs # Image verification
|
||||
│ │ └── verify-stats.rs # Stats verification
|
||||
│ │ ├── verify-stats.rs # Stats verification
|
||||
│ │ └── verify-resource-icons.rs # Resource icons verification
|
||||
│ ├── xml_parser.rs # XML parsing utilities
|
||||
│ ├── image_processor.rs # Image processing utilities
|
||||
│ ├── item_loader.rs # Item loading logic
|
||||
@@ -297,6 +342,7 @@ The parser uses Diesel for database operations with SQLite. Database migrations
|
||||
- Quest definitions and phases
|
||||
- Harvestable resources and drop tables
|
||||
- World resource locations from Unity scenes
|
||||
- Resource icons for harvestables (64x64 WebP with white borders)
|
||||
- Minimap tiles and metadata
|
||||
- Shop inventories and pricing
|
||||
- Player houses and locations
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
# XML Parsing in Cursebreaker Parser
|
||||
|
||||
This document describes the XML parsing functionality added to the cursebreaker-parser project.
|
||||
|
||||
## Overview
|
||||
|
||||
The parser now supports loading game data from Cursebreaker's XML files and storing them in efficient data structures for runtime access and SQL database serialization.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Parse Items, NPCs, Quests, and Harvestables XML files with full attribute and nested element support
|
||||
- ✅ In-memory databases with fast lookups by ID, name, and various filters
|
||||
- ✅ JSON serialization for SQL database storage
|
||||
- ✅ Type-safe data structures with serde support
|
||||
- ✅ Easy-to-use API with query methods
|
||||
- ✅ Cross-referencing support between different data types
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Loading Items
|
||||
|
||||
```rust
|
||||
use cursebreaker_parser::ItemDatabase;
|
||||
|
||||
let item_db = ItemDatabase::load_from_xml("Data/XMLs/Items/Items.xml")?;
|
||||
println!("Loaded {} items", item_db.len());
|
||||
```
|
||||
|
||||
### Querying Items
|
||||
|
||||
```rust
|
||||
// Get by ID
|
||||
if let Some(item) = item_db.get_by_id(150) {
|
||||
println!("Found: {}", item.name);
|
||||
}
|
||||
|
||||
// Get by category
|
||||
let bows = item_db.get_by_category("bow");
|
||||
|
||||
// Get by slot
|
||||
let weapons = item_db.get_by_slot("weapon");
|
||||
|
||||
// Get by skill requirement
|
||||
let magic_items = item_db.get_by_skill("magic");
|
||||
|
||||
// Get all items
|
||||
for item in item_db.all_items() {
|
||||
println!("{}: {}", item.id, item.name);
|
||||
}
|
||||
```
|
||||
|
||||
### SQL Serialization
|
||||
|
||||
```rust
|
||||
// Prepare items for SQL insertion
|
||||
let sql_data = item_db.prepare_for_sql();
|
||||
|
||||
for (id, name, json_data) in sql_data {
|
||||
// INSERT INTO items (id, name, data) VALUES (?, ?, ?)
|
||||
// Use your preferred SQL library to insert
|
||||
}
|
||||
```
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Item
|
||||
|
||||
The main `Item` struct contains all item attributes from the XML:
|
||||
|
||||
```rust
|
||||
pub struct Item {
|
||||
// Required
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
|
||||
// Optional attributes
|
||||
pub level: Option<i32>,
|
||||
pub description: Option<String>,
|
||||
pub price: Option<i32>,
|
||||
pub slot: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub skill: Option<String>,
|
||||
|
||||
// ... many more fields
|
||||
|
||||
// Nested elements
|
||||
pub stats: Vec<ItemStat>,
|
||||
pub crafting_recipes: Vec<CraftingRecipe>,
|
||||
pub animations: Option<AnimationSet>,
|
||||
pub generate_rules: Vec<GenerateRule>,
|
||||
}
|
||||
```
|
||||
|
||||
### ItemStat
|
||||
|
||||
Represents item statistics:
|
||||
|
||||
```rust
|
||||
pub struct ItemStat {
|
||||
// Damage
|
||||
pub damagephysical: Option<i32>,
|
||||
pub damagemagical: Option<i32>,
|
||||
pub damageranged: Option<i32>,
|
||||
|
||||
// Accuracy
|
||||
pub accuracyphysical: Option<i32>,
|
||||
pub accuracymagical: Option<i32>,
|
||||
pub accuracyranged: Option<i32>,
|
||||
|
||||
// Resistance
|
||||
pub resistancephysical: Option<i32>,
|
||||
pub resistancemagical: Option<i32>,
|
||||
pub resistanceranged: Option<i32>,
|
||||
|
||||
// Core stats
|
||||
pub health: Option<i32>,
|
||||
pub mana: Option<i32>,
|
||||
pub manaregen: Option<i32>,
|
||||
pub healing: Option<i32>,
|
||||
|
||||
// Harvesting
|
||||
pub harvestingspeedwoodcutting: Option<i32>,
|
||||
}
|
||||
```
|
||||
|
||||
## Example Programs
|
||||
|
||||
Run the demos to see all features in action:
|
||||
|
||||
```bash
|
||||
# Items only
|
||||
cargo run --example item_database_demo
|
||||
|
||||
# All game data (Items, NPCs, Quests, Harvestables)
|
||||
cargo run --example game_data_demo
|
||||
```
|
||||
|
||||
## Loading NPCs
|
||||
|
||||
```rust
|
||||
use cursebreaker_parser::NpcDatabase;
|
||||
|
||||
let npc_db = NpcDatabase::load_from_xml("Data/XMLs/Npcs/NPCInfo.xml")?;
|
||||
println!("Loaded {} NPCs", npc_db.len());
|
||||
```
|
||||
|
||||
### Querying NPCs
|
||||
|
||||
```rust
|
||||
// Get by ID
|
||||
if let Some(npc) = npc_db.get_by_id(1) {
|
||||
println!("Found: {}", npc.name);
|
||||
}
|
||||
|
||||
// Get hostile NPCs
|
||||
let hostile = npc_db.get_hostile();
|
||||
|
||||
// Get interactable NPCs
|
||||
let interactable = npc_db.get_interactable();
|
||||
|
||||
// Get NPCs by tag
|
||||
let undead = npc_db.get_by_tag("Undead");
|
||||
|
||||
// Get shopkeepers
|
||||
let shops = npc_db.get_shopkeepers();
|
||||
```
|
||||
|
||||
## Loading Quests
|
||||
|
||||
```rust
|
||||
use cursebreaker_parser::QuestDatabase;
|
||||
|
||||
let quest_db = QuestDatabase::load_from_xml("Data/XMLs/Quests/Quests.xml")?;
|
||||
println!("Loaded {} quests", quest_db.len());
|
||||
```
|
||||
|
||||
### Querying Quests
|
||||
|
||||
```rust
|
||||
// Get by ID
|
||||
if let Some(quest) = quest_db.get_by_id(1) {
|
||||
println!("Quest: {}", quest.name);
|
||||
println!("Phases: {}", quest.phase_count());
|
||||
}
|
||||
|
||||
// Get main quests
|
||||
let main_quests = quest_db.get_main_quests();
|
||||
|
||||
// Get side quests
|
||||
let side_quests = quest_db.get_side_quests();
|
||||
|
||||
// Get hidden quests
|
||||
let hidden = quest_db.get_hidden_quests();
|
||||
```
|
||||
|
||||
## Loading Harvestables
|
||||
|
||||
```rust
|
||||
use cursebreaker_parser::HarvestableDatabase;
|
||||
|
||||
let harvestable_db = HarvestableDatabase::load_from_xml("Data/XMLs/Harvestables/HarvestableInfo.xml")?;
|
||||
println!("Loaded {} harvestables", harvestable_db.len());
|
||||
```
|
||||
|
||||
### Querying Harvestables
|
||||
|
||||
```rust
|
||||
// Get by type ID
|
||||
if let Some(harvestable) = harvestable_db.get_by_typeid(1) {
|
||||
println!("Found: {}", harvestable.name);
|
||||
}
|
||||
|
||||
// Get by skill
|
||||
let woodcutting = harvestable_db.get_by_skill("Woodcutting");
|
||||
let mining = harvestable_db.get_by_skill("mining");
|
||||
let fishing = harvestable_db.get_by_skill("Fishing");
|
||||
|
||||
// Get trees (harvestables with tree=1)
|
||||
let trees = harvestable_db.get_trees();
|
||||
|
||||
// Get by tool requirement
|
||||
let hatchet_nodes = harvestable_db.get_by_tool("hatchet");
|
||||
let pickaxe_nodes = harvestable_db.get_by_tool("pickaxe");
|
||||
|
||||
// Get by level range
|
||||
let beginner = harvestable_db.get_by_level_range(1, 10);
|
||||
let advanced = harvestable_db.get_by_level_range(50, 100);
|
||||
```
|
||||
|
||||
## Loading Loot Tables
|
||||
|
||||
```rust
|
||||
use cursebreaker_parser::LootDatabase;
|
||||
|
||||
let loot_db = LootDatabase::load_from_xml("Data/XMLs/Loot/Loot.xml")?;
|
||||
println!("Loaded {} loot tables", loot_db.len());
|
||||
```
|
||||
|
||||
### Querying Loot Tables
|
||||
|
||||
```rust
|
||||
// Get all loot tables for a specific NPC
|
||||
let npc_id = 45;
|
||||
let tables = loot_db.get_tables_for_npc(npc_id);
|
||||
|
||||
// Get all drops for a specific NPC
|
||||
let drops = loot_db.get_drops_for_npc(npc_id);
|
||||
for drop in drops {
|
||||
println!("Item ID: {}, Rate: {:?}", drop.item, drop.rate);
|
||||
}
|
||||
|
||||
// Find which NPCs drop a specific item
|
||||
let item_id = 180;
|
||||
let npcs = loot_db.get_npcs_dropping_item(item_id);
|
||||
println!("Item {} drops from {} NPCs", item_id, npcs.len());
|
||||
|
||||
// Get all tables with conditional drops (checks field)
|
||||
let conditional = loot_db.get_conditional_tables();
|
||||
|
||||
// Get all tables with guaranteed drops (rate = 1)
|
||||
let guaranteed = loot_db.get_tables_with_guaranteed_drops();
|
||||
|
||||
// Get all unique item IDs that can drop
|
||||
let droppable_items = loot_db.get_all_droppable_items();
|
||||
|
||||
// Get all NPCs that have loot tables
|
||||
let npcs_with_loot = loot_db.get_all_npcs_with_loot();
|
||||
```
|
||||
|
||||
## Cross-referencing Data
|
||||
|
||||
```rust
|
||||
// Find items rewarded by quests
|
||||
for quest in quest_db.all_quests() {
|
||||
for reward in &quest.rewards {
|
||||
if let Some(item_id) = reward.item {
|
||||
if let Some(item) = item_db.get_by_id(item_id) {
|
||||
println!("Quest '{}' rewards: {}", quest.name, item.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find NPCs that give quests
|
||||
for npc in npc_db.all_npcs() {
|
||||
if !npc.questmarkers.is_empty() {
|
||||
println!("NPC '{}' has {} quest markers", npc.name, npc.questmarkers.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Find items that drop from harvestables
|
||||
for harvestable in harvestable_db.all_harvestables() {
|
||||
for drop in &harvestable.drops {
|
||||
if let Some(item) = item_db.get_by_id(drop.id) {
|
||||
println!("'{}' drops: {} (rate: {})",
|
||||
harvestable.name, item.name, drop.droprate.unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find what items an NPC drops
|
||||
let npc_id = 45;
|
||||
if let Some(npc) = npc_db.get_by_id(npc_id) {
|
||||
let drops = loot_db.get_drops_for_npc(npc_id);
|
||||
println!("NPC '{}' drops {} items:", npc.name, drops.len());
|
||||
for drop in drops {
|
||||
if let Some(item) = item_db.get_by_id(drop.item) {
|
||||
println!(" - {}", item.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find which NPCs drop a specific item
|
||||
let item_id = 180;
|
||||
if let Some(item) = item_db.get_by_id(item_id) {
|
||||
let npcs = loot_db.get_npcs_dropping_item(item_id);
|
||||
println!("Item '{}' drops from:", item.name);
|
||||
for npc_id in npcs {
|
||||
if let Some(npc) = npc_db.get_by_id(npc_id) {
|
||||
println!(" - {}", npc.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Statistics from XML Files
|
||||
|
||||
When loaded from `/home/connor/repos/CBAssets/Data/XMLs/`:
|
||||
|
||||
### Items.xml
|
||||
- **Total Items**: 1,360
|
||||
- **Weapons**: 166
|
||||
- **Armor**: 148
|
||||
- **Consumables**: 294
|
||||
- **Trinkets**: 59
|
||||
- **Bows**: 18
|
||||
- **Magic Items**: 76
|
||||
|
||||
### NPCs/NPCInfo.xml
|
||||
- **Total NPCs**: 1,242
|
||||
- **Hostile NPCs**: 328
|
||||
- **Interactable NPCs**: 512
|
||||
- **Undead**: 71
|
||||
- **Predators**: 13
|
||||
- **Quest Givers**: 108
|
||||
|
||||
### Quests/Quests.xml
|
||||
- **Total Quests**: 108
|
||||
- **Main Quests**: 19
|
||||
- **Side Quests**: 89
|
||||
- **Hidden Quests**: 2
|
||||
- **Unique Quest Reward Items**: 70
|
||||
|
||||
### Harvestables/HarvestableInfo.xml
|
||||
- **Total Harvestables**: 96
|
||||
- **Trees**: 9
|
||||
- **Woodcutting**: 10
|
||||
- **Mining**: 11
|
||||
- **Fishing**: 11
|
||||
- **Alchemy**: 50
|
||||
- **Level 1-10**: 31
|
||||
- **Level 11-50**: 37
|
||||
- **Level 51-100**: 28
|
||||
- **Unique Items from Harvestables**: 98
|
||||
|
||||
### Loot/Loot.xml
|
||||
- **Total Loot Tables**: 175
|
||||
- **NPCs with Loot**: 267
|
||||
- **Droppable Items**: 405
|
||||
- **Tables with Conditional Drops**: 33
|
||||
- **Tables with Guaranteed Drops**: Multiple tables include guaranteed (rate=1) drops
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
cursebreaker-parser/
|
||||
├── src/
|
||||
│ ├── lib.rs # Library exports
|
||||
│ ├── main.rs # Main binary (Unity + XML parsing)
|
||||
│ ├── types/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── item.rs # Item data structures
|
||||
│ │ ├── npc.rs # NPC data structures
|
||||
│ │ ├── quest.rs # Quest data structures
|
||||
│ │ ├── harvestable.rs # Harvestable data structures
|
||||
│ │ ├── loot.rs # Loot table data structures
|
||||
│ │ └── interactable_resource.rs
|
||||
│ ├── xml_parser.rs # XML parsing logic (all types)
|
||||
│ ├── item_database.rs # ItemDatabase for runtime access
|
||||
│ ├── npc_database.rs # NpcDatabase for runtime access
|
||||
│ ├── quest_database.rs # QuestDatabase for runtime access
|
||||
│ ├── harvestable_database.rs # HarvestableDatabase for runtime access
|
||||
│ └── loot_database.rs # LootDatabase for runtime access
|
||||
└── examples/
|
||||
├── item_database_demo.rs # Items usage example
|
||||
└── game_data_demo.rs # Full game data example
|
||||
```
|
||||
|
||||
## Dependencies Added
|
||||
|
||||
```toml
|
||||
quick-xml = "0.37" # XML parsing
|
||||
serde = { version = "1.0", features = ["derive"] } # Serialization
|
||||
serde_json = "1.0" # JSON serialization
|
||||
diesel = { version = "2.2", features = ["sqlite"], optional = true } # SQL (optional)
|
||||
thiserror = "1.0" # Error handling
|
||||
```
|
||||
|
||||
## Completed Features
|
||||
|
||||
- ✅ Items (`/XMLs/Items/Items.xml`)
|
||||
- ✅ NPCs (`/XMLs/Npcs/NPCInfo.xml`)
|
||||
- ✅ Quests (`/XMLs/Quests/Quests.xml`)
|
||||
- ✅ Harvestables (`/XMLs/Harvestables/HarvestableInfo.xml`)
|
||||
- ✅ Loot tables (`/XMLs/Loot/Loot.xml`)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
The same pattern can be extended to parse other XML files:
|
||||
|
||||
- [ ] Maps (`/XMLs/Maps/*.xml`)
|
||||
- [ ] Dialogue (`/XMLs/Dialogue/*.xml`)
|
||||
- [ ] Events (`/XMLs/Events/*.xml`)
|
||||
- [ ] Achievements (`/XMLs/Achievements/*.xml`)
|
||||
- [ ] Traits (`/XMLs/Traits/*.xml`)
|
||||
- [ ] Shops (`/XMLs/Shops/*.xml`)
|
||||
|
||||
Each follows the same pattern:
|
||||
1. Define data structures in `src/types/`
|
||||
2. Create parser in `src/xml_parser.rs`
|
||||
3. Create database wrapper for runtime access
|
||||
4. Add to `lib.rs` exports
|
||||
|
||||
## Integration with Unity Parser
|
||||
|
||||
The main binary (`src/main.rs`) demonstrates integration of both systems:
|
||||
|
||||
1. Load game data from XML files (Items, etc.)
|
||||
2. Parse Unity scenes for game objects
|
||||
3. Cross-reference data (e.g., item IDs in loot spawners)
|
||||
|
||||
This creates a complete game data pipeline from source files to runtime.
|
||||
53
cursebreaker-parser/examples/resource_icons_example.rs
Normal file
53
cursebreaker-parser/examples/resource_icons_example.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Example: Query resource icons from the database
|
||||
//!
|
||||
//! This example shows how to retrieve processed resource icons for harvestables.
|
||||
//! Icons are 64x64 WebP images with white borders.
|
||||
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use std::env;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to database
|
||||
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||
|
||||
// Define the structure
|
||||
#[derive(Queryable, Debug)]
|
||||
struct ResourceIcon {
|
||||
item_id: i32,
|
||||
name: String,
|
||||
icon_64: Vec<u8>,
|
||||
}
|
||||
|
||||
// Import schema
|
||||
use cursebreaker_parser::schema::resource_icons::dsl::*;
|
||||
|
||||
// Query all resource icons
|
||||
let icons = resource_icons.load::<ResourceIcon>(&mut conn)?;
|
||||
|
||||
println!("📦 Resource Icons Database");
|
||||
println!("========================\n");
|
||||
println!("Total icons: {}\n", icons.len());
|
||||
|
||||
for icon in icons {
|
||||
println!("Harvestable ID: {}", icon.item_id);
|
||||
println!(" Name: {}", icon.name);
|
||||
println!(" Icon size: {} bytes (WebP format, 64x64 with white border)", icon.icon_64.len());
|
||||
println!();
|
||||
}
|
||||
|
||||
// Example: Get icon for a specific harvestable
|
||||
println!("\n🔍 Looking up Copper Ore (harvestable_id = 2):");
|
||||
let copper_icon = resource_icons
|
||||
.filter(item_id.eq(2))
|
||||
.first::<ResourceIcon>(&mut conn)?;
|
||||
|
||||
println!(" Name: {}", copper_icon.name);
|
||||
println!(" Icon size: {} bytes", copper_icon.icon_64.len());
|
||||
|
||||
// You can save the icon to a file for testing:
|
||||
// std::fs::write("copper_ore.webp", &copper_icon.icon_64)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Revert to the simple harvestables table
|
||||
DROP TABLE IF EXISTS harvestable_drops;
|
||||
DROP TABLE IF EXISTS harvestables;
|
||||
|
||||
CREATE TABLE harvestables (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Restructure harvestables table to store expanded data
|
||||
DROP TABLE IF EXISTS harvestables;
|
||||
|
||||
CREATE TABLE harvestables (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
skill TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
min_health INTEGER NOT NULL,
|
||||
max_health INTEGER NOT NULL,
|
||||
harvesttime INTEGER NOT NULL,
|
||||
hittime INTEGER NOT NULL,
|
||||
respawntime INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Create harvestable_drops table
|
||||
CREATE TABLE harvestable_drops (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
harvestable_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
minamount INTEGER NOT NULL,
|
||||
maxamount INTEGER NOT NULL,
|
||||
droprate INTEGER NOT NULL,
|
||||
droprateboost INTEGER NOT NULL,
|
||||
amountboost INTEGER NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
FOREIGN KEY (harvestable_id) REFERENCES harvestables(id),
|
||||
FOREIGN KEY (item_id) REFERENCES items(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_harvestable_drops_harvestable_id ON harvestable_drops(harvestable_id);
|
||||
CREATE INDEX idx_harvestable_drops_item_id ON harvestable_drops(item_id);
|
||||
CREATE INDEX idx_harvestables_skill ON harvestables(skill);
|
||||
CREATE INDEX idx_harvestables_tool ON harvestables(tool);
|
||||
CREATE INDEX idx_harvestables_level ON harvestables(level);
|
||||
|
||||
@@ -122,19 +122,19 @@ fn process_item_icons(
|
||||
conn: &mut SqliteConnection,
|
||||
scene: &unity_parser::UnityScene,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use cursebreaker_parser::schema::{resource_icons, items};
|
||||
use cursebreaker_parser::schema::{resource_icons, items, harvestables, harvestable_drops};
|
||||
|
||||
// Collect unique item IDs from resources
|
||||
let mut unique_items: HashMap<i32, String> = HashMap::new();
|
||||
// Collect unique harvestable IDs from resources
|
||||
let mut unique_harvestables: HashMap<i32, String> = HashMap::new();
|
||||
|
||||
scene.world
|
||||
.query_all::<(&InteractableResource, &unity_parser::GameObject)>()
|
||||
.for_each(|(resource, object)| {
|
||||
unique_items.entry(resource.type_id as i32)
|
||||
unique_harvestables.entry(resource.type_id as i32)
|
||||
.or_insert_with(|| object.name.to_string());
|
||||
});
|
||||
|
||||
info!(" Found {} unique resource types", unique_items.len());
|
||||
info!(" Found {} unique harvestable types", unique_harvestables.len());
|
||||
|
||||
// Clear existing resource icons (regenerated each run)
|
||||
diesel::delete(resource_icons::table).execute(conn)?;
|
||||
@@ -146,22 +146,46 @@ fn process_item_icons(
|
||||
let mut processed_count = 0;
|
||||
let mut failed_count = 0;
|
||||
|
||||
// Process each unique item
|
||||
for (item_id, default_name) in unique_items.iter() {
|
||||
// Try to get the actual item name from the items table
|
||||
let item_name: String = items::table
|
||||
.filter(items::id.eq(item_id))
|
||||
.select(items::name)
|
||||
// Process each unique harvestable
|
||||
for (harvestable_id, default_name) in unique_harvestables.iter() {
|
||||
// Get the harvestable name
|
||||
let harvestable_name: String = harvestables::table
|
||||
.filter(harvestables::id.eq(harvestable_id))
|
||||
.select(harvestables::name)
|
||||
.first(conn)
|
||||
.unwrap_or_else(|_| default_name.clone());
|
||||
|
||||
// Construct icon path
|
||||
// Get the first item drop for this harvestable
|
||||
let item_id_result: Result<i32, _> = harvestable_drops::table
|
||||
.filter(harvestable_drops::harvestable_id.eq(harvestable_id))
|
||||
.select(harvestable_drops::item_id)
|
||||
.order(harvestable_drops::id.asc())
|
||||
.first(conn);
|
||||
|
||||
let item_id = match item_id_result {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
warn!(" ⚠️ No drops found for harvestable {} ({})", harvestable_id, harvestable_name);
|
||||
failed_count += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the item name
|
||||
let item_name: String = items::table
|
||||
.filter(items::id.eq(&item_id))
|
||||
.select(items::name)
|
||||
.first(conn)
|
||||
.unwrap_or_else(|_| format!("Item {}", item_id));
|
||||
|
||||
// Construct icon path using the item_id from the drop
|
||||
let icon_path = PathBuf::from(cb_assets_path)
|
||||
.join("Data/Textures/ItemIcons")
|
||||
.join(format!("{}.png", item_id));
|
||||
|
||||
if !icon_path.exists() {
|
||||
warn!(" ⚠️ Icon not found for item {} ({}): {}", item_id, item_name, icon_path.display());
|
||||
warn!(" ⚠️ Icon not found for harvestable {} ({}) -> item {} ({}): {}",
|
||||
harvestable_id, harvestable_name, item_id, item_name, icon_path.display());
|
||||
failed_count += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -170,38 +194,38 @@ fn process_item_icons(
|
||||
match processor.process_image(&icon_path, &[64], None, Some(&outline_config)) {
|
||||
Ok(processed) => {
|
||||
if let Some(icon_data) = processed.get(64) {
|
||||
// Insert into database
|
||||
// Insert into database using harvestable_id as the key
|
||||
match diesel::insert_into(resource_icons::table)
|
||||
.values((
|
||||
resource_icons::item_id.eq(item_id),
|
||||
resource_icons::name.eq(&item_name),
|
||||
resource_icons::item_id.eq(harvestable_id),
|
||||
resource_icons::name.eq(&harvestable_name),
|
||||
resource_icons::icon_64.eq(icon_data.as_slice()),
|
||||
))
|
||||
.execute(conn)
|
||||
{
|
||||
Ok(_) => {
|
||||
info!(" ✓ Processed icon for item {} ({}): {} bytes",
|
||||
item_id, item_name, icon_data.len());
|
||||
info!(" ✓ Harvestable {} ({}) -> Item {} ({}): {} bytes",
|
||||
harvestable_id, harvestable_name, item_id, item_name, icon_data.len());
|
||||
processed_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(" ⚠️ Failed to insert icon for item {} ({}): {}",
|
||||
item_id, item_name, e);
|
||||
warn!(" ⚠️ Failed to insert icon for harvestable {} ({}): {}",
|
||||
harvestable_id, harvestable_name, e);
|
||||
failed_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(" ⚠️ Failed to process icon for item {} ({}): {}",
|
||||
item_id, item_name, e);
|
||||
warn!(" ⚠️ Failed to process icon for harvestable {} ({}) -> item {} ({}): {}",
|
||||
harvestable_id, harvestable_name, item_id, item_name, e);
|
||||
failed_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("✅ Processed {} item icons ({} succeeded, {} failed)",
|
||||
unique_items.len(), processed_count, failed_count);
|
||||
info!("✅ Processed {} harvestable icons ({} succeeded, {} failed)",
|
||||
unique_harvestables.len(), processed_count, failed_count);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! - Populating the SQLite database with the parsed data
|
||||
//! - Generating statistics about the loaded data
|
||||
|
||||
use cursebreaker_parser::ItemDatabase;
|
||||
use cursebreaker_parser::{ItemDatabase, HarvestableDatabase};
|
||||
use log::{info, warn, LevelFilter};
|
||||
use unity_parser::log::DedupLogger;
|
||||
use diesel::prelude::*;
|
||||
|
||||
@@ -62,27 +62,21 @@ impl HarvestableDatabase {
|
||||
|
||||
/// Get harvestables by skill
|
||||
pub fn get_by_skill(&self, skill: &str) -> Vec<&Harvestable> {
|
||||
use crate::types::SkillType;
|
||||
let skill_type = skill.parse::<SkillType>().unwrap_or(SkillType::None);
|
||||
self.harvestables
|
||||
.iter()
|
||||
.filter(|h| {
|
||||
h.skill
|
||||
.as_ref()
|
||||
.map(|s| s.eq_ignore_ascii_case(skill))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter(|h| h.skill == skill_type)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get harvestables that require a specific tool
|
||||
pub fn get_by_tool(&self, tool: &str) -> Vec<&Harvestable> {
|
||||
use crate::types::Tool;
|
||||
let tool_type = tool.parse::<Tool>().unwrap_or(Tool::None);
|
||||
self.harvestables
|
||||
.iter()
|
||||
.filter(|h| {
|
||||
h.tool
|
||||
.as_ref()
|
||||
.map(|t| t.eq_ignore_ascii_case(tool))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter(|h| h.tool == tool_type)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -106,11 +100,7 @@ impl HarvestableDatabase {
|
||||
pub fn get_by_level_range(&self, min_level: i32, max_level: i32) -> Vec<&Harvestable> {
|
||||
self.harvestables
|
||||
.iter()
|
||||
.filter(|h| {
|
||||
h.level
|
||||
.map(|l| l >= min_level && l <= max_level)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter(|h| h.level >= min_level && h.level <= max_level)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -126,38 +116,136 @@ impl HarvestableDatabase {
|
||||
|
||||
/// Prepare harvestables 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)> {
|
||||
#[allow(deprecated)]
|
||||
pub fn prepare_for_sql(&self) -> Vec<(i32, String, String, String, i32, String, String, i32, i32, i32, i32, i32)> {
|
||||
use crate::types::{SkillType, Tool};
|
||||
|
||||
self.harvestables
|
||||
.iter()
|
||||
.map(|harvestable| {
|
||||
let json = serde_json::to_string(harvestable).unwrap_or_else(|_| "{}".to_string());
|
||||
(harvestable.typeid, harvestable.name.clone(), json)
|
||||
let skill_str = match harvestable.skill {
|
||||
SkillType::None => "none",
|
||||
SkillType::Swordsmanship => "swordsmanship",
|
||||
SkillType::Archery => "archery",
|
||||
SkillType::Magic => "magic",
|
||||
SkillType::Defence => "defence",
|
||||
SkillType::Mining => "mining",
|
||||
SkillType::Woodcutting => "woodcutting",
|
||||
SkillType::Fishing => "fishing",
|
||||
SkillType::Cooking => "cooking",
|
||||
SkillType::Carpentry => "carpentry",
|
||||
SkillType::Blacksmithy => "blacksmithy",
|
||||
SkillType::Tailoring => "tailoring",
|
||||
SkillType::Alchemy => "alchemy",
|
||||
}.to_string();
|
||||
|
||||
let tool_str = match harvestable.tool {
|
||||
Tool::None => "none",
|
||||
Tool::Pickaxe => "pickaxe",
|
||||
Tool::Hatchet => "hatchet",
|
||||
Tool::Scythe => "scythe",
|
||||
Tool::Hammer => "hammer",
|
||||
Tool::Shears => "shears",
|
||||
Tool::FishingRod => "fishingrod",
|
||||
}.to_string();
|
||||
|
||||
(
|
||||
harvestable.typeid,
|
||||
harvestable.name.clone(),
|
||||
harvestable.desc.clone(),
|
||||
harvestable.comment.clone(),
|
||||
harvestable.level,
|
||||
skill_str,
|
||||
tool_str,
|
||||
harvestable.min_health,
|
||||
harvestable.max_health,
|
||||
harvestable.harvesttime,
|
||||
harvestable.hittime,
|
||||
harvestable.respawntime,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Save all harvestables to SQLite database
|
||||
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
|
||||
use crate::schema::harvestables;
|
||||
use crate::schema::{harvestables, harvestable_drops};
|
||||
use crate::types::{SkillType, Tool};
|
||||
|
||||
let records: Vec<_> = self
|
||||
.harvestables
|
||||
.iter()
|
||||
.map(|harvestable| {
|
||||
let json = serde_json::to_string(harvestable).unwrap_or_else(|_| "{}".to_string());
|
||||
(
|
||||
harvestables::id.eq(harvestable.typeid),
|
||||
harvestables::name.eq(&harvestable.name),
|
||||
harvestables::data.eq(json),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Clear existing data
|
||||
diesel::delete(harvestable_drops::table).execute(conn)?;
|
||||
diesel::delete(harvestables::table).execute(conn)?;
|
||||
|
||||
let mut count = 0;
|
||||
for record in records {
|
||||
for harvestable in &self.harvestables {
|
||||
// Convert enums to strings for database storage
|
||||
let skill_str = match harvestable.skill {
|
||||
SkillType::None => "none",
|
||||
SkillType::Swordsmanship => "swordsmanship",
|
||||
SkillType::Archery => "archery",
|
||||
SkillType::Magic => "magic",
|
||||
SkillType::Defence => "defence",
|
||||
SkillType::Mining => "mining",
|
||||
SkillType::Woodcutting => "woodcutting",
|
||||
SkillType::Fishing => "fishing",
|
||||
SkillType::Cooking => "cooking",
|
||||
SkillType::Carpentry => "carpentry",
|
||||
SkillType::Blacksmithy => "blacksmithy",
|
||||
SkillType::Tailoring => "tailoring",
|
||||
SkillType::Alchemy => "alchemy",
|
||||
};
|
||||
|
||||
let tool_str = match harvestable.tool {
|
||||
Tool::None => "none",
|
||||
Tool::Pickaxe => "pickaxe",
|
||||
Tool::Hatchet => "hatchet",
|
||||
Tool::Scythe => "scythe",
|
||||
Tool::Hammer => "hammer",
|
||||
Tool::Shears => "shears",
|
||||
Tool::FishingRod => "fishingrod",
|
||||
};
|
||||
|
||||
// Insert harvestable
|
||||
diesel::insert_into(harvestables::table)
|
||||
.values(&record)
|
||||
.values((
|
||||
harvestables::id.eq(harvestable.typeid),
|
||||
harvestables::name.eq(&harvestable.name),
|
||||
harvestables::description.eq(&harvestable.desc),
|
||||
harvestables::comment.eq(&harvestable.comment),
|
||||
harvestables::level.eq(harvestable.level),
|
||||
harvestables::skill.eq(skill_str),
|
||||
harvestables::tool.eq(tool_str),
|
||||
harvestables::min_health.eq(harvestable.min_health),
|
||||
harvestables::max_health.eq(harvestable.max_health),
|
||||
harvestables::harvesttime.eq(harvestable.harvesttime),
|
||||
harvestables::hittime.eq(harvestable.hittime),
|
||||
harvestables::respawntime.eq(harvestable.respawntime),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
// Insert drops
|
||||
for drop in &harvestable.drops {
|
||||
// Try to insert, but skip if foreign key constraint fails (item doesn't exist)
|
||||
let insert_result = diesel::insert_into(harvestable_drops::table)
|
||||
.values((
|
||||
harvestable_drops::harvestable_id.eq(harvestable.typeid),
|
||||
harvestable_drops::item_id.eq(drop.id),
|
||||
harvestable_drops::minamount.eq(drop.minamount),
|
||||
harvestable_drops::maxamount.eq(drop.maxamount),
|
||||
harvestable_drops::droprate.eq(drop.droprate),
|
||||
harvestable_drops::droprateboost.eq(drop.droprateboost),
|
||||
harvestable_drops::amountboost.eq(drop.amountboost),
|
||||
harvestable_drops::comment.eq(&drop.comment),
|
||||
))
|
||||
.execute(conn);
|
||||
|
||||
// Log warning if insert failed but continue
|
||||
if let Err(e) = insert_result {
|
||||
eprintln!("Warning: Failed to insert drop for harvestable {} (item {}): {}",
|
||||
harvestable.typeid, drop.id, e);
|
||||
}
|
||||
}
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
@@ -166,22 +254,91 @@ impl HarvestableDatabase {
|
||||
|
||||
/// Load all harvestables from SQLite database
|
||||
pub fn load_from_db(conn: &mut SqliteConnection) -> Result<Self, diesel::result::Error> {
|
||||
use crate::schema::harvestables::dsl::*;
|
||||
use crate::schema::{harvestables, harvestable_drops};
|
||||
use crate::types::{Harvestable, HarvestableDrop, SkillType, Tool};
|
||||
use diesel::prelude::*;
|
||||
|
||||
#[derive(Queryable)]
|
||||
struct HarvestableRecord {
|
||||
id: Option<i32>,
|
||||
id: i32,
|
||||
name: String,
|
||||
data: String,
|
||||
description: String,
|
||||
comment: String,
|
||||
level: i32,
|
||||
skill: String,
|
||||
tool: String,
|
||||
min_health: i32,
|
||||
max_health: i32,
|
||||
harvesttime: i32,
|
||||
hittime: i32,
|
||||
respawntime: i32,
|
||||
}
|
||||
|
||||
let records = harvestables.load::<HarvestableRecord>(conn)?;
|
||||
#[derive(Queryable)]
|
||||
struct HarvestableDropRecord {
|
||||
id: Option<i32>,
|
||||
harvestable_id: i32,
|
||||
item_id: i32,
|
||||
minamount: i32,
|
||||
maxamount: i32,
|
||||
droprate: i32,
|
||||
droprateboost: i32,
|
||||
amountboost: i32,
|
||||
comment: String,
|
||||
}
|
||||
|
||||
let harv_records = harvestables::table.load::<HarvestableRecord>(conn)?;
|
||||
let drop_records = harvestable_drops::table.load::<HarvestableDropRecord>(conn)?;
|
||||
|
||||
let mut loaded_harvestables = Vec::new();
|
||||
for record in records {
|
||||
if let Ok(harvestable) = serde_json::from_str::<Harvestable>(&record.data) {
|
||||
loaded_harvestables.push(harvestable);
|
||||
for record in harv_records {
|
||||
let mut harvestable = Harvestable {
|
||||
typeid: record.id,
|
||||
name: record.name,
|
||||
actionname: String::new(),
|
||||
desc: record.description,
|
||||
comment: record.comment,
|
||||
level: record.level,
|
||||
skill: record.skill.parse().unwrap_or(SkillType::None),
|
||||
tool: record.tool.parse().unwrap_or(Tool::None),
|
||||
min_health: record.min_health,
|
||||
max_health: record.max_health,
|
||||
harvesttime: record.harvesttime,
|
||||
hittime: record.hittime,
|
||||
respawntime: record.respawntime,
|
||||
harvestsfx: String::new(),
|
||||
endsfx: String::new(),
|
||||
receiveitemsfx: String::new(),
|
||||
animation: String::new(),
|
||||
takehitanimation: String::new(),
|
||||
endgfx: String::new(),
|
||||
tree: false,
|
||||
hidemilestone: false,
|
||||
nohighlight: false,
|
||||
hideminimap: false,
|
||||
noleftclickinteract: false,
|
||||
interactdistance: String::new(),
|
||||
drops: Vec::new(),
|
||||
};
|
||||
|
||||
// Add drops for this harvestable
|
||||
for drop_rec in &drop_records {
|
||||
if drop_rec.harvestable_id == record.id {
|
||||
harvestable.drops.push(HarvestableDrop {
|
||||
id: drop_rec.item_id,
|
||||
minamount: drop_rec.minamount,
|
||||
maxamount: drop_rec.maxamount,
|
||||
droprate: drop_rec.droprate,
|
||||
droprateboost: drop_rec.droprateboost,
|
||||
amountboost: drop_rec.amountboost,
|
||||
checks: String::new(),
|
||||
comment: drop_rec.comment.clone(),
|
||||
dontconsumehealth: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loaded_harvestables.push(harvestable);
|
||||
}
|
||||
|
||||
let mut db = Self::new();
|
||||
|
||||
@@ -31,10 +31,33 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
harvestables (id) {
|
||||
harvestable_drops (id) {
|
||||
id -> Nullable<Integer>,
|
||||
harvestable_id -> Integer,
|
||||
item_id -> Integer,
|
||||
minamount -> Integer,
|
||||
maxamount -> Integer,
|
||||
droprate -> Integer,
|
||||
droprateboost -> Integer,
|
||||
amountboost -> Integer,
|
||||
comment -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
harvestables (id) {
|
||||
id -> Integer,
|
||||
name -> Text,
|
||||
data -> Text,
|
||||
description -> Text,
|
||||
comment -> Text,
|
||||
level -> Integer,
|
||||
skill -> Text,
|
||||
tool -> Text,
|
||||
min_health -> Integer,
|
||||
max_health -> Integer,
|
||||
harvesttime -> Integer,
|
||||
hittime -> Integer,
|
||||
respawntime -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,12 +197,15 @@ 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!(harvestable_drops -> harvestables (harvestable_id));
|
||||
diesel::joinable!(harvestable_drops -> items (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,
|
||||
harvestable_drops,
|
||||
harvestables,
|
||||
item_stats,
|
||||
items,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::item::{SkillType, Tool};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Harvestable {
|
||||
@@ -7,40 +8,41 @@ pub struct Harvestable {
|
||||
pub name: String,
|
||||
|
||||
// Basic attributes
|
||||
pub actionname: Option<String>,
|
||||
pub desc: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub skill: Option<String>,
|
||||
pub tool: Option<String>,
|
||||
pub actionname: String,
|
||||
pub desc: String,
|
||||
pub comment: String,
|
||||
pub level: i32,
|
||||
pub skill: SkillType,
|
||||
pub tool: Tool,
|
||||
|
||||
// Health (can be range like "3-5" or single value)
|
||||
pub health: Option<String>,
|
||||
// Health
|
||||
pub min_health: i32,
|
||||
pub max_health: i32,
|
||||
|
||||
// Timing
|
||||
pub harvesttime: Option<i32>,
|
||||
pub hittime: Option<i32>,
|
||||
pub respawntime: Option<i32>,
|
||||
pub harvesttime: i32,
|
||||
pub hittime: i32,
|
||||
pub respawntime: i32,
|
||||
|
||||
// Audio
|
||||
pub harvestsfx: Option<String>,
|
||||
pub endsfx: Option<String>,
|
||||
pub receiveitemsfx: Option<String>,
|
||||
pub harvestsfx: String,
|
||||
pub endsfx: String,
|
||||
pub receiveitemsfx: String,
|
||||
|
||||
// Visuals
|
||||
pub animation: Option<String>,
|
||||
pub takehitanimation: Option<String>,
|
||||
pub endgfx: Option<String>,
|
||||
pub animation: String,
|
||||
pub takehitanimation: String,
|
||||
pub endgfx: String,
|
||||
|
||||
// Behavior flags
|
||||
pub tree: Option<i32>,
|
||||
pub hidemilestone: Option<i32>,
|
||||
pub nohighlight: Option<i32>,
|
||||
pub hideminimap: Option<i32>,
|
||||
pub noleftclickinteract: Option<i32>,
|
||||
pub tree: bool,
|
||||
pub hidemilestone: bool,
|
||||
pub nohighlight: bool,
|
||||
pub hideminimap: bool,
|
||||
pub noleftclickinteract: bool,
|
||||
|
||||
// Interaction
|
||||
pub interactdistance: Option<String>,
|
||||
pub interactdistance: String,
|
||||
|
||||
// Drops
|
||||
pub drops: Vec<HarvestableDrop>,
|
||||
@@ -49,14 +51,14 @@ pub struct Harvestable {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HarvestableDrop {
|
||||
pub id: i32,
|
||||
pub minamount: Option<i32>,
|
||||
pub maxamount: Option<i32>,
|
||||
pub droprate: Option<i32>,
|
||||
pub droprateboost: Option<i32>,
|
||||
pub amountboost: Option<i32>,
|
||||
pub checks: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
pub dontconsumehealth: Option<i32>,
|
||||
pub minamount: i32,
|
||||
pub maxamount: i32,
|
||||
pub droprate: i32,
|
||||
pub droprateboost: i32,
|
||||
pub amountboost: i32,
|
||||
pub checks: String,
|
||||
pub comment: String,
|
||||
pub dontconsumehealth: bool,
|
||||
}
|
||||
|
||||
impl Harvestable {
|
||||
@@ -64,45 +66,46 @@ impl Harvestable {
|
||||
Self {
|
||||
typeid,
|
||||
name,
|
||||
actionname: None,
|
||||
desc: None,
|
||||
comment: None,
|
||||
level: None,
|
||||
skill: None,
|
||||
tool: None,
|
||||
health: None,
|
||||
harvesttime: None,
|
||||
hittime: None,
|
||||
respawntime: None,
|
||||
harvestsfx: None,
|
||||
endsfx: None,
|
||||
receiveitemsfx: None,
|
||||
animation: None,
|
||||
takehitanimation: None,
|
||||
endgfx: None,
|
||||
tree: None,
|
||||
hidemilestone: None,
|
||||
nohighlight: None,
|
||||
hideminimap: None,
|
||||
noleftclickinteract: None,
|
||||
interactdistance: None,
|
||||
actionname: String::new(),
|
||||
desc: String::new(),
|
||||
comment: String::new(),
|
||||
level: 0,
|
||||
skill: SkillType::None,
|
||||
tool: Tool::None,
|
||||
min_health: 0,
|
||||
max_health: 0,
|
||||
harvesttime: 0,
|
||||
hittime: 0,
|
||||
respawntime: 0,
|
||||
harvestsfx: String::new(),
|
||||
endsfx: String::new(),
|
||||
receiveitemsfx: String::new(),
|
||||
animation: String::new(),
|
||||
takehitanimation: String::new(),
|
||||
endgfx: String::new(),
|
||||
tree: false,
|
||||
hidemilestone: false,
|
||||
nohighlight: false,
|
||||
hideminimap: false,
|
||||
noleftclickinteract: false,
|
||||
interactdistance: String::new(),
|
||||
drops: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a tree
|
||||
pub fn is_tree(&self) -> bool {
|
||||
self.tree == Some(1)
|
||||
self.tree
|
||||
}
|
||||
|
||||
/// Check if this requires a tool
|
||||
pub fn requires_tool(&self) -> bool {
|
||||
self.tool.is_some()
|
||||
!matches!(self.tool, Tool::None)
|
||||
}
|
||||
|
||||
/// Get the skill associated with this harvestable
|
||||
pub fn get_skill(&self) -> Option<&str> {
|
||||
self.skill.as_deref()
|
||||
pub fn get_skill(&self) -> SkillType {
|
||||
self.skill
|
||||
}
|
||||
|
||||
/// Get all item IDs that can drop from this harvestable
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::types::{
|
||||
PlayerHouse,
|
||||
Trait, TraitTrainer,
|
||||
Shop, ShopItem,
|
||||
SkillType, Tool,
|
||||
};
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
@@ -180,6 +181,20 @@ fn parse_stat(attrs: &HashMap<String, String>) -> ItemStat {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse health range string like "3-5" or "3" into (min, max)
|
||||
fn parse_health_range(health_str: &str) -> (i32, i32) {
|
||||
if let Some(dash_pos) = health_str.find('-') {
|
||||
let min_str = &health_str[..dash_pos];
|
||||
let max_str = &health_str[dash_pos + 1..];
|
||||
let min = min_str.trim().parse().unwrap_or(0);
|
||||
let max = max_str.trim().parse().unwrap_or(0);
|
||||
(min, max)
|
||||
} else {
|
||||
let val = health_str.trim().parse().unwrap_or(0);
|
||||
(val, val)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NPC Parser
|
||||
// ============================================================================
|
||||
@@ -549,45 +564,49 @@ pub fn parse_harvestables_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Harvestable
|
||||
|
||||
let mut harvestable = Harvestable::new(typeid, name);
|
||||
|
||||
// Parse optional attributes
|
||||
if let Some(v) = attrs.get("actionname") { harvestable.actionname = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("desc") { harvestable.desc = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("comment") { harvestable.comment = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("level") { harvestable.level = v.parse().ok(); }
|
||||
if let Some(v) = attrs.get("skill") { harvestable.skill = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("tool") { harvestable.tool = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("health") { harvestable.health = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("harvesttime") { harvestable.harvesttime = v.parse().ok(); }
|
||||
if let Some(v) = attrs.get("hittime") { harvestable.hittime = v.parse().ok(); }
|
||||
if let Some(v) = attrs.get("respawntime") { harvestable.respawntime = v.parse().ok(); }
|
||||
// Parse optional attributes with defaults
|
||||
if let Some(v) = attrs.get("actionname") { harvestable.actionname = v.clone(); }
|
||||
if let Some(v) = attrs.get("desc") { harvestable.desc = v.clone(); }
|
||||
if let Some(v) = attrs.get("comment") { harvestable.comment = v.clone(); }
|
||||
if let Some(v) = attrs.get("level") { harvestable.level = v.parse().unwrap_or(0); }
|
||||
if let Some(v) = attrs.get("skill") { harvestable.skill = v.parse().unwrap_or(SkillType::None); }
|
||||
if let Some(v) = attrs.get("tool") { harvestable.tool = v.parse().unwrap_or(Tool::None); }
|
||||
if let Some(v) = attrs.get("health") {
|
||||
let (min, max) = parse_health_range(v);
|
||||
harvestable.min_health = min;
|
||||
harvestable.max_health = max;
|
||||
}
|
||||
if let Some(v) = attrs.get("harvesttime") { harvestable.harvesttime = v.parse().unwrap_or(0); }
|
||||
if let Some(v) = attrs.get("hittime") { harvestable.hittime = v.parse().unwrap_or(0); }
|
||||
if let Some(v) = attrs.get("respawntime") { harvestable.respawntime = v.parse().unwrap_or(0); }
|
||||
|
||||
// Audio (handle both cases: harvestSfx and harvestsfx)
|
||||
if let Some(v) = attrs.get("harvestSfx").or_else(|| attrs.get("harvestsfx")) {
|
||||
harvestable.harvestsfx = Some(v.clone());
|
||||
harvestable.harvestsfx = v.clone();
|
||||
}
|
||||
if let Some(v) = attrs.get("endSfx").or_else(|| attrs.get("endsfx")) {
|
||||
harvestable.endsfx = Some(v.clone());
|
||||
harvestable.endsfx = v.clone();
|
||||
}
|
||||
if let Some(v) = attrs.get("receiveItemSfx").or_else(|| attrs.get("receiveitemsfx")) {
|
||||
harvestable.receiveitemsfx = Some(v.clone());
|
||||
harvestable.receiveitemsfx = v.clone();
|
||||
}
|
||||
|
||||
if let Some(v) = attrs.get("animation") { harvestable.animation = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("takehitanimation") { harvestable.takehitanimation = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("endgfx") { harvestable.endgfx = Some(v.clone()); }
|
||||
if let Some(v) = attrs.get("tree") { harvestable.tree = v.parse().ok(); }
|
||||
if let Some(v) = attrs.get("hidemilestone") { harvestable.hidemilestone = v.parse().ok(); }
|
||||
if let Some(v) = attrs.get("nohighlight") { harvestable.nohighlight = v.parse().ok(); }
|
||||
if let Some(v) = attrs.get("animation") { harvestable.animation = v.clone(); }
|
||||
if let Some(v) = attrs.get("takehitanimation") { harvestable.takehitanimation = v.clone(); }
|
||||
if let Some(v) = attrs.get("endgfx") { harvestable.endgfx = v.clone(); }
|
||||
if let Some(v) = attrs.get("tree") { harvestable.tree = v.parse().unwrap_or(0) == 1; }
|
||||
if let Some(v) = attrs.get("hidemilestone") { harvestable.hidemilestone = v.parse().unwrap_or(0) == 1; }
|
||||
if let Some(v) = attrs.get("nohighlight") { harvestable.nohighlight = v.parse().unwrap_or(0) == 1; }
|
||||
|
||||
// Handle both cases: hideMinimap and hideminimap
|
||||
if let Some(v) = attrs.get("hideMinimap").or_else(|| attrs.get("hideminimap")) {
|
||||
harvestable.hideminimap = v.parse().ok();
|
||||
harvestable.hideminimap = v.parse().unwrap_or(0) == 1;
|
||||
}
|
||||
if let Some(v) = attrs.get("noLeftClickInteract").or_else(|| attrs.get("noleftclickinteract")) {
|
||||
harvestable.noleftclickinteract = v.parse().ok();
|
||||
harvestable.noleftclickinteract = v.parse().unwrap_or(0) == 1;
|
||||
}
|
||||
if let Some(v) = attrs.get("interactDistance").or_else(|| attrs.get("interactdistance")) {
|
||||
harvestable.interactdistance = Some(v.clone());
|
||||
harvestable.interactdistance = v.clone();
|
||||
}
|
||||
|
||||
current_harvestable = Some(harvestable);
|
||||
@@ -599,14 +618,14 @@ pub fn parse_harvestables_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Harvestable
|
||||
if let Ok(id) = id_str.parse::<i32>() {
|
||||
let drop = HarvestableDrop {
|
||||
id,
|
||||
minamount: attrs.get("minamount").and_then(|v| v.parse().ok()),
|
||||
maxamount: attrs.get("maxamount").and_then(|v| v.parse().ok()),
|
||||
droprate: attrs.get("droprate").and_then(|v| v.parse().ok()),
|
||||
droprateboost: attrs.get("droprateboost").and_then(|v| v.parse().ok()),
|
||||
amountboost: attrs.get("amountboost").and_then(|v| v.parse().ok()),
|
||||
checks: attrs.get("checks").cloned(),
|
||||
comment: attrs.get("comment").cloned(),
|
||||
dontconsumehealth: attrs.get("dontconsumehealth").and_then(|v| v.parse().ok()),
|
||||
minamount: attrs.get("minamount").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
maxamount: attrs.get("maxamount").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
droprate: attrs.get("droprate").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
droprateboost: attrs.get("droprateboost").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
amountboost: attrs.get("amountboost").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
checks: attrs.get("checks").cloned().unwrap_or_default(),
|
||||
comment: attrs.get("comment").cloned().unwrap_or_default(),
|
||||
dontconsumehealth: attrs.get("dontconsumehealth").and_then(|v| v.parse().ok()).unwrap_or(0) == 1,
|
||||
};
|
||||
harvestable.drops.push(drop);
|
||||
}
|
||||
|
||||
BIN
cursebreaker.db
BIN
cursebreaker.db
Binary file not shown.
Reference in New Issue
Block a user