artifacts-dashboard/backend/app/websocket/client.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

124 lines
4.4 KiB
Python

"""Persistent WebSocket client for the Artifacts MMO game server.
Maintains a long-lived connection to ``wss://realtime.artifactsmmo.com``
and dispatches every incoming game event to the :class:`EventBus` so that
other components (event handlers, the frontend relay) can react in real
time.
Reconnection is handled automatically with exponential back-off.
"""
from __future__ import annotations
import asyncio
import json
import logging
import websockets
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
class GameWebSocketClient:
"""Persistent WebSocket connection to the Artifacts game server."""
WS_URL = "wss://realtime.artifactsmmo.com"
def __init__(self, token: str, event_bus: EventBus) -> None:
self._token = token
self._event_bus = event_bus
self._ws: websockets.WebSocketClientProtocol | None = None
self._task: asyncio.Task | None = None
self._reconnect_delay = 1.0
self._max_reconnect_delay = 60.0
self._running = False
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> asyncio.Task:
"""Start the persistent WebSocket connection in a background task."""
self._running = True
self._task = asyncio.create_task(
self._connection_loop(),
name="game-ws-client",
)
logger.info("Game WebSocket client starting")
return self._task
async def stop(self) -> None:
"""Gracefully shut down the WebSocket connection."""
self._running = False
if self._ws is not None:
try:
await self._ws.close()
except Exception:
pass
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Game WebSocket client stopped")
# ------------------------------------------------------------------
# Connection loop
# ------------------------------------------------------------------
async def _connection_loop(self) -> None:
"""Reconnect loop with exponential back-off."""
while self._running:
try:
async with websockets.connect(
self.WS_URL,
additional_headers={"Authorization": f"Bearer {self._token}"},
) as ws:
self._ws = ws
self._reconnect_delay = 1.0
logger.info("Game WebSocket connected")
await self._event_bus.publish("ws_status", {"connected": True})
async for message in ws:
try:
data = json.loads(message)
await self._handle_message(data)
except json.JSONDecodeError:
logger.warning(
"Invalid JSON from game WS: %s", message[:100]
)
except asyncio.CancelledError:
raise
except websockets.ConnectionClosed:
logger.warning("Game WebSocket disconnected")
except Exception:
logger.exception("Game WebSocket error")
self._ws = None
if self._running:
await self._event_bus.publish("ws_status", {"connected": False})
logger.info("Reconnecting in %.1fs", self._reconnect_delay)
await asyncio.sleep(self._reconnect_delay)
self._reconnect_delay = min(
self._reconnect_delay * 2,
self._max_reconnect_delay,
)
# ------------------------------------------------------------------
# Message dispatch
# ------------------------------------------------------------------
async def _handle_message(self, data: dict) -> None:
"""Dispatch a game event to the event bus.
Game events are published under the key ``game_{type}`` where
*type* is the value of the ``"type"`` field in the incoming
message (defaults to ``"unknown"`` if absent).
"""
event_type = data.get("type", "unknown")
await self._event_bus.publish(f"game_{event_type}", data)