import asyncio import logging from contextlib import asynccontextmanager from collections.abc import AsyncGenerator from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.database import async_session_factory, engine, Base from app.services.artifacts_client import ArtifactsClient from app.services.character_service import CharacterService from app.services.game_data_cache import GameDataCacheService # Import models so they are registered on Base.metadata from app.models import game_cache as _game_cache_model # noqa: F401 from app.models import character_snapshot as _snapshot_model # noqa: F401 from app.models import automation as _automation_model # noqa: F401 from app.models import price_history as _price_history_model # noqa: F401 from app.models import event_log as _event_log_model # noqa: F401 # Import routers from app.api.characters import router as characters_router from app.api.game_data import router as game_data_router from app.api.dashboard import router as dashboard_router from app.api.bank import router as bank_router from app.api.automations import router as automations_router from app.api.ws import router as ws_router from app.api.exchange import router as exchange_router from app.api.events import router as events_router from app.api.logs import router as logs_router # Automation engine from app.engine.pathfinder import Pathfinder from app.engine.manager import AutomationManager # Exchange service from app.services.exchange_service import ExchangeService # WebSocket system from app.websocket.event_bus import EventBus from app.websocket.client import GameWebSocketClient from app.websocket.handlers import GameEventHandler logger = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) async def _snapshot_loop( db_factory: async_session_factory.__class__, client: ArtifactsClient, character_service: CharacterService, interval: float = 60.0, ) -> None: """Periodically save character snapshots.""" while True: try: async with db_factory() as db: await character_service.take_snapshot(db, client) except asyncio.CancelledError: logger.info("Character snapshot loop cancelled") return except Exception: logger.exception("Error taking character snapshot") await asyncio.sleep(interval) async def _load_pathfinder_maps( pathfinder: Pathfinder, cache_service: GameDataCacheService, ) -> None: """Load map data from the game data cache into the pathfinder. Retries with a short delay if the cache has not been populated yet (e.g. the background refresh has not completed its first pass). """ max_attempts = 10 for attempt in range(1, max_attempts + 1): try: async with async_session_factory() as db: maps = await cache_service.get_maps(db) if maps: pathfinder.load_maps(maps) logger.info("Pathfinder loaded %d map tiles", len(maps)) return logger.info( "Map cache empty, retrying (%d/%d)", attempt, max_attempts, ) except Exception: logger.exception( "Error loading maps into pathfinder (attempt %d/%d)", attempt, max_attempts, ) await asyncio.sleep(5) logger.warning( "Pathfinder could not load maps after %d attempts; " "automations that depend on pathfinding will not work until maps are cached", max_attempts, ) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # --- Startup --- # Create tables if they do not exist (useful for dev; in prod rely on Alembic) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Instantiate shared services client = ArtifactsClient() cache_service = GameDataCacheService() character_service = CharacterService() # Event bus for internal pub/sub event_bus = EventBus() exchange_service = ExchangeService() app.state.artifacts_client = client app.state.cache_service = cache_service app.state.character_service = character_service app.state.event_bus = event_bus app.state.exchange_service = exchange_service # Start background cache refresh (runs immediately, then every 30 min) cache_task = cache_service.start_background_refresh( db_factory=async_session_factory, client=client, ) # Start periodic character snapshot (every 60 seconds) snapshot_task = asyncio.create_task( _snapshot_loop(async_session_factory, client, character_service) ) # --- Automation engine --- # Initialize pathfinder and load maps (runs in a background task so it # does not block startup if the cache has not been populated yet) pathfinder = Pathfinder() pathfinder_task = asyncio.create_task( _load_pathfinder_maps(pathfinder, cache_service) ) # Create the automation manager and expose it on app.state automation_manager = AutomationManager( client=client, db_factory=async_session_factory, pathfinder=pathfinder, event_bus=event_bus, ) app.state.automation_manager = automation_manager # --- Price capture background task --- price_capture_task = exchange_service.start_price_capture( db_factory=async_session_factory, client=client, ) # --- WebSocket system --- # Game WebSocket client (connects to the Artifacts game server) game_ws_client = GameWebSocketClient( token=settings.artifacts_token, event_bus=event_bus, ) game_ws_task = await game_ws_client.start() app.state.game_ws_client = game_ws_client # Event handler (processes game events from the bus) game_event_handler = GameEventHandler(event_bus=event_bus) event_handler_task = await game_event_handler.start() logger.info("Artifacts Dashboard API started") yield # --- Shutdown --- logger.info("Shutting down background tasks") # Stop all running automations gracefully await automation_manager.stop_all() # Stop WebSocket system await game_event_handler.stop() await game_ws_client.stop() cache_service.stop_background_refresh() exchange_service.stop_price_capture() snapshot_task.cancel() pathfinder_task.cancel() # Wait for tasks to finish cleanly for task in (cache_task, snapshot_task, pathfinder_task, price_capture_task, game_ws_task, event_handler_task): try: await task except asyncio.CancelledError: pass await client.close() await engine.dispose() logger.info("Artifacts Dashboard API stopped") app = FastAPI( title="Artifacts Dashboard API", version="0.1.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Register routers app.include_router(characters_router) app.include_router(game_data_router) app.include_router(dashboard_router) app.include_router(bank_router) app.include_router(automations_router) app.include_router(ws_router) app.include_router(exchange_router) app.include_router(events_router) app.include_router(logs_router) @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok"}