Модели и миграция 0014: groups.nine_rounds_rule, снимок правила в matches,
matches.end_round, match_participants.objectives/worlds. CHECK причины победы
расширяется, только если он в БД есть (старые БД без CHECK таблицу не
пересоздают); пересоздание matches отказывает при PRAGMA foreign_keys=ON,
иначе DROP унёс бы участников каскадом. Бэкфилл: last_standing у завершённых
партий с одним невыбывшим. Проверено на копии dev-БД и на схеме origin/dev
с CHECK: строки, CHECK, FK и индексы на месте, повтор и downgrade работают.
API и валидация: end_round от 1 до лимита раундов партии (9 при хоумруле
и 5+ игроках), у выбывшего миров 0, last_standing ровно при одном невыбывшем —
в завершении и правке (игрока и админа); черновик хранит раунд, цели и миры
без проверки правила. MatchRead отдаёт end_round, снимок правила и max_rounds.
PATCH /groups/{id} принимает nine_rounds_rule вместе с названием. #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
490 lines
18 KiB
Python
490 lines
18 KiB
Python
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, text
|
|
from sqlmodel import Session, select
|
|
|
|
from app.core.timeutil import iso_utc
|
|
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
|
|
from app.services import faction_service, group_service, membership_service, user_service
|
|
from app.services.scoring import (
|
|
FACTION_MIN_GAMES,
|
|
MATCH_POINTS_SQL,
|
|
MIN_GAMES,
|
|
SMOOTHED_SCORE_SQL,
|
|
leaderboard_sort_key,
|
|
smoothed_score,
|
|
)
|
|
|
|
# Базовый блок: одна строка на участие с 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,
|
|
mp.was_random AS was_random,
|
|
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,
|
|
{SMOOTHED_SCORE_SQL} 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 _user_scored_rows(session: Session, user_id: int) -> list[dict]:
|
|
"""Строки участия игрока со всеми полями, нужными витринам профиля.
|
|
|
|
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма
|
|
последних партий считаются из одного и того же набора строк. CTE джойнит участия
|
|
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с
|
|
общим числом партий, а не с числом партий игрока."""
|
|
sql = f"""
|
|
{SCORED_CTE}
|
|
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at,
|
|
s.place AS place, s.player_count AS player_count,
|
|
s.points AS points, s.is_win AS is_win,
|
|
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
|
e.code AS expansion_code
|
|
FROM scored s
|
|
JOIN factions f ON f.id = s.faction_id
|
|
JOIN expansions e ON e.id = f.expansion_id
|
|
WHERE s.user_id = :uid
|
|
"""
|
|
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def _for_group(rows: list[dict], group_id: int | None) -> list[dict]:
|
|
return rows if group_id is None else [r for r in rows if r["group_id"] == group_id]
|
|
|
|
|
|
def _overall_from_rows(rows: list[dict]) -> dict:
|
|
"""Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг."""
|
|
games = len(rows)
|
|
if games == 0:
|
|
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
|
|
wins = sum(int(r["is_win"]) for r in rows)
|
|
return {
|
|
"games": games,
|
|
"wins": wins,
|
|
"win_rate": _round(wins / games, 4),
|
|
"avg_place": _round(sum(r["place"] for r in rows) / games, 2),
|
|
"score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1),
|
|
}
|
|
|
|
|
|
def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
|
|
by_faction: dict[int, list[dict]] = {}
|
|
for r in rows:
|
|
by_faction.setdefault(r["faction_id"], []).append(r)
|
|
out = []
|
|
for fid, group in by_faction.items():
|
|
games = len(group)
|
|
wins = sum(int(r["is_win"]) for r in group)
|
|
meta = group[0]
|
|
out.append(
|
|
{
|
|
"faction_id": fid,
|
|
"code": meta["code"],
|
|
"name_ru": meta["name_ru"],
|
|
"expansion_code": meta["expansion_code"],
|
|
"games": games,
|
|
"wins": wins,
|
|
"win_rate": _round(wins / games, 4),
|
|
"avg_place": _round(sum(r["place"] for r in group) / games, 2),
|
|
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
|
|
# сглаживание задавило бы её к 50).
|
|
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
|
|
"name_ru_prepositional": faction_service.prepositional(
|
|
meta["code"], meta["name_ru"]
|
|
),
|
|
}
|
|
)
|
|
out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0)))
|
|
return out
|
|
|
|
|
|
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]:
|
|
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True)
|
|
return [
|
|
{
|
|
"place": r["place"],
|
|
"player_count": r["player_count"],
|
|
"played_at": str(r["played_at"]),
|
|
}
|
|
for r in recent[:limit]
|
|
]
|
|
|
|
|
|
def _favorite_faction(session: Session, user_id: int) -> dict | None:
|
|
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
|
|
user = session.get(User, user_id)
|
|
if user is None or user.favorite_faction_id is None:
|
|
return None
|
|
faction = session.get(Faction, user.favorite_faction_id)
|
|
if faction is None:
|
|
return None
|
|
return {
|
|
"id": faction.id,
|
|
"code": faction.code,
|
|
"name_ru": faction.name_ru,
|
|
"expansion_id": faction.expansion_id,
|
|
}
|
|
|
|
|
|
def profile_stats(
|
|
session: Session,
|
|
user_id: int,
|
|
group_id: int | None = None,
|
|
*,
|
|
rows: list[dict] | None = None,
|
|
) -> dict:
|
|
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует)."""
|
|
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id)
|
|
overall = _overall_from_rows(scoped)
|
|
factions = _faction_breakdown_from_rows(scoped)
|
|
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
|
|
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
|
|
# рандомные раздачи.
|
|
main = 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,
|
|
"favorite_faction": _favorite_faction(session, user_id),
|
|
"main_faction": main,
|
|
"recent_form": _recent_form_from_rows(scoped),
|
|
"min_games": MIN_GAMES,
|
|
}
|
|
|
|
|
|
def group_stats(session: Session, group_id: int) -> dict:
|
|
board = leaderboard(session, group_id=group_id)
|
|
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
|
|
games_count, last_played = session.exec(
|
|
select(func.count(), func.max(Match.played_at)).where(
|
|
Match.group_id == group_id, Match.status == "finished"
|
|
)
|
|
).one()
|
|
last_at = str(last_played) if last_played else None
|
|
|
|
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": games_count,
|
|
"last_match_at": last_at,
|
|
"leaderboard": board["entries"],
|
|
"provisional": board["provisional"],
|
|
"inactive": inactive,
|
|
"faction_meta": faction_meta,
|
|
"min_games": MIN_GAMES,
|
|
}
|
|
|
|
|
|
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
|
|
return {
|
|
"user_id": u.id,
|
|
"nickname": u.nickname,
|
|
"faction_id": f.id,
|
|
"faction_name": f.name_ru,
|
|
"place": p.place,
|
|
"eliminated": p.eliminated,
|
|
"was_random": p.was_random,
|
|
"comment": p.comment,
|
|
"objectives": p.objectives,
|
|
"worlds": p.worlds,
|
|
}
|
|
|
|
|
|
def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]:
|
|
"""Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1)."""
|
|
if not match_ids:
|
|
return {}
|
|
rows = session.exec(
|
|
select(MatchParticipant, User, Faction)
|
|
.join(User, User.id == MatchParticipant.user_id)
|
|
.join(Faction, Faction.id == MatchParticipant.faction_id)
|
|
.where(MatchParticipant.match_id.in_(match_ids))
|
|
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
|
|
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
|
|
).all()
|
|
out: dict[int, list[dict]] = {}
|
|
for p, u, f in rows:
|
|
out.setdefault(p.match_id, []).append(_participant_row(p, u, f))
|
|
return out
|
|
|
|
|
|
def _match_items(session: Session, matches) -> list[dict]:
|
|
"""Элементы списка партий (общее для списка группы и истории игрока)."""
|
|
by_match = _participants_by_match(session, [m.id for m in matches])
|
|
|
|
items = []
|
|
for m in matches:
|
|
parts = by_match.get(m.id, [])
|
|
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
|
|
|
|
|
|
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
|
total = session.exec(
|
|
select(func.count()).select_from(Match).where(Match.group_id == group_id)
|
|
).one()
|
|
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()
|
|
return {
|
|
"items": _match_items(session, matches),
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
def user_match_list(
|
|
session: Session,
|
|
user_id: int,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
best_only: bool = False,
|
|
) -> dict:
|
|
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
|
|
|
|
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
|
|
место и размер стола); при равных очках берём более свежую."""
|
|
if best_only:
|
|
sql = f"""
|
|
{SCORED_CTE}
|
|
SELECT s.match_id AS match_id
|
|
FROM scored s
|
|
WHERE s.user_id = :uid
|
|
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
|
|
LIMIT 1
|
|
"""
|
|
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
|
|
matches = [session.get(Match, row["match_id"])] if row else []
|
|
return {
|
|
"items": _match_items(session, matches),
|
|
"total": len(matches),
|
|
"limit": 1,
|
|
"offset": 0,
|
|
}
|
|
|
|
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
|
|
total = session.exec(
|
|
select(func.count())
|
|
.select_from(Match)
|
|
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
|
.where(*where)
|
|
).one()
|
|
matches = session.exec(
|
|
select(Match)
|
|
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
|
.where(*where)
|
|
.order_by(Match.played_at.desc(), Match.id.desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
).all()
|
|
return {
|
|
"items": _match_items(session, matches),
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
|
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
|
|
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()
|
|
by_match = _participants_by_match(session, [m.id for m, _ in rows])
|
|
out = []
|
|
for m, gname in rows:
|
|
parts = by_match.get(m.id, [])
|
|
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)
|
|
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе.
|
|
rows = _user_scored_rows(session, user_id)
|
|
profile = profile_stats(session, user_id, group_id=None, rows=rows)
|
|
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_from_rows(_for_group(rows, 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,
|
|
}
|