Compare commits

...

3 Commits

Author SHA1 Message Date
ccc9a894b7 26-1-2026 2026-01-26 13:05:57 +00:00
cdfab8fd1e teleporter 2026-01-23 07:12:25 +00:00
99aecaefde images in database 2026-01-16 13:31:48 +00:00
56 changed files with 4294 additions and 1679 deletions

View File

@@ -44,7 +44,8 @@
"Bash(timeout 10 cargo run:*)", "Bash(timeout 10 cargo run:*)",
"Bash(timeout 60 cargo run:*)", "Bash(timeout 60 cargo run:*)",
"Bash(DATABASE_URL=../cursebreaker.db diesel print-schema:*)", "Bash(DATABASE_URL=../cursebreaker.db diesel print-schema:*)",
"Bash(DATABASE_URL=../cursebreaker.db diesel database:*)" "Bash(DATABASE_URL=../cursebreaker.db diesel database:*)",
"Bash(DATABASE_URL=cursebreaker.db CB_ASSETS_PATH=/home/connor/repos/CBAssets cargo run:*)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/home/connor/repos/CBAssets/" "/home/connor/repos/CBAssets/"

1
.env Normal file
View File

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

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ target/
# Test data (cloned Unity projects for integration tests) # Test data (cloned Unity projects for integration tests)
test_data/ test_data/
cursebreaker.db cursebreaker.db
**/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,3 @@
DROP TABLE IF EXISTS icons;
DROP TABLE IF EXISTS achievement_icons;
DROP TABLE IF EXISTS general_icons;

View File

@@ -0,0 +1,24 @@
-- Simple icons table (abilities, buffs, traits, player houses, stat icons)
CREATE TABLE IF NOT EXISTS icons (
category TEXT NOT NULL,
name TEXT NOT NULL,
icon BLOB NOT NULL,
PRIMARY KEY (category, name)
);
-- Achievement icons table (filtered, no _0 suffix)
CREATE TABLE IF NOT EXISTS achievement_icons (
name TEXT PRIMARY KEY NOT NULL,
icon BLOB NOT NULL
);
-- General icons table (multiple sizes)
CREATE TABLE IF NOT EXISTS general_icons (
name TEXT PRIMARY KEY NOT NULL,
original_width INTEGER NOT NULL,
original_height INTEGER NOT NULL,
icon_original BLOB,
icon_256 BLOB,
icon_64 BLOB,
icon_32 BLOB
);

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,65 +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; 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); 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
if process_icons {
info!("\n=== Processing Game Icons ===");
let icon_db = IconDatabase::new(database_url);
match icon_db.load_all_icons(&cb_assets_path) {
Ok(stats) => {
info!("\n=== Icon Statistics ===");
info!("Ability icons: {}", stats.abilities);
info!("Buff icons: {}", stats.buffs);
info!("Trait icons: {}", stats.traits);
info!("Player house icons: {}", stats.player_houses);
info!("Stat icons: {}", stats.stat_icons);
info!("Achievement icons: {}", stats.achievement_icons);
info!("General icons: {}", stats.general_icons);
info!("Total icons: {}", stats.total_icons());
info!("Total size: {} KB", stats.total_bytes / 1024);
}
Err(e) => {
error!("Failed to process icons: {}", e);
return Err(Box::new(e));
}
} }
} }

View File

