resource icons DB

This commit is contained in:
2026-01-12 07:19:38 +00:00
parent 1072186ff1
commit 3720b6ad80
17 changed files with 545 additions and 2405 deletions

View File

@@ -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!

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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! 🎮

View File

@@ -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`

View File

@@ -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 2. **scene-parser** - Parses Unity scenes and extracts world resource locations
- Slow execution (Unity project initialization) - Slow execution (Unity project initialization)
- Extracts InteractableResource components and their positions - 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 - Run this when scene files change
```bash ```bash
cargo run --bin scene-parser 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 cargo run --bin verify-stats
``` ```
9. **verify-resource-icons** - Verifies resource icons for harvestables
```bash
cargo run --bin verify-resource-icons
```
### Building for Production ### Building for Production
Build specific binaries for release: Build specific binaries for release:
@@ -185,6 +195,39 @@ for resource in copper_ore {
See `examples/query_world_resources.rs` for a complete example. 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 ### Additional Databases
Similar APIs are available for other game data types: 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) - **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 - **item_database_demo.rs** - Focused on item database operations
- **query_world_resources.rs** - Querying world resource locations from the database - **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 - **fast_travel_example.rs** - Working with fast travel locations
- **maps_example.rs** - Map data handling - **maps_example.rs** - Map data handling
- **player_houses_example.rs** - Player house management - **player_houses_example.rs** - Player house management
@@ -252,7 +296,8 @@ cursebreaker-parser/
│ │ ├── verify-db.rs # Database verification │ │ ├── verify-db.rs # Database verification
│ │ ├── verify-expanded-db.rs # Expanded database verification │ │ ├── verify-expanded-db.rs # Expanded database verification
│ │ ├── verify-images.rs # Image 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 │ ├── xml_parser.rs # XML parsing utilities
│ ├── image_processor.rs # Image processing utilities │ ├── image_processor.rs # Image processing utilities
│ ├── item_loader.rs # Item loading logic │ ├── 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 - Quest definitions and phases
- Harvestable resources and drop tables - Harvestable resources and drop tables
- World resource locations from Unity scenes - World resource locations from Unity scenes
- Resource icons for harvestables (64x64 WebP with white borders)
- Minimap tiles and metadata - Minimap tiles and metadata
- Shop inventories and pricing - Shop inventories and pricing
- Player houses and locations - Player houses and locations

View File

@@ -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.

View 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(())
}

View File

@@ -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
);

View File

@@ -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);

View File

@@ -122,19 +122,19 @@ fn process_item_icons(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene, scene: &unity_parser::UnityScene,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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 // Collect unique harvestable IDs from resources
let mut unique_items: HashMap<i32, String> = HashMap::new(); let mut unique_harvestables: HashMap<i32, String> = HashMap::new();
scene.world scene.world
.query_all::<(&InteractableResource, &unity_parser::GameObject)>() .query_all::<(&InteractableResource, &unity_parser::GameObject)>()
.for_each(|(resource, object)| { .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()); .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) // Clear existing resource icons (regenerated each run)
diesel::delete(resource_icons::table).execute(conn)?; diesel::delete(resource_icons::table).execute(conn)?;
@@ -146,22 +146,46 @@ fn process_item_icons(
let mut processed_count = 0; let mut processed_count = 0;
let mut failed_count = 0; let mut failed_count = 0;
// Process each unique item // Process each unique harvestable
for (item_id, default_name) in unique_items.iter() { for (harvestable_id, default_name) in unique_harvestables.iter() {
// Try to get the actual item name from the items table // Get the harvestable name
let item_name: String = items::table let harvestable_name: String = harvestables::table
.filter(items::id.eq(item_id)) .filter(harvestables::id.eq(harvestable_id))
.select(items::name) .select(harvestables::name)
.first(conn) .first(conn)
.unwrap_or_else(|_| default_name.clone()); .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) let icon_path = PathBuf::from(cb_assets_path)
.join("Data/Textures/ItemIcons") .join("Data/Textures/ItemIcons")
.join(format!("{}.png", item_id)); .join(format!("{}.png", item_id));
if !icon_path.exists() { 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; failed_count += 1;
continue; continue;
} }
@@ -170,38 +194,38 @@ fn process_item_icons(
match processor.process_image(&icon_path, &[64], None, Some(&outline_config)) { match processor.process_image(&icon_path, &[64], None, Some(&outline_config)) {
Ok(processed) => { Ok(processed) => {
if let Some(icon_data) = processed.get(64) { 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) match diesel::insert_into(resource_icons::table)
.values(( .values((
resource_icons::item_id.eq(item_id), resource_icons::item_id.eq(harvestable_id),
resource_icons::name.eq(&item_name), resource_icons::name.eq(&harvestable_name),
resource_icons::icon_64.eq(icon_data.as_slice()), resource_icons::icon_64.eq(icon_data.as_slice()),
)) ))
.execute(conn) .execute(conn)
{ {
Ok(_) => { Ok(_) => {
info!("Processed icon for item {} ({}): {} bytes", info!("Harvestable {} ({}) -> Item {} ({}): {} bytes",
item_id, item_name, icon_data.len()); harvestable_id, harvestable_name, item_id, item_name, icon_data.len());
processed_count += 1; processed_count += 1;
} }
Err(e) => { Err(e) => {
warn!(" ⚠️ Failed to insert icon for item {} ({}): {}", warn!(" ⚠️ Failed to insert icon for harvestable {} ({}): {}",
item_id, item_name, e); harvestable_id, harvestable_name, e);
failed_count += 1; failed_count += 1;
} }
} }
} }
} }
Err(e) => { Err(e) => {
warn!(" ⚠️ Failed to process icon for item {} ({}): {}", warn!(" ⚠️ Failed to process icon for harvestable {} ({}) -> item {} ({}): {}",
item_id, item_name, e); harvestable_id, harvestable_name, item_id, item_name, e);
failed_count += 1; failed_count += 1;
} }
} }
} }
info!("✅ Processed {} item icons ({} succeeded, {} failed)", info!("✅ Processed {} harvestable icons ({} succeeded, {} failed)",
unique_items.len(), processed_count, failed_count); unique_harvestables.len(), processed_count, failed_count);
Ok(()) Ok(())
} }

View File

@@ -5,7 +5,7 @@
//! - Populating the SQLite database with the parsed data //! - Populating the SQLite database with the parsed data
//! - Generating statistics about the loaded data //! - Generating statistics about the loaded data
use cursebreaker_parser::ItemDatabase; use cursebreaker_parser::{ItemDatabase, HarvestableDatabase};
use log::{info, warn, LevelFilter}; use log::{info, warn, LevelFilter};
use unity_parser::log::DedupLogger; use unity_parser::log::DedupLogger;
use diesel::prelude::*; use diesel::prelude::*;

View File

@@ -62,27 +62,21 @@ impl HarvestableDatabase {
/// Get harvestables by skill /// Get harvestables by skill
pub fn get_by_skill(&self, skill: &str) -> Vec<&Harvestable> { 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 self.harvestables
.iter() .iter()
.filter(|h| { .filter(|h| h.skill == skill_type)
h.skill
.as_ref()
.map(|s| s.eq_ignore_ascii_case(skill))
.unwrap_or(false)
})
.collect() .collect()
} }
/// Get harvestables that require a specific tool /// Get harvestables that require a specific tool
pub fn get_by_tool(&self, tool: &str) -> Vec<&Harvestable> { 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 self.harvestables
.iter() .iter()
.filter(|h| { .filter(|h| h.tool == tool_type)
h.tool
.as_ref()
.map(|t| t.eq_ignore_ascii_case(tool))
.unwrap_or(false)
})
.collect() .collect()
} }
@@ -106,11 +100,7 @@ impl HarvestableDatabase {
pub fn get_by_level_range(&self, min_level: i32, max_level: i32) -> Vec<&Harvestable> { pub fn get_by_level_range(&self, min_level: i32, max_level: i32) -> Vec<&Harvestable> {
self.harvestables self.harvestables
.iter() .iter()
.filter(|h| { .filter(|h| h.level >= min_level && h.level <= max_level)
h.level
.map(|l| l >= min_level && l <= max_level)
.unwrap_or(false)
})
.collect() .collect()
} }
@@ -126,38 +116,136 @@ impl HarvestableDatabase {
/// Prepare harvestables for SQL insertion (deprecated - use save_to_db instead) /// Prepare harvestables for SQL insertion (deprecated - use save_to_db instead)
#[deprecated(note = "Use save_to_db() to save directly to SQLite database")] #[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 self.harvestables
.iter() .iter()
.map(|harvestable| { .map(|harvestable| {
let json = serde_json::to_string(harvestable).unwrap_or_else(|_| "{}".to_string()); let skill_str = match harvestable.skill {
(harvestable.typeid, harvestable.name.clone(), json) 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() .collect()
} }
/// Save all harvestables to SQLite database /// Save all harvestables to SQLite database
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> { 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 // Clear existing data
.harvestables diesel::delete(harvestable_drops::table).execute(conn)?;
.iter() diesel::delete(harvestables::table).execute(conn)?;
.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();
let mut count = 0; 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) 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)?; .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; count += 1;
} }
@@ -166,22 +254,91 @@ impl HarvestableDatabase {
/// Load all harvestables from SQLite database /// Load all harvestables from SQLite database
pub fn load_from_db(conn: &mut SqliteConnection) -> Result<Self, diesel::result::Error> { 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)] #[derive(Queryable)]
struct HarvestableRecord { struct HarvestableRecord {
id: Option<i32>, id: i32,
name: String, 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(); let mut loaded_harvestables = Vec::new();
for record in records { for record in harv_records {
if let Ok(harvestable) = serde_json::from_str::<Harvestable>(&record.data) { let mut harvestable = Harvestable {
loaded_harvestables.push(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(); let mut db = Self::new();

View File

@@ -31,10 +31,33 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
harvestables (id) { harvestable_drops (id) {
id -> Nullable<Integer>, 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, 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 -> crafting_recipes (recipe_id));
diesel::joinable!(crafting_recipe_items -> items (item_id)); diesel::joinable!(crafting_recipe_items -> items (item_id));
diesel::joinable!(crafting_recipes -> items (product_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::joinable!(item_stats -> items (item_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
crafting_recipe_items, crafting_recipe_items,
crafting_recipes, crafting_recipes,
fast_travel_locations, fast_travel_locations,
harvestable_drops,
harvestables, harvestables,
item_stats, item_stats,
items, items,

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::item::{SkillType, Tool};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Harvestable { pub struct Harvestable {
@@ -7,40 +8,41 @@ pub struct Harvestable {
pub name: String, pub name: String,
// Basic attributes // Basic attributes
pub actionname: Option<String>, pub actionname: String,
pub desc: Option<String>, pub desc: String,
pub comment: Option<String>, pub comment: String,
pub level: Option<i32>, pub level: i32,
pub skill: Option<String>, pub skill: SkillType,
pub tool: Option<String>, pub tool: Tool,
// Health (can be range like "3-5" or single value) // Health
pub health: Option<String>, pub min_health: i32,
pub max_health: i32,
// Timing // Timing
pub harvesttime: Option<i32>, pub harvesttime: i32,
pub hittime: Option<i32>, pub hittime: i32,
pub respawntime: Option<i32>, pub respawntime: i32,
// Audio // Audio
pub harvestsfx: Option<String>, pub harvestsfx: String,
pub endsfx: Option<String>, pub endsfx: String,
pub receiveitemsfx: Option<String>, pub receiveitemsfx: String,
// Visuals // Visuals
pub animation: Option<String>, pub animation: String,
pub takehitanimation: Option<String>, pub takehitanimation: String,
pub endgfx: Option<String>, pub endgfx: String,
// Behavior flags // Behavior flags
pub tree: Option<i32>, pub tree: bool,
pub hidemilestone: Option<i32>, pub hidemilestone: bool,
pub nohighlight: Option<i32>, pub nohighlight: bool,
pub hideminimap: Option<i32>, pub hideminimap: bool,
pub noleftclickinteract: Option<i32>, pub noleftclickinteract: bool,
// Interaction // Interaction
pub interactdistance: Option<String>, pub interactdistance: String,
// Drops // Drops
pub drops: Vec<HarvestableDrop>, pub drops: Vec<HarvestableDrop>,
@@ -49,14 +51,14 @@ pub struct Harvestable {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarvestableDrop { pub struct HarvestableDrop {
pub id: i32, pub id: i32,
pub minamount: Option<i32>, pub minamount: i32,
pub maxamount: Option<i32>, pub maxamount: i32,
pub droprate: Option<i32>, pub droprate: i32,
pub droprateboost: Option<i32>, pub droprateboost: i32,
pub amountboost: Option<i32>, pub amountboost: i32,
pub checks: Option<String>, pub checks: String,
pub comment: Option<String>, pub comment: String,
pub dontconsumehealth: Option<i32>, pub dontconsumehealth: bool,
} }
impl Harvestable { impl Harvestable {
@@ -64,45 +66,46 @@ impl Harvestable {
Self { Self {
typeid, typeid,
name, name,
actionname: None, actionname: String::new(),
desc: None, desc: String::new(),
comment: None, comment: String::new(),
level: None, level: 0,
skill: None, skill: SkillType::None,
tool: None, tool: Tool::None,
health: None, min_health: 0,
harvesttime: None, max_health: 0,
hittime: None, harvesttime: 0,
respawntime: None, hittime: 0,
harvestsfx: None, respawntime: 0,
endsfx: None, harvestsfx: String::new(),
receiveitemsfx: None, endsfx: String::new(),
animation: None, receiveitemsfx: String::new(),
takehitanimation: None, animation: String::new(),
endgfx: None, takehitanimation: String::new(),
tree: None, endgfx: String::new(),
hidemilestone: None, tree: false,
nohighlight: None, hidemilestone: false,
hideminimap: None, nohighlight: false,
noleftclickinteract: None, hideminimap: false,
interactdistance: None, noleftclickinteract: false,
interactdistance: String::new(),
drops: Vec::new(), drops: Vec::new(),
} }
} }
/// Check if this is a tree /// Check if this is a tree
pub fn is_tree(&self) -> bool { pub fn is_tree(&self) -> bool {
self.tree == Some(1) self.tree
} }
/// Check if this requires a tool /// Check if this requires a tool
pub fn requires_tool(&self) -> bool { pub fn requires_tool(&self) -> bool {
self.tool.is_some() !matches!(self.tool, Tool::None)
} }
/// Get the skill associated with this harvestable /// Get the skill associated with this harvestable
pub fn get_skill(&self) -> Option<&str> { pub fn get_skill(&self) -> SkillType {
self.skill.as_deref() self.skill
} }
/// Get all item IDs that can drop from this harvestable /// Get all item IDs that can drop from this harvestable

View File

@@ -9,6 +9,7 @@ use crate::types::{
PlayerHouse, PlayerHouse,
Trait, TraitTrainer, Trait, TraitTrainer,
Shop, ShopItem, Shop, ShopItem,
SkillType, Tool,
}; };
use quick_xml::events::Event; use quick_xml::events::Event;
use quick_xml::reader::Reader; 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 // 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); let mut harvestable = Harvestable::new(typeid, name);
// Parse optional attributes // Parse optional attributes with defaults
if let Some(v) = attrs.get("actionname") { harvestable.actionname = Some(v.clone()); } if let Some(v) = attrs.get("actionname") { harvestable.actionname = v.clone(); }
if let Some(v) = attrs.get("desc") { harvestable.desc = Some(v.clone()); } if let Some(v) = attrs.get("desc") { harvestable.desc = v.clone(); }
if let Some(v) = attrs.get("comment") { harvestable.comment = Some(v.clone()); } if let Some(v) = attrs.get("comment") { harvestable.comment = v.clone(); }
if let Some(v) = attrs.get("level") { harvestable.level = v.parse().ok(); } if let Some(v) = attrs.get("level") { harvestable.level = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("skill") { harvestable.skill = Some(v.clone()); } if let Some(v) = attrs.get("skill") { harvestable.skill = v.parse().unwrap_or(SkillType::None); }
if let Some(v) = attrs.get("tool") { harvestable.tool = Some(v.clone()); } if let Some(v) = attrs.get("tool") { harvestable.tool = v.parse().unwrap_or(Tool::None); }
if let Some(v) = attrs.get("health") { harvestable.health = Some(v.clone()); } if let Some(v) = attrs.get("health") {
if let Some(v) = attrs.get("harvesttime") { harvestable.harvesttime = v.parse().ok(); } let (min, max) = parse_health_range(v);
if let Some(v) = attrs.get("hittime") { harvestable.hittime = v.parse().ok(); } harvestable.min_health = min;
if let Some(v) = attrs.get("respawntime") { harvestable.respawntime = v.parse().ok(); } 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) // Audio (handle both cases: harvestSfx and harvestsfx)
if let Some(v) = attrs.get("harvestSfx").or_else(|| attrs.get("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")) { 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")) { 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("animation") { harvestable.animation = v.clone(); }
if let Some(v) = attrs.get("takehitanimation") { harvestable.takehitanimation = Some(v.clone()); } if let Some(v) = attrs.get("takehitanimation") { harvestable.takehitanimation = v.clone(); }
if let Some(v) = attrs.get("endgfx") { harvestable.endgfx = Some(v.clone()); } if let Some(v) = attrs.get("endgfx") { harvestable.endgfx = v.clone(); }
if let Some(v) = attrs.get("tree") { harvestable.tree = v.parse().ok(); } 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().ok(); } 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().ok(); } if let Some(v) = attrs.get("nohighlight") { harvestable.nohighlight = v.parse().unwrap_or(0) == 1; }
// Handle both cases: hideMinimap and hideminimap // Handle both cases: hideMinimap and hideminimap
if let Some(v) = attrs.get("hideMinimap").or_else(|| attrs.get("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")) { 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")) { 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); 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>() { if let Ok(id) = id_str.parse::<i32>() {
let drop = HarvestableDrop { let drop = HarvestableDrop {
id, id,
minamount: attrs.get("minamount").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()), maxamount: attrs.get("maxamount").and_then(|v| v.parse().ok()).unwrap_or(0),
droprate: attrs.get("droprate").and_then(|v| v.parse().ok()), droprate: attrs.get("droprate").and_then(|v| v.parse().ok()).unwrap_or(0),
droprateboost: attrs.get("droprateboost").and_then(|v| v.parse().ok()), droprateboost: attrs.get("droprateboost").and_then(|v| v.parse().ok()).unwrap_or(0),
amountboost: attrs.get("amountboost").and_then(|v| v.parse().ok()), amountboost: attrs.get("amountboost").and_then(|v| v.parse().ok()).unwrap_or(0),
checks: attrs.get("checks").cloned(), checks: attrs.get("checks").cloned().unwrap_or_default(),
comment: attrs.get("comment").cloned(), comment: attrs.get("comment").cloned().unwrap_or_default(),
dontconsumehealth: attrs.get("dontconsumehealth").and_then(|v| v.parse().ok()), dontconsumehealth: attrs.get("dontconsumehealth").and_then(|v| v.parse().ok()).unwrap_or(0) == 1,
}; };
harvestable.drops.push(drop); harvestable.drops.push(drop);
} }