Партии: «выбывшие» игроки (красный крестик) = последнее место
Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг 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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user