Files
ForbiddenStarsApp/backend/app/services/match_service.py
T
NotBigGhostandClaude Opus 5 8be9a23c0c Партия: общий черновик завершения и запрет результатов до финиша
Результаты (места и причина победы) теперь пишутся только в завершённую партию.
Раньше их можно было проставить идущей: партия оставалась in_progress, висела в
«Незавершённых», в статистику не попадала и очков не приносила — победитель есть,
а игры как бы не было. Дату и общий комментарий по ходу партии править по-прежнему
можно: двойственного состояния они не создают.

Форма завершения получила общий черновик (match_finish_drafts): раскладка мест,
ничьи, выбывшие, комментарии и причина победы видны всем, кто заполняет партию.
Отдельная таблица, а не колонки в matches, намеренно — запись в строку партии
дёргает onupdate у updated_at, то есть версию для оптимистичной блокировки, и
«Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.
Черновик удаляется при завершении и уходит каскадом при удалении партии.

Черновик разъезжается отдельным типом SSE-события: он меняется на каждое движение
тайла, и полная инвалидация (лидерборд, история, профили) по нему была бы
расточительной. Автору правки событие не шлётся.

#28

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

468 lines
19 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, 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],
) -> 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)
# Содержимое черновика уже в результатах — второй источник правды не нужен.
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:
_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] # файлы с диска