artifacts-dashboard/backend/app/services/analytics_service.py
Paweł Orzech 10781c7987
Some checks failed
Release / release (push) Has been cancelled
v0.2.0: Rich interactive map, automation gallery, auth & UX improvements
Map overhaul:
- Replace colored boxes with actual game tile images (skin textures from CDN)
- Overlay content icons (monsters, resources, NPCs) on tiles
- Add layer switching (Overworld/Underground/Interior)
- Fix API schema to parse interactions.content and layer fields
- Add hover tooltips, tile search with coordinate parsing, keyboard shortcuts
- Add minimap with viewport rectangle, zoom-toward-cursor, loading progress
- Show tile/content images in side panel, coordinate labels at high zoom

Automation gallery:
- 27+ pre-built automation templates (combat, gathering, crafting, trading, utility)
- Multi-character selection for batch automation creation
- Gallery component with activate dialog

Auth & settings:
- API key gate with auth provider for token management
- Enhanced settings page with token configuration

UI improvements:
- Game icon component for item/monster/resource images
- Character automations panel on character detail page
- Equipment grid and inventory grid enhancements
- Automations page layout refresh
- Bank, exchange page minor fixes
- README update with live demo link
2026-03-01 20:18:29 +01:00

243 lines
7.4 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_tracked_characters(
self,
db: AsyncSession,
) -> list[str]:
"""Return distinct character names that have snapshots."""
stmt = select(CharacterSnapshot.name).distinct()
result = await db.execute(stmt)
return [row[0] for row in result.all()]
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,
}