26-1-2026

This commit is contained in:
2026-01-26 13:05:57 +00:00
parent cdfab8fd1e
commit ccc9a894b7
47 changed files with 3470 additions and 1601 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL=/home/connor/repos/cursebreaker-parser-rust/cursebreaker.db

115
Cargo.lock generated
View File

@@ -50,6 +50,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.100" version = "1.0.100"
@@ -303,12 +353,58 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "clap"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -387,6 +483,7 @@ name = "cursebreaker-parser"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"image", "image",
@@ -957,6 +1054,12 @@ dependencies = [
"rustversion", "rustversion",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@@ -1256,6 +1359,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2138,6 +2247,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "v_frame" name = "v_frame"
version = "0.3.9" version = "0.3.9"

View File

@@ -49,6 +49,67 @@ struct Position {
y: f32, y: f32,
} }
// Labels response (world_map_icons with icon_type == 16)
#[derive(Serialize)]
struct LabelsResponse {
labels: Vec<Label>,
}
#[derive(Serialize)]
struct Label {
x: f32,
y: f32,
text: String,
font_size: i32,
}
// Entrances response (world_teleporters)
#[derive(Serialize)]
struct EntrancesResponse {
icon_base64: String,
entrances: Vec<Entrance>,
}
#[derive(Serialize)]
struct Entrance {
pos_x: f32,
pos_y: f32,
tp_x: Option<f32>,
tp_y: Option<f32>,
}
// Ground Items response (world_loot)
#[derive(Serialize)]
struct GroundItemsResponse {
icon_base64: String,
items: Vec<GroundItem>,
}
#[derive(Serialize)]
struct GroundItem {
x: f32,
y: f32,
name: String,
amount: i32,
respawn_time: i32,
}
// Houses response (player_houses)
#[derive(Serialize)]
struct HousesResponse {
icon_base64: String,
houses: Vec<House>,
}
#[derive(Serialize)]
struct House {
x: f32,
y: f32,
name: String,
description: String,
price: i32,
}
// Establish database connection // Establish database connection
fn establish_connection(database_url: &str) -> Result<DbConnection, diesel::ConnectionError> { fn establish_connection(database_url: &str) -> Result<DbConnection, diesel::ConnectionError> {
SqliteConnection::establish(database_url) SqliteConnection::establish(database_url)
@@ -201,6 +262,212 @@ async fn get_resources(
Ok(Json(ResourceResponse { resources })) Ok(Json(ResourceResponse { resources }))
} }
// Get labels from world_map_icons where icon_type == 16
async fn get_labels(State(state): State<Arc<AppState>>) -> Result<Json<LabelsResponse>, StatusCode> {
use cursebreaker_parser::schema::world_map_icons;
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let results = world_map_icons::table
.filter(world_map_icons::icon_type.eq(16))
.select((
world_map_icons::pos_x,
world_map_icons::pos_y,
world_map_icons::text,
world_map_icons::font_size,
))
.load::<(f32, f32, String, i32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying labels: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let labels: Vec<Label> = results
.into_iter()
.map(|(pos_x, pos_y, text, font_size)| Label {
x: pos_x * 5.12,
y: pos_y * 5.12,
text,
font_size,
})
.collect();
info!("Returning {} labels", labels.len());
Ok(Json(LabelsResponse { labels }))
}
// Get entrances from world_teleporters
async fn get_entrances(
State(state): State<Arc<AppState>>,
) -> Result<Json<EntrancesResponse>, StatusCode> {
use cursebreaker_parser::schema::{general_icons, world_teleporters};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Get the Entrance icon
let icon_bytes: Vec<u8> = general_icons::table
.filter(general_icons::name.eq("Entrance"))
.select(general_icons::icon_32)
.first::<Option<Vec<u8>>>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying entrance icon: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.unwrap_or_default();
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
// Get teleporter positions
let results = world_teleporters::table
.select((
world_teleporters::pos_x,
world_teleporters::pos_y,
world_teleporters::tp_x,
world_teleporters::tp_y,
))
.load::<(f32, f32, Option<f32>, Option<f32>)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying teleporters: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entrances: Vec<Entrance> = results
.into_iter()
.map(|(pos_x, pos_y, tp_x, tp_y)| Entrance {
pos_x: pos_x * 5.12,
pos_y: pos_y * 5.12,
tp_x: tp_x.map(|x| x * 5.12),
tp_y: tp_y.map(|y| y * 5.12),
})
.collect();
info!("Returning {} entrances", entrances.len());
Ok(Json(EntrancesResponse {
icon_base64,
entrances,
}))
}
// Get ground items from world_loot
async fn get_ground_items(
State(state): State<Arc<AppState>>,
) -> Result<Json<GroundItemsResponse>, StatusCode> {
use cursebreaker_parser::schema::{general_icons, items, world_loot};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Get the Common_tick icon
let icon_bytes: Vec<u8> = general_icons::table
.filter(general_icons::name.eq("Common_tick"))
.select(general_icons::icon_32)
.first::<Option<Vec<u8>>>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying common_tick icon: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.unwrap_or_default();
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
// Get world loot with item names
let results = world_loot::table
.inner_join(items::table.on(world_loot::item_id.eq(items::id.assume_not_null())))
.select((
world_loot::pos_x,
world_loot::pos_y,
items::name,
world_loot::amount,
world_loot::respawn_time,
))
.load::<(f32, f32, String, i32, i32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying ground items: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let ground_items: Vec<GroundItem> = results
.into_iter()
.map(|(pos_x, pos_y, name, amount, respawn_time)| GroundItem {
x: pos_x * 5.12,
y: pos_y * 5.12,
name,
amount,
respawn_time,
})
.collect();
info!("Returning {} ground items", ground_items.len());
Ok(Json(GroundItemsResponse {
icon_base64,
items: ground_items,
}))
}
// Get player houses
async fn get_houses(State(state): State<Arc<AppState>>) -> Result<Json<HousesResponse>, StatusCode> {
use cursebreaker_parser::schema::{general_icons, player_houses};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Get the Notifications_House icon (64px)
let icon_bytes: Vec<u8> = general_icons::table
.filter(general_icons::name.eq("Notifications_House"))
.select(general_icons::icon_64)
.first::<Option<Vec<u8>>>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying house icon: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.unwrap_or_default();
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
// Get player houses
let results = player_houses::table
.select((
player_houses::pos_x,
player_houses::pos_z,
player_houses::name,
player_houses::description,
player_houses::price,
))
.load::<(f32, f32, String, String, i32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying player houses: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let houses: Vec<House> = results
.into_iter()
.map(|(pos_x, pos_z, name, description, price)| House {
x: pos_x * 5.12,
y: pos_z * 5.12,
name,
description,
price,
})
.collect();
info!("Returning {} houses", houses.len());
Ok(Json(HousesResponse { icon_base64, houses }))
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Initialize tracing // Initialize tracing
@@ -219,6 +486,10 @@ async fn main() {
.route("/api/bounds", get(get_bounds)) .route("/api/bounds", get(get_bounds))
.route("/api/tiles/:z/:x/:y", get(get_tile)) .route("/api/tiles/:z/:x/:y", get(get_tile))
.route("/api/resources", get(get_resources)) .route("/api/resources", get(get_resources))
.route("/api/labels", get(get_labels))
.route("/api/entrances", get(get_entrances))
.route("/api/ground-items", get(get_ground_items))
.route("/api/houses", get(get_houses))
.nest_service("/", ServeDir::new("static")) .nest_service("/", ServeDir::new("static"))
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(state); .with_state(state);

View File

@@ -21,6 +21,46 @@
<p class="subtitle">The Black Grimoire: Cursebreaker</p> <p class="subtitle">The Black Grimoire: Cursebreaker</p>
</div> </div>
<div class="filters-section">
<h3>Labels</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="labels-toggle" checked>
<span>Show Labels</span>
</label>
</div>
</div>
<div class="filters-section">
<h3>Entrances</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="entrances-toggle" checked>
<span>Show Entrances</span>
</label>
</div>
</div>
<div class="filters-section">
<h3>Ground Items</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="ground-items-toggle" checked>
<span>Show Ground Items</span>
</label>
</div>
</div>
<div class="filters-section">
<h3>Houses</h3>
<div class="filter-controls">
<label class="filter-label master-toggle">
<input type="checkbox" id="houses-toggle" checked>
<span>Show Houses</span>
</label>
</div>
</div>
<div class="filters-section"> <div class="filters-section">
<h3>Resources</h3> <h3>Resources</h3>
<div class="filter-controls"> <div class="filter-controls">
@@ -59,5 +99,6 @@
<!-- Custom JS --> <!-- Custom JS -->
<script src="map.js"></script> <script src="map.js"></script>
<script src="resources.js"></script> <script src="resources.js"></script>
<script src="markers.js"></script>
</body> </body>
</html> </html>

View File

@@ -120,6 +120,9 @@ async function initMap() {
console.error('Failed to load resources:', error); console.error('Failed to load resources:', error);
}); });
// Load markers (labels, entrances, ground items, houses)
initMarkers();
} catch (error) { } catch (error) {
console.error('Error initializing map:', error); console.error('Error initializing map:', error);
document.getElementById('map-stats').innerHTML = document.getElementById('map-stats').innerHTML =

View File

@@ -0,0 +1,377 @@
// Markers management for Cursebreaker map (Labels, Entrances, Ground Items, Houses)
// Layer groups for each marker type
let labelsLayerGroup = null;
let entrancesLayerGroup = null;
let groundItemsLayerGroup = null;
let housesLayerGroup = null;
// Store active teleport lines for entrances
let activeTeleportLine = null;
// Initialize all markers when map is ready
function initMarkers() {
// Load all marker types in parallel
Promise.all([
loadLabels(),
loadEntrances(),
loadGroundItems(),
loadHouses(),
]).catch(error => {
console.error('Error loading markers:', error);
});
// Set up toggle handlers
setupMarkerToggles();
}
// Set up toggle event handlers
function setupMarkerToggles() {
const labelsToggle = document.getElementById('labels-toggle');
const entrancesToggle = document.getElementById('entrances-toggle');
const groundItemsToggle = document.getElementById('ground-items-toggle');
const housesToggle = document.getElementById('houses-toggle');
if (labelsToggle) {
labelsToggle.addEventListener('change', (e) => {
toggleLayer(labelsLayerGroup, e.target.checked);
saveMarkerState('labels', e.target.checked);
});
}
if (entrancesToggle) {
entrancesToggle.addEventListener('change', (e) => {
toggleLayer(entrancesLayerGroup, e.target.checked);
saveMarkerState('entrances', e.target.checked);
// Remove active teleport line when hiding entrances
if (!e.target.checked && activeTeleportLine) {
map.removeLayer(activeTeleportLine);
activeTeleportLine = null;
}
});
}
if (groundItemsToggle) {
groundItemsToggle.addEventListener('change', (e) => {
toggleLayer(groundItemsLayerGroup, e.target.checked);
saveMarkerState('groundItems', e.target.checked);
});
}
if (housesToggle) {
housesToggle.addEventListener('change', (e) => {
toggleLayer(housesLayerGroup, e.target.checked);
saveMarkerState('houses', e.target.checked);
});
}
// Restore saved state
restoreMarkerState();
}
// Toggle layer visibility
function toggleLayer(layerGroup, visible) {
if (!layerGroup) return;
if (visible) {
layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
}
// Save marker visibility state
function saveMarkerState(type, visible) {
try {
const state = JSON.parse(localStorage.getItem('cursebreaker_marker_state') || '{}');
state[type] = visible;
localStorage.setItem('cursebreaker_marker_state', JSON.stringify(state));
} catch (error) {
console.warn('Failed to save marker state:', error);
}
}
// Restore marker visibility state
function restoreMarkerState() {
try {
const state = JSON.parse(localStorage.getItem('cursebreaker_marker_state') || '{}');
// Update checkboxes and layers based on saved state
setTimeout(() => {
if (state.labels === false) {
const toggle = document.getElementById('labels-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(labelsLayerGroup, false);
}
}
if (state.entrances === false) {
const toggle = document.getElementById('entrances-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(entrancesLayerGroup, false);
}
}
if (state.groundItems === false) {
const toggle = document.getElementById('ground-items-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(groundItemsLayerGroup, false);
}
}
if (state.houses === false) {
const toggle = document.getElementById('houses-toggle');
if (toggle) {
toggle.checked = false;
toggleLayer(housesLayerGroup, false);
}
}
}, 200);
} catch (error) {
console.warn('Failed to restore marker state:', error);
}
}
// Load labels (text markers on the map)
async function loadLabels() {
try {
console.log('Loading labels...');
const response = await fetch('/api/labels');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.labels.length} labels`);
labelsLayerGroup = L.layerGroup();
for (const label of data.labels) {
// Create a divIcon with the label text
const labelIcon = L.divIcon({
className: 'map-label',
html: `<div class="label-text" style="font-size: ${label.font_size}px;">${label.text}</div>`,
iconSize: null, // Let CSS handle sizing
iconAnchor: [0, 0],
});
const marker = L.marker([label.y, label.x], {
icon: labelIcon,
interactive: false, // Labels shouldn't be clickable
});
marker.addTo(labelsLayerGroup);
}
labelsLayerGroup.addTo(map);
console.log('Labels loaded successfully');
} catch (error) {
console.error('Error loading labels:', error);
}
}
// Load entrances (teleporters with lines)
async function loadEntrances() {
try {
console.log('Loading entrances...');
const response = await fetch('/api/entrances');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.entrances.length} entrances`);
entrancesLayerGroup = L.layerGroup();
// Create icon from base64
const iconUrl = `data:image/webp;base64,${data.icon_base64}`;
const entranceIcon = L.icon({
iconUrl: iconUrl,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16],
});
for (const entrance of data.entrances) {
const marker = L.marker([entrance.pos_y, entrance.pos_x], {
icon: entranceIcon,
title: 'Entrance',
});
// Store teleport destination on the marker
marker.teleportDest = {
x: entrance.tp_x,
y: entrance.tp_y,
};
// Handle click to show teleport line
marker.on('click', function(e) {
// Remove existing line if any
if (activeTeleportLine) {
map.removeLayer(activeTeleportLine);
activeTeleportLine = null;
}
const dest = this.teleportDest;
if (dest.x !== null && dest.y !== null) {
// Create a line from entrance to destination
activeTeleportLine = L.polyline(
[
[entrance.pos_y, entrance.pos_x],
[dest.y, dest.x]
],
{
color: '#00ffff',
weight: 3,
opacity: 0.8,
dashArray: '10, 10',
}
).addTo(map);
// Add a destination marker
const destMarker = L.circleMarker([dest.y, dest.x], {
radius: 8,
color: '#00ffff',
fillColor: '#00ffff',
fillOpacity: 0.5,
}).addTo(map);
// Remove line and destination marker after 5 seconds
setTimeout(() => {
if (activeTeleportLine) {
map.removeLayer(activeTeleportLine);
activeTeleportLine = null;
}
map.removeLayer(destMarker);
}, 5000);
}
});
marker.addTo(entrancesLayerGroup);
}
entrancesLayerGroup.addTo(map);
console.log('Entrances loaded successfully');
} catch (error) {
console.error('Error loading entrances:', error);
}
}
// Format respawn time as "XXM XXS"
function formatRespawnTime(seconds) {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (minutes > 0 && secs > 0) {
return `${minutes}M ${secs}S`;
} else if (minutes > 0) {
return `${minutes}M`;
} else {
return `${secs}S`;
}
}
// Load ground items
async function loadGroundItems() {
try {
console.log('Loading ground items...');
const response = await fetch('/api/ground-items');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.items.length} ground items`);
groundItemsLayerGroup = L.layerGroup();
// Create icon from base64
const iconUrl = `data:image/webp;base64,${data.icon_base64}`;
const itemIcon = L.icon({
iconUrl: iconUrl,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12],
});
for (const item of data.items) {
const marker = L.marker([item.y, item.x], {
icon: itemIcon,
title: item.name,
});
// Build popup content
let popupContent = `<strong>${item.name}</strong>`;
if (item.amount > 1) {
popupContent += `<br/>Amount: ${item.amount}`;
}
popupContent += `<br/>Respawn: ${formatRespawnTime(item.respawn_time)}`;
marker.bindPopup(popupContent);
marker.addTo(groundItemsLayerGroup);
}
groundItemsLayerGroup.addTo(map);
console.log('Ground items loaded successfully');
} catch (error) {
console.error('Error loading ground items:', error);
}
}
// Load player houses
async function loadHouses() {
try {
console.log('Loading houses...');
const response = await fetch('/api/houses');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.houses.length} houses`);
housesLayerGroup = L.layerGroup();
// Create icon from base64
const iconUrl = `data:image/webp;base64,${data.icon_base64}`;
const houseIcon = L.icon({
iconUrl: iconUrl,
iconSize: [64, 64],
iconAnchor: [32, 32],
popupAnchor: [0, -32],
});
for (const house of data.houses) {
const marker = L.marker([house.y, house.x], {
icon: houseIcon,
title: house.name,
});
// Format price with commas
const formattedPrice = house.price.toLocaleString();
// Build popup content
const popupContent = `
<strong>${house.name}</strong><br/>
<em>${house.description}</em><br/>
<span class="house-price">Price: ${formattedPrice} gold</span>
`;
marker.bindPopup(popupContent);
marker.addTo(housesLayerGroup);
}
housesLayerGroup.addTo(map);
console.log('Houses loaded successfully');
} catch (error) {
console.error('Error loading houses:', error);
}
}
// Call initMarkers after map is loaded
// This is called from map.js after resources are loaded

