Единый рейтинг вместо группового (#80) #81
@@ -32,12 +32,15 @@ NEW_REASONS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
||||||
|
# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта
|
||||||
|
# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была.
|
||||||
BACKFILL_LAST_STANDING = """
|
BACKFILL_LAST_STANDING = """
|
||||||
UPDATE matches SET win_reason = 'last_standing'
|
UPDATE matches SET win_reason = 'last_standing'
|
||||||
WHERE status = 'finished'
|
WHERE status = 'finished'
|
||||||
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
||||||
AND (SELECT COUNT(*) FROM match_participants mp
|
AND (SELECT COUNT(*) FROM match_participants mp
|
||||||
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
|
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,28 @@ from app.services.scoring import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
def load_history(session: Session) -> list[RatedMatch]:
|
||||||
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
||||||
|
|
||||||
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет.
|
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
|
||||||
Срез группы — _for_group по этой же истории: рейтинг считается только целиком."""
|
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
|
||||||
|
_for_group по этой же истории: рейтинг считается только целиком."""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Match, MatchParticipant)
|
select(Match, MatchParticipant)
|
||||||
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
|
||||||
@@ -43,7 +60,7 @@ def load_history(session: Session) -> list[RatedMatch]:
|
|||||||
seats: list[RatedSeat] = []
|
seats: list[RatedSeat] = []
|
||||||
|
|
||||||
def flush() -> None:
|
def flush() -> None:
|
||||||
if current is not None and seats:
|
if current is not None and len(seats) >= MIN_PARTICIPANTS:
|
||||||
history.append(
|
history.append(
|
||||||
RatedMatch(
|
RatedMatch(
|
||||||
seats=tuple(seats),
|
seats=tuple(seats),
|
||||||
@@ -411,12 +428,11 @@ def _match_items(
|
|||||||
|
|
||||||
|
|
||||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||||
total = session.exec(
|
where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
|
||||||
select(func.count()).select_from(Match).where(Match.group_id == group_id)
|
total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
|
||||||
).one()
|
|
||||||
matches = session.exec(
|
matches = session.exec(
|
||||||
select(Match)
|
select(Match)
|
||||||
.where(Match.group_id == group_id)
|
.where(*where)
|
||||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -456,7 +472,11 @@ def user_match_list(
|
|||||||
"offset": 0,
|
"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(
|
total = session.exec(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Match)
|
.select_from(Match)
|
||||||
|
|||||||
@@ -180,6 +180,39 @@ def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
|
|||||||
assert data["items"][0]["id"] == first["id"]
|
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):
|
def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine):
|
||||||
"""История игрока несёт изменение его общего рейтинга за каждую партию (#77)."""
|
"""История игрока несёт изменение его общего рейтинга за каждую партию (#77)."""
|
||||||
me = login(client, "Историк")
|
me = login(client, "Историк")
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function MatchDetailPage() {
|
|||||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||||
const parsed = matchId ? Number(matchId) : NaN;
|
const parsed = matchId ? Number(matchId) : NaN;
|
||||||
const id = Number.isInteger(parsed) ? parsed : null;
|
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: me } = useMe();
|
||||||
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||||
const finish = useFinishMatch();
|
const finish = useFinishMatch();
|
||||||
@@ -132,7 +132,19 @@ export function MatchDetailPage() {
|
|||||||
}, [incoming, participants, match?.status, me?.id]);
|
}, [incoming, participants, match?.status, me?.id]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
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 canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
|
||||||
const inProgress = match.status === "in_progress";
|
const inProgress = match.status === "in_progress";
|
||||||
|
|||||||
Reference in New Issue
Block a user