From 8be9a23c0cb29d7879083f2d19529ab936e2bc5d Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 19:39:31 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D1=82=D0=B8=D1=8F:=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D0=BA=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=82=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B4=D0=BE=20=D1=84=D0=B8=D0=BD=D0=B8=D1=88?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Результаты (места и причина победы) теперь пишутся только в завершённую партию. Раньше их можно было проставить идущей: партия оставалась in_progress, висела в «Незавершённых», в статистику не попадала и очков не приносила — победитель есть, а игры как бы не было. Дату и общий комментарий по ходу партии править по-прежнему можно: двойственного состояния они не создают. Форма завершения получила общий черновик (match_finish_drafts): раскладка мест, ничьи, выбывшие, комментарии и причина победы видны всем, кто заполняет партию. Отдельная таблица, а не колонки в matches, намеренно — запись в строку партии дёргает onupdate у updated_at, то есть версию для оптимистичной блокировки, и «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку. Черновик удаляется при завершении и уходит каскадом при удалении партии. Черновик разъезжается отдельным типом SSE-события: он меняется на каждое движение тайла, и полная инвалидация (лидерборд, история, профили) по нему была бы расточительной. Автору правки событие не шлётся. #28 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- .../versions/0012_match_finish_draft.py | 51 +++++++ backend/app/models.py | 31 +++++ backend/app/routers/matches.py | 31 +++++ backend/app/schemas/api.py | 21 +++ backend/app/services/match_service.py | 97 ++++++++++++- backend/app/services/notify.py | 10 ++ backend/tests/test_finish_draft.py | 129 ++++++++++++++++++ 7 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0012_match_finish_draft.py create mode 100644 backend/tests/test_finish_draft.py diff --git a/backend/alembic/versions/0012_match_finish_draft.py b/backend/alembic/versions/0012_match_finish_draft.py new file mode 100644 index 0000000..930c6ed --- /dev/null +++ b/backend/alembic/versions/0012_match_finish_draft.py @@ -0,0 +1,51 @@ +"""Партия: общий черновик формы завершения (совместное заполнение результатов). + +Идемпотентна: на свежей БД таблицу создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД создаёт таблицу. + +Revision ID: 0012_match_finish_draft +Revises: 0011_user_history_prefs +Create Date: 2026-09-09 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0012_match_finish_draft" +down_revision: Union[str, None] = "0011_user_history_prefs" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "match_finish_drafts" in insp.get_table_names(): + return + op.create_table( + "match_finish_drafts", + sa.Column( + "match_id", + sa.Integer(), + sa.ForeignKey("matches.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("data", sa.JSON(), nullable=False), + sa.Column( + "updated_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "match_finish_drafts" in insp.get_table_names(): + op.drop_table("match_finish_drafts") diff --git a/backend/app/models.py b/backend/app/models.py index d206e19..993d68e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -362,6 +362,37 @@ class MatchAttachment(SQLModel, table=True): created_at: datetime = Field(default_factory=utcnow, nullable=False) + +class MatchFinishDraft(SQLModel, table=True): + """Общий черновик формы завершения партии: раскладка мест, ничьи, выбывшие, + комментарии и причина победы, пока партию не завершили. + + Отдельная таблица, а не колонки в matches, намеренно: запись в строку партии + дёргает onupdate у matches.updated_at, а это версия для оптимистичной блокировки — + «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку. + Живёт только пока партия идёт: finish_match удаляет строку, удаление партии + уносит её каскадом.""" + + __tablename__ = "match_finish_drafts" + + match_id: int | None = Field( + default=None, + sa_column=Column( + Integer, ForeignKey("matches.id", ondelete="CASCADE"), primary_key=True + ), + ) + data: dict = Field(sa_column=Column(JSON, nullable=False)) + updated_by: int | None = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + ) + updated_at: datetime = Field( + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, + nullable=False, + ) + # ─── Уведомления ───────────────────────────────────────────────────────────── class Notification(SQLModel, table=True): diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index e5d3b4c..eb16f01 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -60,6 +60,16 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False ) for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] ] + draft_row = match_service.get_finish_draft(session, match.id) # type: ignore[arg-type] + draft = None + if draft_row is not None: + author = session.get(User, draft_row.updated_by) if draft_row.updated_by else None + draft = s.MatchFinishDraftRead( + data=s.MatchFinishDraftData(**draft_row.data), + updated_by=draft_row.updated_by, + updated_by_nickname=author.nickname if author else None, + updated_at=iso_utc(draft_row.updated_at), + ) return s.MatchRead( id=match.id, # type: ignore[arg-type] group_id=match.group_id, @@ -79,6 +89,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False attachment_read(a, f"/api/matches/{match.id}") for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type] ], + finish_draft=draft, ) @@ -233,6 +244,26 @@ def update_match( return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) + +@router.put("/{match_id}/finish-draft", response_model=s.MatchRead) +def save_finish_draft( + match_id: int, + body: s.MatchFinishDraftData, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MatchRead: + """Общий черновик формы завершения: то, что видят все, кто заполняет партию. + + Права те же, что у самой формы. Версию партии запись черновика не двигает — + иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.""" + match = match_service.get_match(session, match_id) + match_service.assert_can_modify(session, match, user) + match_service.save_finish_draft(session, match, user, body.model_dump()) + notify.match_draft_changed(session, match, actor_id=user.id) # type: ignore[arg-type] + return build_match_read( + session, match, can_modify=match_service.can_modify(session, match, user) + ) + # ─── Медиа партии (фото) ────────────────────────────────────────────────────── def _assert_can_attach(session: Session, match: Match, user: User) -> None: diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index aedfa14..4a3288e 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -276,6 +276,25 @@ class AttachmentRead(BaseModel): created_at: str +class MatchFinishDraftData(BaseModel): + """Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие, + комментарии об игроках и причина победы. Промежуточное состояние, поэтому + места не валидируются — человек раскладывает их постепенно.""" + + blocks: list[list[int]] = [] + eliminated: list[int] = [] + comments: dict[str, str] = {} + win_reason: WinReason | None = None + overall_comment: str | None = None + + +class MatchFinishDraftRead(BaseModel): + data: MatchFinishDraftData + updated_by: int | None = None + updated_by_nickname: str | None = None + updated_at: str + + class MatchRead(BaseModel): id: int group_id: int @@ -292,6 +311,8 @@ class MatchRead(BaseModel): version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно participants: list[MatchParticipantRead] = [] attachments: list[AttachmentRead] = [] + # Общий черновик формы завершения (только у незавершённой партии). + finish_draft: MatchFinishDraftRead | None = None # ─── Статистика ────────────────────────────────────────────────────────────── diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index 6639686..aecb9ef 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -17,7 +17,7 @@ from app.core.errors import ( ValidationError, ) from app.core.timeutil import app_today, iso_utc, utcnow -from app.models import Faction, GroupMember, Match, MatchParticipant, User +from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.services import group_service MAX_MATCH_PLAYERS = 6 @@ -286,11 +286,93 @@ def finish_match( match.overall_comment = overall_comment or None session.add(match) + # Содержимое черновика уже в результатах — второй источник правды не нужен. + clear_finish_draft(session, match.id) # type: ignore[arg-type] session.commit() session.refresh(match) return match + +# ─── Черновик завершения (совместное заполнение формы) ──────────────────────── + +def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None: + return session.get(MatchFinishDraft, match_id) + + +def _validate_draft(session: Session, match: Match, data: dict) -> dict: + """Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками + партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек + раскладывает их постепенно, и промежуточное состояние может быть любым.""" + if not isinstance(data, dict): + raise ValidationError("Некорректный черновик.") + blocks = data.get("blocks") or [] + eliminated = data.get("eliminated") or [] + comments = data.get("comments") or {} + win_reason = data.get("win_reason") + if not isinstance(blocks, list) or not isinstance(eliminated, list): + raise ValidationError("Некорректный черновик.") + if not isinstance(comments, dict): + raise ValidationError("Некорректный черновик.") + if win_reason is not None and win_reason not in WIN_REASONS: + raise ValidationError("Некорректная причина победы.") + + participant_ids = { + p.user_id + for p in session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + } + listed: list[int] = [] + for block in blocks: + if not isinstance(block, list): + raise ValidationError("Некорректный черновик.") + listed.extend(block) + listed.extend(eliminated) + if any(not isinstance(uid, int) for uid in listed): + raise ValidationError("Некорректный черновик.") + if set(listed) - participant_ids: + raise ValidationError("В черновике есть игроки не из этой партии.") + overall = data.get("overall_comment") + if overall is not None and not isinstance(overall, str): + raise ValidationError("Некорректный черновик.") + return { + "blocks": blocks, + "eliminated": eliminated, + "comments": {str(k): str(v) for k, v in comments.items()}, + "win_reason": win_reason, + "overall_comment": overall, + } + + +def save_finish_draft( + session: Session, match: Match, user: User, data: dict +) -> MatchFinishDraft: + """Сохранить общий черновик формы завершения (последняя запись побеждает). + + Версию партии (updated_at) намеренно НЕ двигаем: иначе «Завершить» у второго + участника ловил бы STALE_WRITE на каждую чужую правку черновика.""" + if match.status != "in_progress": + raise ConflictError("Партия уже завершена.") + payload = _validate_draft(session, match, data) + draft = session.get(MatchFinishDraft, match.id) + if draft is None: + draft = MatchFinishDraft(match_id=match.id, data=payload, updated_by=user.id) + else: + draft.data = payload + draft.updated_by = user.id + draft.updated_at = utcnow() + session.add(draft) + session.commit() + session.refresh(draft) + return draft + + +def clear_finish_draft(session: Session, match_id: int) -> None: + draft = session.get(MatchFinishDraft, match_id) + if draft is not None: + session.delete(draft) + # ─── Права / правка / удаление ──────────────────────────────────────────────── def can_modify(session: Session, match: Match, user: User) -> bool: @@ -318,7 +400,18 @@ def update_match( participants: list[ParticipantInput] | None = None, expected_version: str | None = None, ) -> Match: - """Правка завершённой партии (админ): полный список участников с местами.""" + """Правка партии: состав с местами, дата, комментарий, причина победы. + + Результаты (места и причина победы) пишутся только в завершённую партию: иначе они + оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не + попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished'). + Дату и общий комментарий править можно и по ходу партии — двойственного состояния + они не создают.""" + results_touched = participants is not None or win_reason_set + if results_touched and match.status != "finished": + raise ConflictError( + "Результаты незавершённой партии нельзя править — сначала завершите её." + ) assert_version(match, expected_version) if played_at is not None: match.played_at = played_at diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index d2bd665..b31482a 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -25,6 +25,16 @@ def match_changed(session: Session, match: Match) -> None: ) +def match_draft_changed(session: Session, match: Match, actor_id: int) -> None: + """Черновик формы завершения изменился — остальным заполняющим из группы. + + Отдельный тип события: черновик меняется на каждое движение тайла, и гнать по нему + полную инвалидацию (лидерборд, история, профили) было бы расточительно. Автору + правки событие не шлём — у него уже актуальное состояние.""" + ids = [uid for uid in _group_member_ids(session, match.group_id) if uid != actor_id] + hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id}) + + def match_removed(session: Session, match_id: int, group_id: int) -> None: """Партия удалена — уведомить участников группы (обновить списки).""" hub.publish( diff --git a/backend/tests/test_finish_draft.py b/backend/tests/test_finish_draft.py new file mode 100644 index 0000000..e63e881 --- /dev/null +++ b/backend/tests/test_finish_draft.py @@ -0,0 +1,129 @@ +"""Черновик формы завершения: совместное заполнение результатов партии. + +Плюс запрет правки незавершённой партии (места без завершения — «результат есть, +а игры как бы не было»).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + + +def _start(client: TestClient, engine) -> tuple[dict, int, int, int]: + 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"] + p2 = add_group_member(engine, gid, "Игрок2") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + started = start_match( + client, gid, + [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], + ) + assert started.status_code == 200, started.text + return me, gid, p2, started.json()["id"] + + +def _draft(client: TestClient, mid: int, body: dict): + return client.put(f"/api/matches/{mid}/finish-draft", json=body, headers=csrf_headers(client)) + + +def test_draft_is_shared_between_participants(client: TestClient, engine): + """Второй участник видит раскладку первого, не перезагружая страницу.""" + me, gid, p2, mid = _start(client, engine) + + r = _draft(client, mid, { + "blocks": [[me["id"]], [p2]], + "eliminated": [], + "comments": {str(p2): "почти успел"}, + "win_reason": "worlds", + }) + assert r.status_code == 200, r.text + assert r.json()["finish_draft"]["data"]["blocks"] == [[me["id"]], [p2]] + + login(client, "Игрок2") + seen = client.get(f"/api/matches/{mid}").json()["finish_draft"] + assert seen["data"]["blocks"] == [[me["id"]], [p2]] + assert seen["data"]["comments"][str(p2)] == "почти успел" + assert seen["data"]["win_reason"] == "worlds" + assert seen["updated_by_nickname"] == "Хост" + + +def test_draft_does_not_move_match_version(client: TestClient, engine): + """Черновик не трогает версию партии. + + Иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую + правку — ровно то, ради чего черновик и делался.""" + me, gid, p2, mid = _start(client, engine) + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + assert _draft(client, mid, {"blocks": [[p2], [me["id"]]]}).status_code == 200 + assert client.get(f"/api/matches/{mid}").json()["version"] == v1 + + # И завершение со «старой» (на деле актуальной) версией проходит. + fin = finish_match( + client, mid, [{"user_id": p2, "place": 1}, {"user_id": me["id"], "place": 2}], + ) + assert fin.status_code == 200, fin.text + + +def test_draft_cleared_after_finish(client: TestClient, engine): + me, gid, p2, mid = _start(client, engine) + assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 200 + + finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]) + assert client.get(f"/api/matches/{mid}").json()["finish_draft"] is None + + # В завершённую партию черновик не пишется. + assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 409 + + +def test_draft_rejects_outsider_and_foreign_players(client: TestClient, engine): + me, gid, p2, mid = _start(client, engine) + stranger = login(client, "Чужак") # в группе не состоит + assert _draft(client, mid, {"blocks": [[me["id"]], [p2]]}).status_code == 403 + + login(client, "Хост") + bad = _draft(client, mid, {"blocks": [[me["id"]], [stranger["id"]]]}) + assert bad.status_code == 422, bad.text + + +def test_in_progress_match_cannot_be_patched(client: TestClient, engine): + """Места и причина победы у идущей партии — только через завершение. + + Иначе партия остаётся in_progress с проставленными местами: висит в + «Незавершённых», но в статистику не попадает и очков не приносит.""" + me, gid, p2, mid = _start(client, engine) + parts = client.get(f"/api/matches/{mid}").json()["participants"] + body = { + "participants": [ + {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1} + for i, p in enumerate(parts) + ], + "win_reason": "objectives", + } + r = client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client)) + assert r.status_code == 409, r.text + assert client.get(f"/api/matches/{mid}").json()["status"] == "in_progress" + + +def test_admin_cannot_patch_in_progress_match(client: TestClient, engine, make_admin): + me, gid, p2, mid = _start(client, engine) + parts = client.get(f"/api/matches/{mid}").json()["participants"] + + make_admin("admin", "secret123") + assert client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ).status_code == 200 + body = { + "participants": [ + {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": i + 1} + for i, p in enumerate(parts) + ], + "win_reason": "objectives", + } + r = client.patch(f"/api/admin/matches/{mid}", json=body, headers=csrf_headers(client)) + assert r.status_code == 409, r.text From 32d64c94af3f51c3511e188e949740fb13ecf9cd Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 19:39:42 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D1=82=D0=B8=D1=8F:=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раскладка, комментарии, причина победы и комментарий о партии уходят в общий черновик через ~0.6с после последнего действия, а чужие правки подтягиваются по SSE. Чужой черновик применяется, только если с момента моего последнего действия прошло больше 1.5с — иначе правка соседа перетирала бы тайл прямо под рукой. Перед «Завершить» отложенная запись дожимается: иначе последняя правка попала бы в результаты, но не в черновик, и второй участник увидел бы не то, что записалось. Само завершение по-прежнему шлёт тело запроса, так что работает и без черновика. Под подсказкой о перетаскивании появилась строка «Результаты заполняет также <Ник>», а если партию завершил кто-то другой — тост вместо молча исчезающей формы. В админке у идущей партии вместо формы правки — пояснение и переход на страницу партии: сервер её результаты всё равно не примет. #28 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- frontend/src/api/schema.d.ts | 98 +++++++++++++++++ frontend/src/domain/types.ts | 1 + frontend/src/hooks/matches.ts | 28 ++++- frontend/src/hooks/useServerEvents.ts | 6 +- frontend/src/pages/MatchDetailPage.tsx | 113 ++++++++++++++++++-- frontend/src/pages/admin/AdminMatchEdit.tsx | 16 +++ 6 files changed, 254 insertions(+), 8 deletions(-) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 2b2a03b..371d564 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -548,6 +548,29 @@ export interface paths { patch: operations["update_match_api_matches__match_id__patch"]; trace?: never; }; + "/api/matches/{match_id}/finish-draft": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Save Finish Draft + * @description Общий черновик формы завершения: то, что видят все, кто заполняет партию. + * + * Права те же, что у самой формы. Версию партии запись черновика не двигает — + * иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку. + */ + put: operations["save_finish_draft_api_matches__match_id__finish_draft_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/matches/{match_id}/attachments": { parameters: { query?: never; @@ -1603,6 +1626,45 @@ export interface components { /** Expected Version */ expected_version?: string | null; }; + /** + * MatchFinishDraftData + * @description Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие, + * комментарии об игроках и причина победы. Промежуточное состояние, поэтому + * места не валидируются — человек раскладывает их постепенно. + */ + MatchFinishDraftData: { + /** + * Blocks + * @default [] + */ + blocks: number[][]; + /** + * Eliminated + * @default [] + */ + eliminated: number[]; + /** + * Comments + * @default {} + */ + comments: { + [key: string]: string; + }; + /** Win Reason */ + win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + /** Overall Comment */ + overall_comment?: string | null; + }; + /** MatchFinishDraftRead */ + MatchFinishDraftRead: { + data: components["schemas"]["MatchFinishDraftData"]; + /** Updated By */ + updated_by?: number | null; + /** Updated By Nickname */ + updated_by_nickname?: string | null; + /** Updated At */ + updated_at: string; + }; /** MatchFinishParticipant */ MatchFinishParticipant: { /** User Id */ @@ -1775,6 +1837,7 @@ export interface components { * @default [] */ attachments: components["schemas"]["AttachmentRead"][]; + finish_draft?: components["schemas"]["MatchFinishDraftRead"] | null; }; /** MatchUpdate */ MatchUpdate: { @@ -3238,6 +3301,41 @@ export interface operations { }; }; }; + save_finish_draft_api_matches__match_id__finish_draft_put: { + parameters: { + query?: never; + header?: never; + path: { + match_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MatchFinishDraftData"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; add_attachment_api_matches__match_id__attachments_post: { parameters: { query?: never; diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index baf132b..3230efb 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -33,3 +33,4 @@ export type ParticipantInput = S["ParticipantInput"]; export type MatchUpdate = S["MatchUpdate"]; export type MatchCreate = S["MatchCreate"]; export type MatchFinish = S["MatchFinish"]; +export type MatchFinishDraftData = S["MatchFinishDraftData"]; diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index fd8fc74..2ccd13b 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -2,7 +2,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, unwrap } from "../api/client"; import { matchAffectedKeys, qk } from "../api/queryKeys"; -import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types"; +import type { + FactionRead, + MatchCreate, + MatchFinish, + MatchFinishDraftData, + MatchRead, +} from "../domain/types"; import { uploadFile } from "../lib/upload"; export function useMatch(matchId: number | null) { @@ -20,6 +26,26 @@ export function useMatch(matchId: number | null) { }); } +/** + * Общий черновик формы завершения: то, что видят все, кто заполняет партию. + * Ответ — свежая партия целиком, поэтому кладём его прямо в кэш, без инвалидации: + * перезапрос на каждое движение тайла был бы расточительным. + */ +export function useSaveFinishDraft(matchId: number | null) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (data: MatchFinishDraftData): Promise => + unwrap( + await api.PUT("/api/matches/{match_id}/finish-draft", { + params: { path: { match_id: matchId as number } }, + body: data, + }), + ), + onSuccess: (m) => qc.setQueryData(qk.match(m.id), m), + }); +} + + export function useRandomizeFaction() { return useMutation({ mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise => { diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index 3b853c4..90a4258 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { matchAffectedKeys, qk } from "../api/queryKeys"; interface ServerEvent { - type: "match" | "group" | "invitations" | "notifications"; + type: "match" | "match_draft" | "group" | "invitations" | "notifications"; match_id?: number; group_id?: number; } @@ -32,6 +32,10 @@ export function useServerEvents(enabled: boolean) { qc.invalidateQueries({ queryKey: qk.invitations }); } else if (ev.type === "notifications") { qc.invalidateQueries({ queryKey: qk.notifications }); + } else if (ev.type === "match_draft") { + // Черновик меняется на каждое движение тайла: трогаем только саму партию, + // без лидерборда и историй — иначе группа захлебнётся перезапросами. + if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) }); } else if (ev.type === "match") { if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) }); if (ev.group_id != null) { diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 923eca7..a40a429 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; @@ -9,13 +9,16 @@ import { PlaceEditor } from "../components/PlaceEditor"; import { PlayerLink } from "../components/PlayerLink"; import { Spinner } from "../components/Spinner"; import { formatDate, formatDuration, formatTime } from "../domain/format"; +import type { MatchFinishDraftData } from "../domain/types"; import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; import { useToast } from "../context/ToastContext"; +import { useMe } from "../hooks/auth"; import { useDeleteMatch, useDeleteMatchAttachment, useFinishMatch, useMatch, + useSaveFinishDraft, useUploadMatchAttachment, } from "../hooks/matches"; @@ -27,7 +30,9 @@ export function MatchDetailPage() { const parsed = matchId ? Number(matchId) : NaN; const id = Number.isInteger(parsed) ? parsed : null; const { data: match, isLoading, refetch } = useMatch(id); + const { data: me } = useMe(); const finish = useFinishMatch(); + const saveDraft = useSaveFinishDraft(id); const del = useDeleteMatch(); const uploadAtt = useUploadMatchAttachment(id ?? 0); const deleteAtt = useDeleteMatchAttachment(id ?? 0); @@ -43,6 +48,61 @@ export function MatchDetailPage() { const [error, setError] = useState(null); const [confirmRemove, setConfirmRemove] = useState(false); + // ─── Совместное заполнение формы ─── + // Отложенная запись черновика: он меняется на каждое движение тайла, слать + // каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе + // отправил бы то, что было на момент его постановки. + const draftTimer = useRef | null>(null); + const draftPayload = useRef(null); + const lastLocalEdit = useRef(0); + const appliedDraftAt = useRef(null); + const iFinished = useRef(false); + const prevStatus = useRef(null); + + const sendDraft = () => { + draftTimer.current = null; + if (draftPayload.current && id) saveDraft.mutate(draftPayload.current); + }; + + const queueDraft = (payload: MatchFinishDraftData) => { + draftPayload.current = payload; + lastLocalEdit.current = Date.now(); + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(sendDraft, 600); + }; + + useEffect( + () => () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }, + [], + ); + + // Чужой черновик применяем, только если человек сейчас ничего не двигает: + // иначе правка соседа перетёрла бы тайл прямо под рукой. + const incoming = match?.finish_draft; + useEffect(() => { + if (!incoming || match?.status !== "in_progress") return; + if (incoming.updated_by === me?.id) return; + if (incoming.updated_at === appliedDraftAt.current) return; + if (Date.now() - lastLocalEdit.current < 1500) return; + setBlocks(incoming.data.blocks); + setElim(incoming.data.eliminated); + setComments(incoming.data.comments); + setWinReason((incoming.data.win_reason ?? "objectives") as WinReason); + setOverall(incoming.data.overall_comment ?? ""); + appliedDraftAt.current = incoming.updated_at; + }, [incoming, match?.status, me?.id]); + + // Партию завершил кто-то другой — форма исчезает, и без этого молча. + useEffect(() => { + if (!match) return; + if (prevStatus.current === "in_progress" && match.status === "finished" && !iFinished.current) { + toast.show("Партию завершил другой участник — показаны его результаты"); + } + prevStatus.current = match.status; + }, [match, toast]); + if (isLoading) return ; if (!match) return
Партия не найдена.
; @@ -58,9 +118,28 @@ export function MatchDetailPage() { // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; + // Снимок формы для общего черновика: то, что увидят остальные заполняющие. + const draftOf = (patch: Partial = {}): MatchFinishDraftData => ({ + blocks: finishBlocks, + eliminated: elim, + comments: Object.fromEntries( + Object.entries(finishComments).map(([uid, text]) => [String(uid), text]), + ), + win_reason: winReason, + overall_comment: overall.trim() || null, + ...patch, + }); + const submitFinish = async () => { if (!id || !match) return; setError(null); + // Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты, + // но не в черновик, и второй участник увидел бы не то, что записалось. + if (draftTimer.current) { + clearTimeout(draftTimer.current); + sendDraft(); + } + iFinished.current = true; const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; let place = 1; const survivors = finishBlocks.flatMap((ids) => { @@ -207,6 +286,13 @@ export function MatchDetailPage() { Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы разделить место (ничья).

+ {match.finish_draft && match.finish_draft.updated_by !== me?.id && ( +

+ Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"} + {" · "} + {formatTime(match.finish_draft.updated_at)} +

+ )} { setBlocks(b); setElim(e); + queueDraft(draftOf({ blocks: b, eliminated: e })); + }} + onComment={(uid, text) => { + const next = { ...finishComments, [uid]: text }; + setComments(next); + queueDraft( + draftOf({ + comments: Object.fromEntries( + Object.entries(next).map(([k, v]) => [String(k), v]), + ), + }), + ); }} - onComment={(uid, text) => - setComments({ ...finishComments, [uid]: text }) - } /> @@ -229,7 +324,10 @@ export function MatchDetailPage() { options={REASON_OPTIONS} placeholder="— причина —" renderOption={(o) => o.label} - onPick={(o) => setWinReason(o.id)} + onPick={(o) => { + setWinReason(o.id); + queueDraft(draftOf({ win_reason: o.id })); + }} /> @@ -240,7 +338,10 @@ export function MatchDetailPage() { style={{ resize: "none" }} placeholder="Комментарий о партии" value={overall} - onChange={(e) => setOverall(e.target.value)} + onChange={(e) => { + setOverall(e.target.value); + queueDraft(draftOf({ overall_comment: e.target.value.trim() || null })); + }} />
([]); const [playedAt, setPlayedAt] = useState(""); @@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: {isLoading || !match ? ( + ) : match.status === "in_progress" ? ( + // Результаты идущей партии сервер не примет (409): места без завершения + // оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой. +
+

Партия ещё идёт

+

+ Результаты незавершённой партии правкой не вносятся — их проставляют + при завершении, на странице самой партии. Там же участники заполняют + места совместно. +

+ +
) : ( <>