diff --git a/backend/alembic/versions/0009_participant_eliminated.py b/backend/alembic/versions/0009_participant_eliminated.py new file mode 100644 index 0000000..4ca5031 --- /dev/null +++ b/backend/alembic/versions/0009_participant_eliminated.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index dfb169a..adb9f01 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 09d2087..44b889f 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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, ) diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 62a048f..719efd6 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -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, ) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index f14c142..d602f12 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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 diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index f522670..00ead8e 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -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, ) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 008c320..a50e03b 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -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, } diff --git a/backend/tests/test_eliminated.py b/backend/tests/test_eliminated.py new file mode 100644 index 0000000..3109121 --- /dev/null +++ b/backend/tests/test_eliminated.py @@ -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 # должен остаться хотя бы один с местом diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index b1876ce..8ec6f32 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1566,7 +1566,12 @@ export interface components { /** User Id */ user_id: number; /** Place */ - place: number; + place?: number | null; + /** + * Eliminated + * @default false + */ + eliminated: boolean; /** Comment */ comment?: string | null; /** Faction Id */ @@ -1626,6 +1631,11 @@ export interface components { faction_name: string; /** Place */ place?: number | null; + /** + * Eliminated + * @default false + */ + eliminated: boolean; /** Was Random */ was_random: boolean; /** Comment */ @@ -1643,6 +1653,11 @@ export interface components { faction_name: string; /** Place */ place?: number | null; + /** + * Eliminated + * @default false + */ + eliminated: boolean; /** Was Random */ was_random: boolean; /** Comment */ @@ -1816,7 +1831,12 @@ export interface components { /** Faction Id */ faction_id: number; /** Place */ - place: number; + place?: number | null; + /** + * Eliminated + * @default false + */ + eliminated: boolean; /** * Was Random * @default false diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index 3b9493e..503e553 100644 --- a/frontend/src/components/MatchList.tsx +++ b/frontend/src/components/MatchList.tsx @@ -33,8 +33,14 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
{sorted.map((p) => (
- - {p.place ?? "—"} + + {p.eliminated ? "✕" : (p.place ?? "—")} {p.nickname} · {p.faction_name} diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index e72d97d..32323a9 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -20,6 +20,7 @@ interface FinishRow { nickname: string; faction_name: string; place: number; + eliminated: boolean; comment: string; } @@ -53,6 +54,7 @@ export function MatchDetailPage() { nickname: p.nickname, faction_name: p.faction_name, place: i + 1, + eliminated: false, comment: p.comment ?? "", })); @@ -71,7 +73,8 @@ export function MatchDetailPage() { body: { participants: finishRows.map((r) => ({ user_id: r.user_id, - place: r.place, + place: r.eliminated ? null : r.place, + eliminated: r.eliminated, comment: r.comment.trim() || null, })), win_reason: winReason, @@ -141,7 +144,15 @@ export function MatchDetailPage() { {sorted.map((p) => (
- {p.place ?? "—"} + + {p.eliminated ? "✕" : (p.place ?? "—")} + {p.nickname} · {p.faction_name} {p.was_random && 🎲} @@ -189,12 +200,20 @@ export function MatchDetailPage() {
- { + const v = e.target.value; + if (v === "elim") upd(i, { eliminated: true }); + else upd(i, { eliminated: false, place: Number(v) }); + }} + > {Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => ( ))} +
diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index 15b7cd9..07eba02 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -19,6 +19,7 @@ interface Row { nickname: string; faction_id: number; place: number; + eliminated: boolean; was_random: boolean; comment: string; } @@ -46,6 +47,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: nickname: p.nickname, faction_id: p.faction_id, place: p.place ?? 1, + eliminated: p.eliminated, was_random: p.was_random, comment: p.comment ?? "", })), @@ -71,7 +73,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: participants: rows.map((r) => ({ user_id: r.user_id, faction_id: r.faction_id, - place: r.place, + place: r.eliminated ? null : r.place, + eliminated: r.eliminated, was_random: r.was_random, comment: r.comment.trim() || null, })), @@ -133,9 +136,19 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: type="number" min={1} value={r.place} + disabled={r.eliminated} onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })} />
+