artifacts-dashboard/backend/app/engine/runner.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

504 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.engine.cooldown import CooldownTracker
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.models.automation import AutomationLog, AutomationRun
from app.services.artifacts_client import ArtifactsClient
if TYPE_CHECKING:
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
# Delay before retrying after an unhandled error in the run loop
_ERROR_RETRY_DELAY: float = 2.0
# Maximum consecutive errors before the runner stops itself
_MAX_CONSECUTIVE_ERRORS: int = 10
class AutomationRunner:
"""Drives the automation loop for a single character.
Each runner owns an ``asyncio.Task`` that repeatedly:
1. Waits for the character's cooldown to expire.
2. Fetches the current character state from the API.
3. Asks the strategy for the next action.
4. Executes that action via the artifacts client.
5. Records the cooldown and logs the result to the database.
The runner can be paused, resumed, or stopped at any time.
"""
def __init__(
self,
config_id: int,
character_name: str,
strategy: BaseStrategy,
client: ArtifactsClient,
cooldown_tracker: CooldownTracker,
db_factory: async_sessionmaker[AsyncSession],
run_id: int,
event_bus: EventBus | None = None,
) -> None:
self._config_id = config_id
self._character_name = character_name
self._strategy = strategy
self._client = client
self._cooldown = cooldown_tracker
self._db_factory = db_factory
self._run_id = run_id
self._event_bus = event_bus
self._running = False
self._paused = False
self._task: asyncio.Task[None] | None = None
self._actions_count: int = 0
self._consecutive_errors: int = 0
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
def config_id(self) -> int:
return self._config_id
@property
def character_name(self) -> str:
return self._character_name
@property
def run_id(self) -> int:
return self._run_id
@property
def actions_count(self) -> int:
return self._actions_count
@property
def is_running(self) -> bool:
return self._running and not self._paused
@property
def is_paused(self) -> bool:
return self._running and self._paused
@property
def status(self) -> str:
if not self._running:
return "stopped"
if self._paused:
return "paused"
return "running"
@property
def strategy_state(self) -> str:
return self._strategy.get_state()
# ------------------------------------------------------------------
# Event bus helpers
# ------------------------------------------------------------------
async def _publish(self, event_type: str, data: dict) -> None:
"""Publish an event to the event bus if one is configured."""
if self._event_bus is not None:
try:
await self._event_bus.publish(event_type, data)
except Exception:
logger.exception("Failed to publish event %s", event_type)
async def _publish_status(self, status: str) -> None:
"""Publish an automation_status_changed event."""
await self._publish(
"automation_status_changed",
{
"config_id": self._config_id,
"character_name": self._character_name,
"status": status,
"run_id": self._run_id,
},
)
async def _publish_action(
self,
action_type: str,
success: bool,
details: dict | None = None,
) -> None:
"""Publish an automation_action event."""
await self._publish(
"automation_action",
{
"config_id": self._config_id,
"character_name": self._character_name,
"action_type": action_type,
"success": success,
"details": details or {},
"actions_count": self._actions_count,
},
)
async def _publish_character_update(self) -> None:
"""Publish a character_update event to trigger frontend re-fetch."""
await self._publish(
"character_update",
{
"character_name": self._character_name,
},
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
"""Start the automation loop in a background task."""
if self._running:
logger.warning("Runner for config %d is already running", self._config_id)
return
self._running = True
self._paused = False
self._task = asyncio.create_task(
self._run_loop(),
name=f"automation-{self._config_id}-{self._character_name}",
)
logger.info(
"Started automation runner for config %d (character=%s, run=%d)",
self._config_id,
self._character_name,
self._run_id,
)
await self._publish_status("running")
async def stop(self, error_message: str | None = None) -> None:
"""Stop the automation loop and finalize the run record."""
self._running = False
if self._task is not None and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# Update the run record in the database
final_status = "error" if error_message else "stopped"
await self._finalize_run(
status=final_status,
error_message=error_message,
)
logger.info(
"Stopped automation runner for config %d (actions=%d)",
self._config_id,
self._actions_count,
)
await self._publish_status(final_status)
async def pause(self) -> None:
"""Pause the automation loop (the task keeps running but idles)."""
self._paused = True
await self._update_run_status("paused")
logger.info("Paused automation runner for config %d", self._config_id)
await self._publish_status("paused")
async def resume(self) -> None:
"""Resume a paused automation loop."""
self._paused = False
await self._update_run_status("running")
logger.info("Resumed automation runner for config %d", self._config_id)
await self._publish_status("running")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
async def _run_loop(self) -> None:
"""Core automation loop -- runs until stopped or the strategy completes."""
try:
while self._running:
if self._paused:
await asyncio.sleep(1)
continue
try:
await self._tick()
self._consecutive_errors = 0
except asyncio.CancelledError:
raise
except Exception as exc:
self._consecutive_errors += 1
logger.exception(
"Error in automation loop for config %d (error %d/%d): %s",
self._config_id,
self._consecutive_errors,
_MAX_CONSECUTIVE_ERRORS,
exc,
)
await self._log_action(
ActionPlan(ActionType.IDLE, reason=str(exc)),
success=False,
)
await self._publish_action(
"error",
success=False,
details={"error": str(exc)},
)
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
logger.error(
"Too many consecutive errors for config %d, stopping",
self._config_id,
)
await self._finalize_run(
status="error",
error_message=f"Stopped after {_MAX_CONSECUTIVE_ERRORS} consecutive errors. Last: {exc}",
)
self._running = False
await self._publish_status("error")
return
await asyncio.sleep(_ERROR_RETRY_DELAY)
except asyncio.CancelledError:
logger.info("Automation loop for config %d was cancelled", self._config_id)
async def _tick(self) -> None:
"""Execute a single iteration of the automation loop."""
# 1. Wait for cooldown
await self._cooldown.wait(self._character_name)
# 2. Fetch current character state
character = await self._client.get_character(self._character_name)
# 3. Ask strategy for the next action
plan = await self._strategy.next_action(character)
# 4. Handle terminal actions
if plan.action_type == ActionType.COMPLETE:
logger.info(
"Strategy completed for config %d: %s",
self._config_id,
plan.reason,
)
await self._log_action(plan, success=True)
await self._finalize_run(status="completed")
self._running = False
await self._publish_status("completed")
await self._publish_action(
plan.action_type.value,
success=True,
details={"reason": plan.reason},
)
return
if plan.action_type == ActionType.IDLE:
logger.debug(
"Strategy idle for config %d: %s",
self._config_id,
plan.reason,
)
await asyncio.sleep(1)
return
# 5. Execute the action
result = await self._execute_action(plan)
# 6. Update cooldown from response
self._update_cooldown_from_result(result)
# 7. Record success
self._actions_count += 1
await self._log_action(plan, success=True)
# 8. Publish events for the frontend
await self._publish_action(
plan.action_type.value,
success=True,
details={
"params": plan.params,
"reason": plan.reason,
"strategy_state": self._strategy.get_state(),
},
)
await self._publish_character_update()
# ------------------------------------------------------------------
# Action execution
# ------------------------------------------------------------------
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
"""Dispatch an action plan to the appropriate client method."""
match plan.action_type:
case ActionType.MOVE:
return await self._client.move(
self._character_name,
plan.params["x"],
plan.params["y"],
)
case ActionType.FIGHT:
return await self._client.fight(self._character_name)
case ActionType.GATHER:
return await self._client.gather(self._character_name)
case ActionType.REST:
return await self._client.rest(self._character_name)
case ActionType.EQUIP:
return await self._client.equip(
self._character_name,
plan.params["code"],
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.UNEQUIP:
return await self._client.unequip(
self._character_name,
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.USE_ITEM:
return await self._client.use_item(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.DEPOSIT_ITEM:
return await self._client.deposit_item(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.WITHDRAW_ITEM:
return await self._client.withdraw_item(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.CRAFT:
return await self._client.craft(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.RECYCLE:
return await self._client.recycle(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.GE_BUY:
return await self._client.ge_buy(
self._character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_SELL:
return await self._client.ge_sell_order(
self._character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_CANCEL:
return await self._client.ge_cancel(
self._character_name,
plan.params["order_id"],
)
case ActionType.TASK_NEW:
return await self._client.task_new(self._character_name)
case ActionType.TASK_TRADE:
return await self._client.task_trade(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.TASK_COMPLETE:
return await self._client.task_complete(self._character_name)
case ActionType.TASK_EXCHANGE:
return await self._client.task_exchange(self._character_name)
case _:
logger.warning("Unhandled action type: %s", plan.action_type)
return {}
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
"""Extract cooldown information from an action response and update the tracker."""
cooldown = result.get("cooldown")
if cooldown is None:
return
self._cooldown.update(
self._character_name,
cooldown.get("total_seconds", 0),
cooldown.get("expiration"),
)
# ------------------------------------------------------------------
# Database helpers
# ------------------------------------------------------------------
async def _log_action(self, plan: ActionPlan, success: bool) -> None:
"""Write an action log entry and update the run's action count."""
try:
async with self._db_factory() as db:
log = AutomationLog(
run_id=self._run_id,
action_type=plan.action_type.value,
details={
"params": plan.params,
"reason": plan.reason,
"strategy_state": self._strategy.get_state(),
},
success=success,
)
db.add(log)
# Update the run's action counter
stmt = select(AutomationRun).where(AutomationRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.actions_count = self._actions_count
await db.commit()
except Exception:
logger.exception("Failed to log action for run %d", self._run_id)
async def _update_run_status(self, status: str) -> None:
"""Update the status field of the current run."""
try:
async with self._db_factory() as db:
stmt = select(AutomationRun).where(AutomationRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
await db.commit()
except Exception:
logger.exception("Failed to update run %d status to %s", self._run_id, status)
async def _finalize_run(
self,
status: str,
error_message: str | None = None,
) -> None:
"""Mark the run as finished with a final status and timestamp."""
try:
async with self._db_factory() as db:
stmt = select(AutomationRun).where(AutomationRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
run.stopped_at = datetime.now(timezone.utc)
run.actions_count = self._actions_count
if error_message:
run.error_message = error_message
await db.commit()
except Exception:
logger.exception("Failed to finalize run %d", self._run_id)