Files
ForbiddenStarsApp/backend/app/services/stats_service.py
T

384 lines
14 KiB
Python

"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
from __future__ import annotations
from typing import Any
from sqlalchemy import text
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Group, GroupMember, Match
from app.services import group_service, membership_service, user_service
from app.services.scoring import (
FACTION_MIN_GAMES,
MATCH_POINTS_SQL,
MIN_GAMES,
leaderboard_sort_key,
)
# Базовый блок: одна строка на участие с tie-aware очками.
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
SCORED_CTE = f"""
WITH tie AS (
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
WHERE m.status = 'finished' AND mp.place IS NOT NULL
GROUP BY mp.match_id, mp.place
),
scored AS (
SELECT mp.user_id AS user_id,
mp.faction_id AS faction_id,
m.id AS match_id,
m.group_id AS group_id,
m.played_at AS played_at,
mp.place AS place,
m.player_count AS player_count,
({MATCH_POINTS_SQL}) AS points,
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
WHERE m.status = 'finished'
)
"""
def _round(value: Any, ndigits: int) -> float | None:
return None if value is None else round(float(value), ndigits)
def _normalize(row: dict) -> dict:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
return {
"user_id": row["user_id"],
"nickname": row["nickname"],
"games": int(row["games"] or 0),
"wins": int(row["wins"] or 0),
"win_rate": _round(row["win_rate"] or 0.0, 4),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
}
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
where = "WHERE s.group_id = :gid" if group_id is not None else ""
sql = f"""
{SCORED_CTE}
SELECT u.id AS user_id, u.nickname AS nickname,
u.avatar_path AS avatar_path,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score
FROM scored s
JOIN users u ON u.id = s.user_id
{where}
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
"""
params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all()
return [_normalize(dict(r)) for r in result]
def leaderboard(session: Session, group_id: int | None = None) -> dict:
rows = _leaderboard_rows(session, group_id)
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
provisional = [r for r in rows if r["games"] < MIN_GAMES]
qualified.sort(key=leaderboard_sort_key)
provisional.sort(key=leaderboard_sort_key)
for i, r in enumerate(qualified, start=1):
r["rank"] = i
for r in provisional:
r["rank"] = None
return {
"entries": qualified,
"provisional": provisional,
"min_games": MIN_GAMES,
}
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score
FROM scored s
{cond}
"""
params: dict[str, Any] = {"uid": user_id}
if group_id is not None:
params["gid"] = group_id
r = session.execute(text(sql), params).mappings().first() or {}
return {
"games": int(r.get("games") or 0),
"wins": int(r.get("wins") or 0),
"win_rate": _round(r.get("win_rate") or 0.0, 4),
"avg_place": _round(r.get("avg_place"), 2),
"score": _round(r.get("score"), 1),
}
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
e.code AS expansion_code,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score
FROM scored s
JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id
{cond}
GROUP BY f.id, f.code, f.name_ru, e.code
ORDER BY games DESC, score DESC
"""
params: dict[str, Any] = {"uid": user_id}
if group_id is not None:
params["gid"] = group_id
result = session.execute(text(sql), params).mappings().all()
out = []
for r in result:
out.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"expansion_code": r["expansion_code"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"win_rate": _round(r["win_rate"] or 0.0, 4),
"avg_place": _round(r["avg_place"], 2),
"score": _round(r["score"], 1),
}
)
return out
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
FROM scored s
{cond}
ORDER BY s.played_at DESC, s.match_id DESC
LIMIT :lim
"""
params: dict[str, Any] = {"uid": user_id, "lim": limit}
if group_id is not None:
params["gid"] = group_id
result = session.execute(text(sql), params).mappings().all()
return [
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
for r in result
]
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
overall = _overall_for_user(session, user_id, group_id)
factions = _faction_breakdown(session, user_id, group_id)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
most_played = max(factions, key=lambda f: f["games"]) if factions else None
return {
"user_id": user_id,
"overall": overall,
"factions": factions,
"best_faction": best,
"worst_faction": worst,
"most_played_faction": most_played,
"recent_form": _recent_form(session, user_id, group_id),
}
def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id)
total_matches = session.exec(
select(Match).where(Match.group_id == group_id, Match.status == "finished")
).all()
last_at = None
if total_matches:
last_at = str(max(m.played_at for m in total_matches))
available_ids = group_service.available_faction_ids(session, group_id)
faction_meta = []
sql = f"""
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
FROM factions f
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
GROUP BY f.id, f.code, f.name_ru
ORDER BY games DESC, f.sort_order
"""
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
for r in rows:
faction_meta.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"available": r["faction_id"] in available_ids,
}
)
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
played_ids = {e["user_id"] for e in board["entries"]} | {
e["user_id"] for e in board["provisional"]
}
inactive = []
for _m, u in membership_service.list_members(session, group_id):
if u.id in played_ids:
continue
inactive.append(
{
"user_id": u.id,
"nickname": u.nickname,
"games": 0,
"wins": 0,
"win_rate": 0.0,
"avg_place": None,
"score": None,
"rank": None,
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
}
)
return {
"group_id": group_id,
"total_matches": len(total_matches),
"last_match_at": last_at,
"leaderboard": board["entries"],
"provisional": board["provisional"],
"inactive": inactive,
"faction_meta": faction_meta,
"min_games": MIN_GAMES,
}
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
from app.services.match_service import participants_detail # избегаем цикла импорта
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
items = []
for m in matches:
parts = []
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
parts.append(
{
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"was_random": p.was_random,
"comment": p.comment,
}
)
items.append(
{
"id": m.id,
"status": m.status,
"played_at": str(m.played_at),
"started_at": iso_utc(m.started_at),
"finished_at": iso_utc(m.finished_at),
"duration_minutes": m.duration_minutes,
"win_reason": m.win_reason,
"player_count": m.player_count,
"overall_comment": m.overall_comment,
"created_by": m.created_by,
"participants": parts,
}
)
return {"items": items, "total": total, "limit": limit, "offset": offset}
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
group_ids = list(
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
)
if not group_ids:
return []
rows = session.exec(
select(Match, Group.name)
.join(Group, Group.id == Match.group_id)
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
.order_by(Match.started_at.desc(), Match.id.desc())
).all()
out = []
for m, gname in rows:
parts = [
{
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"was_random": p.was_random,
"comment": p.comment,
}
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
]
out.append(
{
"id": m.id,
"group_id": m.group_id,
"group_name": gname,
"started_at": iso_utc(m.started_at),
"player_count": m.player_count,
"participants": parts,
}
)
return out
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
board = leaderboard(session, group_id=None)
profile = profile_stats(session, user_id, group_id=None)
active_group_brief = None
if active_group_id is not None:
group = session.get(Group, active_group_id)
if group is not None:
active_group_brief = {
"id": group.id,
"name": group.name,
**_overall_for_user(session, user_id, active_group_id),
}
return {
"leaderboard": board["entries"][:leaderboard_limit],
"provisional": board["provisional"][:leaderboard_limit],
"profile": profile,
"active_group": active_group_brief,
"in_progress": user_in_progress_matches(session, user_id),
"min_games": MIN_GAMES,
}