Compare commits

..

10 Commits

Author SHA1 Message Date
9143f73317 DB addition 2026-01-16 09:33:30 +00:00
3012599263 resource icons DB 2026-01-12 07:19:38 +00:00
e53deb57bb resource icons 2026-01-12 06:06:44 +00:00
50c28932e3 world resources to DB 2026-01-12 04:32:38 +00:00
2df606cbdd cursebreaker readme update 2026-01-12 04:32:20 +00:00
cca3469c1f item DB extension 2026-01-12 03:02:45 +00:00
7e399fe544 item DB upgrade 2026-01-11 13:48:15 +00:00
cf4fb922f0 selective parsing of scenes 2026-01-11 03:03:39 +00:00
fae8594bc1 interactive map init 2026-01-11 02:46:49 +00:00
4abca5928c different commands 2026-01-10 10:43:41 +00:00
26 changed files with 1037 additions and 416 deletions

View File

@@ -39,7 +39,12 @@
"Bash(DATABASE_URL=../cursebreaker-parser/cursebreaker.db cargo run:*)",
"Bash(identify:*)",
"Bash(diesel migration revert:*)",
"Bash(xargs:*)"
"Bash(xargs:*)",
"Bash(ss:*)",
"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:*)"
],
"additionalDirectories": [
"/home/connor/repos/CBAssets/"

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ target/
# Test data (cloned Unity projects for integration tests)
test_data/
cursebreaker.db

7
Cargo.lock generated
View File

@@ -215,6 +215,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -363,6 +369,7 @@ name = "cursebreaker-map"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"cursebreaker-parser",
"diesel",
"dotenvy",

View File

@@ -14,6 +14,7 @@ diesel = { version = "2.1", features = ["sqlite", "returning_clauses_for_sqlite_
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
base64 = "0.22"
[dependencies.cursebreaker-parser]
path = "../cursebreaker-parser"

View File

@@ -5,6 +5,7 @@ use axum::{
routing::get,
Json, Router,
};
use base64::Engine;
use diesel::prelude::*;
use serde::Serialize;
use std::sync::Arc;
@@ -27,6 +28,27 @@ struct MapBounds {
max_y: i32,
}
#[derive(Serialize)]
struct ResourceResponse {
resources: Vec<ResourceGroup>,
}
#[derive(Serialize)]
struct ResourceGroup {
item_id: i32,
name: String,
skill: String,
level: i32,
icon_base64: String,
positions: Vec<Position>,
}
#[derive(Serialize)]
struct Position {
x: f32,
y: f32,
}
// Establish database connection
fn establish_connection(database_url: &str) -> Result<DbConnection, diesel::ConnectionError> {
SqliteConnection::establish(database_url)
@@ -111,6 +133,74 @@ async fn get_tile(
}
}
// Get all resources with icons from database
async fn get_resources(
State(state): State<Arc<AppState>>,
) -> Result<Json<ResourceResponse>, StatusCode> {
use cursebreaker_parser::schema::{harvestables, resource_icons, world_resources};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Query with three-way join
let results = world_resources::table
.inner_join(
resource_icons::table.on(world_resources::item_id.eq(resource_icons::item_id)),
)
.inner_join(harvestables::table.on(resource_icons::item_id.eq(harvestables::id)))
.select((
resource_icons::item_id,
resource_icons::name,
harvestables::skill,
harvestables::level,
resource_icons::icon_64,
world_resources::pos_x,
world_resources::pos_y,
))
.order_by((harvestables::skill, harvestables::level))
.load::<(i32, String, String, i32, Vec<u8>, f32, f32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying resources: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Group results by item_id
use std::collections::HashMap;
let mut grouped: HashMap<i32, ResourceGroup> = HashMap::new();
for (item_id, name, skill, level, icon_bytes, pos_x, pos_y) in results {
let entry = grouped.entry(item_id).or_insert_with(|| {
// Convert icon to base64 (only once per resource type)
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
ResourceGroup {
item_id,
name,
skill,
level,
icon_base64,
positions: Vec::new(),
}
});
// Add position with multiplier applied
entry.positions.push(Position {
x: pos_x * 5.12,
y: pos_y * 5.12,
});
}
// Convert to vec and sort by skill and level
let mut resources: Vec<ResourceGroup> = grouped.into_values().collect();
resources.sort_by(|a, b| a.skill.cmp(&b.skill).then(a.level.cmp(&b.level)));
info!("Returning {} resource types", resources.len());
Ok(Json(ResourceResponse { resources }))
}
#[tokio::main]
async fn main() {
// Initialize tracing
@@ -128,6 +218,7 @@ async fn main() {
let app = Router::new()
.route("/api/bounds", get(get_bounds))
.route("/api/tiles/:z/:x/:y", get(get_tile))
.route("/api/resources", get(get_resources))
.nest_service("/", ServeDir::new("static"))
.layer(CorsLayer::permissive())
.with_state(state);

View File

@@ -10,7 +10,7 @@ const MapConfig = {
// Leaflet zoom 1 → Database zoom 1 (2x2 merged)
{ leafletZoom: -0.5, dbZoom: 1, mergeFactor: 2, label: "2x2 merged" },
// Leaflet zoom 2+ → Database zoom 2 (original tiles)
{ leafletZoom: 1.5, dbZoom: 2, mergeFactor: 1, label: "original" },
{ leafletZoom: 1, dbZoom: 2, mergeFactor: 1, label: "original" },
],
// Leaflet map settings
@@ -23,6 +23,9 @@ const MapConfig = {
// Debug mode - shows tile boundaries and coordinates
debug: true,
// Resource icon configuration
resourceIconSize: 48, // Icon size in pixels (configurable)
// Get zoom configuration for a specific Leaflet zoom level
getZoomConfig(leafletZoom) {
// Find the appropriate config for this zoom level

View File

@@ -15,7 +15,6 @@
<div id="app">
<!-- Sidebar -->
<div id="sidebar" class="sidebar collapsed">
<button id="toggle-sidebar" class="toggle-btn"></button>
<div class="sidebar-content">
<h2>Cursebreaker Map</h2>
<div class="info-section">
@@ -23,15 +22,13 @@
</div>
<div class="filters-section">
<h3>Filters</h3>
<p class="coming-soon">Coming soon: Filter shops, resources, and more</p>
<!-- Placeholder for future filters -->
<div class="filter-group" style="opacity: 0.5; pointer-events: none;">
<label><input type="checkbox" checked disabled> Shops</label>
<label><input type="checkbox" checked disabled> Resources</label>
<label><input type="checkbox" checked disabled> Fast Travel</label>
<label><input type="checkbox" checked disabled> Workbenches</label>
<h3>Resources</h3>
<div class="filter-controls">
<button id="select-all-resources" class="filter-btn">Show All</button>
<button id="deselect-all-resources" class="filter-btn">Hide All</button>
</div>
<div id="resource-filters" class="filter-group">
<p class="loading-text">Loading resources...</p>
</div>
</div>
@@ -61,5 +58,6 @@
<!-- Custom JS -->
<script src="map.js"></script>
<script src="resources.js"></script>
</body>
</html>

View File

@@ -86,10 +86,40 @@ async function initMap() {
L.control.attribution({
position: 'bottomright',
prefix: false
}).addHTML('The Black Grimoire: Cursebreaker').addTo(map);
}).addAttribution('The Black Grimoire: Cursebreaker').addTo(map);
// Add sidebar toggle control
const SidebarControl = L.Control.extend({
options: {
position: 'topleft'
},
onAdd: function(map) {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
const button = L.DomUtil.create('a', 'leaflet-control-sidebar', container);
button.innerHTML = '☰';
button.href = '#';
button.title = 'Toggle Sidebar';
L.DomEvent.on(button, 'click', function(e) {
L.DomEvent.preventDefault(e);
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('collapsed');
});
return container;
}
});
map.addControl(new SidebarControl());
console.log('Map initialized successfully');
// Load resources asynchronously
loadResources().catch(error => {
console.error('Failed to load resources:', error);
});
} catch (error) {
console.error('Error initializing map:', error);
document.getElementById('map-stats').innerHTML =
@@ -236,11 +266,5 @@ function updateMapInfo(bounds) {
`;
}
// Toggle sidebar
document.getElementById('toggle-sidebar').addEventListener('click', function() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('collapsed');
});
// Initialize map when page loads
window.addEventListener('DOMContentLoaded', initMap);

View File

@@ -0,0 +1,261 @@
// Resource management for Cursebreaker map
let resourceLayerGroups = {}; // Map: resource name -> L.layerGroup
let resourceIcons = {}; // Map: resource name -> L.icon
let resourceData = {}; // Map: resource name -> resource metadata (skill, level, etc.)
let filterState = {}; // Map: resource name -> boolean (visible)
// Load resources from API
async function loadResources() {
try {
console.log('Loading resources from API...');
const response = await fetch('/api/resources');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.resources.length} resource types`);
// Create icons and layer groups for each resource
for (const group of data.resources) {
createResourceGroup(group);
}
// Initialize filter UI
initializeFilterUI();
// Restore filter state from localStorage
restoreFilterState();
console.log(`Loaded ${data.resources.length} resource types successfully`);
} catch (error) {
console.error('Error loading resources:', error);
const container = document.getElementById('resource-filters');
if (container) {
container.innerHTML = '<p style="color: #ff6b6b;">Failed to load resources. Check console for details.</p>';
}
}
}
// Create resource group with icon and markers
function createResourceGroup(group) {
const config = window.MapConfig;
// Create icon definition (cached per resource type)
const iconUrl = `data:image/webp;base64,${group.icon_base64}`;
const icon = L.icon({
iconUrl: iconUrl,
iconSize: [config.resourceIconSize, config.resourceIconSize],
iconAnchor: [config.resourceIconSize / 2, config.resourceIconSize / 2],
popupAnchor: [0, -(config.resourceIconSize / 2)],
});
resourceIcons[group.name] = icon;
// Store metadata
resourceData[group.name] = {
item_id: group.item_id,
skill: group.skill,
level: group.level,
};
// Create layer group for this resource type
const layerGroup = L.layerGroup();
// Add markers for all positions
for (const pos of group.positions) {
const marker = L.marker([pos.y, pos.x], {
icon: icon,
title: group.name,
});
// Add popup with resource details
marker.bindPopup(
`<strong>${group.name}</strong><br/>Position: (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`
);
marker.addTo(layerGroup);
}
// Add to map (initially visible)
layerGroup.addTo(map);
resourceLayerGroups[group.name] = layerGroup;
filterState[group.name] = true; // Initially visible
}
// Initialize filter UI with skill grouping
function initializeFilterUI() {
const container = document.getElementById('resource-filters');
if (!container) {
console.error('resource-filters container not found');
return;
}
container.innerHTML = ''; // Clear loading text
// Group resources by skill
const skillGroups = {};
for (const name in resourceLayerGroups) {
const metadata = resourceData[name];
if (!skillGroups[metadata.skill]) {
skillGroups[metadata.skill] = [];
}
skillGroups[metadata.skill].push({
name: name,
level: metadata.level,
});
}
// Sort skills alphabetically
const sortedSkills = Object.keys(skillGroups).sort();
// Create UI for each skill group
for (const skill of sortedSkills) {
const skillDiv = document.createElement('div');
skillDiv.className = 'skill-group';
const header = document.createElement('div');
header.className = 'skill-header';
// Capitalize first letter of skill
header.textContent = skill.charAt(0).toUpperCase() + skill.slice(1);
skillDiv.appendChild(header);
// Resources are already sorted by level in backend, but sort again to be sure
skillGroups[skill].sort((a, b) => a.level - b.level);
// Create checkbox for each resource
for (const resource of skillGroups[skill]) {
const label = createFilterLabel(resource.name);
skillDiv.appendChild(label);
}
container.appendChild(skillDiv);
}
// Attach bulk filter handlers
const selectAllBtn = document.getElementById('select-all-resources');
const deselectAllBtn = document.getElementById('deselect-all-resources');
if (selectAllBtn) {
selectAllBtn.addEventListener('click', () => {
setAllFilters(true);
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', () => {
setAllFilters(false);
});
}
}
// Create filter label with checkbox and icon
function createFilterLabel(resourceName) {
const label = document.createElement('label');
label.className = 'filter-label';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = filterState[resourceName];
checkbox.dataset.resource = resourceName;
checkbox.addEventListener('change', handleFilterChange);
const icon = document.createElement('img');
icon.src = resourceIcons[resourceName].options.iconUrl;
icon.className = 'filter-icon';
icon.alt = resourceName;
const text = document.createElement('span');
text.textContent = resourceName;
label.appendChild(checkbox);
label.appendChild(icon);
label.appendChild(text);
return label;
}
// Handle filter checkbox change
function handleFilterChange(event) {
const resourceName = event.target.dataset.resource;
const isVisible = event.target.checked;
filterState[resourceName] = isVisible;
// Show/hide layer group
const layerGroup = resourceLayerGroups[resourceName];
if (isVisible) {
layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
// Persist state
saveFilterState();
}
// Set all filters to visible or hidden
function setAllFilters(visible) {
for (const name in filterState) {
filterState[name] = visible;
const layerGroup = resourceLayerGroups[name];
if (visible) {
layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
}
// Update checkboxes
document.querySelectorAll('#resource-filters input[type="checkbox"]').forEach((cb) => {
cb.checked = visible;
});
saveFilterState();
}
// Save filter state to localStorage
function saveFilterState() {
try {
localStorage.setItem('cursebreaker_resource_filters', JSON.stringify(filterState));
} catch (error) {
console.warn('Failed to save filter state to localStorage:', error);
}
}
// Restore filter state from localStorage
function restoreFilterState() {
const saved = localStorage.getItem('cursebreaker_resource_filters');
if (!saved) return;
try {
const savedState = JSON.parse(saved);
for (const name in savedState) {
if (resourceLayerGroups[name]) {
filterState[name] = savedState[name];
const layerGroup = resourceLayerGroups[name];
if (!savedState[name]) {
map.removeLayer(layerGroup);
}
}
}
// Update checkboxes after UI is created
setTimeout(() => {
document.querySelectorAll('#resource-filters input[type="checkbox"]').forEach((cb) => {
const name = cb.dataset.resource;
if (filterState[name] !== undefined) {
cb.checked = filterState[name];
}
});
}, 100);
console.log('Restored filter state from localStorage');
} catch (error) {
console.warn('Failed to restore filter state:', error);
}
}

View File

@@ -32,24 +32,17 @@ body {
margin-left: -320px;
}
.toggle-btn {
position: absolute;
right: -40px;
top: 10px;
width: 40px;
height: 40px;
background: #2a2a2a;
border: none;
border-radius: 0 5px 5px 0;
color: #e0e0e0;
font-size: 20px;
cursor: pointer;
transition: background 0.2s;
z-index: 1001;
}
.toggle-btn:hover {
background: #3a3a3a;
/* Sidebar toggle control */
.leaflet-control-sidebar {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 30px;
text-align: center;
text-decoration: none;
}
.sidebar-content {
@@ -176,6 +169,96 @@ body {
font-weight: bold;
}
/* Filter controls */
.filter-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.filter-btn {
flex: 1;
padding: 6px 12px;
background: #3a3a3a;
color: #e0e0e0;
border: 1px solid #4a4a4a;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.filter-btn:hover {
background: #4a4a4a;
}
.filter-btn:active {
background: #2a2a2a;
}
/* Filter groups by skill */
.skill-group {
margin-bottom: 12px;
}
.skill-header {
color: #8b5cf6;
font-size: 13px;
font-weight: bold;
margin-bottom: 4px;
padding: 4px 8px;
background: rgba(139, 92, 246, 0.1);
border-radius: 3px;
}
/* Filter items */
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
margin-left: 8px;
border-radius: 4px;
transition: background 0.2s;
font-size: 13px;
}
.filter-label:hover {
background: #3a3a3a;
}
.filter-icon {
width: 20px;
height: 20px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
.loading-text {
color: #a0a0a0;
font-style: italic;
font-size: 13px;
padding: 8px;
}
/* Dark theme popups */
.leaflet-popup-content-wrapper {
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #3a3a3a;
}
.leaflet-popup-content {
margin: 8px 12px;
font-size: 13px;
}
.leaflet-popup-tip {
background: #2a2a2a;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {

View File

@@ -42,14 +42,19 @@ The project provides multiple binaries to handle different parsing tasks. This a
cargo run --bin xml-parser
```
2. **scene-parser** - Parses Unity scenes and extracts world resource locations
2. **scene-parser** - Parses Unity scenes and extracts world objects
- Slow execution (Unity project initialization)
- Extracts InteractableResource components and their positions
- Saves to `world_resources` table (harvestable_id and 2D coordinates)
- Extracts multiple types of interactable components and their positions:
- **InteractableResource**: Harvestable resources → `world_resources` table
- **InteractableTeleporter**: Teleporters with source/destination positions → `world_teleporters` table
- **InteractableWorkbench**: Workbenches with workbench ID → `world_workbenches` table
- **LootSpawner**: Loot spawners with item, amount, respawn time → `world_loot` table
- **MapIcon**: Map icons with type, size, text, etc. → `world_map_icons` table
- **MapNameChanger**: Map name changers → `world_map_name_changers` table
- Processes item icons for harvestables:
- Looks up the first item drop for each harvestable from `harvestable_drops` table
- Loads the icon from `Data/Textures/ItemIcons/{item_id}.png`
- Applies white outline (1px) and resizes to 64x64
- Applies white outline (4px) and resizes to 64x64
- Converts to WebP and stores in `resource_icons` table
- Run this when scene files change
```bash
@@ -343,6 +348,11 @@ The parser uses Diesel for database operations with SQLite. Database migrations
- Harvestable resources and drop tables
- World resource locations from Unity scenes
- Resource icons for harvestables (64x64 WebP with white borders)
- World teleporters with source/destination coordinates
- World workbenches with workbench IDs
- World loot spawners with item, amount, and respawn time
- Map icons with type, size, text, and hover text
- Map name changers with location and map name
- Minimap tiles and metadata
- Shop inventories and pricing
- Player houses and locations

View File

@@ -0,0 +1,140 @@
//! Example: Query world objects from the database
//!
//! Run with: cargo run --example verify_world_objects
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect to database
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
// Query teleporters
{
use cursebreaker_parser::schema::world_teleporters::dsl::*;
#[derive(Queryable, Debug)]
struct Teleporter {
pos_x: f32,
pos_y: f32,
tp_x: Option<f32>,
tp_y: Option<f32>,
}
let results = world_teleporters.load::<Teleporter>(&mut conn)?;
println!("=== World Teleporters ===");
println!("Found {} teleporters\n", results.len());
for tp in results {
print!(" At ({:.2}, {:.2})", tp.pos_x, tp.pos_y);
if let (Some(tx), Some(ty)) = (tp.tp_x, tp.tp_y) {
println!(" -> teleports to ({:.2}, {:.2})", tx, ty);
} else {
println!(" -> no destination");
}
}
println!();
}
// Query workbenches
{
use cursebreaker_parser::schema::world_workbenches::dsl::*;
#[derive(Queryable, Debug)]
struct Workbench {
pos_x: f32,
pos_y: f32,
workbench_id: i32,
}
let results = world_workbenches.load::<Workbench>(&mut conn)?;
println!("=== World Workbenches ===");
println!("Found {} workbenches\n", results.len());
for wb in results {
println!(" Workbench ID {} at ({:.2}, {:.2})", wb.workbench_id, wb.pos_x, wb.pos_y);
}
println!();
}
// Query loot spawners
{
use cursebreaker_parser::schema::world_loot::dsl::*;
#[derive(Queryable, Debug)]
struct Loot {
pos_x: f32,
pos_y: f32,
item_id: i32,
amount: i32,
respawn_time: i32,
visibility_checks: String,
}
let results = world_loot.load::<Loot>(&mut conn)?;
println!("=== World Loot ===");
println!("Found {} loot spawners\n", results.len());
for loot in results {
println!(" Item {} x{} (respawn: {}s) at ({:.2}, {:.2})",
loot.item_id, loot.amount, loot.respawn_time, loot.pos_x, loot.pos_y);
if !loot.visibility_checks.is_empty() {
println!(" Visibility checks: {}", loot.visibility_checks);
}
}
println!();
}
// Query map icons
{
use cursebreaker_parser::schema::world_map_icons::dsl::*;
#[derive(Queryable, Debug)]
struct MapIcon {
pos_x: f32,
pos_y: f32,
icon_type: i32,
icon_size: i32,
icon: String,
text: String,
font_size: i32,
hover_text: String,
}
let results = world_map_icons.load::<MapIcon>(&mut conn)?;
println!("=== World Map Icons ===");
println!("Found {} map icons\n", results.len());
for map_icon in results {
print!(" Type {} at ({:.2}, {:.2})", map_icon.icon_type, map_icon.pos_x, map_icon.pos_y);
if !map_icon.text.is_empty() {
print!(" - Text: \"{}\"", map_icon.text);
}
if !map_icon.hover_text.is_empty() {
print!(" - Hover: \"{}\"", map_icon.hover_text);
}
println!();
}
println!();
}
// Query map name changers
{
use cursebreaker_parser::schema::world_map_name_changers::dsl::*;
#[derive(Queryable, Debug)]
struct MapNameChanger {
pos_x: f32,
pos_y: f32,
map_name: String,
}
let results = world_map_name_changers.load::<MapNameChanger>(&mut conn)?;
println!("=== World Map Name Changers ===");
println!("Found {} map name changers\n", results.len());
for changer in results {
println!(" \"{}\" at ({:.2}, {:.2})", changer.map_name, changer.pos_x, changer.pos_y);
}
println!();
}
Ok(())
}

View File

@@ -0,0 +1,6 @@
-- Drop world scene object tables
DROP TABLE world_teleporters;
DROP TABLE world_workbenches;
DROP TABLE world_loot;
DROP TABLE world_map_icons;
DROP TABLE world_map_name_changers;

View File

@@ -0,0 +1,48 @@
-- Create world_teleporters table
CREATE TABLE world_teleporters (
pos_x REAL NOT NULL,
pos_y REAL NOT NULL,
tp_x REAL,
tp_y REAL,
PRIMARY KEY (pos_x, pos_y)
);
-- Create world_workbenches table
CREATE TABLE world_workbenches (
pos_x REAL NOT NULL,
pos_y REAL NOT NULL,
workbench_id INTEGER NOT NULL,
PRIMARY KEY (pos_x, pos_y)
);
-- Create world_loot table
CREATE TABLE world_loot (
pos_x REAL NOT NULL,
pos_y REAL NOT NULL,
item_id INTEGER NOT NULL,
amount INTEGER NOT NULL,
respawn_time INTEGER NOT NULL,
visibility_checks TEXT NOT NULL DEFAULT '',
PRIMARY KEY (pos_x, pos_y)
);
-- Create world_map_icons table
CREATE TABLE world_map_icons (
pos_x REAL NOT NULL,
pos_y REAL NOT NULL,
icon_type INTEGER NOT NULL,
icon_size INTEGER NOT NULL,
icon TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
font_size INTEGER NOT NULL,
hover_text TEXT NOT NULL DEFAULT '',
PRIMARY KEY (pos_x, pos_y)
);
-- Create world_map_name_changers table
CREATE TABLE world_map_name_changers (
pos_x REAL NOT NULL,
pos_y REAL NOT NULL,
map_name TEXT NOT NULL,
PRIMARY KEY (pos_x, pos_y)
);

View File

@@ -20,11 +20,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap();
info!("🎮 Cursebreaker - Image Parser");
info!("Generates all zoom levels (0, 1, 2) with merged tiles\n");
info!("Generates all zoom levels (0, 1, 2) with merged tiles");
info!("⚠️ Will override existing database entries\n");
// Process minimap tiles
info!("🗺️ Processing minimap tiles...");
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
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")

View File

@@ -8,7 +8,10 @@
//! - Saving resource locations to the database
//! - Processing and saving item icons for resources
use cursebreaker_parser::{InteractableResource, ImageProcessor, OutlineConfig};
use cursebreaker_parser::{
InteractableResource, InteractableTeleporter, InteractableWorkbench,
LootSpawner, MapIcon, MapNameChanger, ImageProcessor, OutlineConfig
};
use unity_parser::{UnityProject, TypeFilter};
use std::path::{Path, PathBuf};
use unity_parser::log::DedupLogger;
@@ -40,7 +43,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
info!(" • Custom MonoBehaviours: InteractableResource");
let type_filter = TypeFilter::new(
vec!["GameObject", "Transform", "PrefabInstance"],
vec!["InteractableResource"]
vec!["InteractableResource", "InteractableTeleporter", "InteractableWorkbench", "LootSpawner", "MapIcon", "MapNameChanger"]
);
// Now parse the scene using the pre-built GUID resolvers with filtering
@@ -104,6 +107,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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);
@@ -141,7 +160,7 @@ fn process_item_icons(
// Create image processor with white outline
let processor = ImageProcessor::default();
let outline_config = OutlineConfig::white(1);
let outline_config = OutlineConfig::white(4);
let mut processed_count = 0;
let mut failed_count = 0;
@@ -229,3 +248,204 @@ fn process_item_icons(
Ok(())
}
/// Save teleporter data to database
fn save_teleporters(
conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene,
) -> Result<(), 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)| {
let world_pos = transform.position();
// Get the tp_transform position if it exists
let (tp_x, tp_y) = if let Some(tp_entity) = teleporter.tp_transform {
if let Some(tp_transform) = scene.world.borrow::<unity_parser::WorldTransform>().get(tp_entity) {
let tp_pos = tp_transform.position();
(Some(tp_pos.x as f32), Some(tp_pos.z as f32))
} else {
(None, None)
}
} else {
(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),
world_teleporters::pos_y.eq(world_pos.z as f32),
world_teleporters::tp_x.eq(tp_x),
world_teleporters::tp_y.eq(tp_y),
))
.execute(conn);
count += 1;
});
info!("✅ Saved {} teleporters to database", count);
Ok(())
}
/// Save workbench data to database
fn save_workbenches(
conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene,
) -> Result<(), 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)| {
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),
world_workbenches::pos_y.eq(world_pos.z as f32),
world_workbenches::workbench_id.eq(workbench.workbench_id as i32),
))
.execute(conn);
count += 1;
});
info!("✅ Saved {} workbenches to database", count);
Ok(())
}
/// Save loot spawner data to database
fn save_loot_spawners(
conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene,
) -> Result<(), 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)| {
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),
world_loot::pos_y.eq(world_pos.z as f32),
world_loot::item_id.eq(loot.item_id as i32),
world_loot::amount.eq(loot.amount as i32),
world_loot::respawn_time.eq(loot.respawn_time as i32),
world_loot::visibility_checks.eq(&loot.visibility_checks),
))
.execute(conn);
count += 1;
});
info!("✅ Saved {} loot spawners to database", count);
Ok(())
}
/// Save map icon data to database
fn save_map_icons(
conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene,
) -> Result<(), 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)| {
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),
world_map_icons::pos_y.eq(world_pos.z as f32),
world_map_icons::icon_type.eq(icon.icon_type as i32),
world_map_icons::icon_size.eq(icon.icon_size as i32),
world_map_icons::icon.eq(&icon.icon),
world_map_icons::text.eq(&icon.text),
world_map_icons::font_size.eq(icon.font_size as i32),
world_map_icons::hover_text.eq(&icon.hover_text),
))
.execute(conn);
count += 1;
});
info!("✅ Saved {} map icons to database", count);
Ok(())
}
/// Save map name changer data to database
fn save_map_name_changers(
conn: &mut SqliteConnection,
scene: &unity_parser::UnityScene,
) -> Result<(), 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)| {
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),
world_map_name_changers::pos_y.eq(world_pos.z as f32),
world_map_name_changers::map_name.eq(&changer.map_name),
))
.execute(conn);
count += 1;
});
info!("✅ Saved {} map name changers to database", count);
Ok(())
}

