artifacts-dashboard/backend/app/main.py
Paweł Orzech f845647934
Some checks failed
Release / release (push) Has been cancelled
Initial release: Artifacts MMO Dashboard & Automation Platform
Full-stack dashboard for controlling, automating, and analyzing
Artifacts MMO characters via the game's HTTP API.

Backend (FastAPI):
- Async Artifacts API client with rate limiting and retry
- 6 automation strategies (combat, gathering, crafting, trading, task, leveling)
- Automation engine with runner, manager, cooldown tracker, pathfinder
- WebSocket relay (game server -> frontend)
- Game data cache, character snapshots, price history, analytics
- 9 API routers, 7 database tables, 3 Alembic migrations
- 108 unit tests

Frontend (Next.js 15 + shadcn/ui):
- Live character dashboard with HP/XP bars and cooldowns
- Character detail with stats, equipment, inventory, skills, manual actions
- Automation management with live log streaming
- Interactive canvas map with content-type coloring and zoom/pan
- Bank management, Grand Exchange with price charts
- Events, logs, analytics pages with Recharts
- WebSocket auto-reconnect with query cache invalidation
- Settings page, error boundaries, dark theme

Infrastructure:
- Docker Compose (dev + prod)
- GitHub Actions CI/CD
- Documentation (Architecture, Automation, Deployment, API)
2026-03-01 19:46:45 +01:00

242 lines
7.5 KiB
Python

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"}