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)
130 lines
4.5 KiB
Python
130 lines
4.5 KiB
Python
import logging
|
|
|
|
from app.schemas.game import MapSchema
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Pathfinder:
|
|
"""Spatial index over the game map for finding tiles by content.
|
|
|
|
Uses Manhattan distance (since the Artifacts MMO API ``move`` action
|
|
performs a direct teleport with a cooldown proportional to Manhattan
|
|
distance). A* path-finding over walkable tiles is therefore unnecessary;
|
|
the optimal strategy is always to move directly to the target.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._maps: list[MapSchema] = []
|
|
self._map_index: dict[tuple[int, int], MapSchema] = {}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Initialization
|
|
# ------------------------------------------------------------------
|
|
|
|
def load_maps(self, maps: list[MapSchema]) -> None:
|
|
"""Load map data (typically from the game data cache)."""
|
|
self._maps = list(maps)
|
|
self._map_index = {(m.x, m.y): m for m in self._maps}
|
|
logger.info("Pathfinder loaded %d map tiles", len(self._maps))
|
|
|
|
@property
|
|
def is_loaded(self) -> bool:
|
|
return len(self._maps) > 0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tile lookup
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_tile(self, x: int, y: int) -> MapSchema | None:
|
|
"""Return the map tile at the given coordinates, or None."""
|
|
return self._map_index.get((x, y))
|
|
|
|
def tile_has_content(self, x: int, y: int, content_type: str, content_code: str) -> bool:
|
|
"""Check whether the tile at (x, y) has the specified content."""
|
|
tile = self._map_index.get((x, y))
|
|
if tile is None or tile.content is None:
|
|
return False
|
|
return tile.content.type == content_type and tile.content.code == content_code
|
|
|
|
def tile_has_content_type(self, x: int, y: int, content_type: str) -> bool:
|
|
"""Check whether the tile at (x, y) has any content of the given type."""
|
|
tile = self._map_index.get((x, y))
|
|
if tile is None or tile.content is None:
|
|
return False
|
|
return tile.content.type == content_type
|
|
|
|
# ------------------------------------------------------------------
|
|
# Nearest-tile search
|
|
# ------------------------------------------------------------------
|
|
|
|
def find_nearest(
|
|
self,
|
|
from_x: int,
|
|
from_y: int,
|
|
content_type: str,
|
|
content_code: str,
|
|
) -> tuple[int, int] | None:
|
|
"""Find the nearest tile whose content matches type *and* code.
|
|
|
|
Returns ``(x, y)`` of the closest match, or ``None`` if not found.
|
|
"""
|
|
best: tuple[int, int] | None = None
|
|
best_dist = float("inf")
|
|
|
|
for m in self._maps:
|
|
if (
|
|
m.content is not None
|
|
and m.content.type == content_type
|
|
and m.content.code == content_code
|
|
):
|
|
dist = abs(m.x - from_x) + abs(m.y - from_y)
|
|
if dist < best_dist:
|
|
best_dist = dist
|
|
best = (m.x, m.y)
|
|
|
|
return best
|
|
|
|
def find_nearest_by_type(
|
|
self,
|
|
from_x: int,
|
|
from_y: int,
|
|
content_type: str,
|
|
) -> tuple[int, int] | None:
|
|
"""Find the nearest tile that has any content of *content_type*.
|
|
|
|
Returns ``(x, y)`` of the closest match, or ``None`` if not found.
|
|
"""
|
|
best: tuple[int, int] | None = None
|
|
best_dist = float("inf")
|
|
|
|
for m in self._maps:
|
|
if m.content is not None and m.content.type == content_type:
|
|
dist = abs(m.x - from_x) + abs(m.y - from_y)
|
|
if dist < best_dist:
|
|
best_dist = dist
|
|
best = (m.x, m.y)
|
|
|
|
return best
|
|
|
|
def find_all(
|
|
self,
|
|
content_type: str,
|
|
content_code: str | None = None,
|
|
) -> list[tuple[int, int]]:
|
|
"""Return coordinates of all tiles matching the given content filter."""
|
|
results: list[tuple[int, int]] = []
|
|
for m in self._maps:
|
|
if m.content is None:
|
|
continue
|
|
if m.content.type != content_type:
|
|
continue
|
|
if content_code is not None and m.content.code != content_code:
|
|
continue
|
|
results.append((m.x, m.y))
|
|
return results
|
|
|
|
@staticmethod
|
|
def manhattan_distance(x1: int, y1: int, x2: int, y2: int) -> int:
|
|
"""Compute the Manhattan distance between two points."""
|
|
return abs(x1 - x2) + abs(y1 - y2)
|