""" Echoes of the Ashes - Main FastAPI Application Streamlined and modular architecture for easy maintenance """ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPAuthorizationCredentials from contextlib import asynccontextmanager from pathlib import Path from datetime import datetime import logging # Import core modules from .core.config import CORS_ORIGINS, IMAGES_DIR, API_INTERNAL_KEY from .core.websockets import manager from .core.security import get_current_user, decode_token, security, verify_internal_key # Import database and game data from . import database as db from .world_loader import load_world, World, Location from .items import ItemsManager from . import background_tasks from .redis_manager import redis_manager # Import all routers from .routers import ( auth, characters, game_routes, combat, equipment, crafting, loot, statistics, admin ) # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Load game data print("🔄 Loading game world...") WORLD: World = load_world() LOCATIONS = WORLD.locations ITEMS_MANAGER = ItemsManager() print(f"✅ Game world ready: {len(LOCATIONS)} locations, {len(ITEMS_MANAGER.items)} items") @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager for startup/shutdown""" # Startup await db.init_db() print("✅ Database initialized") # Connect to Redis await redis_manager.connect() print("✅ Redis connected") # Inject Redis manager into ConnectionManager manager.set_redis_manager(redis_manager) # Subscribe to all location channels + global broadcast location_channels = [f"location:{loc_id}" for loc_id in LOCATIONS.keys()] await redis_manager.subscribe_to_channels(location_channels + ['game:broadcast']) print(f"✅ Subscribed to {len(location_channels)} location channels") # Register this worker await redis_manager.register_worker() print(f"✅ Worker registered: {redis_manager.worker_id}") # Start Redis message listener (background task) redis_manager.start_listener(manager.handle_redis_message) print("✅ Redis listener started") # Start background tasks (distributed via Redis locks) tasks = await background_tasks.start_background_tasks(manager, LOCATIONS) if tasks: print(f"✅ Started {len(tasks)} background tasks in this worker") else: print("⏭️ Background tasks running in another worker") yield # Shutdown await background_tasks.stop_background_tasks(tasks) # Unregister worker await redis_manager.unregister_worker() print(f"🔌 Worker unregistered: {redis_manager.worker_id}") # Disconnect from Redis await redis_manager.disconnect() print("✅ Redis disconnected") # Initialize FastAPI app app = FastAPI( title="Echoes of the Ashes API", version="2.0.0", description="Post-apocalyptic survival RPG - Modular Architecture", lifespan=lifespan ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Mount static files for images if IMAGES_DIR.exists(): app.mount("/images", StaticFiles(directory=str(IMAGES_DIR)), name="images") print(f"✅ Mounted images directory: {IMAGES_DIR}") else: print(f"⚠️ Images directory not found: {IMAGES_DIR}") # Initialize routers with game data dependencies game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager) combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager) equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR) # Include all routers app.include_router(auth.router) app.include_router(characters.router) app.include_router(game_routes.router) app.include_router(combat.router) app.include_router(equipment.router) app.include_router(crafting.router) app.include_router(loot.router) app.include_router(statistics.router) app.include_router(admin.router) print("✅ All routers registered") @app.get("/health") async def health_check(): """Health check endpoint for load balancers""" return {"status": "ok", "version": "2.0.0"} @app.websocket("/ws/game/{token}") async def websocket_endpoint(websocket: WebSocket, token: str): """ WebSocket endpoint for real-time game updates. Clients connect with their JWT token in the path. """ try: # Decode and validate token payload = decode_token(token) character_id = payload.get("character_id") if not character_id: await websocket.close(code=1008, reason="No character selected") return # Get character data character = await db.get_player_by_id(character_id) if not character: await websocket.close(code=1008, reason="Character not found") return player_id = character['id'] username = character['name'] location_id = character['location_id'] # Connect WebSocket await manager.connect(websocket, player_id, username) # Register in Redis if redis_manager: await redis_manager.set_player_session(player_id, { 'username': username, 'location_id': location_id, 'hp': character.get('hp'), 'max_hp': character.get('max_hp'), 'stamina': character.get('stamina'), 'max_stamina': character.get('max_stamina'), 'level': character.get('level', 1), 'xp': character.get('xp', 0), 'websocket_connected': 'true' }) # Add player to location registry await redis_manager.add_player_to_location(player_id, location_id) # Increment connected player count await redis_manager.increment_connected_player(player_id) # Broadcast new player count count = await redis_manager.get_connected_player_count() await redis_manager.publish_global_broadcast({ "type": "player_count_update", "data": { "count": count } }) logger.info(f"WebSocket connected: {username} (ID: {player_id})") # Keep connection alive while True: try: data_text = await websocket.receive_text() try: data_json = json.loads(data_text) if data_json.get("type") == "ack": logger.debug(f"ACK received from {username} for msg {data_json.get('reply_to')}") else: logger.debug(f"Received from {username}: {data_text}") except: logger.debug(f"Received from {username}: {data_text}") except WebSocketDisconnect: break except Exception as e: logger.error(f"WebSocket error for {username}: {e}") break except HTTPException as e: await websocket.close(code=1008, reason=e.detail) return except Exception as e: logger.error(f"WebSocket connection error: {e}") await websocket.close(code=1011, reason="Internal error") return finally: # Cleanup on disconnect try: await manager.disconnect(player_id, websocket) if location_id and redis_manager: await redis_manager.remove_player_from_location(player_id, location_id) # Decrement connected player count await redis_manager.decrement_connected_player(player_id) # Broadcast new player count count = await redis_manager.get_connected_player_count() await redis_manager.publish_global_broadcast({ "type": "player_count_update", "data": { "count": count } }) logger.info(f"WebSocket disconnected: {username}") except: pass print("\n" + "="*60) print("✅ Echoes of the Ashes API - Ready") print(f"📊 Total Routers: 9 (auth, characters, game, combat, equipment, crafting, loot, statistics, admin)") print(f"🌍 Locations: {len(LOCATIONS)}") print(f"📦 Items: {len(ITEMS_MANAGER.items)}") print("="*60 + "\n")