Партии: «выбывшие» игроки (красный крестик) = последнее место
Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг 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:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 # должен остаться хотя бы один с местом
|
||||
Vendored
+22
-2
@@ -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
|
||||
|
||||
@@ -33,8 +33,14 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>
|
||||
{p.place ?? "—"}
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||||
(p.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||||
</span>
|
||||
<span>{p.nickname}</span>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
|
||||
@@ -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) => (
|
||||
<div key={p.user_id}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>{p.place ?? "—"}</span>
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||||
(p.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||||
</span>
|
||||
<b>{p.nickname}</b>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
{p.was_random && <span className="badge">🎲</span>}
|
||||
@@ -189,12 +200,20 @@ export function MatchDetailPage() {
|
||||
<div className="row">
|
||||
<div style={{ flex: "0 0 130px" }}>
|
||||
<label className="label">Место</label>
|
||||
<select value={r.place} onChange={(e) => upd(i, { place: Number(e.target.value) })}>
|
||||
<select
|
||||
value={r.eliminated ? "elim" : String(r.place)}
|
||||
onChange={(e) => {
|
||||
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) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
<option value="elim">✕ выбыл</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) })}
|
||||
/>
|
||||
</div>
|
||||
<label className="row small" style={{ flex: "0 0 auto", gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: "auto" }}
|
||||
checked={r.eliminated}
|
||||
onChange={(e) => upd(i, { eliminated: e.target.checked })}
|
||||
/>
|
||||
выбыл
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий об игроке"
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
|
||||
.place-badge.eliminated { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ─── Боковое меню (выезжает справа) ─────────────────────────────────────────── */
|
||||
.drawer-overlay {
|
||||
|
||||
Reference in New Issue
Block a user