384 lines
14 KiB
Python
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,
|
|
}
|