""" Enhanced Map Server with Editor and Authentication """ from flask import Flask, jsonify, request, send_from_directory, session, redirect, url_for from flask_cors import CORS from pathlib import Path import json import os import sys import secrets from werkzeug.utils import secure_filename from functools import wraps # Get the directory of this script SCRIPT_DIR = Path(__file__).parent.resolve() PARENT_DIR = SCRIPT_DIR.parent # Add parent directory to path for imports sys.path.insert(0, str(PARENT_DIR)) app = Flask(__name__, static_folder=str(SCRIPT_DIR)) app.secret_key = os.environ.get('EDITOR_SECRET_KEY', secrets.token_hex(32)) CORS(app) # Configuration ADMIN_PASSWORD = os.environ.get('EDITOR_PASSWORD', 'admin123') # Change this! UPLOAD_FOLDER = PARENT_DIR / 'images' / 'locations' UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True) ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} # JSON data files GAMEDATA_DIR = PARENT_DIR / 'gamedata' LOCATIONS_FILE = GAMEDATA_DIR / 'locations.json' NPCS_FILE = GAMEDATA_DIR / 'npcs.json' ITEMS_FILE = GAMEDATA_DIR / 'items.json' INTERACTABLES_FILE = GAMEDATA_DIR / 'interactables.json' def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def load_json_file(filepath): """Helper to load JSON file""" try: with open(filepath, 'r') as f: return json.load(f) except FileNotFoundError: return None except Exception as e: print(f"Error loading {filepath}: {e}") return None def save_json_file(filepath, data): """Helper to save JSON file""" try: with open(filepath, 'w') as f: json.dump(data, f, indent=2) return True except Exception as e: print(f"Error saving {filepath}: {e}") return False def require_auth(f): """Decorator to require authentication for editor routes""" @wraps(f) def decorated_function(*args, **kwargs): if not session.get('authenticated'): return jsonify({'error': 'Unauthorized'}), 401 return f(*args, **kwargs) return decorated_function # ==================== AUTHENTICATION ROUTES ==================== @app.route('/api/login', methods=['POST']) def login(): """Authenticate user with password""" data = request.get_json() password = data.get('password', '') if password == ADMIN_PASSWORD: session['authenticated'] = True session.permanent = True return jsonify({'success': True, 'message': 'Authenticated successfully'}) else: return jsonify({'success': False, 'message': 'Invalid password'}), 401 @app.route('/api/logout', methods=['POST']) def logout(): """Logout user""" session.pop('authenticated', None) return jsonify({'success': True, 'message': 'Logged out'}) @app.route('/api/editor/restart-bot', methods=['POST']) @require_auth def restart_bot(): """Restart the Telegram bot container""" try: import subprocess # Try to restart the bot container result = subprocess.run( ['docker', 'restart', 'echoes_of_the_ashes_api'], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: return jsonify({ 'success': True, 'message': 'Bot restarted successfully' }) else: return jsonify({ 'success': False, 'error': f'Docker restart failed: {result.stderr}' }), 500 except subprocess.TimeoutExpired: return jsonify({ 'success': False, 'error': 'Restart command timed out' }), 500 except FileNotFoundError: return jsonify({ 'success': False, 'error': 'Docker command not found. Is Docker installed?' }), 500 except Exception as e: return jsonify({ 'success': False, 'error': f'Unexpected error: {str(e)}' }), 500 @app.route('/api/editor/bot-logs', methods=['GET']) @require_auth def get_bot_logs(): """Get logs from the Telegram bot container""" try: import subprocess # Get number of lines from query parameter (default 100) lines = request.args.get('lines', '100') try: lines = int(lines) if lines < 1 or lines > 1000: lines = 100 except ValueError: lines = 100 # Get logs from the bot container result = subprocess.run( ['docker', 'logs', 'echoes_of_the_ashes_api', '--tail', str(lines)], capture_output=True, text=True, timeout=10 ) # Combine stdout and stderr (Docker logs can use both) logs = result.stdout + result.stderr return jsonify({ 'success': True, 'logs': logs, 'lines': lines }) except subprocess.TimeoutExpired: return jsonify({ 'success': False, 'error': 'Logs command timed out' }), 500 except FileNotFoundError: return jsonify({ 'success': False, 'error': 'Docker command not found. Is Docker installed?' }), 500 except Exception as e: return jsonify({ 'success': False, 'error': f'Unexpected error: {str(e)}' }), 500 @app.route('/api/check-auth', methods=['GET']) def check_auth(): """Check if user is authenticated""" return jsonify({'authenticated': session.get('authenticated', False)}) # ==================== PUBLIC ROUTES ==================== @app.route('/') def index(): """Serve the main map viewer""" return send_from_directory(SCRIPT_DIR, 'index.html') @app.route('/editor') def editor(): """Serve the map editor (requires auth)""" return send_from_directory(SCRIPT_DIR, 'editor.html') @app.route('/') def serve_static(path): """Serve static files""" return send_from_directory(SCRIPT_DIR, path) @app.route('/images/') def serve_images(path): """Serve images from parent directory""" return send_from_directory(PARENT_DIR / 'images', path) @app.route('/api/map-data', methods=['GET']) @app.route('/map_data.json', methods=['GET']) def get_map_data(): """Get current map data (exported from world_loader which now loads from JSON)""" try: from data.world_loader import export_map_data map_data = export_map_data() return jsonify(map_data) except Exception as e: return jsonify({'error': str(e)}), 500 # ==================== EDITOR API ROUTES (PROTECTED) ==================== @app.route('/api/editor/locations', methods=['GET']) @require_auth def get_locations(): """Get all locations with full details""" try: # Load directly from JSON files to get latest data locations_data = load_json_file(LOCATIONS_FILE) npcs_data = load_json_file(NPCS_FILE) if not locations_data: return jsonify({'locations': []}) locations = [] for loc_data in locations_data.get('locations', []): location_id = loc_data.get('id') # Get danger config danger_config = locations_data.get('danger_config', {}).get(location_id, {}) # Get spawn config spawn_config = locations_data.get('spawn_config', {}).get(location_id, []) spawn_npcs = [] for spawn_entry in spawn_config: npc_id = spawn_entry.get('npc_id') weight = spawn_entry.get('weight', 50) # Get NPC details npc_def = npcs_data.get('npcs', {}).get(npc_id, {}) if npc_def: spawn_npcs.append({ 'npc_id': npc_id, 'name': npc_def.get('name', npc_id), 'emoji': npc_def.get('emoji', 'πŸ‘Ή'), 'weight': weight }) # Get connections from locations.json exits = [] for conn in locations_data.get('connections', []): if conn.get('from') == location_id: exits.append(conn.get('to')) locations.append({ 'id': location_id, 'name': loc_data.get('name', ''), 'description': loc_data.get('description', ''), 'image_path': loc_data.get('image_path', ''), 'x': loc_data.get('x', 0.0), 'y': loc_data.get('y', 0.0), 'danger_level': danger_config.get('danger_level', 0), 'encounter_rate': danger_config.get('encounter_rate', 0.0), 'wandering_chance': danger_config.get('wandering_chance', 0.0), 'spawn_npcs': spawn_npcs, 'exits': exits, 'interactables_count': len(loc_data.get('interactables', {})) }) return jsonify({'locations': locations}) except Exception as e: import traceback print(f"Error loading locations: {e}\n{traceback.format_exc()}") return jsonify({'error': str(e)}), 500 @app.route('/api/editor/location/', methods=['GET']) @require_auth def get_location_detail(location_id): """Get detailed information about a specific location""" try: # Load directly from JSON to get latest data locations_data = load_json_file(LOCATIONS_FILE) npcs_data = load_json_file(NPCS_FILE) if not locations_data: return jsonify({'error': 'Locations file not found'}), 404 # Find the location in the list location = None for loc in locations_data.get('locations', []): if loc.get('id') == location_id: location = loc break if not location: return jsonify({'error': 'Location not found'}), 404 # Get danger config danger_config = locations_data.get('danger_config', {}).get(location_id, {}) # Get spawn config spawn_config = locations_data.get('spawn_config', {}).get(location_id, []) spawn_npcs = [] for spawn_entry in spawn_config: npc_id = spawn_entry.get('npc_id') weight = spawn_entry.get('weight', 50) # Get NPC details npc_def = npcs_data.get('npcs', {}).get(npc_id, {}) if npc_def: spawn_npcs.append({ 'npc_id': npc_id, 'name': npc_def.get('name', npc_id), 'emoji': npc_def.get('emoji', 'πŸ‘Ή'), 'weight': weight }) # Get exits from connections exits = {} for conn in locations_data.get('connections', []): if conn.get('from') == location_id: to_id = conn.get('to') exits[to_id] = { 'stamina_cost': conn.get('stamina_cost', 1), 'distance': conn.get('distance', 1.0) } return jsonify({ 'id': location_id, 'name': location.get('name', ''), 'description': location.get('description', ''), 'image_path': location.get('image_path', ''), 'x': location.get('x', 0.0), 'y': location.get('y', 0.0), 'danger_level': danger_config.get('danger_level', 0), 'encounter_rate': danger_config.get('encounter_rate', 0.0), 'wandering_chance': danger_config.get('wandering_chance', 0.0), 'spawn_npcs': spawn_npcs, 'exits': exits, 'interactables': location.get('interactables', {}) }) except Exception as e: import traceback print(f"Error loading location detail: {e}\n{traceback.format_exc()}") return jsonify({'error': str(e)}), 500 @app.route('/api/editor/location', methods=['POST']) @require_auth def update_location(): """Update or create a location""" try: data = request.get_json() location_id = data.get('id') print(f"[DEBUG] Received location data: {json.dumps(data, indent=2)}") # Load or create config config = {} if LOCATIONS_FILE.exists(): with open(LOCATIONS_FILE, 'r') as f: config = json.load(f) # Ensure proper structure - locations is a list, others are dicts if 'locations' not in config or not isinstance(config['locations'], list): config['locations'] = [] if 'danger_config' not in config or not isinstance(config['danger_config'], dict): config['danger_config'] = {} if 'spawn_config' not in config or not isinstance(config['spawn_config'], dict): config['spawn_config'] = {} # Find or create location in the list location_index = None for i, loc in enumerate(config['locations']): if loc.get('id') == location_id: location_index = i break location_data = { 'id': location_id, 'name': data.get('name'), 'description': data.get('description'), 'image_path': data.get('image_path'), 'x': data.get('x'), 'y': data.get('y'), 'interactables': data.get('interactables', {}) } if location_index is not None: # Update existing location config['locations'][location_index] = location_data else: # Add new location config['locations'].append(location_data) # Update danger config config['danger_config'][location_id] = { 'danger_level': data.get('danger_level', 0), 'encounter_rate': data.get('encounter_rate', 0.0), 'wandering_chance': data.get('wandering_chance', 0.0) } # Update spawn config spawn_npcs = data.get('spawn_npcs', []) print(f"[DEBUG] spawn_npcs type: {type(spawn_npcs)}, value: {spawn_npcs}") if spawn_npcs and isinstance(spawn_npcs, list): config['spawn_config'][location_id] = [ {'npc_id': npc.get('npc_id', npc) if isinstance(npc, dict) else npc, 'weight': npc.get('weight', 50) if isinstance(npc, dict) else 50} for npc in spawn_npcs ] else: config['spawn_config'][location_id] = [] # Save config with open(LOCATIONS_FILE, 'w') as f: json.dump(config, f, indent=2) return jsonify({'success': True, 'message': 'Location updated successfully'}) except Exception as e: import traceback error_msg = f"[ERROR] Failed to save location: {e}\n{traceback.format_exc()}" print(error_msg, flush=True) # Also write to a file for debugging try: with open('/tmp/location_save_error.txt', 'w') as f: f.write(error_msg) f.write(f"\n\nReceived data: {json.dumps(data, indent=2)}") except: pass return jsonify({'error': str(e)}), 500 @app.route('/api/editor/location/', methods=['DELETE']) @require_auth def delete_location(location_id): """Delete a location from config""" try: if not LOCATIONS_FILE.exists(): return jsonify({'error': 'No config file found'}), 404 with open(LOCATIONS_FILE, 'r') as f: config = json.load(f) # Remove location from locations array if 'locations' in config: config['locations'] = [loc for loc in config['locations'] if loc.get('id') != location_id] # Remove from connections array (both from and to) if 'connections' in config: config['connections'] = [ conn for conn in config['connections'] if conn.get('from') != location_id and conn.get('to') != location_id ] # Remove from danger_config if 'danger_config' in config and location_id in config['danger_config']: del config['danger_config'][location_id] # Remove from spawn_config if 'spawn_config' in config and location_id in config['spawn_config']: del config['spawn_config'][location_id] with open(LOCATIONS_FILE, 'w') as f: json.dump(config, f, indent=2) return jsonify({'success': True, 'message': 'Location deleted successfully'}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/available-npcs', methods=['GET']) @require_auth def get_available_npcs(): """Get list of all available NPCs for spawning""" try: # Load directly from JSON to get latest data npcs_data = load_json_file(NPCS_FILE) if not npcs_data: return jsonify({'npcs': []}) npcs = [] for npc_id, npc_def in npcs_data.get('npcs', {}).items(): npcs.append({ 'id': npc_id, 'name': npc_def.get('name', npc_id), 'emoji': npc_def.get('emoji', 'πŸ‘Ή'), 'hp_range': [npc_def.get('hp_min', 10), npc_def.get('hp_max', 20)], 'damage_range': [npc_def.get('damage_min', 1), npc_def.get('damage_max', 5)], 'xp_reward': npc_def.get('xp_reward', 10) }) return jsonify({'npcs': npcs}) except Exception as e: import traceback print(f"Error loading available NPCs: {e}\n{traceback.format_exc()}") return jsonify({'error': str(e)}), 500 @app.route('/api/editor/upload-image', methods=['POST']) @require_auth def upload_image(): """Upload a location image""" try: if 'image' not in request.files: return jsonify({'error': 'No image file provided'}), 400 file = request.files['image'] if file.filename == '': return jsonify({'error': 'No selected file'}), 400 if file and allowed_file(file.filename): filename = secure_filename(file.filename) filepath = UPLOAD_FOLDER / filename file.save(str(filepath)) # Return relative path relative_path = f'images/locations/{filename}' return jsonify({ 'success': True, 'image_path': relative_path, 'message': f'Image uploaded successfully: {filename}' }) else: return jsonify({'error': 'Invalid file type'}), 400 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/export-config', methods=['GET']) @require_auth def export_config(): """Export current config as downloadable JSON""" try: if LOCATIONS_FILE.exists(): with open(LOCATIONS_FILE, 'r') as f: config = json.load(f) return jsonify(config) else: return jsonify({'locations': {}, 'danger_config': {}, 'spawn_config': {}}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/import-config', methods=['POST']) @require_auth def import_config(): """Import config from JSON""" try: config = request.get_json() with open(LOCATIONS_FILE, 'w') as f: json.dump(config, f, indent=2) return jsonify({'success': True, 'message': 'Configuration imported successfully'}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/connections', methods=['GET']) @require_auth def get_connections(): """Get all connections between locations""" try: import math # Load directly from JSON file to get latest data locations_data = load_json_file(LOCATIONS_FILE) if not locations_data: return jsonify({'connections': []}) # Build location lookup for calculating distances locations_by_id = {} for loc in locations_data.get('locations', []): locations_by_id[loc['id']] = loc connections = [] for conn in locations_data.get('connections', []): from_id = conn.get('from') to_id = conn.get('to') direction = conn.get('direction') from_loc = locations_by_id.get(from_id) to_loc = locations_by_id.get(to_id) if from_loc and to_loc: distance = math.sqrt((to_loc.get('x', 0) - from_loc.get('x', 0))**2 + (to_loc.get('y', 0) - from_loc.get('y', 0))**2) connections.append({ 'from': from_id, 'to': to_id, 'direction': direction, 'distance': round(distance, 2) }) return jsonify({'connections': connections}) except Exception as e: import traceback print(f"Error loading connections: {e}\n{traceback.format_exc()}") return jsonify({'error': str(e)}), 500 @app.route('/api/editor/connection', methods=['POST']) @require_auth def add_connection(): """Add a connection between two locations""" try: data = request.get_json() from_id = data.get('from') to_id = data.get('to') direction = data.get('direction') # Load or create config config = {} if LOCATIONS_FILE.exists(): with open(LOCATIONS_FILE, 'r') as f: config = json.load(f) if 'connections' not in config: config['connections'] = [] # Add connection config['connections'].append({ 'from': from_id, 'to': to_id, 'direction': direction }) with open(LOCATIONS_FILE, 'w') as f: json.dump(config, f, indent=2) return jsonify({'success': True, 'message': 'Connection added successfully'}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/connection', methods=['DELETE']) @require_auth def delete_connection(): """Delete a connection between two locations""" try: data = request.get_json() from_id = data.get('from') to_id = data.get('to') if not LOCATIONS_FILE.exists(): return jsonify({'error': 'No config file found'}), 404 with open(LOCATIONS_FILE, 'r') as f: config = json.load(f) if 'connections' in config: config['connections'] = [ conn for conn in config['connections'] if not (conn['from'] == from_id and conn['to'] == to_id) ] with open(LOCATIONS_FILE, 'w') as f: json.dump(config, f, indent=2) return jsonify({'success': True, 'message': 'Connection deleted successfully'}) except Exception as e: return jsonify({'error': str(e)}), 500 # ==================== NPC MANAGEMENT ==================== @app.route('/api/editor/npcs', methods=['GET']) @require_auth def get_npcs(): """Get all NPCs""" try: npcs_data = load_json_file(NPCS_FILE) if not npcs_data: return jsonify({'npcs': []}) npcs = [] for npc_id, npc_def in npcs_data.get('npcs', {}).items(): npcs.append({ 'id': npc_id, 'name': npc_def.get('name', npc_id), 'emoji': npc_def.get('emoji', 'πŸ‘Ή'), 'hp_min': npc_def.get('hp_min', 10), 'hp_max': npc_def.get('hp_max', 20), 'damage_min': npc_def.get('damage_min', 1), 'damage_max': npc_def.get('damage_max', 5), 'xp_reward': npc_def.get('xp_reward', 10), 'defense': npc_def.get('defense', 0), 'description': npc_def.get('description', ''), 'image_path': npc_def.get('image_path', ''), 'loot_table': npc_def.get('loot_table', []), 'corpse_loot': npc_def.get('corpse_loot', []) }) return jsonify({'npcs': npcs}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/npc', methods=['POST']) @require_auth def save_npc(): """Save or update an NPC""" try: npc_data = request.get_json() npc_id = npc_data.get('id') # Load current NPCs npcs_data = load_json_file(NPCS_FILE) or {'npcs': {}, 'danger_levels': {}, 'spawn_tables': {}} if 'npcs' not in npcs_data: npcs_data['npcs'] = {} # Update NPC npcs_data['npcs'][npc_id] = { 'name': npc_data.get('name'), 'emoji': npc_data.get('emoji'), 'hp_min': npc_data.get('hp_min'), 'hp_max': npc_data.get('hp_max'), 'damage_min': npc_data.get('damage_min'), 'damage_max': npc_data.get('damage_max'), 'xp_reward': npc_data.get('xp_reward'), 'defense': npc_data.get('defense', 0), 'description': npc_data.get('description', ''), 'image_path': npc_data.get('image_path', ''), 'loot_table': npc_data.get('loot_table', []), 'corpse_loot': npc_data.get('corpse_loot', []) } # Save back if save_json_file(NPCS_FILE, npcs_data): return jsonify({'success': True, 'message': 'NPC saved successfully'}) else: return jsonify({'error': 'Failed to save NPC'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/npc/', methods=['DELETE']) @require_auth def delete_npc(npc_id): """Delete an NPC""" try: npcs_data = load_json_file(NPCS_FILE) if not npcs_data or 'npcs' not in npcs_data: return jsonify({'error': 'NPCs file not found'}), 404 if npc_id in npcs_data['npcs']: del npcs_data['npcs'][npc_id] if save_json_file(NPCS_FILE, npcs_data): return jsonify({'success': True, 'message': 'NPC deleted successfully'}) else: return jsonify({'error': 'Failed to delete NPC'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 # ==================== ITEM MANAGEMENT ==================== @app.route('/api/editor/items', methods=['GET']) @require_auth def get_items(): """Get all items with complete field set""" try: items_data = load_json_file(ITEMS_FILE) if not items_data: return jsonify({'items': []}) items = [] for item_id, item_def in items_data.get('items', {}).items(): # Build complete item object with all possible fields item = { 'id': item_id, 'name': item_def.get('name', item_id), 'emoji': item_def.get('emoji', ''), 'type': item_def.get('type', 'resource'), 'weight': item_def.get('weight', 0.1), 'volume': item_def.get('volume', 0.1), 'description': item_def.get('description', ''), 'image_path': item_def.get('image_path', ''), # Consumable properties 'stackable': item_def.get('stackable', True), 'hp_restore': item_def.get('hp_restore', 0), 'stamina_restore': item_def.get('stamina_restore', 0), 'treats': item_def.get('treats', ''), # Equipment properties 'equippable': item_def.get('equippable', False), 'slot': item_def.get('slot', ''), 'durability': item_def.get('durability', 0), 'tier': item_def.get('tier', 0), 'encumbrance': item_def.get('encumbrance', 0), # Stats object 'stats': item_def.get('stats', {}), # Weapon effects 'weapon_effects': item_def.get('weapon_effects', {}), # Crafting system 'craftable': item_def.get('craftable', False), 'craft_level': item_def.get('craft_level', 0), 'craft_materials': item_def.get('craft_materials', []), 'craft_tools': item_def.get('craft_tools', []), # Repair system 'repairable': item_def.get('repairable', False), 'repair_materials': item_def.get('repair_materials', []), 'repair_tools': item_def.get('repair_tools', []), 'repair_percentage': item_def.get('repair_percentage', 0), # Uncrafting system 'uncraftable': item_def.get('uncraftable', False), 'uncraft_yield': item_def.get('uncraft_yield', []), 'uncraft_loss_chance': item_def.get('uncraft_loss_chance', 0), 'uncraft_tools': item_def.get('uncraft_tools', []), } items.append(item) return jsonify({'items': items}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/item', methods=['POST']) @require_auth def save_item(): """Save or update an item with complete field support""" try: item_data = request.get_json() item_id = item_data.get('id') # Load current items items_data = load_json_file(ITEMS_FILE) or {'items': {}} if 'items' not in items_data: items_data['items'] = {} # Build item dict with required fields item_entry = { 'name': item_data.get('name'), 'type': item_data.get('type'), 'weight': item_data.get('weight'), 'volume': item_data.get('volume'), } # Add optional string fields if item_data.get('emoji'): item_entry['emoji'] = item_data.get('emoji') if item_data.get('description'): item_entry['description'] = item_data.get('description') if item_data.get('image_path'): item_entry['image_path'] = item_data.get('image_path') if item_data.get('treats'): item_entry['treats'] = item_data.get('treats') # Stackable defaults to True, only include if False if not item_data.get('stackable', True): item_entry['stackable'] = False # Consumable properties if item_data.get('hp_restore', 0) != 0: item_entry['hp_restore'] = item_data.get('hp_restore') if item_data.get('stamina_restore', 0) != 0: item_entry['stamina_restore'] = item_data.get('stamina_restore') # Equipment properties if item_data.get('equippable'): item_entry['equippable'] = True if item_data.get('slot'): item_entry['slot'] = item_data.get('slot') if item_data.get('durability', 0) != 0: item_entry['durability'] = item_data.get('durability') if item_data.get('tier', 0) != 0: item_entry['tier'] = item_data.get('tier') if item_data.get('encumbrance', 0) != 0: item_entry['encumbrance'] = item_data.get('encumbrance') # Stats object (only include if not empty) stats = item_data.get('stats', {}) if stats and isinstance(stats, dict) and len(stats) > 0: item_entry['stats'] = stats # Weapon effects object (only include if not empty) weapon_effects = item_data.get('weapon_effects', {}) if weapon_effects and isinstance(weapon_effects, dict) and len(weapon_effects) > 0: item_entry['weapon_effects'] = weapon_effects # Crafting system if item_data.get('craftable'): item_entry['craftable'] = True if item_data.get('craft_level', 0) != 0: item_entry['craft_level'] = item_data.get('craft_level') craft_materials = item_data.get('craft_materials', []) if craft_materials and isinstance(craft_materials, list) and len(craft_materials) > 0: item_entry['craft_materials'] = craft_materials craft_tools = item_data.get('craft_tools', []) if craft_tools and isinstance(craft_tools, list) and len(craft_tools) > 0: item_entry['craft_tools'] = craft_tools # Repair system if item_data.get('repairable'): item_entry['repairable'] = True repair_materials = item_data.get('repair_materials', []) if repair_materials and isinstance(repair_materials, list) and len(repair_materials) > 0: item_entry['repair_materials'] = repair_materials repair_tools = item_data.get('repair_tools', []) if repair_tools and isinstance(repair_tools, list) and len(repair_tools) > 0: item_entry['repair_tools'] = repair_tools if item_data.get('repair_percentage', 0) != 0: item_entry['repair_percentage'] = item_data.get('repair_percentage') # Uncrafting system if item_data.get('uncraftable'): item_entry['uncraftable'] = True uncraft_yield = item_data.get('uncraft_yield', []) if uncraft_yield and isinstance(uncraft_yield, list) and len(uncraft_yield) > 0: item_entry['uncraft_yield'] = uncraft_yield if item_data.get('uncraft_loss_chance', 0) != 0: item_entry['uncraft_loss_chance'] = item_data.get('uncraft_loss_chance') uncraft_tools = item_data.get('uncraft_tools', []) if uncraft_tools and isinstance(uncraft_tools, list) and len(uncraft_tools) > 0: item_entry['uncraft_tools'] = uncraft_tools # Update item items_data['items'][item_id] = item_entry # Save back if save_json_file(ITEMS_FILE, items_data): return jsonify({'success': True, 'message': 'Item saved successfully'}) else: return jsonify({'error': 'Failed to save item'}), 500 except Exception as e: import traceback print(f"Error saving item: {e}\n{traceback.format_exc()}", flush=True) return jsonify({'error': str(e)}), 500 @app.route('/api/editor/item/', methods=['DELETE']) @require_auth def delete_item(item_id): """Delete an item""" try: items_data = load_json_file(ITEMS_FILE) if not items_data or 'items' not in items_data: return jsonify({'error': 'Items file not found'}), 404 if item_id in items_data['items']: del items_data['items'][item_id] if save_json_file(ITEMS_FILE, items_data): return jsonify({'success': True, 'message': 'Item deleted successfully'}) else: return jsonify({'error': 'Failed to delete item'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 # ==================== INTERACTABLES ENDPOINTS ==================== @app.route('/api/editor/interactables', methods=['GET']) @require_auth def get_interactables(): """Get all interactable templates""" try: interactables_data = load_json_file(INTERACTABLES_FILE) if not interactables_data: return jsonify({'interactables': []}) interactables = [] for inter_id, inter_def in interactables_data.get('interactables', {}).items(): interactables.append({ 'id': inter_id, 'name': inter_def.get('name', inter_id), 'description': inter_def.get('description', ''), 'image_path': inter_def.get('image_path', ''), 'actions': inter_def.get('actions', {}) }) return jsonify({'interactables': interactables}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/interactable', methods=['POST']) @require_auth def save_interactable(): """Save or update an interactable template""" try: inter_data = request.get_json() inter_id = inter_data.get('id') # Load current interactables interactables_data = load_json_file(INTERACTABLES_FILE) or {'interactables': {}} if 'interactables' not in interactables_data: interactables_data['interactables'] = {} # Update interactable interactables_data['interactables'][inter_id] = { 'id': inter_id, 'name': inter_data.get('name'), 'description': inter_data.get('description', ''), 'image_path': inter_data.get('image_path', ''), 'actions': inter_data.get('actions', {}) } # Save back if save_json_file(INTERACTABLES_FILE, interactables_data): return jsonify({'success': True, 'message': 'Interactable saved successfully'}) else: return jsonify({'error': 'Failed to save interactable'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/interactable/', methods=['DELETE']) @require_auth def delete_interactable(inter_id): """Delete an interactable template""" try: interactables_data = load_json_file(INTERACTABLES_FILE) if not interactables_data or 'interactables' not in interactables_data: return jsonify({'error': 'Interactables file not found'}), 404 if inter_id in interactables_data['interactables']: del interactables_data['interactables'][inter_id] if save_json_file(INTERACTABLES_FILE, interactables_data): return jsonify({'success': True, 'message': 'Interactable deleted successfully'}) else: return jsonify({'error': 'Failed to delete interactable'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/editor/live-stats', methods=['GET']) @require_auth def get_live_stats(): """Get real-time player locations and enemy counts""" # Try to import database module - may not be available in map container sys.path.insert(0, str(PARENT_DIR)) try: from bot import database except ImportError: # Bot module not available in this container # Return empty stats - this is expected in map-only container return jsonify({ 'players_by_location': {}, 'enemies_by_location': {} }) import asyncio from sqlalchemy import text # Run async queries async def get_stats(): players_by_location = {} enemies_by_location = {} # Get all players and their locations try: async with database.engine.connect() as conn: # Get player counts per location result = await conn.execute(text( "SELECT location_id, COUNT(*) as count FROM characters GROUP BY location_id" )) rows = result.fetchall() for row in rows: players_by_location[row[0]] = row[1] # Get wandering enemy counts per location result = await conn.execute(text( "SELECT location_id, COUNT(*) as count FROM wandering_enemies WHERE despawn_timestamp > EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) GROUP BY location_id" )) rows = result.fetchall() for row in rows: enemies_by_location[row[0]] = row[1] except Exception as e: print(f"[LIVE-STATS] Database query error: {e}", flush=True) import traceback traceback.print_exc() return players_by_location, enemies_by_location # Run the async function try: players, enemies = asyncio.run(get_stats()) #print(f"[LIVE-STATS] Success! Players: {players}, Enemies: {enemies}", flush=True) except Exception as e: #print(f"[LIVE-STATS] asyncio.run error: {e}", flush=True) import traceback traceback.print_exc() return jsonify({'error': str(e), 'players_by_location': {}, 'enemies_by_location': {}}), 200 return jsonify({ 'players_by_location': players, 'enemies_by_location': enemies }) # Export to JSON endpoint removed to prevent accidental data loss # The editor now works directly with JSON files @app.route('/api/editor/export-python', methods=['GET']) @require_auth def export_python(): """Export configuration as Python code to update world_loader.py""" try: if not LOCATIONS_FILE.exists(): return jsonify({'error': 'No configuration to export'}), 404 with open(LOCATIONS_FILE, 'r') as f: config = json.load(f) # Generate Python code python_code = generate_world_loader_code(config) return jsonify({ 'success': True, 'python_code': python_code, 'message': 'Python code generated. Review and manually update world_loader.py' }) except Exception as e: return jsonify({'error': str(e)}), 500 def generate_world_loader_code(config): """Generate Python code from config""" code_lines = ["# Generated configuration updates\n", "# REVIEW CAREFULLY before applying!\n\n"] # Generate location updates if 'locations' in config: code_lines.append("# ===== LOCATION UPDATES =====\n") for loc_id, loc_data in config['locations'].items(): code_lines.append(f"\n# Update {loc_id}\n") code_lines.append(f"{loc_id}.name = {repr(loc_data['name'])}\n") code_lines.append(f"{loc_id}.description = {repr(loc_data['description'])}\n") code_lines.append(f"{loc_id}.x = {loc_data['x']}\n") code_lines.append(f"{loc_id}.y = {loc_data['y']}\n") if loc_data.get('image_path'): code_lines.append(f"{loc_id}.image_path = {repr(loc_data['image_path'])}\n") # Generate danger config if 'danger_config' in config: code_lines.append("\n# ===== DANGER CONFIG (for npcs.py) =====\n") code_lines.append("LOCATION_DANGER = {\n") for loc_id, danger_data in config['danger_config'].items(): code_lines.append(f" {repr(loc_id)}: ({danger_data['danger_level']}, {danger_data['encounter_rate']}, {danger_data['wandering_chance']}),\n") code_lines.append("}\n") # Generate spawn config if 'spawn_config' in config: code_lines.append("\n# ===== SPAWN CONFIG (for npcs.py) =====\n") code_lines.append("LOCATION_SPAWNS = {\n") for loc_id, spawns in config['spawn_config'].items(): code_lines.append(f" {repr(loc_id)}: [\n") for spawn in spawns: code_lines.append(f" ({repr(spawn['npc_id'])}, {spawn['weight']}),\n") code_lines.append(" ],\n") code_lines.append("}\n") # Generate connections if 'connections' in config: code_lines.append("\n# ===== CONNECTIONS =====\n") for conn in config['connections']: code_lines.append(f"{conn['from']}.add_exit({repr(conn['direction'])}, {repr(conn['to'])})\n") return ''.join(code_lines) # ==================== ACCOUNT MANAGEMENT ==================== @app.route('/api/editor/accounts', methods=['GET']) @require_auth def get_accounts(): """Get list of all accounts with their characters""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio import time from sqlalchemy import text async def fetch_accounts(): accounts_list = [] try: async with database.engine.connect() as conn: # Get all accounts result = await conn.execute(text(""" SELECT id, email, premium_expires_at, created_at FROM accounts ORDER BY created_at DESC """)) accounts_data = result.fetchall() for acc_row in accounts_data: account = { 'id': acc_row[0], 'email': acc_row[1], 'premium_expires_at': acc_row[2], 'created_at': acc_row[3], 'is_premium': acc_row[2] and acc_row[2] > time.time() if acc_row[2] else False, 'characters': [] } # Get characters for this account char_result = await conn.execute(text(""" SELECT id, name, level, location_id, hp, max_hp FROM characters WHERE account_id = :aid ORDER BY last_played_at DESC """), {'aid': acc_row[0]}) for char_row in char_result.fetchall(): account['characters'].append({ 'id': char_row[0], 'name': char_row[1], 'level': char_row[2], 'location_id': char_row[3], 'hp': char_row[4], 'max_hp': char_row[5] }) account['character_count'] = len(account['characters']) accounts_list.append(account) except Exception as e: print(f"[ACCOUNTS] Error fetching accounts: {e}", flush=True) import traceback traceback.print_exc() return accounts_list accounts = asyncio.run(fetch_accounts()) return jsonify({'accounts': accounts}) @app.route('/api/editor/account/', methods=['GET']) @require_auth def get_account_details(account_id): """Get detailed account information with all characters""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text async def fetch_account_details(): try: async with database.engine.connect() as conn: # Get account info result = await conn.execute(text(""" SELECT id, email, premium_expires_at, created_at, last_login_at FROM accounts WHERE id = :aid """), {'aid': account_id}) row = result.fetchone() if not row: return None account = { 'id': row[0], 'email': row[1], 'premium_expires_at': row[2], 'created_at': row[3], 'last_login_at': row[4], 'characters': [] } # Get all characters with full details char_result = await conn.execute(text(""" SELECT id, name, level, xp, hp, max_hp, stamina, max_stamina, strength, agility, endurance, intellect, location_id, is_dead, created_at, last_played_at FROM characters WHERE account_id = :aid ORDER BY last_played_at DESC """), {'aid': account_id}) for char_row in char_result.fetchall(): account['characters'].append({ 'id': char_row[0], 'name': char_row[1], 'level': char_row[2], 'xp': char_row[3], 'hp': char_row[4], 'max_hp': char_row[5], 'stamina': char_row[6], 'max_stamina': char_row[7], 'strength': char_row[8], 'agility': char_row[9], 'endurance': char_row[10], 'intellect': char_row[11], 'location_id': char_row[12], 'is_dead': char_row[13], 'created_at': char_row[14], 'last_played_at': char_row[15] }) return account except Exception as e: print(f"[ACCOUNT-DETAILS] Error: {e}", flush=True) import traceback traceback.print_exc() return None account = asyncio.run(fetch_account_details()) if not account: return jsonify({'error': 'Account not found'}), 404 return jsonify(account) @app.route('/api/editor/account/', methods=['POST']) @require_auth def update_account_details(account_id): """Update account information""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text data = request.get_json() async def update_account_data(): try: async with database.engine.begin() as conn: await conn.execute(text(""" UPDATE accounts SET email = :email, premium_expires_at = :premium_exp WHERE id = :aid """), { 'aid': account_id, 'email': data.get('email'), 'premium_exp': data.get('premium_expires_at') }) return True except Exception as e: print(f"[UPDATE-ACCOUNT] Error: {e}", flush=True) import traceback traceback.print_exc() return False success = asyncio.run(update_account_data()) if success: return jsonify({'success': True, 'message': 'Account updated successfully'}) else: return jsonify({'error': 'Failed to update account'}), 500 # ==================== PLAYER MANAGEMENT ==================== @app.route('/api/editor/players', methods=['GET']) @require_auth def get_players(): """Get list of all characters with their accounts""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio import time from sqlalchemy import text async def fetch_players(): players_list = [] try: async with database.engine.connect() as conn: # Get all characters with account info result = await conn.execute(text(""" SELECT c.id, c.account_id, c.name, c.location_id, c.hp, c.max_hp, c.stamina, c.max_stamina, c.level, c.xp, c.strength, c.agility, c.endurance, c.intellect, c.unspent_points, a.email, a.premium_expires_at, a.created_at, c.created_at as character_created_at FROM characters c LEFT JOIN accounts a ON c.account_id = a.id ORDER BY c.name """)) rows = result.fetchall() for row in rows: players_list.append({ 'id': row[0], 'account_id': row[1], 'character_name': row[2], 'location_id': row[3], 'hp': row[4], 'max_hp': row[5], 'stamina': row[6], 'max_stamina': row[7], 'level': row[8], 'xp': row[9], 'strength': row[10], 'agility': row[11], 'endurance': row[12], 'intellect': row[13], 'unspent_points': row[14], 'email': row[15], 'premium_expires_at': row[16], 'account_created_at': row[17], 'character_created_at': row[18], 'is_premium': row[16] and row[16] > time.time() if row[16] else False }) except Exception as e: print(f"[PLAYERS] Error fetching players: {e}", flush=True) import traceback traceback.print_exc() return players_list players = asyncio.run(fetch_players()) return jsonify({'players': players}) @app.route('/api/editor/player/', methods=['GET']) @require_auth def get_player_details(character_id): """Get detailed character information including inventory""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text async def fetch_player_details(): player_data = {} try: async with database.engine.connect() as conn: # Get character basic info with account result = await conn.execute(text(""" SELECT c.*, a.email, a.premium_expires_at, a.created_at as account_created_at FROM characters c LEFT JOIN accounts a ON c.account_id = a.id WHERE c.id = :cid """), {'cid': character_id}) row = result.fetchone() if not row: return None player_data = dict(row._mapping) # Get inventory result = await conn.execute(text(""" SELECT i.item_id, i.quantity, i.is_equipped, i.unique_item_id, u.durability, u.max_durability, u.tier, u.unique_stats FROM inventory i LEFT JOIN unique_items u ON i.unique_item_id = u.id WHERE i.character_id = :cid """), {'cid': character_id}) inventory = [] equipped = {} for row in result.fetchall(): item_data = { 'item_id': row[0], 'quantity': row[1] } if row[3]: # Has unique_item_id item_data['unique_item_data'] = { 'durability': row[4], 'max_durability': row[5], 'tier': row[6], 'unique_stats': row[7] } if row[2]: # is_equipped equipped[row[0]] = item_data else: inventory.append(item_data) player_data['inventory'] = inventory player_data['equipped'] = equipped except Exception as e: print(f"[PLAYER-DETAILS] Error: {e}", flush=True) import traceback traceback.print_exc() return None return player_data player = asyncio.run(fetch_player_details()) if not player: return jsonify({'error': 'Player not found'}), 404 # Timestamps are already Unix floats, no conversion needed return jsonify(player) @app.route('/api/editor/player/', methods=['POST']) @require_auth def update_player(character_id): """Update character stats and properties""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text data = request.get_json() async def update_player_data(): try: async with database.engine.begin() as conn: # Update character stats await conn.execute(text(""" UPDATE characters SET name = :name, location_id = :location, hp = :hp, max_hp = :max_hp, stamina = :stamina, max_stamina = :max_stamina, level = :level, xp = :xp, strength = :str, agility = :agi, endurance = :end, intellect = :int, unspent_points = :unspent WHERE id = :cid """), { 'cid': character_id, 'name': data.get('character_name'), 'location': data.get('location_id'), 'hp': data.get('hp'), 'max_hp': data.get('max_hp'), 'stamina': data.get('stamina'), 'max_stamina': data.get('max_stamina'), 'level': data.get('level'), 'xp': data.get('xp'), 'str': data.get('strength'), 'agi': data.get('agility'), 'end': data.get('endurance'), 'int': data.get('intellect'), 'unspent': data.get('unspent_points', 0) }) return True except Exception as e: print(f"[UPDATE-PLAYER] Error: {e}", flush=True) import traceback traceback.print_exc() return False success = asyncio.run(update_player_data()) if success: return jsonify({'success': True, 'message': 'Player updated successfully'}) else: return jsonify({'error': 'Failed to update player'}), 500 @app.route('/api/editor/player//inventory', methods=['POST']) @require_auth def update_player_inventory(character_id): """Update player inventory""" sys.path.insert(0, str(PARENT_DIR)) try: from bot import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text data = request.get_json() inventory_items = data.get('inventory', []) async def update_inventory(): try: async with database.engine.begin() as conn: # Clear existing inventory await conn.execute(text(""" DELETE FROM inventory WHERE character_id = :cid """), {'cid': character_id}) # Insert new inventory (simplified - full unique item support would need more work) for item in inventory_items: await conn.execute(text(""" INSERT INTO inventory (character_id, item_id, quantity, is_equipped) VALUES (:cid, :item_id, :qty, :equipped) """), { 'cid': character_id, 'item_id': item['item_id'], 'qty': item['quantity'], 'equipped': item.get('is_equipped', False) }) return True except Exception as e: print(f"[UPDATE-INVENTORY] Error: {e}", flush=True) import traceback traceback.print_exc() return False success = asyncio.run(update_inventory()) if success: return jsonify({'success': True, 'message': 'Inventory updated successfully'}) else: return jsonify({'error': 'Failed to update inventory'}), 500 @app.route('/api/editor/player//equipment', methods=['POST']) @require_auth def update_player_equipment(character_id): """Update player equipped items""" sys.path.insert(0, str(PARENT_DIR)) try: from bot import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text data = request.get_json() equipped_items = data.get('equipped', {}) async def update_equipment(): try: async with database.engine.begin() as conn: # Clear existing equipment await conn.execute(text(""" DELETE FROM equipped_items WHERE telegram_id = :tid """), {'tid': telegram_id}) # Insert new equipment for slot, item_data in equipped_items.items(): if item_data and item_data.get('item_id'): unique_data = json.dumps(item_data['unique_item_data']) if item_data.get('unique_item_data') else None await conn.execute(text(""" INSERT INTO equipped_items (telegram_id, slot, item_id, unique_item_data) VALUES (:tid, :slot, :item_id, :unique_data) """), { 'tid': telegram_id, 'slot': slot, 'item_id': item_data['item_id'], 'unique_data': unique_data }) return True except Exception as e: print(f"[UPDATE-EQUIPMENT] Error: {e}", flush=True) import traceback traceback.print_exc() return False success = asyncio.run(update_equipment()) if success: return jsonify({'success': True, 'message': 'Equipment updated successfully'}) else: return jsonify({'error': 'Failed to update equipment'}), 500 @app.route('/api/editor/account//ban', methods=['POST']) @require_auth def ban_account(telegram_id): """Ban or unban an account""" sys.path.insert(0, str(PARENT_DIR)) try: from bot import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text data = request.get_json() is_banned = data.get('is_banned', True) ban_reason = data.get('ban_reason', '') async def update_ban_status(): try: async with database.engine.begin() as conn: await conn.execute(text(""" UPDATE accounts SET is_banned = :banned, ban_reason = :reason WHERE telegram_id = :tid """), { 'tid': telegram_id, 'banned': is_banned, 'reason': ban_reason if is_banned else None }) return True except Exception as e: print(f"[BAN-ACCOUNT] Error: {e}", flush=True) return False success = asyncio.run(update_ban_status()) if success: action = 'banned' if is_banned else 'unbanned' return jsonify({'success': True, 'message': f'Account {action} successfully'}) else: return jsonify({'error': 'Failed to update ban status'}), 500 @app.route('/api/editor/account//delete', methods=['DELETE']) @require_auth def delete_account(account_id): """Delete an account and all associated characters""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text async def delete_account_data(): try: async with database.engine.begin() as conn: # CASCADE will handle characters and their inventory await conn.execute(text("DELETE FROM accounts WHERE id = :aid"), {'aid': account_id}) return True except Exception as e: print(f"[DELETE-ACCOUNT] Error: {e}", flush=True) import traceback traceback.print_exc() return False success = asyncio.run(delete_account_data()) if success: return jsonify({'success': True, 'message': 'Account deleted successfully'}) else: return jsonify({'error': 'Failed to delete account'}), 500 @app.route('/api/editor/player//reset', methods=['POST']) @require_auth def reset_player(character_id): """Reset character to starting state""" sys.path.insert(0, str(PARENT_DIR)) try: from api import database except ImportError: return jsonify({'error': 'Database module not available'}), 500 import asyncio from sqlalchemy import text async def reset_player_data(): try: async with database.engine.begin() as conn: # Clear inventory await conn.execute(text("DELETE FROM inventory WHERE character_id = :cid"), {'cid': character_id}) # Reset character stats to defaults await conn.execute(text(""" UPDATE characters SET location_id = 'cabin', hp = 100, max_hp = 100, stamina = 100, max_stamina = 100, level = 1, xp = 0, strength = 0, agility = 0, endurance = 0, intellect = 0, unspent_points = 20, is_dead = false WHERE id = :cid """), {'cid': character_id}) return True except Exception as e: print(f"[RESET-PLAYER] Error: {e}", flush=True) import traceback traceback.print_exc() return False success = asyncio.run(reset_player_data()) if success: return jsonify({'success': True, 'message': 'Player reset successfully'}) else: return jsonify({'error': 'Failed to reset player'}), 500 if __name__ == '__main__': port = int(os.environ.get('PORT', 8080)) print(f""" ╔════════════════════════════════════════════╗ β•‘ Echoes of the Ashes - Map Server β•‘ β•‘ WITH EDITOR β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• πŸ—ΊοΈ Map Viewer: http://localhost:{port} ✏️ Map Editor: http://localhost:{port}/editor πŸ” Password: {ADMIN_PASSWORD} ⚠️ IMPORTANT: Set EDITOR_PASSWORD environment variable in production! """) app.run(host='0.0.0.0', port=port, debug=False)