View File

@@ -259,6 +259,53 @@ body {
background: #2a2a2a; background: #2a2a2a;
} }
/* Master toggle for marker categories */
.master-toggle {
margin-left: 0 !important;
}
.master-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #8b5cf6;
}
/* Map labels (text overlays) */
.map-label {
background: transparent;
border: none;
}
.label-text {
color: #e0e0e0;
text-shadow:
-1px -1px 2px #000,
1px -1px 2px #000,
-1px 1px 2px #000,
1px 1px 2px #000,
0 0 4px #000;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-weight: bold;
white-space: nowrap;
pointer-events: none;
}
/* House price styling in popup */
.house-price {
color: #ffd700;
font-weight: bold;
}
/* Popup styling for various marker types */
.leaflet-popup-content strong {
color: #8b5cf6;
}
.leaflet-popup-content em {
color: #a0a0a0;
font-size: 12px;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {

View File

@@ -42,6 +42,7 @@ image = "0.25"
webp = "0.3" webp = "0.3"
thiserror = "1.0" thiserror = "1.0"
chrono = "0.4" chrono = "0.4"
clap = { version = "4.5", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
diesel_migrations = "2.2" diesel_migrations = "2.2"

View File

@@ -0,0 +1,278 @@
# XML Parser Documentation
This document explains the XML parsing system used to load game data from Cursebreaker's XML files and populate the SQLite database.
## Overview
The XML parser system is responsible for:
1. Reading game data from XML files (items, NPCs, quests, etc.)
2. Parsing the XML into Rust structs
3. Storing the parsed data in a SQLite database
## Architecture
### File Structure
```
cursebreaker-parser/src/
├── xml_parsers/ # XML parsing module
│ ├── mod.rs # Shared utilities and re-exports
│ ├── items.rs # Item parser
│ ├── npcs.rs # NPC parser
│ ├── quests.rs # Quest parser
│ ├── harvestables.rs # Harvestable resource parser
│ ├── loot.rs # Loot table parser
│ ├── maps.rs # Map/scene parser
│ ├── fast_travel.rs # Fast travel location parser
│ ├── player_houses.rs # Player house parser
│ ├── traits.rs # Character trait parser
│ └── shops.rs # Shop/vendor parser
├── databases/ # Database abstraction layer
│ ├── item_database.rs
│ ├── npc_database.rs
│ └── ...
├── types/ # Data structures
│ └── cursebreaker/
│ ├── item.rs
│ ├── npc.rs
│ └── ...
└── bin/
└── xml-parser.rs # CLI binary
```
### Data Flow
```
XML Files (CBAssets/Data/XMLs/)
XML Parsers (xml_parsers/*.rs)
Rust Structs (types/cursebreaker/*.rs)
Database Layer (databases/*.rs)
SQLite Database (cursebreaker.db)
```
## Parser Components
### Shared Utilities (`xml_parsers/mod.rs`)
The module provides common functionality used by all parsers:
```rust
/// Error types for XML parsing
pub enum XmlParseError {
XmlError(quick_xml::Error), // XML syntax errors
IoError(std::io::Error), // File read errors
AttrError(AttrError), // Attribute parsing errors
MissingAttribute(String), // Required attribute not found
InvalidAttribute(String), // Attribute value invalid
}
/// Parse XML element attributes into a HashMap
fn parse_attributes(element: &BytesStart) -> Result<HashMap<String, String>, XmlParseError>
/// Parse health range strings like "3-5" or "3" into (min, max)
fn parse_health_range(health_str: &str) -> (i32, i32)
```
### Individual Parsers
Each parser follows a similar pattern:
1. **Open and read the XML file** using `quick_xml::Reader`
2. **Iterate through XML events** (Start, Empty, End, Text, Eof)
3. **Match element names** and extract attributes
4. **Build Rust structs** from the parsed data
5. **Return a Vec** of parsed objects
#### Example: Item Parser Flow
```rust
pub fn parse_items_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Item>, XmlParseError> {
// 1. Open file and create reader
let file = File::open(path)?;
let mut reader = Reader::from_reader(BufReader::new(file));
// 2. Process XML events
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
match e.name().as_ref() {
b"item" => {
// 3. Parse attributes
let attrs = parse_attributes(&e)?;
let id = attrs.get("id")...;
let name = attrs.get("name")...;
// 4. Create struct
let item = Item::new(id, name);
current_item = Some(item);
}
b"stat" => { /* Parse nested stat element */ }
_ => {}
}
}
Ok(Event::End(e)) => {
if e.name().as_ref() == b"item" {
// 5. Add completed item to results
items.push(current_item.take().unwrap());
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
}
Ok(items)
}
```
## Supported Data Types
| Parser | XML Source | Description |
|--------|-----------|-------------|
| `items` | `Items/Items.xml` | Game items (weapons, armor, consumables, etc.) |
| `npcs` | `Npcs/NPCInfo.xml` | Non-player characters (enemies, vendors, quest givers) |
| `quests` | `Quests/Quests.xml` | Quest definitions with phases and rewards |
| `harvestables` | `Harvestables/HarvestableInfo.xml` | Gatherable resources (trees, rocks, fishing spots) |
| `loot` | `Loot/Loot.xml` | NPC drop tables |
| `maps` | `Maps/Maps.xml` | Game scenes/areas with lighting and fog settings |
| `fast_travel` | `FastTravel*.xml` | Teleport locations, canoe routes, portals |
| `player_houses` | `PlayerHouses/PlayerHouses.xml` | Purchasable player housing |
| `traits` | `Traits/Traits.xml` | Character traits/perks |
| `shops` | `Shops/Shops.xml` | Vendor inventories and pricing |
## CLI Usage
The `xml-parser` binary provides command-line control over which parsers to run:
```bash
# Parse all data types
xml-parser --all
xml-parser -a
# Parse specific data types
xml-parser --items # or -i
xml-parser --npcs # or -n
xml-parser --quests # or -q
xml-parser --harvestables # or -r
xml-parser --loot # or -l
xml-parser --maps # or -m
xml-parser --fast-travel # or -f
xml-parser --houses # or -p
xml-parser --traits # or -t
xml-parser --shops # or -s
# Combine multiple parsers
xml-parser --items --npcs --quests
xml-parser -i -n -q
# View help
xml-parser --help
```
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CB_ASSETS_PATH` | `/home/connor/repos/CBAssets` | Path to game assets directory |
| `DATABASE_URL` | `cursebreaker.db` | SQLite database file path |
## Database Integration
Each parser has a corresponding database module that handles:
1. **Loading from XML** - Wraps the parser and creates a queryable database
2. **Querying** - Methods like `get_by_id()`, `get_by_name()`, `get_all()`
3. **Saving to SQLite** - Serializes data and inserts into database tables
### Example: ItemDatabase
```rust
// Load items from XML
let item_db = ItemDatabase::load_from_xml("path/to/Items.xml")?;
// Query items
let sword = item_db.get_by_id(150);
let bows = item_db.get_by_category("bow");
// Save to database (includes icon processing)
item_db.save_to_db_with_images(&mut conn, "path/to/icons")?;
```
## XML Format Examples
### Item XML
```xml
<item id="150" name="Iron Sword" level="10" price="500" maxstack="1">
<stat damagephysical="25" accuracyphysical="5"/>
<anim idle="1" walk="2" run="3" weaponattack="4"/>
</item>
```
### NPC XML
```xml
<npc id="45" name="Goblin" level="5" health="100" aggressive="1">
<stat damagephysical="10" resistancephysical="5"/>
<level swordsmanship="3" defence="2"/>
</npc>
```
### Quest XML
```xml
<quest id="1" name="First Steps" mainquest="1">
<phase id="1" trackerdescription="Talk to the Elder"/>
<phase id="2" trackerdescription="Collect 5 herbs"/>
<rewards>
<reward item="100" amount="1"/>
<reward skill="swordsmanship" xp="50"/>
</rewards>
</quest>
```
## Error Handling
The parser uses a custom `XmlParseError` enum to handle various failure modes:
- **MissingAttribute**: Required XML attribute not found (e.g., missing `id`)
- **InvalidAttribute**: Attribute value cannot be parsed (e.g., non-numeric ID)
- **XmlError**: Malformed XML syntax
- **IoError**: File not found or permission denied
Parsers fail fast on required attributes but use defaults for optional ones:
```rust
// Required - returns error if missing
let id = attrs.get("id")
.ok_or_else(|| XmlParseError::MissingAttribute("id".to_string()))?;
// Optional - uses default if missing
let level = attrs.get("level")
.and_then(|v| v.parse().ok())
.unwrap_or(1);
```
## Performance Considerations
- **Streaming parser**: Uses `quick_xml` which processes XML as a stream, keeping memory usage low
- **Single-pass parsing**: Each file is read once and parsed in a single pass
- **Batch database inserts**: Data is collected into vectors before database insertion
- **Selective parsing**: CLI allows parsing only needed data types, reducing processing time
## Adding a New Parser
To add support for a new XML data type:
1. **Create the type** in `types/cursebreaker/new_type.rs`
2. **Create the parser** in `xml_parsers/new_type.rs`
3. **Export from mod.rs**: Add `mod new_type;` and `pub use new_type::parse_new_type_xml;`
4. **Create database module** in `databases/new_type_database.rs`
5. **Add CLI flag** in `bin/xml-parser.rs`
6. **Update this documentation**

View File

@@ -0,0 +1,14 @@
-- Drop the separate icon tables
DROP TABLE IF EXISTS ability_icons;
DROP TABLE IF EXISTS buff_icons;
DROP TABLE IF EXISTS trait_icons;
DROP TABLE IF EXISTS player_house_icons;
DROP TABLE IF EXISTS stat_icons;
-- Recreate the combined icons table
CREATE TABLE IF NOT EXISTS icons (
category TEXT NOT NULL,
name TEXT NOT NULL,
icon BLOB NOT NULL,
PRIMARY KEY (category, name)
);

View File

@@ -0,0 +1,32 @@
-- Drop the combined icons table
DROP TABLE IF EXISTS icons;
-- Ability icons table
CREATE TABLE IF NOT EXISTS ability_icons (
name TEXT PRIMARY KEY NOT NULL,
icon BLOB NOT NULL
);
-- Buff icons table
CREATE TABLE IF NOT EXISTS buff_icons (
name TEXT PRIMARY KEY NOT NULL,
icon BLOB NOT NULL
);
-- Trait icons table
CREATE TABLE IF NOT EXISTS trait_icons (
name TEXT PRIMARY KEY NOT NULL,
icon BLOB NOT NULL
);
-- Player house icons table
CREATE TABLE IF NOT EXISTS player_house_icons (
name TEXT PRIMARY KEY NOT NULL,
icon BLOB NOT NULL
);
-- Stat icons table
CREATE TABLE IF NOT EXISTS stat_icons (
name TEXT PRIMARY KEY NOT NULL,
icon BLOB NOT NULL
);

View File

@@ -0,0 +1,9 @@
-- Revert to original schema with JSON data field
DROP TABLE IF EXISTS player_houses;
CREATE TABLE player_houses (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
map_id INTEGER NOT NULL,
data TEXT NOT NULL
);

View File

@@ -0,0 +1,11 @@
-- Drop the old table and recreate with direct fields instead of JSON data
DROP TABLE IF EXISTS player_houses;
CREATE TABLE player_houses (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
pos_x REAL NOT NULL,
pos_z REAL NOT NULL,
price INTEGER NOT NULL
);

View File

@@ -0,0 +1,9 @@
-- Restore old table schema
DROP TABLE IF EXISTS fast_travel_locations;
CREATE TABLE fast_travel_locations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
map_name TEXT NOT NULL,
data TEXT NOT NULL
);

View File

@@ -0,0 +1,12 @@
-- Drop the old table and create with new schema
DROP TABLE IF EXISTS fast_travel_locations;
CREATE TABLE fast_travel_locations (
name TEXT PRIMARY KEY,
pos_x REAL NOT NULL,
pos_z REAL NOT NULL,
travel_type TEXT NOT NULL,
unlocked INTEGER NOT NULL DEFAULT 0,
connections TEXT,
checks TEXT
);

View File

@@ -8,88 +8,132 @@
//! - Storing all tiles in the SQLite database //! - Storing all tiles in the SQLite database
//! - Generating statistics about storage and compression //! - Generating statistics about storage and compression
use cursebreaker_parser::{MinimapDatabase, IconDatabase}; use clap::Parser;
use log::{info, error, LevelFilter}; use cursebreaker_parser::{IconDatabase, MinimapDatabase};
use unity_parser::log::DedupLogger; use log::{error, info, LevelFilter};
use std::env; use std::env;
use unity_parser::log::DedupLogger;
#[derive(Parser, Debug)]
#[command(name = "image-parser")]
#[command(about = "Processes minimap tiles and game icons")]
struct Args {
/// Process minimap tiles
#[arg(long)]
minimap: bool,
/// Process game icons
#[arg(long)]
icons: bool,
/// Process everything (minimap and icons)
#[arg(long)]
all: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
// Validate that at least one option is specified
if !args.minimap && !args.icons && !args.all {
eprintln!("Error: At least one option must be specified.\n");
eprintln!("Usage: image-parser [OPTIONS]\n");
eprintln!("Options:");
eprintln!(" --minimap Process minimap tiles");
eprintln!(" --icons Process game icons");
eprintln!(" --all Process everything");
std::process::exit(1);
}
let process_minimap = args.minimap || args.all;
let process_icons = args.icons || args.all;
let logger = DedupLogger::new(); let logger = DedupLogger::new();
log::set_boxed_logger(Box::new(logger)) log::set_boxed_logger(Box::new(logger))
.map(|()| log::set_max_level(LevelFilter::Trace)) .map(|()| log::set_max_level(LevelFilter::Trace))
.unwrap(); .unwrap();
info!("🎮 Cursebreaker - Image Parser"); info!("Image Parser");
info!("Generates all zoom levels (0, 1, 2) with merged tiles"); info!("Generates all zoom levels (0, 1, 2) with merged tiles");
info!("⚠️ Will override existing database entries\n"); info!("Will override existing database entries\n");
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string());
let cb_assets_path =
env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
// Process minimap tiles // Process minimap tiles
info!("🗺️ Processing minimap tiles..."); if process_minimap {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string()); info!("Processing minimap tiles...");
let minimap_db = MinimapDatabase::new(database_url.clone()); let minimap_db = MinimapDatabase::new(database_url.clone());
let minimap_path = format!("{}/Data/Textures/MinimapSquares", cb_assets_path);
let cb_assets_path = env::var("CB_ASSETS_PATH") match minimap_db.load_from_directory(&minimap_path, &cb_assets_path) {
.unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string()); Ok(total_count) => {
let minimap_path = format!("{}/Data/Textures/MinimapSquares", cb_assets_path); info!("\nProcessed {} total tiles (all zoom levels)", total_count);
match minimap_db.load_from_directory(&minimap_path, &cb_assets_path) { // Get statistics
Ok(total_count) => { if let Ok(stats) = minimap_db.get_storage_stats() {
info!("\n✅ Processed {} total tiles (all zoom levels)", total_count); info!("\n=== Storage Statistics ===");
info!(
"Original PNG total: {} MB",
stats.total_original_size / 1_048_576
);
info!("WebP total: {} MB", stats.total_webp_size() / 1_048_576);
info!("Compression ratio: {:.2}%\n", stats.compression_ratio());
// Get statistics info!("=== Tiles Per Zoom Level ===");
if let Ok(stats) = minimap_db.get_storage_stats() { info!(
info!("\n=== Storage Statistics ==="); "Zoom 2 (original): {} tiles ({} MB)",
info!("Original PNG total: {} MB", stats.total_original_size / 1_048_576); stats.zoom2_count,
info!("WebP total: {} MB", stats.total_webp_size() / 1_048_576); stats.zoom2_size / 1_048_576
info!("Compression ratio: {:.2}%\n", stats.compression_ratio()); );
info!(
"Zoom 1 (2x2 merged): {} tiles ({} MB)",
stats.zoom1_count,
stats.zoom1_size / 1_048_576
);
info!(
"Zoom 0 (4x4 merged): {} tiles ({} MB)",
stats.zoom0_count,
stats.zoom0_size / 1_048_576
);
}
info!("=== Tiles Per Zoom Level ==="); if let Ok(bounds) = minimap_db.get_map_bounds() {
info!("Zoom 2 (original): {} tiles ({} MB)", info!("\n=== Map Bounds ===");
stats.zoom2_count, info!("Min (x,y): {:?}", bounds.0);
stats.zoom2_size / 1_048_576 info!("Max (x,y): {:?}", bounds.1);
); }
info!("Zoom 1 (2x2 merged): {} tiles ({} MB)",
stats.zoom1_count,
stats.zoom1_size / 1_048_576
);
info!("Zoom 0 (4x4 merged): {} tiles ({} MB)",
stats.zoom0_count,
stats.zoom0_size / 1_048_576
);
} }
Err(e) => {
if let Ok(bounds) = minimap_db.get_map_bounds() { error!("Failed to process minimap tiles: {}", e);
info!("\n=== Map Bounds ==="); return Err(Box::new(e));
info!("Min (x,y): {:?}", bounds.0);
info!("Max (x,y): {:?}", bounds.1);
} }
} }
Err(e) => {
error!("Failed to process minimap tiles: {}", e);
return Err(Box::new(e));
}
} }
// Process game icons // Process game icons
info!("\n=== Processing Game Icons ==="); if process_icons {
let icon_db = IconDatabase::new(database_url); info!("\n=== Processing Game Icons ===");
let icon_db = IconDatabase::new(database_url);
match icon_db.load_all_icons(&cb_assets_path) { match icon_db.load_all_icons(&cb_assets_path) {
Ok(stats) => { Ok(stats) => {
info!("\n=== Icon Statistics ==="); info!("\n=== Icon Statistics ===");
info!("Ability icons: {}", stats.abilities); info!("Ability icons: {}", stats.abilities);
info!("Buff icons: {}", stats.buffs); info!("Buff icons: {}", stats.buffs);
info!("Trait icons: {}", stats.traits); info!("Trait icons: {}", stats.traits);
info!("Player house icons: {}", stats.player_houses); info!("Player house icons: {}", stats.player_houses);
info!("Stat icons: {}", stats.stat_icons); info!("Stat icons: {}", stats.stat_icons);
info!("Achievement icons: {}", stats.achievement_icons); info!("Achievement icons: {}", stats.achievement_icons);
info!("General icons: {}", stats.general_icons); info!("General icons: {}", stats.general_icons);
info!("Total icons: {}", stats.total_icons()); info!("Total icons: {}", stats.total_icons());
info!("Total size: {} KB", stats.total_bytes / 1024); info!("Total size: {} KB", stats.total_bytes / 1024);
} }
Err(e) => { Err(e) => {
error!("Failed to process icons: {}", e); error!("Failed to process icons: {}", e);
return Err(Box::new(e)); return Err(Box::new(e));
}
} }
} }

View File

@@ -1,16 +1,150 @@
//! XML Parser - Loads game data from XML files and populates the SQLite database //! XML Parser - Loads game data from XML files and populates the SQLite database
//! //!
//! This binary handles: //! Usage:
//! - Loading all game data from XML files //! xml-parser --all Parse all data types
//! - Populating the SQLite database with the parsed data //! xml-parser --items Parse items only
//! - Generating statistics about the loaded data //! xml-parser --npcs Parse NPCs only
//! xml-parser --quests Parse quests only
//! xml-parser --harvestables Parse harvestables only
//! xml-parser --loot Parse loot tables only
//! xml-parser --maps Parse maps only
//! xml-parser --fast-travel Parse fast travel locations only
//! xml-parser --houses Parse player houses only
//! xml-parser --traits Parse traits only
//! xml-parser --shops Parse shops only
//!
//! Multiple flags can be combined:
//! xml-parser --items --npcs --quests
use cursebreaker_parser::{ItemDatabase, HarvestableDatabase}; use clap::Parser;
use log::{info, warn, LevelFilter}; use cursebreaker_parser::{
use unity_parser::log::DedupLogger; ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase,
LootDatabase, MapDatabase, FastTravelDatabase, PlayerHouseDatabase,
TraitDatabase, ShopDatabase,
};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use log::{info, warn, LevelFilter};
use std::env; use std::env;
use unity_parser::log::DedupLogger;
#[derive(Parser, Debug)]
#[command(name = "xml-parser")]
#[command(author = "Cursebreaker Team")]
#[command(version = "1.0")]
#[command(about = "Parses game XML data and populates the SQLite database")]
struct Args {
/// Parse all data types
#[arg(long, short = 'a')]
all: bool,
/// Parse items
#[arg(long, short = 'i')]
items: bool,
/// Parse NPCs
#[arg(long, short = 'n')]
npcs: bool,
/// Parse quests
#[arg(long, short = 'q')]
quests: bool,
/// Parse harvestables
#[arg(long, short = 'r')]
harvestables: bool,
/// Parse loot tables
#[arg(long, short = 'l')]
loot: bool,
/// Parse maps
#[arg(long, short = 'm')]
maps: bool,
/// Parse fast travel locations
#[arg(long, short = 'f')]
fast_travel: bool,
/// Parse player houses
#[arg(long, short = 'p')]
houses: bool,
/// Parse traits
#[arg(long, short = 't')]
traits: bool,
/// Parse shops
#[arg(long, short = 's')]
shops: bool,
}
impl Args {
/// Returns true if no specific parsers were selected
fn none_selected(&self) -> bool {
!self.all
&& !self.items
&& !self.npcs
&& !self.quests
&& !self.harvestables
&& !self.loot
&& !self.maps
&& !self.fast_travel
&& !self.houses
&& !self.traits
&& !self.shops
}
/// Returns true if items should be parsed
fn should_parse_items(&self) -> bool {
self.all || self.items
}
/// Returns true if NPCs should be parsed
fn should_parse_npcs(&self) -> bool {
self.all || self.npcs
}
/// Returns true if quests should be parsed
fn should_parse_quests(&self) -> bool {
self.all || self.quests
}
/// Returns true if harvestables should be parsed
fn should_parse_harvestables(&self) -> bool {
self.all || self.harvestables
}
/// Returns true if loot should be parsed
fn should_parse_loot(&self) -> bool {
self.all || self.loot
}
/// Returns true if maps should be parsed
fn should_parse_maps(&self) -> bool {
self.all || self.maps
}
/// Returns true if fast travel should be parsed
fn should_parse_fast_travel(&self) -> bool {
self.all || self.fast_travel
}
/// Returns true if houses should be parsed
fn should_parse_houses(&self) -> bool {
self.all || self.houses
}
/// Returns true if traits should be parsed
fn should_parse_traits(&self) -> bool {
self.all || self.traits
}
/// Returns true if shops should be parsed
fn should_parse_shops(&self) -> bool {
self.all || self.shops
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let logger = DedupLogger::new(); let logger = DedupLogger::new();
@@ -18,114 +152,190 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(|()| log::set_max_level(LevelFilter::Trace)) .map(|()| log::set_max_level(LevelFilter::Trace))
.unwrap(); .unwrap();
info!("🎮 Cursebreaker - XML Parser"); let args = Args::parse();
info!("📚 Loading game data from XML...");
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string()); // If no parsers selected, show help
if args.none_selected() {
eprintln!("No parsers selected. Use --all to parse everything, or specify individual parsers.");
eprintln!("Run with --help for usage information.");
std::process::exit(1);
}
// Load items from XML info!("Cursebreaker - XML Parser");
let items_path = format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path); info!("Loading game data from XML...");
let item_db = ItemDatabase::load_from_xml(items_path)?;
info!("✅ Loaded {} items", item_db.len());
// let npcs_path = format!("{}/Data/XMLs/Npcs/NPCInfo.xml", cb_assets_path); let cb_assets_path = env::var("CB_ASSETS_PATH")
// let npc_db = NpcDatabase::load_from_xml(npcs_path)?; .unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
// info!("✅ Loaded {} NPCs", npc_db.len()); let database_url = env::var("DATABASE_URL")
.unwrap_or_else(|_| "cursebreaker.db".to_string());
// let quests_path = format!("{}/Data/XMLs/Quests/Quests.xml", cb_assets_path);
// let quest_db = QuestDatabase::load_from_xml(quests_path)?;
// info!("✅ Loaded {} quests", quest_db.len());
let harvestables_path = format!("{}/Data/XMLs/Harvestables/HarvestableInfo.xml", cb_assets_path);
let harvestable_db = HarvestableDatabase::load_from_xml(harvestables_path)?;
info!("✅ Loaded {} harvestables", harvestable_db.len());
// let loot_path = format!("{}/Data/XMLs/Loot/Loot.xml", cb_assets_path);
// let loot_db = LootDatabase::load_from_xml(loot_path)?;
// info!("✅ Loaded {} loot tables", loot_db.len());
// let maps_path = format!("{}/Data/XMLs/Maps/Maps.xml", cb_assets_path);
// let map_db = MapDatabase::load_from_xml(maps_path)?;
// info!("✅ Loaded {} maps", map_db.len());
// let fast_travel_dir = format!("{}/Data/XMLs", cb_assets_path);
// let fast_travel_db = FastTravelDatabase::load_from_directory(fast_travel_dir)?;
// info!("✅ Loaded {} fast travel locations", fast_travel_db.len());
// let player_houses_path = format!("{}/Data/XMLs/PlayerHouses/PlayerHouses.xml", cb_assets_path);
// let player_house_db = PlayerHouseDatabase::load_from_xml(player_houses_path)?;
// info!("✅ Loaded {} player houses", player_house_db.len());
// let traits_path = format!("{}/Data/XMLs/Traits/Traits.xml", cb_assets_path);
// let trait_db = TraitDatabase::load_from_xml(traits_path)?;
// info!("✅ Loaded {} traits", trait_db.len());
// let shops_path = format!("{}/Data/XMLs/Shops/Shops.xml", cb_assets_path);
// let shop_db = ShopDatabase::load_from_xml(shops_path)?;
// info!("✅ Loaded {} shops", shop_db.len());
// Save to SQLite database
info!("\n💾 Saving game data to SQLite database...");
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?; let mut conn = SqliteConnection::establish(&database_url)?;
// Process and save items with icons // Parse Items
let icon_path = format!("{}/Data/Textures/ItemIcons", cb_assets_path); if args.should_parse_items() {
info!("📸 Processing item icons from: {}", icon_path); info!("Parsing items...");
let items_path = format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path);
match item_db.save_to_db_with_images(&mut conn, &icon_path) { match ItemDatabase::load_from_xml(&items_path) {
Ok((items_count, images_count)) => { Ok(item_db) => {
info!("✅ Saved {} items to database", items_count); info!("Loaded {} items", item_db.len());
info!("✅ Processed {} item icons (256px, 64px, 16px)", images_count); let icon_path = format!("{}/Data/Textures/ItemIcons", cb_assets_path);
match item_db.save_to_db_with_images(&mut conn, &icon_path) {
Ok((items_count, images_count)) => {
info!("Saved {} items to database", items_count);
info!("Processed {} item icons", images_count);
}
Err(e) => warn!("Failed to save items: {}", e),
}
}
Err(e) => warn!("Failed to load items: {}", e),
} }
Err(e) => warn!("⚠️ Failed to save items: {}", e),
} }
// match npc_db.save_to_db(&mut conn) { // Parse NPCs
// Ok(count) => info!("✅ Saved {} NPCs to database", count), if args.should_parse_npcs() {
// Err(e) => warn!("⚠️ Failed to save NPCs: {}", e), info!("Parsing NPCs...");
// } let npcs_path = format!("{}/Data/XMLs/Npcs/NPCInfo.xml", cb_assets_path);
match NpcDatabase::load_from_xml(&npcs_path) {
// match quest_db.save_to_db(&mut conn) { Ok(npc_db) => {
// Ok(count) => info!("✅ Saved {} quests to database", count), info!("Loaded {} NPCs", npc_db.len());
// Err(e) => warn!("⚠️ Failed to save quests: {}", e), match npc_db.save_to_db(&mut conn) {
// } Ok(count) => info!("Saved {} NPCs to database", count),
Err(e) => warn!("Failed to save NPCs: {}", e),
match harvestable_db.save_to_db(&mut conn) { }
Ok(count) => info!("✅ Saved {} harvestables to database", count), }
Err(e) => warn!("⚠️ Failed to save harvestables: {}", e), Err(e) => warn!("Failed to load NPCs: {}", e),
}
} }
// match loot_db.save_to_db(&mut conn) { // Parse Quests
// Ok(count) => info!("✅ Saved {} loot tables to database", count), if args.should_parse_quests() {
// Err(e) => warn!("⚠️ Failed to save loot tables: {}", e), info!("Parsing quests...");
// } let quests_path = format!("{}/Data/XMLs/Quests/Quests.xml", cb_assets_path);
match QuestDatabase::load_from_xml(&quests_path) {
Ok(quest_db) => {
info!("Loaded {} quests", quest_db.len());
match quest_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} quests to database", count),
Err(e) => warn!("Failed to save quests: {}", e),
}
}
Err(e) => warn!("Failed to load quests: {}", e),
}
}
// match map_db.save_to_db(&mut conn) { // Parse Harvestables
// Ok(count) => info!("✅ Saved {} maps to database", count), if args.should_parse_harvestables() {
// Err(e) => warn!("⚠️ Failed to save maps: {}", e), info!("Parsing harvestables...");
// } let harvestables_path = format!("{}/Data/XMLs/Harvestables/HarvestableInfo.xml", cb_assets_path);
match HarvestableDatabase::load_from_xml(&harvestables_path) {
Ok(harvestable_db) => {
info!("Loaded {} harvestables", harvestable_db.len());
match harvestable_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} harvestables to database", count),
Err(e) => warn!("Failed to save harvestables: {}", e),
}
}
Err(e) => warn!("Failed to load harvestables: {}", e),
}
}
// match fast_travel_db.save_to_db(&mut conn) { // Parse Loot
// Ok(count) => info!("✅ Saved {} fast travel locations to database", count), if args.should_parse_loot() {
// Err(e) => warn!("⚠️ Failed to save fast travel locations: {}", e), info!("Parsing loot tables...");
// } let loot_path = format!("{}/Data/XMLs/Loot/Loot.xml", cb_assets_path);
match LootDatabase::load_from_xml(&loot_path) {
Ok(loot_db) => {
info!("Loaded {} loot tables", loot_db.len());
match loot_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} loot tables to database", count),
Err(e) => warn!("Failed to save loot tables: {}", e),
}
}
Err(e) => warn!("Failed to load loot tables: {}", e),
}
}
// match player_house_db.save_to_db(&mut conn) { // Parse Maps
// Ok(count) => info!("✅ Saved {} player houses to database", count), if args.should_parse_maps() {
// Err(e) => warn!("⚠️ Failed to save player houses: {}", e), info!("Parsing maps...");
// } let maps_path = format!("{}/Data/XMLs/Maps/Maps.xml", cb_assets_path);
match MapDatabase::load_from_xml(&maps_path) {
Ok(map_db) => {
info!("Loaded {} maps", map_db.len());
match map_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} maps to database", count),
Err(e) => warn!("Failed to save maps: {}", e),
}
}
Err(e) => warn!("Failed to load maps: {}", e),
}
}
// match trait_db.save_to_db(&mut conn) { // Parse Fast Travel
// Ok(count) => info!("✅ Saved {} traits to database", count), if args.should_parse_fast_travel() {
// Err(e) => warn!("⚠️ Failed to save traits: {}", e), info!("Parsing fast travel locations...");
// } let fast_travel_dir = format!("{}/Data/XMLs", cb_assets_path);
match FastTravelDatabase::load_from_directory(&fast_travel_dir) {
Ok(fast_travel_db) => {
info!("Loaded {} fast travel locations", fast_travel_db.len());
match fast_travel_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} fast travel locations to database", count),
Err(e) => warn!("Failed to save fast travel locations: {}", e),
}
}
Err(e) => warn!("Failed to load fast travel locations: {}", e),
}
}
// match shop_db.save_to_db(&mut conn) { // Parse Player Houses
// Ok(count) => info!("✅ Saved {} shops to database", count), if args.should_parse_houses() {
// Err(e) => warn!("⚠️ Failed to save shops: {}", e), info!("Parsing player houses...");
// } let player_houses_path = format!("{}/Data/XMLs/PlayerHouses/PlayerHouses.xml", cb_assets_path);
match PlayerHouseDatabase::load_from_xml(&player_houses_path) {
Ok(player_house_db) => {
info!("Loaded {} player houses", player_house_db.len());
match player_house_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} player houses to database", count),
Err(e) => warn!("Failed to save player houses: {}", e),
}
}
Err(e) => warn!("Failed to load player houses: {}", e),
}
}
// Parse Traits
if args.should_parse_traits() {
info!("Parsing traits...");
let traits_path = format!("{}/Data/XMLs/Traits/Traits.xml", cb_assets_path);
match TraitDatabase::load_from_xml(&traits_path) {
Ok(trait_db) => {
info!("Loaded {} traits", trait_db.len());
match trait_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} traits to database", count),
Err(e) => warn!("Failed to save traits: {}", e),
}
}
Err(e) => warn!("Failed to load traits: {}", e),
}
}
// Parse Shops
if args.should_parse_shops() {
info!("Parsing shops...");
let shops_path = format!("{}/Data/XMLs/Shops/Shops.xml", cb_assets_path);
match ShopDatabase::load_from_xml(&shops_path) {
Ok(shop_db) => {
info!("Loaded {} shops", shop_db.len());
match shop_db.save_to_db(&mut conn) {
Ok(count) => info!("Saved {} shops to database", count),
Err(e) => warn!("Failed to save shops: {}", e),
}
}
Err(e) => warn!("Failed to load shops: {}", e),
}
}
info!("XML parsing complete!");
log::logger().flush(); log::logger().flush();
Ok(()) Ok(())

View File

@@ -1,5 +1,5 @@
use crate::types::{FastTravelLocation, FastTravelType}; use crate::types::{FastTravelLocation, FastTravelType};
use crate::xml_parser::{ use crate::xml_parsers::{
parse_fast_travel_canoe_xml, parse_fast_travel_locations_xml, parse_fast_travel_portals_xml, parse_fast_travel_canoe_xml, parse_fast_travel_locations_xml, parse_fast_travel_portals_xml,
XmlParseError, XmlParseError,
}; };
@@ -236,44 +236,25 @@ impl FastTravelDatabase {
self.locations.is_empty() self.locations.is_empty()
} }
/// Prepare fast travel locations for SQL insertion (deprecated - use save_to_db instead)
#[deprecated(note = "Use save_to_db() to save directly to SQLite database")]
pub fn prepare_for_sql(&self) -> Vec<(i32, String, String, String)> {
self.locations
.iter()
.map(|location| {
let json =
serde_json::to_string(location).unwrap_or_else(|_| "{}".to_string());
(
location.id,
location.name.clone(),
location.travel_type.to_string(),
json,
)
})
.collect()
}
/// Save all fast travel locations to SQLite database /// Save all fast travel locations to SQLite database
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> { pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
use crate::schema::fast_travel_locations; use crate::schema::fast_travel_locations;
let records: Vec<_> = self // Clear existing entries
.locations diesel::delete(fast_travel_locations::table).execute(conn)?;
.iter()
.map(|location| {
let json = serde_json::to_string(location).unwrap_or_else(|_| "{}".to_string());
(
fast_travel_locations::id.eq(location.id),
fast_travel_locations::name.eq(&location.name),
fast_travel_locations::map_name.eq(""), // TODO: determine actual map name
fast_travel_locations::data.eq(json),
)
})
.collect();
let mut count = 0; let mut count = 0;
for record in records { for location in &self.locations {
let record = (
fast_travel_locations::name.eq(&location.name),
fast_travel_locations::pos_x.eq(location.pos_x),
fast_travel_locations::pos_z.eq(location.pos_z),
fast_travel_locations::travel_type.eq(location.travel_type.to_string()),
fast_travel_locations::unlocked.eq(if location.unlocked { 1 } else { 0 }),
fast_travel_locations::connections.eq(&location.connections),
fast_travel_locations::checks.eq(&location.checks),
);
diesel::insert_into(fast_travel_locations::table) diesel::insert_into(fast_travel_locations::table)
.values(&record) .values(&record)
.execute(conn)?; .execute(conn)?;
@@ -288,20 +269,40 @@ impl FastTravelDatabase {
use crate::schema::fast_travel_locations::dsl::*; use crate::schema::fast_travel_locations::dsl::*;
#[derive(Queryable)] #[derive(Queryable)]
#[allow(dead_code)]
struct FastTravelLocationRecord { struct FastTravelLocationRecord {
id: Option<i32>, name: Option<String>,
name: String, pos_x: f32,
map_name: String, pos_z: f32,
data: String, travel_type: String,
unlocked: i32,
connections: Option<String>,
checks: Option<String>,
} }
let records = fast_travel_locations.load::<FastTravelLocationRecord>(conn)?; let records = fast_travel_locations.load::<FastTravelLocationRecord>(conn)?;
let mut loaded_locations = Vec::new(); let mut loaded_locations = Vec::new();
for record in records { for record in records {
if let Ok(location) = serde_json::from_str::<FastTravelLocation>(&record.data) { let travel_type_enum = match record.travel_type.as_str() {
loaded_locations.push(location); "Location" => FastTravelType::Location,
} "Canoe" => FastTravelType::Canoe,
"Portal" => FastTravelType::Portal,
_ => FastTravelType::Location, // Default fallback
};
let mut location = FastTravelLocation::new(
0, // id not stored in DB
record.name.unwrap_or_default(),
record.pos_x,
record.pos_z,
travel_type_enum,
);
location.unlocked = record.unlocked != 0;
location.connections = record.connections;
location.checks = record.checks;
loaded_locations.push(location);
} }
let mut db = Self::new(); let mut db = Self::new();

View File

@@ -1,5 +1,5 @@
use crate::types::Harvestable; use crate::types::Harvestable;
use crate::xml_parser::{parse_harvestables_xml, XmlParseError}; use crate::xml_parsers::{parse_harvestables_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -1,4 +1,7 @@
use crate::types::{IconCategory, NewIcon, NewAchievementIcon, NewGeneralIcon}; use crate::types::{
NewAbilityIcon, NewBuffIcon, NewTraitIcon, NewPlayerHouseIcon, NewStatIcon,
NewAchievementIcon, NewGeneralIcon
};
use crate::image_processor::ImageProcessor; use crate::image_processor::ImageProcessor;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
@@ -68,64 +71,55 @@ impl IconDatabase {
let textures = base.join("Data/Textures"); let textures = base.join("Data/Textures");
let mut stats = IconStats::default(); let mut stats = IconStats::default();
// 1. Simple icons (original size only)
info!("Loading ability icons..."); info!("Loading ability icons...");
stats.abilities = self.load_simple_icons( stats.abilities = self.load_ability_icons(
&textures.join("Abilities"), &textures.join("Abilities"),
IconCategory::Ability,
&mut stats.total_bytes, &mut stats.total_bytes,
)?; )?;
info!("Loading buff icons..."); info!("Loading buff icons...");
stats.buffs = self.load_simple_icons( stats.buffs = self.load_buff_icons(
&textures.join("Buffs"), &textures.join("Buffs"),
IconCategory::Buff,
&mut stats.total_bytes, &mut stats.total_bytes,
)?; )?;
info!("Loading trait icons..."); info!("Loading trait icons...");
stats.traits = self.load_simple_icons( stats.traits = self.load_trait_icons(
&textures.join("Traits"), &textures.join("Traits"),
IconCategory::Trait,
&mut stats.total_bytes, &mut stats.total_bytes,
)?; )?;
info!("Loading player house icons..."); info!("Loading player house icons...");
stats.player_houses = self.load_simple_icons( stats.player_houses = self.load_player_house_icons(
&textures.join("PlayerHouses/Houses"), &textures.join("PlayerHouses/Houses"),
IconCategory::PlayerHouse,
&mut stats.total_bytes, &mut stats.total_bytes,
)?; )?;
info!("Loading stat icons..."); info!("Loading stat icons...");
stats.stat_icons = self.load_simple_icons( stats.stat_icons = self.load_stat_icons(
&textures.join("StatIcons"), &textures.join("StatIcons"),
IconCategory::StatIcon,
&mut stats.total_bytes, &mut stats.total_bytes,
)?; )?;
// 2. Achievement icons (filtered, no _0 suffix)
info!("Loading achievement icons..."); info!("Loading achievement icons...");
stats.achievement_icons = self.load_achievement_icons( stats.achievement_icons = self.load_achievement_icons(
&textures.join("Achievements/Icons"), &textures.join("Achievements/Icons"),
&mut stats.total_bytes, &mut stats.total_bytes,
)?; )?;
// 3. General icons (multi-size)
info!("Loading general icons..."); info!("Loading general icons...");
stats.general_icons = self.load_general_icons(&textures, &mut stats.total_bytes)?; stats.general_icons = self.load_general_icons(&textures, &mut stats.total_bytes)?;
Ok(stats) Ok(stats)
} }
/// Load simple icons from a directory (original size only, converted to WebP) /// Load ability icons from a directory
fn load_simple_icons<P: AsRef<Path>>( fn load_ability_icons<P: AsRef<Path>>(
&self, &self,
dir: P, dir: P,
category: IconCategory,
total_bytes: &mut usize, total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> { ) -> Result<usize, IconDatabaseError> {
use crate::schema::icons; use crate::schema::ability_icons;
let dir_path = dir.as_ref(); let dir_path = dir.as_ref();
if !dir_path.exists() { if !dir_path.exists() {
@@ -148,7 +142,6 @@ impl IconDatabase {
continue; continue;
} }
// Load and encode as lossless WebP
let img = image::open(&path)?; let img = image::open(&path)?;
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();
let webp_data = ImageProcessor::encode_webp_lossless(&rgba) let webp_data = ImageProcessor::encode_webp_lossless(&rgba)
@@ -156,20 +149,227 @@ impl IconDatabase {
*total_bytes += webp_data.len(); *total_bytes += webp_data.len();
let new_icon = NewIcon { let new_icon = NewAbilityIcon {
category: category.as_str(),
name: &name, name: &name,
icon: &webp_data, icon: &webp_data,
}; };
diesel::replace_into(icons::table) diesel::replace_into(ability_icons::table)
.values(&new_icon) .values(&new_icon)
.execute(&mut conn)?; .execute(&mut conn)?;
count += 1; count += 1;
} }
info!(" Loaded {} {} icons", count, category.as_str()); info!(" Loaded {} ability icons", count);
Ok(count)
}
/// Load buff icons from a directory
fn load_buff_icons<P: AsRef<Path>>(
&self,
dir: P,
total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> {
use crate::schema::buff_icons;
let dir_path = dir.as_ref();
if !dir_path.exists() {
warn!("Directory does not exist: {}", dir_path.display());
return Ok(0);
}
let mut conn = self.establish_connection()?;
let mut count = 0;
let image_files = self.find_image_files(dir_path)?;
for path in image_files {
let name = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let img = image::open(&path)?;
let rgba = img.to_rgba8();
let webp_data = ImageProcessor::encode_webp_lossless(&rgba)
.map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?;
*total_bytes += webp_data.len();
let new_icon = NewBuffIcon {
name: &name,
icon: &webp_data,
};
diesel::replace_into(buff_icons::table)
.values(&new_icon)
.execute(&mut conn)?;
count += 1;
}
info!(" Loaded {} buff icons", count);
Ok(count)
}
/// Load trait icons from a directory
fn load_trait_icons<P: AsRef<Path>>(
&self,
dir: P,
total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> {
use crate::schema::trait_icons;
let dir_path = dir.as_ref();
if !dir_path.exists() {
warn!("Directory does not exist: {}", dir_path.display());
return Ok(0);
}
let mut conn = self.establish_connection()?;
let mut count = 0;
let image_files = self.find_image_files(dir_path)?;
for path in image_files {
let name = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let img = image::open(&path)?;
let rgba = img.to_rgba8();
let webp_data = ImageProcessor::encode_webp_lossless(&rgba)
.map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?;
*total_bytes += webp_data.len();
let new_icon = NewTraitIcon {
name: &name,
icon: &webp_data,
};
diesel::replace_into(trait_icons::table)
.values(&new_icon)
.execute(&mut conn)?;
count += 1;
}
info!(" Loaded {} trait icons", count);
Ok(count)
}
/// Load player house icons from a directory
fn load_player_house_icons<P: AsRef<Path>>(
&self,
dir: P,
total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> {
use crate::schema::player_house_icons;
let dir_path = dir.as_ref();
if !dir_path.exists() {
warn!("Directory does not exist: {}", dir_path.display());
return Ok(0);
}
let mut conn = self.establish_connection()?;
let mut count = 0;
let image_files = self.find_image_files(dir_path)?;
for path in image_files {
let name = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let img = image::open(&path)?;
let rgba = img.to_rgba8();
let webp_data = ImageProcessor::encode_webp_lossless(&rgba)
.map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?;
*total_bytes += webp_data.len();
let new_icon = NewPlayerHouseIcon {
name: &name,
icon: &webp_data,
};
diesel::replace_into(player_house_icons::table)
.values(&new_icon)
.execute(&mut conn)?;
count += 1;
}
info!(" Loaded {} player house icons", count);
Ok(count)
}
/// Load stat icons from a directory
fn load_stat_icons<P: AsRef<Path>>(
&self,
dir: P,
total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> {
use crate::schema::stat_icons;
let dir_path = dir.as_ref();
if !dir_path.exists() {
warn!("Directory does not exist: {}", dir_path.display());
return Ok(0);
}
let mut conn = self.establish_connection()?;
let mut count = 0;
let image_files = self.find_image_files(dir_path)?;
for path in image_files {
let name = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let img = image::open(&path)?;
let rgba = img.to_rgba8();
let webp_data = ImageProcessor::encode_webp_lossless(&rgba)
.map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?;
*total_bytes += webp_data.len();
let new_icon = NewStatIcon {
name: &name,
icon: &webp_data,
};
diesel::replace_into(stat_icons::table)
.values(&new_icon)
.execute(&mut conn)?;
count += 1;
}
info!(" Loaded {} stat icons", count);
Ok(count) Ok(count)
} }
@@ -288,7 +488,11 @@ impl IconDatabase {
("Inventory/Banknote.png", "Inventory_Banknote"), ("Inventory/Banknote.png", "Inventory_Banknote"),
("Minimap/ShowCoordinates.png", "Minimap_ShowCoordinates"), ("Minimap/ShowCoordinates.png", "Minimap_ShowCoordinates"),
("SplashScreens/Olipa.png", "SplashScreens_Olipa"), ("SplashScreens/Olipa.png", "SplashScreens_Olipa"),
("ItemIcons/131.png", "SplashScreens_Olipa"), ("ItemIcons/131.png", "Coins"),
("118.png", "Map"),
("124.png", "Entrance"),
("Bug.png", "Bug"),
("Checkmark.png", "Checkmark"),
]; ];
for (file, name) in individual_files { for (file, name) in individual_files {

View File

@@ -3,7 +3,7 @@ use crate::item_loader::{
calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory, calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory,
}; };
use crate::types::Item; use crate::types::Item;
use crate::xml_parser::{parse_items_xml, XmlParseError}; use crate::xml_parsers::{parse_items_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -517,16 +517,15 @@ impl ItemDatabase {
item_id: i32, item_id: i32,
) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) { ) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) {
// Try both lowercase and uppercase extensions (Linux is case-sensitive) // Try both lowercase and uppercase extensions (Linux is case-sensitive)
let icon_file = icon_base_path.join(format!("{}.png", item_id)); let lowercase = icon_base_path.join(format!("{}.png", item_id));
let icon_file = if icon_file.exists() { let uppercase = icon_base_path.join(format!("{}.PNG", item_id));
icon_file
let icon_file = if lowercase.exists() {
lowercase
} else if uppercase.exists() {
uppercase
} else { } else {
let uppercase = icon_base_path.join(format!("{}.PNG", item_id)); return (None, None, None);
if uppercase.exists() {
uppercase
} else {
return (None, None, None);
}
}; };
// Process image at 3 sizes: 256, 64, 16 // Process image at 3 sizes: 256, 64, 16

View File

@@ -1,5 +1,5 @@
use crate::types::{LootTable, LootDrop}; use crate::types::{LootTable, LootDrop};
use crate::xml_parser::{parse_loot_xml, XmlParseError}; use crate::xml_parsers::{parse_loot_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -1,5 +1,5 @@
use crate::types::Map; use crate::types::Map;
use crate::xml_parser::{parse_maps_xml, XmlParseError}; use crate::xml_parsers::{parse_maps_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -1,5 +1,5 @@
use crate::types::Npc; use crate::types::Npc;
use crate::xml_parser::{parse_npcs_xml, XmlParseError}; use crate::xml_parsers::{parse_npcs_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -1,5 +1,5 @@
use crate::types::PlayerHouse; use crate::types::PlayerHouse;
use crate::xml_parser::{parse_player_houses_xml, XmlParseError}; use crate::xml_parsers::{parse_player_houses_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;
@@ -76,16 +76,6 @@ impl PlayerHouseDatabase {
&self.houses &self.houses
} }
/// Get all visible houses (not hidden)
pub fn get_visible_houses(&self) -> Vec<&PlayerHouse> {
self.houses.iter().filter(|h| h.is_visible()).collect()
}
/// Get all hidden houses
pub fn get_hidden_houses(&self) -> Vec<&PlayerHouse> {
self.houses.iter().filter(|h| h.hidden).collect()
}
/// Get all free houses (price is 0) /// Get all free houses (price is 0)
pub fn get_free_houses(&self) -> Vec<&PlayerHouse> { pub fn get_free_houses(&self) -> Vec<&PlayerHouse> {
self.houses.iter().filter(|h| h.is_free()).collect() self.houses.iter().filter(|h| h.is_free()).collect()
@@ -152,32 +142,24 @@ impl PlayerHouseDatabase {
self.houses.is_empty() self.houses.is_empty()
} }
/// Prepare player houses for SQL insertion (deprecated - use save_to_db instead) /// Save all player houses to SQLite database (clears existing entries first)
#[deprecated(note = "Use save_to_db() to save directly to SQLite database")]
pub fn prepare_for_sql(&self) -> Vec<(i32, String, i32, String)> {
self.houses
.iter()
.map(|house| {
let json = serde_json::to_string(house).unwrap_or_else(|_| "{}".to_string());
(house.id, house.name.clone(), house.price, json)
})
.collect()
}
/// Save all player houses to SQLite database
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> { pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
use crate::schema::player_houses; use crate::schema::player_houses;
// Clear existing entries
diesel::delete(player_houses::table).execute(conn)?;
let records: Vec<_> = self let records: Vec<_> = self
.houses .houses
.iter() .iter()
.map(|house| { .map(|house| {
let json = serde_json::to_string(house).unwrap_or_else(|_| "{}".to_string());
( (
player_houses::id.eq(house.id), player_houses::id.eq(house.id),
player_houses::name.eq(&house.name), player_houses::name.eq(&house.name),
player_houses::map_id.eq(0), // TODO: determine actual map ID player_houses::description.eq(&house.description),
player_houses::data.eq(json), player_houses::pos_x.eq(house.pos_x),
player_houses::pos_z.eq(house.pos_z),
player_houses::price.eq(house.price),
) )
}) })
.collect(); .collect();
@@ -199,20 +181,31 @@ impl PlayerHouseDatabase {
#[derive(Queryable)] #[derive(Queryable)]
struct PlayerHouseRecord { struct PlayerHouseRecord {
id: Option<i32>, record_id: Option<i32>,
name: String, name: String,
map_id: i32, description: String,
data: String, pos_x: f32,
pos_z: f32,
price: i32,
} }
let records = player_houses.load::<PlayerHouseRecord>(conn)?; let records = player_houses.load::<PlayerHouseRecord>(conn)?;
let mut loaded_houses = Vec::new(); let loaded_houses: Vec<PlayerHouse> = records
for record in records { .into_iter()
if let Ok(house) = serde_json::from_str::<PlayerHouse>(&record.data) { .filter_map(|record| {
loaded_houses.push(house); record.record_id.map(|house_id| {
} PlayerHouse::new(
} house_id,
record.name,
record.description,
record.pos_x,
record.pos_z,
record.price,
)
})
})
.collect();
let mut db = Self::new(); let mut db = Self::new();
db.add_houses(loaded_houses); db.add_houses(loaded_houses);

View File

@@ -1,5 +1,5 @@
use crate::types::Quest; use crate::types::Quest;
use crate::xml_parser::{parse_quests_xml, XmlParseError}; use crate::xml_parsers::{parse_quests_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -1,5 +1,5 @@
use crate::types::Shop; use crate::types::Shop;
use crate::xml_parser::{parse_shops_xml, XmlParseError}; use crate::xml_parsers::{parse_shops_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -1,5 +1,5 @@
use crate::types::Trait; use crate::types::Trait;
use crate::xml_parser::{parse_traits_xml, XmlParseError}; use crate::xml_parsers::{parse_traits_xml, XmlParseError};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -3,7 +3,7 @@ use crate::types::{
ItemCategory, ItemType, ItemXpBoost, PermanentStatBoost, SkillType, Stat, StatType, ItemCategory, ItemType, ItemXpBoost, PermanentStatBoost, SkillType, Stat, StatType,
Tool, MAX_STACK, Tool, MAX_STACK,
}; };
use crate::xml_parser::XmlParseError; use crate::xml_parsers::XmlParseError;
use quick_xml::events::Event; use quick_xml::events::Event;
use quick_xml::reader::Reader; use quick_xml::reader::Reader;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};

View File

@@ -52,7 +52,7 @@
pub mod types; pub mod types;
pub mod databases; pub mod databases;
pub mod schema; pub mod schema;
mod xml_parser; mod xml_parsers;
mod item_loader; mod item_loader;
mod image_processor; mod image_processor;
@@ -128,13 +128,20 @@ pub use types::{
MinimapTileRecord, MinimapTileRecord,
NewMinimapTile, NewMinimapTile,
// Icons // Icons
IconCategory, AbilityIconRecord,
IconRecord, NewAbilityIcon,
NewIcon, BuffIconRecord,
NewBuffIcon,
TraitIconRecord,
NewTraitIcon,
PlayerHouseIconRecord,
NewPlayerHouseIcon,
StatIconRecord,
NewStatIcon,
AchievementIconRecord, AchievementIconRecord,
NewAchievementIcon, NewAchievementIcon,
GeneralIconRecord, GeneralIconRecord,
NewGeneralIcon, NewGeneralIcon,
}; };
pub use xml_parser::XmlParseError; pub use xml_parsers::XmlParseError;
pub use image_processor::{ImageProcessor, ImageProcessingError, ProcessedImages, OutlineConfig}; pub use image_processor::{ImageProcessor, ImageProcessingError, ProcessedImages, OutlineConfig};

View File

@@ -1,5 +1,12 @@
// @generated automatically by Diesel CLI. // @generated automatically by Diesel CLI.
diesel::table! {
ability_icons (name) {
name -> Text,
icon -> Binary,
}
}
diesel::table! { diesel::table! {
achievement_icons (name) { achievement_icons (name) {
name -> Text, name -> Text,
@@ -7,6 +14,13 @@ diesel::table! {
} }
} }
diesel::table! {
buff_icons (name) {
name -> Text,
icon -> Binary,
}
}
diesel::table! { diesel::table! {
crafting_recipe_items (recipe_id, item_id) { crafting_recipe_items (recipe_id, item_id) {
recipe_id -> Integer, recipe_id -> Integer,
@@ -29,11 +43,14 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
fast_travel_locations (id) { fast_travel_locations (name) {
id -> Nullable<Integer>, name -> Nullable<Text>,
name -> Text, pos_x -> Float,
map_name -> Text, pos_z -> Float,
data -> Text, travel_type -> Text,
unlocked -> Integer,
connections -> Nullable<Text>,
checks -> Nullable<Text>,
} }
} }
@@ -80,14 +97,6 @@ diesel::table! {
} }
} }
diesel::table! {
icons (category, name) {
category -> Text,
name -> Text,
icon -> Binary,
}
}
diesel::table! { diesel::table! {
item_stats (item_id, stat_type) { item_stats (item_id, stat_type) {
item_id -> Integer, item_id -> Integer,
@@ -168,12 +177,21 @@ diesel::table! {
} }
} }
diesel::table! {
player_house_icons (name) {
name -> Text,
icon -> Binary,
}
}
diesel::table! { diesel::table! {
player_houses (id) { player_houses (id) {
id -> Nullable<Integer>, id -> Nullable<Integer>,
name -> Text, name -> Text,
map_id -> Integer, description -> Text,
data -> Text, pos_x -> Float,
pos_z -> Float,
price -> Integer,
} }
} }
@@ -203,6 +221,20 @@ diesel::table! {
} }
} }
diesel::table! {
stat_icons (name) {
name -> Text,
icon -> Binary,
}
}
diesel::table! {
trait_icons (name) {
name -> Text,
icon -> Binary,
}
}
diesel::table! { diesel::table! {
traits (id) { traits (id) {
id -> Nullable<Integer>, id -> Nullable<Integer>,
@@ -278,24 +310,28 @@ diesel::joinable!(harvestable_drops -> items (item_id));
diesel::joinable!(item_stats -> items (item_id)); diesel::joinable!(item_stats -> items (item_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
ability_icons,
achievement_icons, achievement_icons,
buff_icons,
crafting_recipe_items, crafting_recipe_items,
crafting_recipes, crafting_recipes,
fast_travel_locations, fast_travel_locations,
general_icons, general_icons,
harvestable_drops, harvestable_drops,
harvestables, harvestables,
icons,
item_stats, item_stats,
items, items,
loot_tables, loot_tables,
maps, maps,
minimap_tiles, minimap_tiles,
npcs, npcs,
player_house_icons,
player_houses, player_houses,
quests, quests,
resource_icons, resource_icons,
shops, shops,
stat_icons,
trait_icons,
traits, traits,
world_loot, world_loot,
world_map_icons, world_map_icons,

View File

@@ -30,8 +30,11 @@ pub struct FastTravelLocation {
/// Display name /// Display name
pub name: String, pub name: String,
/// 3D position in world space (x,y,z) /// X position in world space
pub position: String, pub pos_x: f32,
/// Z position in world space
pub pos_z: f32,
/// Type of fast travel /// Type of fast travel
pub travel_type: FastTravelType, pub travel_type: FastTravelType,
@@ -49,11 +52,12 @@ pub struct FastTravelLocation {
impl FastTravelLocation { impl FastTravelLocation {
/// Create a new FastTravelLocation with required fields /// Create a new FastTravelLocation with required fields
pub fn new(id: i32, name: String, position: String, travel_type: FastTravelType) -> Self { pub fn new(id: i32, name: String, pos_x: f32, pos_z: f32, travel_type: FastTravelType) -> Self {
Self { Self {
id, id,
name, name,
position, pos_x,
pos_z,
travel_type, travel_type,
unlocked: false, unlocked: false,
connections: None, connections: None,
@@ -61,19 +65,9 @@ impl FastTravelLocation {
} }
} }
/// Parse position into (x, y, z) coordinates /// Get position as (x, z) tuple
pub fn get_position(&self) -> Option<(f32, f32, f32)> { pub fn get_position(&self) -> (f32, f32) {
let parts: Vec<&str> = self.position.split(',').collect(); (self.pos_x, self.pos_z)
if parts.len() == 3 {
if let (Ok(x), Ok(y), Ok(z)) = (
parts[0].parse::<f32>(),
parts[1].parse::<f32>(),
parts[2].parse::<f32>(),
) {
return Some((x, y, z));
}
}
None
} }
/// Get list of connected location IDs /// Get list of connected location IDs

View File

@@ -1,43 +1,90 @@
use diesel::prelude::*; use diesel::prelude::*;
use crate::schema::{icons, achievement_icons, general_icons}; use crate::schema::{
ability_icons, buff_icons, trait_icons, player_house_icons, stat_icons,
achievement_icons, general_icons
};
/// Icon category for the icons table /// Diesel queryable model for ability_icons table
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconCategory {
Ability,
Buff,
Trait,
PlayerHouse,
StatIcon,
}
impl IconCategory {
pub fn as_str(&self) -> &'static str {
match self {
IconCategory::Ability => "ability",
IconCategory::Buff => "buff",
IconCategory::Trait => "trait",
IconCategory::PlayerHouse => "player_house",
IconCategory::StatIcon => "stat_icon",
}
}
}
/// Diesel queryable model for icons table
#[derive(Queryable, Selectable, Debug, Clone)] #[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = icons)] #[diesel(table_name = ability_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct IconRecord { pub struct AbilityIconRecord {
pub category: String,
pub name: String, pub name: String,
pub icon: Vec<u8>, pub icon: Vec<u8>,
} }
/// Diesel insertable model for icons table /// Diesel insertable model for ability_icons table
#[derive(Insertable, Debug)] #[derive(Insertable, Debug)]
#[diesel(table_name = icons)] #[diesel(table_name = ability_icons)]
pub struct NewIcon<'a> { pub struct NewAbilityIcon<'a> {
pub category: &'a str, pub name: &'a str,
pub icon: &'a [u8],
}
/// Diesel queryable model for buff_icons table
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = buff_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct BuffIconRecord {
pub name: String,
pub icon: Vec<u8>,
}
/// Diesel insertable model for buff_icons table
#[derive(Insertable, Debug)]
#[diesel(table_name = buff_icons)]
pub struct NewBuffIcon<'a> {
pub name: &'a str,
pub icon: &'a [u8],
}
/// Diesel queryable model for trait_icons table
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = trait_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct TraitIconRecord {
pub name: String,
pub icon: Vec<u8>,
}
/// Diesel insertable model for trait_icons table
#[derive(Insertable, Debug)]
#[diesel(table_name = trait_icons)]
pub struct NewTraitIcon<'a> {
pub name: &'a str,
pub icon: &'a [u8],
}
/// Diesel queryable model for player_house_icons table
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = player_house_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct PlayerHouseIconRecord {
pub name: String,
pub icon: Vec<u8>,
}
/// Diesel insertable model for player_house_icons table
#[derive(Insertable, Debug)]
#[diesel(table_name = player_house_icons)]
pub struct NewPlayerHouseIcon<'a> {
pub name: &'a str,
pub icon: &'a [u8],
}
/// Diesel queryable model for stat_icons table
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = stat_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct StatIconRecord {
pub name: String,
pub icon: Vec<u8>,
}
/// Diesel insertable model for stat_icons table
#[derive(Insertable, Debug)]
#[diesel(table_name = stat_icons)]
pub struct NewStatIcon<'a> {
pub name: &'a str, pub name: &'a str,
pub icon: &'a [u8], pub icon: &'a [u8],
} }

View File

@@ -46,9 +46,16 @@ pub use shop::{Shop, ShopItem};
pub use minimap_tile::MinimapTile; pub use minimap_tile::MinimapTile;
pub use minimap_models::{MinimapTileRecord, NewMinimapTile}; pub use minimap_models::{MinimapTileRecord, NewMinimapTile};
pub use icon_models::{ pub use icon_models::{
IconCategory, AbilityIconRecord,
IconRecord, NewAbilityIcon,
NewIcon, BuffIconRecord,
NewBuffIcon,
TraitIconRecord,
NewTraitIcon,
PlayerHouseIconRecord,
NewPlayerHouseIcon,
StatIconRecord,
NewStatIcon,
AchievementIconRecord, AchievementIconRecord,
NewAchievementIcon, NewAchievementIcon,
GeneralIconRecord, GeneralIconRecord,

View File

@@ -12,42 +12,32 @@ pub struct PlayerHouse {
/// Description text /// Description text
pub description: String, pub description: String,
/// 3D position in world space (x,y,z) /// X position in world space
pub position: String, pub pos_x: f32,
/// Z position in world space
pub pos_z: f32,
/// Purchase price in gold /// Purchase price in gold
pub price: i32, pub price: i32,
/// Whether this house is hidden (not shown in normal lists)
pub hidden: bool,
} }
impl PlayerHouse { impl PlayerHouse {
/// Create a new PlayerHouse with required fields /// Create a new PlayerHouse with required fields
pub fn new(id: i32, name: String, description: String, position: String, price: i32) -> Self { pub fn new(id: i32, name: String, description: String, pos_x: f32, pos_z: f32, price: i32) -> Self {
Self { Self {
id, id,
name, name,
description, description,
position, pos_x,
pos_z,
price, price,
hidden: false,
} }
} }
/// Parse position into (x, y, z) coordinates /// Get position as (x, z) tuple
pub fn get_position(&self) -> Option<(f32, f32, f32)> { pub fn get_position(&self) -> (f32, f32) {
let parts: Vec<&str> = self.position.split(',').collect(); (self.pos_x, self.pos_z)
if parts.len() == 3 {
if let (Ok(x), Ok(y), Ok(z)) = (
parts[0].parse::<f32>(),
parts[1].parse::<f32>(),
parts[2].parse::<f32>(),
) {
return Some((x, y, z));
}
}
None
} }
/// Check if this house is free (price is 0) /// Check if this house is free (price is 0)
@@ -55,11 +45,6 @@ impl PlayerHouse {
self.price == 0 self.price == 0
} }
/// Check if this house is visible (not hidden)
pub fn is_visible(&self) -> bool {
!self.hidden
}
/// Check if this house is expensive (price >= 10000) /// Check if this house is expensive (price >= 10000)
pub fn is_expensive(&self) -> bool { pub fn is_expensive(&self) -> bool {
self.price >= 10000 self.price >= 10000

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
//! Fast Travel XML Parser
use crate::types::{FastTravelLocation, FastTravelType};
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
/// Parse FastTravelLocations.xml (regular fast travel locations)
pub fn parse_fast_travel_locations_xml<P: AsRef<Path>>(
path: P,
) -> Result<Vec<FastTravelLocation>, XmlParseError> {
parse_fast_travel_xml_internal(path, FastTravelType::Location)
}
/// Parse FastTravelCanoe.xml (canoe fast travel locations)
pub fn parse_fast_travel_canoe_xml<P: AsRef<Path>>(
path: P,
) -> Result<Vec<FastTravelLocation>, XmlParseError> {
parse_fast_travel_xml_internal(path, FastTravelType::Canoe)
}
/// Parse FastTravelPortals.xml (portal fast travel locations)
pub fn parse_fast_travel_portals_xml<P: AsRef<Path>>(
path: P,
) -> Result<Vec<FastTravelLocation>, XmlParseError> {
parse_fast_travel_xml_internal(path, FastTravelType::Portal)
}
/// Internal function to parse any fast travel XML file
fn parse_fast_travel_xml_internal<P: AsRef<Path>>(
path: P,
travel_type: FastTravelType,
) -> Result<Vec<FastTravelLocation>, 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 locations = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
match e.name().as_ref() {
b"location" => {
let attrs = parse_attributes(e)?;
// Get required attributes
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 position_str = attrs
.get("pos")
.ok_or_else(|| XmlParseError::MissingAttribute("pos".to_string()))?;
// Parse position "x,y,z" and extract x,z (discard y)
let (pos_x, pos_z) = parse_position(position_str)?;
let mut location = FastTravelLocation::new(id, name, pos_x, pos_z, travel_type);
// Parse optional attributes based on type
match travel_type {
FastTravelType::Location => {
// Regular locations have unlocked and connections
if attrs.get("unlocked").is_some() {
location.unlocked = true;
}
if let Some(v) = attrs.get("connections") {
location.connections = Some(v.clone());
}
}
FastTravelType::Canoe => {
// Canoe locations have checks
if let Some(v) = attrs.get("checks") {
location.checks = Some(v.clone());
}
}
FastTravelType::Portal => {
// Portals have no additional fields
}
}
locations.push(location);
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
buf.clear();
}
Ok(locations)
}
/// Parse position string "x,y,z" and return (x, z), discarding y
fn parse_position(pos: &str) -> Result<(f32, f32), XmlParseError> {
let parts: Vec<&str> = pos.split(',').collect();
if parts.len() != 3 {
return Err(XmlParseError::InvalidAttribute("pos".to_string()));
}
let x = parts[0]
.trim()
.parse::<f32>()
.map_err(|_| XmlParseError::InvalidAttribute("pos.x".to_string()))?;
let z = parts[2]
.trim()
.parse::<f32>()
.map_err(|_| XmlParseError::InvalidAttribute("pos.z".to_string()))?;
Ok((x, z))
}

View File

@@ -0,0 +1,127 @@
//! Harvestable XML Parser
use crate::types::{Harvestable, HarvestableDrop, SkillType, Tool};
use super::{parse_attributes, parse_health_range, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
pub fn parse_harvestables_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Harvestable>, 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 harvestables = Vec::new();
let mut buf = Vec::new();
let mut current_harvestable: Option<Harvestable> = 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"harvestable" => {
let attrs = parse_attributes(e)?;
let typeid = attrs.get("typeid")
.ok_or_else(|| XmlParseError::MissingAttribute("typeid".to_string()))?
.parse::<i32>()
.map_err(|_| XmlParseError::InvalidAttribute("typeid".to_string()))?;
let name = attrs.get("name")
.ok_or_else(|| XmlParseError::MissingAttribute("name".to_string()))?
.clone();
let mut harvestable = Harvestable::new(typeid, name);
// Parse optional attributes with defaults
if let Some(v) = attrs.get("actionname") { harvestable.actionname = v.clone(); }
if let Some(v) = attrs.get("desc") { harvestable.desc = v.clone(); }
if let Some(v) = attrs.get("comment") { harvestable.comment = v.clone(); }
if let Some(v) = attrs.get("level") { harvestable.level = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("skill") { harvestable.skill = v.parse().unwrap_or(SkillType::None); }
if let Some(v) = attrs.get("tool") { harvestable.tool = v.parse().unwrap_or(Tool::None); }
if let Some(v) = attrs.get("health") {
let (min, max) = parse_health_range(v);
harvestable.min_health = min;
harvestable.max_health = max;
}
if let Some(v) = attrs.get("harvesttime") { harvestable.harvesttime = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("hittime") { harvestable.hittime = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("respawntime") { harvestable.respawntime = v.parse().unwrap_or(0); }
// Audio (handle both cases: harvestSfx and harvestsfx)
if let Some(v) = attrs.get("harvestSfx").or_else(|| attrs.get("harvestsfx")) {
harvestable.harvestsfx = v.clone();
}
if let Some(v) = attrs.get("endSfx").or_else(|| attrs.get("endsfx")) {
harvestable.endsfx = v.clone();
}
if let Some(v) = attrs.get("receiveItemSfx").or_else(|| attrs.get("receiveitemsfx")) {
harvestable.receiveitemsfx = v.clone();
}
if let Some(v) = attrs.get("animation") { harvestable.animation = v.clone(); }
if let Some(v) = attrs.get("takehitanimation") { harvestable.takehitanimation = v.clone(); }
if let Some(v) = attrs.get("endgfx") { harvestable.endgfx = v.clone(); }
if let Some(v) = attrs.get("tree") { harvestable.tree = v.parse().unwrap_or(0) == 1; }
if let Some(v) = attrs.get("hidemilestone") { harvestable.hidemilestone = v.parse().unwrap_or(0) == 1; }
if let Some(v) = attrs.get("nohighlight") { harvestable.nohighlight = v.parse().unwrap_or(0) == 1; }
// Handle both cases: hideMinimap and hideminimap
if let Some(v) = attrs.get("hideMinimap").or_else(|| attrs.get("hideminimap")) {
harvestable.hideminimap = v.parse().unwrap_or(0) == 1;
}
if let Some(v) = attrs.get("noLeftClickInteract").or_else(|| attrs.get("noleftclickinteract")) {
harvestable.noleftclickinteract = v.parse().unwrap_or(0) == 1;
}
if let Some(v) = attrs.get("interactDistance").or_else(|| attrs.get("interactdistance")) {
harvestable.interactdistance = v.clone();
}
current_harvestable = Some(harvestable);
}
b"item" if current_harvestable.is_some() => {
if let Some(ref mut harvestable) = current_harvestable {
let attrs = parse_attributes(e)?;
if let Some(id_str) = attrs.get("id") {
if let Ok(id) = id_str.parse::<i32>() {
let drop = HarvestableDrop {
id,
minamount: attrs.get("minamount").and_then(|v| v.parse().ok()).unwrap_or(0),
maxamount: attrs.get("maxamount").and_then(|v| v.parse().ok()).unwrap_or(0),
droprate: attrs.get("droprate").and_then(|v| v.parse().ok()).unwrap_or(0),
droprateboost: attrs.get("droprateboost").and_then(|v| v.parse().ok()).unwrap_or(0),
amountboost: attrs.get("amountboost").and_then(|v| v.parse().ok()).unwrap_or(0),
checks: attrs.get("checks").cloned().unwrap_or_default(),
comment: attrs.get("comment").cloned().unwrap_or_default(),
dontconsumehealth: attrs.get("dontconsumehealth").and_then(|v| v.parse().ok()).unwrap_or(0) == 1,
};
harvestable.drops.push(drop);
}
}
}
}
_ => {}
}
}
Ok(Event::End(ref e)) => {
match e.name().as_ref() {
b"harvestable" => {
if let Some(harvestable) = current_harvestable.take() {
harvestables.push(harvestable);
}
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
buf.clear();
}
Ok(harvestables)
}

View File

@@ -0,0 +1,141 @@
//! Item XML Parser
use crate::types::{Item, ItemStat, AnimationSet};
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use std::collections::HashMap;
pub fn parse_items_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Item>, 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 items = Vec::new();
let mut buf = Vec::new();
let mut current_item: Option<Item> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
match e.name().as_ref() {
b"item" => {
let attrs = parse_attributes(&e)?;
// Get required attributes
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 item = Item::new(id, name);
// Note: This is a legacy simple parser. For full functionality,
// use the item_loader module's load_items_from_directory function.
// Parse optional attributes using defaults for new structure
if let Some(v) = attrs.get("level") { item.level = v.parse().unwrap_or(1); }
if let Some(v) = attrs.get("description") { item.description = v.clone(); }
if let Some(v) = attrs.get("price") { item.price = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("maxstack") { item.max_stack = v.parse().unwrap_or(1); }
if let Some(v) = attrs.get("abilityid") { item.ability_id = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("swap") { item.swap_item = v.parse().unwrap_or(0); }
if attrs.get("twohanded").is_some() { item.two_handed = true; }
if let Some(v) = attrs.get("foodamount") { item.food_amount = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("foodfrequency") { item.food_frequency = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("foodtime") { item.food_time = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("foodlevel") { item.food_level = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("handmodel") { item.handmodel = v.clone(); }
if let Some(v) = attrs.get("groundmodel") {
item.ground_model = v.parse().unwrap_or(item.type_id);
}
if let Some(v) = attrs.get("usingitemmodel") { item.using_item_model = v.clone(); }
if let Some(v) = attrs.get("dropsfx") { item.drop_sfx = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("pickupsfx") { item.pickup_sfx = v.parse().unwrap_or(0); }
if let Some(v) = attrs.get("storagesize") { item.storage_size = v.parse().unwrap_or(0); }
if attrs.get("hidemilestone").is_some() { item.hide_milestone = true; }
if attrs.get("generateicon").is_some() { item.generate_icon = true; }
if let Some(v) = attrs.get("comment") { item.comment = v.clone(); }
current_item = Some(item);
}
b"stat" => {
if let Some(ref mut item) = current_item {
let attrs = parse_attributes(&e)?;
let stat = parse_stat(&attrs);
// Convert legacy ItemStat to new Stat vec
let stats = stat.to_stats();
item.stats.extend(stats);
}
}
b"crafting" => {
// Crafting recipes are now handled by item_loader for full functionality
// This is kept for backwards compatibility but doesn't fully populate the new structure
}
b"anim" => {
if let Some(ref mut item) = current_item {
let attrs = parse_attributes(&e)?;
let anim = AnimationSet {
idle: attrs.get("idle").and_then(|v| v.parse().ok()).unwrap_or(0),
walk: attrs.get("walk").and_then(|v| v.parse().ok()).unwrap_or(0),
run: attrs.get("run").and_then(|v| v.parse().ok()).unwrap_or(0),
weapon_attack: attrs.get("weaponattack").and_then(|v| v.parse().ok()).unwrap_or(0),
takehit: attrs.get("takehit").and_then(|v| v.parse().ok()).unwrap_or(0),
use_anim: attrs.get("use").and_then(|v| v.parse().ok()).unwrap_or(0),
};
item.animations = Some(anim);
}
}
b"generate" => {
// Generate rules are now handled by item_loader for full functionality
// This is kept for backwards compatibility but doesn't process them
}
_ => {}
}
}
Ok(Event::End(e)) => {
match e.name().as_ref() {
b"item" => {
if let Some(item) = current_item.take() {
items.push(item);
}
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
buf.clear();
}
Ok(items)
}
fn parse_stat(attrs: &HashMap<String, String>) -> ItemStat {
ItemStat {
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").and_then(|v| v.parse().ok()),
resistancemagical: attrs.get("resistancemagical").and_then(|v| v.parse().ok()),
resistanceranged: 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()),
harvestingspeedwoodcutting: attrs.get("harvestingspeedwoodcutting").and_then(|v| v.parse().ok()),
}
}

View File

@@ -0,0 +1,88 @@
//! Loot Table XML Parser
use crate::types::{LootTable, LootDrop};
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
pub fn parse_loot_xml<P: AsRef<Path>>(path: P) -> Result<Vec<LootTable>, 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<LootTable> = 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::<i32>().ok())
.collect::<Vec<i32>>()
} 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::<i32>() {
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)
}

View File

@@ -0,0 +1,128 @@
//! Map XML Parser
use crate::types::Map;
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
pub fn parse_maps_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Map>, 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 maps = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
match e.name().as_ref() {
b"map" => {
let attrs = parse_attributes(e)?;
// Get required attributes
let scene_id = attrs.get("sceneid")
.ok_or_else(|| XmlParseError::MissingAttribute("sceneid".to_string()))?
.clone();
let music = attrs.get("music")
.ok_or_else(|| XmlParseError::MissingAttribute("music".to_string()))?
.parse::<i32>()
.map_err(|_| XmlParseError::InvalidAttribute("music".to_string()))?;
let ambience = attrs.get("ambience")
.ok_or_else(|| XmlParseError::MissingAttribute("ambience".to_string()))?
.parse::<i32>()
.map_err(|_| XmlParseError::InvalidAttribute("ambience".to_string()))?;
let mut map = Map::new(scene_id, music, ambience);
// Parse optional attributes
if let Some(v) = attrs.get("name") {
map.name = v.clone();
}
if let Some(v) = attrs.get("fogcolor") {
map.fog_color = Some(v.clone());
}
if let Some(v) = attrs.get("fogginess") {
map.fogginess = v.parse().ok();
}
if let Some(v) = attrs.get("viewdistance") {
map.view_distance = v.parse().ok();
}
if let Some(v) = attrs.get("npcviewdistance") {
map.npc_view_distance = v.parse().ok();
}
if let Some(v) = attrs.get("sunlight") {
map.sunlight = v.parse().ok();
}
if let Some(v) = attrs.get("suncolor") {
map.sun_color = Some(v.clone());
}
if let Some(v) = attrs.get("ambientcolor") {
map.ambient_color = Some(v.clone());
}
if let Some(v) = attrs.get("indoorsunlight") {
map.indoor_sunlight = v.parse().ok();
}
if let Some(v) = attrs.get("fogstart") {
map.fog_start = v.parse().ok();
}
if attrs.get("indoors").is_some() {
map.indoors = true;
}
if attrs.get("noworldmap").is_some() {
map.no_world_map = true;
}
if attrs.get("nominimap").is_some() {
map.no_minimap = true;
}
if attrs.get("tpdisabled").is_some() {
map.tp_disabled = true;
}
if attrs.get("dontloadnearbyscenes").is_some() {
map.dont_load_nearby_scenes = true;
}
if attrs.get("noborder").is_some() {
map.no_border = true;
}
if attrs.get("borderleft").is_some() {
map.border_left = true;
}
if attrs.get("borderright").is_some() {
map.border_right = true;
}
if attrs.get("borderup").is_some() {
map.border_up = true;
}
if attrs.get("borderdown").is_some() {
map.border_down = true;
}
if let Some(v) = attrs.get("respawnmap") {
map.respawn_map = Some(v.clone());
}
if let Some(v) = attrs.get("connectedmaps") {
map.connected_maps = Some(v.clone());
}
if let Some(v) = attrs.get("comment") {
map.comment = Some(v.clone());
}
maps.push(map);
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
buf.clear();
}
Ok(maps)
}

View File

@@ -0,0 +1,91 @@
//! XML Parsers - Individual parsers for each game data type
//!
//! This module contains separate parsers for each type of game data:
//! - Items
//! - NPCs
//! - Quests
//! - Harvestables
//! - Loot tables
//! - Maps
//! - Fast travel locations
//! - Player houses
//! - Traits
//! - Shops
mod items;
mod npcs;
mod quests;
mod harvestables;
mod loot;
mod maps;
mod fast_travel;
mod player_houses;
mod traits;
mod shops;
use quick_xml::events::BytesStart;
use std::collections::HashMap;
use thiserror::Error;
// Re-export all parsers
pub use items::parse_items_xml;
pub use npcs::parse_npcs_xml;
pub use quests::parse_quests_xml;
pub use harvestables::parse_harvestables_xml;
pub use loot::parse_loot_xml;
pub use maps::parse_maps_xml;
pub use fast_travel::{
parse_fast_travel_locations_xml,
parse_fast_travel_canoe_xml,
parse_fast_travel_portals_xml,
};
pub use player_houses::parse_player_houses_xml;
pub use traits::parse_traits_xml;
pub use shops::parse_shops_xml;
/// Errors that can occur during XML parsing
#[derive(Debug, Error)]
pub enum XmlParseError {
#[error("XML parsing error: {0}")]
XmlError(#[from] quick_xml::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Attribute error: {0}")]
AttrError(#[from] quick_xml::events::attributes::AttrError),
#[error("Missing required attribute: {0}")]
MissingAttribute(String),
#[error("Invalid attribute value: {0}")]
InvalidAttribute(String),
}
/// Parse all attributes from an XML element into a HashMap
pub(crate) fn parse_attributes(element: &BytesStart) -> Result<HashMap<String, String>, XmlParseError> {
let mut attrs = HashMap::new();
for attr in element.attributes() {
let attr = attr?;
let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
let value = attr.unescape_value()?.to_string();
attrs.insert(key, value);
}
Ok(attrs)
}
/// Parse health range string like "3-5" or "3" into (min, max)
pub(crate) fn parse_health_range(health_str: &str) -> (i32, i32) {
if let Some(dash_pos) = health_str.find('-') {
let min_str = &health_str[..dash_pos];
let max_str = &health_str[dash_pos + 1..];
let min = min_str.trim().parse().unwrap_or(0);
let max = max_str.trim().parse().unwrap_or(0);
(min, max)
} else {
let val = health_str.trim().parse().unwrap_or(0);
(val, val)
}
}

View File

@@ -0,0 +1,243 @@
//! NPC XML Parser
use crate::types::{Npc, NpcStat, NpcLevel, RightClick, BarkGroup, QuestMarker, NpcAnimationSet};
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
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()),
}
}

View File

@@ -0,0 +1,93 @@
//! Player House XML Parser
use crate::types::PlayerHouse;
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
/// Parse position string "x,y,z" and return (x, z), discarding y
fn parse_position(pos_str: &str) -> Result<(f32, f32), XmlParseError> {
let parts: Vec<&str> = pos_str.split(',').collect();
if parts.len() != 3 {
return Err(XmlParseError::InvalidAttribute("pos".to_string()));
}
let x = parts[0]
.parse::<f32>()
.map_err(|_| XmlParseError::InvalidAttribute("pos".to_string()))?;
// Skip y (parts[1])
let z = parts[2]
.parse::<f32>()
.map_err(|_| XmlParseError::InvalidAttribute("pos".to_string()))?;
Ok((x, z))
}
pub fn parse_player_houses_xml<P: AsRef<Path>>(path: P) -> Result<Vec<PlayerHouse>, 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 houses = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
match e.name().as_ref() {
b"playerhouse" => {
let attrs = parse_attributes(e)?;
// Skip hidden houses
if attrs.contains_key("hidden") {
continue;
}
// Get required attributes
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 description = attrs
.get("description")
.unwrap_or(&String::new())
.clone();
let pos_str = attrs
.get("pos")
.ok_or_else(|| XmlParseError::MissingAttribute("pos".to_string()))?;
let (pos_x, pos_z) = parse_position(pos_str)?;
let price = attrs
.get("price")
.ok_or_else(|| XmlParseError::MissingAttribute("price".to_string()))?
.parse::<i32>()
.map_err(|_| XmlParseError::InvalidAttribute("price".to_string()))?;
let house = PlayerHouse::new(id, name, description, pos_x, pos_z, price);
houses.push(house);
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
buf.clear();
}
Ok(houses)
}

View File

@@ -0,0 +1,106 @@
//! Quest XML Parser
use crate::types::{Quest, QuestPhase, QuestReward};
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
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)
}

View File

@@ -0,0 +1,105 @@
//! Shop XML Parser
use crate::types::{Shop, ShopItem};
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
pub fn parse_shops_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Shop>, 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 shops = Vec::new();
let mut buf = Vec::new();
let mut current_shop: Option<Shop> = 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"shop" => {
let attrs = parse_attributes(e)?;
// Get required attributes
let shop_id = attrs
.get("shopid")
.ok_or_else(|| XmlParseError::MissingAttribute("shopid".to_string()))?
.parse::<i32>()
.map_err(|_| XmlParseError::InvalidAttribute("shopid".to_string()))?;
let name = attrs
.get("name")
.unwrap_or(&String::new())
.clone();
let mut shop = Shop::new(shop_id, name);
// Parse optional attributes
if attrs.get("isgeneralstore").is_some() {
shop.is_general_store = true;
}
if let Some(v) = attrs.get("comment") {
shop.comment = Some(v.clone());
}
current_shop = Some(shop);
}
b"item" if current_shop.is_some() => {
if let Some(ref mut shop) = current_shop {
let attrs = parse_attributes(e)?;
// Get item ID (can be numeric or string)
if let Some(item_id) = attrs.get("id") {
let mut item = ShopItem::new(item_id.clone());
// Parse optional attributes
if let Some(v) = attrs.get("name") {
item.name = Some(v.clone());
}
if let Some(v) = attrs.get("price") {
item.price = v.parse().ok();
}
if let Some(v) = attrs.get("maxstock") {
item.max_stock = v.parse().ok();
}
if let Some(v) = attrs.get("restocktime") {
item.restock_time = v.parse().ok();
}
if let Some(v) = attrs.get("buyprice") {
item.buy_price = v.parse().ok();
}
if let Some(v) = attrs.get("comment") {
item.comment = Some(v.clone());
}
shop.add_item(item);
}
}
}
_ => {}
}
}
Ok(Event::End(ref e)) => {
match e.name().as_ref() {
b"shop" => {
if let Some(shop) = current_shop.take() {
shops.push(shop);
}
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
buf.clear();
}
Ok(shops)
}

View File

@@ -0,0 +1,99 @@
//! Trait XML Parser
use crate::types::{Trait, TraitTrainer};
use super::{parse_attributes, XmlParseError};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
pub fn parse_traits_xml<P: AsRef<Path>>(path: P) -> Result<Vec<Trait>, 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 traits = Vec::new();
let mut buf = Vec::new();
let mut current_trait: Option<Trait> = 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"trait" => {
let attrs = parse_attributes(e)?;
// Get required attributes
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")
.unwrap_or(&String::new())
.clone();
let description = attrs
.get("description")
.unwrap_or(&String::new())
.clone();
let mut trait_obj = Trait::new(id, name, description);
// Parse optional attributes
if let Some(v) = attrs.get("learnability") {
trait_obj.learnability = v.parse().ok();
}
if let Some(v) = attrs.get("comment") {
trait_obj.comment = Some(v.clone());
}
current_trait = Some(trait_obj);
}
b"trainer" if current_trait.is_some() => {
if let Some(ref mut trait_obj) = current_trait {
let attrs = parse_attributes(e)?;
// Parse trainer requirements
if let (Some(skill), Some(level_str)) =
(attrs.get("skill"), attrs.get("level"))
{
if let Ok(level) = level_str.parse::<i32>() {
let mut trainer = TraitTrainer::new(skill.clone(), level);
// Parse optional tier icon
if let Some(v) = attrs.get("tiericon") {
trainer.tier_icon = v.parse().ok();
}
trait_obj.trainer = Some(trainer);
}
}
}
}
_ => {}
}
}
Ok(Event::End(ref e)) => {
match e.name().as_ref() {
b"trait" => {
if let Some(trait_obj) = current_trait.take() {
traits.push(trait_obj);
}
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(XmlParseError::XmlError(e)),
_ => {}
}
buf.clear();
}
Ok(traits)
}