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

Править завершённую партию умел любой участник группы, но только через API —
кнопки не было, и на практике это мог сделать лишь админ через админку. Теперь у
блока «Результаты» есть «Редактировать», и правка идёт тем же перетаскиванием,
что и завершение: раскладка восстанавливается из сохранённых мест (одинаковое
место — ничья, выбывшие отдельно), рядом — фракции, причина победы и комментарии.
Сохранение шлёт версию партии, так что устаревшая правка отклоняется как раньше.

Фракции вынесены отдельным блоком, а не в PlaceEditor: он занят перетаскиванием,
и селекты внутри него — лишний риск. В списке доступных фракций к набору группы
добавляются те, что уже стоят в партии, — иначе фракцию из отключённого
дополнения нельзя было бы даже оставить как есть.

#29

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

485 lines
21 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],
*,
keep_user_ids: set[int] | None = None,
keep_faction_ids: set[int] | None = None,
) -> None:
"""Состав партии: размер, отсутствие дублей, принадлежность группе.
keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят
независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из
группы игрок делали бы старую партию неисправимой навсегда."""
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()
allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set())
if not set(user_ids).issubset(allowed_users):
raise ValidationError("Все участники должны состоять в группе.")
allowed_factions = group_service.available_faction_ids(session, group_id) | (
keep_faction_ids or set()
)
if not set(faction_ids).issubset(allowed_factions):
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:
saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
# Что уже записано в партии, остаётся допустимым: состав группы и набор
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
_validate_roster_basics(
session,
match.group_id,
[p.user_id for p in participants],
[p.faction_id for p in participants],
keep_user_ids={p.user_id for p in saved},
keep_faction_ids={p.faction_id for p in saved},
)
places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants]
)
for old in saved:
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] # файлы с диска