npc + achievements

This commit is contained in:
2026-01-07 09:56:17 +00:00
parent 2efa1aa86d
commit beecbd33c6
10 changed files with 1223 additions and 35 deletions

View File

@@ -8,11 +8,12 @@ The parser now supports loading game data from Cursebreaker's XML files and stor
## Features ## Features
- ✅ Parse Items.xml with full attribute and nested element support - ✅ Parse Items, NPCs, and Quests XML files with full attribute and nested element support
- ✅ In-memory database with fast lookups by ID, name, category, slot, and skill - ✅ In-memory databases with fast lookups by ID, name, and various filters
- ✅ JSON serialization for SQL database storage - ✅ JSON serialization for SQL database storage
- ✅ Type-safe data structures with serde support - ✅ 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 ## 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 ```bash
# Items only
cargo run --example item_database_demo 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 - **Total Items**: 1,360
- **Weapons**: 166 - **Weapons**: 166
- **Armor**: 148 - **Armor**: 148
@@ -142,21 +228,41 @@ When loaded from `/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml`:
- **Bows**: 18 - **Bows**: 18
- **Magic Items**: 76 - **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 ## File Structure
``` ```
cursebreaker-parser/ cursebreaker-parser/
├── src/ ├── src/
│ ├── lib.rs # Library exports │ ├── lib.rs # Library exports
│ ├── main.rs # Main binary (includes Unity + XML parsing) │ ├── main.rs # Main binary (Unity + XML parsing)
│ ├── types/ │ ├── types/
│ │ ├── mod.rs │ │ ├── mod.rs
│ │ ├── item.rs # Item data structures │ │ ├── item.rs # Item data structures
│ │ ├── npc.rs # NPC data structures
│ │ ├── quest.rs # Quest data structures
│ │ └── interactable_resource.rs │ │ └── interactable_resource.rs
│ ├── xml_parser.rs # XML parsing logic │ ├── xml_parser.rs # XML parsing logic (Items, NPCs, Quests)
── item_database.rs # ItemDatabase for runtime access ── item_database.rs # ItemDatabase for runtime access
│ ├── npc_database.rs # NpcDatabase for runtime access
│ └── quest_database.rs # QuestDatabase for runtime access
└── examples/ └── 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 ## Dependencies Added
@@ -169,18 +275,25 @@ diesel = { version = "2.2", features = ["sqlite"], optional = true } # SQL (opt
thiserror = "1.0" # Error handling thiserror = "1.0" # Error handling
``` ```
## Completed Features
- ✅ Items (`/XMLs/Items/Items.xml`)
- ✅ NPCs (`/XMLs/Npcs/NPCInfo.xml`)
- ✅ Quests (`/XMLs/Quests/Quests.xml`)
## Future Enhancements ## Future Enhancements
The same pattern can be extended to parse other XML files: The same pattern can be extended to parse other XML files:
- [ ] NPCs (`/XMLs/Npcs/*.xml`)
- [ ] Quests (`/XMLs/Quests/*.xml`)
- [ ] Loot tables (`/XMLs/Loot/*.xml`) - [ ] Loot tables (`/XMLs/Loot/*.xml`)
- [ ] Maps (`/XMLs/Maps/*.xml`) - [ ] Maps (`/XMLs/Maps/*.xml`)
- [ ] Dialogue (`/XMLs/Dialogue/*.xml`) - [ ] Dialogue (`/XMLs/Dialogue/*.xml`)
- [ ] Events (`/XMLs/Events/*.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/` 1. Define data structures in `src/types/`
2. Create parser in `src/xml_parser.rs` 2. Create parser in `src/xml_parser.rs`
3. Create database wrapper for runtime access 3. Create database wrapper for runtime access

View File

@@ -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<dyn std::error::Error>> {
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(())
}

View File

@@ -52,7 +52,15 @@
pub mod types; pub mod types;
mod xml_parser; mod xml_parser;
mod item_database; mod item_database;
mod npc_database;
mod quest_database;
pub use item_database::ItemDatabase; 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; pub use xml_parser::XmlParseError;

View File

@@ -6,7 +6,7 @@
//! 3. Extracting typeId and transform positions //! 3. Extracting typeId and transform positions
//! 4. Writing resource data to an output file //! 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 unity_parser::UnityProject;
use std::path::Path; use std::path::Path;
use unity_parser::log::DedupLogger; use unity_parser::log::DedupLogger;
@@ -23,28 +23,31 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
info!("🎮 Cursebreaker - Resource Parser"); info!("🎮 Cursebreaker - Resource Parser");
// Load items from XML // 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 items_path = "/home/connor/repos/CBAssets/Data/XMLs/Items/Items.xml";
let item_db = ItemDatabase::load_from_xml(items_path)?; 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 npcs_path = "/home/connor/repos/CBAssets/Data/XMLs/Npcs/NPCInfo.xml";
let weapons = item_db.get_by_slot("weapon"); let npc_db = NpcDatabase::load_from_xml(npcs_path)?;
let consumables = item_db.get_by_slot("consumable"); info!("✅ Loaded {} NPCs", npc_db.len());
info!(" • Weapons: {}", weapons.len());
info!(" • Consumables: {}", consumables.len());
// Example: Print first few items let quests_path = "/home/connor/repos/CBAssets/Data/XMLs/Quests/Quests.xml";
info!("\n📦 Sample Items:"); let quest_db = QuestDatabase::load_from_xml(quests_path)?;
for item in item_db.all_items().iter().take(5) { info!("✅ Loaded {} quests", quest_db.len());
info!(" ID: {}, Name: \"{}\"", item.id, item.name);
if let Some(desc) = &item.description { // Print statistics
info!(" Description: {}", desc); info!("\n📊 Game Data Statistics:");
} info!(" Items:");
if let Some(price) = item.price { info!(" • Weapons: {}", item_db.get_by_slot("weapon").len());
info!(" Price: {}", price); 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 // Initialize Unity project once - scans entire project for GUID mappings
let project_root = Path::new("/home/connor/repos/CBAssets"); let project_root = Path::new("/home/connor/repos/CBAssets");

View File

@@ -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<Npc>,
npcs_by_id: HashMap<i32, usize>,
npcs_by_name: HashMap<String, Vec<usize>>,
}
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<P: AsRef<Path>>(path: P) -> Result<Self, XmlParseError> {
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<Npc>) {
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()
}
}

View File

@@ -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<Quest>,
quests_by_id: HashMap<i32, usize>,
quests_by_name: HashMap<String, usize>,
}
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<P: AsRef<Path>>(path: P) -> Result<Self, XmlParseError> {
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<Quest>) {
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()
}
}

