Merge pull request 'Правка идущей партии проставляет места, не завершая её (#28)' (#35) from issue-28-finish-form-sync into dev
Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Vendored
+98
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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<MatchRead> =>
|
||||
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<FactionRead> => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
// ─── Совместное заполнение формы ───
|
||||
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
||||
// каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе
|
||||
// отправил бы то, что было на момент его постановки.
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const draftPayload = useRef<MatchFinishDraftData | null>(null);
|
||||
const lastLocalEdit = useRef(0);
|
||||
const appliedDraftAt = useRef<string | null>(null);
|
||||
const iFinished = useRef(false);
|
||||
const prevStatus = useRef<string | null>(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 <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
|
||||
@@ -58,9 +118,28 @@ export function MatchDetailPage() {
|
||||
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
|
||||
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||||
|
||||
// Снимок формы для общего черновика: то, что увидят остальные заполняющие.
|
||||
const draftOf = (patch: Partial<MatchFinishDraftData> = {}): 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-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||||
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||||
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||||
{" · "}
|
||||
{formatTime(match.finish_draft.updated_at)}
|
||||
</p>
|
||||
)}
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
@@ -215,10 +301,19 @@ export function MatchDetailPage() {
|
||||
onChange={(b, e) => {
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 }));
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<MatchMedia
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { MatchMedia } from "../../components/MatchMedia";
|
||||
@@ -33,6 +34,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
const uploadAtt = useAdminUploadAttachment(matchId);
|
||||
const deleteAtt = useAdminDeleteAttachment(matchId);
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [playedAt, setPlayedAt] = useState("");
|
||||
@@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
|
||||
{isLoading || !match ? (
|
||||
<Spinner />
|
||||
) : match.status === "in_progress" ? (
|
||||
// Результаты идущей партии сервер не примет (409): места без завершения
|
||||
// оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
|
||||
<div className="card">
|
||||
<h3>Партия ещё идёт</h3>
|
||||
<p className="muted small">
|
||||
Результаты незавершённой партии правкой не вносятся — их проставляют
|
||||
при завершении, на странице самой партии. Там же участники заполняют
|
||||
места совместно.
|
||||
</p>
|
||||
<button className="btn btn-block" onClick={() => navigate(`/match/${matchId}`)}>
|
||||
Открыть страницу партии
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
|
||||
Reference in New Issue
Block a user