artifacts-dashboard/backend/app/services/analytics_service.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

234 lines
7.1 KiB
Python

"""Analytics service for XP history, gold tracking, and action rate calculations."""
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character_snapshot import CharacterSnapshot
logger = logging.getLogger(__name__)
class AnalyticsService:
"""Provides analytics derived from character snapshot time-series data."""
async def get_xp_history(
self,
db: AsyncSession,
character_name: str,
hours: int = 24,
) -> list[dict[str, Any]]:
"""Get XP snapshots over time for a character.
Parameters
----------
db:
Database session.
character_name:
Name of the character.
hours:
How many hours of history to return.
Returns
-------
List of dicts with timestamp and XP values for each skill.
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
stmt = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= since,
)
.order_by(CharacterSnapshot.created_at.asc())
)
result = await db.execute(stmt)
snapshots = result.scalars().all()
history: list[dict[str, Any]] = []
for snap in snapshots:
data = snap.data or {}
entry: dict[str, Any] = {
"timestamp": snap.created_at.isoformat() if snap.created_at else None,
"level": data.get("level", 0),
"xp": data.get("xp", 0),
"max_xp": data.get("max_xp", 0),
"skills": {},
}
# Extract all skill XP values
for skill in (
"mining",
"woodcutting",
"fishing",
"weaponcrafting",
"gearcrafting",
"jewelrycrafting",
"cooking",
"alchemy",
):
entry["skills"][skill] = {
"level": data.get(f"{skill}_level", 0),
"xp": data.get(f"{skill}_xp", 0),
}
history.append(entry)
return history
async def get_gold_history(
self,
db: AsyncSession,
character_name: str,
hours: int = 24,
) -> list[dict[str, Any]]:
"""Get gold snapshots over time for a character.
Parameters
----------
db:
Database session.
character_name:
Name of the character.
hours:
How many hours of history to return.
Returns
-------
List of dicts with timestamp and gold amount.
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
stmt = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= since,
)
.order_by(CharacterSnapshot.created_at.asc())
)
result = await db.execute(stmt)
snapshots = result.scalars().all()
return [
{
"timestamp": snap.created_at.isoformat() if snap.created_at else None,
"gold": (snap.data or {}).get("gold", 0),
}
for snap in snapshots
]
async def get_actions_per_hour(
self,
db: AsyncSession,
character_name: str,
) -> dict[str, Any]:
"""Calculate the action rate for a character based on recent snapshots.
Uses the difference between the latest and earliest snapshot in the
last hour to estimate actions per hour (approximated by XP changes).
Returns
-------
Dict with "character_name", "period_hours", "xp_gained", "estimated_actions_per_hour".
"""
now = datetime.now(timezone.utc)
one_hour_ago = now - timedelta(hours=1)
# Get earliest snapshot in the window
stmt_earliest = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= one_hour_ago,
)
.order_by(CharacterSnapshot.created_at.asc())
.limit(1)
)
result = await db.execute(stmt_earliest)
earliest = result.scalar_one_or_none()
# Get latest snapshot
stmt_latest = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= one_hour_ago,
)
.order_by(CharacterSnapshot.created_at.desc())
.limit(1)
)
result = await db.execute(stmt_latest)
latest = result.scalar_one_or_none()
if earliest is None or latest is None or earliest.id == latest.id:
return {
"character_name": character_name,
"period_hours": 1,
"xp_gained": 0,
"gold_gained": 0,
"estimated_actions_per_hour": 0,
}
earliest_data = earliest.data or {}
latest_data = latest.data or {}
# Calculate total XP gained across all skills
total_xp_gained = 0
for skill in (
"mining",
"woodcutting",
"fishing",
"weaponcrafting",
"gearcrafting",
"jewelrycrafting",
"cooking",
"alchemy",
):
xp_key = f"{skill}_xp"
early_xp = earliest_data.get(xp_key, 0)
late_xp = latest_data.get(xp_key, 0)
total_xp_gained += max(0, late_xp - early_xp)
# Also add combat XP
total_xp_gained += max(
0,
latest_data.get("xp", 0) - earliest_data.get("xp", 0),
)
gold_gained = max(
0,
latest_data.get("gold", 0) - earliest_data.get("gold", 0),
)
# Estimate time span
if earliest.created_at and latest.created_at:
time_span = (latest.created_at - earliest.created_at).total_seconds()
hours = max(time_span / 3600, 0.01) # Avoid division by zero
else:
hours = 1.0
# Count snapshots as a proxy for activity periods
count_stmt = (
select(func.count())
.select_from(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= one_hour_ago,
)
)
count_result = await db.execute(count_stmt)
snapshot_count = count_result.scalar() or 0
return {
"character_name": character_name,
"period_hours": round(hours, 2),
"xp_gained": total_xp_gained,
"gold_gained": gold_gained,
"snapshot_count": snapshot_count,
"estimated_actions_per_hour": round(total_xp_gained / hours, 1) if hours > 0 else 0,
}