Files
ForbiddenStarsApp/backend/app/services/match_service.py
T
NotBigGhostandClaude Opus 5 8ac2757cd6 Simplify: единый источник времени, версии партии и запросов
Проход /simplify по backend/app:

- Три источника «текущего времени» (_utcnow в models.py и match_service.py при
  живом timeutil.utcnow) сведены к одному — это прямо инвариант из CLAUDE.md.
- Бамп версии партии из двух независимых мест собран в match_service.touch:
  следующая точка мутации, не трогающая строку matches, теперь имеет очевидный
  способ сделать правильно.
- membership_service переиспользует group_service.get_membership вместо трёх
  копий одного запроса; защита последнего владельца — один хелпер на удаление
  и смену роли вместо двух похожих блоков.
- Убраны N+1: участники страницы партий и ники пригласивших берутся одним
  запросом вместо запроса на строку (20 партий = 20 лишних запросов с двумя
  join каждый).
- Счётчики партий считает СУБД (COUNT/MAX) вместо выгрузки всех строк ради
  len() и max() в Python.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
2026-09-09 18:45:50 +03:00

375 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление."""
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, 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],
) -> None:
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()
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
raise ValidationError("Все участники должны состоять в группе.")
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
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)
session.commit()
session.refresh(match)
return match
# ─── Права / правка / удаление ────────────────────────────────────────────────
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:
"""Правка завершённой партии (админ): полный список участников с местами."""
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:
_validate_roster_basics(
session,
match.group_id,
[p.user_id for p in participants],
[p.faction_id for p in participants],
)
places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants]
)
for old in session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all():
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] # файлы с диска