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
--
2.54.0
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-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
([]);
const [playedAt, setPlayedAt] = useState("");
@@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
{isLoading || !match ? (
+ ) : match.status === "in_progress" ? (
+ // Результаты идущей партии сервер не примет (409): места без завершения
+ // оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
+
+
Партия ещё идёт
+
+ Результаты незавершённой партии правкой не вносятся — их проставляют
+ при завершении, на странице самой партии. Там же участники заполняют
+ места совместно.
+