From 0d81901a63d29009a5d60a0432fbde1ce8ea8de5 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 20:16:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D1=82=D0=B8=D1=8F:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=C2=BB=20=D1=83=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правка завершённой партии проверяла состав теми же правилами, что и создание: участник обязан состоять в группе сейчас, фракция — быть доступной сейчас. После отключения дополнения партию, сыгранную на Тау, было уже не исправить, а после удаления игрока из группы — любую партию с ним. Теперь то, что уже записано в партии, проходит всегда, а новые игроки и фракции по-прежнему берутся только из текущего состава: чинить историю можно, занести в неё постороннего — нет. Создание партии не ослабло. Править завершённую партию умел любой участник группы, но только через API — кнопки не было, и на практике это мог сделать лишь админ через админку. Теперь у блока «Результаты» есть «Редактировать», и правка идёт тем же перетаскиванием, что и завершение: раскладка восстанавливается из сохранённых мест (одинаковое место — ничья, выбывшие отдельно), рядом — фракции, причина победы и комментарии. Сохранение шлёт версию партии, так что устаревшая правка отклоняется как раньше. Фракции вынесены отдельным блоком, а не в PlaceEditor: он занят перетаскиванием, и селекты внутри него — лишний риск. В списке доступных фракций к набору группы добавляются те, что уже стоят в партии, — иначе фракцию из отключённого дополнения нельзя было бы даже оставить как есть. #29 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- backend/app/services/match_service.py | 27 +++- backend/tests/test_match_edit.py | 142 +++++++++++++++++++ frontend/src/hooks/matches.ts | 23 +++ frontend/src/pages/MatchDetailPage.tsx | 186 ++++++++++++++++++++++++- 4 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_match_edit.py diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index aecb9ef..e016aca 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -168,16 +168,28 @@ def _validate_roster_basics( group_id: int, user_ids: list[int], faction_ids: list[int], + *, + keep_user_ids: set[int] | None = None, + keep_faction_ids: set[int] | None = None, ) -> None: + """Состав партии: размер, отсутствие дублей, принадлежность группе. + + keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят + независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из + группы игрок делали бы старую партию неисправимой навсегда.""" if len(user_ids) < 2: raise ValidationError("В партии должно быть не менее 2 участников.") if len(user_ids) > MAX_MATCH_PLAYERS: raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.") if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids): raise DuplicateParticipantError() - if not set(user_ids).issubset(_group_member_ids(session, group_id)): + allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set()) + if not set(user_ids).issubset(allowed_users): raise ValidationError("Все участники должны состоять в группе.") - if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)): + allowed_factions = group_service.available_faction_ids(session, group_id) | ( + keep_faction_ids or set() + ) + if not set(faction_ids).issubset(allowed_factions): raise FactionNotAvailableError() @@ -423,18 +435,23 @@ def update_match( match.win_reason = win_reason if participants is not None: + saved = session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + # Что уже записано в партии, остаётся допустимым: состав группы и набор + # дополнений с тех пор могли поменяться, но историю это чинить не мешает. _validate_roster_basics( session, match.group_id, [p.user_id for p in participants], [p.faction_id for p in participants], + keep_user_ids={p.user_id for p in saved}, + keep_faction_ids={p.faction_id for p in saved}, ) places = _resolve_finish_places( [(p.user_id, p.place, p.eliminated) for p in participants] ) - for old in session.exec( - select(MatchParticipant).where(MatchParticipant.match_id == match.id) - ).all(): + for old in saved: session.delete(old) session.flush() for p in participants: diff --git a/backend/tests/test_match_edit.py b/backend/tests/test_match_edit.py new file mode 100644 index 0000000..43b11b8 --- /dev/null +++ b/backend/tests/test_match_edit.py @@ -0,0 +1,142 @@ +"""Правка завершённой партии игроком: история чинится после изменений в группе.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, create_finished_match, csrf_headers, login + + +def _exp_id(client: TestClient, code: str) -> int: + return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code) + + +def _played(client: TestClient, engine) -> tuple[dict, int, int, int, dict]: + """Партия «Аня против Бори» в группе с обоими дополнениями.""" + me = login(client, "Аня") + fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]}, + headers=csrf_headers(client), + ).json()["id"] + b = add_group_member(engine, gid, "Боря") + factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()} + mid = create_finished_match( + client, gid, + [ + {"user_id": me["id"], "faction_id": factions["orks"], "place": 1}, + {"user_id": b, "faction_id": factions["tau"], "place": 2}, + ], + )["id"] + return me, gid, b, mid, factions + + +def _swap_places(client: TestClient, mid: int, me_id: int, b: int, factions: dict) -> dict: + detail = client.get(f"/api/matches/{mid}").json() + fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]} + return { + "participants": [ + {"user_id": me_id, "faction_id": fid[me_id], "place": 2}, + {"user_id": b, "faction_id": fid[b], "place": 1}, + ], + "expected_version": detail["version"], + } + + +def test_edit_after_expansion_disabled(client: TestClient, engine): + """Дополнение выключили — партия на Тау всё равно правится. + + Регрессия: правка проверяла фракции по ТЕКУЩЕМУ набору группы, и партия, + сыгранная на фракции из отключённого дополнения, становилась неисправимой.""" + me, gid, b, mid, factions = _played(client, engine) + + off = client.put( + f"/api/groups/{gid}/expansions", + json={"expansion_ids": [_exp_id(client, "forsaken_voids")]}, + headers=csrf_headers(client), + ) + assert off.status_code == 200, off.text + + r = client.patch( + f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions), + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + places = {p["user_id"]: p["place"] for p in client.get(f"/api/matches/{mid}").json()["participants"]} + assert places[b] == 1 and places[me["id"]] == 2 + + +def test_edit_after_player_left_group(client: TestClient, engine): + """Игрока убрали из группы — партия с ним всё равно правится.""" + me, gid, b, mid, factions = _played(client, engine) + + out = client.delete(f"/api/groups/{gid}/members/{b}", headers=csrf_headers(client)) + assert out.status_code == 200, out.text + + r = client.patch( + f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions), + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + +def test_edit_rejects_new_outsider_and_unavailable_faction(client: TestClient, engine): + """Послабление — только для того, что уже в партии. + + Вписать нового игрока не из группы или фракцию, которой в партии не было и у + группы нет, по-прежнему нельзя: иначе в историю можно занести что угодно.""" + me, gid, b, mid, factions = _played(client, engine) + stranger = client.post( + "/api/auth/dev/users", json={"nickname": "Чужак"}, headers=csrf_headers(client) + ).json() + detail = client.get(f"/api/matches/{mid}").json() + fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]} + + bad_user = client.patch( + f"/api/matches/{mid}", + json={ + "participants": [ + {"user_id": me["id"], "faction_id": fid[me["id"]], "place": 1}, + {"user_id": stranger["id"], "faction_id": fid[b], "place": 2}, + ] + }, + headers=csrf_headers(client), + ) + assert bad_user.status_code == 422, bad_user.text + + # Выключаем дополнение и пробуем поставить ЕГО фракцию, которой в партии не было. + assert client.put( + f"/api/groups/{gid}/expansions", + json={"expansion_ids": [_exp_id(client, "forsaken_voids")]}, + headers=csrf_headers(client), + ).status_code == 200 + bad_faction = client.patch( + f"/api/matches/{mid}", + json={ + "participants": [ + {"user_id": me["id"], "faction_id": factions["necrons"], "place": 1}, + {"user_id": b, "faction_id": fid[b], "place": 2}, + ] + }, + headers=csrf_headers(client), + ) + assert bad_faction.status_code == 422, bad_faction.text + + +def test_create_match_still_validated(client: TestClient, engine): + """Создание партии не ослабло: посторонний игрок по-прежнему отклоняется.""" + me, gid, b, mid, factions = _played(client, engine) + stranger = client.post( + "/api/auth/dev/users", json={"nickname": "Чужак2"}, headers=csrf_headers(client) + ).json() + r = client.post( + "/api/matches", + json={ + "group_id": gid, + "participants": [ + {"user_id": me["id"], "faction_id": factions["orks"]}, + {"user_id": stranger["id"], "faction_id": factions["eldar"]}, + ], + }, + headers=csrf_headers(client), + ) + assert r.status_code == 422, r.text diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 2ccd13b..8945c2e 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -8,6 +8,7 @@ import type { MatchFinish, MatchFinishDraftData, MatchRead, + MatchUpdate, } from "../domain/types"; import { uploadFile } from "../lib/upload"; @@ -88,6 +89,28 @@ export function useFinishMatch() { }); } +/** Правка завершённой партии участником группы (у админки свой эндпойнт). */ +export function useUpdateMatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { matchId: number; body: MatchUpdate }): Promise => + unwrap( + await api.PATCH("/api/matches/{match_id}", { + params: { path: { match_id: args.matchId } }, + body: args.body, + }), + ), + onSuccess: (m) => { + qc.setQueryData(qk.match(m.id), m); + qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); + qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); + // Места изменились — значит изменились лидерборд, история и профили. + for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); + }, + }); +} + + export function useDeleteMatch() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index a40a429..2ad8878 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -19,8 +19,10 @@ import { useFinishMatch, useMatch, useSaveFinishDraft, + useUpdateMatch, useUploadMatchAttachment, } from "../hooks/matches"; +import { useGroupFactions } from "../hooks/reference"; const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); @@ -31,8 +33,10 @@ export function MatchDetailPage() { const id = Number.isInteger(parsed) ? parsed : null; const { data: match, isLoading, refetch } = useMatch(id); const { data: me } = useMe(); + const { data: groupFactions } = useGroupFactions(match?.group_id ?? null); const finish = useFinishMatch(); const saveDraft = useSaveFinishDraft(id); + const updateMatch = useUpdateMatch(); const del = useDeleteMatch(); const uploadAtt = useUploadMatchAttachment(id ?? 0); const deleteAtt = useDeleteMatchAttachment(id ?? 0); @@ -47,6 +51,9 @@ export function MatchDetailPage() { const [overall, setOverall] = useState(""); const [error, setError] = useState(null); const [confirmRemove, setConfirmRemove] = useState(false); + // Правка завершённой партии: тот же редактор мест, что и при завершении. + const [editing, setEditing] = useState(false); + const [editFactions, setEditFactions] = useState>({}); // ─── Совместное заполнение формы ─── // Отложенная запись черновика: он меняется на каждое движение тайла, слать @@ -181,6 +188,77 @@ export function MatchDetailPage() { } }; + // Раскладка для правки восстанавливается из сохранённых результатов: одинаковое + // место = ничья, выбывшие уходят отдельным списком. + const startEditing = () => { + const byPlace = new Map(); + for (const p of match.participants.filter((x) => !x.eliminated)) { + const key = p.place ?? 99; + byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]); + } + setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids)); + setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id)); + setComments( + Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])), + ); + setWinReason((match.win_reason ?? "objectives") as WinReason); + setOverall(match.overall_comment ?? ""); + setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id]))); + setError(null); + setEditing(true); + }; + + const cancelEditing = () => { + setEditing(false); + setBlocks(null); + setComments(null); + setError(null); + }; + + const submitEdit = async () => { + if (!id || !match) return; + setError(null); + const wasRandom = Object.fromEntries( + match.participants.map((p) => [p.user_id, p.was_random]), + ); + const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; + const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({ + user_id: uid, + faction_id: editFactions[uid], + place, + eliminated, + was_random: wasRandom[uid] ?? false, + comment: commentOf(uid), + }); + let place = 1; + const survivors = finishBlocks.flatMap((ids) => { + const rows = ids.map((uid) => rowOf(uid, place, false)); + place += ids.length; // competition ranking: ничья съедает следующие места + return rows; + }); + try { + await updateMatch.mutateAsync({ + matchId: id, + body: { + participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))], + win_reason: winReason, + overall_comment: overall.trim() || null, + expected_version: match.version, + }, + }); + toast.show("Партия обновлена"); + cancelEditing(); + } catch (e) { + if (isStale(e)) { + toast.show("Партия изменилась на другом устройстве — обновлено"); + refetch(); + cancelEditing(); + return; + } + setError(e instanceof ApiError ? e.message : "Не удалось сохранить"); + } + }; + const remove = async () => { if (!id || !match) return; try { @@ -203,6 +281,15 @@ export function MatchDetailPage() { (a, b) => (a.place ?? 99) - (b.place ?? 99), ); + // К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение + // могли отключить после игры, но менять её фракцию это мешать не должно. + const factionOptions = [ + ...(groupFactions ?? []), + ...match.participants + .filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id)) + .map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })), + ]; + return (
@@ -226,11 +313,104 @@ export function MatchDetailPage() { {match.overall_comment &&

{match.overall_comment}

}
+ {/* Завершённая партия — правка результатов */} + {!inProgress && editing && ( + <> +
+

Правка результатов

+

+ Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого + игрока, чтобы разделить место (ничья). +

+ { + setBlocks(b); + setElim(e); + }} + onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })} + /> +
+ +
+

Фракции

+
+ {match.participants.map((p) => ( +
+ {p.nickname} +
+ f.id === editFactions[p.user_id]) ?? null} + options={factionOptions.filter( + (f) => + f.id === editFactions[p.user_id] || + !Object.values(editFactions).includes(f.id), + )} + placeholder="— фракция —" + renderOption={(f) => f.name_ru} + onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })} + /> +
+
+ ))} +
+
+ +
+

Причина победы

+ o.id === winReason) ?? null} + options={REASON_OPTIONS} + placeholder="— причина —" + renderOption={(o) => o.label} + onPick={(o) => setWinReason(o.id)} + /> +
+ +
+

О партии

+