From 06dcfb7a9c35614cde0dfd0aad4a88d030cc56bd Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 7 Jan 2026 10:40:29 +0000 Subject: [PATCH] loot --- cursebreaker-parser/XML_PARSING.md | 77 +++++++- .../examples/game_data_demo.rs | 74 +++++++- cursebreaker-parser/src/lib.rs | 3 + cursebreaker-parser/src/loot_database.rs | 168 ++++++++++++++++++ cursebreaker-parser/src/main.rs | 11 +- cursebreaker-parser/src/types/loot.rs | 76 ++++++++ cursebreaker-parser/src/types/mod.rs | 2 + cursebreaker-parser/src/xml_parser.rs | 84 +++++++++ 8 files changed, 490 insertions(+), 5 deletions(-) create mode 100644 cursebreaker-parser/src/loot_database.rs create mode 100644 cursebreaker-parser/src/types/loot.rs diff --git a/cursebreaker-parser/XML_PARSING.md b/cursebreaker-parser/XML_PARSING.md index 666e4bb..8eba99a 100644 --- a/cursebreaker-parser/XML_PARSING.md +++ b/cursebreaker-parser/XML_PARSING.md @@ -227,6 +227,46 @@ 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 @@ -257,6 +297,30 @@ for harvestable in harvestable_db.all_harvestables() { } } } + +// 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 @@ -299,6 +363,13 @@ When loaded from `/home/connor/repos/CBAssets/Data/XMLs/`: - **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 ``` @@ -312,12 +383,14 @@ cursebreaker-parser/ │ │ ├── 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 +│ ├── 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 @@ -339,12 +412,12 @@ thiserror = "1.0" # Error handling - ✅ 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: -- [ ] Loot tables (`/XMLs/Loot/*.xml`) - [ ] Maps (`/XMLs/Maps/*.xml`) - [ ] Dialogue (`/XMLs/Dialogue/*.xml`) - [ ] Events (`/XMLs/Events/*.xml`) diff --git a/cursebreaker-parser/examples/game_data_demo.rs b/cursebreaker-parser/examples/game_data_demo.rs index 5bae60e..8b2d24f 100644 --- a/cursebreaker-parser/examples/game_data_demo.rs +++ b/cursebreaker-parser/examples/game_data_demo.rs @@ -2,7 +2,7 @@ //! //! Run with: cargo run --example game_data_demo -use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase}; +use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase, LootDatabase}; fn main() -> Result<(), Box> { println!("🎮 Cursebreaker Game Data Demo\n"); @@ -13,11 +13,13 @@ fn main() -> Result<(), Box> { let npc_db = NpcDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Npcs/NPCInfo.xml")?; let quest_db = QuestDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Quests/Quests.xml")?; let harvestable_db = HarvestableDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Harvestables/HarvestableInfo.xml")?; + let loot_db = LootDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Loot/Loot.xml")?; println!("✅ Loaded {} items", item_db.len()); println!("✅ Loaded {} NPCs", npc_db.len()); println!("✅ Loaded {} quests", quest_db.len()); - println!("✅ Loaded {} harvestables\n", harvestable_db.len()); + println!("✅ Loaded {} harvestables", harvestable_db.len()); + println!("✅ Loaded {} loot tables\n", loot_db.len()); // ======================================================================= // Items @@ -191,6 +193,74 @@ fn main() -> Result<(), Box> { println!(" • Level 51-100: {}", high_level.len()); println!(); + // ======================================================================= + // Loot Tables + // ======================================================================= + println!("=== Loot Tables ==="); + let all_tables = loot_db.all_tables(); + let conditional_tables = loot_db.get_conditional_tables(); + let guaranteed_tables = loot_db.get_tables_with_guaranteed_drops(); + + println!("Statistics:"); + println!(" • Total loot tables: {}", loot_db.len()); + println!(" • NPCs with loot: {}", loot_db.get_all_npcs_with_loot().len()); + println!(" • Droppable items: {}", loot_db.get_all_droppable_items().len()); + println!(" • Tables with conditional drops: {}", conditional_tables.len()); + println!(" • Tables with guaranteed drops: {}", guaranteed_tables.len()); + + // Sample loot table + if let Some(table) = all_tables.first() { + println!("\nSample loot table:"); + if let Some(name) = &table.name { + println!(" Name: {}", name); + } + println!(" NPCs: {:?}", table.npc_ids); + println!(" Drops: {} items", table.drops.len()); + + // Show first few drops + println!(" Sample drops:"); + for drop in table.drops.iter().take(3) { + if let Some(item) = item_db.get_by_id(drop.item) { + let rate_str = drop.rate.map(|r| r.to_string()).unwrap_or_else(|| "N/A".to_string()); + let amount_str = if let (Some(min), Some(max)) = (drop.minamount, drop.maxamount) { + format!("{}x{}", min, max) + } else { + "1x1".to_string() + }; + println!(" - {} ({}, rate: {})", item.name, amount_str, rate_str); + } + } + } + + // Cross-reference: Find what an NPC drops + println!("\nSample NPC drops:"); + if let Some(npc) = npc_db.get_hostile().first() { + println!(" NPC: {} (ID: {})", npc.name, npc.id); + let drops = loot_db.get_drops_for_npc(npc.id); + if !drops.is_empty() { + println!(" Drops {} different items:", drops.len()); + for drop in drops.iter().take(5) { + if let Some(item) = item_db.get_by_id(drop.item) { + println!(" - {}", item.name); + } + } + } else { + println!(" No drops configured"); + } + } + + // Cross-reference: Find what NPCs drop an item + if let Some(item) = item_db.get_by_id(180) { + println!("\nItem '{}' drops from:", item.name); + let npcs = loot_db.get_npcs_dropping_item(180); + for npc_id in npcs.iter().take(5) { + if let Some(npc) = npc_db.get_by_id(*npc_id) { + println!(" • {}", npc.name); + } + } + } + println!(); + // ======================================================================= // Cross-referencing data // ======================================================================= diff --git a/cursebreaker-parser/src/lib.rs b/cursebreaker-parser/src/lib.rs index 16b5a8b..8ef8b64 100644 --- a/cursebreaker-parser/src/lib.rs +++ b/cursebreaker-parser/src/lib.rs @@ -55,15 +55,18 @@ mod item_database; mod npc_database; mod quest_database; mod harvestable_database; +mod loot_database; pub use item_database::ItemDatabase; pub use npc_database::NpcDatabase; pub use quest_database::QuestDatabase; pub use harvestable_database::HarvestableDatabase; +pub use loot_database::LootDatabase; pub use types::{ Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule, InteractableResource, Npc, NpcStat, NpcLevel, RightClick, BarkGroup, Bark, QuestMarker, NpcAnimationSet, Quest, QuestPhase, QuestReward, Harvestable, HarvestableDrop, + LootTable, LootDrop, }; pub use xml_parser::XmlParseError; diff --git a/cursebreaker-parser/src/loot_database.rs b/cursebreaker-parser/src/loot_database.rs new file mode 100644 index 0000000..8d37109 --- /dev/null +++ b/cursebreaker-parser/src/loot_database.rs @@ -0,0 +1,168 @@ +use crate::types::{LootTable, LootDrop}; +use crate::xml_parser::{parse_loot_xml, XmlParseError}; +use std::collections::HashMap; +use std::path::Path; + +/// A database for managing Loot Tables loaded from XML files +#[derive(Debug, Clone)] +pub struct LootDatabase { + tables: Vec, + // Map NPC ID -> list of table indices that apply to this NPC + tables_by_npc: HashMap>, + // Map item ID -> list of table indices that drop this item + tables_by_item: HashMap>, +} + +impl LootDatabase { + /// Create a new empty LootDatabase + pub fn new() -> Self { + Self { + tables: Vec::new(), + tables_by_npc: HashMap::new(), + tables_by_item: HashMap::new(), + } + } + + /// Load loot tables from an XML file + pub fn load_from_xml>(path: P) -> Result { + let tables = parse_loot_xml(path)?; + let mut db = Self::new(); + db.add_tables(tables); + Ok(db) + } + + /// Add loot tables to the database + pub fn add_tables(&mut self, tables: Vec) { + for table in tables { + let index = self.tables.len(); + + // Index by NPC IDs + for &npc_id in &table.npc_ids { + self.tables_by_npc + .entry(npc_id) + .or_insert_with(Vec::new) + .push(index); + } + + // Index by item IDs + for drop in &table.drops { + self.tables_by_item + .entry(drop.item) + .or_insert_with(Vec::new) + .push(index); + } + + self.tables.push(table); + } + } + + /// Get all loot tables that apply to a specific NPC ID + pub fn get_tables_for_npc(&self, npc_id: i32) -> Vec<&LootTable> { + self.tables_by_npc + .get(&npc_id) + .map(|indices| { + indices + .iter() + .filter_map(|&idx| self.tables.get(idx)) + .collect() + }) + .unwrap_or_default() + } + + /// Get all loot tables that drop a specific item + pub fn get_tables_with_item(&self, item_id: i32) -> Vec<&LootTable> { + self.tables_by_item + .get(&item_id) + .map(|indices| { + indices + .iter() + .filter_map(|&idx| self.tables.get(idx)) + .collect() + }) + .unwrap_or_default() + } + + /// Get all possible drops for a specific NPC + pub fn get_drops_for_npc(&self, npc_id: i32) -> Vec<&LootDrop> { + let mut drops = Vec::new(); + for table in self.get_tables_for_npc(npc_id) { + drops.extend(&table.drops); + } + drops + } + + /// Get all NPCs that can drop a specific item + pub fn get_npcs_dropping_item(&self, item_id: i32) -> Vec { + let mut npcs = std::collections::HashSet::new(); + + if let Some(table_indices) = self.tables_by_item.get(&item_id) { + for &idx in table_indices { + if let Some(table) = self.tables.get(idx) { + npcs.extend(&table.npc_ids); + } + } + } + + npcs.into_iter().collect() + } + + /// Get all loot tables + pub fn all_tables(&self) -> &[LootTable] { + &self.tables + } + + /// Get tables with conditional drops (that have checks) + pub fn get_conditional_tables(&self) -> Vec<&LootTable> { + self.tables + .iter() + .filter(|t| !t.get_conditional_drops().is_empty()) + .collect() + } + + /// Get tables with guaranteed drops (rate = 1) + pub fn get_tables_with_guaranteed_drops(&self) -> Vec<&LootTable> { + self.tables + .iter() + .filter(|t| !t.get_guaranteed_drops().is_empty()) + .collect() + } + + /// Get all unique item IDs that can drop + pub fn get_all_droppable_items(&self) -> Vec { + self.tables_by_item.keys().copied().collect() + } + + /// Get all unique NPC IDs that have loot tables + pub fn get_all_npcs_with_loot(&self) -> Vec { + self.tables_by_npc.keys().copied().collect() + } + + /// Get number of loot tables in database + pub fn len(&self) -> usize { + self.tables.len() + } + + /// Check if database is empty + pub fn is_empty(&self) -> bool { + self.tables.is_empty() + } + + /// Prepare loot tables for SQL insertion + /// Returns a vector of tuples (npc_ids_json, name, json_data) + pub fn prepare_for_sql(&self) -> Vec<(String, Option, String)> { + self.tables + .iter() + .map(|table| { + let npc_ids_json = serde_json::to_string(&table.npc_ids).unwrap_or_else(|_| "[]".to_string()); + let json = serde_json::to_string(table).unwrap_or_else(|_| "{}".to_string()); + (npc_ids_json, table.name.clone(), json) + }) + .collect() + } +} + +impl Default for LootDatabase { + fn default() -> Self { + Self::new() + } +} diff --git a/cursebreaker-parser/src/main.rs b/cursebreaker-parser/src/main.rs index d8f19cb..26b7934 100644 --- a/cursebreaker-parser/src/main.rs +++ b/cursebreaker-parser/src/main.rs @@ -6,7 +6,7 @@ //! 3. Extracting typeId and transform positions //! 4. Writing resource data to an output file -use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase, InteractableResource}; +use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase, LootDatabase, InteractableResource}; use unity_parser::UnityProject; use std::path::Path; use unity_parser::log::DedupLogger; @@ -41,6 +41,10 @@ fn main() -> Result<(), Box> { let harvestable_db = HarvestableDatabase::load_from_xml(harvestables_path)?; info!("✅ Loaded {} harvestables", harvestable_db.len()); + let loot_path = "/home/connor/repos/CBAssets/Data/XMLs/Loot/Loot.xml"; + let loot_db = LootDatabase::load_from_xml(loot_path)?; + info!("✅ Loaded {} loot tables", loot_db.len()); + // Print statistics info!("\n📊 Game Data Statistics:"); info!(" Items:"); @@ -58,6 +62,11 @@ fn main() -> Result<(), Box> { info!(" • Mining: {}", harvestable_db.get_by_skill("mining").len()); info!(" • Fishing: {}", harvestable_db.get_by_skill("Fishing").len()); info!(" • Alchemy: {}", harvestable_db.get_by_skill("Alchemy").len()); + info!(" Loot:"); + info!(" • Total tables: {}", loot_db.len()); + info!(" • NPCs with loot: {}", loot_db.get_all_npcs_with_loot().len()); + info!(" • Droppable items: {}", loot_db.get_all_droppable_items().len()); + info!(" • Tables with conditional drops: {}", loot_db.get_conditional_tables().len()); // Initialize Unity project once - scans entire project for GUID mappings let project_root = Path::new("/home/connor/repos/CBAssets"); diff --git a/cursebreaker-parser/src/types/loot.rs b/cursebreaker-parser/src/types/loot.rs new file mode 100644 index 0000000..c57be9f --- /dev/null +++ b/cursebreaker-parser/src/types/loot.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LootTable { + // NPC IDs this table applies to (can be multiple, comma-separated in XML) + pub npc_ids: Vec, + + // Optional name/description of the loot table + pub name: Option, + + // List of possible drops + pub drops: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LootDrop { + // Item ID that can drop + pub item: i32, + + // Drop rate (higher = rarer, e.g., rate=1 means common, rate=100 means very rare) + pub rate: Option, + + // Amount range + pub minamount: Option, + pub maxamount: Option, + + // Optional requirements/checks + pub checks: Option, + + // Optional comment/description + pub comment: Option, +} + +impl LootTable { + pub fn new(npc_ids: Vec) -> Self { + Self { + npc_ids, + name: None, + drops: Vec::new(), + } + } + + /// Check if this loot table applies to a given NPC ID + pub fn applies_to_npc(&self, npc_id: i32) -> bool { + self.npc_ids.contains(&npc_id) + } + + /// Get all item IDs that can drop from this table + pub fn get_drop_item_ids(&self) -> Vec { + self.drops.iter().map(|d| d.item).collect() + } + + /// Get drops that have conditional checks + pub fn get_conditional_drops(&self) -> Vec<&LootDrop> { + self.drops.iter().filter(|d| d.checks.is_some()).collect() + } + + /// Get guaranteed drops (rate = 1) + pub fn get_guaranteed_drops(&self) -> Vec<&LootDrop> { + self.drops.iter().filter(|d| d.rate == Some(1)).collect() + } +} + +impl LootDrop { + /// Check if this drop has requirements + pub fn has_requirements(&self) -> bool { + self.checks.is_some() + } + + /// Get the average drop amount + pub fn average_amount(&self) -> f32 { + let min = self.minamount.unwrap_or(1) as f32; + let max = self.maxamount.unwrap_or(1) as f32; + (min + max) / 2.0 + } +} diff --git a/cursebreaker-parser/src/types/mod.rs b/cursebreaker-parser/src/types/mod.rs index f09abf1..830e80b 100644 --- a/cursebreaker-parser/src/types/mod.rs +++ b/cursebreaker-parser/src/types/mod.rs @@ -3,9 +3,11 @@ mod item; mod npc; mod quest; mod harvestable; +mod loot; pub use interactable_resource::InteractableResource; pub use item::{Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule}; pub use npc::{Npc, NpcStat, NpcLevel, RightClick, BarkGroup, Bark, QuestMarker, NpcAnimationSet}; pub use quest::{Quest, QuestPhase, QuestReward}; pub use harvestable::{Harvestable, HarvestableDrop}; +pub use loot::{LootTable, LootDrop}; diff --git a/cursebreaker-parser/src/xml_parser.rs b/cursebreaker-parser/src/xml_parser.rs index 112ac79..c13c18f 100644 --- a/cursebreaker-parser/src/xml_parser.rs +++ b/cursebreaker-parser/src/xml_parser.rs @@ -3,6 +3,7 @@ use crate::types::{ Npc, NpcStat, NpcLevel, RightClick, BarkGroup, QuestMarker, NpcAnimationSet, Quest, QuestPhase, QuestReward, Harvestable, HarvestableDrop, + LootTable, LootDrop, }; use quick_xml::events::Event; use quick_xml::reader::Reader; @@ -649,3 +650,86 @@ pub fn parse_harvestables_xml>(path: P) -> Result>(path: P) -> Result, XmlParseError> { + let file = File::open(path)?; + let buf_reader = BufReader::new(file); + let mut reader = Reader::from_reader(buf_reader); + reader.config_mut().trim_text(true); + + let mut loot_tables = Vec::new(); + let mut buf = Vec::new(); + let mut current_table: Option = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + match e.name().as_ref() { + b"table" => { + let attrs = parse_attributes(e)?; + + // Parse npcid - can be comma-separated like "45,459" + let npc_ids = if let Some(npcid_str) = attrs.get("npcid") { + npcid_str + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect::>() + } else { + Vec::new() + }; + + let mut table = LootTable::new(npc_ids); + + // Parse optional name + if let Some(v) = attrs.get("name") { + table.name = Some(v.clone()); + } + + current_table = Some(table); + } + b"drop" if current_table.is_some() => { + if let Some(ref mut table) = current_table { + let attrs = parse_attributes(e)?; + + // Parse item ID (required for a drop) + if let Some(item_str) = attrs.get("item") { + if let Ok(item) = item_str.parse::() { + let drop = LootDrop { + item, + rate: attrs.get("rate").and_then(|v| v.parse().ok()), + minamount: attrs.get("minamount").and_then(|v| v.parse().ok()), + maxamount: attrs.get("maxamount").and_then(|v| v.parse().ok()), + checks: attrs.get("checks").cloned(), + comment: attrs.get("comment").cloned(), + }; + table.drops.push(drop); + } + } + } + } + _ => {} + } + } + Ok(Event::End(ref e)) => { + match e.name().as_ref() { + b"table" => { + if let Some(table) = current_table.take() { + loot_tables.push(table); + } + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(XmlParseError::XmlError(e)), + _ => {} + } + buf.clear(); + } + + Ok(loot_tables) +}