View File

@@ -1,22 +0,0 @@
use cursebreaker_parser::ItemDatabase;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
let item_db = ItemDatabase::load_from_db(&mut conn)?;
println!("✅ Database contains {} items", item_db.len());
if item_db.len() > 0 {
println!("\nFirst 5 items:");
for (i, item) in item_db.all_items().iter().take(5).enumerate() {
println!(" {}. {} (ID: {})", i + 1, item.item_name, item.type_id);
}
}
Ok(())
}

View File

@@ -1,107 +0,0 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
// Check items with new columns
#[derive(Queryable)]
struct ItemInfo {
id: Option<i32>,
name: String,
item_type: String,
level: i32,
price: i32,
max_stack: i32,
skill: String,
}
use cursebreaker_parser::schema::items::dsl::*;
let sample_items = items
.select((id, name, item_type, level, price, max_stack, skill))
.limit(5)
.load::<ItemInfo>(&mut conn)?;
println!("✅ Sample items with expanded columns:\n");
for item in sample_items {
println!(
" {} - {} (Type: {}, Level: {}, Price: {}, MaxStack: {}, Skill: {})",
item.id.unwrap_or(0),
item.name,
item.item_type,
item.level,
item.price,
item.max_stack,
item.skill
);
}
// Check crafting recipes
#[derive(Queryable)]
struct RecipeCount {
count: i64,
}
use diesel::dsl::count_star;
use cursebreaker_parser::schema::crafting_recipes;
let recipe_count = crafting_recipes::table
.select(count_star())
.first::<i64>(&mut conn)?;
println!("\n✅ Total crafting recipes: {}", recipe_count);
// Show sample recipes
if recipe_count > 0 {
#[derive(Queryable)]
struct RecipeInfo {
id: Option<i32>,
product_item_id: i32,
skill: String,
level: i32,
workbench_id: i32,
}
let sample_recipes = crafting_recipes::table
.select((
crafting_recipes::id,
crafting_recipes::product_item_id,
crafting_recipes::skill,
crafting_recipes::level,
crafting_recipes::workbench_id,
))
.limit(3)
.load::<RecipeInfo>(&mut conn)?;
println!("\nSample crafting recipes:");
for recipe in sample_recipes {
// Get product name
let product_name: String = items
.filter(id.eq(recipe.product_item_id))
.select(name)
.first(&mut conn)?;
// Get ingredient count
use cursebreaker_parser::schema::crafting_recipe_items;
let ingredient_count: i64 = crafting_recipe_items::table
.filter(crafting_recipe_items::recipe_id.eq(recipe.id.unwrap()))
.select(count_star())
.first(&mut conn)?;
println!(
" Recipe #{}: {} (Skill: {}, Level: {}, Workbench: {}, Ingredients: {})",
recipe.id.unwrap(),
product_name,
recipe.skill,
recipe.level,
recipe.workbench_id,
ingredient_count
);
}
}
Ok(())
}

