"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление.""" 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 MAX_MATCH_PLAYERS = 6 WIN_REASONS = ("objectives", "worlds", "plastic", "resources") @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 # опц. смена фракции при завершении @dataclass class ParticipantInput: """Полный участник (для правки завершённой партии админом).""" user_id: int faction_id: int place: int | None = None eliminated: bool = False was_random: bool = False comment: str | 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 _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() match = Match( group_id=group_id, status="in_progress", played_at=app_today(), # дата игры — в поясе приложения (+3) started_at=now, player_count=len(roster), 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, 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("Укажите корректную причину победы.") 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]) 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 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 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 _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") 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("Некорректная причина победы.") 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, } 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, 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 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 participants is not None: saved = session.exec( select(MatchParticipant).where(MatchParticipant.match_id == match.id) ).all() # Что уже записано в партии, остаётся допустимым: состав группы и набор # дополнений с тех пор могли поменяться, но историю это чинить не мешает. _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] ) 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, ) ) 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] # файлы с диска