Партия: правка истории и кнопка «Редактировать» у игрока

Правка завершённой партии проверяла состав теми же правилами, что и создание:
участник обязан состоять в группе сейчас, фракция — быть доступной сейчас. После
отключения дополнения партию, сыгранную на Тау, было уже не исправить, а после
удаления игрока из группы — любую партию с ним. Теперь то, что уже записано в
партии, проходит всегда, а новые игроки и фракции по-прежнему берутся только из
текущего состава: чинить историю можно, занести в неё постороннего — нет.
Создание партии не ослабло.

Править завершённую партию умел любой участник группы, но только через API —
кнопки не было, и на практике это мог сделать лишь админ через админку. Теперь у
блока «Результаты» есть «Редактировать», и правка идёт тем же перетаскиванием,
что и завершение: раскладка восстанавливается из сохранённых мест (одинаковое
место — ничья, выбывшие отдельно), рядом — фракции, причина победы и комментарии.
Сохранение шлёт версию партии, так что устаревшая правка отклоняется как раньше.

Фракции вынесены отдельным блоком, а не в PlaceEditor: он занят перетаскиванием,
и селекты внутри него — лишний риск. В списке доступных фракций к набору группы
добавляются те, что уже стоят в партии, — иначе фракцию из отключённого
дополнения нельзя было бы даже оставить как есть.

#29

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 20:16:33 +03:00
co-authored by Claude Opus 5
parent 2986a58ef1
commit 0d81901a63
4 changed files with 370 additions and 8 deletions
+23
View File
@@ -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<MatchRead> =>
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({
+183 -3
View File
@@ -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<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false);
// Правка завершённой партии: тот же редактор мест, что и при завершении.
const [editing, setEditing] = useState(false);
const [editFactions, setEditFactions] = useState<Record<number, number>>({});
// ─── Совместное заполнение формы ───
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
@@ -181,6 +188,77 @@ export function MatchDetailPage() {
}
};
// Раскладка для правки восстанавливается из сохранённых результатов: одинаковое
// место = ничья, выбывшие уходят отдельным списком.
const startEditing = () => {
const byPlace = new Map<number, number[]>();
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 (
<div>
<div className="card">
@@ -226,11 +313,104 @@ export function MatchDetailPage() {
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
</div>
{/* Завершённая партия — правка результатов */}
{!inProgress && editing && (
<>
<div className="card">
<h3>Правка результатов</h3>
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
<PlaceEditor
players={match.participants}
blocks={finishBlocks}
eliminated={elim}
comments={finishComments}
onChange={(b, e) => {
setBlocks(b);
setElim(e);
}}
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
/>
</div>
<div className="card">
<h3>Фракции</h3>
<div className="stack">
{match.participants.map((p) => (
<div key={p.user_id} className="row" style={{ gap: 8 }}>
<b style={{ flex: "0 0 40%" }}>{p.nickname}</b>
<div style={{ flex: 1 }}>
<PickerSelect
selected={factionOptions.find((f) => 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 })}
/>
</div>
</div>
))}
</div>
</div>
<div className="card">
<h3>Причина победы</h3>
<PickerSelect
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
options={REASON_OPTIONS}
placeholder="— причина —"
renderOption={(o) => o.label}
onPick={(o) => setWinReason(o.id)}
/>
</div>
<div className="card">
<h3>О партии</h3>
<textarea
rows={2}
style={{ resize: "none" }}
placeholder="Комментарий о партии"
value={overall}
onChange={(e) => setOverall(e.target.value)}
/>
</div>
{error && <p className="error-text">{error}</p>}
<div className="row" style={{ gap: 8 }}>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={submitEdit}
disabled={updateMatch.isPending}
>
Сохранить
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={cancelEditing}>
Отмена
</button>
</div>
</>
)}
{/* Завершённая партия — результаты */}
{!inProgress && (
{!inProgress && !editing && (
<div className="card">
<h3>Результаты</h3>
<div className="stack">
<div className="row-between">
<h3 style={{ margin: 0 }}>Результаты</h3>
{canModify && (
<button className="btn btn-ghost small" onClick={startEditing}>
Редактировать
</button>
)}
</div>
<div className="stack" style={{ marginTop: 10 }}>
{sorted.map((p) => (
<div key={p.user_id}>
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>