"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление.""" from __future__ import annotations import random from dataclasses import dataclass from datetime import date from sqlmodel import Session, select from app.core.errors import ( ConflictError, DuplicateParticipantError, FactionNotAvailableError, ForbiddenError, InvalidRankingError, NotFoundError, ValidationError, ) 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 LAST_STANDING = "last_standing" WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING) MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API) @dataclass class RosterInput: """Участник на этапе старта (мест ещё нет).""" user_id: int faction_id: int was_random: bool = False @dataclass class FinishInput: """Результат участника на этапе завершения.""" user_id: int place: int | None = None # None у выбывшего (eliminated) — место проставится авто 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 place: int | None = None 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: """Округление длительности до получаса, минимум 30 минут.""" return max(30, int(round(minutes / 30.0)) * 30) def get_match(session: Session, match_id: int) -> Match: match = session.get(Match, match_id) if match is None: raise NotFoundError("Партия не найдена.") return match def match_version(match: Match) -> str: """Версия партии для оптимистичной блокировки (меняется при любом изменении).""" return iso_utc(match.updated_at) def touch(session: Session, match: Match) -> None: """Пометить партию изменённой (без commit). Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь связанные сущности (участники, вложения), строку не трогают, поэтому каждая такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча.""" match.updated_at = utcnow() session.add(match) def assert_version(match: Match, expected: str | None) -> None: """Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию).""" if expected is not None and expected != match_version(match): raise ConflictError( "Партия уже изменена на другом устройстве — обновите страницу.", code="STALE_WRITE", ) def participants_detail( session: Session, match_id: int ) -> list[tuple[MatchParticipant, User, Faction]]: rows = session.exec( select(MatchParticipant, User, Faction) .join(User, User.id == MatchParticipant.user_id) .join(Faction, Faction.id == MatchParticipant.faction_id) .where(MatchParticipant.match_id == match_id) # place может быть NULL (партия идёт) — NULL уходит в конец сортировки. .order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname) ).all() return [(p, u, f) for p, u, f in rows] def randomize_faction( session: Session, group_id: int, exclude_faction_ids: list[int] | None = None ) -> Faction: exclude = set(exclude_faction_ids or []) pool = [f for f in group_service.available_factions(session, group_id) if f.id not in exclude] if not pool: raise ValidationError("Нет доступных фракций для рандома.") return random.choice(pool) def _validate_ranking(places: list[int]) -> None: """Проверяет competition ranking (1,2,2,4) с допуском ничьих.""" if any(p < 1 for p in places): raise InvalidRankingError("Место должно быть ≥ 1.") ordered = sorted(places) expected = 1 i = 0 n = len(ordered) while i < n: current = ordered[i] if current != expected: raise InvalidRankingError( "Места должны идти по правилу 1,2,2,4 (без пропусков перед группой ничьих)." ) tie = 0 while i < n and ordered[i] == current: tie += 1 i += 1 expected = current + tie def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int, int]: """rows: (user_id, place, eliminated) → {user_id: место}. Выжившим (не eliminated) валидируем competition ranking (1,2,2,4). Выбывшим проставляем общее последнее место = len(survivors)+1 (ничья последнего ранга), чтобы подсчёт очков считал их как последнее место без отдельных правок SQL.""" survivors = [(uid, place) for uid, place, elim in rows if not elim] if not survivors: raise InvalidRankingError("Хотя бы один игрок должен остаться в партии (с местом).") if any(place is None for _uid, place in survivors): raise InvalidRankingError("Укажите место каждому не выбывшему игроку.") _validate_ranking([place for _uid, place in survivors]) # type: ignore[misc] elim_place = len(survivors) + 1 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 for m in session.exec( select(GroupMember).where(GroupMember.group_id == group_id) ).all() } def _validate_roster_basics( session: Session, group_id: int, user_ids: list[int], faction_ids: list[int], *, keep_user_ids: set[int] | None = None, keep_faction_ids: set[int] | None = None, ) -> None: """Состав партии: размер, отсутствие дублей, принадлежность группе. keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из группы игрок делали бы старую партию неисправимой навсегда.""" if len(user_ids) < 2: raise ValidationError("В партии должно быть не менее 2 участников.") if len(user_ids) > MAX_MATCH_PLAYERS: raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.") if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids): raise DuplicateParticipantError() allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set()) if not set(user_ids).issubset(allowed_users): raise ValidationError("Все участники должны состоять в группе.") allowed_factions = group_service.available_faction_ids(session, group_id) | ( keep_faction_ids or set() ) if not set(faction_ids).issubset(allowed_factions): raise FactionNotAvailableError() # ─── Этап 1: старт партии ───────────────────────────────────────────────────── def create_match( session: Session, creator: User, *, group_id: int, roster: list[RosterInput], ) -> Match: group_service.assert_member(session, group_id, creator.id) # type: ignore[arg-type] _validate_roster_basics( session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster] ) 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) session.flush() for r in roster: session.add( MatchParticipant( match_id=match.id, # type: ignore[arg-type] user_id=r.user_id, faction_id=r.faction_id, place=None, was_random=r.was_random, ) ) session.commit() session.refresh(match) return match # ─── Этап 2: завершение партии ──────────────────────────────────────────────── def finish_match( session: Session, match: 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, ) -> Match: assert_version(match, expected_version) if match.status != "in_progress": 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 for p in session.exec( select(MatchParticipant).where(MatchParticipant.match_id == match.id) ).all() } if {f.user_id for f in finish} != set(existing.keys()): raise ValidationError("Нужно указать результат по всем участникам партии.") # Эффективные фракции (с учётом возможной замены при завершении). eff_factions = { f.user_id: (f.faction_id if f.faction_id is not None else existing[f.user_id].faction_id) for f in finish } fids = list(eff_factions.values()) if len(set(fids)) != len(fids): raise DuplicateParticipantError() if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)): 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) now = utcnow() match.finished_at = now started = match.started_at if started is not None: if started.tzinfo is not None: started = started.replace(tzinfo=None) # max(0, ...) на случай перекоса часов — не уходим в отрицательную длительность. elapsed_min = max(0.0, (now.replace(tzinfo=None) - started).total_seconds() / 60.0) 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 session.add(match) # Содержимое черновика уже в результатах — второй источник правды не нужен. clear_finish_draft(session, match.id) # type: ignore[arg-type] session.commit() session.refresh(match) return match # ─── Черновик завершения (совместное заполнение формы) ──────────────────────── 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 for p in session.exec( select(MatchParticipant).where(MatchParticipant.match_id == match.id) ).all() } listed: list[int] = [] for block in blocks: if not isinstance(block, list): raise ValidationError("Некорректный черновик.") listed.extend(block) listed.extend(eliminated) if any(not isinstance(uid, int) for uid in listed): raise ValidationError("Некорректный черновик.") if set(listed) - participant_ids: raise ValidationError("В черновике есть игроки не из этой партии.") overall = data.get("overall_comment") if overall is not None and not isinstance(overall, str): raise ValidationError("Некорректный черновик.") return { "blocks": blocks, "eliminated": eliminated, "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, } def save_finish_draft( session: Session, match: Match, user: User, data: dict ) -> MatchFinishDraft: """Сохранить общий черновик формы завершения (последняя запись побеждает). Версию партии (updated_at) намеренно НЕ двигаем: иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку черновика.""" if match.status != "in_progress": raise ConflictError("Партия уже завершена.") payload = _validate_draft(session, match, data) draft = session.get(MatchFinishDraft, match.id) if draft is None: draft = MatchFinishDraft(match_id=match.id, data=payload, updated_by=user.id) else: draft.data = payload draft.updated_by = user.id draft.updated_at = utcnow() session.add(draft) session.commit() session.refresh(draft) return draft def clear_finish_draft(session: Session, match_id: int) -> None: draft = session.get(MatchFinishDraft, match_id) if draft is not None: session.delete(draft) # ─── Права / правка / удаление ──────────────────────────────────────────────── def can_modify(session: Session, match: Match, user: User) -> bool: # Управление партиями доступно любому участнику группы (а также админу). if user.role == "admin": return True member = group_service.get_membership(session, match.group_id, user.id) # type: ignore[arg-type] return member is not None def assert_can_modify(session: Session, match: Match, user: User) -> None: if not can_modify(session, match, user): raise ForbiddenError("Недостаточно прав для изменения партии.") def update_match( session: Session, match: Match, *, played_at: date | None = None, overall_comment: str | None = None, 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, которая остаётся в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает только 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 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: # Что уже записано в партии, остаётся допустимым: состав группы и набор # дополнений с тех пор могли поменяться, но историю это чинить не мешает. _validate_roster_basics( session, match.group_id, [p.user_id for p in participants], [p.faction_id for p in participants], keep_user_ids={p.user_id for p in saved}, keep_faction_ids={p.faction_id for p in saved}, ) 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() for p in participants: session.add( MatchParticipant( match_id=match.id, # type: ignore[arg-type] user_id=p.user_id, faction_id=p.faction_id, place=places[p.user_id], 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) touch(session, match) session.commit() session.refresh(match) return match def delete_match(session: Session, match: Match, expected_version: str | None = None) -> None: from app.services import attachment_service # избегаем цикла импорта assert_version(match, expected_version) match_id = match.id session.delete(match) # участники и вложения (БД) удалятся каскадом (FK ON DELETE CASCADE) session.commit() attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска