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)
465 lines
17 KiB
Python
465 lines
17 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
from collections import deque
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
from app.schemas.game import (
|
|
CharacterSchema,
|
|
ItemSchema,
|
|
MapSchema,
|
|
MonsterSchema,
|
|
ResourceSchema,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RateLimiter:
|
|
"""Token-bucket style rate limiter using a sliding window of timestamps."""
|
|
|
|
def __init__(self, max_requests: int, window_seconds: float) -> None:
|
|
self._max_requests = max_requests
|
|
self._window = window_seconds
|
|
self._timestamps: deque[float] = deque()
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def acquire(self) -> None:
|
|
async with self._lock:
|
|
now = time.monotonic()
|
|
|
|
# Evict timestamps outside the current window
|
|
while self._timestamps and self._timestamps[0] <= now - self._window:
|
|
self._timestamps.popleft()
|
|
|
|
if len(self._timestamps) >= self._max_requests:
|
|
# Wait until the oldest timestamp exits the window
|
|
sleep_duration = self._window - (now - self._timestamps[0])
|
|
if sleep_duration > 0:
|
|
await asyncio.sleep(sleep_duration)
|
|
|
|
# Re-evict after sleeping
|
|
now = time.monotonic()
|
|
while self._timestamps and self._timestamps[0] <= now - self._window:
|
|
self._timestamps.popleft()
|
|
|
|
self._timestamps.append(time.monotonic())
|
|
|
|
|
|
class ArtifactsClient:
|
|
"""Async HTTP client for the Artifacts MMO API.
|
|
|
|
Handles authentication, rate limiting, pagination, and retry logic.
|
|
"""
|
|
|
|
MAX_RETRIES: int = 3
|
|
RETRY_BASE_DELAY: float = 1.0
|
|
|
|
def __init__(self) -> None:
|
|
self._client = httpx.AsyncClient(
|
|
base_url=settings.artifacts_api_url,
|
|
headers={
|
|
"Authorization": f"Bearer {settings.artifacts_token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
},
|
|
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
)
|
|
self._action_limiter = RateLimiter(
|
|
max_requests=settings.action_rate_limit,
|
|
window_seconds=settings.action_rate_window,
|
|
)
|
|
self._data_limiter = RateLimiter(
|
|
max_requests=settings.data_rate_limit,
|
|
window_seconds=settings.data_rate_window,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Low-level request helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
limiter: RateLimiter,
|
|
json_body: dict[str, Any] | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
last_exc: Exception | None = None
|
|
|
|
for attempt in range(1, self.MAX_RETRIES + 1):
|
|
await limiter.acquire()
|
|
try:
|
|
response = await self._client.request(
|
|
method,
|
|
path,
|
|
json=json_body,
|
|
params=params,
|
|
)
|
|
|
|
if response.status_code == 429:
|
|
retry_after = float(response.headers.get("Retry-After", "2"))
|
|
logger.warning(
|
|
"Rate limited on %s %s, retrying after %.1fs",
|
|
method,
|
|
path,
|
|
retry_after,
|
|
)
|
|
await asyncio.sleep(retry_after)
|
|
continue
|
|
|
|
if response.status_code >= 500:
|
|
logger.warning(
|
|
"Server error %d on %s %s (attempt %d/%d)",
|
|
response.status_code,
|
|
method,
|
|
path,
|
|
attempt,
|
|
self.MAX_RETRIES,
|
|
)
|
|
if attempt < self.MAX_RETRIES:
|
|
delay = self.RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
response.raise_for_status()
|
|
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
except httpx.HTTPStatusError:
|
|
raise
|
|
except (httpx.TransportError, httpx.TimeoutException) as exc:
|
|
last_exc = exc
|
|
logger.warning(
|
|
"Network error on %s %s (attempt %d/%d): %s",
|
|
method,
|
|
path,
|
|
attempt,
|
|
self.MAX_RETRIES,
|
|
exc,
|
|
)
|
|
if attempt < self.MAX_RETRIES:
|
|
delay = self.RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
|
|
raise last_exc or RuntimeError(f"Request failed after {self.MAX_RETRIES} retries")
|
|
|
|
async def _get(
|
|
self,
|
|
path: str,
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
return await self._request(
|
|
"GET",
|
|
path,
|
|
limiter=self._data_limiter,
|
|
params=params,
|
|
)
|
|
|
|
async def _post_action(
|
|
self,
|
|
path: str,
|
|
*,
|
|
json_body: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
return await self._request(
|
|
"POST",
|
|
path,
|
|
limiter=self._action_limiter,
|
|
json_body=json_body,
|
|
)
|
|
|
|
async def _get_paginated(
|
|
self,
|
|
path: str,
|
|
page_size: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
"""Fetch all pages from a paginated endpoint."""
|
|
all_items: list[dict[str, Any]] = []
|
|
page = 1
|
|
|
|
while True:
|
|
result = await self._get(path, params={"page": page, "size": page_size})
|
|
data = result.get("data", [])
|
|
all_items.extend(data)
|
|
|
|
total_pages = result.get("pages", 1)
|
|
if page >= total_pages:
|
|
break
|
|
page += 1
|
|
|
|
return all_items
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data endpoints - Characters
|
|
# ------------------------------------------------------------------
|
|
|
|
async def get_characters(self) -> list[CharacterSchema]:
|
|
result = await self._get("/my/characters")
|
|
data = result.get("data", [])
|
|
return [CharacterSchema.model_validate(c) for c in data]
|
|
|
|
async def get_character(self, name: str) -> CharacterSchema:
|
|
result = await self._get(f"/characters/{name}")
|
|
return CharacterSchema.model_validate(result["data"])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data endpoints - Items
|
|
# ------------------------------------------------------------------
|
|
|
|
async def get_items(self, page: int = 1, size: int = 100) -> list[ItemSchema]:
|
|
result = await self._get("/items", params={"page": page, "size": size})
|
|
return [ItemSchema.model_validate(i) for i in result.get("data", [])]
|
|
|
|
async def get_all_items(self) -> list[ItemSchema]:
|
|
raw = await self._get_paginated("/items")
|
|
return [ItemSchema.model_validate(i) for i in raw]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data endpoints - Monsters
|
|
# ------------------------------------------------------------------
|
|
|
|
async def get_monsters(self, page: int = 1, size: int = 100) -> list[MonsterSchema]:
|
|
result = await self._get("/monsters", params={"page": page, "size": size})
|
|
return [MonsterSchema.model_validate(m) for m in result.get("data", [])]
|
|
|
|
async def get_all_monsters(self) -> list[MonsterSchema]:
|
|
raw = await self._get_paginated("/monsters")
|
|
return [MonsterSchema.model_validate(m) for m in raw]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data endpoints - Resources
|
|
# ------------------------------------------------------------------
|
|
|
|
async def get_resources(self, page: int = 1, size: int = 100) -> list[ResourceSchema]:
|
|
result = await self._get("/resources", params={"page": page, "size": size})
|
|
return [ResourceSchema.model_validate(r) for r in result.get("data", [])]
|
|
|
|
async def get_all_resources(self) -> list[ResourceSchema]:
|
|
raw = await self._get_paginated("/resources")
|
|
return [ResourceSchema.model_validate(r) for r in raw]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data endpoints - Maps
|
|
# ------------------------------------------------------------------
|
|
|
|
async def get_maps(
|
|
self,
|
|
page: int = 1,
|
|
size: int = 100,
|
|
content_type: str | None = None,
|
|
content_code: str | None = None,
|
|
) -> list[MapSchema]:
|
|
params: dict[str, Any] = {"page": page, "size": size}
|
|
if content_type is not None:
|
|
params["content_type"] = content_type
|
|
if content_code is not None:
|
|
params["content_code"] = content_code
|
|
result = await self._get("/maps", params=params)
|
|
return [MapSchema.model_validate(m) for m in result.get("data", [])]
|
|
|
|
async def get_all_maps(self) -> list[MapSchema]:
|
|
raw = await self._get_paginated("/maps")
|
|
return [MapSchema.model_validate(m) for m in raw]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data endpoints - Events, Bank, GE
|
|
# ------------------------------------------------------------------
|
|
|
|
async def get_events(self) -> list[dict[str, Any]]:
|
|
result = await self._get("/events")
|
|
return result.get("data", [])
|
|
|
|
async def get_bank_items(self, page: int = 1, size: int = 100) -> list[dict[str, Any]]:
|
|
result = await self._get("/my/bank/items", params={"page": page, "size": size})
|
|
return result.get("data", [])
|
|
|
|
async def get_all_bank_items(self) -> list[dict[str, Any]]:
|
|
return await self._get_paginated("/my/bank/items")
|
|
|
|
async def get_bank_details(self) -> dict[str, Any]:
|
|
result = await self._get("/my/bank")
|
|
return result.get("data", {})
|
|
|
|
async def get_ge_orders(self) -> list[dict[str, Any]]:
|
|
result = await self._get("/my/grandexchange/orders")
|
|
return result.get("data", [])
|
|
|
|
async def get_ge_history(self) -> list[dict[str, Any]]:
|
|
result = await self._get("/my/grandexchange/history")
|
|
return result.get("data", [])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Action endpoints
|
|
# ------------------------------------------------------------------
|
|
|
|
async def move(self, name: str, x: int, y: int) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/move",
|
|
json_body={"x": x, "y": y},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def fight(self, name: str) -> dict[str, Any]:
|
|
result = await self._post_action(f"/my/{name}/action/fight")
|
|
return result.get("data", {})
|
|
|
|
async def gather(self, name: str) -> dict[str, Any]:
|
|
result = await self._post_action(f"/my/{name}/action/gathering")
|
|
return result.get("data", {})
|
|
|
|
async def rest(self, name: str) -> dict[str, Any]:
|
|
result = await self._post_action(f"/my/{name}/action/rest")
|
|
return result.get("data", {})
|
|
|
|
async def equip(
|
|
self, name: str, code: str, slot: str, quantity: int = 1
|
|
) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/equip",
|
|
json_body={"code": code, "slot": slot, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def unequip(self, name: str, slot: str, quantity: int = 1) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/unequip",
|
|
json_body={"slot": slot, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def use_item(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/use",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def deposit_item(self, name: str, code: str, quantity: int) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/bank/deposit",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def withdraw_item(self, name: str, code: str, quantity: int) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/bank/withdraw",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def deposit_gold(self, name: str, quantity: int) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/bank/deposit/gold",
|
|
json_body={"quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def withdraw_gold(self, name: str, quantity: int) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/bank/withdraw/gold",
|
|
json_body={"quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def craft(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/crafting",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def recycle(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/recycling",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def ge_buy(
|
|
self, name: str, code: str, quantity: int, price: int
|
|
) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/grandexchange/buy",
|
|
json_body={"code": code, "quantity": quantity, "price": price},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def ge_sell_order(
|
|
self, name: str, code: str, quantity: int, price: int
|
|
) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/grandexchange/sell",
|
|
json_body={"code": code, "quantity": quantity, "price": price},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def ge_buy_order(
|
|
self, name: str, code: str, quantity: int, price: int
|
|
) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/grandexchange/buy",
|
|
json_body={"code": code, "quantity": quantity, "price": price},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/grandexchange/cancel",
|
|
json_body={"id": order_id},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def task_new(self, name: str) -> dict[str, Any]:
|
|
result = await self._post_action(f"/my/{name}/action/task/new")
|
|
return result.get("data", {})
|
|
|
|
async def task_trade(
|
|
self, name: str, code: str, quantity: int
|
|
) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/task/trade",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def task_complete(self, name: str) -> dict[str, Any]:
|
|
result = await self._post_action(f"/my/{name}/action/task/complete")
|
|
return result.get("data", {})
|
|
|
|
async def task_exchange(self, name: str) -> dict[str, Any]:
|
|
result = await self._post_action(f"/my/{name}/action/task/exchange")
|
|
return result.get("data", {})
|
|
|
|
async def task_cancel(self, name: str) -> dict[str, Any]:
|
|
result = await self._post_action(f"/my/{name}/action/task/cancel")
|
|
return result.get("data", {})
|
|
|
|
async def npc_buy(self, name: str, code: str, quantity: int) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/npc/buy",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
async def npc_sell(self, name: str, code: str, quantity: int) -> dict[str, Any]:
|
|
result = await self._post_action(
|
|
f"/my/{name}/action/npc/sell",
|
|
json_body={"code": code, "quantity": quantity},
|
|
)
|
|
return result.get("data", {})
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
async def close(self) -> None:
|
|
await self._client.aclose()
|