v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление."""
|
||||
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()
|
||||
Reference in New Issue
Block a user