Партии: «выбывшие» игроки (красный крестик) = последнее место

Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему
авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг
eliminated для отрисовки красным крестиком. В результатах и списке партий
выбывшие показываются крестиком и идут в конец. Подсчёт очков не меняется
(используются обычные числовые места).

Бэкенд: модель MatchParticipant.eliminated + миграция 0009 (идемпотентная);
схемы (place стал необязательным, +eliminated); match_service (хелпер
_resolve_finish_places, finish/update); проброс eliminated в роутерах и stats.
Фронт: выбор места с крестиком в MatchDetailPage и AdminMatchEdit, отрисовка в
результатах и MatchList, стиль .place-badge.eliminated; регенерация schema.d.ts.
Тесты: завершение с выбывшим (последнее место, засчитан), все-выбывшие → 422.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 16:54:37 +03:00
co-authored by Claude Opus 4.8
parent cd7e5d16b4
commit 1438a17441
13 changed files with 226 additions and 17 deletions
@@ -0,0 +1,40 @@
"""Участник партии: признак выбытия (eliminated).
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) → no-op;
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
Revision ID: 0009_participant_eliminated
Revises: 0008_notifications
Create Date: 2026-06-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0009_participant_eliminated"
down_revision: Union[str, None] = "0008_notifications"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("match_participants")}
if "eliminated" not in cols:
with op.batch_alter_table("match_participants") as b:
b.add_column(
sa.Column("eliminated", sa.Boolean(), nullable=False, server_default="0")
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("match_participants")}
if "eliminated" in cols:
with op.batch_alter_table("match_participants") as b:
b.drop_column("eliminated")
+2
View File
@@ -305,6 +305,8 @@ class MatchParticipant(SQLModel, table=True):
)
)
place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Выбыл из партии: place авто = последнее место (отображается красным крестиком).
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
+1
View File
@@ -216,6 +216,7 @@ def update_match(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
+9 -1
View File
@@ -53,6 +53,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
faction_id=f.id, # type: ignore[arg-type]
faction_name=f.name_ru,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
@@ -138,7 +139,13 @@ def finish_match(
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
finish = [
FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id)
FinishInput(
user_id=p.user_id,
place=p.place,
eliminated=p.eliminated,
comment=p.comment,
faction_id=p.faction_id,
)
for p in body.participants
]
match = match_service.finish_match(
@@ -194,6 +201,7 @@ def update_match(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
+6 -2
View File
@@ -204,7 +204,8 @@ class MatchCreate(BaseModel):
# Этап 2 (завершение): места, комментарии, причина победы.
class MatchFinishParticipant(BaseModel):
user_id: int
place: int = Field(ge=1)
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении
@@ -221,7 +222,8 @@ class MatchFinish(BaseModel):
class ParticipantInput(BaseModel):
user_id: int
faction_id: int
place: int = Field(ge=1)
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
eliminated: bool = False
was_random: bool = False
comment: str | None = None
@@ -240,6 +242,7 @@ class MatchParticipantRead(BaseModel):
faction_id: int
faction_name: str
place: int | None = None
eliminated: bool = False
was_random: bool
comment: str | None = None
@@ -357,6 +360,7 @@ class MatchListParticipant(BaseModel):
faction_id: int
faction_name: str
place: int | None = None
eliminated: bool = False
was_random: bool
comment: str | None = None
+28 -6
View File
@@ -38,7 +38,8 @@ class FinishInput:
"""Результат участника на этапе завершения."""
user_id: int
place: int
place: int | None = None # None у выбывшего (eliminated) — место проставится авто
eliminated: bool = False
comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении
@@ -49,7 +50,8 @@ class ParticipantInput:
user_id: int
faction_id: int
place: int
place: int | None = None
eliminated: bool = False
was_random: bool = False
comment: str | None = None
@@ -129,6 +131,22 @@ def _validate_ranking(places: list[int]) -> None:
expected = current + tie
def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int, int]:
"""rows: (user_id, place, eliminated) → {user_id: место}.
Выжившим (не eliminated) валидируем competition ranking (1,2,2,4). Выбывшим
проставляем общее последнее место = len(survivors)+1 (ничья последнего ранга),
чтобы подсчёт очков считал их как последнее место без отдельных правок SQL."""
survivors = [(uid, place) for uid, place, elim in rows if not elim]
if not survivors:
raise InvalidRankingError("Хотя бы один игрок должен остаться в партии (с местом).")
if any(place is None for _uid, place in survivors):
raise InvalidRankingError("Укажите место каждому не выбывшему игроку.")
_validate_ranking([place for _uid, place in survivors]) # type: ignore[misc]
elim_place = len(survivors) + 1
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
def _group_member_ids(session: Session, group_id: int) -> set[int]:
return {
m.user_id
@@ -235,11 +253,12 @@ def finish_match(
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
raise FactionNotAvailableError()
_validate_ranking([f.place for f in finish])
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
for f in finish:
p = existing[f.user_id]
p.place = f.place
p.place = places[f.user_id]
p.eliminated = f.eliminated
p.comment = f.comment or None
if f.faction_id is not None:
p.faction_id = f.faction_id
@@ -310,7 +329,9 @@ def update_match(
[p.user_id for p in participants],
[p.faction_id for p in participants],
)
_validate_ranking([p.place for p in participants])
places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants]
)
for old in session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all():
@@ -322,7 +343,8 @@ def update_match(
match_id=match.id, # type: ignore[arg-type]
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
place=places[p.user_id],
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment or None,
)
+2
View File
@@ -297,6 +297,7 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
@@ -343,6 +344,7 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
+71
View File
@@ -0,0 +1,71 @@
"""Выбывшие игроки: красный крестик = последнее место (засчитывается, без отд. «проигрыша»)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _group(client: TestClient, name: str = "Гр") -> int:
return client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
def _start3(client: TestClient, engine):
me = login(client, "Капитан")
gid = _group(client)
b = add_group_member(engine, gid, "Боец")
c = add_group_member(engine, gid, "Третий")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0]},
{"user_id": b, "faction_id": fids[1]},
{"user_id": c, "faction_id": fids[2]},
],
)
assert started.status_code == 200, started.text
return me, b, c, gid, started.json()["id"]
def test_eliminated_gets_last_place(client: TestClient, engine):
me, b, c, _gid, mid = _start3(client, engine)
r = finish_match(
client,
mid,
[
{"user_id": me["id"], "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "eliminated": True}, # выбыл
],
)
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert parts[c]["eliminated"] is True
assert parts[c]["place"] == 3 # survivors=2 → последнее место 3
assert parts[me["id"]]["eliminated"] is False
# Выбывший — в конце списка результатов.
assert r.json()["participants"][-1]["user_id"] == c
# Партия засчитана выбывшему как сыгранная (последнее место, 0 очков).
login(client, "Третий")
stats = client.get("/api/users/me/stats").json()
assert stats["overall"]["games"] == 1
assert stats["overall"]["wins"] == 0
def test_all_eliminated_rejected(client: TestClient, engine):
me, b, c, _gid, mid = _start3(client, engine)
r = finish_match(
client,
mid,
[
{"user_id": me["id"], "eliminated": True},
{"user_id": b, "eliminated": True},
{"user_id": c, "eliminated": True},
],
)
assert r.status_code == 422, r.text # должен остаться хотя бы один с местом