Рейтинг: раунд, цели, миры, правило 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:
+20
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user