"""Геймификация: XP за закрытые задачи и уровни (ТЗ 3.13).
Только позитивное подкрепление: XP только начисляется, ничего не сгорает и
не отнимается (удаление задачи не трогает заработанное, стриков/серий нет).
"""
import random
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
# Редкость растения (ТЗ 3.13): чем «весомее» задача (больше XP), тем выше шанс.
# epic — только для крупных закрытий; rare — линейный рост от XP.
EPIC_XP_THRESHOLD = 25
EPIC_CHANCE = 0.08
RARE_BASE_CHANCE = 0.05
RARE_CHANCE_PER_XP = 0.02
def compute_rarity(xp: int, roll: float) -> str:
"""Редкость растения по весу задачи: roll из random.random() (для тестов подаётся).
Порядок проверки: epic -> rare -> common.
"""
if xp >= EPIC_XP_THRESHOLD and roll < EPIC_CHANCE:
return "epic"
rare_chance = RARE_BASE_CHANCE + max(xp - XP_BASE, 0) * RARE_CHANCE_PER_XP
if roll < rare_chance:
return "rare"
return "common"
def grant_task_xp(db: Session, task: Task) -> XpEvent | None:
"""Начислить XP за закрытие задачи (один раз на задачу, идемпотентно).
Возвращает созданное событие (с rarity) или None, если XP уже начислен.
"""
existing = db.scalar(select(XpEvent).where(XpEvent.task_id == task.id))
if existing is not None:
return None
amount = compute_task_xp(task)
event = XpEvent(
task_id=task.id,
amount=amount,
rarity=compute_rarity(amount, random.random()),
)
db.add(event)
db.flush()
return event
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),
}
def xp_events(db: Session) -> list[dict[str, Any]]:
"""Все события начисления (для сада: редкости, счётчик «выбрал и сделал», история)."""
rows = db.scalars(select(XpEvent).order_by(XpEvent.created_at.desc())).all()
return [
{
"task_id": r.task_id,
"amount": r.amount,
"rarity": r.rarity,
"via_options": r.via_options,
"created_at": r.created_at,
}
for r in rows
]