View File

@@ -1,68 +0,0 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
// Check items with images
#[derive(Queryable)]
struct ItemImageInfo {
id: Option<i32>,
name: String,
icon_large: Option<Vec<u8>>,
icon_medium: Option<Vec<u8>>,
icon_small: Option<Vec<u8>>,
}
use cursebreaker_parser::schema::items::dsl::*;
// Count items with images
let all_items: Vec<ItemImageInfo> = items
.select((id, name, icon_large, icon_medium, icon_small))
.load(&mut conn)?;
let items_with_images = all_items.iter().filter(|item| item.icon_large.is_some()).count();
let items_without_images = all_items.len() - items_with_images;
println!("✅ Image Statistics:\n");
println!(" Total items: {}", all_items.len());
println!(" Items with icons: {}", items_with_images);
println!(" Items without icons: {}", items_without_images);
// Show sample images with sizes
println!("\n📸 Sample items with icons:\n");
for (i, item) in all_items.iter().filter(|item| item.icon_large.is_some()).take(5).enumerate() {
let large_size = item.icon_large.as_ref().map(|v| v.len()).unwrap_or(0);
let medium_size = item.icon_medium.as_ref().map(|v| v.len()).unwrap_or(0);
let small_size = item.icon_small.as_ref().map(|v| v.len()).unwrap_or(0);
let total_size = large_size + medium_size + small_size;
println!(
" {}. {} (ID: {})",
i + 1,
item.name,
item.id.unwrap_or(0)
);
println!(
" Large (256px): {:.1} KB | Medium (64px): {:.1} KB | Small (16px): {:.1} KB | Total: {:.1} KB",
large_size as f64 / 1024.0,
medium_size as f64 / 1024.0,
small_size as f64 / 1024.0,
total_size as f64 / 1024.0
);
}
// Calculate total storage used by images
let total_storage: usize = all_items.iter().map(|item| {
item.icon_large.as_ref().map(|v| v.len()).unwrap_or(0) +
item.icon_medium.as_ref().map(|v| v.len()).unwrap_or(0) +
item.icon_small.as_ref().map(|v| v.len()).unwrap_or(0)
}).sum();
println!("\n💾 Total image storage: {:.2} MB", total_storage as f64 / 1024.0 / 1024.0);
Ok(())
}

