summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorkboz <kutayboz@gmail.com>2026-04-19 01:50:32 +0200
committerkboz <kutayboz@gmail.com>2026-04-19 01:50:32 +0200
commitacbf6d6bd6d63ab0e9f7c4e119827f76e0382aac (patch)
tree9d2f74608ae4b910a480648c7bd95fdf6c403389
parent8cc70dfee2049772a2da9cfdeb15f64452bc390b (diff)
downloadhaul-acbf6d6bd6d63ab0e9f7c4e119827f76e0382aac.tar.gz
feat(backend): unify boot process and refactor route properties
- Added scripts/haul.sh, setup_auth.py, and setup_eve_data.py for simplified SSO and CSV data bootstrapping. - Refactored `Ship` and `RouteStep` properties across route planning modules to utilize dictionaries instead of strict dataclass attributes. - Re-aligned route limits, reducing upfront capital risk values in `arbitrage.py` and updating ship metrics in `manager.py`. - Updated `.gitignore`, `.env.example`, and created AI-assistant prompt configuration. - Added comprehensive "Getting Started" guides to the README and initialized CHANGELOG.md.
-rw-r--r--.env.example6
-rw-r--r--.github/prompts/document-changes.prompt.md10
-rw-r--r--.gitignore2
-rw-r--r--CHANGELOG.md27
-rw-r--r--backend/src/arbitrage.py2
-rw-r--r--backend/src/graph.py1
-rw-r--r--backend/src/loader.py5
-rw-r--r--backend/src/manager.py31
-rw-r--r--backend/src/pathfinder.py4
-rw-r--r--backend/src/route.py31
-rw-r--r--readme.md73
-rwxr-xr-xscripts/haul.sh217
-rw-r--r--scripts/setup_auth.py129
-rw-r--r--scripts/setup_eve_data.py55
14 files changed, 557 insertions, 36 deletions
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..438c714
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+# EVE Online SSO application credentials
+CLIENT_ID=your_eve_sso_client_id
+SECRET_KEY=your_eve_sso_secret
+
+# The Python backend reads this exact lowercase variable name.
+character_id=your_eve_character_id \ No newline at end of file
diff --git a/.github/prompts/document-changes.prompt.md b/.github/prompts/document-changes.prompt.md
new file mode 100644
index 0000000..215389c
--- /dev/null
+++ b/.github/prompts/document-changes.prompt.md
@@ -0,0 +1,10 @@
+---
+description: Analyzes the recent workspace changes to update documentation, CHANGELOG, and generate a commit message.
+---
+
+Please review the current workspace changes or diffs and perform the following tasks sequentially:
+
+1. **Analyze Changes**: Review the modified files and understand the scope of the updates.
+2. **Update README**: Check if the changes affect the project structure, run instructions, or architecture described in `readme.md`. If so, update `readme.md` to reflect these changes.
+3. **Update CHANGELOG**: Modify `CHANGELOG.md` to include these changes under the `[Unreleased]` section following the Keep a Changelog format (Added, Changed, Deprecated, Removed, Fixed, Security).
+4. **Suggest Commit Message**: Provide a clear, concise commit message summarizing the changes, optionally formatted with Conventional Commits specifications (e.g., `feat: ...`, `fix: ...`, `docs: ...`).
diff --git a/.gitignore b/.gitignore
index c4326fb..42084a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
+.venv/
venv/
+.tools/
data/
cache/
__pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..c3b3c23
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,27 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+- `scripts/haul.sh` CLI for unified backend/frontend dependency checking, running, and EVE data bootstrapping.
+- `scripts/setup_auth.py` and `scripts/setup_eve_data.py` scripts to fetch OAuth credentials and base CSV game data.
+- `.env.example` to ease SSO configuration.
+- Custom Copilot Chat prompt for documenting changes under `.github/prompts`.
+
+### Changed
+- Refactored `backend/src/manager.py` to fix graph instantiations, updated ship properties to feature larger cargo, lower subwarp/warp speed, and accurately lower ship ISK value.
+- Re-aligned route and item capital checks across `backend/src/manager.py` and `backend/src/pathfinder.py` by transitioning the `Ship` attribute references from dataclasses to dictionaries.
+- Reduced initial max capital layout cap to `20,000,000` in `backend/src/arbitrage.py` to be more cautious about early-game up-front ISK demands.
+- Updated `backend/src/loader.py` to handle edge cases where `solar_system_id` was returned as 'None'.
+- Enhanced graph generation to reliably build its parent directory for `PATHS_FILE`.
+- Adjusted `.gitignore` to track standard Python and Node virtual environment directories properly.
+
+### Fixed
+- Fixed uninitialized class name alias by swapping `Graph` to `NavigationGraph` across references.
+- Remedied route step structure by moving away from hardcoded dataclass references (`RouteStep`) to generic dictionaries in `backend/src/route.py`.
+- Checked and resolved printing of `snipe` variable references within `backend/src/manager.py` loops arrays versus strings.
diff --git a/backend/src/arbitrage.py b/backend/src/arbitrage.py
index 0991bbc..c1a0459 100644
--- a/backend/src/arbitrage.py
+++ b/backend/src/arbitrage.py
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
TAX_RATE = 0.08
PROFIT_THRESHOLD = 100_000
-MAX_CAPITAL = 100_000_000
+MAX_CAPITAL = 20_000_000
SNIPE_PROFIT_THRESHOLD = 20_000_000
@dataclass
diff --git a/backend/src/graph.py b/backend/src/graph.py
index 31eef80..9e4534a 100644
--- a/backend/src/graph.py
+++ b/backend/src/graph.py
@@ -300,6 +300,7 @@ class NavigationGraph:
else:
logger.info("Generating path lengths and caching to file")
dist = dict(nx.all_pairs_dijkstra_path_length(self.graph))
+ PATHS_FILE.parent.mkdir(parents=True, exist_ok=True)
with PATHS_FILE.open("wb") as f:
pickle.dump(dist, f)
return dist
diff --git a/backend/src/loader.py b/backend/src/loader.py
index 7e536c1..2d46cd6 100644
--- a/backend/src/loader.py
+++ b/backend/src/loader.py
@@ -96,7 +96,10 @@ def process_row(
item_id_int = int(item_id)
type_id_int = int(type_id)
group_id_int = int(group_id)
- solar_system_id_int = int(solar_system_id)
+
+ solar_system_id_int = None
+ if solar_system_id and solar_system_id != 'None':
+ solar_system_id_int = int(solar_system_id)
if type_id_int == TYPE_ID_SOLAR_SYSTEM:
solar_systems[item_id_int] = {
diff --git a/backend/src/manager.py b/backend/src/manager.py
index 03df5d8..09d920e 100644
--- a/backend/src/manager.py
+++ b/backend/src/manager.py
@@ -1,6 +1,6 @@
import threading
-from graph import Graph
+from graph import NavigationGraph
from esi.api import get_location, set_waypoints, open_market_window
from market import Market
from arbitrage import arbitrage, snipe
@@ -14,15 +14,15 @@ class Manager:
def __init__(self):
self.ship = {
"location": get_location(),
- "cargo": 600,
- "max_warp_speed": 8.22, # AU/s
- "max_subwarp_speed": 216.5, # m/s
+ "cargo": 4000,
+ "max_warp_speed": 4.55, # AU/s
+ "max_subwarp_speed": 166.9, # m/s
"gankers_areas": {"Ahbazon": 0.5}, #["Miroitem", "Rancer", "Huola", "Ahbazon", "Uedama", "Sivala", "Tama", "Onatoh"],
"player_cost": 15_000_000 / 3600, # isk/second
"risk_cost": 60_000_000, # isk
- "ship_cost": 50_000_000 # isk
+ "ship_cost": 5_500_000 # isk
}
- self.graph = Graph(self.ship)
+ self.graph = NavigationGraph(self.ship)
self.route = []
self.route_info = {}
self.market = Market()
@@ -51,12 +51,12 @@ class Manager:
potential_snipe = snipe(self.market.orders, self.ship["cargo"], current_region)
if potential_snipe:
self.log.append("----------------")
- self.log.append("Snipe: " + str(potential_snipe[1]) + " orders for " + str(potential_snipe[0]) + " ISK")
+ self.log.append(f"Snipe: {potential_snipe.item_name} orders for {potential_snipe.gross_profit} ISK")
self.log.append("----------------")
- print("snipe possible", potential_snipe[0], "ISK", potential_snipe[1])
+ print("snipe possible", potential_snipe.gross_profit, "ISK", potential_snipe.item_name)
- open_market_window(potential_snipe[1]["type_id"])
+ open_market_window(potential_snipe.type_id)
def update(self):
@@ -82,8 +82,9 @@ class Manager:
def target_function(log):
try:
- updated_regions = self.market.update_orders(log)
+ updated_regions = self.market.update_orders()
if updated_regions:
+ log.append(f"Updated {len(updated_regions)} market regions")
self.snipe_callback(updated_regions)
finally:
self.updating_orders = False
@@ -97,8 +98,12 @@ class Manager:
order_matches = pathfinder(order_matches, self.graph, self.ship)
self.route, self.route_info = route.route(order_matches, self.graph, self.ship)
- set_waypoints([r["location_id"] for r in self.route[1:]])
-
- print(self.route)
+ if self.route:
+ set_waypoints([r["location_id"] for r in self.route[1:]])
+ print(self.route)
+ else:
+ self.route = []
+ self.route_info = {}
+ print("No profitable route found yet.")
\ No newline at end of file
diff --git a/backend/src/pathfinder.py b/backend/src/pathfinder.py
index 80e7a44..4ce2df3 100644
--- a/backend/src/pathfinder.py
+++ b/backend/src/pathfinder.py
@@ -3,7 +3,7 @@ from dataclasses import dataclass
from tqdm import tqdm
from arbitrage import Trade
-from graph import Graph
+from graph import NavigationGraph as Graph
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -111,7 +111,7 @@ def calculate_net_profit(order_matches: list[Trade], graph: Graph, ship: Ship) -
for order_match in tqdm(order_matches):
try:
# Calculate transport cost from ship location to from_station
- cost_to_source = graph.shortest_path_length(ship.location, order_match.from_station)
+ cost_to_source = graph.shortest_path_length(ship["location"], order_match.from_station)
# Calculate transport cost from from_station to to_station
cost_to_destination = graph.shortest_path_length(order_match.from_station, order_match.to_station)
diff --git a/backend/src/route.py b/backend/src/route.py
index ec099b1..2bcae7e 100644
--- a/backend/src/route.py
+++ b/backend/src/route.py
@@ -4,9 +4,10 @@ from typing import Optional
from tqdm import tqdm
-from graph import Graph
+from graph import NavigationGraph as Graph
from pathfinder import Ship
from arbitrage import Trade
+from typing import Any
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -14,13 +15,7 @@ logger = logging.getLogger(__name__)
MAX_TRADES_TO_CONSIDER = 20_000
MAX_CAPITAL = 100_000_000
-@dataclass
-class RouteStep:
- location_id: int
- station_name: str
- actions: list[dict] = field(default_factory=list)
-
-Route = list[RouteStep]
+Route = list[dict[str, Any]]
def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route], Optional[dict]]:
"""
@@ -46,7 +41,7 @@ def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route
seen_routes = set()
for trade in tqdm(trades[:MAX_TRADES_TO_CONSIDER], desc="Evaluating trades"):
- route_stations = (ship.location, trade.from_station, trade.to_station)
+ route_stations = (ship["location"], trade.from_station, trade.to_station)
if route_stations in seen_routes:
continue
@@ -59,7 +54,7 @@ def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route
continue
try:
- path = graph.shortest_path(ship.location, trade.from_station)[:-1]
+ path = graph.shortest_path(ship["location"], trade.from_station)[:-1]
path += graph.shortest_path(trade.from_station, trade.to_station)
except Exception as e:
logger.error(f"Error finding path: {e}")
@@ -68,7 +63,7 @@ def route(trades: list[Trade], graph: Graph, ship: Ship) -> tuple[Optional[Route
risk = sum(graph.graph[u][v]['risk'] for u, v in zip(path, path[1:]))
transport_time = sum(graph.graph[u][v]['time'] for u, v in zip(path, path[1:]))
- capital = getattr(ship, 'ship_cost', 0.0) + sum(t.from_price * t.quantity for t in optimized_trades)
+ capital = ship.get('ship_cost', 0.0) + sum(t.from_price * t.quantity for t in optimized_trades)
if transport_time == 0:
logger.warning("Transport time is zero, skipping this route to avoid division by zero.")
@@ -133,7 +128,7 @@ def select_trades(stations: list[int], trades: list[Trade], ship: Ship) -> list[
from_remaining = used_orders.get(from_order, quantity)
to_remaining = used_orders.get(to_order, quantity)
- max_quantity_by_cargo = int((ship.cargo - cargo) // trade.item_cargo_volume)
+ max_quantity_by_cargo = int((ship["cargo"] - cargo) // trade.item_cargo_volume)
max_quantity_by_capital = int((MAX_CAPITAL - capital) // trade.from_price)
max_quantity = min(quantity, from_remaining, to_remaining, max_quantity_by_cargo, max_quantity_by_capital)
@@ -165,7 +160,7 @@ def select_trades(stations: list[int], trades: list[Trade], ship: Ship) -> list[
optimized_trades.append(updated_trade)
- if cargo >= ship.cargo or capital >= MAX_CAPITAL:
+ if cargo >= ship["cargo"] or capital >= MAX_CAPITAL:
break
return optimized_trades
@@ -184,8 +179,8 @@ def set_actions(route: Route, trades: list[Trade]) -> Route:
bought_items = set()
for trade in trades:
for step in route:
- if trade.from_station == step.location_id:
- step.actions.append({
+ if trade.from_station == step["location_id"]:
+ step["actions"].append({
"action_type": "buy",
"item": trade.item_name,
"type_id": trade.type_id,
@@ -193,8 +188,8 @@ def set_actions(route: Route, trades: list[Trade]) -> Route:
"price": trade.from_price,
})
bought_items.add(trade.type_id)
- if trade.to_station == step.location_id and trade.type_id in bought_items:
- step.actions.append({
+ if trade.to_station == step["location_id"] and trade.type_id in bought_items:
+ step["actions"].append({
"action_type": "sell",
"item": trade.item_name,
"type_id": trade.type_id,
@@ -203,6 +198,6 @@ def set_actions(route: Route, trades: list[Trade]) -> Route:
})
for step in route:
- step.actions.sort(key=lambda action: (action["action_type"], action["item"], action["price"]))
+ step["actions"].sort(key=lambda action: (action["action_type"], action["item"], action["price"]))
return route
diff --git a/readme.md b/readme.md
index 65d9501..b145239 100644
--- a/readme.md
+++ b/readme.md
@@ -75,6 +75,38 @@ The project comprises:
* * * * *
+**Algorithm Criteria & Limits**
+-------------------------------
+
+The route planning and arbitrage algorithms evaluate the profitability and safety of moving items using the following constraints:
+
+1. **Ship & Financial Limits**
+ - **Cargo Capacity**: Hard limit of 600m³. Orders that don't fit are ignored.
+ - **Maximum Route Capital**: Caps the up-front item purchase cost at 100,000,000 ISK when compounding multiple items in a route.
+ - **Profit Threshold**: A single trade must yield a minimum gross profit of 100,000 ISK to be considered.
+
+2. **Time & Navigational Costs**
+ - **Ship Speed**: Calculations factor in `max_warp_speed = 8.22 AU/s` and `max_subwarp_speed = 216.5 m/s`.
+ - **Stargate Delay**: Each jump is assigned a fixed 9.0-second delay.
+ - **In-system Warp**: Calculates exact 3D geometry for acceleration and deceleration.
+ - **Player Time Cost**: The algorithm assigns a real-life time value of ~4,166 ISK per second (`15_000_000 / 3600`) to punish overly long routes.
+
+3. **Risk & Safety Penalties**
+ - **Gank Zones**: Hardcoded danger zones (e.g., Ahbazon) multiply the risk penalty heavily.
+ - **Security Status**: High-sec space carries virtually no penalty, low-sec applies a 0.005 penalty, and null-sec adds a massive 0.5 risk penalty.
+ - **Value at Risk**: The calculated risk percentage is multiplied against a theoretical 60m ISK penalty + the 50m ship value + the cargo value.
+
+4. **Trade Execution & Filtering Strategy**
+ - **Market Tax**: Accounts for an 8% transaction tax on all trades automatically.
+ - **Arbitrage Capital Limits**: Rejects individual items exceeding 20,000,000 ISK up-front capital during initial data processing but allows up to 100,000,000 ISK when compounding multiple items continuously.
+ - **Same-Region Filtering**: Ensures that pickup and dropoff stations for a single item are within the same region.
+ - **Evaluation Limit**: Limits pathfinding to the top 20,000 trades to conserve CPU overhead.
+ - **Snipe Feature Execution**: Background polling monitors loop regions actively for single trades that boast >20,000,000 ISK gross profit. If triggered, it executes an automatic ESI API call to forcefully open your in-game client's market window to intercept the trade instantly.
+ - **ESI Error Limits**: Pauses backend routines entirely if the X-Esi-Error-Limit-Remain headers fall below 50.
+ - **The Final Metric**: The route chosen is the one that produces the highest `profit_rate` (which is `net_profit = total_profit - risk * capital` divided by `transport_time`).
+
+* * * * *
+
**Project Structure**
---------------------
@@ -88,4 +120,43 @@ The project comprises:
├── rust_backend/ # Experimental Rust rewrite
│ ├── src/ # Rust codebase
│ └── Cargo.toml # Rust dependencies
-``` \ No newline at end of file
+├── scripts/ # Setup and run scripts
+│ └── haul.sh # Unified script for data/auth setup and app runtime
+```
+
+* * * * *
+
+**Getting Started**
+-------------------
+
+HAUL uses a unified shell script to handle setup, data fetching, authorization, and running the application.
+
+1. **Check Environment**:
+ Verify what prerequisites are missing by running:
+ ```bash
+ ./scripts/haul.sh check
+ ```
+
+2. **Fetch Static Data**:
+ HAUL requires EVE Online CSV dumps. Download and extract them automatically:
+ ```bash
+ ./scripts/haul.sh setup-data
+ ```
+
+3. **Authenticate**:
+ Set up your EVE SSO credentials and generate the auth cache:
+ ```bash
+ ./scripts/haul.sh setup-auth
+ ```
+
+4. **Run the Application**:
+ Start both the Python backend and React frontend concurrently:
+ ```bash
+ ./scripts/haul.sh up
+ ```
+
+ Alternatively, run them separately:
+ ```bash
+ ./scripts/haul.sh backend
+ ./scripts/haul.sh frontend
+ ``` \ No newline at end of file
diff --git a/scripts/haul.sh b/scripts/haul.sh
new file mode 100755
index 0000000..b677694
--- /dev/null
+++ b/scripts/haul.sh
@@ -0,0 +1,217 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PYTHON_BIN="$ROOT_DIR/.venv/bin/python"
+NODE_BIN_DIR="$ROOT_DIR/.tools/node/bin"
+AUTH_FILE="$ROOT_DIR/backend/src/cache/auth.json"
+DATA_DIR="$ROOT_DIR/backend/data"
+ENV_FILE="$ROOT_DIR/.env"
+
+REQUIRED_DATA_FILES=(
+ mapDenormalize.csv
+ mapJumps.csv
+ invTypes.csv
+ mapRegions.csv
+)
+
+print_usage() {
+ cat <<'EOF'
+Usage: scripts/haul.sh <command>
+
+Commands:
+ check Show what is ready and what is still missing
+ setup-data Download required EVE CSV files into backend/data
+ setup-auth Create backend/src/cache/auth.json via EVE SSO
+ backend Start only the Python backend on http://127.0.0.1:8000
+ frontend Start only the React frontend on http://127.0.0.1:3000
+ up Start backend and frontend together
+EOF
+}
+
+load_env() {
+ if [[ -f "$ENV_FILE" ]]; then
+ set -a
+ # shellcheck disable=SC1090
+ source "$ENV_FILE"
+ set +a
+ fi
+
+ if [[ -n "${CHARACTER_ID:-}" && -z "${character_id:-}" ]]; then
+ export character_id="$CHARACTER_ID"
+ fi
+}
+
+require_python() {
+ if [[ ! -x "$PYTHON_BIN" ]]; then
+ echo "Missing Python virtualenv at $PYTHON_BIN"
+ echo "Create it first or rerun environment setup."
+ exit 1
+ fi
+}
+
+require_node() {
+ if [[ ! -x "$NODE_BIN_DIR/node" || ! -x "$NODE_BIN_DIR/npm" ]]; then
+ echo "Missing repo-local Node runtime under $NODE_BIN_DIR"
+ exit 1
+ fi
+}
+
+require_frontend_install() {
+ if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then
+ echo "Missing frontend/node_modules"
+ echo "Run: scripts/haul.sh frontend"
+ exit 1
+ fi
+}
+
+backend_runtime_ready() {
+ local missing=0
+
+ if [[ -z "${CLIENT_ID:-}" || -z "${SECRET_KEY:-}" || -z "${character_id:-}" ]]; then
+ echo "Missing required env vars. Run: scripts/haul.sh setup-auth"
+ missing=1
+ fi
+
+ if [[ ! -f "$AUTH_FILE" ]]; then
+ echo "Missing auth cache: $AUTH_FILE"
+ echo "Run: scripts/haul.sh setup-auth"
+ missing=1
+ fi
+
+ for file_name in "${REQUIRED_DATA_FILES[@]}"; do
+ if [[ ! -f "$DATA_DIR/$file_name" ]]; then
+ echo "Missing data file: $DATA_DIR/$file_name"
+ missing=1
+ fi
+ done
+
+ if [[ $missing -ne 0 ]]; then
+ echo "Run: scripts/haul.sh setup-data"
+ return 1
+ fi
+
+ return 0
+}
+
+print_status() {
+ load_env
+
+ if [[ -x "$PYTHON_BIN" ]]; then
+ echo "python: ready ($PYTHON_BIN)"
+ else
+ echo "python: missing ($PYTHON_BIN)"
+ fi
+
+ if [[ -x "$NODE_BIN_DIR/node" ]]; then
+ echo "node: ready ($NODE_BIN_DIR/node)"
+ else
+ echo "node: missing ($NODE_BIN_DIR/node)"
+ fi
+
+ if [[ -d "$ROOT_DIR/frontend/node_modules" ]]; then
+ echo "frontend deps: ready"
+ else
+ echo "frontend deps: missing"
+ fi
+
+ if [[ -f "$AUTH_FILE" ]]; then
+ echo "auth cache: ready"
+ else
+ echo "auth cache: missing"
+ fi
+
+ for file_name in "${REQUIRED_DATA_FILES[@]}"; do
+ if [[ -f "$DATA_DIR/$file_name" ]]; then
+ echo "data: ready ($file_name)"
+ else
+ echo "data: missing ($file_name)"
+ fi
+ done
+
+ if [[ -f "$ENV_FILE" ]]; then
+ echo ".env: present"
+ else
+ echo ".env: missing (setup-auth can create it)"
+ fi
+}
+
+run_setup_data() {
+ require_python
+ exec "$PYTHON_BIN" "$ROOT_DIR/scripts/setup_eve_data.py"
+}
+
+run_setup_auth() {
+ require_python
+ load_env
+ exec "$PYTHON_BIN" "$ROOT_DIR/scripts/setup_auth.py"
+}
+
+run_backend() {
+ require_python
+ load_env
+ backend_runtime_ready
+ cd "$ROOT_DIR/backend/src"
+ exec "$PYTHON_BIN" -m uvicorn main:app --reload --host 127.0.0.1 --port 8000
+}
+
+run_frontend() {
+ require_node
+ require_frontend_install
+ export PATH="$NODE_BIN_DIR:$PATH"
+ cd "$ROOT_DIR/frontend"
+ exec npm start
+}
+
+run_up() {
+ require_python
+ require_node
+ require_frontend_install
+ load_env
+ backend_runtime_ready
+
+ export PATH="$NODE_BIN_DIR:$PATH"
+
+ cd "$ROOT_DIR/backend/src"
+ "$PYTHON_BIN" -m uvicorn main:app --host 127.0.0.1 --port 8000 &
+ backend_pid=$!
+
+ cleanup() {
+ if kill -0 "$backend_pid" >/dev/null 2>&1; then
+ kill "$backend_pid"
+ wait "$backend_pid" 2>/dev/null || true
+ fi
+ }
+
+ trap cleanup EXIT INT TERM
+
+ cd "$ROOT_DIR/frontend"
+ exec npm start
+}
+
+command_name="${1:-}"
+
+case "$command_name" in
+ check)
+ print_status
+ ;;
+ setup-data)
+ run_setup_data
+ ;;
+ setup-auth)
+ run_setup_auth
+ ;;
+ backend)
+ run_backend
+ ;;
+ frontend)
+ run_frontend
+ ;;
+ up)
+ run_up
+ ;;
+ *)
+ print_usage
+ exit 1
+ ;;
+esac \ No newline at end of file
diff --git a/scripts/setup_auth.py b/scripts/setup_auth.py
new file mode 100644
index 0000000..d675ddd
--- /dev/null
+++ b/scripts/setup_auth.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+import base64
+import json
+from datetime import datetime, UTC
+from getpass import getpass
+from pathlib import Path
+import shlex
+from urllib.parse import urlencode, quote
+
+import requests
+from dotenv import load_dotenv
+from jose import jwt
+import os
+
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+AUTH_FILE = ROOT_DIR / "backend" / "src" / "cache" / "auth.json"
+ENV_FILE = ROOT_DIR / ".env"
+REDIRECT_URI = "https://localhost/callback/"
+TOKEN_URL = "https://login.eveonline.com/v2/oauth/token"
+AUTH_URL = "https://login.eveonline.com/v2/oauth/authorize/"
+SCOPES = [
+ "esi-assets.read_assets.v1",
+ "esi-location.read_location.v1",
+ "esi-location.read_online.v1",
+ "esi-ui.open_window.v1",
+ "esi-ui.write_waypoint.v1",
+]
+
+
+def build_auth_url(client_id: str) -> str:
+ params = {
+ "response_type": "code",
+ "redirect_uri": REDIRECT_URI,
+ "client_id": client_id,
+ "state": "haul-local",
+ "scope": " ".join(SCOPES),
+ }
+ return f"{AUTH_URL}?{urlencode(params, quote_via=quote)}"
+
+
+def prompt_for_value(name: str, secret: bool = False) -> str:
+ while True:
+ value = getpass(f"{name}: ") if secret else input(f"{name}: ").strip()
+ if value:
+ return value
+ print(f"{name} is required.")
+
+
+def append_missing_env_values(values: dict[str, str]) -> None:
+ existing_text = ENV_FILE.read_text(encoding="utf-8") if ENV_FILE.exists() else ""
+ existing_keys = set()
+
+ for raw_line in existing_text.splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _ = line.split("=", 1)
+ existing_keys.add(key.strip())
+
+ lines_to_add = []
+ for key, value in values.items():
+ if key not in existing_keys:
+ lines_to_add.append(f"{key}={shlex.quote(value)}")
+
+ if not lines_to_add:
+ return
+
+ if existing_text and not existing_text.endswith("\n"):
+ existing_text += "\n"
+
+ ENV_FILE.write_text(existing_text + "\n".join(lines_to_add) + "\n", encoding="utf-8")
+
+
+def main() -> int:
+ load_dotenv(ENV_FILE)
+
+ client_id = os.getenv("CLIENT_ID") or prompt_for_value("CLIENT_ID")
+ secret_key = os.getenv("SECRET_KEY") or prompt_for_value("SECRET_KEY", secret=True)
+
+ print("Open this URL in your browser and complete the EVE SSO flow:\n")
+ print(build_auth_url(client_id))
+ print("\nAfter the redirect, copy the 'code' query parameter and paste it below.")
+ code = input("Authorization code: ").strip()
+
+ if not code:
+ print("No authorization code provided.")
+ return 1
+
+ user_pass = f"{client_id}:{secret_key}".encode("utf-8")
+ headers = {
+ "Authorization": f"Basic {base64.urlsafe_b64encode(user_pass).decode()}",
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Host": "login.eveonline.com",
+ }
+ form_values = {
+ "grant_type": "authorization_code",
+ "code": code,
+ }
+
+ response = requests.post(TOKEN_URL, data=form_values, headers=headers, timeout=30)
+ response.raise_for_status()
+ auth_data = response.json()
+ auth_data["expires_at"] = int(datetime.now(UTC).timestamp()) + int(auth_data.get("expires_in", 0))
+
+ AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
+ AUTH_FILE.write_text(json.dumps(auth_data, indent=2), encoding="utf-8")
+
+ claims = jwt.get_unverified_claims(auth_data["access_token"])
+ character_id = claims.get("sub", "").split(":")[-1] if claims.get("sub") else "unknown"
+ character_name = claims.get("name", "unknown")
+
+ append_missing_env_values(
+ {
+ "CLIENT_ID": client_id,
+ "SECRET_KEY": secret_key,
+ "character_id": character_id,
+ }
+ )
+
+ print(f"Saved auth cache to {AUTH_FILE}")
+ print(f"Detected character: {character_name} ({character_id})")
+ print(f"Ensured runtime env values exist in {ENV_FILE}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main()) \ No newline at end of file
diff --git a/scripts/setup_eve_data.py b/scripts/setup_eve_data.py
new file mode 100644
index 0000000..a7f7efd
--- /dev/null
+++ b/scripts/setup_eve_data.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import bz2
+import shutil
+import sys
+import urllib.request
+from pathlib import Path
+
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+DATA_DIR = ROOT_DIR / "backend" / "data"
+BASE_URL = "https://www.fuzzwork.co.uk/dump/latest"
+
+REQUIRED_FILES = {
+ "mapDenormalize.csv": f"{BASE_URL}/mapDenormalize.csv.bz2",
+ "mapJumps.csv": f"{BASE_URL}/mapJumps.csv.bz2",
+ "invTypes.csv": f"{BASE_URL}/invTypes.csv.bz2",
+ "mapRegions.csv": f"{BASE_URL}/mapRegions.csv.bz2",
+}
+
+
+def download_and_extract(file_name: str, url: str) -> None:
+ destination = DATA_DIR / file_name
+ archive_path = destination.with_suffix(destination.suffix + ".bz2")
+
+ if destination.exists():
+ print(f"Skipping {file_name}; already exists.")
+ return
+
+ print(f"Downloading {file_name} from {url}")
+
+ try:
+ with urllib.request.urlopen(url, timeout=60) as response, archive_path.open("wb") as output_file:
+ shutil.copyfileobj(response, output_file)
+
+ with bz2.open(archive_path, "rb") as compressed_file, destination.open("wb") as output_file:
+ shutil.copyfileobj(compressed_file, output_file)
+ finally:
+ archive_path.unlink(missing_ok=True)
+
+ print(f"Wrote {destination}")
+
+
+def main() -> int:
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+
+ for file_name, url in REQUIRED_FILES.items():
+ download_and_extract(file_name, url)
+
+ print("EVE static data is ready.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main()) \ No newline at end of file