Рейтинг: витрины статистики на проигрывании истории

SQL-агрегат League Points (SCORED_CTE, сглаженное среднее) заменён
проигрыванием завершённых партий по порядку played_at, finished_at, id:
load_history — один запрос на партии с участниками, scoring.replay считает
рейтинг. Цепочки две: общая (все партии) и групповая (только партии группы,
K — по партиям внутри группы). Правка или удаление прошлой партии
пересчитывает всё после неё без отдельной логики.

Топ, профиль, главная и статистика группы берут игры, победы, винрейт
и среднее место из той же истории; score — рейтинг целым числом, сортировка
по неокруглённому. Главная грузит историю один раз. Лучшая партия — наибольший
ΔR в общей цепочке, лучшая/худшая фракция — средний S − E. League Points
из scoring.py удалён.

Тесты: дуэль новичков ±32, ранжирование после 10 партий, пересчёт после
правки прошлой партии, отдельная групповая цепочка, лучшая партия по ΔR,
одна загрузка истории на главной. #23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-14 22:38:57 +03:00
co-authored by Claude Opus 5
parent 8aa6bb7235
commit 6ea4a67672
7 changed files with 380 additions and 291 deletions
+4 -1
View File
@@ -375,7 +375,8 @@ class OverallStats(BaseModel):
wins: int
win_rate: float
avg_place: float | None = None
score: float | None = None
# Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
score: int | None = None
class LeaderboardEntry(OverallStats):
@@ -416,6 +417,8 @@ class FactionStat(BaseModel):
wins: int
win_rate: float
avg_place: float | None = None
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
score: float | None = None
+5 -25
View File
@@ -203,33 +203,13 @@ def replay(matches: Iterable[RatedMatch]) -> Replay:
return out
# ─── League Points (прежняя система; уходит вместе с SQL-витринами) ──────────
PRIOR_GAMES = 10
PRIOR_MEAN = 0.5
SMOOTHED_SCORE_SQL = (
f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100"
)
MATCH_POINTS_SQL = (
"CASE WHEN m.player_count > 1 "
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
"/ (m.player_count - 1) "
"ELSE 1.0 END"
)
def smoothed_score(points_sum: float, games: int) -> float | None:
if games <= 0:
return None
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
def leaderboard_sort_key(row: dict) -> tuple:
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
"""Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
всё равно упорядочены по настоящему значению."""
return (
-(row["score"] or 0.0),
-(row["rating"] or 0.0),
-(row["win_rate"] or 0.0),
-(row["games"] or 0),
(row["avg_place"] or 0.0),
+213 -198
View File
@@ -1,105 +1,151 @@
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
считаются игры, победы, среднее место и разбивки. Цепочек две: общая — по всем партиям
приложения, групповая — только по партиям группы (это разные числа)."""
from __future__ import annotations
from typing import Any
from collections import defaultdict
from sqlalchemy import func, text
from sqlalchemy import func
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
from app.models import Expansion, 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,
RatedMatch,
RatedSeat,
Replay,
leaderboard_sort_key,
smoothed_score,
replay,
)
# Базовый блок: одна строка на участие с 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 load_history(session: Session, group_id: int | None = None) -> list[RatedMatch]:
"""Завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет."""
stmt = (
select(Match, MatchParticipant)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(Match.status == "finished")
)
if group_id is not None:
stmt = stmt.where(Match.group_id == group_id)
stmt = stmt.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
history: list[RatedMatch] = []
current: Match | None = None
seats: list[RatedSeat] = []
def flush() -> None:
if current is not None and seats:
history.append(
RatedMatch(
seats=tuple(seats),
win_reason=current.win_reason,
end_round=current.end_round,
nine_rounds_rule=current.nine_rounds_rule,
id=current.id, # type: ignore[arg-type]
group_id=current.group_id,
played_at=current.played_at,
finished_at=current.finished_at,
)
)
for m, p in session.exec(stmt).all():
if current is None or m.id != current.id:
flush()
current, seats = m, []
if p.place is None: # у завершённой партии мест без значения не бывает
continue
seats.append(
RatedSeat(
user_id=p.user_id,
place=p.place,
faction_id=p.faction_id,
eliminated=p.eliminated,
objectives=p.objectives,
worlds=p.worlds,
)
)
flush()
return history
def _round(value: Any, ndigits: int) -> float | None:
return None if value is None else round(float(value), ndigits)
def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]:
return [m for m in history if m.group_id == group_id]
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)}"
def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]:
return [(m, s) for m in history for s in m.seats if s.user_id == user_id]
def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict:
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
В расчёте рейтинг без округления (иначе ошибка копилась бы по цепочке партий),
показывается — целым."""
games = len(seats)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(1 for _m, s in seats if s.place == 1)
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,
"games": games,
"wins": wins,
"win_rate": round(wins / games, 4),
"avg_place": round(sum(s.place for _m, s in seats) / games, 2),
"score": None if rating is None else round(rating),
}
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,
*,
history: list[RatedMatch] | None = None,
rep: Replay | None = None,
) -> dict:
"""Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует)."""
if history is None:
history = load_history(session, group_id)
if rep is None:
rep = replay(history)
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
for m in history:
for s in m.seats:
by_user[s.user_id].append((m, s))
users = (
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
if by_user
else {}
)
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)
rows = []
for uid, seats in by_user.items():
u = users[uid]
rows.append(
{
"user_id": uid,
"nickname": u.nickname,
**_summary(seats, rep.ratings[uid]),
"rating": rep.ratings[uid], # только для сортировки
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
}
)
qualified = sorted((r for r in rows if r["games"] >= MIN_GAMES), key=leaderboard_sort_key)
provisional = sorted((r for r in rows if r["games"] < MIN_GAMES), key=leaderboard_sort_key)
for i, r in enumerate(qualified, start=1):
r["rank"] = i
for r in provisional:
r["rank"] = None
for r in rows:
del r["rating"]
return {
"entries": qualified,
"provisional": provisional,
@@ -107,72 +153,38 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict:
}
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(
session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int
) -> list[dict]:
by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
for m, s in seats:
by_faction[s.faction_id].append((m, s))
if not by_faction:
return []
meta = {
f.id: (f, code)
for f, code in session.exec(
select(Faction, Expansion.code)
.join(Expansion, Expansion.id == Faction.expansion_id)
.where(Faction.id.in_(list(by_faction)))
).all()
}
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]
faction, expansion_code = meta[fid]
perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
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),
"code": faction.code,
"name_ru": faction.name_ru,
"expansion_code": expansion_code,
**{k: v for k, v in _summary(group, None).items() if k != "score"},
# Средний результат относительно ожидания (S − E) × 100: насколько игрок
# на фракции выступает выше рейтинговых шансов — без привязки к рейтингу.
"score": round(perf * 100, 1),
"name_ru_prepositional": faction_service.prepositional(
meta["code"], meta["name_ru"]
faction.code, faction.name_ru
),
}
)
@@ -180,15 +192,15 @@ def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
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)
def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]:
recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True)
return [
{
"place": r["place"],
"player_count": r["player_count"],
"played_at": str(r["played_at"]),
"place": s.place,
"player_count": len(m.seats),
"played_at": str(m.played_at),
}
for r in recent[:limit]
for m, s in recent[:limit]
]
@@ -208,69 +220,73 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None:
}
def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
return _summary(_user_seats(history, user_id), rep.ratings.get(user_id))
def profile_stats(
session: Session,
user_id: int,
group_id: int | None = None,
*,
rows: list[dict] | None = None,
history: list[RatedMatch] | None = None,
rep: Replay | 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)
"""Витрина профиля. history/rep — общая цепочка, уже посчитанная (home их переиспользует);
с group_id считается групповая цепочка."""
if history is None:
history = load_history(session)
if group_id is not None:
history, rep = _for_group(history, group_id), None
if rep is None:
rep = replay(history)
seats = _user_seats(history, user_id)
factions = _faction_breakdown(session, seats, rep, user_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
best = max(qualified, key=lambda f: f["score"]) if qualified else None
worst = min(qualified, key=lambda f: f["score"]) if qualified else None
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
# рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None
return {
"user_id": user_id,
"overall": overall,
"overall": _summary(seats, rep.ratings.get(user_id)),
"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),
"recent_form": _recent_form(seats),
"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
history = load_history(session, group_id)
board = leaderboard(session, group_id, history=history, rep=replay(history))
last_played = max((m.played_at for m in history), default=None)
games: dict[int, int] = defaultdict(int)
wins: dict[int, int] = defaultdict(int)
for m in history:
for s in m.seats:
games[s.faction_id] += 1
wins[s.faction_id] += s.place == 1
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,
}
)
factions = sorted(
session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index]
)
faction_meta = [
{
"faction_id": f.id,
"code": f.code,
"name_ru": f.name_ru,
"games": games[f.id], # type: ignore[index]
"wins": wins[f.id], # type: ignore[index]
"available": f.id in available_ids,
}
for f in factions
]
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
played_ids = {e["user_id"] for e in board["entries"]} | {
@@ -296,8 +312,8 @@ def group_stats(session: Session, group_id: int) -> dict:
return {
"group_id": group_id,
"total_matches": games_count,
"last_match_at": last_at,
"total_matches": len(history),
"last_match_at": str(last_played) if last_played else None,
"leaderboard": board["entries"],
"provisional": board["provisional"],
"inactive": inactive,
@@ -392,19 +408,15 @@ def user_match_list(
) -> dict:
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
место и размер стола); при равных очках берём более свежую."""
best_only — одна лучшая партия: наибольший прирост рейтинга в общей цепочке
(учитывает и соперников, и ход партии); при равенстве берём более свежую."""
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 []
history = load_history(session)
rep = replay(history)
candidates = [
(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in _user_seats(history, user_id)
]
matches = [session.get(Match, max(candidates)[2])] if candidates else []
return {
"items": _match_items(session, matches),
"total": len(matches),
@@ -466,18 +478,21 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
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)
# История грузится и общая цепочка проигрывается один раз: из них и топ, и профиль;
# итог по активной группе — отдельная (групповая) цепочка по срезу той же истории.
history = load_history(session)
rep = replay(history)
board = leaderboard(session, history=history, rep=rep)
profile = profile_stats(session, user_id, history=history, rep=rep)
active_group_brief = None
if active_group_id is not None:
group = session.get(Group, active_group_id)
if group is not None:
group_history = _for_group(history, active_group_id)
active_group_brief = {
"id": group.id,
"name": group.name,
**_overall_from_rows(_for_group(rows, active_group_id)),
**_overall(group_history, replay(group_history), user_id),
}
return {
"leaderboard": board["entries"][:leaderboard_limit],