Рейтинг: раунд, цели, миры, правило 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
@@ -0,0 +1,130 @@
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
last_standing.
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
Revision ID: 0014_rating_inputs
Revises: 0013_user_token_version
Create Date: 2026-09-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0014_rating_inputs"
down_revision: Union[str, None] = "0013_user_token_version"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
CK_WIN_REASON = "ck_match_win_reason"
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
NEW_REASONS = (
"win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')"
)
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
BACKFILL_LAST_STANDING = """
UPDATE matches SET win_reason = 'last_standing'
WHERE status = 'finished'
AND (win_reason IS NULL OR win_reason <> 'last_standing')
AND (SELECT COUNT(*) FROM match_participants mp
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
"""
def _columns(insp, table: str) -> set[str]:
return {c["name"] for c in insp.get_columns(table)}
def _win_reason_check(insp) -> str | None:
for ck in insp.get_check_constraints("matches"):
if ck.get("name") == CK_WIN_REASON:
return ck["sqltext"]
return None
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
raise RuntimeError(
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
)
with op.batch_alter_table("matches", recreate="always") as b:
if drop_existing:
b.drop_constraint(CK_WIN_REASON, type_="check")
b.create_check_constraint(CK_WIN_REASON, sqltext)
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "nine_rounds_rule" not in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
if "end_round" not in match_cols:
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
if "nine_rounds_rule" not in match_cols:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
if "objectives" not in participant_cols:
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
if "worlds" not in participant_cols:
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" not in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
op.execute(BACKFILL_LAST_STANDING)
def downgrade() -> None:
bind = op.get_bind()
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
# записывали «по целям»).
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
insp = inspect(bind)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
for name in ("worlds", "objectives"):
if name in participant_cols:
b.drop_column(name)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
for name in ("nine_rounds_rule", "end_round"):
if name in match_cols:
b.drop_column(name)
if "nine_rounds_rule" in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.drop_column("nine_rounds_rule")
+20 -1
View File
@@ -195,6 +195,12 @@ class Group(SQLModel, table=True):
sa_column_kwargs={"onupdate": utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, 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): class GroupMember(SQLModel, table=True):
@@ -275,7 +281,8 @@ class Match(SQLModel, table=True):
Index("ix_matches_group_played", "group_id", "played_at"), Index("ix_matches_group_played", "group_id", "played_at"),
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"), CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
CheckConstraint( 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", 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)) 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)) 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)) 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)) player_count: int = Field(sa_column=Column(Integer, nullable=False))
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True)) overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_by: int = Field( 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")) 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")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True)) 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) 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.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User 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.schemas import api as s
from app.services.match_service import ParticipantInput
from app.services import ( from app.services import (
achievement_service, achievement_service,
admin_service, admin_service,
@@ -243,19 +242,6 @@ def update_match(
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) 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( match = match_service.update_match(
session, session,
match, match,
@@ -264,7 +250,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), 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, expected_version=body.expected_version,
) )
audit_service.record( 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, owner_id=group.owner_id,
my_role=member.role, my_role=member.role,
expansion_ids=group_service.group_expansion_ids(session, group_id), 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) @router.patch("/{group_id}", response_model=s.GroupDetail)
def rename_group( def update_group(
group_id: int, group_id: int,
body: s.GroupRename, body: s.GroupUpdate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.GroupDetail: ) -> s.GroupDetail:
"""Название и домашние правила группы; меняются только переданные поля."""
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) 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] 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, user_service,
) )
from app.services.match_service import FinishInput, ParticipantInput, RosterInput from app.services.match_service import FinishInput, ParticipantInput, RosterInput
from app.services.scoring import max_rounds
router = APIRouter(prefix="/matches", tags=["matches"]) 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: def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или """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, eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment, 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] 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] 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), finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes, duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type] 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, player_count=match.player_count,
overall_comment=match.overall_comment, overall_comment=match.overall_comment,
created_by=match.created_by, created_by=match.created_by,
@@ -157,6 +182,8 @@ def finish_match(
eliminated=p.eliminated, eliminated=p.eliminated,
comment=p.comment, comment=p.comment,
faction_id=p.faction_id, faction_id=p.faction_id,
objectives=p.objectives,
worlds=p.worlds,
) )
for p in body.participants for p in body.participants
] ]
@@ -165,6 +192,7 @@ def finish_match(
match, match,
finish=finish, finish=finish,
win_reason=body.win_reason, win_reason=body.win_reason,
end_round=body.end_round,
overall_comment=body.overall_comment, overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
expected_version=body.expected_version, expected_version=body.expected_version,
@@ -206,19 +234,6 @@ def update_match(
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user) 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( match = match_service.update_match(
session, session,
match, match,
@@ -227,7 +242,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), 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, expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
+35 -5
View File
@@ -2,11 +2,18 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from typing import Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field 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 ──────────────────────────────────────────────────────────────────── # ─── Auth ────────────────────────────────────────────────────────────────────
@@ -162,8 +169,10 @@ class GroupCreate(BaseModel):
expansion_ids: list[int] = [] expansion_ids: list[int] = []
class GroupRename(BaseModel): class GroupUpdate(BaseModel):
name: str # Частичная правка: переданные поля меняются, остальные остаются как есть.
name: str | None = None
nine_rounds_rule: bool | None = None
class GroupExpansionsUpdate(BaseModel): class GroupExpansionsUpdate(BaseModel):
@@ -176,6 +185,8 @@ class GroupDetail(BaseModel):
owner_id: int owner_id: int
my_role: str my_role: str
expansion_ids: list[int] = [] expansion_ids: list[int] = []
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
nine_rounds_rule: bool = False
class MemberRead(BaseModel): class MemberRead(BaseModel):
@@ -252,17 +263,20 @@ class MatchFinishParticipant(BaseModel):
eliminated: bool = False # выбыл из партии → авто-проставится последнее место eliminated: bool = False # выбыл из партии → авто-проставится последнее место
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
class MatchFinish(BaseModel): class MatchFinish(BaseModel):
participants: list[MatchFinishParticipant] participants: list[MatchFinishParticipant]
win_reason: WinReason win_reason: WinReason
end_round: EndRound | None = None # раунд, в котором партия закончилась
overall_comment: str | None = None overall_comment: str | None = None
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version). # Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
expected_version: str | None = None expected_version: str | None = None
# Полный участник (правка завершённой партии админом). # Полный участник (правка результатов завершённой партии).
class ParticipantInput(BaseModel): class ParticipantInput(BaseModel):
user_id: int user_id: int
faction_id: int faction_id: int
@@ -270,12 +284,15 @@ class ParticipantInput(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: Count | None = None
worlds: Count | None = None
class MatchUpdate(BaseModel): class MatchUpdate(BaseModel):
played_at: date | None = None played_at: date | None = None
overall_comment: str | None = None overall_comment: str | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: EndRound | None = None
participants: list[ParticipantInput] | None = None participants: list[ParticipantInput] | None = None
expected_version: str | None = None # оптимистичная блокировка expected_version: str | None = None # оптимистичная блокировка
@@ -289,6 +306,8 @@ class MatchParticipantRead(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
avatar_url: str | None = None avatar_url: str | None = None
@@ -311,6 +330,10 @@ class MatchFinishDraftData(BaseModel):
comments: dict[str, str] = {} comments: dict[str, str] = {}
win_reason: WinReason | None = None win_reason: WinReason | None = None
overall_comment: str | 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): class MatchFinishDraftRead(BaseModel):
@@ -329,6 +352,11 @@ class MatchRead(BaseModel):
finished_at: str | None = None finished_at: str | None = None
duration_minutes: int | None = None duration_minutes: int | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: int | None = None
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
# фронт берёт лимит отсюда, а не повторяет правило у себя.
nine_rounds_rule: bool = False
max_rounds: int
player_count: int player_count: int
overall_comment: str | None = None overall_comment: str | None = None
created_by: int created_by: int
@@ -451,6 +479,8 @@ class MatchListParticipant(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
class MatchListItem(BaseModel): class MatchListItem(BaseModel):
+10
View File
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
return 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: def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
valid = set(_valid_non_base_expansion_ids(session, expansion_ids)) valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
current = session.exec( 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.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
from app.services import group_service from app.services import group_service
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
MAX_MATCH_PLAYERS = 6 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 @dataclass
@@ -42,11 +45,13 @@ class FinishInput:
eliminated: bool = False eliminated: bool = False
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: int | None = None
worlds: int | None = None
@dataclass @dataclass
class ParticipantInput: class ParticipantInput:
"""Полный участник (для правки завершённой партии админом).""" """Полный участник (для правки результатов завершённой партии)."""
user_id: int user_id: int
faction_id: int faction_id: int
@@ -54,6 +59,8 @@ class ParticipantInput:
eliminated: bool = False eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
def round_to_30(minutes: float) -> int: 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] 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]: def _group_member_ids(session: Session, group_id: int) -> set[int]:
return { return {
m.user_id m.user_id
@@ -208,12 +252,15 @@ def create_match(
) )
now = utcnow() now = utcnow()
group = group_service.get_group(session, group_id)
match = Match( match = Match(
group_id=group_id, group_id=group_id,
status="in_progress", status="in_progress",
played_at=app_today(), # дата игры — в поясе приложения (+3) played_at=app_today(), # дата игры — в поясе приложения (+3)
started_at=now, started_at=now,
player_count=len(roster), player_count=len(roster),
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
nine_rounds_rule=group.nine_rounds_rule,
created_by=creator.id, # type: ignore[arg-type] created_by=creator.id, # type: ignore[arg-type]
) )
session.add(match) session.add(match)
@@ -242,6 +289,7 @@ def finish_match(
*, *,
finish: list[FinishInput], finish: list[FinishInput],
win_reason: str, win_reason: str,
end_round: int | None = None,
overall_comment: str | None = None, overall_comment: str | None = None,
overall_comment_set: bool = False, overall_comment_set: bool = False,
expected_version: str | None = None, expected_version: str | None = None,
@@ -251,6 +299,7 @@ def finish_match(
raise ConflictError("Партия уже завершена.") raise ConflictError("Партия уже завершена.")
if win_reason not in WIN_REASONS: if win_reason not in WIN_REASONS:
raise ValidationError("Укажите корректную причину победы.") raise ValidationError("Укажите корректную причину победы.")
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
existing = { existing = {
p.user_id: p p.user_id: p
@@ -273,12 +322,16 @@ def finish_match(
raise FactionNotAvailableError() raise FactionNotAvailableError()
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish]) 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: for f in finish:
p = existing[f.user_id] p = existing[f.user_id]
p.place = places[f.user_id] p.place = places[f.user_id]
p.eliminated = f.eliminated p.eliminated = f.eliminated
p.comment = f.comment or None p.comment = f.comment or None
p.objectives = f.objectives
p.worlds = worlds[f.user_id]
if f.faction_id is not None: if f.faction_id is not None:
p.faction_id = f.faction_id p.faction_id = f.faction_id
session.add(p) session.add(p)
@@ -294,6 +347,7 @@ def finish_match(
match.duration_minutes = round_to_30(elapsed_min) match.duration_minutes = round_to_30(elapsed_min)
match.status = "finished" match.status = "finished"
match.win_reason = win_reason match.win_reason = win_reason
match.end_round = end_round
if overall_comment_set: if overall_comment_set:
match.overall_comment = overall_comment or None 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) 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: def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками """Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
раскладывает их постепенно, и промежуточное состояние может быть любым.""" раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
причине не проверяется и правило «последнего выжившего»."""
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
blocks = data.get("blocks") or [] blocks = data.get("blocks") or []
eliminated = data.get("eliminated") or [] eliminated = data.get("eliminated") or []
comments = data.get("comments") or {} comments = data.get("comments") or {}
win_reason = data.get("win_reason") win_reason = data.get("win_reason")
end_round = data.get("end_round")
if not isinstance(blocks, list) or not isinstance(eliminated, list): if not isinstance(blocks, list) or not isinstance(eliminated, list):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
if not isinstance(comments, dict): if not isinstance(comments, dict):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
if win_reason is not None and win_reason not in WIN_REASONS: if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.") 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 = { participant_ids = {
p.user_id 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()}, "comments": {str(k): str(v) for k, v in comments.items()},
"win_reason": win_reason, "win_reason": win_reason,
"overall_comment": overall, "overall_comment": overall,
"end_round": end_round,
"objectives": objectives,
"worlds": worlds,
} }
@@ -409,35 +488,41 @@ def update_match(
overall_comment_set: bool = False, overall_comment_set: bool = False,
win_reason: str | None = None, win_reason: str | None = None,
win_reason_set: bool = False, win_reason_set: bool = False,
end_round: int | None = None,
end_round_set: bool = False,
participants: list[ParticipantInput] | None = None, participants: list[ParticipantInput] | None = None,
expected_version: str | None = None, expected_version: str | None = None,
) -> Match: ) -> Match:
"""Правка партии: состав с местами, дата, комментарий, причина победы. """Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не иначе они оседали бы в партии со статусом in_progress, которая остаётся
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished'). в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
Дату и общий комментарий править можно и по ходу партии — двойственного состояния только status='finished'). Дату и общий комментарий править можно и по ходу
они не создают.""" партии — двойственного состояния они не создают."""
results_touched = participants is not None or win_reason_set results_touched = participants is not None or win_reason_set or end_round_set
if results_touched and match.status != "finished": if results_touched and match.status != "finished":
raise ConflictError( raise ConflictError(
"Результаты незавершённой партии нельзя править — сначала завершите её." "Результаты незавершённой партии нельзя править — сначала завершите её."
) )
assert_version(match, expected_version) assert_version(match, expected_version)
if played_at is not None: if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
match.played_at = played_at raise ValidationError("Некорректная причина победы.")
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
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: if participants is not None:
saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
# Что уже записано в партии, остаётся допустимым: состав группы и набор # Что уже записано в партии, остаётся допустимым: состав группы и набор
# дополнений с тех пор могли поменяться, но историю это чинить не мешает. # дополнений с тех пор могли поменяться, но историю это чинить не мешает.
_validate_roster_basics( _validate_roster_basics(
@@ -451,6 +536,25 @@ def update_match(
places = _resolve_finish_places( places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants] [(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: for old in saved:
session.delete(old) session.delete(old)
session.flush() session.flush()
@@ -464,6 +568,8 @@ def update_match(
eliminated=p.eliminated, eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment or None, comment=p.comment or None,
objectives=p.objectives,
worlds=worlds[p.user_id],
) )
) )
match.player_count = len(participants) 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, "eliminated": p.eliminated,
"was_random": p.was_random, "was_random": p.was_random,
"comment": p.comment, "comment": p.comment,
"objectives": p.objectives,
"worlds": p.worlds,
} }
+226
View File
@@ -0,0 +1,226 @@
"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов,
причина «последний выживший». Сервер проверяет диапазоны и явные противоречия."""
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, engine, players: int) -> tuple[int, list[int], list[int]]:
"""Группа со всеми дополнениями и players участниками (первый — вошедший)."""
me = login(client, "Хозяин")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, uids, fids
def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
r = start_match(
client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)]
)
assert r.status_code == 200, r.text
return r.json()
def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra):
body = {"participants": participants, "win_reason": win_reason, **extra}
return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client))
def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict:
r = client.patch(
f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
return r.json()
def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
assert match["max_rounds"] == 8 and match["end_round"] is None
r = _finish(
client, match["id"],
[
{"user_id": a, "place": 1, "objectives": 2, "worlds": 5},
{"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся
],
"objectives", end_round=6,
)
assert r.status_code == 200, r.text
data = r.json()
assert data["end_round"] == 6
parts = {p["user_id"]: p for p in data["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5)
assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None)
listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"]
assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1}
def test_end_round_limited_by_max_rounds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200
def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine):
gid, uids, fids = _group(client, engine, 5)
assert _set_rule(client, gid, True)["nine_rounds_rule"] is True
assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто
five = _start(client, gid, uids, fids)
four = _start(client, gid, uids[:4], fids)
assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9)
assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5
# Смена настройки группы не переписывает уже начатую партию.
_set_rule(client, gid, False)
assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200
rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])]
assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422
def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
alone = [
{"user_id": a, "place": 1},
{"user_id": b, "eliminated": True},
{"user_id": c, "eliminated": True},
]
r = _finish(client, match["id"], alone, "objectives")
assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"]
two = [
{"user_id": a, "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "eliminated": True},
]
assert _finish(client, match["id"], two, "last_standing").status_code == 422
ok = _finish(client, match["id"], alone, "last_standing")
assert ok.status_code == 200, ok.text
assert ok.json()["win_reason"] == "last_standing"
def test_eliminated_player_has_no_worlds(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
rows = [
{"user_id": a, "place": 1, "worlds": 7},
{"user_id": b, "place": 2, "worlds": 4},
{"user_id": c, "eliminated": True, "worlds": 2},
]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1)
def test_negative_counts_rejected(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
match = _start(client, gid, uids, fids)
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
return r.json()
def _edit_rows(match: dict, **by_user) -> list[dict]:
rows = []
for p in match["participants"]:
row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]}
row.update(by_user.get(str(p["user_id"]), {}))
rows.append(row)
return rows
def test_edit_checks_last_standing_and_round(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _finished(client, gid, [a, b, c], fids)
mid = match["id"]
def patch(body: dict):
return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
elim = {"place": None, "eliminated": True}
rows = _edit_rows(match, **{str(b): elim, str(c): elim})
assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422
# Причина не передана — сверяется с записанной («по целям»): тоже противоречие.
assert patch({"participants": rows}).status_code == 422
r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5})
assert r.status_code == 200, r.text
assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5)
# Одна только причина: при одном выжившем вернуть «по целям» нельзя.
assert patch({"win_reason": "objectives"}).status_code == 422
# Один только раунд: в пределах лимита — можно, за лимитом — нет.
assert patch({"end_round": 9}).status_code == 422
assert patch({"end_round": None}).json()["end_round"] is None
def test_admin_edit_saves_counts(client: TestClient, engine, make_admin):
gid, (a, b), fids = _group(client, engine, 2)
match = _finished(client, gid, [a, b], fids)
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}})
r = client.patch(
f"/api/admin/matches/{match['id']}",
json={"participants": rows, "win_reason": "objectives", "end_round": 7},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7)
def test_draft_keeps_round_and_counts(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
body = {
"blocks": [[a], [b]],
"win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется
"end_round": 4,
"objectives": {str(a): 2},
"worlds": {str(a): 5, str(b): 3},
}
r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client))
assert r.status_code == 200, r.text
data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"]
assert data["end_round"] == 4
assert data["objectives"] == {str(a): 2}
assert data["worlds"] == {str(a): 5, str(b): 3}
bad = client.put(
f"/api/matches/{match['id']}/finish-draft",
json={"worlds": {str(a): -3}},
headers=csrf_headers(client),
)
assert bad.status_code == 422