@@ -7,6 +7,13 @@
//! - Computing world transforms //! - Computing world transforms
//! - Saving resource locations to the database //! - Saving resource locations to the database
//! - Processing and saving item icons for resources //! - Processing and saving item icons for resources
//!
//! Usage:
//! scene-parser [min_x max_x min_y max_y]
//!
//! Examples:
//! scene-parser # Parse all scenes
//! scene-parser 0 10 0 10 # Parse scenes from (0,0) to (10,10)
use cursebreaker_parser::{ use cursebreaker_parser::{
InteractableResource, InteractableTeleporter, InteractableWorkbench, InteractableResource, InteractableTeleporter, InteractableWorkbench,
@@ -20,15 +27,111 @@ use std::env;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs;
/// Bounds for filtering which scene tiles to parse
#[derive(Debug, Clone)]
struct Bounds {
min_x: i32,
max_x: i32,
min_y: i32,
max_y: i32,
}
impl Bounds {
fn contains(&self, x: i32, y: i32) -> bool {
x >= self.min_x && x <= self.max_x && y >= self.min_y && y <= self.max_y
}
}
/// Parse scene filename to extract tile coordinates (e.g., "10_3.unity" -> (10, 3))
fn parse_scene_coords(filename: &str) -> Option<(i32, i32)> {
let stem = filename.strip_suffix(".unity")?;
let parts: Vec<&str> = stem.split('_').collect();
if parts.len() == 2 {
let x = parts[0].parse().ok()?;
let y = parts[1].parse().ok()?;
Some((x, y))
} else {
None
}
}
/// Find all scene files matching the *_*.unity pattern
fn find_scene_files(scenes_dir: &Path, bounds: Option<&Bounds>) -> Vec<PathBuf> {
let mut scenes = Vec::new();
if let Ok(entries) = fs::read_dir(scenes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.ends_with(".unity") {
if let Some((x, y)) = parse_scene_coords(filename) {
// Check bounds if specified
if let Some(b) = bounds {
if !b.contains(x, y) {
continue;
}
}
scenes.push(path);
}
}
}
}
}
// Sort by coordinates for consistent ordering
scenes.sort_by(|a, b| {
let a_coords = a.file_name()
.and_then(|n| n.to_str())
.and_then(parse_scene_coords)
.unwrap_or((0, 0));
let b_coords = b.file_name()
.and_then(|n| n.to_str())
.and_then(parse_scene_coords)
.unwrap_or((0, 0));
a_coords.cmp(&b_coords)
});
scenes
}
/// Parse command line arguments for bounds
fn parse_bounds_args() -> Option<Bounds> {
let args: Vec<String> = env::args().collect();
if args.len() == 5 {
let min_x = args[1].parse().ok()?;
let max_x = args[2].parse().ok()?;
let min_y = args[3].parse().ok()?;
let max_y = args[4].parse().ok()?;
Some(Bounds { min_x, max_x, min_y, max_y })
} else if args.len() == 1 {
None // No bounds specified, parse all
} else {
eprintln!("Usage: {} [min_x max_x min_y max_y]", args[0]);
eprintln!(" No arguments: parse all scenes");
eprintln!(" 4 arguments: parse scenes within bounds (inclusive)");
std::process::exit(1);
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
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::Warn))
.unwrap(); .unwrap();
info!("🎮 Cursebreaker - Scene Parser"); info!("🎮 Cursebreaker - Scene Parser");
// Parse bounds from command line
let bounds = parse_bounds_args();
if let Some(ref b) = bounds {
info!("📐 Bounds: x=[{}, {}], y=[{}, {}]", b.min_x, b.max_x, b.min_y, b.max_y);
} else {
info!("📐 Bounds: none (parsing all scenes)");
}
let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string()); let cb_assets_path = env::var("CB_ASSETS_PATH").unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
// Initialize Unity project once - scans entire project for GUID mappings // Initialize Unity project once - scans entire project for GUID mappings
@@ -37,6 +140,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let project = UnityProject::from_path(project_root)?; let project = UnityProject::from_path(project_root)?;
// Find all scene files
let scenes_dir = project_root.join("_GameAssets/Scenes/Tiles");
let scene_files = find_scene_files(&scenes_dir, bounds.as_ref());
info!("🔍 Found {} scene files to parse", scene_files.len());
if scene_files.is_empty() {
warn!("No scene files found matching criteria");
return Ok(());
}
// Create type filter to only parse GameObject, Transform, and InteractableResource MonoBehaviour // Create type filter to only parse GameObject, Transform, and InteractableResource MonoBehaviour
info!("🔍 Setting up type filter:"); info!("🔍 Setting up type filter:");
info!(" • Unity types: GameObject, Transform"); info!(" • Unity types: GameObject, Transform");
@@ -46,117 +159,145 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
vec!["InteractableResource", "InteractableTeleporter", "InteractableWorkbench", "LootSpawner", "MapIcon", "MapNameChanger"] vec!["InteractableResource", "InteractableTeleporter", "InteractableWorkbench", "LootSpawner", "MapIcon", "MapNameChanger"]
); );
// Now parse the scene using the pre-built GUID resolvers with filtering // Setup database connection
let scene_path = "_GameAssets/Scenes/Tiles/10_3.unity"; let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string());
info!("📁 Parsing scene: {}", scene_path); let mut conn = SqliteConnection::establish(&database_url)?;
// Clear all tables before processing (they're regenerated each run)
{
use cursebreaker_parser::schema::{
world_resources, world_teleporters, world_workbenches,
world_loot, world_map_icons, world_map_name_changers, resource_icons
};
diesel::delete(world_resources::table).execute(&mut conn)?;
diesel::delete(world_teleporters::table).execute(&mut conn)?;
diesel::delete(world_workbenches::table).execute(&mut conn)?;
diesel::delete(world_loot::table).execute(&mut conn)?;
diesel::delete(world_map_icons::table).execute(&mut conn)?;
diesel::delete(world_map_name_changers::table).execute(&mut conn)?;
diesel::delete(resource_icons::table).execute(&mut conn)?;
}
// Collect unique harvestables across all scenes for icon processing
let mut all_unique_harvestables: HashMap<i32, String> = HashMap::new();
// Track totals
let mut total_resources = 0;
let mut total_teleporters = 0;
let mut total_workbenches = 0;
let mut total_loot = 0;
let mut total_map_icons = 0;
let mut total_map_name_changers = 0;
let mut scenes_processed = 0;
let mut scenes_failed = 0;
// Process each scene
for (idx, scene_path) in scene_files.iter().enumerate() {
let relative_path = scene_path.strip_prefix(project_root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| scene_path.to_string_lossy().to_string());
print!("\n📁 [{}/{}] Parsing scene: {}", idx + 1, scene_files.len(), relative_path);
match project.parse_scene_filtered(&relative_path, Some(&type_filter)) {
Ok(mut scene) => {
info!(" ✓ Parsed ({} entities)", scene.entity_map.len());
// Post-processing: Compute world transforms
unity_parser::compute_world_transforms(&mut scene.world, &scene.entity_map);
// Save resources
let resource_count = save_resources(&mut conn, &scene)?;
total_resources += resource_count;
// Collect unique harvestables for icon processing later
scene.world
.query_all::<(&InteractableResource, &unity_parser::GameObject)>()
.for_each(|(resource, object)| {
all_unique_harvestables.entry(resource.type_id as i32)
.or_insert_with(|| object.name.to_string());
});
// Save other world objects (append mode - tables already cleared)
total_teleporters += save_teleporters_append(&mut conn, &scene)?;
total_workbenches += save_workbenches_append(&mut conn, &scene)?;
total_loot += save_loot_spawners_append(&mut conn, &scene)?;
total_map_icons += save_map_icons_append(&mut conn, &scene)?;
total_map_name_changers += save_map_name_changers_append(&mut conn, &scene)?;
scenes_processed += 1;
}
Err(e) => {
error!(" ✗ Parse error: {}", e);
scenes_failed += 1;
}
}
}
log::logger().flush(); log::logger().flush();
// Parse the scene using the project with type filtering // Process icons for all unique harvestables
match project.parse_scene_filtered(scene_path, Some(&type_filter)) { info!("\n🎨 Processing item icons for {} unique harvestable types...", all_unique_harvestables.len());
Ok(mut scene) => { process_item_icons_from_map(&cb_assets_path, &mut conn, &all_unique_harvestables)?;
info!("✅ Scene parsed successfully!");
info!(" Total entities: {}", scene.entity_map.len());
// Post-processing: Compute world transforms // Print summary
info!("🔄 Computing world transforms..."); println!("\n==================================================");
unity_parser::compute_world_transforms(&mut scene.world, &scene.entity_map); println!("📊 SUMMARY");
info!(" ✓ World transforms computed"); println!("==================================================");
println!(" Scenes processed: {} ({} failed)", scenes_processed, scenes_failed);
// Save resources to database println!(" Resources: {}", total_resources);
info!("💾 Saving resources to database..."); println!(" Teleporters: {}", total_teleporters);
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string()); println!(" Workbenches: {}", total_workbenches);
let mut conn = SqliteConnection::establish(&database_url)?; println!(" Loot spawners: {}", total_loot);
println!(" Map icons: {}", total_map_icons);
// Use diesel schema println!(" Map name changers:{}", total_map_name_changers);
use cursebreaker_parser::schema::world_resources; println!("==================================================");
// Clear the entire table (it's regenerated each run)
diesel::delete(world_resources::table).execute(&mut conn)?;
let mut resource_count = 0;
// Insert all resources in a transaction
conn.transaction::<_, diesel::result::Error, _>(|conn| {
scene.world
.query_all::<(&InteractableResource, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
.for_each(|(resource, transform, object)| {
let world_pos = transform.position();
info!(" 📦 Resource: \"{}\"", object.name);
info!(" • typeId: {}", resource.type_id);
info!(" • Position: ({:.2}, {:.2})", world_pos.x, world_pos.z);
// Insert: store x and z (as y) coordinates only
let _ = diesel::insert_into(world_resources::table)
.values((
world_resources::item_id.eq(resource.type_id as i32),
world_resources::pos_x.eq(world_pos.x as f32),
world_resources::pos_y.eq(world_pos.z as f32),
))
.execute(conn);
resource_count += 1;
});
Ok(())
})?;
info!("✅ Saved {} resources to database", resource_count);
log::logger().flush();
// Process and save item icons
info!("🎨 Processing item icons...");
process_item_icons(&cb_assets_path, &mut conn, &scene)?;
// Save other world objects
info!("🗺️ Saving teleporters...");
save_teleporters(&mut conn, &scene)?;
info!("🔨 Saving workbenches...");
save_workbenches(&mut conn, &scene)?;
info!("💰 Saving loot spawners...");
save_loot_spawners(&mut conn, &scene)?;
info!("📍 Saving map icons...");
save_map_icons(&mut conn, &scene)?;
info!("🏷️ Saving map name changers...");
save_map_name_changers(&mut conn, &scene)?;
}
Err(e) => {
error!("Parse error: {}", e);
return Err(Box::new(e));
}
}
log::logger().flush(); log::logger().flush();
Ok(()) Ok(())
} }
/// Process item icons for all resources in the scene /// Save resources from a scene (append mode)
fn process_item_icons( fn save_resources(
cb_assets_path: &str,
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene, scene: &unity_parser::UnityScene,
) -> Result<usize, Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::world_resources;
let mut count = 0;
conn.transaction::<_, diesel::result::Error, _>(|conn| {
scene.world
.query_all::<(&InteractableResource, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
.for_each(|(resource, transform, _object)| {
let world_pos = transform.position();
let _ = diesel::insert_into(world_resources::table)
.values((
world_resources::item_id.eq(resource.type_id as i32),
world_resources::pos_x.eq(world_pos.x as f32),
world_resources::pos_y.eq(world_pos.z as f32),
))
.execute(conn);
count += 1;
});
Ok(())
})?;
Ok(count)
}
/// Process item icons from a pre-collected map of harvestables
fn process_item_icons_from_map(
cb_assets_path: &str,
conn: &mut SqliteConnection,
unique_harvestables: &HashMap<i32, String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::{resource_icons, items, harvestables, harvestable_drops}; use cursebreaker_parser::schema::{resource_icons, items, harvestables, harvestable_drops};
// Collect unique harvestable IDs from resources info!(" Processing {} unique harvestable types", unique_harvestables.len());
let mut unique_harvestables: HashMap<i32, String> = HashMap::new();
scene.world
.query_all::<(&InteractableResource, &unity_parser::GameObject)>()
.for_each(|(resource, object)| {
unique_harvestables.entry(resource.type_id as i32)
.or_insert_with(|| object.name.to_string());
});
info!(" Found {} unique harvestable types", unique_harvestables.len());
// Clear existing resource icons (regenerated each run)
diesel::delete(resource_icons::table).execute(conn)?;
// Create image processor with white outline // Create image processor with white outline
let processor = ImageProcessor::default(); let processor = ImageProcessor::default();
@@ -249,22 +390,19 @@ fn process_item_icons(
Ok(()) Ok(())
} }
/// Save teleporter data to database /// Save teleporter data to database (append mode - doesn't clear table)
fn save_teleporters( fn save_teleporters_append(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene, scene: &unity_parser::UnityScene,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<usize, Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::world_teleporters; use cursebreaker_parser::schema::world_teleporters;
// Clear existing teleporters
diesel::delete(world_teleporters::table).execute(conn)?;
let mut count = 0; let mut count = 0;
// Query all teleporters // Query all teleporters
scene.world scene.world
.query_all::<(&InteractableTeleporter, &unity_parser::WorldTransform, &unity_parser::GameObject)>() .query_all::<(&InteractableTeleporter, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
.for_each(|(teleporter, transform, object)| { .for_each(|(teleporter, transform, _object)| {
let world_pos = transform.position(); let world_pos = transform.position();
// Get the tp_transform position if it exists // Get the tp_transform position if it exists
@@ -279,9 +417,6 @@ fn save_teleporters(
(None, None) (None, None)
}; };
info!(" 🗺️ Teleporter: \"{}\" at ({:.2}, {:.2}) -> ({:?}, {:?})",
object.name, world_pos.x, world_pos.z, tp_x, tp_y);
let _ = diesel::insert_into(world_teleporters::table) let _ = diesel::insert_into(world_teleporters::table)
.values(( .values((
world_teleporters::pos_x.eq(world_pos.x as f32), world_teleporters::pos_x.eq(world_pos.x as f32),
@@ -294,31 +429,24 @@ fn save_teleporters(
count += 1; count += 1;
}); });
info!("✅ Saved {} teleporters to database", count); Ok(count)
Ok(())
} }
/// Save workbench data to database /// Save workbench data to database (append mode - doesn't clear table)
fn save_workbenches( fn save_workbenches_append(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene, scene: &unity_parser::UnityScene,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<usize, Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::world_workbenches; use cursebreaker_parser::schema::world_workbenches;
// Clear existing workbenches
diesel::delete(world_workbenches::table).execute(conn)?;
let mut count = 0; let mut count = 0;
// Query all workbenches // Query all workbenches
scene.world scene.world
.query_all::<(&InteractableWorkbench, &unity_parser::WorldTransform, &unity_parser::GameObject)>() .query_all::<(&InteractableWorkbench, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
.for_each(|(workbench, transform, object)| { .for_each(|(workbench, transform, _object)| {
let world_pos = transform.position(); let world_pos = transform.position();
info!(" 🔨 Workbench: \"{}\" (ID: {}) at ({:.2}, {:.2})",
object.name, workbench.workbench_id, world_pos.x, world_pos.z);
let _ = diesel::insert_into(world_workbenches::table) let _ = diesel::insert_into(world_workbenches::table)
.values(( .values((
world_workbenches::pos_x.eq(world_pos.x as f32), world_workbenches::pos_x.eq(world_pos.x as f32),
@@ -330,31 +458,24 @@ fn save_workbenches(
count += 1; count += 1;
}); });
info!("✅ Saved {} workbenches to database", count); Ok(count)
Ok(())
} }
/// Save loot spawner data to database /// Save loot spawner data to database (append mode - doesn't clear table)
fn save_loot_spawners( fn save_loot_spawners_append(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene, scene: &unity_parser::UnityScene,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<usize, Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::world_loot; use cursebreaker_parser::schema::world_loot;
// Clear existing loot spawners
diesel::delete(world_loot::table).execute(conn)?;
let mut count = 0; let mut count = 0;
// Query all loot spawners // Query all loot spawners
scene.world scene.world
.query_all::<(&LootSpawner, &unity_parser::WorldTransform, &unity_parser::GameObject)>() .query_all::<(&LootSpawner, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
.for_each(|(loot, transform, object)| { .for_each(|(loot, transform, _object)| {
let world_pos = transform.position(); let world_pos = transform.position();
info!(" 💰 Loot: \"{}\" (Item: {}, Amount: {}, Respawn: {}s) at ({:.2}, {:.2})",
object.name, loot.item_id, loot.amount, loot.respawn_time, world_pos.x, world_pos.z);
let _ = diesel::insert_into(world_loot::table) let _ = diesel::insert_into(world_loot::table)
.values(( .values((
world_loot::pos_x.eq(world_pos.x as f32), world_loot::pos_x.eq(world_pos.x as f32),
@@ -369,31 +490,24 @@ fn save_loot_spawners(
count += 1; count += 1;
}); });
info!("✅ Saved {} loot spawners to database", count); Ok(count)
Ok(())
} }
/// Save map icon data to database /// Save map icon data to database (append mode - doesn't clear table)
fn save_map_icons( fn save_map_icons_append(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene, scene: &unity_parser::UnityScene,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<usize, Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::world_map_icons; use cursebreaker_parser::schema::world_map_icons;
// Clear existing map icons
diesel::delete(world_map_icons::table).execute(conn)?;
let mut count = 0; let mut count = 0;
// Query all map icons // Query all map icons
scene.world scene.world
.query_all::<(&MapIcon, &unity_parser::WorldTransform, &unity_parser::GameObject)>() .query_all::<(&MapIcon, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
.for_each(|(icon, transform, object)| { .for_each(|(icon, transform, _object)| {
let world_pos = transform.position(); let world_pos = transform.position();
info!(" 📍 MapIcon: \"{}\" (Type: {:?}, Text: \"{}\") at ({:.2}, {:.2})",
object.name, icon.icon_type, icon.text, world_pos.x, world_pos.z);
let _ = diesel::insert_into(world_map_icons::table) let _ = diesel::insert_into(world_map_icons::table)
.values(( .values((
world_map_icons::pos_x.eq(world_pos.x as f32), world_map_icons::pos_x.eq(world_pos.x as f32),
@@ -410,31 +524,24 @@ fn save_map_icons(
count += 1; count += 1;
}); });
info!("✅ Saved {} map icons to database", count); Ok(count)
Ok(())
} }
/// Save map name changer data to database /// Save map name changer data to database (append mode - doesn't clear table)
fn save_map_name_changers( fn save_map_name_changers_append(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene, scene: &unity_parser::UnityScene,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<usize, Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::world_map_name_changers; use cursebreaker_parser::schema::world_map_name_changers;
// Clear existing map name changers
diesel::delete(world_map_name_changers::table).execute(conn)?;
let mut count = 0; let mut count = 0;
// Query all map name changers // Query all map name changers
scene.world scene.world
.query_all::<(&MapNameChanger, &unity_parser::WorldTransform, &unity_parser::GameObject)>() .query_all::<(&MapNameChanger, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
.for_each(|(changer, transform, object)| { .for_each(|(changer, transform, _object)| {
let world_pos = transform.position(); let world_pos = transform.position();
info!(" 🏷️ MapNameChanger: \"{}\" -> \"{}\" at ({:.2}, {:.2})",
object.name, changer.map_name, world_pos.x, world_pos.z);
let _ = diesel::insert_into(world_map_name_changers::table) let _ = diesel::insert_into(world_map_name_changers::table)
.values(( .values((
world_map_name_changers::pos_x.eq(world_pos.x as f32), world_map_name_changers::pos_x.eq(world_pos.x as f32),
@@ -446,6 +553,5 @@ fn save_map_name_changers(
count += 1; count += 1;
}); });
info!("✅ Saved {} map name changers to database", count); Ok(count)
Ok(())
} }

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

@@ -0,0 +1,655 @@
use crate::types::{
NewAbilityIcon, NewBuffIcon, NewTraitIcon, NewPlayerHouseIcon, NewStatIcon,
NewAchievementIcon, NewGeneralIcon
};
use crate::image_processor::ImageProcessor;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::path::{Path, PathBuf};
use std::fs;
use thiserror::Error;
use log::{info, warn};
#[derive(Debug, Error)]
pub enum IconDatabaseError {
#[error("Database error: {0}")]
DatabaseError(#[from] diesel::result::Error),
#[error("Image load error: {0}")]
ImageLoadError(#[from] image::ImageError),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Connection pool error: {0}")]
ConnectionError(String),
}
/// Statistics for icon loading
#[derive(Debug, Default)]
pub struct IconStats {
pub abilities: usize,
pub buffs: usize,
pub traits: usize,
pub player_houses: usize,
pub stat_icons: usize,
pub achievement_icons: usize,
pub general_icons: usize,
pub total_bytes: usize,
}
impl IconStats {
pub fn total_icons(&self) -> usize {
self.abilities + self.buffs + self.traits + self.player_houses
+ self.stat_icons + self.achievement_icons + self.general_icons
}
}
/// Database for managing game icons
pub struct IconDatabase {
database_url: String,
}
impl IconDatabase {
/// Create new database connection
pub fn new(database_url: String) -> Self {
Self { database_url }
}
/// Establish database connection
fn establish_connection(&self) -> Result<SqliteConnection, IconDatabaseError> {
SqliteConnection::establish(&self.database_url)
.map_err(|e| IconDatabaseError::ConnectionError(e.to_string()))
}
/// Load all icons from the CBAssets directory
pub fn load_all_icons<P: AsRef<Path>>(
&self,
cb_assets_path: P,
) -> Result<IconStats, IconDatabaseError> {
let base = cb_assets_path.as_ref();
let textures = base.join("Data/Textures");
let mut stats = IconStats::default();
info!("Loading ability icons...");
stats.abilities = self.load_ability_icons(
&textures.join("Abilities"),
&mut stats.total_bytes,
)?;
info!("Loading buff icons...");
stats.buffs = self.load_buff_icons(
&textures.join("Buffs"),
&mut stats.total_bytes,
)?;
info!("Loading trait icons...");
stats.traits = self.load_trait_icons(
&textures.join("Traits"),
&mut stats.total_bytes,
)?;
info!("Loading player house icons...");
stats.player_houses = self.load_player_house_icons(
&textures.join("PlayerHouses/Houses"),
&mut stats.total_bytes,
)?;
info!("Loading stat icons...");
stats.stat_icons = self.load_stat_icons(
&textures.join("StatIcons"),
&mut stats.total_bytes,
)?;
info!("Loading achievement icons...");
stats.achievement_icons = self.load_achievement_icons(
&textures.join("Achievements/Icons"),
&mut stats.total_bytes,
)?;
info!("Loading general icons...");
stats.general_icons = self.load_general_icons(&textures, &mut stats.total_bytes)?;
Ok(stats)
}
/// Load ability icons from a directory
fn load_ability_icons<P: AsRef<Path>>(
&self,
dir: P,
total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> {
use crate::schema::ability_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 = NewAbilityIcon {
name: &name,
icon: &webp_data,
};
diesel::replace_into(ability_icons::table)
.values(&new_icon)
.execute(&mut conn)?;
count += 1;
}
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)
}
/// Load achievement icons, filtering out files ending with _0
fn load_achievement_icons<P: AsRef<Path>>(
&self,
dir: P,
total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> {
use crate::schema::achievement_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;
}
// Skip files ending with _0
if name.ends_with("_0") {
continue;
}
// Load and encode as lossless WebP
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 = NewAchievementIcon {
name: &name,
icon: &webp_data,
};
diesel::replace_into(achievement_icons::table)
.values(&new_icon)
.execute(&mut conn)?;
count += 1;
}
info!(" Loaded {} achievement icons", count);
Ok(count)
}
/// Load general icons with multiple sizes
fn load_general_icons<P: AsRef<Path>>(
&self,
textures_dir: P,
total_bytes: &mut usize,
) -> Result<usize, IconDatabaseError> {
let textures = textures_dir.as_ref();
let mut count = 0;
// Collect all general icon paths
let mut icon_paths: Vec<(String, PathBuf)> = Vec::new();
// Directory-based icons
let directories = [
("Achievements/Trophies", true), // PNG only
("BottomRightTabs", false),
("MinimapIcons", false),
("Notifications", false),
("OverheadIcons", false),
("Skills", false),
];
for (subdir, png_only) in directories {
let dir = textures.join(subdir);
if dir.exists() {
let files = if png_only {
self.find_png_files(&dir)?
} else {
self.find_image_files(&dir)?
};
for path in files {
let name = path.file_stem()
.and_then(|s| s.to_str())
.map(|s| format!("{}_{}", subdir.replace('/', "_"), s))
.unwrap_or_default();
if !name.is_empty() {
icon_paths.push((name, path));
}
}
}
}
// Individual file icons
let individual_files = [
("Common/Book.png", "Common_Book"),
("Common/Hourglass.png", "Common_Hourglass"),
("Common/Mana.png", "Common_Mana"),
("Common/QuestCompleteTrophy.png", "Common_QuestCompleteTrophy"),
("Common/Tick.png", "Common_Tick"),
("Common/TutorialTip.png", "Common_TutorialTip"),
("Common/Zoom_Minus.png", "Common_Zoom_Minus"),
("Common/Zoom_Plus.png", "Common_Zoom_Plus"),
("Inventory/Banknote.png", "Inventory_Banknote"),
("Minimap/ShowCoordinates.png", "Minimap_ShowCoordinates"),
("SplashScreens/Olipa.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 {
let path = textures.join(file);
if path.exists() {
icon_paths.push((name.to_string(), path));
} else {
warn!("File not found: {}", path.display());
}
}
// Process all collected icons
let mut conn = self.establish_connection()?;
for (name, path) in icon_paths {
if let Ok(bytes) = self.process_general_icon(&path, &name, &mut conn) {
*total_bytes += bytes;
count += 1;
}
}
info!(" Loaded {} general icons", count);
Ok(count)
}
/// Process a single general icon at multiple sizes
fn process_general_icon(
&self,
path: &Path,
name: &str,
conn: &mut SqliteConnection,
) -> Result<usize, IconDatabaseError> {
use crate::schema::general_icons;
// Load image
let img = image::open(path)?;
let (width, height) = (img.width(), img.height());
let rgba = img.to_rgba8();
let mut total_bytes = 0;
// Original size (lossless)
let icon_original = ImageProcessor::encode_webp_lossless(&rgba)
.map_err(|e| IconDatabaseError::IoError(std::io::Error::other(e.to_string())))?;
total_bytes += icon_original.len();
// Generate smaller sizes only if image is large enough (no upscaling)
let processor = ImageProcessor::new(90.0);
let icon_256 = if width >= 256 && height >= 256 {
Some(self.resize_and_encode(&img, 256, &processor)?)
} else {
None
};
if let Some(ref data) = icon_256 {
total_bytes += data.len();
}
let icon_64 = if width >= 64 && height >= 64 {
Some(self.resize_and_encode(&img, 64, &processor)?)
} else {
None
};
if let Some(ref data) = icon_64 {
total_bytes += data.len();
}
let icon_32 = if width >= 32 && height >= 32 {
Some(self.resize_and_encode(&img, 32, &processor)?)
} else {
None
};
if let Some(ref data) = icon_32 {
total_bytes += data.len();
}
let new_icon = NewGeneralIcon {
name,
original_width: width as i32,
original_height: height as i32,
icon_original: Some(&icon_original),
icon_256: icon_256.as_deref(),
icon_64: icon_64.as_deref(),
icon_32: icon_32.as_deref(),
};
diesel::replace_into(general_icons::table)
.values(&new_icon)
.execute(conn)?;
Ok(total_bytes)
}
/// Resize image and encode to WebP
fn resize_and_encode(
&self,
img: &image::DynamicImage,
size: u32,
_processor: &ImageProcessor,
) -> Result<Vec<u8>, IconDatabaseError> {
let resized = img.resize_exact(size, size, image::imageops::FilterType::Lanczos3);
let rgba = resized.to_rgba8();
// Use lossy encoding for smaller sizes
let encoder = webp::Encoder::from_rgba(rgba.as_raw(), size, size);
let webp_data = encoder.encode(90.0);
Ok(webp_data.to_vec())
}
/// Find all image files (PNG, JPG, etc.) in a directory
fn find_image_files<P: AsRef<Path>>(
&self,
dir: P,
) -> Result<Vec<PathBuf>, IconDatabaseError> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
let ext_lower = ext.to_lowercase();
if ext_lower == "png" || ext_lower == "jpg" || ext_lower == "jpeg" {
files.push(path);
}
}
}
}
files.sort();
Ok(files)
}
/// Find only PNG files in a directory
fn find_png_files<P: AsRef<Path>>(
&self,
dir: P,
) -> Result<Vec<PathBuf>, IconDatabaseError> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
let ext_lower = ext.to_lowercase();
if ext_lower == "png" {
files.push(path);
}
}
}
}
files.sort();
Ok(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};
@@ -516,11 +516,17 @@ impl ItemDatabase {
icon_base_path: &Path, icon_base_path: &Path,
item_id: i32, item_id: i32,
) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) { ) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) {
let icon_file = icon_base_path.join(format!("{}.png", item_id)); // Try both lowercase and uppercase extensions (Linux is case-sensitive)
let lowercase = icon_base_path.join(format!("{}.png", item_id));
let uppercase = icon_base_path.join(format!("{}.PNG", item_id));
if !icon_file.exists() { let icon_file = if lowercase.exists() {
lowercase
} else if uppercase.exists() {
uppercase
} else {
return (None, None, None); return (None, None, None);
} };
// Process image at 3 sizes: 256, 64, 16 // Process image at 3 sizes: 256, 64, 16
match processor.process_image(&icon_file, &[256, 64, 16], None, None) { match processor.process_image(&icon_file, &[256, 64, 16], None, None) {

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

@@ -9,6 +9,7 @@ mod player_house_database;
mod trait_database; mod trait_database;
mod shop_database; mod shop_database;
mod minimap_database; mod minimap_database;
mod icon_database;
pub use item_database::ItemDatabase; pub use item_database::ItemDatabase;
pub use npc_database::NpcDatabase; pub use npc_database::NpcDatabase;
@@ -21,3 +22,4 @@ pub use player_house_database::PlayerHouseDatabase;
pub use trait_database::TraitDatabase; pub use trait_database::TraitDatabase;
pub use shop_database::ShopDatabase; pub use shop_database::ShopDatabase;
pub use minimap_database::{MinimapDatabase, MinimapDatabaseError, StorageStats}; pub use minimap_database::{MinimapDatabase, MinimapDatabaseError, StorageStats};
pub use icon_database::{IconDatabase, IconDatabaseError, IconStats};

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

@@ -132,16 +132,6 @@ impl ImageProcessor {
Ok(ProcessedImages { images }) Ok(ProcessedImages { images })
} }
/// Load PNG and generate 4 WebP sizes specifically for minimap tiles (512x512 source)
///
/// Convenience method that generates 512, 256, 128, and 64 pixel versions
pub fn process_minimap_png<P: AsRef<Path>>(
&self,
png_path: P,
) -> Result<ProcessedImages, ImageProcessingError> {
self.process_image(png_path, &[512, 256, 128, 64], Some((512, 512)), None)
}
/// Apply outline effect to image based on alpha channel edges /// Apply outline effect to image based on alpha channel edges
fn apply_outline(&self, img: RgbaImage, config: &OutlineConfig) -> RgbaImage { fn apply_outline(&self, img: RgbaImage, config: &OutlineConfig) -> RgbaImage {
let (width, height) = img.dimensions(); let (width, height) = img.dimensions();

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;
@@ -70,6 +70,9 @@ pub use databases::{
MinimapDatabase, MinimapDatabase,
MinimapDatabaseError, MinimapDatabaseError,
StorageStats, StorageStats,
IconDatabase,
IconDatabaseError,
IconStats,
}; };
pub use types::{ pub use types::{
// Items // Items
@@ -124,6 +127,21 @@ pub use types::{
MinimapTile, MinimapTile,
MinimapTileRecord, MinimapTileRecord,
NewMinimapTile, NewMinimapTile,
// Icons
AbilityIconRecord,
NewAbilityIcon,
BuffIconRecord,
NewBuffIcon,
TraitIconRecord,
NewTraitIcon,
PlayerHouseIconRecord,
NewPlayerHouseIcon,
StatIconRecord,
NewStatIcon,
AchievementIconRecord,
NewAchievementIcon,
GeneralIconRecord,
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,26 @@
// @generated automatically by Diesel CLI. // @generated automatically by Diesel CLI.
diesel::table! {
ability_icons (name) {
name -> Text,
icon -> Binary,
}
}
diesel::table! {
achievement_icons (name) {
name -> Text,
icon -> Binary,
}
}
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,
@@ -22,11 +43,26 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
fast_travel_locations (id) { fast_travel_locations (name) {
id -> Nullable<Integer>, name -> Nullable<Text>,
pos_x -> Float,
pos_z -> Float,
travel_type -> Text,
unlocked -> Integer,
connections -> Nullable<Text>,
checks -> Nullable<Text>,
}
}
diesel::table! {
general_icons (name) {
name -> Text, name -> Text,
map_name -> Text, original_width -> Integer,
data -> Text, original_height -> Integer,
icon_original -> Nullable<Binary>,
icon_256 -> Nullable<Binary>,
icon_64 -> Nullable<Binary>,
icon_32 -> Nullable<Binary>,
} }
} }
@@ -141,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,
} }
} }
@@ -176,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>,
@@ -251,9 +310,13 @@ 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,
buff_icons,
crafting_recipe_items, crafting_recipe_items,
crafting_recipes, crafting_recipes,
fast_travel_locations, fast_travel_locations,
general_icons,
harvestable_drops, harvestable_drops,
harvestables, harvestables,
item_stats, item_stats,
@@ -262,10 +325,13 @@ diesel::allow_tables_to_appear_in_same_query!(
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

@@ -0,0 +1,134 @@
use diesel::prelude::*;
use crate::schema::{
ability_icons, buff_icons, trait_icons, player_house_icons, stat_icons,
achievement_icons, general_icons
};
/// Diesel queryable model for ability_icons table
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = ability_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct AbilityIconRecord {
pub name: String,
pub icon: Vec<u8>,
}
/// Diesel insertable model for ability_icons table
#[derive(Insertable, Debug)]
#[diesel(table_name = ability_icons)]
pub struct NewAbilityIcon<'a> {
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 icon: &'a [u8],
}
/// Diesel queryable model for achievement_icons table
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = achievement_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct AchievementIconRecord {
pub name: String,
pub icon: Vec<u8>,
}
/// Diesel insertable model for achievement_icons table
#[derive(Insertable, Debug)]
#[diesel(table_name = achievement_icons)]
pub struct NewAchievementIcon<'a> {
pub name: &'a str,
pub icon: &'a [u8],
}
/// Diesel queryable model for general_icons table
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = general_icons)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct GeneralIconRecord {
pub name: String,
pub original_width: i32,
pub original_height: i32,
pub icon_original: Option<Vec<u8>>,
pub icon_256: Option<Vec<u8>>,
pub icon_64: Option<Vec<u8>>,
pub icon_32: Option<Vec<u8>>,
}
/// Diesel insertable model for general_icons table
#[derive(Insertable, Debug)]
#[diesel(table_name = general_icons)]
pub struct NewGeneralIcon<'a> {
pub name: &'a str,
pub original_width: i32,
pub original_height: i32,
pub icon_original: Option<&'a [u8]>,
pub icon_256: Option<&'a [u8]>,
pub icon_64: Option<&'a [u8]>,
pub icon_32: Option<&'a [u8]>,
}

View File

@@ -10,6 +10,7 @@ mod r#trait;
mod shop; mod shop;
mod minimap_tile; mod minimap_tile;
mod minimap_models; mod minimap_models;
mod icon_models;
pub use item::{ pub use item::{
// Main types // Main types
@@ -44,3 +45,19 @@ pub use r#trait::{Trait, TraitTrainer};
pub use shop::{Shop, ShopItem}; 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::{
AbilityIconRecord,
NewAbilityIcon,
BuffIconRecord,
NewBuffIcon,
TraitIconRecord,
NewTraitIcon,
PlayerHouseIconRecord,
NewPlayerHouseIcon,
StatIconRecord,
NewStatIcon,
AchievementIconRecord,
NewAchievementIcon,
GeneralIconRecord,
NewGeneralIcon,
};

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

View File

@@ -1,8 +1,8 @@
/// Interactable_TeleporterTeleporter component from Cursebreaker /// Interactable_TeleporterDoor component from Cursebreaker
/// ///
/// C# definition from Interactable_TeleporterTeleporter.cs: /// C# definition from Interactable_TeleporterDoor.cs:
/// ```csharp /// ```csharp
/// public class Interactable_TeleporterTeleporter : MonoBehaviour /// public class Interactable_TeleporterDoor : MonoBehaviour
/// { /// {
/// public Transform tpTransform; /// public Transform tpTransform;
/// } /// }
@@ -53,7 +53,7 @@ impl EcsInsertable for InteractableTeleporter {
inventory::submit! { inventory::submit! {
unity_parser::ComponentRegistration { unity_parser::ComponentRegistration {
type_id: 114, type_id: 114,
class_name: "Interactable_TeleporterTeleporter", class_name: "Interactable_TeleporterDoorEditor",
parse_and_insert: |yaml, ctx, world, entity| { parse_and_insert: |yaml, ctx, world, entity| {
<InteractableTeleporter as EcsInsertable>::parse_and_insert(yaml, ctx, world, entity) <InteractableTeleporter as EcsInsertable>::parse_and_insert(yaml, ctx, world, entity)
}, },

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

View File

@@ -1,6 +1,6 @@
//! ECS world building from Unity documents //! ECS world building from Unity documents
use log::{info, warn}; use log::{info, warn, debug};
use crate::model::RawDocument; use crate::model::RawDocument;
use crate::parser::{GuidResolver, PrefabGuidResolver}; use crate::parser::{GuidResolver, PrefabGuidResolver};
@@ -150,7 +150,7 @@ pub fn build_world_from_documents(
} }
Err(e) => { Err(e) => {
// Soft failure - warn but continue // Soft failure - warn but continue
warn!("Failed to instantiate prefab: {}", e); debug!("Failed to instantiate prefab: {}", e);
} }
} }