v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
+322
View File
@@ -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()