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)
90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Safety buffer added after every cooldown to avoid 499 "action already in progress" errors
|
|
_BUFFER_SECONDS: float = 0.1
|
|
|
|
|
|
class CooldownTracker:
|
|
"""Track per-character cooldowns with a safety buffer.
|
|
|
|
The Artifacts MMO API returns cooldown information after every action.
|
|
This tracker stores the expiry timestamp for each character and provides
|
|
an async ``wait`` method that sleeps until the cooldown has elapsed plus
|
|
a small buffer (100 ms) to prevent race-condition 499 errors.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._cooldowns: dict[str, datetime] = {}
|
|
|
|
def update(
|
|
self,
|
|
character_name: str,
|
|
cooldown_seconds: float,
|
|
cooldown_expiration: str | None = None,
|
|
) -> None:
|
|
"""Record the cooldown from an action response.
|
|
|
|
Parameters
|
|
----------
|
|
character_name:
|
|
The character whose cooldown is being updated.
|
|
cooldown_seconds:
|
|
Total cooldown duration in seconds (used as fallback).
|
|
cooldown_expiration:
|
|
ISO-8601 timestamp of when the cooldown expires (preferred).
|
|
"""
|
|
if cooldown_expiration:
|
|
try:
|
|
expiry = datetime.fromisoformat(cooldown_expiration)
|
|
# Ensure timezone-aware
|
|
if expiry.tzinfo is None:
|
|
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
except (ValueError, TypeError):
|
|
logger.warning(
|
|
"Failed to parse cooldown_expiration %r for %s, using duration fallback",
|
|
cooldown_expiration,
|
|
character_name,
|
|
)
|
|
expiry = datetime.now(timezone.utc) + timedelta(seconds=cooldown_seconds)
|
|
else:
|
|
expiry = datetime.now(timezone.utc) + timedelta(seconds=cooldown_seconds)
|
|
|
|
self._cooldowns[character_name] = expiry
|
|
logger.debug(
|
|
"Cooldown for %s set to %s (%.1fs)",
|
|
character_name,
|
|
expiry.isoformat(),
|
|
cooldown_seconds,
|
|
)
|
|
|
|
async def wait(self, character_name: str) -> None:
|
|
"""Sleep until the character's cooldown has expired plus a safety buffer."""
|
|
expiry = self._cooldowns.get(character_name)
|
|
if expiry is None:
|
|
return
|
|
|
|
now = datetime.now(timezone.utc)
|
|
remaining = (expiry - now).total_seconds() + _BUFFER_SECONDS
|
|
|
|
if remaining > 0:
|
|
logger.debug("Waiting %.2fs for %s cooldown", remaining, character_name)
|
|
await asyncio.sleep(remaining)
|
|
|
|
def is_ready(self, character_name: str) -> bool:
|
|
"""Return True if the character has no active cooldown."""
|
|
expiry = self._cooldowns.get(character_name)
|
|
if expiry is None:
|
|
return True
|
|
return datetime.now(timezone.utc) >= expiry
|
|
|
|
def remaining(self, character_name: str) -> float:
|
|
"""Return remaining cooldown seconds (0 if ready)."""
|
|
expiry = self._cooldowns.get(character_name)
|
|
if expiry is None:
|
|
return 0.0
|
|
delta = (expiry - datetime.now(timezone.utc)).total_seconds()
|
|
return max(delta, 0.0)
|