Рейтинг: раунд, цели, миры, правило 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
+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):