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): места без завершения
+ // оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
+
+
Партия ещё идёт
+
+ Результаты незавершённой партии правкой не вносятся — их проставляют
+ при завершении, на странице самой партии. Там же участники заполняют
+ места совместно.
+