Newer
Older
gnexus-tasks / backend / app / services / xp.py
"""Геймификация: XP за закрытые задачи и уровни (ТЗ 3.13).

Только позитивное подкрепление: XP только начисляется, ничего не сгорает и
не отнимается (удаление задачи не трогает заработанное, стриков/серий нет).
"""

from typing import Any

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models import Task, XpEvent

# База за любую закрытую задачу
XP_BASE = 10
# Бонус за приоритет (по градации шкалы 0-10: very_low/low/medium/high/urgent)
PRIORITY_BONUS = {"very_low": 0, "low": 2, "medium": 5, "high": 10, "urgent": 15}
# Бонус за оценку времени: больше работа — больше награда (минуты -> бонус,
# больше 240 -> последний бонус)
ESTIMATE_BONUS = ((15, 2), (60, 5), (240, 10), (float("inf"), 15))

# Уровень L достигается при XP >= LEVEL_FACTOR * (L-1) * L / 2:
# L2 = 100, L3 = 300, L4 = 600, L5 = 1000 ... на уровень L -> L+1 нужно 100*L
LEVEL_FACTOR = 100


def priority_grade(priority: int | None) -> str:
    if priority is None:
        return "very_low"
    if priority <= 2:
        return "very_low"
    if priority <= 4:
        return "low"
    if priority <= 6:
        return "medium"
    if priority <= 8:
        return "high"
    return "urgent"


def compute_task_xp(task: Task) -> int:
    """XP за задачу: база + бонус приоритета + бонус объёма (оценки времени)."""
    xp = XP_BASE + PRIORITY_BONUS[priority_grade(task.priority)]
    minutes = task.estimated_minutes
    if minutes is not None:
        for limit, bonus in ESTIMATE_BONUS:
            if minutes <= limit:
                xp += bonus
                break
    return xp


def grant_task_xp(db: Session, task: Task) -> int:
    """Начислить XP за закрытие задачи (один раз на задачу, идемпотентно)."""
    existing = db.scalar(select(XpEvent).where(XpEvent.task_id == task.id))
    if existing is not None:
        return 0
    event = XpEvent(task_id=task.id, amount=compute_task_xp(task))
    db.add(event)
    db.flush()
    return event.amount


def total_xp(db: Session) -> int:
    return int(db.scalar(select(func.coalesce(func.sum(XpEvent.amount), 0))) or 0)


def level_from_xp(xp: int) -> int:
    """Наибольший уровень L, при котором xp >= LEVEL_FACTOR*(L-1)*L/2."""
    level = 1
    while xp >= LEVEL_FACTOR * level * (level + 1) // 2:
        level += 1
    return level


def xp_summary(db: Session) -> dict[str, Any]:
    """Сводка для UI: XP, уровень, прогресс до следующего уровня."""
    xp = total_xp(db)
    level = level_from_xp(xp)
    current_floor = LEVEL_FACTOR * (level - 1) * level // 2
    next_floor = LEVEL_FACTOR * level * (level + 1) // 2
    span = next_floor - current_floor
    return {
        "total_xp": xp,
        "level": level,
        "level_xp": xp - current_floor,
        "level_span": span,
        "level_percent": round((xp - current_floor) / span * 100) if span else 100,
        "completed_count": int(db.scalar(select(func.count(XpEvent.id))) or 0),
    }