Рейтинг: раунд, цели, миры, правило 9 раундов и причина last_standing

Модели и миграция 0014: groups.nine_rounds_rule, снимок правила в matches,
matches.end_round, match_participants.objectives/worlds. CHECK причины победы
расширяется, только если он в БД есть (старые БД без CHECK таблицу не
пересоздают); пересоздание matches отказывает при PRAGMA foreign_keys=ON,
иначе DROP унёс бы участников каскадом. Бэкфилл: last_standing у завершённых
партий с одним невыбывшим. Проверено на копии dev-БД и на схеме origin/dev
с CHECK: строки, CHECK, FK и индексы на месте, повтор и downgrade работают.

API и валидация: end_round от 1 до лимита раундов партии (9 при хоумруле
и 5+ игроках), у выбывшего миров 0, last_standing ровно при одном невыбывшем —
в завершении и правке (игрока и админа); черновик хранит раунд, цели и миры
без проверки правила. MatchRead отдаёт end_round, снимок правила и max_rounds.
PATCH /groups/{id} принимает nine_rounds_rule вместе с названием. #23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-14 22:29:01 +03:00
co-authored by Claude Opus 5
parent e58b4f6614
commit 8aa6bb7235
10 changed files with 593 additions and 60 deletions
+20 -1
View File
@@ -195,6 +195,12 @@ class Group(SQLModel, table=True):
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
class GroupMember(SQLModel, table=True):
@@ -275,7 +281,8 @@ class Match(SQLModel, table=True):
Index("ix_matches_group_played", "group_id", "played_at"),
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
CheckConstraint(
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
"win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')",
name="ck_match_win_reason",
),
)
@@ -295,6 +302,14 @@ class Match(SQLModel, table=True):
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Снимок Group.nine_rounds_rule на момент старта партии.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
player_count: int = Field(sa_column=Column(Integer, nullable=False))
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_by: int = Field(
@@ -340,6 +355,10 @@ class MatchParticipant(SQLModel, table=True):
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))
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
# на конец партии. У выбывшего миров 0.
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False)
+4 -16
View File
@@ -13,9 +13,8 @@ from app.core.errors import InvalidCredentialsError, NotFoundError
from app.core.timeutil import iso_utc
from app.db.session import get_session
from app.models import User
from app.routers.matches import attachment_read, build_match_read
from app.routers.matches import attachment_read, build_match_read, participant_inputs
from app.schemas import api as s
from app.services.match_service import ParticipantInput
from app.services import (
achievement_service,
admin_service,
@@ -243,19 +242,6 @@ def update_match(
admin: User = Depends(get_current_admin),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match(
session,
match,
@@ -264,7 +250,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set),
participants=participants,
end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version,
)
audit_service.record(
+8 -3
View File
@@ -33,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
owner_id=group.owner_id,
my_role=member.role,
expansion_ids=group_service.group_expansion_ids(session, group_id),
nine_rounds_rule=group.nine_rounds_rule,
)
@@ -79,15 +80,19 @@ def get_group(
@router.patch("/{group_id}", response_model=s.GroupDetail)
def rename_group(
def update_group(
group_id: int,
body: s.GroupRename,
body: s.GroupUpdate,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.GroupDetail:
"""Название и домашние правила группы; меняются только переданные поля."""
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
group_service.rename_group(session, group, body.name)
if body.name is not None:
group_service.rename_group(session, group, body.name)
if body.nine_rounds_rule is not None:
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
return _detail(session, group_id, user.id) # type: ignore[arg-type]
+31 -14
View File
@@ -22,10 +22,30 @@ from app.services import (
user_service,
)
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
from app.services.scoring import max_rounds
router = APIRouter(prefix="/matches", tags=["matches"])
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
"""Участники правки — общее для игроцкого и админского PATCH."""
if body.participants is None:
return None
return [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
@@ -56,6 +76,8 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
)
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
@@ -79,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type]
end_round=match.end_round,
nine_rounds_rule=match.nine_rounds_rule,
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
player_count=match.player_count,
overall_comment=match.overall_comment,
created_by=match.created_by,
@@ -157,6 +182,8 @@ def finish_match(
eliminated=p.eliminated,
comment=p.comment,
faction_id=p.faction_id,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
@@ -165,6 +192,7 @@ def finish_match(
match,
finish=finish,
win_reason=body.win_reason,
end_round=body.end_round,
overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set),
expected_version=body.expected_version,
@@ -206,19 +234,6 @@ def update_match(
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match(
session,
match,
@@ -227,7 +242,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set),
participants=participants,
end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version,
)
audit_service.record(
+35 -5
View File
@@ -2,11 +2,18 @@
from __future__ import annotations
from datetime import date
from typing import Literal
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
WinReason = Literal["objectives", "worlds", "plastic", "resources"]
# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
# тогда, когда невыбывший участник один (match_service._check_last_standing).
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
EndRound = Annotated[int, Field(ge=1, le=9)]
Count = Annotated[int, Field(ge=0, le=99)]
# ─── Auth ────────────────────────────────────────────────────────────────────
@@ -162,8 +169,10 @@ class GroupCreate(BaseModel):
expansion_ids: list[int] = []
class GroupRename(BaseModel):
name: str
class GroupUpdate(BaseModel):
# Частичная правка: переданные поля меняются, остальные остаются как есть.
name: str | None = None
nine_rounds_rule: bool | None = None
class GroupExpansionsUpdate(BaseModel):
@@ -176,6 +185,8 @@ class GroupDetail(BaseModel):
owner_id: int
my_role: str
expansion_ids: list[int] = []
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
nine_rounds_rule: bool = False
class MemberRead(BaseModel):
@@ -252,17 +263,20 @@ class MatchFinishParticipant(BaseModel):
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
class MatchFinish(BaseModel):
participants: list[MatchFinishParticipant]
win_reason: WinReason
end_round: EndRound | None = None # раунд, в котором партия закончилась
overall_comment: str | None = None
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
expected_version: str | None = None
# Полный участник (правка завершённой партии админом).
# Полный участник (правка результатов завершённой партии).
class ParticipantInput(BaseModel):
user_id: int
faction_id: int
@@ -270,12 +284,15 @@ class ParticipantInput(BaseModel):
eliminated: bool = False
was_random: bool = False
comment: str | None = None
objectives: Count | None = None
worlds: Count | None = None
class MatchUpdate(BaseModel):
played_at: date | None = None
overall_comment: str | None = None
win_reason: WinReason | None = None
end_round: EndRound | None = None
participants: list[ParticipantInput] | None = None
expected_version: str | None = None # оптимистичная блокировка
@@ -289,6 +306,8 @@ class MatchParticipantRead(BaseModel):
eliminated: bool = False
was_random: bool
comment: str | None = None
objectives: int | None = None
worlds: int | None = None
avatar_url: str | None = None
@@ -311,6 +330,10 @@ class MatchFinishDraftData(BaseModel):
comments: dict[str, str] = {}
win_reason: WinReason | None = None
overall_comment: str | None = None
end_round: EndRound | None = None
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
objectives: dict[str, Count] = {}
worlds: dict[str, Count] = {}
class MatchFinishDraftRead(BaseModel):
@@ -329,6 +352,11 @@ class MatchRead(BaseModel):
finished_at: str | None = None
duration_minutes: int | None = None
win_reason: WinReason | None = None
end_round: int | None = None
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
# фронт берёт лимит отсюда, а не повторяет правило у себя.
nine_rounds_rule: bool = False
max_rounds: int
player_count: int
overall_comment: str | None = None
created_by: int
@@ -451,6 +479,8 @@ class MatchListParticipant(BaseModel):
eliminated: bool = False
was_random: bool
comment: str | None = None
objectives: int | None = None
worlds: int | None = None
class MatchListItem(BaseModel):
+10
View File
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
return group
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
group.nine_rounds_rule = enabled
session.add(group)
session.commit()
session.refresh(group)
return group
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
current = session.exec(
+127 -21
View File
@@ -19,9 +19,12 @@ from app.core.errors import (
from app.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
from app.services import group_service
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
MAX_MATCH_PLAYERS = 6
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
LAST_STANDING = "last_standing"
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
@dataclass
@@ -42,11 +45,13 @@ class FinishInput:
eliminated: bool = False
comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении
objectives: int | None = None
worlds: int | None = None
@dataclass
class ParticipantInput:
"""Полный участник (для правки завершённой партии админом)."""
"""Полный участник (для правки результатов завершённой партии)."""
user_id: int
faction_id: int
@@ -54,6 +59,8 @@ class ParticipantInput:
eliminated: bool = False
was_random: bool = False
comment: str | None = None
objectives: int | None = None
worlds: int | None = None
def round_to_30(minutes: float) -> int:
@@ -154,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
if end_round is None:
return
rmax = max_rounds(player_count, nine_rounds_rule)
if not 1 <= end_round <= rmax:
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
if not eliminated:
return worlds
if worlds:
raise ValidationError("У выбывшего игрока не может быть миров.")
return 0
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
сервер лишь не пропускает расхождение."""
alone = sum(1 for e in eliminated if not e) == 1
if alone and win_reason != LAST_STANDING:
raise ValidationError(
"Остался один невыбывший игрок — причина победы «последний выживший»."
)
if not alone and win_reason == LAST_STANDING:
raise ValidationError(
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
)
def _group_member_ids(session: Session, group_id: int) -> set[int]:
return {
m.user_id
@@ -208,12 +252,15 @@ def create_match(
)
now = utcnow()
group = group_service.get_group(session, group_id)
match = Match(
group_id=group_id,
status="in_progress",
played_at=app_today(), # дата игры — в поясе приложения (+3)
started_at=now,
player_count=len(roster),
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
nine_rounds_rule=group.nine_rounds_rule,
created_by=creator.id, # type: ignore[arg-type]
)
session.add(match)
@@ -242,6 +289,7 @@ def finish_match(
*,
finish: list[FinishInput],
win_reason: str,
end_round: int | None = None,
overall_comment: str | None = None,
overall_comment_set: bool = False,
expected_version: str | None = None,
@@ -251,6 +299,7 @@ def finish_match(
raise ConflictError("Партия уже завершена.")
if win_reason not in WIN_REASONS:
raise ValidationError("Укажите корректную причину победы.")
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
existing = {
p.user_id: p
@@ -273,12 +322,16 @@ def finish_match(
raise FactionNotAvailableError()
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
_check_last_standing(win_reason, [f.eliminated for f in finish])
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish}
for f in finish:
p = existing[f.user_id]
p.place = places[f.user_id]
p.eliminated = f.eliminated
p.comment = f.comment or None
p.objectives = f.objectives
p.worlds = worlds[f.user_id]
if f.faction_id is not None:
p.faction_id = f.faction_id
session.add(p)
@@ -294,6 +347,7 @@ def finish_match(
match.duration_minutes = round_to_30(elapsed_min)
match.status = "finished"
match.win_reason = win_reason
match.end_round = end_round
if overall_comment_set:
match.overall_comment = overall_comment or None
@@ -312,22 +366,44 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None
return session.get(MatchFinishDraft, match_id)
def _draft_counts(value: object) -> dict[str, int]:
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
if not isinstance(value, dict):
raise ValidationError("Некорректный черновик.")
out: dict[str, int] = {}
for k, v in value.items():
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
raise ValidationError("Некорректный черновик.")
out[str(k)] = v
return out
def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
раскладывает их постепенно, и промежуточное состояние может быть любым."""
раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
причине не проверяется и правило «последнего выжившего»."""
if not isinstance(data, dict):
raise ValidationError("Некорректный черновик.")
blocks = data.get("blocks") or []
eliminated = data.get("eliminated") or []
comments = data.get("comments") or {}
win_reason = data.get("win_reason")
end_round = data.get("end_round")
if not isinstance(blocks, list) or not isinstance(eliminated, list):
raise ValidationError("Некорректный черновик.")
if not isinstance(comments, dict):
raise ValidationError("Некорректный черновик.")
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
if end_round is not None and (
isinstance(end_round, bool)
or not isinstance(end_round, int)
or not 1 <= end_round <= EXTENDED_ROUNDS
):
raise ValidationError("Некорректный черновик.")
objectives = _draft_counts(data.get("objectives") or {})
worlds = _draft_counts(data.get("worlds") or {})
participant_ids = {
p.user_id
@@ -354,6 +430,9 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"comments": {str(k): str(v) for k, v in comments.items()},
"win_reason": win_reason,
"overall_comment": overall,
"end_round": end_round,
"objectives": objectives,
"worlds": worlds,
}
@@ -409,35 +488,41 @@ def update_match(
overall_comment_set: bool = False,
win_reason: str | None = None,
win_reason_set: bool = False,
end_round: int | None = None,
end_round_set: bool = False,
participants: list[ParticipantInput] | None = None,
expected_version: str | None = None,
) -> Match:
"""Правка партии: состав с местами, дата, комментарий, причина победы.
"""Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished').
Дату и общий комментарий править можно и по ходу партии — двойственного состояния
они не создают."""
results_touched = participants is not None or win_reason_set
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
иначе они оседали бы в партии со статусом in_progress, которая остаётся
в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
только status='finished'). Дату и общий комментарий править можно и по ходу
партии — двойственного состояния они не создают."""
results_touched = participants is not None or win_reason_set or end_round_set
if results_touched and match.status != "finished":
raise ConflictError(
"Результаты незавершённой партии нельзя править — сначала завершите её."
)
assert_version(match, expected_version)
if played_at is not None:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
match.win_reason = win_reason
if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
# с тем, что уже записано.
new_reason = win_reason if win_reason_set else match.win_reason
new_end_round = end_round if end_round_set else match.end_round
new_count = len(participants) if participants is not None else match.player_count
if end_round_set or participants is not None:
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
places: dict[int, int] = {}
worlds: dict[int, int | None] = {}
if participants is not None:
saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
# Что уже записано в партии, остаётся допустимым: состав группы и набор
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
_validate_roster_basics(
@@ -451,6 +536,25 @@ def update_match(
places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants]
)
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
if participants is not None or win_reason_set:
flags = (
[p.eliminated for p in participants]
if participants is not None
else [p.eliminated for p in saved]
)
_check_last_standing(new_reason, flags)
if played_at is not None:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
match.win_reason = win_reason
if end_round_set:
match.end_round = end_round
if participants is not None:
for old in saved:
session.delete(old)
session.flush()
@@ -464,6 +568,8 @@ def update_match(
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment or None,
objectives=p.objectives,
worlds=worlds[p.user_id],
)
)
match.player_count = len(participants)
+2
View File
@@ -316,6 +316,8 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
"objectives": p.objectives,
"worlds": p.worlds,
}