View File

@@ -1,40 +0,0 @@
//! Verify resource_icons table
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
use cursebreaker_parser::schema::resource_icons;
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
// Count total icons
let count: i64 = resource_icons::table.count().get_result(&mut conn)?;
println!("✅ Database contains {} resource icons\n", count);
// Get all icons and show details
#[derive(Queryable, Debug)]
struct ResourceIcon {
item_id: i32,
name: String,
icon_64: Vec<u8>,
}
let icons: Vec<ResourceIcon> = resource_icons::table
.load(&mut conn)?;
if icons.is_empty() {
println!(" No resource icons found in database.");
println!(" This is expected if no item icons were available during scene parsing.");
} else {
println!("Resource icons:");
for icon in icons {
println!(" • Item {}: {} ({} bytes)",
icon.item_id, icon.name, icon.icon_64.len());
}
}
Ok(())
}

View File

@@ -1,131 +0,0 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "../cursebreaker.db".to_string());
let mut conn = SqliteConnection::establish(&database_url)?;
// Count total stats
use cursebreaker_parser::schema::item_stats::dsl::*;
use diesel::dsl::count_star;
let total_stats: i64 = item_stats.select(count_star()).first(&mut conn)?;
println!("✅ Item Stats Statistics:\n");
println!(" Total stat entries: {}", total_stats);
// Get stats breakdown by type
#[derive(Queryable)]
struct StatTypeCount {
stat_type: String,
count: i64,
}
let stats_by_type: Vec<StatTypeCount> = item_stats
.group_by(stat_type)
.select((stat_type, count_star()))
.order_by(count_star().desc())
.load(&mut conn)?;
println!("\n📊 Stats breakdown by type:\n");
for stat in &stats_by_type {
println!(" {}: {} items", stat.stat_type, stat.count);
}
// Find items with the most stats
#[derive(Queryable)]
struct ItemStatCount {
item_id: i32,
stat_count: i64,
}
let items_with_most_stats: Vec<ItemStatCount> = item_stats
.group_by(item_id)
.select((item_id, count_star()))
.order_by(count_star().desc())
.limit(5)
.load(&mut conn)?;
println!("\n🏆 Items with most stats:\n");
for item_stat in items_with_most_stats {
// Get item name
use cursebreaker_parser::schema::items;
let item_name: String = items::table
.filter(items::id.eq(item_stat.item_id))
.select(items::name)
.first(&mut conn)?;
println!(" {} (ID: {}) - {} stats", item_name, item_stat.item_id, item_stat.stat_count);
// Get the actual stats for this item
#[derive(Queryable)]
struct ItemStatDetail {
stat_type: String,
value: f32,
}
let stats: Vec<ItemStatDetail> = item_stats
.filter(item_id.eq(item_stat.item_id))
.select((stat_type, value))
.load(&mut conn)?;
for stat in stats {
println!(" {}: {}", stat.stat_type, stat.value);
}
println!();
}
// Show some example stat queries
println!("📈 Example queries:\n");
// Items with high physical damage
#[derive(Queryable)]
struct ItemWithStat {
item_id: i32,
value: f32,
}
let high_damage_items: Vec<ItemWithStat> = item_stats
.filter(stat_type.eq("damage_physical"))
.filter(value.gt(50.0))
.select((item_id, value))
.order_by(value.desc())
.limit(5)
.load(&mut conn)?;
if !high_damage_items.is_empty() {
println!(" Items with Physical Damage > 50:");
for item in high_damage_items {
use cursebreaker_parser::schema::items;
let item_name: String = items::table
.filter(items::id.eq(item.item_id))
.select(items::name)
.first(&mut conn)?;
println!(" {} - {:.1} damage", item_name, item.value);
}
}
// Items with health bonuses
let health_items: Vec<ItemWithStat> = item_stats
.filter(stat_type.eq("health"))
.filter(value.gt(0.0))
.select((item_id, value))
.order_by(value.desc())
.limit(5)
.load(&mut conn)?;
if !health_items.is_empty() {
println!("\n Items with Health bonuses:");
for item in health_items {
use cursebreaker_parser::schema::items;
let item_name: String = items::table
.filter(items::id.eq(item.item_id))
.select(items::name)
.first(&mut conn)?;
println!(" {} - {:.0} health", item_name, item.value);
}
}
Ok(())
}

