"""Геймификация: 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),
}