From beecbd33c616985106173ffbf4279f011371bcf5 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 7 Jan 2026 09:56:17 +0000 Subject: [PATCH] npc + achievements --- cursebreaker-parser/XML_PARSING.md | 141 ++++++- .../examples/game_data_demo.rs | 165 +++++++++ cursebreaker-parser/src/lib.rs | 10 +- cursebreaker-parser/src/main.rs | 41 ++- cursebreaker-parser/src/npc_database.rs | 138 +++++++ cursebreaker-parser/src/quest_database.rs | 112 ++++++ cursebreaker-parser/src/types/mod.rs | 4 + cursebreaker-parser/src/types/npc.rs | 226 ++++++++++++ cursebreaker-parser/src/types/quest.rs | 77 ++++ cursebreaker-parser/src/xml_parser.rs | 344 +++++++++++++++++- 10 files changed, 1223 insertions(+), 35 deletions(-) create mode 100644 cursebreaker-parser/examples/game_data_demo.rs create mode 100644 cursebreaker-parser/src/npc_database.rs create mode 100644 cursebreaker-parser/src/quest_database.rs create mode 100644 cursebreaker-parser/src/types/npc.rs create mode 100644 cursebreaker-parser/src/types/quest.rs diff --git a/cursebreaker-parser/XML_PARSING.md b/cursebreaker-parser/XML_PARSING.md index 84da0e7..46981b9 100644 --- a/cursebreaker-parser/XML_PARSING.md +++ b/cursebreaker-parser/XML_PARSING.md @@ -8,11 +8,12 @@ The parser now supports loading game data from Cursebreaker's XML files and stor ## Features -- ✅ Parse Items.xml with full attribute and nested element support -- ✅ In-memory database with fast lookups by ID, name, category, slot, and skill +- ✅ Parse Items, NPCs, and Quests 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 +- ✅ Easy-to-use API with query methods +- ✅ Cross-referencing support between different data types ## Quick Start @@ -122,18 +123,103 @@ pub struct ItemStat { } ``` -## Example Program +## Example Programs -Run the demo to see all features in action: +Run the demos to see all features in action: ```bash +# Items only cargo run --example item_database_demo + +# All game data (Items, NPCs, Quests) +cargo run --example game_data_demo ``` -## Statistics from Items.xml +## Loading NPCs -When loaded from `/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml`: +```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(); +``` + +## 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()); + } +} +``` + +## Statistics from XML Files + +When loaded from `/home/connor/repos/CBAssets/Data/XMLs/`: + +### Items.xml - **Total Items**: 1,360 - **Weapons**: 166 - **Armor**: 148 @@ -142,21 +228,41 @@ When loaded from `/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml`: - **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 + ## File Structure ``` cursebreaker-parser/ ├── src/ │ ├── lib.rs # Library exports -│ ├── main.rs # Main binary (includes Unity + XML parsing) +│ ├── 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 │ │ └── interactable_resource.rs -│ ├── xml_parser.rs # XML parsing logic -│ └── item_database.rs # ItemDatabase for runtime access +│ ├── xml_parser.rs # XML parsing logic (Items, NPCs, Quests) +│ ├── item_database.rs # ItemDatabase for runtime access +│ ├── npc_database.rs # NpcDatabase for runtime access +│ └── quest_database.rs # QuestDatabase for runtime access └── examples/ - └── item_database_demo.rs # Full usage example + ├── item_database_demo.rs # Items usage example + └── game_data_demo.rs # Full game data example ``` ## Dependencies Added @@ -169,18 +275,25 @@ diesel = { version = "2.2", features = ["sqlite"], optional = true } # SQL (opt thiserror = "1.0" # Error handling ``` +## Completed Features + +- ✅ Items (`/XMLs/Items/Items.xml`) +- ✅ NPCs (`/XMLs/Npcs/NPCInfo.xml`) +- ✅ Quests (`/XMLs/Quests/Quests.xml`) + ## Future Enhancements The same pattern can be extended to parse other XML files: -- [ ] NPCs (`/XMLs/Npcs/*.xml`) -- [ ] Quests (`/XMLs/Quests/*.xml`) - [ ] Loot tables (`/XMLs/Loot/*.xml`) - [ ] 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 would follow the same pattern: +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 diff --git a/cursebreaker-parser/examples/game_data_demo.rs b/cursebreaker-parser/examples/game_data_demo.rs new file mode 100644 index 0000000..12d3fc3 --- /dev/null +++ b/cursebreaker-parser/examples/game_data_demo.rs @@ -0,0 +1,165 @@ +//! Example demonstrating combined Items, NPCs, and Quests database usage +//! +//! Run with: cargo run --example game_data_demo + +use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase}; + +fn main() -> Result<(), Box> { + println!("🎮 Cursebreaker Game Data Demo\n"); + + // Load all game data + println!("📚 Loading game data..."); + let item_db = ItemDatabase::load_from_xml("/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml")?; + 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")?; + + println!("✅ Loaded {} items", item_db.len()); + println!("✅ Loaded {} NPCs", npc_db.len()); + println!("✅ Loaded {} quests\n", quest_db.len()); + + // ======================================================================= + // Items + // ======================================================================= + println!("=== Items ==="); + let weapons = item_db.get_by_slot("weapon"); + let armor = item_db.get_by_slot("armor"); + let consumables = item_db.get_by_slot("consumable"); + + println!("By slot:"); + println!(" • Weapons: {}", weapons.len()); + println!(" • Armor: {}", armor.len()); + println!(" • Consumables: {}", consumables.len()); + + // Find specific item + if let Some(sword) = item_db.get_by_id(150) { + println!("\nSample item (ID 150):"); + println!(" Name: {}", sword.name); + if let Some(desc) = &sword.description { + println!(" Description: {}", desc); + } + if let Some(skill) = &sword.skill { + println!(" Skill: {}", skill); + } + } + println!(); + + // ======================================================================= + // NPCs + // ======================================================================= + println!("=== NPCs ==="); + let hostile = npc_db.get_hostile(); + let interactable = npc_db.get_interactable(); + let shopkeepers = npc_db.get_shopkeepers(); + + println!("By type:"); + println!(" • Hostile NPCs: {}", hostile.len()); + println!(" • Interactable NPCs: {}", interactable.len()); + println!(" • Shopkeepers: {}", shopkeepers.len()); + + // Find NPCs by tag + let undead = npc_db.get_by_tag("Undead"); + let predators = npc_db.get_by_tag("Predator"); + println!("\nBy tag:"); + println!(" • Undead: {}", undead.len()); + println!(" • Predators: {}", predators.len()); + + // Sample hostile NPC + if let Some(wolf) = npc_db.get_by_id(1) { + println!("\nSample hostile NPC (ID 1):"); + println!(" Name: {}", wolf.name); + if let Some(level) = wolf.level { + println!(" Level: {}", level); + } + if let Some(aggro) = wolf.aggrodistance { + println!(" Aggro Distance: {}", aggro); + } + if let Some(speed) = wolf.movementspeed { + println!(" Movement Speed: {}", speed); + } + println!(" Stats: {} stat entries", wolf.stats.len()); + } + + // Sample interactable NPC + println!("\nSample shopkeepers:"); + for shopkeeper in shopkeepers.iter().take(3) { + println!(" • {} (Shop ID: {:?})", shopkeeper.name, shopkeeper.shop); + } + println!(); + + // ======================================================================= + // Quests + // ======================================================================= + println!("=== Quests ==="); + let main_quests = quest_db.get_main_quests(); + let side_quests = quest_db.get_side_quests(); + let hidden_quests = quest_db.get_hidden_quests(); + + println!("By type:"); + println!(" • Main quests: {}", main_quests.len()); + println!(" • Side quests: {}", side_quests.len()); + println!(" • Hidden quests: {}", hidden_quests.len()); + + // Main quest details + println!("\nMain quests:"); + for quest in main_quests.iter().take(5) { + println!(" • {} (ID: {}, {} phases)", + quest.name, quest.id, quest.phase_count()); + } + + // Sample quest details + if let Some(quest) = quest_db.get_by_id(1) { + println!("\nSample quest (ID 1):"); + println!(" Name: {}", quest.name); + println!(" Phases: {}", quest.phases.len()); + println!(" Rewards: {}", quest.rewards.len()); + + if let Some(phase) = quest.get_phase(1) { + if let Some(desc) = &phase.trackerdescription { + println!(" Phase 1: {}", desc); + } + } + + if !quest.rewards.is_empty() { + println!(" Quest rewards:"); + for reward in &quest.rewards { + if let Some(item_id) = reward.item { + if let Some(item) = item_db.get_by_id(item_id) { + println!(" - {} x{}", item.name, reward.amount.unwrap_or(1)); + } + } else if let Some(skill) = &reward.skill { + println!(" - {} XP: {}", skill, reward.xp.unwrap_or(0)); + } + } + } + } + println!(); + + // ======================================================================= + // Cross-referencing data + // ======================================================================= + println!("=== Cross-referencing Data ==="); + + // Find NPCs that give quests + let mut quest_givers = 0; + for npc in npc_db.all_npcs() { + if !npc.questmarkers.is_empty() { + quest_givers += 1; + } + } + println!("NPCs with quest markers: {}", quest_givers); + + // Find items that are quest rewards + let mut quest_reward_items = std::collections::HashSet::new(); + for quest in quest_db.all_quests() { + for reward in &quest.rewards { + if let Some(item_id) = reward.item { + quest_reward_items.insert(item_id); + } + } + } + println!("Unique items used as quest rewards: {}", quest_reward_items.len()); + + println!("\n✨ Demo complete!"); + + Ok(()) +} diff --git a/cursebreaker-parser/src/lib.rs b/cursebreaker-parser/src/lib.rs index 0567501..5a14013 100644 --- a/cursebreaker-parser/src/lib.rs +++ b/cursebreaker-parser/src/lib.rs @@ -52,7 +52,15 @@ pub mod types; mod xml_parser; mod item_database; +mod npc_database; +mod quest_database; pub use item_database::ItemDatabase; -pub use types::{Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule, InteractableResource}; +pub use npc_database::NpcDatabase; +pub use quest_database::QuestDatabase; +pub use types::{ + Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule, InteractableResource, + Npc, NpcStat, NpcLevel, RightClick, BarkGroup, Bark, QuestMarker, NpcAnimationSet, + Quest, QuestPhase, QuestReward, +}; pub use xml_parser::XmlParseError; diff --git a/cursebreaker-parser/src/main.rs b/cursebreaker-parser/src/main.rs index c22cfb8..2ee7da5 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, InteractableResource}; +use cursebreaker_parser::{ItemDatabase, NpcDatabase, QuestDatabase, InteractableResource}; use unity_parser::UnityProject; use std::path::Path; use unity_parser::log::DedupLogger; @@ -23,28 +23,31 @@ fn main() -> Result<(), Box> { info!("🎮 Cursebreaker - Resource Parser"); // Load items from XML - info!("📚 Loading items from XML..."); + info!("📚 Loading game data from XML..."); + let items_path = "/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml"; let item_db = ItemDatabase::load_from_xml(items_path)?; - info!("✅ Loaded {} items from XML", item_db.len()); + info!("✅ Loaded {} items", item_db.len()); - // Print some item statistics - let weapons = item_db.get_by_slot("weapon"); - let consumables = item_db.get_by_slot("consumable"); - info!(" • Weapons: {}", weapons.len()); - info!(" • Consumables: {}", consumables.len()); + let npcs_path = "/home/connor/repos/CBAssets/Data/XMLs/Npcs/NPCInfo.xml"; + let npc_db = NpcDatabase::load_from_xml(npcs_path)?; + info!("✅ Loaded {} NPCs", npc_db.len()); - // Example: Print first few items - info!("\n📦 Sample Items:"); - for item in item_db.all_items().iter().take(5) { - info!(" ID: {}, Name: \"{}\"", item.id, item.name); - if let Some(desc) = &item.description { - info!(" Description: {}", desc); - } - if let Some(price) = item.price { - info!(" Price: {}", price); - } - } + let quests_path = "/home/connor/repos/CBAssets/Data/XMLs/Quests/Quests.xml"; + let quest_db = QuestDatabase::load_from_xml(quests_path)?; + info!("✅ Loaded {} quests", quest_db.len()); + + // Print statistics + info!("\n📊 Game Data Statistics:"); + info!(" Items:"); + info!(" • Weapons: {}", item_db.get_by_slot("weapon").len()); + info!(" • Consumables: {}", item_db.get_by_slot("consumable").len()); + info!(" NPCs:"); + info!(" • Hostile: {}", npc_db.get_hostile().len()); + info!(" • Interactable: {}", npc_db.get_interactable().len()); + info!(" Quests:"); + info!(" • Main quests: {}", quest_db.get_main_quests().len()); + info!(" • Side quests: {}", quest_db.get_side_quests().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/npc_database.rs b/cursebreaker-parser/src/npc_database.rs new file mode 100644 index 0000000..0c51a7b --- /dev/null +++ b/cursebreaker-parser/src/npc_database.rs @@ -0,0 +1,138 @@ +use crate::types::Npc; +use crate::xml_parser::{parse_npcs_xml, XmlParseError}; +use std::collections::HashMap; +use std::path::Path; + +/// A database for managing NPCs loaded from XML files +#[derive(Debug, Clone)] +pub struct NpcDatabase { + npcs: Vec, + npcs_by_id: HashMap, + npcs_by_name: HashMap>, +} + +impl NpcDatabase { + /// Create a new empty NpcDatabase + pub fn new() -> Self { + Self { + npcs: Vec::new(), + npcs_by_id: HashMap::new(), + npcs_by_name: HashMap::new(), + } + } + + /// Load NPCs from an XML file + pub fn load_from_xml>(path: P) -> Result { + let npcs = parse_npcs_xml(path)?; + let mut db = Self::new(); + db.add_npcs(npcs); + Ok(db) + } + + /// Add NPCs to the database + pub fn add_npcs(&mut self, npcs: Vec) { + for npc in npcs { + let index = self.npcs.len(); + self.npcs_by_id.insert(npc.id, index); + + self.npcs_by_name + .entry(npc.name.clone()) + .or_insert_with(Vec::new) + .push(index); + + self.npcs.push(npc); + } + } + + /// Get an NPC by ID + pub fn get_by_id(&self, id: i32) -> Option<&Npc> { + self.npcs_by_id + .get(&id) + .and_then(|&index| self.npcs.get(index)) + } + + /// Get NPCs by name + pub fn get_by_name(&self, name: &str) -> Vec<&Npc> { + self.npcs_by_name + .get(name) + .map(|indices| { + indices + .iter() + .filter_map(|&index| self.npcs.get(index)) + .collect() + }) + .unwrap_or_default() + } + + /// Get all NPCs + pub fn all_npcs(&self) -> &[Npc] { + &self.npcs + } + + /// Get all hostile NPCs (can fight and aggressive) + pub fn get_hostile(&self) -> Vec<&Npc> { + self.npcs + .iter() + .filter(|npc| { + npc.canfight == Some(1) && npc.aggressive == Some(1) + }) + .collect() + } + + /// Get all interactable NPCs + pub fn get_interactable(&self) -> Vec<&Npc> { + self.npcs + .iter() + .filter(|npc| npc.interactable == Some(1)) + .collect() + } + + /// Get NPCs by tag + pub fn get_by_tag(&self, tag: &str) -> Vec<&Npc> { + self.npcs + .iter() + .filter(|npc| { + npc.tags + .as_ref() + .map(|tags| tags.split(',').any(|t| t.trim() == tag)) + .unwrap_or(false) + }) + .collect() + } + + /// Get NPCs that offer shops + pub fn get_shopkeepers(&self) -> Vec<&Npc> { + self.npcs + .iter() + .filter(|npc| npc.shop.is_some()) + .collect() + } + + /// Get number of NPCs in database + pub fn len(&self) -> usize { + self.npcs.len() + } + + /// Check if database is empty + pub fn is_empty(&self) -> bool { + self.npcs.is_empty() + } + + /// Prepare NPCs for SQL insertion + /// Returns a vector of tuples (id, name, json_data) + pub fn prepare_for_sql(&self) -> Vec<(i32, String, String)> { + self.npcs + .iter() + .map(|npc| { + let json = serde_json::to_string(npc).unwrap_or_else(|_| "{}".to_string()); + (npc.id, npc.name.clone(), json) + }) + .collect() + } +} + +impl Default for NpcDatabase { + fn default() -> Self { + Self::new() + } +} diff --git a/cursebreaker-parser/src/quest_database.rs b/cursebreaker-parser/src/quest_database.rs new file mode 100644 index 0000000..91c7a22 --- /dev/null +++ b/cursebreaker-parser/src/quest_database.rs @@ -0,0 +1,112 @@ +use crate::types::Quest; +use crate::xml_parser::{parse_quests_xml, XmlParseError}; +use std::collections::HashMap; +use std::path::Path; + +/// A database for managing Quests loaded from XML files +#[derive(Debug, Clone)] +pub struct QuestDatabase { + quests: Vec, + quests_by_id: HashMap, + quests_by_name: HashMap, +} + +impl QuestDatabase { + /// Create a new empty QuestDatabase + pub fn new() -> Self { + Self { + quests: Vec::new(), + quests_by_id: HashMap::new(), + quests_by_name: HashMap::new(), + } + } + + /// Load quests from an XML file + pub fn load_from_xml>(path: P) -> Result { + let quests = parse_quests_xml(path)?; + let mut db = Self::new(); + db.add_quests(quests); + Ok(db) + } + + /// Add quests to the database + pub fn add_quests(&mut self, quests: Vec) { + for quest in quests { + let index = self.quests.len(); + self.quests_by_id.insert(quest.id, index); + self.quests_by_name.insert(quest.name.clone(), index); + self.quests.push(quest); + } + } + + /// Get a quest by ID + pub fn get_by_id(&self, id: i32) -> Option<&Quest> { + self.quests_by_id + .get(&id) + .and_then(|&index| self.quests.get(index)) + } + + /// Get a quest by name + pub fn get_by_name(&self, name: &str) -> Option<&Quest> { + self.quests_by_name + .get(name) + .and_then(|&index| self.quests.get(index)) + } + + /// Get all quests + pub fn all_quests(&self) -> &[Quest] { + &self.quests + } + + /// Get all main quests + pub fn get_main_quests(&self) -> Vec<&Quest> { + self.quests + .iter() + .filter(|quest| quest.is_main_quest()) + .collect() + } + + /// Get all side quests (non-main quests) + pub fn get_side_quests(&self) -> Vec<&Quest> { + self.quests + .iter() + .filter(|quest| !quest.is_main_quest()) + .collect() + } + + /// Get all hidden quests + pub fn get_hidden_quests(&self) -> Vec<&Quest> { + self.quests + .iter() + .filter(|quest| quest.is_hidden()) + .collect() + } + + /// Get number of quests in database + pub fn len(&self) -> usize { + self.quests.len() + } + + /// Check if database is empty + pub fn is_empty(&self) -> bool { + self.quests.is_empty() + } + + /// Prepare quests for SQL insertion + /// Returns a vector of tuples (id, name, json_data) + pub fn prepare_for_sql(&self) -> Vec<(i32, String, String)> { + self.quests + .iter() + .map(|quest| { + let json = serde_json::to_string(quest).unwrap_or_else(|_| "{}".to_string()); + (quest.id, quest.name.clone(), json) + }) + .collect() + } +} + +impl Default for QuestDatabase { + fn default() -> Self { + Self::new() + } +} diff --git a/cursebreaker-parser/src/types/mod.rs b/cursebreaker-parser/src/types/mod.rs index ecd31da..37e0e70 100644 --- a/cursebreaker-parser/src/types/mod.rs +++ b/cursebreaker-parser/src/types/mod.rs @@ -1,5 +1,9 @@ mod interactable_resource; mod item; +mod npc; +mod quest; 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}; diff --git a/cursebreaker-parser/src/types/npc.rs b/cursebreaker-parser/src/types/npc.rs new file mode 100644 index 0000000..4a3f370 --- /dev/null +++ b/cursebreaker-parser/src/types/npc.rs @@ -0,0 +1,226 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Npc { + // Required fields + pub id: i32, + pub name: String, + + // Basic attributes + pub tags: Option, + pub level: Option, + pub description: Option, + pub comment: Option, + pub model: Option, + + // Combat attributes + pub canfight: Option, + pub aggressive: Option, + pub team: Option, + pub aggrodistance: Option, + pub respawntime: Option, + pub health: Option, + pub mana: Option, + pub accuracy: Option, + pub damagetype: Option, + pub damageblock: Option, + pub ability: Option, + + // Attack attributes + pub attackdistance: Option, + pub attackspeed: Option, + pub attackdelay: Option, + pub gfxattack: Option, + + // Projectile attributes + pub projectile: Option, + pub projectilerate: Option, + pub projectileendgfx: Option, + pub projectileattackdistance: Option, + + // Movement + pub movementspeed: Option, + pub walkspeed: Option, + pub wandering: Option, + pub wanderingdistance: Option, + + // AI behavior + pub aibehaviour: Option, + pub nobestiary: Option, + + // Interaction + pub interactable: Option, + pub interactdistance: Option, + pub dontrotateoninteract: Option, + pub shop: Option, + + // Sound effects + pub sfxattack: Option, + pub sfxdeath: Option, + pub sfxtakehit: Option, + pub sfxidle: Option, + pub idlesoundtext: Option, + + // Animations + pub anim_attack: Option, + pub anim_death: Option, + pub anim_idle: Option, + pub anim_run: Option, + pub anim_walk: Option, + pub anim_takehit: Option, + pub startanim: Option, + + // Nested elements + pub stats: Vec, + pub levels: Vec, + pub rightclick: Option, + pub barks: Vec, + pub exitdialoguebarks: Vec, + pub questmarkers: Vec, + pub animations: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NpcStat { + // Damage + pub damagephysical: Option, + pub damagemagical: Option, + pub damageranged: Option, + + // Accuracy + pub accuracyphysical: Option, + pub accuracymagical: Option, + pub accuracyranged: Option, + + // Resistance + pub resistancephysical: Option, + pub resistancemagical: Option, + pub resistanceranged: Option, + + // Core stats + pub health: Option, + pub mana: Option, + pub manaregen: Option, + pub healing: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NpcLevel { + pub swordsmanship: Option, + pub archery: Option, + pub magic: Option, + pub defence: Option, + pub mining: Option, + pub woodcutting: Option, + pub fishing: Option, + pub cooking: Option, + pub carpentry: Option, + pub blacksmithy: Option, + pub tailoring: Option, + pub alchemy: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RightClick { + pub option: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BarkGroup { + pub cooldown: Option, + pub rate: Option, + pub range: Option, + pub checks: Option, + pub npcs: Option, + pub barks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Bark { + pub text: String, + pub pausetime: Option, + pub rate: Option, + pub anim: Option, + pub npc: Option, + pub dontrotate: Option, + pub dontrotateothers: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuestMarker { + pub id: i32, + pub phase: i32, + pub checks: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NpcAnimationSet { + pub idle: Option, + pub walk: Option, + pub run: Option, + pub attack: Option, + pub death: Option, + pub talk: Option, +} + +impl Npc { + pub fn new(id: i32, name: String) -> Self { + Self { + id, + name, + tags: None, + level: None, + description: None, + comment: None, + model: None, + canfight: None, + aggressive: None, + team: None, + aggrodistance: None, + respawntime: None, + health: None, + mana: None, + accuracy: None, + damagetype: None, + damageblock: None, + ability: None, + attackdistance: None, + attackspeed: None, + attackdelay: None, + gfxattack: None, + projectile: None, + projectilerate: None, + projectileendgfx: None, + projectileattackdistance: None, + movementspeed: None, + walkspeed: None, + wandering: None, + wanderingdistance: None, + aibehaviour: None, + nobestiary: None, + interactable: None, + interactdistance: None, + dontrotateoninteract: None, + shop: None, + sfxattack: None, + sfxdeath: None, + sfxtakehit: None, + sfxidle: None, + idlesoundtext: None, + anim_attack: None, + anim_death: None, + anim_idle: None, + anim_run: None, + anim_walk: None, + anim_takehit: None, + startanim: None, + stats: Vec::new(), + levels: Vec::new(), + rightclick: None, + barks: Vec::new(), + exitdialoguebarks: Vec::new(), + questmarkers: Vec::new(), + animations: None, + } + } +} diff --git a/cursebreaker-parser/src/types/quest.rs b/cursebreaker-parser/src/types/quest.rs new file mode 100644 index 0000000..9b17310 --- /dev/null +++ b/cursebreaker-parser/src/types/quest.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Quest { + // Required fields + pub id: i32, + pub name: String, + + // Optional attributes + pub mainquest: Option, + pub hidden: Option, + pub questdescription: Option, + pub completiontext: Option, + pub dontshowcompletionscreen: Option, + pub comment: Option, + + // Nested elements + pub phases: Vec, + pub rewards: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuestPhase { + pub id: i32, + pub trackerdescription: Option, + pub description: Option, + pub helperarrownpc: Option, + pub helperarrowpos: Option, + pub checks: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuestReward { + pub item: Option, + pub skill: Option, + pub amount: Option, + pub xp: Option, + pub checks: Option, + pub comment: Option, +} + +impl Quest { + pub fn new(id: i32, name: String) -> Self { + Self { + id, + name, + mainquest: None, + hidden: None, + questdescription: None, + completiontext: None, + dontshowcompletionscreen: None, + comment: None, + phases: Vec::new(), + rewards: Vec::new(), + } + } + + /// Check if this is a main quest + pub fn is_main_quest(&self) -> bool { + self.mainquest == Some(1) + } + + /// Check if this quest is hidden + pub fn is_hidden(&self) -> bool { + self.hidden == Some(1) + } + + /// Get the number of phases in this quest + pub fn phase_count(&self) -> usize { + self.phases.len() + } + + /// Get a specific phase by ID + pub fn get_phase(&self, phase_id: i32) -> Option<&QuestPhase> { + self.phases.iter().find(|p| p.id == phase_id) + } +} diff --git a/cursebreaker-parser/src/xml_parser.rs b/cursebreaker-parser/src/xml_parser.rs index fb32da6..3ebce14 100644 --- a/cursebreaker-parser/src/xml_parser.rs +++ b/cursebreaker-parser/src/xml_parser.rs @@ -1,4 +1,8 @@ -use crate::types::{Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule}; +use crate::types::{ + Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule, + Npc, NpcStat, NpcLevel, RightClick, BarkGroup, QuestMarker, NpcAnimationSet, + Quest, QuestPhase, QuestReward, +}; use quick_xml::events::Event; use quick_xml::reader::Reader; use std::collections::HashMap; @@ -188,3 +192,341 @@ fn parse_stat(attrs: &HashMap) -> ItemStat { harvestingspeedwoodcutting: attrs.get("harvestingspeedwoodcutting").and_then(|v| v.parse().ok()), } } + +// ============================================================================ +// NPC Parser +// ============================================================================ + +pub fn parse_npcs_xml>(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 npcs = Vec::new(); + let mut buf = Vec::new(); + let mut current_npc: Option = None; + let mut current_bark_group: Option = None; + let mut in_exitdialoguebarks = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + match e.name().as_ref() { + b"npc" => { + let attrs = parse_attributes(e)?; + let id = attrs.get("id") + .ok_or_else(|| XmlParseError::MissingAttribute("id".to_string()))? + .parse::() + .map_err(|_| XmlParseError::InvalidAttribute("id".to_string()))?; + + let name = attrs.get("name").cloned().unwrap_or_default(); + let mut npc = Npc::new(id, name); + + // Parse all optional attributes + if let Some(v) = attrs.get("tags") { npc.tags = Some(v.clone()); } + if let Some(v) = attrs.get("level") { npc.level = v.parse().ok(); } + if let Some(v) = attrs.get("description") { npc.description = Some(v.clone()); } + if let Some(v) = attrs.get("comment") { npc.comment = Some(v.clone()); } + if let Some(v) = attrs.get("model") { npc.model = Some(v.clone()); } + if let Some(v) = attrs.get("canfight") { npc.canfight = v.parse().ok(); } + if let Some(v) = attrs.get("aggressive") { npc.aggressive = v.parse().ok(); } + if let Some(v) = attrs.get("team") { npc.team = v.parse().ok(); } + if let Some(v) = attrs.get("aggrodistance") { npc.aggrodistance = v.parse().ok(); } + if let Some(v) = attrs.get("respawntime") { npc.respawntime = v.parse().ok(); } + if let Some(v) = attrs.get("health") { npc.health = v.parse().ok(); } + if let Some(v) = attrs.get("mana") { npc.mana = v.parse().ok(); } + if let Some(v) = attrs.get("accuracy") { npc.accuracy = v.parse().ok(); } + if let Some(v) = attrs.get("damagetype") { npc.damagetype = v.parse().ok(); } + if let Some(v) = attrs.get("damageblock") { npc.damageblock = v.parse().ok(); } + if let Some(v) = attrs.get("ability") { npc.ability = v.parse().ok(); } + if let Some(v) = attrs.get("attackdistance") { npc.attackdistance = v.parse().ok(); } + if let Some(v) = attrs.get("attackspeed") { npc.attackspeed = v.parse().ok(); } + if let Some(v) = attrs.get("attackdelay") { npc.attackdelay = v.parse().ok(); } + if let Some(v) = attrs.get("gfxattack") { npc.gfxattack = Some(v.clone()); } + if let Some(v) = attrs.get("projectile") { npc.projectile = v.parse().ok(); } + if let Some(v) = attrs.get("projectilerate") { npc.projectilerate = v.parse().ok(); } + if let Some(v) = attrs.get("projectileendgfx") { npc.projectileendgfx = Some(v.clone()); } + if let Some(v) = attrs.get("projectileattackdistance") { npc.projectileattackdistance = v.parse().ok(); } + if let Some(v) = attrs.get("movementspeed") { npc.movementspeed = v.parse().ok(); } + if let Some(v) = attrs.get("walkspeed") { npc.walkspeed = v.parse().ok(); } + if let Some(v) = attrs.get("wandering") { npc.wandering = v.parse().ok(); } + if let Some(v) = attrs.get("wanderingdistance") { npc.wanderingdistance = v.parse().ok(); } + if let Some(v) = attrs.get("aibehaviour") { npc.aibehaviour = v.parse().ok(); } + if let Some(v) = attrs.get("nobestiary") { npc.nobestiary = v.parse().ok(); } + if let Some(v) = attrs.get("interactable") { npc.interactable = v.parse().ok(); } + if let Some(v) = attrs.get("interactdistance") { npc.interactdistance = v.parse().ok(); } + if let Some(v) = attrs.get("dontrotateoninteract") { npc.dontrotateoninteract = v.parse().ok(); } + if let Some(v) = attrs.get("shop") { npc.shop = v.parse().ok(); } + if let Some(v) = attrs.get("sfxattack") { npc.sfxattack = Some(v.clone()); } + if let Some(v) = attrs.get("sfxdeath") { npc.sfxdeath = Some(v.clone()); } + if let Some(v) = attrs.get("sfxtakehit") { npc.sfxtakehit = Some(v.clone()); } + if let Some(v) = attrs.get("sfxidle") { npc.sfxidle = Some(v.clone()); } + if let Some(v) = attrs.get("idlesoundtext") { npc.idlesoundtext = Some(v.clone()); } + if let Some(v) = attrs.get("anim_attack") { npc.anim_attack = Some(v.clone()); } + if let Some(v) = attrs.get("anim_death") { npc.anim_death = Some(v.clone()); } + if let Some(v) = attrs.get("anim_idle") { npc.anim_idle = Some(v.clone()); } + if let Some(v) = attrs.get("anim_run") { npc.anim_run = Some(v.clone()); } + if let Some(v) = attrs.get("anim_walk") { npc.anim_walk = Some(v.clone()); } + if let Some(v) = attrs.get("anim_takehit") { npc.anim_takehit = Some(v.clone()); } + if let Some(v) = attrs.get("startanim") { npc.startanim = Some(v.clone()); } + + current_npc = Some(npc); + } + b"stat" if current_npc.is_some() => { + if let Some(ref mut npc) = current_npc { + let attrs = parse_attributes(e)?; + let stat = parse_npc_stat(&attrs); + npc.stats.push(stat); + } + } + b"level" if current_npc.is_some() => { + if let Some(ref mut npc) = current_npc { + let attrs = parse_attributes(e)?; + let level = parse_npc_level(&attrs); + npc.levels.push(level); + } + } + b"rightclick" if current_npc.is_some() => { + if let Some(ref mut npc) = current_npc { + let attrs = parse_attributes(e)?; + if let Some(option) = attrs.get("option") { + npc.rightclick = Some(RightClick { option: option.clone() }); + } + } + } + b"questmarker" if current_npc.is_some() => { + if let Some(ref mut npc) = current_npc { + let attrs = parse_attributes(e)?; + if let (Some(id), Some(phase)) = (attrs.get("id"), attrs.get("phase")) { + if let (Ok(id), Ok(phase)) = (id.parse::(), phase.parse::()) { + npc.questmarkers.push(QuestMarker { + id, + phase, + checks: attrs.get("checks").cloned(), + }); + } + } + } + } + b"barks" if current_npc.is_some() => { + let attrs = parse_attributes(e)?; + current_bark_group = Some(BarkGroup { + cooldown: attrs.get("cooldown").and_then(|v| v.parse().ok()), + rate: attrs.get("rate").and_then(|v| v.parse().ok()), + range: attrs.get("range").and_then(|v| v.parse().ok()), + checks: attrs.get("checks").cloned(), + npcs: attrs.get("npcs").cloned(), + barks: Vec::new(), + }); + } + b"exitdialoguebarks" if current_npc.is_some() => { + in_exitdialoguebarks = true; + current_bark_group = Some(BarkGroup { + cooldown: None, + rate: None, + range: None, + checks: None, + npcs: None, + barks: Vec::new(), + }); + } + b"anim" if current_npc.is_some() => { + if let Some(ref mut npc) = current_npc { + let attrs = parse_attributes(e)?; + npc.animations = Some(NpcAnimationSet { + idle: attrs.get("idle").cloned(), + walk: attrs.get("walk").cloned(), + run: attrs.get("run").cloned(), + attack: attrs.get("attack").cloned(), + death: attrs.get("death").cloned(), + talk: attrs.get("talk").cloned(), + }); + } + } + _ => {} + } + } + Ok(Event::Text(e)) => { + // Handle bark text content + if current_bark_group.is_some() { + let text = e.unescape()?.to_string().trim().to_string(); + if !text.is_empty() { + // Text will be added to bark when we hit the end tag + } + } + } + Ok(Event::End(ref e)) => { + match e.name().as_ref() { + b"npc" => { + if let Some(npc) = current_npc.take() { + npcs.push(npc); + } + } + b"barks" => { + if let Some(bark_group) = current_bark_group.take() { + if let Some(ref mut npc) = current_npc { + if in_exitdialoguebarks { + npc.exitdialoguebarks.push(bark_group); + } else { + npc.barks.push(bark_group); + } + } + } + } + b"exitdialoguebarks" => { + in_exitdialoguebarks = false; + if let Some(bark_group) = current_bark_group.take() { + if let Some(ref mut npc) = current_npc { + npc.exitdialoguebarks.push(bark_group); + } + } + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(XmlParseError::XmlError(e)), + _ => {} + } + buf.clear(); + } + + Ok(npcs) +} + +fn parse_npc_stat(attrs: &HashMap) -> NpcStat { + NpcStat { + damagephysical: attrs.get("damagephysical").and_then(|v| v.parse().ok()), + damagemagical: attrs.get("damagemagical").and_then(|v| v.parse().ok()), + damageranged: attrs.get("damageranged").and_then(|v| v.parse().ok()), + accuracyphysical: attrs.get("accuracyphysical").and_then(|v| v.parse().ok()), + accuracymagical: attrs.get("accuracymagical").and_then(|v| v.parse().ok()), + accuracyranged: attrs.get("accuracyranged").and_then(|v| v.parse().ok()), + resistancephysical: attrs.get("resistancephysical").or_else(|| attrs.get("resistancePhysical")).and_then(|v| v.parse().ok()), + resistancemagical: attrs.get("resistancemagical").or_else(|| attrs.get("resistanceMagical")).and_then(|v| v.parse().ok()), + resistanceranged: attrs.get("resistanceranged").or_else(|| attrs.get("resistanceRanged")).and_then(|v| v.parse().ok()), + health: attrs.get("health").and_then(|v| v.parse().ok()), + mana: attrs.get("mana").and_then(|v| v.parse().ok()), + manaregen: attrs.get("manaregen").and_then(|v| v.parse().ok()), + healing: attrs.get("healing").and_then(|v| v.parse().ok()), + } +} + +fn parse_npc_level(attrs: &HashMap) -> NpcLevel { + NpcLevel { + swordsmanship: attrs.get("swordsmanship").and_then(|v| v.parse().ok()), + archery: attrs.get("archery").and_then(|v| v.parse().ok()), + magic: attrs.get("magic").and_then(|v| v.parse().ok()), + defence: attrs.get("defence").and_then(|v| v.parse().ok()), + mining: attrs.get("mining").and_then(|v| v.parse().ok()), + woodcutting: attrs.get("woodcutting").and_then(|v| v.parse().ok()), + fishing: attrs.get("fishing").and_then(|v| v.parse().ok()), + cooking: attrs.get("cooking").and_then(|v| v.parse().ok()), + carpentry: attrs.get("carpentry").and_then(|v| v.parse().ok()), + blacksmithy: attrs.get("blacksmithy").and_then(|v| v.parse().ok()), + tailoring: attrs.get("tailoring").and_then(|v| v.parse().ok()), + alchemy: attrs.get("alchemy").and_then(|v| v.parse().ok()), + } +} + +// ============================================================================ +// Quest Parser +// ============================================================================ + +pub fn parse_quests_xml>(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 quests = Vec::new(); + let mut buf = Vec::new(); + let mut current_quest: Option = None; + let mut in_rewards = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + match e.name().as_ref() { + b"quest" => { + let attrs = parse_attributes(e)?; + let id = attrs.get("id") + .ok_or_else(|| XmlParseError::MissingAttribute("id".to_string()))? + .parse::() + .map_err(|_| XmlParseError::InvalidAttribute("id".to_string()))?; + + let name = attrs.get("name") + .ok_or_else(|| XmlParseError::MissingAttribute("name".to_string()))? + .clone(); + + let mut quest = Quest::new(id, name); + + // Parse optional attributes + if let Some(v) = attrs.get("mainquest") { quest.mainquest = v.parse().ok(); } + if let Some(v) = attrs.get("hidden") { quest.hidden = v.parse().ok(); } + if let Some(v) = attrs.get("questdescription") { quest.questdescription = Some(v.clone()); } + if let Some(v) = attrs.get("completiontext") { quest.completiontext = Some(v.clone()); } + if let Some(v) = attrs.get("dontshowcompletionscreen") { quest.dontshowcompletionscreen = v.parse().ok(); } + if let Some(v) = attrs.get("comment") { quest.comment = Some(v.clone()); } + + current_quest = Some(quest); + } + b"phase" if current_quest.is_some() => { + if let Some(ref mut quest) = current_quest { + let attrs = parse_attributes(e)?; + if let Some(id) = attrs.get("id") { + if let Ok(id) = id.parse::() { + quest.phases.push(QuestPhase { + id, + trackerdescription: attrs.get("trackerdescription").cloned(), + description: attrs.get("description").cloned(), + helperarrownpc: attrs.get("helperarrownpc").cloned(), + helperarrowpos: attrs.get("helperarrowpos").cloned(), + checks: attrs.get("checks").cloned(), + }); + } + } + } + } + b"rewards" if current_quest.is_some() => { + in_rewards = true; + } + b"reward" if current_quest.is_some() && in_rewards => { + if let Some(ref mut quest) = current_quest { + let attrs = parse_attributes(e)?; + quest.rewards.push(QuestReward { + item: attrs.get("item").and_then(|v| v.parse().ok()), + skill: attrs.get("skill").cloned(), + amount: attrs.get("amount").and_then(|v| v.parse().ok()), + xp: attrs.get("xp").and_then(|v| v.parse().ok()), + checks: attrs.get("checks").cloned(), + comment: attrs.get("comment").cloned(), + }); + } + } + _ => {} + } + } + Ok(Event::End(ref e)) => { + match e.name().as_ref() { + b"quest" => { + if let Some(quest) = current_quest.take() { + quests.push(quest); + } + } + b"rewards" => { + in_rewards = false; + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(XmlParseError::XmlError(e)), + _ => {} + } + buf.clear(); + } + + Ok(quests) +}