Правка идущей партии проставляет места, не завершая её (#28) #35

Merged
NotBigGhost merged 2 commits from issue-28-finish-form-sync into dev 2026-09-09 20:03:26 +03:00
6 changed files with 254 additions and 8 deletions
Showing only changes of commit 32d64c94af - Show all commits
+98
View File
@@ -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;
+1
View File
@@ -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"];
+27 -1
View File
@@ -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> => {
+5 -1
View File
@@ -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) {
+107 -6
View File
@@ -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">