Реализация новой рейтинговой системы (#23) #75
@@ -0,0 +1,130 @@
|
||||
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
|
||||
|
||||
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
|
||||
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
|
||||
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
|
||||
last_standing.
|
||||
|
||||
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
|
||||
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
|
||||
|
||||
Revision ID: 0014_rating_inputs
|
||||
Revises: 0013_user_token_version
|
||||
Create Date: 2026-09-14
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_rating_inputs"
|
||||
down_revision: Union[str, None] = "0013_user_token_version"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
CK_WIN_REASON = "ck_match_win_reason"
|
||||
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
|
||||
NEW_REASONS = (
|
||||
"win_reason IS NULL OR win_reason IN "
|
||||
"('objectives','worlds','plastic','resources','last_standing')"
|
||||
)
|
||||
|
||||
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
|
||||
BACKFILL_LAST_STANDING = """
|
||||
UPDATE matches SET win_reason = 'last_standing'
|
||||
WHERE status = 'finished'
|
||||
AND (win_reason IS NULL OR win_reason <> 'last_standing')
|
||||
AND (SELECT COUNT(*) FROM match_participants mp
|
||||
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
|
||||
"""
|
||||
|
||||
|
||||
def _columns(insp, table: str) -> set[str]:
|
||||
return {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def _win_reason_check(insp) -> str | None:
|
||||
for ck in insp.get_check_constraints("matches"):
|
||||
if ck.get("name") == CK_WIN_REASON:
|
||||
return ck["sqltext"]
|
||||
return None
|
||||
|
||||
|
||||
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
|
||||
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
|
||||
|
||||
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
|
||||
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
|
||||
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
|
||||
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
|
||||
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
|
||||
raise RuntimeError(
|
||||
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
|
||||
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
|
||||
)
|
||||
with op.batch_alter_table("matches", recreate="always") as b:
|
||||
if drop_existing:
|
||||
b.drop_constraint(CK_WIN_REASON, type_="check")
|
||||
b.create_check_constraint(CK_WIN_REASON, sqltext)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
|
||||
if "nine_rounds_rule" not in _columns(insp, "groups"):
|
||||
with op.batch_alter_table("groups") as b:
|
||||
b.add_column(
|
||||
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
match_cols = _columns(insp, "matches")
|
||||
with op.batch_alter_table("matches") as b:
|
||||
if "end_round" not in match_cols:
|
||||
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
|
||||
if "nine_rounds_rule" not in match_cols:
|
||||
b.add_column(
|
||||
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
participant_cols = _columns(insp, "match_participants")
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
if "objectives" not in participant_cols:
|
||||
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
|
||||
if "worlds" not in participant_cols:
|
||||
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
|
||||
|
||||
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
|
||||
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
|
||||
check = _win_reason_check(inspect(bind))
|
||||
if check is not None and "last_standing" not in check:
|
||||
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
|
||||
|
||||
op.execute(BACKFILL_LAST_STANDING)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
|
||||
# записывали «по целям»).
|
||||
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
|
||||
check = _win_reason_check(inspect(bind))
|
||||
if check is not None and "last_standing" in check:
|
||||
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
|
||||
|
||||
insp = inspect(bind)
|
||||
participant_cols = _columns(insp, "match_participants")
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
for name in ("worlds", "objectives"):
|
||||
if name in participant_cols:
|
||||
b.drop_column(name)
|
||||
match_cols = _columns(insp, "matches")
|
||||
with op.batch_alter_table("matches") as b:
|
||||
for name in ("nine_rounds_rule", "end_round"):
|
||||
if name in match_cols:
|
||||
b.drop_column(name)
|
||||
if "nine_rounds_rule" in _columns(insp, "groups"):
|
||||
with op.batch_alter_table("groups") as b:
|
||||
b.drop_column("nine_rounds_rule")
|
||||
+20
-1
@@ -195,6 +195,12 @@ class Group(SQLModel, table=True):
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
nullable=False,
|
||||
)
|
||||
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
|
||||
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
|
||||
nine_rounds_rule: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
class GroupMember(SQLModel, table=True):
|
||||
@@ -275,7 +281,8 @@ class Match(SQLModel, table=True):
|
||||
Index("ix_matches_group_played", "group_id", "played_at"),
|
||||
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
||||
CheckConstraint(
|
||||
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')",
|
||||
"win_reason IS NULL OR win_reason IN "
|
||||
"('objectives','worlds','plastic','resources','last_standing')",
|
||||
name="ck_match_win_reason",
|
||||
),
|
||||
)
|
||||
@@ -295,6 +302,14 @@ class Match(SQLModel, table=True):
|
||||
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
|
||||
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
|
||||
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
|
||||
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
|
||||
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
# Снимок Group.nine_rounds_rule на момент старта партии.
|
||||
nine_rounds_rule: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="0"),
|
||||
)
|
||||
player_count: int = Field(sa_column=Column(Integer, nullable=False))
|
||||
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_by: int = Field(
|
||||
@@ -340,6 +355,10 @@ class MatchParticipant(SQLModel, table=True):
|
||||
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
|
||||
# на конец партии. У выбывшего миров 0.
|
||||
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
created_at: datetime = Field(default_factory=utcnow, nullable=False)
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,8 @@ from app.core.errors import InvalidCredentialsError, NotFoundError
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.routers.matches import attachment_read, build_match_read
|
||||
from app.routers.matches import attachment_read, build_match_read, participant_inputs
|
||||
from app.schemas import api as s
|
||||
from app.services.match_service import ParticipantInput
|
||||
from app.services import (
|
||||
achievement_service,
|
||||
admin_service,
|
||||
@@ -243,19 +242,6 @@ def update_match(
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
@@ -264,7 +250,9 @@ def update_match(
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
end_round=body.end_round,
|
||||
end_round_set=("end_round" in body.model_fields_set),
|
||||
participants=participant_inputs(body),
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
audit_service.record(
|
||||
|
||||
@@ -33,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
|
||||
owner_id=group.owner_id,
|
||||
my_role=member.role,
|
||||
expansion_ids=group_service.group_expansion_ids(session, group_id),
|
||||
nine_rounds_rule=group.nine_rounds_rule,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,15 +80,19 @@ def get_group(
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=s.GroupDetail)
|
||||
def rename_group(
|
||||
def update_group(
|
||||
group_id: int,
|
||||
body: s.GroupRename,
|
||||
body: s.GroupUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.GroupDetail:
|
||||
"""Название и домашние правила группы; меняются только переданные поля."""
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
if body.name is not None:
|
||||
group_service.rename_group(session, group, body.name)
|
||||
if body.nine_rounds_rule is not None:
|
||||
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
|
||||
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,30 @@ from app.services import (
|
||||
user_service,
|
||||
)
|
||||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||
from app.services.scoring import max_rounds
|
||||
|
||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
||||
|
||||
|
||||
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
|
||||
"""Участники правки — общее для игроцкого и админского PATCH."""
|
||||
if body.participants is None:
|
||||
return None
|
||||
return [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
objectives=p.objectives,
|
||||
worlds=p.worlds,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
|
||||
|
||||
|
||||
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
|
||||
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
|
||||
@@ -56,6 +76,8 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
objectives=p.objectives,
|
||||
worlds=p.worlds,
|
||||
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||||
)
|
||||
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
|
||||
@@ -79,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
finished_at=iso_utc(match.finished_at),
|
||||
duration_minutes=match.duration_minutes,
|
||||
win_reason=match.win_reason, # type: ignore[arg-type]
|
||||
end_round=match.end_round,
|
||||
nine_rounds_rule=match.nine_rounds_rule,
|
||||
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
|
||||
player_count=match.player_count,
|
||||
overall_comment=match.overall_comment,
|
||||
created_by=match.created_by,
|
||||
@@ -157,6 +182,8 @@ def finish_match(
|
||||
eliminated=p.eliminated,
|
||||
comment=p.comment,
|
||||
faction_id=p.faction_id,
|
||||
objectives=p.objectives,
|
||||
worlds=p.worlds,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
@@ -165,6 +192,7 @@ def finish_match(
|
||||
match,
|
||||
finish=finish,
|
||||
win_reason=body.win_reason,
|
||||
end_round=body.end_round,
|
||||
overall_comment=body.overall_comment,
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
expected_version=body.expected_version,
|
||||
@@ -206,19 +234,6 @@ def update_match(
|
||||
) -> s.MatchRead:
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
participants = None
|
||||
if body.participants is not None:
|
||||
participants = [
|
||||
ParticipantInput(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.update_match(
|
||||
session,
|
||||
match,
|
||||
@@ -227,7 +242,9 @@ def update_match(
|
||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||
win_reason=body.win_reason,
|
||||
win_reason_set=("win_reason" in body.model_fields_set),
|
||||
participants=participants,
|
||||
end_round=body.end_round,
|
||||
end_round_set=("end_round" in body.model_fields_set),
|
||||
participants=participant_inputs(body),
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
audit_service.record(
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
||||
# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
|
||||
# тогда, когда невыбывший участник один (match_service._check_last_standing).
|
||||
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
|
||||
|
||||
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
|
||||
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
|
||||
EndRound = Annotated[int, Field(ge=1, le=9)]
|
||||
Count = Annotated[int, Field(ge=0, le=99)]
|
||||
|
||||
|
||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
@@ -162,8 +169,10 @@ class GroupCreate(BaseModel):
|
||||
expansion_ids: list[int] = []
|
||||
|
||||
|
||||
class GroupRename(BaseModel):
|
||||
name: str
|
||||
class GroupUpdate(BaseModel):
|
||||
# Частичная правка: переданные поля меняются, остальные остаются как есть.
|
||||
name: str | None = None
|
||||
nine_rounds_rule: bool | None = None
|
||||
|
||||
|
||||
class GroupExpansionsUpdate(BaseModel):
|
||||
@@ -176,6 +185,8 @@ class GroupDetail(BaseModel):
|
||||
owner_id: int
|
||||
my_role: str
|
||||
expansion_ids: list[int] = []
|
||||
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
|
||||
nine_rounds_rule: bool = False
|
||||
|
||||
|
||||
class MemberRead(BaseModel):
|
||||
@@ -252,17 +263,20 @@ class MatchFinishParticipant(BaseModel):
|
||||
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
|
||||
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
|
||||
|
||||
|
||||
class MatchFinish(BaseModel):
|
||||
participants: list[MatchFinishParticipant]
|
||||
win_reason: WinReason
|
||||
end_round: EndRound | None = None # раунд, в котором партия закончилась
|
||||
overall_comment: str | None = None
|
||||
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
|
||||
expected_version: str | None = None
|
||||
|
||||
|
||||
# Полный участник (правка завершённой партии админом).
|
||||
# Полный участник (правка результатов завершённой партии).
|
||||
class ParticipantInput(BaseModel):
|
||||
user_id: int
|
||||
faction_id: int
|
||||
@@ -270,12 +284,15 @@ class ParticipantInput(BaseModel):
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
objectives: Count | None = None
|
||||
worlds: Count | None = None
|
||||
|
||||
|
||||
class MatchUpdate(BaseModel):
|
||||
played_at: date | None = None
|
||||
overall_comment: str | None = None
|
||||
win_reason: WinReason | None = None
|
||||
end_round: EndRound | None = None
|
||||
participants: list[ParticipantInput] | None = None
|
||||
expected_version: str | None = None # оптимистичная блокировка
|
||||
|
||||
@@ -289,6 +306,8 @@ class MatchParticipantRead(BaseModel):
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
@@ -311,6 +330,10 @@ class MatchFinishDraftData(BaseModel):
|
||||
comments: dict[str, str] = {}
|
||||
win_reason: WinReason | None = None
|
||||
overall_comment: str | None = None
|
||||
end_round: EndRound | None = None
|
||||
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
|
||||
objectives: dict[str, Count] = {}
|
||||
worlds: dict[str, Count] = {}
|
||||
|
||||
|
||||
class MatchFinishDraftRead(BaseModel):
|
||||
@@ -329,6 +352,11 @@ class MatchRead(BaseModel):
|
||||
finished_at: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
win_reason: WinReason | None = None
|
||||
end_round: int | None = None
|
||||
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
|
||||
# фронт берёт лимит отсюда, а не повторяет правило у себя.
|
||||
nine_rounds_rule: bool = False
|
||||
max_rounds: int
|
||||
player_count: int
|
||||
overall_comment: str | None = None
|
||||
created_by: int
|
||||
@@ -451,6 +479,8 @@ class MatchListParticipant(BaseModel):
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
class MatchListItem(BaseModel):
|
||||
|
||||
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
|
||||
return group
|
||||
|
||||
|
||||
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
|
||||
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
|
||||
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
|
||||
group.nine_rounds_rule = enabled
|
||||
session.add(group)
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
|
||||
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
||||
current = session.exec(
|
||||
|
||||
@@ -19,9 +19,12 @@ from app.core.errors import (
|
||||
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
|
||||
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
|
||||
|
||||
MAX_MATCH_PLAYERS = 6
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
|
||||
LAST_STANDING = "last_standing"
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
|
||||
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,11 +45,13 @@ class FinishInput:
|
||||
eliminated: bool = False
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParticipantInput:
|
||||
"""Полный участник (для правки завершённой партии админом)."""
|
||||
"""Полный участник (для правки результатов завершённой партии)."""
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
@@ -54,6 +59,8 @@ class ParticipantInput:
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
objectives: int | None = None
|
||||
worlds: int | None = None
|
||||
|
||||
|
||||
def round_to_30(minutes: float) -> int:
|
||||
@@ -154,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int
|
||||
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
|
||||
|
||||
|
||||
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
|
||||
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
|
||||
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
|
||||
|
||||
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
|
||||
if end_round is None:
|
||||
return
|
||||
rmax = max_rounds(player_count, nine_rounds_rule)
|
||||
if not 1 <= end_round <= rmax:
|
||||
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
|
||||
|
||||
|
||||
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
|
||||
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
|
||||
if not eliminated:
|
||||
return worlds
|
||||
if worlds:
|
||||
raise ValidationError("У выбывшего игрока не может быть миров.")
|
||||
return 0
|
||||
|
||||
|
||||
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
|
||||
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
|
||||
|
||||
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
|
||||
сервер лишь не пропускает расхождение."""
|
||||
alone = sum(1 for e in eliminated if not e) == 1
|
||||
if alone and win_reason != LAST_STANDING:
|
||||
raise ValidationError(
|
||||
"Остался один невыбывший игрок — причина победы «последний выживший»."
|
||||
)
|
||||
if not alone and win_reason == LAST_STANDING:
|
||||
raise ValidationError(
|
||||
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
|
||||
)
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {
|
||||
m.user_id
|
||||
@@ -208,12 +252,15 @@ def create_match(
|
||||
)
|
||||
|
||||
now = utcnow()
|
||||
group = group_service.get_group(session, group_id)
|
||||
match = Match(
|
||||
group_id=group_id,
|
||||
status="in_progress",
|
||||
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
||||
started_at=now,
|
||||
player_count=len(roster),
|
||||
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
|
||||
nine_rounds_rule=group.nine_rounds_rule,
|
||||
created_by=creator.id, # type: ignore[arg-type]
|
||||
)
|
||||
session.add(match)
|
||||
@@ -242,6 +289,7 @@ def finish_match(
|
||||
*,
|
||||
finish: list[FinishInput],
|
||||
win_reason: str,
|
||||
end_round: int | None = None,
|
||||
overall_comment: str | None = None,
|
||||
overall_comment_set: bool = False,
|
||||
expected_version: str | None = None,
|
||||
@@ -251,6 +299,7 @@ def finish_match(
|
||||
raise ConflictError("Партия уже завершена.")
|
||||
if win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Укажите корректную причину победы.")
|
||||
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
|
||||
|
||||
existing = {
|
||||
p.user_id: p
|
||||
@@ -273,12 +322,16 @@ def finish_match(
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
|
||||
_check_last_standing(win_reason, [f.eliminated for f in finish])
|
||||
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) 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
|
||||
p.objectives = f.objectives
|
||||
p.worlds = worlds[f.user_id]
|
||||
if f.faction_id is not None:
|
||||
p.faction_id = f.faction_id
|
||||
session.add(p)
|
||||
@@ -294,6 +347,7 @@ def finish_match(
|
||||
match.duration_minutes = round_to_30(elapsed_min)
|
||||
match.status = "finished"
|
||||
match.win_reason = win_reason
|
||||
match.end_round = end_round
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
|
||||
@@ -312,22 +366,44 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None
|
||||
return session.get(MatchFinishDraft, match_id)
|
||||
|
||||
|
||||
def _draft_counts(value: object) -> dict[str, int]:
|
||||
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
out: dict[str, int] = {}
|
||||
for k, v in value.items():
|
||||
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
out[str(k)] = v
|
||||
return out
|
||||
|
||||
|
||||
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")
|
||||
end_round = data.get("end_round")
|
||||
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("Некорректная причина победы.")
|
||||
if end_round is not None and (
|
||||
isinstance(end_round, bool)
|
||||
or not isinstance(end_round, int)
|
||||
or not 1 <= end_round <= EXTENDED_ROUNDS
|
||||
):
|
||||
raise ValidationError("Некорректный черновик.")
|
||||
objectives = _draft_counts(data.get("objectives") or {})
|
||||
worlds = _draft_counts(data.get("worlds") or {})
|
||||
|
||||
participant_ids = {
|
||||
p.user_id
|
||||
@@ -354,6 +430,9 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict:
|
||||
"comments": {str(k): str(v) for k, v in comments.items()},
|
||||
"win_reason": win_reason,
|
||||
"overall_comment": overall,
|
||||
"end_round": end_round,
|
||||
"objectives": objectives,
|
||||
"worlds": worlds,
|
||||
}
|
||||
|
||||
|
||||
@@ -409,35 +488,41 @@ def update_match(
|
||||
overall_comment_set: bool = False,
|
||||
win_reason: str | None = None,
|
||||
win_reason_set: bool = False,
|
||||
end_round: int | None = None,
|
||||
end_round_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
|
||||
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
|
||||
иначе они оседали бы в партии со статусом in_progress, которая остаётся
|
||||
в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
|
||||
только status='finished'). Дату и общий комментарий править можно и по ходу
|
||||
партии — двойственного состояния они не создают."""
|
||||
results_touched = participants is not None or win_reason_set or end_round_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:
|
||||
if win_reason_set and 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()
|
||||
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
|
||||
# с тем, что уже записано.
|
||||
new_reason = win_reason if win_reason_set else match.win_reason
|
||||
new_end_round = end_round if end_round_set else match.end_round
|
||||
new_count = len(participants) if participants is not None else match.player_count
|
||||
if end_round_set or participants is not None:
|
||||
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
|
||||
|
||||
places: dict[int, int] = {}
|
||||
worlds: dict[int, int | None] = {}
|
||||
if participants is not None:
|
||||
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||
_validate_roster_basics(
|
||||
@@ -451,6 +536,25 @@ def update_match(
|
||||
places = _resolve_finish_places(
|
||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||
)
|
||||
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
|
||||
if participants is not None or win_reason_set:
|
||||
flags = (
|
||||
[p.eliminated for p in participants]
|
||||
if participants is not None
|
||||
else [p.eliminated for p in saved]
|
||||
)
|
||||
_check_last_standing(new_reason, flags)
|
||||
|
||||
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:
|
||||
match.win_reason = win_reason
|
||||
if end_round_set:
|
||||
match.end_round = end_round
|
||||
|
||||
if participants is not None:
|
||||
for old in saved:
|
||||
session.delete(old)
|
||||
session.flush()
|
||||
@@ -464,6 +568,8 @@ def update_match(
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment or None,
|
||||
objectives=p.objectives,
|
||||
worlds=worlds[p.user_id],
|
||||
)
|
||||
)
|
||||
match.player_count = len(participants)
|
||||
|
||||
@@ -316,6 +316,8 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
"objectives": p.objectives,
|
||||
"worlds": p.worlds,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов,
|
||||
причина «последний выживший». Сервер проверяет диапазоны и явные противоречия."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]:
|
||||
"""Группа со всеми дополнениями и players участниками (первый — вошедший)."""
|
||||
me = login(client, "Хозяин")
|
||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)]
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return gid, uids, fids
|
||||
|
||||
|
||||
def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
|
||||
r = start_match(
|
||||
client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)]
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra):
|
||||
body = {"participants": participants, "win_reason": win_reason, **extra}
|
||||
return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client))
|
||||
|
||||
|
||||
def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict:
|
||||
r = client.patch(
|
||||
f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
assert match["max_rounds"] == 8 and match["end_round"] is None
|
||||
|
||||
r = _finish(
|
||||
client, match["id"],
|
||||
[
|
||||
{"user_id": a, "place": 1, "objectives": 2, "worlds": 5},
|
||||
{"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся
|
||||
],
|
||||
"objectives", end_round=6,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["end_round"] == 6
|
||||
parts = {p["user_id"]: p for p in data["participants"]}
|
||||
assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5)
|
||||
assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None)
|
||||
|
||||
listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"]
|
||||
assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1}
|
||||
|
||||
|
||||
def test_end_round_limited_by_max_rounds(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}]
|
||||
assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422
|
||||
assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422
|
||||
assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200
|
||||
|
||||
|
||||
def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine):
|
||||
gid, uids, fids = _group(client, engine, 5)
|
||||
assert _set_rule(client, gid, True)["nine_rounds_rule"] is True
|
||||
assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто
|
||||
|
||||
five = _start(client, gid, uids, fids)
|
||||
four = _start(client, gid, uids[:4], fids)
|
||||
assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9)
|
||||
assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5
|
||||
|
||||
# Смена настройки группы не переписывает уже начатую партию.
|
||||
_set_rule(client, gid, False)
|
||||
assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9
|
||||
|
||||
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
|
||||
assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200
|
||||
rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])]
|
||||
assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422
|
||||
|
||||
|
||||
def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine):
|
||||
gid, (a, b, c), fids = _group(client, engine, 3)
|
||||
match = _start(client, gid, [a, b, c], fids)
|
||||
alone = [
|
||||
{"user_id": a, "place": 1},
|
||||
{"user_id": b, "eliminated": True},
|
||||
{"user_id": c, "eliminated": True},
|
||||
]
|
||||
r = _finish(client, match["id"], alone, "objectives")
|
||||
assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"]
|
||||
|
||||
two = [
|
||||
{"user_id": a, "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
{"user_id": c, "eliminated": True},
|
||||
]
|
||||
assert _finish(client, match["id"], two, "last_standing").status_code == 422
|
||||
|
||||
ok = _finish(client, match["id"], alone, "last_standing")
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["win_reason"] == "last_standing"
|
||||
|
||||
|
||||
def test_eliminated_player_has_no_worlds(client: TestClient, engine):
|
||||
gid, (a, b, c), fids = _group(client, engine, 3)
|
||||
match = _start(client, gid, [a, b, c], fids)
|
||||
rows = [
|
||||
{"user_id": a, "place": 1, "worlds": 7},
|
||||
{"user_id": b, "place": 2, "worlds": 4},
|
||||
{"user_id": c, "eliminated": True, "worlds": 2},
|
||||
]
|
||||
assert _finish(client, match["id"], rows, "objectives").status_code == 422
|
||||
|
||||
rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны
|
||||
r = _finish(client, match["id"], rows, "objectives")
|
||||
assert r.status_code == 200, r.text
|
||||
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
||||
assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1)
|
||||
|
||||
|
||||
def test_negative_counts_rejected(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}]
|
||||
assert _finish(client, match["id"], rows, "objectives").status_code == 422
|
||||
|
||||
|
||||
def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
|
||||
match = _start(client, gid, uids, fids)
|
||||
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
|
||||
r = _finish(client, match["id"], rows, "objectives")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _edit_rows(match: dict, **by_user) -> list[dict]:
|
||||
rows = []
|
||||
for p in match["participants"]:
|
||||
row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]}
|
||||
row.update(by_user.get(str(p["user_id"]), {}))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def test_edit_checks_last_standing_and_round(client: TestClient, engine):
|
||||
gid, (a, b, c), fids = _group(client, engine, 3)
|
||||
match = _finished(client, gid, [a, b, c], fids)
|
||||
mid = match["id"]
|
||||
|
||||
def patch(body: dict):
|
||||
return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
|
||||
|
||||
elim = {"place": None, "eliminated": True}
|
||||
rows = _edit_rows(match, **{str(b): elim, str(c): elim})
|
||||
assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422
|
||||
# Причина не передана — сверяется с записанной («по целям»): тоже противоречие.
|
||||
assert patch({"participants": rows}).status_code == 422
|
||||
r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5})
|
||||
assert r.status_code == 200, r.text
|
||||
assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5)
|
||||
|
||||
# Одна только причина: при одном выжившем вернуть «по целям» нельзя.
|
||||
assert patch({"win_reason": "objectives"}).status_code == 422
|
||||
# Один только раунд: в пределах лимита — можно, за лимитом — нет.
|
||||
assert patch({"end_round": 9}).status_code == 422
|
||||
assert patch({"end_round": None}).json()["end_round"] is None
|
||||
|
||||
|
||||
def test_admin_edit_saves_counts(client: TestClient, engine, make_admin):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _finished(client, gid, [a, b], fids)
|
||||
make_admin("admin", "secret123")
|
||||
assert client.post(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "admin", "password": "secret123"},
|
||||
headers=csrf_headers(client),
|
||||
).status_code == 200
|
||||
rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}})
|
||||
r = client.patch(
|
||||
f"/api/admin/matches/{match['id']}",
|
||||
json={"participants": rows, "win_reason": "objectives", "end_round": 7},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
||||
assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7)
|
||||
|
||||
|
||||
def test_draft_keeps_round_and_counts(client: TestClient, engine):
|
||||
gid, (a, b), fids = _group(client, engine, 2)
|
||||
match = _start(client, gid, [a, b], fids)
|
||||
body = {
|
||||
"blocks": [[a], [b]],
|
||||
"win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется
|
||||
"end_round": 4,
|
||||
"objectives": {str(a): 2},
|
||||
"worlds": {str(a): 5, str(b): 3},
|
||||
}
|
||||
r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client))
|
||||
assert r.status_code == 200, r.text
|
||||
data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"]
|
||||
assert data["end_round"] == 4
|
||||
assert data["objectives"] == {str(a): 2}
|
||||
assert data["worlds"] == {str(a): 5, str(b): 3}
|
||||
|
||||
bad = client.put(
|
||||
f"/api/matches/{match['id']}/finish-draft",
|
||||
json={"worlds": {str(a): -3}},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert bad.status_code == 422
|
||||
Reference in New Issue
Block a user