Группа: в рейтинге группы только текущие участники
Удалённый из группы игрок больше не остаётся в «Списке игроков»: leaderboard получает набор текущих участников, и group_stats фильтрует по нему вывод до сортировки, так что места нумеруются без пропусков. Расчёт не меняется — групповая цепочка проигрывает все партии группы, и партии с ушедшим по-прежнему влияют на рейтинг оставшихся; счётчик партий и статистика фракций группы тоже прежние. Общий топ и главная без изменений. После удаления участника фронт сразу перезапрашивает статистику группы. Тесты: удалённый пропадает из группы, но остаётся в общем топе, рейтинг оставшегося не меняется, после возврата игрок снова в списке; ранги после удаления перенумерованы. #76 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
@@ -109,8 +109,13 @@ def leaderboard(
|
|||||||
*,
|
*,
|
||||||
history: list[RatedMatch] | None = None,
|
history: list[RatedMatch] | None = None,
|
||||||
rep: Replay | None = None,
|
rep: Replay | None = None,
|
||||||
|
member_ids: set[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует)."""
|
"""Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует).
|
||||||
|
|
||||||
|
member_ids — показывать только этих игроков (топ группы — её текущий состав, #76).
|
||||||
|
Фильтруется вывод, а не расчёт: партии с ушедшим игроком остаются в цепочке и влияют
|
||||||
|
на рейтинг оставшихся. Места нумеруются уже после фильтра."""
|
||||||
if history is None:
|
if history is None:
|
||||||
history = load_history(session, group_id)
|
history = load_history(session, group_id)
|
||||||
if rep is None:
|
if rep is None:
|
||||||
@@ -119,6 +124,7 @@ def leaderboard(
|
|||||||
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
|
||||||
for m in history:
|
for m in history:
|
||||||
for s in m.seats:
|
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))
|
by_user[s.user_id].append((m, s))
|
||||||
users = (
|
users = (
|
||||||
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
|
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
|
||||||
@@ -263,7 +269,15 @@ def profile_stats(
|
|||||||
|
|
||||||
def group_stats(session: Session, group_id: int) -> dict:
|
def group_stats(session: Session, group_id: int) -> dict:
|
||||||
history = load_history(session, group_id)
|
history = load_history(session, group_id)
|
||||||
board = leaderboard(session, group_id, history=history, rep=replay(history))
|
members = membership_service.list_members(session, group_id)
|
||||||
|
# Список игроков группы — только её текущий состав: удалённый из группы в нём не висит.
|
||||||
|
board = leaderboard(
|
||||||
|
session,
|
||||||
|
group_id,
|
||||||
|
history=history,
|
||||||
|
rep=replay(history),
|
||||||
|
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 history), default=None)
|
||||||
|
|
||||||
games: dict[int, int] = defaultdict(int)
|
games: dict[int, int] = defaultdict(int)
|
||||||
@@ -293,7 +307,7 @@ def group_stats(session: Session, group_id: int) -> dict:
|
|||||||
e["user_id"] for e in board["provisional"]
|
e["user_id"] for e in board["provisional"]
|
||||||
}
|
}
|
||||||
inactive = []
|
inactive = []
|
||||||
for _m, u in membership_service.list_members(session, group_id):
|
for _m, u in members:
|
||||||
if u.id in played_ids:
|
if u.id in played_ids:
|
||||||
continue
|
continue
|
||||||
inactive.append(
|
inactive.append(
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Список игроков группы — только текущий состав (#76).
|
||||||
|
|
||||||
|
Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в
|
||||||
|
групповой цепочке: они уже повлияли на рейтинг оставшихся."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(stats: dict) -> dict[str, set[int]]:
|
||||||
|
return {
|
||||||
|
block: {e["user_id"] for e in stats[block]}
|
||||||
|
for block in ("leaderboard", "provisional", "inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _group(client: TestClient) -> tuple[int, list[int]]:
|
||||||
|
gid = client.post(
|
||||||
|
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
return gid, fids
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(client: TestClient, gid: int, uid: int) -> None:
|
||||||
|
r = client.delete(f"/api/groups/{gid}/members/{uid}", headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_member_leaves_group_rating_but_not_overall(client: TestClient, engine):
|
||||||
|
me = login(client, "Хозяин")
|
||||||
|
gid, fids = _group(client)
|
||||||
|
b = add_group_member(engine, gid, "Ушедший")
|
||||||
|
create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": fids[1], "place": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
before = client.get(f"/api/groups/{gid}/stats").json()
|
||||||
|
my_score = next(e["score"] for e in before["provisional"] if e["user_id"] == me["id"])
|
||||||
|
|
||||||
|
_remove(client, gid, b)
|
||||||
|
|
||||||
|
stats = client.get(f"/api/groups/{gid}/stats").json()
|
||||||
|
ids = _ids(stats)
|
||||||
|
assert all(b not in block for block in ids.values())
|
||||||
|
assert me["id"] in ids["provisional"]
|
||||||
|
# Партия с ушедшим учтена: рейтинг оставшегося не изменился, счётчик партий тоже.
|
||||||
|
assert next(e["score"] for e in stats["provisional"] if e["user_id"] == me["id"]) == my_score
|
||||||
|
assert stats["total_matches"] == 1
|
||||||
|
|
||||||
|
board = client.get("/api/stats/leaderboard").json()
|
||||||
|
assert b in {e["user_id"] for e in board["entries"] + board["provisional"]}
|
||||||
|
|
||||||
|
# Вернули в группу — снова в списке со своей историей.
|
||||||
|
add_group_member(engine, gid, "Ушедший")
|
||||||
|
back = client.get(f"/api/groups/{gid}/stats").json()
|
||||||
|
entry = next(e for e in back["provisional"] if e["user_id"] == b)
|
||||||
|
assert (entry["games"], entry["score"]) == (1, 1468)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_ranks_renumbered_after_removal(client: TestClient, engine):
|
||||||
|
me = login(client, "Первый")
|
||||||
|
gid, fids = _group(client)
|
||||||
|
b = add_group_member(engine, gid, "Второй")
|
||||||
|
c = add_group_member(engine, gid, "Третий")
|
||||||
|
for _ in range(10):
|
||||||
|
create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
|
||||||
|
{"user_id": b, "faction_id": fids[1], "place": 2},
|
||||||
|
{"user_id": c, "faction_id": fids[2], "place": 3},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert [e["rank"] for e in client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]] == [1, 2, 3]
|
||||||
|
|
||||||
|
_remove(client, gid, b)
|
||||||
|
|
||||||
|
board = client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]
|
||||||
|
assert [(e["user_id"], e["rank"]) for e in board] == [(me["id"], 1), (c, 2)]
|
||||||
@@ -138,6 +138,8 @@ export function useRemoveMember(groupId: number) {
|
|||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
|
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
|
||||||
|
// Список игроков группы показывает только текущий состав — удалённый из него уходит.
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groupStats(groupId) });
|
||||||
qc.invalidateQueries({ queryKey: qk.me });
|
qc.invalidateQueries({ queryKey: qk.me });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user