Some checks failed
Release / release (push) Has been cancelled
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)
242 lines
7.5 KiB
Python
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"}
|