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)
202 lines
7.5 KiB
Python
202 lines
7.5 KiB
Python
import logging
|
|
from enum import Enum
|
|
|
|
from app.engine.pathfinder import Pathfinder
|
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
|
from app.schemas.game import CharacterSchema
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _GatherState(str, Enum):
|
|
"""Internal state machine states for the gathering loop."""
|
|
|
|
MOVE_TO_RESOURCE = "move_to_resource"
|
|
GATHER = "gather"
|
|
CHECK_INVENTORY = "check_inventory"
|
|
MOVE_TO_BANK = "move_to_bank"
|
|
DEPOSIT = "deposit"
|
|
|
|
|
|
class GatheringStrategy(BaseStrategy):
|
|
"""Automated gathering strategy.
|
|
|
|
State machine flow::
|
|
|
|
MOVE_TO_RESOURCE -> GATHER -> CHECK_INVENTORY
|
|
|
|
|
(full?) -> MOVE_TO_BANK -> DEPOSIT -> MOVE_TO_RESOURCE
|
|
(ok?) -> MOVE_TO_RESOURCE (loop)
|
|
|
|
|
(max_loops reached?) -> COMPLETE
|
|
|
|
Configuration keys (see :class:`~app.schemas.automation.GatheringConfig`):
|
|
- resource_code: str
|
|
- deposit_on_full: bool (default True)
|
|
- max_loops: int (default 0 = infinite)
|
|
"""
|
|
|
|
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
|
super().__init__(config, pathfinder)
|
|
self._state = _GatherState.MOVE_TO_RESOURCE
|
|
|
|
# Parsed config with defaults
|
|
self._resource_code: str = config["resource_code"]
|
|
self._deposit_on_full: bool = config.get("deposit_on_full", True)
|
|
self._max_loops: int = config.get("max_loops", 0)
|
|
|
|
# Runtime counters
|
|
self._loop_count: int = 0
|
|
|
|
# Cached locations (resolved lazily)
|
|
self._resource_pos: tuple[int, int] | None = None
|
|
self._bank_pos: tuple[int, int] | None = None
|
|
|
|
def get_state(self) -> str:
|
|
return self._state.value
|
|
|
|
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
|
# Check loop limit
|
|
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
|
return ActionPlan(
|
|
ActionType.COMPLETE,
|
|
reason=f"Completed {self._loop_count}/{self._max_loops} gather-deposit cycles",
|
|
)
|
|
|
|
# Lazily resolve tile positions
|
|
self._resolve_locations(character)
|
|
|
|
match self._state:
|
|
case _GatherState.MOVE_TO_RESOURCE:
|
|
return self._handle_move_to_resource(character)
|
|
case _GatherState.GATHER:
|
|
return self._handle_gather(character)
|
|
case _GatherState.CHECK_INVENTORY:
|
|
return self._handle_check_inventory(character)
|
|
case _GatherState.MOVE_TO_BANK:
|
|
return self._handle_move_to_bank(character)
|
|
case _GatherState.DEPOSIT:
|
|
return self._handle_deposit(character)
|
|
case _:
|
|
return ActionPlan(ActionType.IDLE, reason="Unknown state")
|
|
|
|
# ------------------------------------------------------------------
|
|
# State handlers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_move_to_resource(self, character: CharacterSchema) -> ActionPlan:
|
|
if self._resource_pos is None:
|
|
return ActionPlan(
|
|
ActionType.IDLE,
|
|
reason=f"No map tile found for resource {self._resource_code}",
|
|
)
|
|
|
|
rx, ry = self._resource_pos
|
|
|
|
# Already at the resource tile
|
|
if self._is_at(character, rx, ry):
|
|
self._state = _GatherState.GATHER
|
|
return self._handle_gather(character)
|
|
|
|
self._state = _GatherState.GATHER # transition after move
|
|
return ActionPlan(
|
|
ActionType.MOVE,
|
|
params={"x": rx, "y": ry},
|
|
reason=f"Moving to resource {self._resource_code} at ({rx}, {ry})",
|
|
)
|
|
|
|
def _handle_gather(self, character: CharacterSchema) -> ActionPlan:
|
|
# Before gathering, check if inventory is full
|
|
if self._inventory_free_slots(character) == 0:
|
|
self._state = _GatherState.CHECK_INVENTORY
|
|
return self._handle_check_inventory(character)
|
|
|
|
self._state = _GatherState.CHECK_INVENTORY # after gather we check inventory
|
|
return ActionPlan(
|
|
ActionType.GATHER,
|
|
reason=f"Gathering {self._resource_code}",
|
|
)
|
|
|
|
def _handle_check_inventory(self, character: CharacterSchema) -> ActionPlan:
|
|
free_slots = self._inventory_free_slots(character)
|
|
|
|
if free_slots == 0 and self._deposit_on_full:
|
|
self._state = _GatherState.MOVE_TO_BANK
|
|
return self._handle_move_to_bank(character)
|
|
|
|
if free_slots == 0 and not self._deposit_on_full:
|
|
# Inventory full and not depositing -- complete the automation
|
|
return ActionPlan(
|
|
ActionType.COMPLETE,
|
|
reason="Inventory full and deposit_on_full is disabled",
|
|
)
|
|
|
|
# Inventory has space, go gather more
|
|
self._state = _GatherState.MOVE_TO_RESOURCE
|
|
return self._handle_move_to_resource(character)
|
|
|
|
def _handle_move_to_bank(self, character: CharacterSchema) -> ActionPlan:
|
|
if self._bank_pos is None:
|
|
return ActionPlan(
|
|
ActionType.IDLE,
|
|
reason="No bank tile found on map",
|
|
)
|
|
|
|
bx, by = self._bank_pos
|
|
|
|
if self._is_at(character, bx, by):
|
|
self._state = _GatherState.DEPOSIT
|
|
return self._handle_deposit(character)
|
|
|
|
self._state = _GatherState.DEPOSIT # transition after move
|
|
return ActionPlan(
|
|
ActionType.MOVE,
|
|
params={"x": bx, "y": by},
|
|
reason=f"Moving to bank at ({bx}, {by}) to deposit items",
|
|
)
|
|
|
|
def _handle_deposit(self, character: CharacterSchema) -> ActionPlan:
|
|
# Deposit the first non-empty inventory slot
|
|
for slot in character.inventory:
|
|
if slot.quantity > 0:
|
|
# Stay in DEPOSIT state to deposit the next item on the next tick
|
|
return ActionPlan(
|
|
ActionType.DEPOSIT_ITEM,
|
|
params={"code": slot.code, "quantity": slot.quantity},
|
|
reason=f"Depositing {slot.quantity}x {slot.code}",
|
|
)
|
|
|
|
# All items deposited -- count the loop and go back to resource
|
|
self._loop_count += 1
|
|
logger.info(
|
|
"Gather-deposit cycle %d completed for %s",
|
|
self._loop_count,
|
|
self._resource_code,
|
|
)
|
|
|
|
self._state = _GatherState.MOVE_TO_RESOURCE
|
|
return self._handle_move_to_resource(character)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _resolve_locations(self, character: CharacterSchema) -> None:
|
|
"""Lazily resolve and cache resource / bank tile positions."""
|
|
if self._resource_pos is None:
|
|
self._resource_pos = self.pathfinder.find_nearest(
|
|
character.x, character.y, "resource", self._resource_code
|
|
)
|
|
if self._resource_pos:
|
|
logger.info(
|
|
"Resolved resource %s at %s",
|
|
self._resource_code,
|
|
self._resource_pos,
|
|
)
|
|
|
|
if self._bank_pos is None and self._deposit_on_full:
|
|
self._bank_pos = self.pathfinder.find_nearest_by_type(
|
|
character.x, character.y, "bank"
|
|
)
|
|
if self._bank_pos:
|
|
logger.info("Resolved bank at %s", self._bank_pos)
|