diff --git a/backend/alembic/versions/0014_rating_inputs.py b/backend/alembic/versions/0014_rating_inputs.py new file mode 100644 index 0000000..fec3289 --- /dev/null +++ b/backend/alembic/versions/0014_rating_inputs.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 4c5d45d..f59dc79 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a07e8da..97164dd 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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( diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 1a9bc9d..010d43a 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -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] diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 8778b7c..c49b65e 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -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( diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 60026aa..0910e3c 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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): diff --git a/backend/app/services/group_service.py b/backend/app/services/group_service.py index 7c9f3df..8cd2151 100644 --- a/backend/app/services/group_service.py +++ b/backend/app/services/group_service.py @@ -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( diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index e016aca..d51953c 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -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) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index d77987e..5f57eb1 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -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, } diff --git a/backend/tests/test_rating_inputs.py b/backend/tests/test_rating_inputs.py new file mode 100644 index 0000000..29ea915 --- /dev/null +++ b/backend/tests/test_rating_inputs.py @@ -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