Merge pull request 'Единый рейтинг вместо группового (#80)' (#81) from issue-80-single-rating into dev
Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
@@ -32,12 +32,15 @@ NEW_REASONS = (
|
||||
)
|
||||
|
||||
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
||||
# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта
|
||||
# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была.
|
||||
BACKFILL_LAST_STANDING = """
|
||||
UPDATE matches SET win_reason = 'last_standing'
|
||||
WHERE status = 'finished'
|
||||
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
||||
AND (SELECT COUNT(*) FROM match_participants mp
|
||||
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
|
||||
AND (SELECT COUNT(*) FROM match_participants mp WHERE mp.match_id = matches.id) >= 2
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -384,6 +384,9 @@ class LeaderboardEntry(OverallStats):
|
||||
nickname: str
|
||||
rank: int | None = None
|
||||
avatar_url: str | None = None
|
||||
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
|
||||
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
|
||||
rating_confirmed: bool = False
|
||||
|
||||
|
||||
class MatchHistory(BaseModel):
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
|
||||
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
|
||||
считаются игры, победы, среднее место и разбивки. Цепочек две: общая — по всем партиям
|
||||
приложения, групповая — только по партиям группы (это разные числа)."""
|
||||
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
|
||||
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
|
||||
винрейт и среднее место считает по партиям группы."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
@@ -25,25 +26,41 @@ from app.services.scoring import (
|
||||
)
|
||||
|
||||
|
||||
def load_history(session: Session, group_id: int | None = None) -> list[RatedMatch]:
|
||||
"""Завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
||||
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
|
||||
|
||||
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет."""
|
||||
|
||||
def _playable_match_ids():
|
||||
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
|
||||
|
||||
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
|
||||
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
|
||||
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
|
||||
return (
|
||||
select(MatchParticipant.match_id)
|
||||
.group_by(MatchParticipant.match_id)
|
||||
.having(func.count() >= MIN_PARTICIPANTS)
|
||||
)
|
||||
|
||||
|
||||
def load_history(session: Session) -> list[RatedMatch]:
|
||||
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
||||
|
||||
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
|
||||
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
|
||||
_for_group по этой же истории: рейтинг считается только целиком."""
|
||||
stmt = (
|
||||
select(Match, MatchParticipant)
|
||||
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||
.where(Match.status == "finished")
|
||||
.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
|
||||
)
|
||||
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:
|
||||
if current is not None and len(seats) >= MIN_PARTICIPANTS:
|
||||
history.append(
|
||||
RatedMatch(
|
||||
seats=tuple(seats),
|
||||
@@ -85,6 +102,11 @@ def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatc
|
||||
return [(m, s) for m in history for s in m.seats if s.user_id == user_id]
|
||||
|
||||
|
||||
def _rating_confirmed(rep: Replay, user_id: int) -> bool:
|
||||
"""Рейтинг подтверждён, когда за игроком MIN_GAMES партий во всём приложении."""
|
||||
return rep.games.get(user_id, 0) >= MIN_GAMES
|
||||
|
||||
|
||||
def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict:
|
||||
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
|
||||
|
||||
@@ -111,18 +133,21 @@ def leaderboard(
|
||||
rep: Replay | None = None,
|
||||
member_ids: set[int] | None = None,
|
||||
) -> dict:
|
||||
"""Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует).
|
||||
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и
|
||||
group_stats их переиспользуют).
|
||||
|
||||
member_ids — показывать только этих игроков (топ группы — её текущий состав, #76).
|
||||
Фильтруется вывод, а не расчёт: партии с ушедшим игроком остаются в цепочке и влияют
|
||||
на рейтинг оставшихся. Места нумеруются уже после фильтра."""
|
||||
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
|
||||
считается по всем партиям. С group_id игры, победы, винрейт и среднее место берутся
|
||||
только из партий группы (#80). member_ids — показывать только этих игроков (топ
|
||||
группы — её текущий состав, #76); места нумеруются уже после фильтра."""
|
||||
if history is None:
|
||||
history = load_history(session, group_id)
|
||||
history = load_history(session)
|
||||
if rep is None:
|
||||
rep = replay(history)
|
||||
shown = history if group_id is None else _for_group(history, group_id)
|
||||
|
||||
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
||||
for m in history:
|
||||
for m in shown:
|
||||
for s in m.seats:
|
||||
if member_ids is None or s.user_id in member_ids:
|
||||
by_user[s.user_id].append((m, s))
|
||||
@@ -141,11 +166,12 @@ def leaderboard(
|
||||
"nickname": u.nickname,
|
||||
**_summary(seats, rep.ratings[uid]),
|
||||
"rating": rep.ratings[uid], # только для сортировки
|
||||
"rating_confirmed": _rating_confirmed(rep, 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)
|
||||
qualified = sorted((r for r in rows if r["rating_confirmed"]), key=leaderboard_sort_key)
|
||||
provisional = sorted((r for r in rows if not r["rating_confirmed"]), key=leaderboard_sort_key)
|
||||
for i, r in enumerate(qualified, start=1):
|
||||
r["rank"] = i
|
||||
for r in provisional:
|
||||
@@ -233,17 +259,14 @@ def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
|
||||
def profile_stats(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
group_id: int | None = None,
|
||||
*,
|
||||
history: list[RatedMatch] | None = None,
|
||||
rep: Replay | None = None,
|
||||
) -> dict:
|
||||
"""Витрина профиля. history/rep — общая цепочка, уже посчитанная (home их переиспользует);
|
||||
с group_id считается групповая цепочка."""
|
||||
"""Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
|
||||
переиспользует)."""
|
||||
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)
|
||||
@@ -268,21 +291,23 @@ def profile_stats(
|
||||
|
||||
|
||||
def group_stats(session: Session, group_id: int) -> dict:
|
||||
history = load_history(session, group_id)
|
||||
history = load_history(session)
|
||||
rep = replay(history)
|
||||
group_history = _for_group(history, group_id)
|
||||
members = membership_service.list_members(session, group_id)
|
||||
# Список игроков группы — только её текущий состав: удалённый из группы в нём не висит.
|
||||
board = leaderboard(
|
||||
session,
|
||||
group_id,
|
||||
history=history,
|
||||
rep=replay(history),
|
||||
rep=rep,
|
||||
member_ids={u.id for _m, u in members}, # type: ignore[misc]
|
||||
)
|
||||
last_played = max((m.played_at for m in history), default=None)
|
||||
last_played = max((m.played_at for m in group_history), default=None)
|
||||
|
||||
games: dict[int, int] = defaultdict(int)
|
||||
wins: dict[int, int] = defaultdict(int)
|
||||
for m in history:
|
||||
for m in group_history:
|
||||
for s in m.seats:
|
||||
games[s.faction_id] += 1
|
||||
wins[s.faction_id] += s.place == 1
|
||||
@@ -302,7 +327,8 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
for f in factions
|
||||
]
|
||||
|
||||
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
|
||||
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
|
||||
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
|
||||
played_ids = {e["user_id"] for e in board["entries"]} | {
|
||||
e["user_id"] for e in board["provisional"]
|
||||
}
|
||||
@@ -310,6 +336,7 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
for _m, u in members:
|
||||
if u.id in played_ids:
|
||||
continue
|
||||
rating = rep.ratings.get(u.id) # type: ignore[arg-type]
|
||||
inactive.append(
|
||||
{
|
||||
"user_id": u.id,
|
||||
@@ -318,7 +345,8 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
"wins": 0,
|
||||
"win_rate": 0.0,
|
||||
"avg_place": None,
|
||||
"score": None,
|
||||
"score": None if rating is None else round(rating),
|
||||
"rating_confirmed": _rating_confirmed(rep, u.id), # type: ignore[arg-type]
|
||||
"rank": None,
|
||||
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||
}
|
||||
@@ -326,7 +354,7 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"total_matches": len(history),
|
||||
"total_matches": len(group_history),
|
||||
"last_match_at": str(last_played) if last_played else None,
|
||||
"leaderboard": board["entries"],
|
||||
"provisional": board["provisional"],
|
||||
@@ -400,12 +428,11 @@ def _match_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()
|
||||
where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
|
||||
total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
|
||||
matches = session.exec(
|
||||
select(Match)
|
||||
.where(Match.group_id == group_id)
|
||||
.where(*where)
|
||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -445,7 +472,11 @@ def user_match_list(
|
||||
"offset": 0,
|
||||
}
|
||||
|
||||
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
|
||||
where = (
|
||||
MatchParticipant.user_id == user_id,
|
||||
Match.status == "finished",
|
||||
Match.id.in_(_playable_match_ids()),
|
||||
)
|
||||
total = session.exec(
|
||||
select(func.count())
|
||||
.select_from(Match)
|
||||
@@ -499,8 +530,8 @@ 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:
|
||||
# История грузится и общая цепочка проигрывается один раз: из них и топ, и профиль;
|
||||
# итог по активной группе — отдельная (групповая) цепочка по срезу той же истории.
|
||||
# История грузится и проигрывается один раз: из неё и топ, и профиль, и блок активной
|
||||
# группы (там игры и победы по группе, рейтинг — общий).
|
||||
history = load_history(session)
|
||||
rep = replay(history)
|
||||
board = leaderboard(session, history=history, rep=rep)
|
||||
@@ -509,11 +540,10 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
|
||||
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(group_history, replay(group_history), user_id),
|
||||
**_overall(_for_group(history, active_group_id), rep, user_id),
|
||||
}
|
||||
return {
|
||||
"leaderboard": board["entries"][:leaderboard_limit],
|
||||
|
||||
@@ -380,5 +380,5 @@ def public_profile(session: Session, user_id: int) -> dict:
|
||||
"nickname": user.nickname,
|
||||
"bio": user.bio,
|
||||
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||
"stats": stats_service.profile_stats(session, user_id, group_id=None),
|
||||
"stats": stats_service.profile_stats(session, user_id),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Список игроков группы — только текущий состав (#76).
|
||||
|
||||
Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в
|
||||
групповой цепочке: они уже повлияли на рейтинг оставшихся."""
|
||||
истории: они уже повлияли на (общий) рейтинг оставшихся."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Рейтинг в витринах (#23): Elo по упорядоченной истории, общая и групповая цепочки."""
|
||||
"""Рейтинг в витринах (#23, #80): Elo по упорядоченной истории, один рейтинг на игрока."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -100,8 +100,9 @@ def test_editing_past_match_recalculates_later_ones(client: TestClient, engine):
|
||||
assert {uid: e["score"] for uid, e in _board(client).items()} == after
|
||||
|
||||
|
||||
def test_group_rating_is_its_own_chain(client: TestClient, engine):
|
||||
"""Групповой рейтинг проигрывает только партии группы, общий — все партии."""
|
||||
def test_group_page_shows_overall_rating_with_group_stats(client: TestClient, engine):
|
||||
"""Рейтинг один на всё приложение (#80): в группе он тот же, что в общем топе,
|
||||
а игры и победы — только по партиям группы."""
|
||||
me = login(client, "Путешественник")
|
||||
g1, f1 = _group(client, "Первая")
|
||||
g2, f2 = _group(client, "Вторая")
|
||||
@@ -110,16 +111,55 @@ def test_group_rating_is_its_own_chain(client: TestClient, engine):
|
||||
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
|
||||
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
|
||||
|
||||
assert _board(client, f"/api/groups/{g1}/stats")[me["id"]]["score"] == 1532
|
||||
assert _board(client, f"/api/groups/{g2}/stats")[me["id"]]["score"] == 1468
|
||||
overall = _board(client)[me["id"]]["score"]
|
||||
assert overall not in (1532, 1468)
|
||||
assert overall == round(
|
||||
overall = _board(client)[me["id"]]
|
||||
assert overall["games"] == 2
|
||||
assert overall["score"] == round(
|
||||
replay([
|
||||
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
|
||||
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
|
||||
]).ratings[me["id"]]
|
||||
)
|
||||
first = _board(client, f"/api/groups/{g1}/stats")[me["id"]]
|
||||
second = _board(client, f"/api/groups/{g2}/stats")[me["id"]]
|
||||
assert first["score"] == second["score"] == overall["score"]
|
||||
assert (first["games"], first["wins"]) == (1, 1)
|
||||
assert (second["games"], second["wins"]) == (1, 0)
|
||||
|
||||
# Главная и профиль — общие показатели; блок активной группы — игры в группе.
|
||||
client.put("/api/users/me/active-group", json={"group_id": g2}, headers=csrf_headers(client))
|
||||
home = client.get("/api/home").json()
|
||||
assert (home["profile"]["overall"]["games"], home["profile"]["overall"]["score"]) == (
|
||||
2, overall["score"]
|
||||
)
|
||||
assert (home["active_group"]["games"], home["active_group"]["score"]) == (1, overall["score"])
|
||||
|
||||
|
||||
def test_veteran_is_not_a_newcomer_in_new_group(client: TestClient, engine):
|
||||
"""Статус «Новичок» — про надёжность рейтинга, а он общий: 10 партий где угодно
|
||||
делают игрока ранжированным и в группе, где он сыграл одну."""
|
||||
me = login(client, "Ветеран")
|
||||
g1, f1 = _group(client, "Старая")
|
||||
b = add_group_member(engine, g1, "Спарринг")
|
||||
for _ in range(10):
|
||||
_duel(client, g1, f1, me["id"], b)
|
||||
|
||||
g2, f2 = _group(client, "Новая")
|
||||
c = add_group_member(engine, g2, "Новенький")
|
||||
add_group_member(engine, g2, "Спарринг") # опытный, но в новой группе не играл
|
||||
d = add_group_member(engine, g2, "Зритель") # не играл нигде
|
||||
_duel(client, g2, f2, me["id"], c)
|
||||
|
||||
stats = client.get(f"/api/groups/{g2}/stats").json()
|
||||
ranked = {e["user_id"]: e for e in stats["leaderboard"]}
|
||||
assert ranked[me["id"]]["rank"] == 1
|
||||
assert (ranked[me["id"]]["games"], ranked[me["id"]]["rating_confirmed"]) == (1, True)
|
||||
assert ranked[me["id"]]["score"] == _board(client)[me["id"]]["score"]
|
||||
assert [e["user_id"] for e in stats["provisional"]] == [c]
|
||||
|
||||
inactive = {e["user_id"]: e for e in stats["inactive"]}
|
||||
assert inactive[b]["score"] == _board(client)[b]["score"]
|
||||
assert (inactive[b]["games"], inactive[b]["rating_confirmed"]) == (0, True)
|
||||
assert (inactive[d]["score"], inactive[d]["rating_confirmed"]) == (None, False)
|
||||
|
||||
|
||||
def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
|
||||
@@ -140,6 +180,39 @@ def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
|
||||
assert data["items"][0]["id"] == first["id"]
|
||||
|
||||
|
||||
def test_match_left_with_one_participant_is_not_a_game(client: TestClient, engine, make_admin):
|
||||
"""Dev-удаление аккаунта вычёркивает игрока из партий, не трогая сами партии. Партия,
|
||||
где остался один участник, — не игра: её нет в рейтинге, историях и списках группы
|
||||
(карточка «с одним игроком» раньше висела в профиле)."""
|
||||
me = login(client, "Выживший")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Удалённый")
|
||||
c = add_group_member(engine, gid, "Соперник")
|
||||
orphan = _duel(client, gid, fids, me["id"], b)
|
||||
kept = _duel(client, gid, fids, c, me["id"])
|
||||
|
||||
make_admin("admin", "secret123")
|
||||
assert client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
).status_code == 200
|
||||
assert client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)).status_code == 200
|
||||
# Админка партию по-прежнему видит — удалить её можно оттуда.
|
||||
assert client.get(f"/api/admin/matches/{orphan['id']}").status_code == 200
|
||||
|
||||
history = client.get(f"/api/users/{me['id']}/matches").json()
|
||||
assert (history["total"], [i["id"] for i in history["items"]]) == (1, [kept["id"]])
|
||||
group_list = client.get(f"/api/groups/{gid}/matches").json()
|
||||
assert (group_list["total"], [i["id"] for i in group_list["items"]]) == (1, [kept["id"]])
|
||||
assert client.get(f"/api/groups/{gid}/stats").json()["total_matches"] == 1
|
||||
|
||||
# В рейтинге — только настоящая партия: дуэль новичков, проигрыш −32.
|
||||
board = _board(client)
|
||||
assert (board[me["id"]]["games"], board[me["id"]]["score"]) == (1, 1468)
|
||||
assert board[c]["score"] == 1532
|
||||
|
||||
|
||||
def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine):
|
||||
"""История игрока несёт изменение его общего рейтинга за каждую партию (#77)."""
|
||||
me = login(client, "Историк")
|
||||
|
||||
@@ -52,17 +52,17 @@ def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
|
||||
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
||||
)
|
||||
|
||||
calls: list[int | None] = []
|
||||
calls: list[int] = []
|
||||
original = stats_service.load_history
|
||||
|
||||
def spy(session, group_id=None):
|
||||
calls.append(group_id)
|
||||
return original(session, group_id)
|
||||
def spy(session):
|
||||
calls.append(1)
|
||||
return original(session)
|
||||
|
||||
monkeypatch.setattr(stats_service, "load_history", spy)
|
||||
r = client.get("/api/home")
|
||||
assert r.status_code == 200, r.text
|
||||
assert calls == [None]
|
||||
assert len(calls) == 1
|
||||
# Главная всё ещё показывает и профиль, и блок активной группы.
|
||||
body = r.json()
|
||||
assert body["profile"]["overall"]["games"] == 2
|
||||
|
||||
@@ -670,8 +670,11 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
||||
завершённых партий по порядку (`played_at`, `finished_at`, `id`), а не агрегатом SQL.
|
||||
Данных мало: сотни партий, микросекунды на пару. Существующий принцип «считается вживую»
|
||||
сохраняется, кэш можно ввести позже с инвалидацией по уже существующим SSE-событиям.
|
||||
- **Две цепочки:** общий рейтинг — по всем партиям приложения, групповой — по партиям
|
||||
группы. Это разные числа, как и сейчас.
|
||||
- **Одна цепочка** (решение владельца 2026-09-15, #80): рейтинг у игрока один — по всем
|
||||
партиям приложения, K — по всем его партиям. Отдельного группового рейтинга нет:
|
||||
на странице группы игры, победы, винрейт и среднее место считаются по партиям группы,
|
||||
а рейтинг и статус «Новичок» — общие. Первая версия реализации (#23) держала две
|
||||
цепочки, общую и групповую, — это оказалось неинтуитивно (раздел 9).
|
||||
- Правка или удаление прошлой партии автоматически меняет всё после неё: при пересчёте
|
||||
с нуля отдельной логики не нужно.
|
||||
- Эталон — `rate_match` в `simulate.py`. Примеры из раздела 6 стоит перенести в тесты
|
||||
@@ -728,6 +731,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
||||
| 4 | Ввод миров на конец партии | **Оставить** | без изменений: `w_w = 0.5` |
|
||||
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
||||
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
||||
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
|
||||
|
||||
Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных —
|
||||
после внедрения #23 (раздел 8, «Калибровка»).
|
||||
|
||||
Vendored
+5
@@ -1724,6 +1724,11 @@ export interface components {
|
||||
rank?: number | null;
|
||||
/** Avatar Url */
|
||||
avatar_url?: string | null;
|
||||
/**
|
||||
* Rating Confirmed
|
||||
* @default false
|
||||
*/
|
||||
rating_confirmed: boolean;
|
||||
};
|
||||
/** MatchCreate */
|
||||
MatchCreate: {
|
||||
|
||||
@@ -30,8 +30,14 @@ function Row({
|
||||
</div>
|
||||
</div>
|
||||
{/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только
|
||||
подтверждённый рейтинг. */}
|
||||
<div className={"lb-score" + (provisional || entry.score == null ? " provisional" : "")}>
|
||||
подтверждённый рейтинг. Рейтинг общий, поэтому подтверждённость приходит
|
||||
флагом: в группе «Ещё не играли» бывают и новички, и опытные игроки. */}
|
||||
<div
|
||||
className={
|
||||
"lb-score" +
|
||||
(provisional || entry.score == null || !entry.rating_confirmed ? " provisional" : "")
|
||||
}
|
||||
>
|
||||
{entry.score ?? "—"}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -135,10 +135,12 @@ export function HelpPage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Общий и групповой рейтинг</h3>
|
||||
<h3>Один рейтинг на всё приложение</h3>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
Общий рейтинг считается по всем партиям приложения, групповой — только по партиям
|
||||
группы. Поэтому числа в общем топе и в топе группы различаются.
|
||||
Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый
|
||||
в общем топе, в профиле и на странице группы. На странице группы по партиям этой
|
||||
группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус
|
||||
«Новичок» там общие.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function MatchDetailPage() {
|
||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||
const parsed = matchId ? Number(matchId) : NaN;
|
||||
const id = Number.isInteger(parsed) ? parsed : null;
|
||||
const { data: match, isLoading, refetch } = useMatch(id);
|
||||
const { data: match, isLoading, error: loadError, refetch } = useMatch(id);
|
||||
const { data: me } = useMe();
|
||||
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||
const finish = useFinishMatch();
|
||||
@@ -132,7 +132,19 @@ export function MatchDetailPage() {
|
||||
}, [incoming, participants, match?.status, me?.id]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
if (!match) {
|
||||
// Из истории чужого профиля можно попасть в партию группы, где зритель не состоит:
|
||||
// сервер отдаёт 403, и «не найдена» здесь вводила бы в заблуждение.
|
||||
if (loadError instanceof ApiError && loadError.code === "NOT_GROUP_MEMBER") {
|
||||
return (
|
||||
<div className="muted">
|
||||
Партия сыграна в группе, в которой вы не состоите, — открыть её могут только
|
||||
участники группы.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="muted">Партия не найдена.</div>;
|
||||
}
|
||||
|
||||
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
|
||||
const inProgress = match.status === "in_progress";
|
||||
|
||||
Reference in New Issue
Block a user