323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление."""
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timezone
|
||
|
||
from sqlmodel import Session, select
|
||
|
||
from app.core.errors import (
|
||
ConflictError,
|
||
DuplicateParticipantError,
|
||
FactionNotAvailableError,
|
||
ForbiddenError,
|
||
InvalidRankingError,
|
||
NotFoundError,
|
||
ValidationError,
|
||
)
|
||
from app.core.timeutil import app_today
|
||
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
|
||
comment: str | None = None
|
||
faction_id: int | None = None # опц. смена фракции при завершении
|
||
|
||
|
||
@dataclass
|
||
class ParticipantInput:
|
||
"""Полный участник (для правки завершённой партии админом)."""
|
||
|
||
user_id: int
|
||
faction_id: int
|
||
place: int
|
||
was_random: bool = False
|
||
comment: str | None = None
|
||
|
||
|
||
def _utcnow() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
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 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 _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,
|
||
) -> Match:
|
||
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()
|
||
|
||
_validate_ranking([f.place for f in finish])
|
||
|
||
for f in finish:
|
||
p = existing[f.user_id]
|
||
p.place = f.place
|
||
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,
|
||
) -> Match:
|
||
"""Правка завершённой партии (админ): полный список участников с местами."""
|
||
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],
|
||
)
|
||
_validate_ranking([p.place 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=p.place,
|
||
was_random=p.was_random,
|
||
comment=p.comment or None,
|
||
)
|
||
)
|
||
match.player_count = len(participants)
|
||
|
||
session.add(match)
|
||
session.commit()
|
||
session.refresh(match)
|
||
return match
|
||
|
||
|
||
def delete_match(session: Session, match: Match) -> None:
|
||
session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE)
|
||
session.commit()
|