Compare commits
3 Commits
642ba643ad
...
ccc9a894b7
| Author | SHA1 | Date | |
|---|---|---|---|
| ccc9a894b7 | |||
| cdfab8fd1e | |||
| 99aecaefde |
@@ -44,7 +44,8 @@
|
||||
"Bash(timeout 10 cargo run:*)",
|
||||
"Bash(timeout 60 cargo run:*)",
|
||||
"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": [
|
||||
"/home/connor/repos/CBAssets/"
|
||||
|
||||
1
.env
Normal file
1
.env
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL=/home/connor/repos/cursebreaker-parser-rust/cursebreaker.db
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ target/
|
||||
# Test data (cloned Unity projects for integration tests)
|
||||
test_data/
|
||||
cursebreaker.db
|
||||
**/cursebreaker.db
|
||||
|
||||
115
Cargo.lock
generated
115
Cargo.lock
generated
@@ -50,6 +50,56 @@ dependencies = [
|
||||
"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]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
@@ -303,12 +353,58 @@ dependencies = [
|
||||
"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]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -387,6 +483,7 @@ name = "cursebreaker-parser"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
"image",
|
||||
@@ -957,6 +1054,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1256,6 +1359,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2138,6 +2247,12 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "v_frame"
|
||||
version = "0.3.9"
|
||||
|
||||
@@ -49,6 +49,67 @@ struct Position {
|
||||
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
|
||||
fn establish_connection(database_url: &str) -> Result<DbConnection, diesel::ConnectionError> {
|
||||
SqliteConnection::establish(database_url)
|
||||
@@ -201,6 +262,212 @@ async fn get_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]
|
||||
async fn main() {
|
||||
// Initialize tracing
|
||||
@@ -219,6 +486,10 @@ async fn main() {
|
||||
.route("/api/bounds", get(get_bounds))
|
||||
.route("/api/tiles/:z/:x/:y", get(get_tile))
|
||||
.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"))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
@@ -21,6 +21,46 @@
|
||||
<p class="subtitle">The Black Grimoire: Cursebreaker</p>
|
||||
</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">
|
||||
<h3>Resources</h3>
|
||||
<div class="filter-controls">
|
||||
@@ -59,5 +99,6 @@
|
||||
<!-- Custom JS -->
|
||||
<script src="map.js"></script>
|
||||
<script src="resources.js"></script>
|
||||
<script src="markers.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -120,6 +120,9 @@ async function initMap() {
|
||||
console.error('Failed to load resources:', error);
|
||||
});
|
||||
|
||||
// Load markers (labels, entrances, ground items, houses)
|
||||
initMarkers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing map:', error);
|
||||
document.getElementById('map-stats').innerHTML =
|
||||
|
||||
377
cursebreaker-map/static/markers.js
Normal file
377
cursebreaker-map/static/markers.js
Normal 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
|
||||
@@ -259,6 +259,53 @@ body {
|
||||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
|
||||
@@ -42,6 +42,7 @@ image = "0.25"
|
||||
webp = "0.3"
|
||||
thiserror = "1.0"
|
||||
chrono = "0.4"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
diesel_migrations = "2.2"
|
||||
|
||||
278
cursebreaker-parser/XML_PARSER.md
Normal file
278
cursebreaker-parser/XML_PARSER.md
Normal 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**
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS icons;
|
||||
DROP TABLE IF EXISTS achievement_icons;
|
||||
DROP TABLE IF EXISTS general_icons;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -8,51 +8,92 @@
|
||||
//! - Storing all tiles in the SQLite database
|
||||
//! - Generating statistics about storage and compression
|
||||
|
||||
use cursebreaker_parser::MinimapDatabase;
|
||||
use log::{info, error, LevelFilter};
|
||||
use unity_parser::log::DedupLogger;
|
||||
use clap::Parser;
|
||||
use cursebreaker_parser::{IconDatabase, MinimapDatabase};
|
||||
use log::{error, info, LevelFilter};
|
||||
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>> {
|
||||
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();
|
||||
log::set_boxed_logger(Box::new(logger))
|
||||
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||
.unwrap();
|
||||
|
||||
info!("🎮 Cursebreaker - Image Parser");
|
||||
info!("Image Parser");
|
||||
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
|
||||
info!("🗺️ Processing minimap tiles...");
|
||||
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string());
|
||||
let minimap_db = MinimapDatabase::new(database_url);
|
||||
|
||||
let cb_assets_path = env::var("CB_ASSETS_PATH")
|
||||
.unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||
if process_minimap {
|
||||
info!("Processing minimap tiles...");
|
||||
let minimap_db = MinimapDatabase::new(database_url.clone());
|
||||
let minimap_path = format!("{}/Data/Textures/MinimapSquares", cb_assets_path);
|
||||
|
||||
match minimap_db.load_from_directory(&minimap_path, &cb_assets_path) {
|
||||
Ok(total_count) => {
|
||||
info!("\n✅ Processed {} total tiles (all zoom levels)", total_count);
|
||||
info!("\nProcessed {} total tiles (all zoom levels)", total_count);
|
||||
|
||||
// Get statistics
|
||||
if let Ok(stats) = minimap_db.get_storage_stats() {
|
||||
info!("\n=== Storage Statistics ===");
|
||||
info!("Original PNG total: {} MB", stats.total_original_size / 1_048_576);
|
||||
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());
|
||||
|
||||
info!("=== Tiles Per Zoom Level ===");
|
||||
info!("Zoom 2 (original): {} tiles ({} MB)",
|
||||
info!(
|
||||
"Zoom 2 (original): {} tiles ({} MB)",
|
||||
stats.zoom2_count,
|
||||
stats.zoom2_size / 1_048_576
|
||||
);
|
||||
info!("Zoom 1 (2x2 merged): {} tiles ({} MB)",
|
||||
info!(
|
||||
"Zoom 1 (2x2 merged): {} tiles ({} MB)",
|
||||
stats.zoom1_count,
|
||||
stats.zoom1_size / 1_048_576
|
||||
);
|
||||
info!("Zoom 0 (4x4 merged): {} tiles ({} MB)",
|
||||
info!(
|
||||
"Zoom 0 (4x4 merged): {} tiles ({} MB)",
|
||||
stats.zoom0_count,
|
||||
stats.zoom0_size / 1_048_576
|
||||
);
|
||||
@@ -69,6 +110,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::logger().flush();
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
//! - Computing world transforms
|
||||
//! - Saving resource locations to the database
|
||||
//! - 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::{
|
||||
InteractableResource, InteractableTeleporter, InteractableWorkbench,
|
||||
@@ -20,15 +27,111 @@ use std::env;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
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>> {
|
||||
let logger = DedupLogger::new();
|
||||
log::set_boxed_logger(Box::new(logger))
|
||||
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||
.map(|()| log::set_max_level(LevelFilter::Warn))
|
||||
.unwrap();
|
||||
|
||||
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());
|
||||
|
||||
// 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)?;
|
||||
|
||||
// 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
|
||||
info!("🔍 Setting up type filter:");
|
||||
info!(" • Unity types: GameObject, Transform");
|
||||
@@ -46,48 +159,120 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
vec!["InteractableResource", "InteractableTeleporter", "InteractableWorkbench", "LootSpawner", "MapIcon", "MapNameChanger"]
|
||||
);
|
||||
|
||||
// Now parse the scene using the pre-built GUID resolvers with filtering
|
||||
let scene_path = "_GameAssets/Scenes/Tiles/10_3.unity";
|
||||
info!("📁 Parsing scene: {}", scene_path);
|
||||
// Setup database connection
|
||||
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "cursebreaker.db".to_string());
|
||||
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();
|
||||
|
||||
// Parse the scene using the project with type filtering
|
||||
match project.parse_scene_filtered(scene_path, Some(&type_filter)) {
|
||||
Ok(mut scene) => {
|
||||
info!("✅ Scene parsed successfully!");
|
||||
info!(" Total entities: {}", scene.entity_map.len());
|
||||
// Process icons for all unique harvestables
|
||||
info!("\n🎨 Processing item icons for {} unique harvestable types...", all_unique_harvestables.len());
|
||||
process_item_icons_from_map(&cb_assets_path, &mut conn, &all_unique_harvestables)?;
|
||||
|
||||
// Post-processing: Compute world transforms
|
||||
info!("🔄 Computing world transforms...");
|
||||
unity_parser::compute_world_transforms(&mut scene.world, &scene.entity_map);
|
||||
info!(" ✓ World transforms computed");
|
||||
// Print summary
|
||||
println!("\n==================================================");
|
||||
println!("📊 SUMMARY");
|
||||
println!("==================================================");
|
||||
println!(" Scenes processed: {} ({} failed)", scenes_processed, scenes_failed);
|
||||
println!(" Resources: {}", total_resources);
|
||||
println!(" Teleporters: {}", total_teleporters);
|
||||
println!(" Workbenches: {}", total_workbenches);
|
||||
println!(" Loot spawners: {}", total_loot);
|
||||
println!(" Map icons: {}", total_map_icons);
|
||||
println!(" Map name changers:{}", total_map_name_changers);
|
||||
println!("==================================================");
|
||||
|
||||
// Save resources to database
|
||||
info!("💾 Saving resources to database...");
|
||||
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
|
||||
let mut conn = SqliteConnection::establish(&database_url)?;
|
||||
log::logger().flush();
|
||||
|
||||
// Use diesel schema
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save resources from a scene (append mode)
|
||||
fn save_resources(
|
||||
conn: &mut SqliteConnection,
|
||||
scene: &unity_parser::UnityScene,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
use cursebreaker_parser::schema::world_resources;
|
||||
|
||||
// Clear the entire table (it's regenerated each run)
|
||||
diesel::delete(world_resources::table).execute(&mut conn)?;
|
||||
let mut count = 0;
|
||||
|
||||
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)| {
|
||||
.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),
|
||||
@@ -96,67 +281,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
))
|
||||
.execute(conn);
|
||||
|
||||
resource_count += 1;
|
||||
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();
|
||||
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Process item icons for all resources in the scene
|
||||
fn process_item_icons(
|
||||
/// Process item icons from a pre-collected map of harvestables
|
||||
fn process_item_icons_from_map(
|
||||
cb_assets_path: &str,
|
||||
conn: &mut SqliteConnection,
|
||||
scene: &unity_parser::UnityScene,
|
||||
unique_harvestables: &HashMap<i32, String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use cursebreaker_parser::schema::{resource_icons, items, harvestables, harvestable_drops};
|
||||
|
||||
// Collect unique harvestable IDs from resources
|
||||
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)?;
|
||||
info!(" Processing {} unique harvestable types", unique_harvestables.len());
|
||||
|
||||
// Create image processor with white outline
|
||||
let processor = ImageProcessor::default();
|
||||
@@ -249,22 +390,19 @@ fn process_item_icons(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save teleporter data to database
|
||||
fn save_teleporters(
|
||||
/// Save teleporter data to database (append mode - doesn't clear table)
|
||||
fn save_teleporters_append(
|
||||
conn: &mut SqliteConnection,
|
||||
scene: &unity_parser::UnityScene,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
use cursebreaker_parser::schema::world_teleporters;
|
||||
|
||||
// Clear existing teleporters
|
||||
diesel::delete(world_teleporters::table).execute(conn)?;
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
// Query all teleporters
|
||||
scene.world
|
||||
.query_all::<(&InteractableTeleporter, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||
.for_each(|(teleporter, transform, object)| {
|
||||
.for_each(|(teleporter, transform, _object)| {
|
||||
let world_pos = transform.position();
|
||||
|
||||
// Get the tp_transform position if it exists
|
||||
@@ -279,9 +417,6 @@ fn save_teleporters(
|
||||
(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)
|
||||
.values((
|
||||
world_teleporters::pos_x.eq(world_pos.x as f32),
|
||||
@@ -294,31 +429,24 @@ fn save_teleporters(
|
||||
count += 1;
|
||||
});
|
||||
|
||||
info!("✅ Saved {} teleporters to database", count);
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Save workbench data to database
|
||||
fn save_workbenches(
|
||||
/// Save workbench data to database (append mode - doesn't clear table)
|
||||
fn save_workbenches_append(
|
||||
conn: &mut SqliteConnection,
|
||||
scene: &unity_parser::UnityScene,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
use cursebreaker_parser::schema::world_workbenches;
|
||||
|
||||
// Clear existing workbenches
|
||||
diesel::delete(world_workbenches::table).execute(conn)?;
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
// Query all workbenches
|
||||
scene.world
|
||||
.query_all::<(&InteractableWorkbench, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||
.for_each(|(workbench, transform, object)| {
|
||||
.for_each(|(workbench, transform, _object)| {
|
||||
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)
|
||||
.values((
|
||||
world_workbenches::pos_x.eq(world_pos.x as f32),
|
||||
@@ -330,31 +458,24 @@ fn save_workbenches(
|
||||
count += 1;
|
||||
});
|
||||
|
||||
info!("✅ Saved {} workbenches to database", count);
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Save loot spawner data to database
|
||||
fn save_loot_spawners(
|
||||
/// Save loot spawner data to database (append mode - doesn't clear table)
|
||||
fn save_loot_spawners_append(
|
||||
conn: &mut SqliteConnection,
|
||||
scene: &unity_parser::UnityScene,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
use cursebreaker_parser::schema::world_loot;
|
||||
|
||||
// Clear existing loot spawners
|
||||
diesel::delete(world_loot::table).execute(conn)?;
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
// Query all loot spawners
|
||||
scene.world
|
||||
.query_all::<(&LootSpawner, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||
.for_each(|(loot, transform, object)| {
|
||||
.for_each(|(loot, transform, _object)| {
|
||||
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)
|
||||
.values((
|
||||
world_loot::pos_x.eq(world_pos.x as f32),
|
||||
@@ -369,31 +490,24 @@ fn save_loot_spawners(
|
||||
count += 1;
|
||||
});
|
||||
|
||||
info!("✅ Saved {} loot spawners to database", count);
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Save map icon data to database
|
||||
fn save_map_icons(
|
||||
/// Save map icon data to database (append mode - doesn't clear table)
|
||||
fn save_map_icons_append(
|
||||
conn: &mut SqliteConnection,
|
||||
scene: &unity_parser::UnityScene,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
use cursebreaker_parser::schema::world_map_icons;
|
||||
|
||||
// Clear existing map icons
|
||||
diesel::delete(world_map_icons::table).execute(conn)?;
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
// Query all map icons
|
||||
scene.world
|
||||
.query_all::<(&MapIcon, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||
.for_each(|(icon, transform, object)| {
|
||||
.for_each(|(icon, transform, _object)| {
|
||||
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)
|
||||
.values((
|
||||
world_map_icons::pos_x.eq(world_pos.x as f32),
|
||||
@@ -410,31 +524,24 @@ fn save_map_icons(
|
||||
count += 1;
|
||||
});
|
||||
|
||||
info!("✅ Saved {} map icons to database", count);
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Save map name changer data to database
|
||||
fn save_map_name_changers(
|
||||
/// Save map name changer data to database (append mode - doesn't clear table)
|
||||
fn save_map_name_changers_append(
|
||||
conn: &mut SqliteConnection,
|
||||
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;
|
||||
|
||||
// Clear existing map name changers
|
||||
diesel::delete(world_map_name_changers::table).execute(conn)?;
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
// Query all map name changers
|
||||
scene.world
|
||||
.query_all::<(&MapNameChanger, &unity_parser::WorldTransform, &unity_parser::GameObject)>()
|
||||
.for_each(|(changer, transform, object)| {
|
||||
.for_each(|(changer, transform, _object)| {
|
||||
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)
|
||||
.values((
|
||||
world_map_name_changers::pos_x.eq(world_pos.x as f32),
|
||||
@@ -446,6 +553,5 @@ fn save_map_name_changers(
|
||||
count += 1;
|
||||
});
|
||||
|
||||
info!("✅ Saved {} map name changers to database", count);
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,150 @@
|
||||
//! XML Parser - Loads game data from XML files and populates the SQLite database
|
||||
//!
|
||||
//! This binary handles:
|
||||
//! - Loading all game data from XML files
|
||||
//! - Populating the SQLite database with the parsed data
|
||||
//! - Generating statistics about the loaded data
|
||||
//! Usage:
|
||||
//! xml-parser --all Parse all data types
|
||||
//! xml-parser --items Parse items only
|
||||
//! 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 log::{info, warn, LevelFilter};
|
||||
use unity_parser::log::DedupLogger;
|
||||
use clap::Parser;
|
||||
use cursebreaker_parser::{
|
||||
ItemDatabase, NpcDatabase, QuestDatabase, HarvestableDatabase,
|
||||
LootDatabase, MapDatabase, FastTravelDatabase, PlayerHouseDatabase,
|
||||
TraitDatabase, ShopDatabase,
|
||||
};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use log::{info, warn, LevelFilter};
|
||||
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>> {
|
||||
let logger = DedupLogger::new();
|
||||
@@ -18,114 +152,190 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(|()| log::set_max_level(LevelFilter::Trace))
|
||||
.unwrap();
|
||||
|
||||
info!("🎮 Cursebreaker - XML Parser");
|
||||
info!("📚 Loading game data from XML...");
|
||||
let args = Args::parse();
|
||||
|
||||
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
|
||||
let items_path = format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path);
|
||||
let item_db = ItemDatabase::load_from_xml(items_path)?;
|
||||
info!("✅ Loaded {} items", item_db.len());
|
||||
info!("Cursebreaker - XML Parser");
|
||||
info!("Loading game data from XML...");
|
||||
|
||||
// let npcs_path = format!("{}/Data/XMLs/Npcs/NPCInfo.xml", cb_assets_path);
|
||||
// let npc_db = NpcDatabase::load_from_xml(npcs_path)?;
|
||||
// info!("✅ Loaded {} NPCs", npc_db.len());
|
||||
let cb_assets_path = env::var("CB_ASSETS_PATH")
|
||||
.unwrap_or_else(|_| "/home/connor/repos/CBAssets".to_string());
|
||||
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)?;
|
||||
|
||||
// Process and save items with icons
|
||||
// Parse Items
|
||||
if args.should_parse_items() {
|
||||
info!("Parsing items...");
|
||||
let items_path = format!("{}/Data/XMLs/Items/Items.xml", cb_assets_path);
|
||||
match ItemDatabase::load_from_xml(&items_path) {
|
||||
Ok(item_db) => {
|
||||
info!("Loaded {} items", item_db.len());
|
||||
let icon_path = format!("{}/Data/Textures/ItemIcons", cb_assets_path);
|
||||
info!("📸 Processing item icons from: {}", icon_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 (256px, 64px, 16px)", 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) {
|
||||
// Ok(count) => info!("✅ Saved {} NPCs to database", count),
|
||||
// Err(e) => warn!("⚠️ Failed to save NPCs: {}", e),
|
||||
// }
|
||||
// Parse NPCs
|
||||
if args.should_parse_npcs() {
|
||||
info!("Parsing NPCs...");
|
||||
let npcs_path = format!("{}/Data/XMLs/Npcs/NPCInfo.xml", cb_assets_path);
|
||||
match NpcDatabase::load_from_xml(&npcs_path) {
|
||||
Ok(npc_db) => {
|
||||
info!("Loaded {} NPCs", npc_db.len());
|
||||
match npc_db.save_to_db(&mut conn) {
|
||||
Ok(count) => info!("Saved {} NPCs to database", count),
|
||||
Err(e) => warn!("Failed to save NPCs: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to load NPCs: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// match quest_db.save_to_db(&mut conn) {
|
||||
// Ok(count) => info!("✅ Saved {} quests to database", count),
|
||||
// Err(e) => warn!("⚠️ Failed to save quests: {}", e),
|
||||
// }
|
||||
// Parse Quests
|
||||
if args.should_parse_quests() {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Harvestables
|
||||
if args.should_parse_harvestables() {
|
||||
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),
|
||||
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 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),
|
||||
// }
|
||||
// Parse Loot
|
||||
if args.should_parse_loot() {
|
||||
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 map_db.save_to_db(&mut conn) {
|
||||
// Ok(count) => info!("✅ Saved {} maps to database", count),
|
||||
// Err(e) => warn!("⚠️ Failed to save maps: {}", e),
|
||||
// }
|
||||
// Parse Maps
|
||||
if args.should_parse_maps() {
|
||||
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 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),
|
||||
// }
|
||||
// Parse Fast Travel
|
||||
if args.should_parse_fast_travel() {
|
||||
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 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),
|
||||
// }
|
||||
// Parse Player Houses
|
||||
if args.should_parse_houses() {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// match trait_db.save_to_db(&mut conn) {
|
||||
// Ok(count) => info!("✅ Saved {} traits to database", count),
|
||||
// Err(e) => warn!("⚠️ Failed to save traits: {}", 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),
|
||||
}
|
||||
}
|
||||
|
||||
// match shop_db.save_to_db(&mut conn) {
|
||||
// Ok(count) => info!("✅ Saved {} shops to database", count),
|
||||
// Err(e) => warn!("⚠️ Failed to save shops: {}", 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();
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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,
|
||||
XmlParseError,
|
||||
};
|
||||
@@ -236,44 +236,25 @@ impl FastTravelDatabase {
|
||||
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
|
||||
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
|
||||
use crate::schema::fast_travel_locations;
|
||||
|
||||
let records: Vec<_> = self
|
||||
.locations
|
||||
.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();
|
||||
// Clear existing entries
|
||||
diesel::delete(fast_travel_locations::table).execute(conn)?;
|
||||
|
||||
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)
|
||||
.values(&record)
|
||||
.execute(conn)?;
|
||||
@@ -288,21 +269,41 @@ impl FastTravelDatabase {
|
||||
use crate::schema::fast_travel_locations::dsl::*;
|
||||
|
||||
#[derive(Queryable)]
|
||||
#[allow(dead_code)]
|
||||
struct FastTravelLocationRecord {
|
||||
id: Option<i32>,
|
||||
name: String,
|
||||
map_name: String,
|
||||
data: String,
|
||||
name: Option<String>,
|
||||
pos_x: f32,
|
||||
pos_z: f32,
|
||||
travel_type: String,
|
||||
unlocked: i32,
|
||||
connections: Option<String>,
|
||||
checks: Option<String>,
|
||||
}
|
||||
|
||||
let records = fast_travel_locations.load::<FastTravelLocationRecord>(conn)?;
|
||||
|
||||
let mut loaded_locations = Vec::new();
|
||||
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() {
|
||||
"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();
|
||||
db.add_locations(loaded_locations);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
655
cursebreaker-parser/src/databases/icon_database.rs
Normal file
655
cursebreaker-parser/src/databases/icon_database.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::item_loader::{
|
||||
calculate_prices, generate_banknotes, generate_exceptional_items, load_items_from_directory,
|
||||
};
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -516,11 +516,17 @@ impl ItemDatabase {
|
||||
icon_base_path: &Path,
|
||||
item_id: i32,
|
||||
) -> (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);
|
||||
}
|
||||
};
|
||||
|
||||
// Process image at 3 sizes: 256, 64, 16
|
||||
match processor.process_image(&icon_file, &[256, 64, 16], None, None) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -9,6 +9,7 @@ mod player_house_database;
|
||||
mod trait_database;
|
||||
mod shop_database;
|
||||
mod minimap_database;
|
||||
mod icon_database;
|
||||
|
||||
pub use item_database::ItemDatabase;
|
||||
pub use npc_database::NpcDatabase;
|
||||
@@ -21,3 +22,4 @@ pub use player_house_database::PlayerHouseDatabase;
|
||||
pub use trait_database::TraitDatabase;
|
||||
pub use shop_database::ShopDatabase;
|
||||
pub use minimap_database::{MinimapDatabase, MinimapDatabaseError, StorageStats};
|
||||
pub use icon_database::{IconDatabase, IconDatabaseError, IconStats};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
@@ -76,16 +76,6 @@ impl PlayerHouseDatabase {
|
||||
&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)
|
||||
pub fn get_free_houses(&self) -> Vec<&PlayerHouse> {
|
||||
self.houses.iter().filter(|h| h.is_free()).collect()
|
||||
@@ -152,32 +142,24 @@ impl PlayerHouseDatabase {
|
||||
self.houses.is_empty()
|
||||
}
|
||||
|
||||
/// Prepare player houses 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, 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
|
||||
/// Save all player houses to SQLite database (clears existing entries first)
|
||||
pub fn save_to_db(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
|
||||
use crate::schema::player_houses;
|
||||
|
||||
// Clear existing entries
|
||||
diesel::delete(player_houses::table).execute(conn)?;
|
||||
|
||||
let records: Vec<_> = self
|
||||
.houses
|
||||
.iter()
|
||||
.map(|house| {
|
||||
let json = serde_json::to_string(house).unwrap_or_else(|_| "{}".to_string());
|
||||
(
|
||||
player_houses::id.eq(house.id),
|
||||
player_houses::name.eq(&house.name),
|
||||
player_houses::map_id.eq(0), // TODO: determine actual map ID
|
||||
player_houses::data.eq(json),
|
||||
player_houses::description.eq(&house.description),
|
||||
player_houses::pos_x.eq(house.pos_x),
|
||||
player_houses::pos_z.eq(house.pos_z),
|
||||
player_houses::price.eq(house.price),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -199,20 +181,31 @@ impl PlayerHouseDatabase {
|
||||
|
||||
#[derive(Queryable)]
|
||||
struct PlayerHouseRecord {
|
||||
id: Option<i32>,
|
||||
record_id: Option<i32>,
|
||||
name: String,
|
||||
map_id: i32,
|
||||
data: String,
|
||||
description: String,
|
||||
pos_x: f32,
|
||||
pos_z: f32,
|
||||
price: i32,
|
||||
}
|
||||
|
||||
let records = player_houses.load::<PlayerHouseRecord>(conn)?;
|
||||
|
||||
let mut loaded_houses = Vec::new();
|
||||
for record in records {
|
||||
if let Ok(house) = serde_json::from_str::<PlayerHouse>(&record.data) {
|
||||
loaded_houses.push(house);
|
||||
}
|
||||
}
|
||||
let loaded_houses: Vec<PlayerHouse> = records
|
||||
.into_iter()
|
||||
.filter_map(|record| {
|
||||
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();
|
||||
db.add_houses(loaded_houses);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::sqlite::SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -132,16 +132,6 @@ impl ImageProcessor {
|
||||
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
|
||||
fn apply_outline(&self, img: RgbaImage, config: &OutlineConfig) -> RgbaImage {
|
||||
let (width, height) = img.dimensions();
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::types::{
|
||||
ItemCategory, ItemType, ItemXpBoost, PermanentStatBoost, SkillType, Stat, StatType,
|
||||
Tool, MAX_STACK,
|
||||
};
|
||||
use crate::xml_parser::XmlParseError;
|
||||
use crate::xml_parsers::XmlParseError;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
pub mod types;
|
||||
pub mod databases;
|
||||
pub mod schema;
|
||||
mod xml_parser;
|
||||
mod xml_parsers;
|
||||
mod item_loader;
|
||||
mod image_processor;
|
||||
|
||||
@@ -70,6 +70,9 @@ pub use databases::{
|
||||
MinimapDatabase,
|
||||
MinimapDatabaseError,
|
||||
StorageStats,
|
||||
IconDatabase,
|
||||
IconDatabaseError,
|
||||
IconStats,
|
||||
};
|
||||
pub use types::{
|
||||
// Items
|
||||
@@ -124,6 +127,21 @@ pub use types::{
|
||||
MinimapTile,
|
||||
MinimapTileRecord,
|
||||
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};
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
// @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! {
|
||||
crafting_recipe_items (recipe_id, item_id) {
|
||||
recipe_id -> Integer,
|
||||
@@ -22,11 +43,26 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
fast_travel_locations (id) {
|
||||
id -> Nullable<Integer>,
|
||||
fast_travel_locations (name) {
|
||||
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,
|
||||
map_name -> Text,
|
||||
data -> Text,
|
||||
original_width -> Integer,
|
||||
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! {
|
||||
player_houses (id) {
|
||||
id -> Nullable<Integer>,
|
||||
name -> Text,
|
||||
map_id -> Integer,
|
||||
data -> Text,
|
||||
description -> 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! {
|
||||
traits (id) {
|
||||
id -> Nullable<Integer>,
|
||||
@@ -251,9 +310,13 @@ diesel::joinable!(harvestable_drops -> items (item_id));
|
||||
diesel::joinable!(item_stats -> items (item_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
ability_icons,
|
||||
achievement_icons,
|
||||
buff_icons,
|
||||
crafting_recipe_items,
|
||||
crafting_recipes,
|
||||
fast_travel_locations,
|
||||
general_icons,
|
||||
harvestable_drops,
|
||||
harvestables,
|
||||
item_stats,
|
||||
@@ -262,10 +325,13 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
maps,
|
||||
minimap_tiles,
|
||||
npcs,
|
||||
player_house_icons,
|
||||
player_houses,
|
||||
quests,
|
||||
resource_icons,
|
||||
shops,
|
||||
stat_icons,
|
||||
trait_icons,
|
||||
traits,
|
||||
world_loot,
|
||||
world_map_icons,
|
||||
|
||||
@@ -30,8 +30,11 @@ pub struct FastTravelLocation {
|
||||
/// Display name
|
||||
pub name: String,
|
||||
|
||||
/// 3D position in world space (x,y,z)
|
||||
pub position: String,
|
||||
/// X position in world space
|
||||
pub pos_x: f32,
|
||||
|
||||
/// Z position in world space
|
||||
pub pos_z: f32,
|
||||
|
||||
/// Type of fast travel
|
||||
pub travel_type: FastTravelType,
|
||||
@@ -49,11 +52,12 @@ pub struct FastTravelLocation {
|
||||
|
||||
impl FastTravelLocation {
|
||||
/// 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 {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
pos_x,
|
||||
pos_z,
|
||||
travel_type,
|
||||
unlocked: false,
|
||||
connections: None,
|
||||
@@ -61,19 +65,9 @@ impl FastTravelLocation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse position into (x, y, z) coordinates
|
||||
pub fn get_position(&self) -> Option<(f32, f32, f32)> {
|
||||
let parts: Vec<&str> = self.position.split(',').collect();
|
||||
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 position as (x, z) tuple
|
||||
pub fn get_position(&self) -> (f32, f32) {
|
||||
(self.pos_x, self.pos_z)
|
||||
}
|
||||
|
||||
/// Get list of connected location IDs
|
||||
|
||||
134
cursebreaker-parser/src/types/cursebreaker/icon_models.rs
Normal file
134
cursebreaker-parser/src/types/cursebreaker/icon_models.rs
Normal 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]>,
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod r#trait;
|
||||
mod shop;
|
||||
mod minimap_tile;
|
||||
mod minimap_models;
|
||||
mod icon_models;
|
||||
|
||||
pub use item::{
|
||||
// Main types
|
||||
@@ -44,3 +45,19 @@ pub use r#trait::{Trait, TraitTrainer};
|
||||
pub use shop::{Shop, ShopItem};
|
||||
pub use minimap_tile::MinimapTile;
|
||||
pub use minimap_models::{MinimapTileRecord, NewMinimapTile};
|
||||
pub use icon_models::{
|
||||
AbilityIconRecord,
|
||||
NewAbilityIcon,
|
||||
BuffIconRecord,
|
||||
NewBuffIcon,
|
||||
TraitIconRecord,
|
||||
NewTraitIcon,
|
||||
PlayerHouseIconRecord,
|
||||
NewPlayerHouseIcon,
|
||||
StatIconRecord,
|
||||
NewStatIcon,
|
||||
AchievementIconRecord,
|
||||
NewAchievementIcon,
|
||||
GeneralIconRecord,
|
||||
NewGeneralIcon,
|
||||
};
|
||||
|
||||
@@ -12,42 +12,32 @@ pub struct PlayerHouse {
|
||||
/// Description text
|
||||
pub description: String,
|
||||
|
||||
/// 3D position in world space (x,y,z)
|
||||
pub position: String,
|
||||
/// X position in world space
|
||||
pub pos_x: f32,
|
||||
|
||||
/// Z position in world space
|
||||
pub pos_z: f32,
|
||||
|
||||
/// Purchase price in gold
|
||||
pub price: i32,
|
||||
|
||||
/// Whether this house is hidden (not shown in normal lists)
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
impl PlayerHouse {
|
||||
/// 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 {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
position,
|
||||
pos_x,
|
||||
pos_z,
|
||||
price,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse position into (x, y, z) coordinates
|
||||
pub fn get_position(&self) -> Option<(f32, f32, f32)> {
|
||||
let parts: Vec<&str> = self.position.split(',').collect();
|
||||
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 position as (x, z) tuple
|
||||
pub fn get_position(&self) -> (f32, f32) {
|
||||
(self.pos_x, self.pos_z)
|
||||
}
|
||||
|
||||
/// Check if this house is free (price is 0)
|
||||
@@ -55,11 +45,6 @@ impl PlayerHouse {
|
||||
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)
|
||||
pub fn is_expensive(&self) -> bool {
|
||||
self.price >= 10000
|
||||
|
||||
@@ -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
|
||||
/// public class Interactable_TeleporterTeleporter : MonoBehaviour
|
||||
/// public class Interactable_TeleporterDoor : MonoBehaviour
|
||||
/// {
|
||||
/// public Transform tpTransform;
|
||||
/// }
|
||||
@@ -53,7 +53,7 @@ impl EcsInsertable for InteractableTeleporter {
|
||||
inventory::submit! {
|
||||
unity_parser::ComponentRegistration {
|
||||
type_id: 114,
|
||||
class_name: "Interactable_TeleporterTeleporter",
|
||||
class_name: "Interactable_TeleporterDoorEditor",
|
||||
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
127
cursebreaker-parser/src/xml_parsers/fast_travel.rs
Normal file
127
cursebreaker-parser/src/xml_parsers/fast_travel.rs
Normal 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))
|
||||
}
|
||||
127
cursebreaker-parser/src/xml_parsers/harvestables.rs
Normal file
127
cursebreaker-parser/src/xml_parsers/harvestables.rs
Normal 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)
|
||||
}
|
||||
141
cursebreaker-parser/src/xml_parsers/items.rs
Normal file
141
cursebreaker-parser/src/xml_parsers/items.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
88
cursebreaker-parser/src/xml_parsers/loot.rs
Normal file
88
cursebreaker-parser/src/xml_parsers/loot.rs
Normal 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)
|
||||
}
|
||||
128
cursebreaker-parser/src/xml_parsers/maps.rs
Normal file
128
cursebreaker-parser/src/xml_parsers/maps.rs
Normal 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)
|
||||
}
|
||||
91
cursebreaker-parser/src/xml_parsers/mod.rs
Normal file
91
cursebreaker-parser/src/xml_parsers/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
243
cursebreaker-parser/src/xml_parsers/npcs.rs
Normal file
243
cursebreaker-parser/src/xml_parsers/npcs.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
93
cursebreaker-parser/src/xml_parsers/player_houses.rs
Normal file
93
cursebreaker-parser/src/xml_parsers/player_houses.rs
Normal 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)
|
||||
}
|
||||
106
cursebreaker-parser/src/xml_parsers/quests.rs
Normal file
106
cursebreaker-parser/src/xml_parsers/quests.rs
Normal 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)
|
||||
}
|
||||
105
cursebreaker-parser/src/xml_parsers/shops.rs
Normal file
105
cursebreaker-parser/src/xml_parsers/shops.rs
Normal 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)
|
||||
}
|
||||
99
cursebreaker-parser/src/xml_parsers/traits.rs
Normal file
99
cursebreaker-parser/src/xml_parsers/traits.rs
Normal 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)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! ECS world building from Unity documents
|
||||
|
||||
use log::{info, warn};
|
||||
use log::{info, warn, debug};
|
||||
|
||||
use crate::model::RawDocument;
|
||||
use crate::parser::{GuidResolver, PrefabGuidResolver};
|
||||
@@ -150,7 +150,7 @@ pub fn build_world_from_documents(
|
||||
}
|
||||
Err(e) => {
|
||||
// Soft failure - warn but continue
|
||||
warn!("Failed to instantiate prefab: {}", e);
|
||||
debug!("Failed to instantiate prefab: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user