Рейтинг: раунд, цели, миры, правило 9 раундов и причина last_standing
Модели и миграция 0014: groups.nine_rounds_rule, снимок правила в matches,
matches.end_round, match_participants.objectives/worlds. CHECK причины победы
расширяется, только если он в БД есть (старые БД без CHECK таблицу не
пересоздают); пересоздание matches отказывает при PRAGMA foreign_keys=ON,
иначе DROP унёс бы участников каскадом. Бэкфилл: last_standing у завершённых
партий с одним невыбывшим. Проверено на копии dev-БД и на схеме origin/dev
с CHECK: строки, CHECK, FK и индексы на месте, повтор и downgrade работают.
API и валидация: end_round от 1 до лимита раундов партии (9 при хоумруле
и 5+ игроках), у выбывшего миров 0, last_standing ровно при одном невыбывшем —
в завершении и правке (игрока и админа); черновик хранит раунд, цели и миры
без проверки правила. MatchRead отдаёт end_round, снимок правила и max_rounds.
PATCH /groups/{id} принимает nine_rounds_rule вместе с названием. #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
@@ -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},
|
sa_column_kwargs={"onupdate": utcnow},
|
||||||
nullable=False,
|
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):
|
class GroupMember(SQLModel, table=True):
|
||||||
@@ -275,7 +281,8 @@ class Match(SQLModel, table=True):
|
|||||||
Index("ix_matches_group_played", "group_id", "played_at"),
|
Index("ix_matches_group_played", "group_id", "played_at"),
|
||||||
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
|
||||||
CheckConstraint(
|
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",
|
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))
|
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))
|
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))
|
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))
|
player_count: int = Field(sa_column=Column(Integer, nullable=False))
|
||||||
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||||
created_by: int = Field(
|
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"))
|
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"))
|
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
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)
|
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.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
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.schemas import api as s
|
||||||
from app.services.match_service import ParticipantInput
|
|
||||||
from app.services import (
|
from app.services import (
|
||||||
achievement_service,
|
achievement_service,
|
||||||
admin_service,
|
admin_service,
|
||||||
@@ -243,19 +242,6 @@ def update_match(
|
|||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.MatchRead:
|
) -> s.MatchRead:
|
||||||
match = match_service.get_match(session, match_id)
|
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(
|
match = match_service.update_match(
|
||||||
session,
|
session,
|
||||||
match,
|
match,
|
||||||
@@ -264,7 +250,9 @@ def update_match(
|
|||||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||||
win_reason=body.win_reason,
|
win_reason=body.win_reason,
|
||||||
win_reason_set=("win_reason" in body.model_fields_set),
|
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,
|
expected_version=body.expected_version,
|
||||||
)
|
)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
|
|||||||
owner_id=group.owner_id,
|
owner_id=group.owner_id,
|
||||||
my_role=member.role,
|
my_role=member.role,
|
||||||
expansion_ids=group_service.group_expansion_ids(session, group_id),
|
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)
|
@router.patch("/{group_id}", response_model=s.GroupDetail)
|
||||||
def rename_group(
|
def update_group(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
body: s.GroupRename,
|
body: s.GroupUpdate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> s.GroupDetail:
|
) -> s.GroupDetail:
|
||||||
|
"""Название и домашние правила группы; меняются только переданные поля."""
|
||||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||||
group = group_service.get_group(session, group_id)
|
group = group_service.get_group(session, group_id)
|
||||||
group_service.rename_group(session, group, body.name)
|
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]
|
return _detail(session, group_id, user.id) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,30 @@ from app.services import (
|
|||||||
user_service,
|
user_service,
|
||||||
)
|
)
|
||||||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||||
|
from app.services.scoring import max_rounds
|
||||||
|
|
||||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
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:
|
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
|
||||||
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
|
"""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,
|
eliminated=p.eliminated,
|
||||||
was_random=p.was_random,
|
was_random=p.was_random,
|
||||||
comment=p.comment,
|
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]
|
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]
|
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),
|
finished_at=iso_utc(match.finished_at),
|
||||||
duration_minutes=match.duration_minutes,
|
duration_minutes=match.duration_minutes,
|
||||||
win_reason=match.win_reason, # type: ignore[arg-type]
|
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,
|
player_count=match.player_count,
|
||||||
overall_comment=match.overall_comment,
|
overall_comment=match.overall_comment,
|
||||||
created_by=match.created_by,
|
created_by=match.created_by,
|
||||||
@@ -157,6 +182,8 @@ def finish_match(
|
|||||||
eliminated=p.eliminated,
|
eliminated=p.eliminated,
|
||||||
comment=p.comment,
|
comment=p.comment,
|
||||||
faction_id=p.faction_id,
|
faction_id=p.faction_id,
|
||||||
|
objectives=p.objectives,
|
||||||
|
worlds=p.worlds,
|
||||||
)
|
)
|
||||||
for p in body.participants
|
for p in body.participants
|
||||||
]
|
]
|
||||||
@@ -165,6 +192,7 @@ def finish_match(
|
|||||||
match,
|
match,
|
||||||
finish=finish,
|
finish=finish,
|
||||||
win_reason=body.win_reason,
|
win_reason=body.win_reason,
|
||||||
|
end_round=body.end_round,
|
||||||
overall_comment=body.overall_comment,
|
overall_comment=body.overall_comment,
|
||||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||||
expected_version=body.expected_version,
|
expected_version=body.expected_version,
|
||||||
@@ -206,19 +234,6 @@ def update_match(
|
|||||||
) -> s.MatchRead:
|
) -> s.MatchRead:
|
||||||
match = match_service.get_match(session, match_id)
|
match = match_service.get_match(session, match_id)
|
||||||
match_service.assert_can_modify(session, match, user)
|
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(
|
match = match_service.update_match(
|
||||||
session,
|
session,
|
||||||
match,
|
match,
|
||||||
@@ -227,7 +242,9 @@ def update_match(
|
|||||||
overall_comment_set=("overall_comment" in body.model_fields_set),
|
overall_comment_set=("overall_comment" in body.model_fields_set),
|
||||||
win_reason=body.win_reason,
|
win_reason=body.win_reason,
|
||||||
win_reason_set=("win_reason" in body.model_fields_set),
|
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,
|
expected_version=body.expected_version,
|
||||||
)
|
)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
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 ────────────────────────────────────────────────────────────────────
|
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
@@ -162,8 +169,10 @@ class GroupCreate(BaseModel):
|
|||||||
expansion_ids: list[int] = []
|
expansion_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class GroupRename(BaseModel):
|
class GroupUpdate(BaseModel):
|
||||||
name: str
|
# Частичная правка: переданные поля меняются, остальные остаются как есть.
|
||||||
|
name: str | None = None
|
||||||
|
nine_rounds_rule: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class GroupExpansionsUpdate(BaseModel):
|
class GroupExpansionsUpdate(BaseModel):
|
||||||
@@ -176,6 +185,8 @@ class GroupDetail(BaseModel):
|
|||||||
owner_id: int
|
owner_id: int
|
||||||
my_role: str
|
my_role: str
|
||||||
expansion_ids: list[int] = []
|
expansion_ids: list[int] = []
|
||||||
|
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
|
||||||
|
nine_rounds_rule: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MemberRead(BaseModel):
|
class MemberRead(BaseModel):
|
||||||
@@ -252,17 +263,20 @@ class MatchFinishParticipant(BaseModel):
|
|||||||
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
faction_id: int | None = None # опц. смена фракции при завершении
|
faction_id: int | None = None # опц. смена фракции при завершении
|
||||||
|
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
|
||||||
|
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
|
||||||
|
|
||||||
|
|
||||||
class MatchFinish(BaseModel):
|
class MatchFinish(BaseModel):
|
||||||
participants: list[MatchFinishParticipant]
|
participants: list[MatchFinishParticipant]
|
||||||
win_reason: WinReason
|
win_reason: WinReason
|
||||||
|
end_round: EndRound | None = None # раунд, в котором партия закончилась
|
||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
|
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
|
||||||
expected_version: str | None = None
|
expected_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# Полный участник (правка завершённой партии админом).
|
# Полный участник (правка результатов завершённой партии).
|
||||||
class ParticipantInput(BaseModel):
|
class ParticipantInput(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
faction_id: int
|
faction_id: int
|
||||||
@@ -270,12 +284,15 @@ class ParticipantInput(BaseModel):
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool = False
|
was_random: bool = False
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
objectives: Count | None = None
|
||||||
|
worlds: Count | None = None
|
||||||
|
|
||||||
|
|
||||||
class MatchUpdate(BaseModel):
|
class MatchUpdate(BaseModel):
|
||||||
played_at: date | None = None
|
played_at: date | None = None
|
||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
win_reason: WinReason | None = None
|
win_reason: WinReason | None = None
|
||||||
|
end_round: EndRound | None = None
|
||||||
participants: list[ParticipantInput] | None = None
|
participants: list[ParticipantInput] | None = None
|
||||||
expected_version: str | None = None # оптимистичная блокировка
|
expected_version: str | None = None # оптимистичная блокировка
|
||||||
|
|
||||||
@@ -289,6 +306,8 @@ class MatchParticipantRead(BaseModel):
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool
|
was_random: bool
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
objectives: int | None = None
|
||||||
|
worlds: int | None = None
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -311,6 +330,10 @@ class MatchFinishDraftData(BaseModel):
|
|||||||
comments: dict[str, str] = {}
|
comments: dict[str, str] = {}
|
||||||
win_reason: WinReason | None = None
|
win_reason: WinReason | None = None
|
||||||
overall_comment: str | 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):
|
class MatchFinishDraftRead(BaseModel):
|
||||||
@@ -329,6 +352,11 @@ class MatchRead(BaseModel):
|
|||||||
finished_at: str | None = None
|
finished_at: str | None = None
|
||||||
duration_minutes: int | None = None
|
duration_minutes: int | None = None
|
||||||
win_reason: WinReason | None = None
|
win_reason: WinReason | None = None
|
||||||
|
end_round: int | None = None
|
||||||
|
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
|
||||||
|
# фронт берёт лимит отсюда, а не повторяет правило у себя.
|
||||||
|
nine_rounds_rule: bool = False
|
||||||
|
max_rounds: int
|
||||||
player_count: int
|
player_count: int
|
||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
created_by: int
|
created_by: int
|
||||||
@@ -451,6 +479,8 @@ class MatchListParticipant(BaseModel):
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool
|
was_random: bool
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
objectives: int | None = None
|
||||||
|
worlds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class MatchListItem(BaseModel):
|
class MatchListItem(BaseModel):
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
|
|||||||
return 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:
|
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
|
||||||
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
||||||
current = session.exec(
|
current = session.exec(
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ from app.core.errors import (
|
|||||||
from app.core.timeutil import app_today, iso_utc, utcnow
|
from app.core.timeutil import app_today, iso_utc, utcnow
|
||||||
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
|
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
|
||||||
from app.services import group_service
|
from app.services import group_service
|
||||||
|
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
|
||||||
|
|
||||||
MAX_MATCH_PLAYERS = 6
|
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
|
@dataclass
|
||||||
@@ -42,11 +45,13 @@ class FinishInput:
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
faction_id: int | None = None # опц. смена фракции при завершении
|
faction_id: int | None = None # опц. смена фракции при завершении
|
||||||
|
objectives: int | None = None
|
||||||
|
worlds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ParticipantInput:
|
class ParticipantInput:
|
||||||
"""Полный участник (для правки завершённой партии админом)."""
|
"""Полный участник (для правки результатов завершённой партии)."""
|
||||||
|
|
||||||
user_id: int
|
user_id: int
|
||||||
faction_id: int
|
faction_id: int
|
||||||
@@ -54,6 +59,8 @@ class ParticipantInput:
|
|||||||
eliminated: bool = False
|
eliminated: bool = False
|
||||||
was_random: bool = False
|
was_random: bool = False
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
objectives: int | None = None
|
||||||
|
worlds: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def round_to_30(minutes: float) -> int:
|
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]
|
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]:
|
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||||
return {
|
return {
|
||||||
m.user_id
|
m.user_id
|
||||||
@@ -208,12 +252,15 @@ def create_match(
|
|||||||
)
|
)
|
||||||
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
|
group = group_service.get_group(session, group_id)
|
||||||
match = Match(
|
match = Match(
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
status="in_progress",
|
status="in_progress",
|
||||||
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
||||||
started_at=now,
|
started_at=now,
|
||||||
player_count=len(roster),
|
player_count=len(roster),
|
||||||
|
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
|
||||||
|
nine_rounds_rule=group.nine_rounds_rule,
|
||||||
created_by=creator.id, # type: ignore[arg-type]
|
created_by=creator.id, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
session.add(match)
|
session.add(match)
|
||||||
@@ -242,6 +289,7 @@ def finish_match(
|
|||||||
*,
|
*,
|
||||||
finish: list[FinishInput],
|
finish: list[FinishInput],
|
||||||
win_reason: str,
|
win_reason: str,
|
||||||
|
end_round: int | None = None,
|
||||||
overall_comment: str | None = None,
|
overall_comment: str | None = None,
|
||||||
overall_comment_set: bool = False,
|
overall_comment_set: bool = False,
|
||||||
expected_version: str | None = None,
|
expected_version: str | None = None,
|
||||||
@@ -251,6 +299,7 @@ def finish_match(
|
|||||||
raise ConflictError("Партия уже завершена.")
|
raise ConflictError("Партия уже завершена.")
|
||||||
if win_reason not in WIN_REASONS:
|
if win_reason not in WIN_REASONS:
|
||||||
raise ValidationError("Укажите корректную причину победы.")
|
raise ValidationError("Укажите корректную причину победы.")
|
||||||
|
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
|
||||||
|
|
||||||
existing = {
|
existing = {
|
||||||
p.user_id: p
|
p.user_id: p
|
||||||
@@ -273,12 +322,16 @@ def finish_match(
|
|||||||
raise FactionNotAvailableError()
|
raise FactionNotAvailableError()
|
||||||
|
|
||||||
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
|
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:
|
for f in finish:
|
||||||
p = existing[f.user_id]
|
p = existing[f.user_id]
|
||||||
p.place = places[f.user_id]
|
p.place = places[f.user_id]
|
||||||
p.eliminated = f.eliminated
|
p.eliminated = f.eliminated
|
||||||
p.comment = f.comment or None
|
p.comment = f.comment or None
|
||||||
|
p.objectives = f.objectives
|
||||||
|
p.worlds = worlds[f.user_id]
|
||||||
if f.faction_id is not None:
|
if f.faction_id is not None:
|
||||||
p.faction_id = f.faction_id
|
p.faction_id = f.faction_id
|
||||||
session.add(p)
|
session.add(p)
|
||||||
@@ -294,6 +347,7 @@ def finish_match(
|
|||||||
match.duration_minutes = round_to_30(elapsed_min)
|
match.duration_minutes = round_to_30(elapsed_min)
|
||||||
match.status = "finished"
|
match.status = "finished"
|
||||||
match.win_reason = win_reason
|
match.win_reason = win_reason
|
||||||
|
match.end_round = end_round
|
||||||
if overall_comment_set:
|
if overall_comment_set:
|
||||||
match.overall_comment = overall_comment or None
|
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)
|
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:
|
def _validate_draft(session: Session, match: Match, data: dict) -> dict:
|
||||||
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
|
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
|
||||||
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
|
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
|
||||||
раскладывает их постепенно, и промежуточное состояние может быть любым."""
|
раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
|
||||||
|
причине не проверяется и правило «последнего выжившего»."""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValidationError("Некорректный черновик.")
|
raise ValidationError("Некорректный черновик.")
|
||||||
blocks = data.get("blocks") or []
|
blocks = data.get("blocks") or []
|
||||||
eliminated = data.get("eliminated") or []
|
eliminated = data.get("eliminated") or []
|
||||||
comments = data.get("comments") or {}
|
comments = data.get("comments") or {}
|
||||||
win_reason = data.get("win_reason")
|
win_reason = data.get("win_reason")
|
||||||
|
end_round = data.get("end_round")
|
||||||
if not isinstance(blocks, list) or not isinstance(eliminated, list):
|
if not isinstance(blocks, list) or not isinstance(eliminated, list):
|
||||||
raise ValidationError("Некорректный черновик.")
|
raise ValidationError("Некорректный черновик.")
|
||||||
if not isinstance(comments, dict):
|
if not isinstance(comments, dict):
|
||||||
raise ValidationError("Некорректный черновик.")
|
raise ValidationError("Некорректный черновик.")
|
||||||
if win_reason is not None and win_reason not in WIN_REASONS:
|
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||||
raise ValidationError("Некорректная причина победы.")
|
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 = {
|
participant_ids = {
|
||||||
p.user_id
|
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()},
|
"comments": {str(k): str(v) for k, v in comments.items()},
|
||||||
"win_reason": win_reason,
|
"win_reason": win_reason,
|
||||||
"overall_comment": overall,
|
"overall_comment": overall,
|
||||||
|
"end_round": end_round,
|
||||||
|
"objectives": objectives,
|
||||||
|
"worlds": worlds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -409,35 +488,41 @@ def update_match(
|
|||||||
overall_comment_set: bool = False,
|
overall_comment_set: bool = False,
|
||||||
win_reason: str | None = None,
|
win_reason: str | None = None,
|
||||||
win_reason_set: bool = False,
|
win_reason_set: bool = False,
|
||||||
|
end_round: int | None = None,
|
||||||
|
end_round_set: bool = False,
|
||||||
participants: list[ParticipantInput] | None = None,
|
participants: list[ParticipantInput] | None = None,
|
||||||
expected_version: str | None = None,
|
expected_version: str | None = None,
|
||||||
) -> Match:
|
) -> Match:
|
||||||
"""Правка партии: состав с местами, дата, комментарий, причина победы.
|
"""Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
|
||||||
|
|
||||||
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они
|
Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
|
||||||
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не
|
иначе они оседали бы в партии со статусом in_progress, которая остаётся
|
||||||
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished').
|
в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
|
||||||
Дату и общий комментарий править можно и по ходу партии — двойственного состояния
|
только status='finished'). Дату и общий комментарий править можно и по ходу
|
||||||
они не создают."""
|
партии — двойственного состояния они не создают."""
|
||||||
results_touched = participants is not None or win_reason_set
|
results_touched = participants is not None or win_reason_set or end_round_set
|
||||||
if results_touched and match.status != "finished":
|
if results_touched and match.status != "finished":
|
||||||
raise ConflictError(
|
raise ConflictError(
|
||||||
"Результаты незавершённой партии нельзя править — сначала завершите её."
|
"Результаты незавершённой партии нельзя править — сначала завершите её."
|
||||||
)
|
)
|
||||||
assert_version(match, expected_version)
|
assert_version(match, expected_version)
|
||||||
if played_at is not None:
|
if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
|
||||||
match.played_at = played_at
|
raise ValidationError("Некорректная причина победы.")
|
||||||
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
|
|
||||||
|
|
||||||
|
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:
|
if participants is not None:
|
||||||
saved = session.exec(
|
|
||||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
|
||||||
).all()
|
|
||||||
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||||
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||||
_validate_roster_basics(
|
_validate_roster_basics(
|
||||||
@@ -451,6 +536,25 @@ def update_match(
|
|||||||
places = _resolve_finish_places(
|
places = _resolve_finish_places(
|
||||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
[(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:
|
for old in saved:
|
||||||
session.delete(old)
|
session.delete(old)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -464,6 +568,8 @@ def update_match(
|
|||||||
eliminated=p.eliminated,
|
eliminated=p.eliminated,
|
||||||
was_random=p.was_random,
|
was_random=p.was_random,
|
||||||
comment=p.comment or None,
|
comment=p.comment or None,
|
||||||
|
objectives=p.objectives,
|
||||||
|
worlds=worlds[p.user_id],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
match.player_count = len(participants)
|
match.player_count = len(participants)
|
||||||
|
|||||||
@@ -316,6 +316,8 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
|
|||||||
"eliminated": p.eliminated,
|
"eliminated": p.eliminated,
|
||||||
"was_random": p.was_random,
|
"was_random": p.was_random,
|
||||||
"comment": p.comment,
|
"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