View File

@@ -1,5 +1,9 @@
mod interactable_resource; mod interactable_resource;
mod item; mod item;
mod npc;
mod quest;
pub use interactable_resource::InteractableResource; pub use interactable_resource::InteractableResource;
pub use item::{Item, ItemStat, CraftingRecipe, AnimationSet, GenerateRule}; 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};

View File

@@ -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<String>,
pub level: Option<i32>,
pub description: Option<String>,
pub comment: Option<String>,
pub model: Option<String>,
// Combat attributes
pub canfight: Option<i32>,
pub aggressive: Option<i32>,
pub team: Option<i32>,
pub aggrodistance: Option<i32>,
pub respawntime: Option<i32>,
pub health: Option<i32>,
pub mana: Option<i32>,
pub accuracy: Option<i32>,
pub damagetype: Option<i32>,
pub damageblock: Option<i32>,
pub ability: Option<i32>,
// Attack attributes
pub attackdistance: Option<i32>,
pub attackspeed: Option<i32>,
pub attackdelay: Option<i32>,
pub gfxattack: Option<String>,
// Projectile attributes
pub projectile: Option<i32>,
pub projectilerate: Option<i32>,
pub projectileendgfx: Option<String>,
pub projectileattackdistance: Option<i32>,
// Movement
pub movementspeed: Option<i32>,
pub walkspeed: Option<i32>,
pub wandering: Option<i32>,
pub wanderingdistance: Option<i32>,
// AI behavior
pub aibehaviour: Option<i32>,
pub nobestiary: Option<i32>,
// Interaction
pub interactable: Option<i32>,
pub interactdistance: Option<i32>,
pub dontrotateoninteract: Option<i32>,
pub shop: Option<i32>,
// Sound effects
pub sfxattack: Option<String>,
pub sfxdeath: Option<String>,
pub sfxtakehit: Option<String>,
pub sfxidle: Option<String>,
pub idlesoundtext: Option<String>,
// Animations
pub anim_attack: Option<String>,
pub anim_death: Option<String>,
pub anim_idle: Option<String>,
pub anim_run: Option<String>,
pub anim_walk: Option<String>,
pub anim_takehit: Option<String>,
pub startanim: Option<String>,
// Nested elements
pub stats: Vec<NpcStat>,
pub levels: Vec<NpcLevel>,
pub rightclick: Option<RightClick>,
pub barks: Vec<BarkGroup>,
pub exitdialoguebarks: Vec<BarkGroup>,
pub questmarkers: Vec<QuestMarker>,
pub animations: Option<NpcAnimationSet>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcStat {
// 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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcLevel {
pub swordsmanship: Option<i32>,
pub archery: Option<i32>,
pub magic: Option<i32>,
pub defence: Option<i32>,
pub mining: Option<i32>,
pub woodcutting: Option<i32>,
pub fishing: Option<i32>,
pub cooking: Option<i32>,
pub carpentry: Option<i32>,
pub blacksmithy: Option<i32>,
pub tailoring: Option<i32>,
pub alchemy: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RightClick {
pub option: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BarkGroup {
pub cooldown: Option<i32>,
pub rate: Option<i32>,
pub range: Option<i32>,
pub checks: Option<String>,
pub npcs: Option<String>,
pub barks: Vec<Bark>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bark {
pub text: String,
pub pausetime: Option<i32>,
pub rate: Option<i32>,
pub anim: Option<String>,
pub npc: Option<String>,
pub dontrotate: Option<i32>,
pub dontrotateothers: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestMarker {
pub id: i32,
pub phase: i32,
pub checks: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcAnimationSet {
pub idle: Option<String>,
pub walk: Option<String>,
pub run: Option<String>,
pub attack: Option<String>,
pub death: Option<String>,
pub talk: Option<String>,
}
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,
}
}
}

View File

@@ -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<i32>,
pub hidden: Option<i32>,
pub questdescription: Option<String>,
pub completiontext: Option<String>,
pub dontshowcompletionscreen: Option<i32>,
pub comment: Option<String>,
// Nested elements
pub phases: Vec<QuestPhase>,
pub rewards: Vec<QuestReward>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestPhase {
pub id: i32,
pub trackerdescription: Option<String>,
pub description: Option<String>,
pub helperarrownpc: Option<String>,
pub helperarrowpos: Option<String>,
pub checks: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestReward {
pub item: Option<i32>,
pub skill: Option<String>,
pub amount: Option<i32>,
pub xp: Option<i32>,
pub checks: Option<String>,
pub comment: Option<String>,
}
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)
}
}

View File

@@ -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::events::Event;
use quick_xml::reader::Reader; use quick_xml::reader::Reader;
use std::collections::HashMap; use std::collections::HashMap;
@@ -188,3 +192,341 @@ fn parse_stat(attrs: &HashMap<String, String>) -> ItemStat {
harvestingspeedwoodcutting: attrs.get("harvestingspeedwoodcutting").and_then(|v| v.parse().ok()), harvestingspeedwoodcutting: attrs.get("harvestingspeedwoodcutting").and_then(|v| v.parse().ok()),
} }
} }
// ============================================================================
// NPC Parser
// ============================================================================
pub fn parse_npcs_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Npc>, 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<Npc> = None;
let mut current_bark_group: Option<BarkGroup> = 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::<i32>()
.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::<i32>(), phase.parse::<i32>()) {
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<String, String>) -> 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<String, String>) -> 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<P: AsRef<Path>>(path: P) -> Result<Vec<Quest>, 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<Quest> = 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::<i32>()
.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::<i32>() {
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)
}