View File

@@ -89,7 +89,7 @@ impl MinimapDatabase {
source_path: relative_path.to_str().unwrap_or(""),
};
diesel::insert_into(minimap_tiles::table)
diesel::replace_into(minimap_tiles::table)
.values(&new_tile)
.execute(&mut conn)?;
@@ -223,7 +223,7 @@ impl MinimapDatabase {
source_path: &source_tiles,
};
diesel::insert_into(minimap_tiles::table)
diesel::replace_into(minimap_tiles::table)
.values(&new_tile)
.execute(conn)?;

View File

@@ -91,6 +91,12 @@ pub use types::{
MAX_STACK,
// Other types
InteractableResource,
InteractableTeleporter,
InteractableWorkbench,
LootSpawner,
MapIcon,
MapIconType,
MapNameChanger,
Npc,
NpcStat,
NpcLevel,

View File

@@ -186,6 +186,38 @@ diesel::table! {
}
}
diesel::table! {
world_loot (pos_x, pos_y) {
pos_x -> Float,
pos_y -> Float,
item_id -> Integer,
amount -> Integer,
respawn_time -> Integer,
visibility_checks -> Text,
}
}
diesel::table! {
world_map_icons (pos_x, pos_y) {
pos_x -> Float,
pos_y -> Float,
icon_type -> Integer,
icon_size -> Integer,
icon -> Text,
text -> Text,
font_size -> Integer,
hover_text -> Text,
}
}
diesel::table! {
world_map_name_changers (pos_x, pos_y) {
pos_x -> Float,
pos_y -> Float,
map_name -> Text,
}
}
diesel::table! {
world_resources (item_id, pos_x, pos_y) {
item_id -> Integer,
@@ -194,6 +226,23 @@ diesel::table! {
}
}
diesel::table! {
world_teleporters (pos_x, pos_y) {
pos_x -> Float,
pos_y -> Float,
tp_x -> Nullable<Float>,
tp_y -> Nullable<Float>,
}
}
diesel::table! {
world_workbenches (pos_x, pos_y) {
pos_x -> Float,
pos_y -> Float,
workbench_id -> Integer,
}
}
diesel::joinable!(crafting_recipe_items -> crafting_recipes (recipe_id));
diesel::joinable!(crafting_recipe_items -> items (item_id));
diesel::joinable!(crafting_recipes -> items (product_item_id));
@@ -218,5 +267,10 @@ diesel::allow_tables_to_appear_in_same_query!(
resource_icons,
shops,
traits,
world_loot,
world_map_icons,
world_map_name_changers,
world_resources,
world_teleporters,
world_workbenches,
);

Binary file not shown.

30
settings.json Normal file
View File

@@ -0,0 +1,30 @@
{
"ssh_connections": [
{
"host": "192.168.11.7",
"username": "connor",
"args": [],
"projects": [
{
"paths": ["/home/connor/repos/cursebreaker-parser-rust"]
}
],
"nickname": "connor-mini"
}
],
"ui_font_size": 16,
"buffer_font_size": 15,
"theme": {
"mode": "system",
"light": "One Light",
"dark": "One Dark"
},
"agent_servers": {
"Grok Build Agent": {
"type": "custom",
"command": "/usr/bin/grok",
"args": ["agent", "--yolo", "stdio"],
"env": {}
}
}
}