Рейтинг: ввод деталей партии и предупреждения в формах
Форма завершения и правка результатов (MatchDetailPage): у каждого игрока
в PlaceEditor поля «цели» и «миры» на конец партии (у выбывшего миры
заблокированы нулём), общий блок «Итог партии» — причина победы и раунд
окончания из max_rounds партии. Всё новое уходит в общий черновик.
Причина «последний выживший» ставится сама при одном невыбывшем, выбор
заблокирован; вернули второго — причина сбрасывается и её нужно выбрать.
То же в админской правке партии.
Предупреждения (domain/finishWarnings.ts, отправку не блокируют): тип победы
и цели/миры лидеров не согласуются, досрочный конец без N целей у победителя,
у соперника целей больше, чем у победителя. Настройки группы: переключатель
«9 раундов при 5–6 игроках» через PATCH /groups/{id}. schema.d.ts
перегенерирован. #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
@@ -13,8 +13,8 @@ import {
|
||||
useGroup,
|
||||
useGroupMembers,
|
||||
useRemoveMember,
|
||||
useRenameGroup,
|
||||
useSetGroupExpansions,
|
||||
useUpdateGroup,
|
||||
} from "../hooks/groups";
|
||||
import { useExpansions } from "../hooks/reference";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GroupSettingsPage() {
|
||||
const { data: expansions } = useExpansions();
|
||||
const { data: members } = useGroupMembers(groupId);
|
||||
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
||||
const renameGroup = useRenameGroup(groupId ?? 0);
|
||||
const updateGroup = useUpdateGroup(groupId ?? 0);
|
||||
const removeMember = useRemoveMember(groupId ?? 0);
|
||||
const toast = useToast();
|
||||
|
||||
@@ -55,7 +55,7 @@ export function GroupSettingsPage() {
|
||||
const trimmed = nameValue.trim();
|
||||
if (!trimmed || trimmed === group.name) return;
|
||||
try {
|
||||
await renameGroup.mutateAsync(trimmed);
|
||||
await updateGroup.mutateAsync({ name: trimmed });
|
||||
setName(null);
|
||||
toast.show("Название сохранено");
|
||||
} catch (e) {
|
||||
@@ -63,6 +63,15 @@ export function GroupSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveNineRounds = async (enabled: boolean) => {
|
||||
try {
|
||||
await updateGroup.mutateAsync({ nine_rounds_rule: enabled });
|
||||
toast.show(enabled ? "9 раундов при 5–6 игроках включено" : "Правило 9 раундов выключено");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const saveExpansions = async () => {
|
||||
try {
|
||||
await setExpansions.mutateAsync([...selected]);
|
||||
@@ -95,7 +104,7 @@ export function GroupSettingsPage() {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={saveName}
|
||||
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
|
||||
disabled={updateGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
@@ -127,6 +136,22 @@ export function GroupSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Домашние правила</h3>
|
||||
<label className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>9 раундов при 5–6 игроках</span>
|
||||
<Switch
|
||||
checked={group.nine_rounds_rule}
|
||||
disabled={updateGroup.isPending}
|
||||
onChange={saveNineRounds}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||
Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных
|
||||
партий не меняется.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}
|
||||
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ApiError } from "../api/client";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { MatchMedia } from "../components/MatchMedia";
|
||||
import { MatchOutcomeFields } from "../components/MatchOutcomeFields";
|
||||
import { PickerSelect } from "../components/PickerSelect";
|
||||
import { PlaceEditor } from "../components/PlaceEditor";
|
||||
import { type CountField, type Counts, PlaceEditor } from "../components/PlaceEditor";
|
||||
import { PlayerLink } from "../components/PlayerLink";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings";
|
||||
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
||||
import type { MatchFinishDraftData } from "../domain/types";
|
||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
import type { MatchFinishDraftData, MatchRead } from "../domain/types";
|
||||
import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import {
|
||||
@@ -24,7 +26,31 @@ import {
|
||||
} from "../hooks/matches";
|
||||
import { useGroupFactions } from "../hooks/reference";
|
||||
|
||||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||
type Participant = MatchRead["participants"][number];
|
||||
|
||||
const countsOf = (participants: Participant[]): Counts =>
|
||||
Object.fromEntries(
|
||||
participants.map((p) => [p.user_id, { objectives: p.objectives ?? null, worlds: p.worlds ?? null }]),
|
||||
);
|
||||
|
||||
const countsFromDraft = (participants: Participant[], data: MatchFinishDraftData): Counts =>
|
||||
Object.fromEntries(
|
||||
participants.map((p) => [
|
||||
p.user_id,
|
||||
{
|
||||
objectives: data.objectives?.[String(p.user_id)] ?? null,
|
||||
worlds: data.worlds?.[String(p.user_id)] ?? null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Черновик хранит только заполненные поля: {user_id строкой: число}.
|
||||
const countDict = (counts: Counts, field: CountField): Record<string, number> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(counts).flatMap(([uid, c]) => (c[field] == null ? [] : [[uid, c[field]]])),
|
||||
);
|
||||
|
||||
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
||||
|
||||
export function MatchDetailPage() {
|
||||
const { matchId } = useParams();
|
||||
@@ -47,7 +73,10 @@ export function MatchDetailPage() {
|
||||
const [blocks, setBlocks] = useState<number[][] | null>(null);
|
||||
const [elim, setElim] = useState<number[]>([]);
|
||||
const [comments, setComments] = useState<Record<number, string> | null>(null);
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [counts, setCounts] = useState<Counts | null>(null);
|
||||
// null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors).
|
||||
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
|
||||
const [endRound, setEndRound] = useState<number | null>(null);
|
||||
const [overall, setOverall] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
@@ -86,23 +115,26 @@ export function MatchDetailPage() {
|
||||
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
||||
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
||||
const incoming = match?.finish_draft;
|
||||
const participants = match?.participants;
|
||||
useEffect(() => {
|
||||
if (!incoming || match?.status !== "in_progress") return;
|
||||
if (!incoming || !participants || 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);
|
||||
setCounts(countsFromDraft(participants, incoming.data));
|
||||
setWinReason(incoming.data.win_reason ?? null);
|
||||
setEndRound(incoming.data.end_round ?? null);
|
||||
setOverall(incoming.data.overall_comment ?? "");
|
||||
appliedDraftAt.current = incoming.updated_at;
|
||||
}, [incoming, match?.status, me?.id]);
|
||||
}, [incoming, participants, match?.status, me?.id]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
|
||||
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin)
|
||||
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
|
||||
const inProgress = match.status === "in_progress";
|
||||
|
||||
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
|
||||
@@ -110,6 +142,7 @@ export function MatchDetailPage() {
|
||||
const finishComments: Record<number, string> =
|
||||
comments ??
|
||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""]));
|
||||
const finishCounts: Counts = counts ?? countsOf(match.participants);
|
||||
|
||||
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
|
||||
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||||
@@ -123,19 +156,64 @@ export function MatchDetailPage() {
|
||||
),
|
||||
win_reason: winReason,
|
||||
overall_comment: overall.trim() || null,
|
||||
end_round: endRound,
|
||||
objectives: countDict(finishCounts, "objectives"),
|
||||
worlds: countDict(finishCounts, "worlds"),
|
||||
...patch,
|
||||
});
|
||||
|
||||
const submitFinish = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
||||
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
||||
if (draftTimer.current) {
|
||||
clearTimeout(draftTimer.current);
|
||||
sendDraft();
|
||||
}
|
||||
// Раскладка изменилась: при одном выжившем причина становится «последний выживший»,
|
||||
// при возврате второго — сбрасывается.
|
||||
const applyLayout = (b: number[][], e: number[]): WinReason | null => {
|
||||
const reason = reasonForSurvivors(winReason, survivorsIn(b));
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
setWinReason(reason);
|
||||
return reason;
|
||||
};
|
||||
|
||||
const applyCount = (uid: number, field: CountField, value: number | null): Counts => {
|
||||
const current = finishCounts[uid] ?? { objectives: null, worlds: null };
|
||||
const next = { ...finishCounts, [uid]: { ...current, [field]: value } };
|
||||
setCounts(next);
|
||||
return next;
|
||||
};
|
||||
|
||||
const outcomeSeats = (): OutcomeSeat[] => {
|
||||
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
||||
const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({
|
||||
userId: uid,
|
||||
nickname: byId.get(uid)?.nickname ?? "",
|
||||
place,
|
||||
eliminated,
|
||||
objectives: finishCounts[uid]?.objectives ?? null,
|
||||
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
||||
});
|
||||
let place = 1;
|
||||
const rows = finishBlocks.flatMap((ids) => {
|
||||
const out = ids.map((uid) => seat(uid, place, false));
|
||||
place += ids.length;
|
||||
return out;
|
||||
});
|
||||
return [...rows, ...elim.map((uid) => seat(uid, place, true))];
|
||||
};
|
||||
|
||||
const warnings = () =>
|
||||
finishWarnings({
|
||||
seats: outcomeSeats(),
|
||||
winReason,
|
||||
endRound,
|
||||
maxRounds: match.max_rounds,
|
||||
});
|
||||
|
||||
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
|
||||
const resultRows = () => {
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
const countsFor = (uid: number, eliminated: boolean) => ({
|
||||
objectives: finishCounts[uid]?.objectives ?? null,
|
||||
// У выбывшего миров нет — сервер сам запишет 0.
|
||||
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||
});
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => ({
|
||||
@@ -143,24 +221,41 @@ export function MatchDetailPage() {
|
||||
place,
|
||||
eliminated: false,
|
||||
comment: commentOf(uid),
|
||||
...countsFor(uid, false),
|
||||
}));
|
||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||
return rows;
|
||||
});
|
||||
const eliminated = elim.map((uid) => ({
|
||||
user_id: uid,
|
||||
place: null,
|
||||
eliminated: true,
|
||||
comment: commentOf(uid),
|
||||
...countsFor(uid, true),
|
||||
}));
|
||||
return [...survivors, ...eliminated];
|
||||
};
|
||||
|
||||
const submitFinish = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
if (!winReason) {
|
||||
setError("Выберите причину победы.");
|
||||
return;
|
||||
}
|
||||
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
||||
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
||||
if (draftTimer.current) {
|
||||
clearTimeout(draftTimer.current);
|
||||
sendDraft();
|
||||
}
|
||||
try {
|
||||
await finish.mutateAsync({
|
||||
matchId: id,
|
||||
body: {
|
||||
participants: [
|
||||
...survivors,
|
||||
...elim.map((uid) => ({
|
||||
user_id: uid,
|
||||
place: null,
|
||||
eliminated: true,
|
||||
comment: commentOf(uid),
|
||||
})),
|
||||
],
|
||||
participants: resultRows(),
|
||||
win_reason: winReason,
|
||||
end_round: endRound,
|
||||
overall_comment: overall.trim() || null,
|
||||
expected_version: match.version,
|
||||
},
|
||||
@@ -188,7 +283,9 @@ export function MatchDetailPage() {
|
||||
setComments(
|
||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
||||
);
|
||||
setWinReason((match.win_reason ?? "objectives") as WinReason);
|
||||
setCounts(countsOf(match.participants));
|
||||
setWinReason((match.win_reason ?? null) as WinReason | null);
|
||||
setEndRound(match.end_round ?? null);
|
||||
setOverall(match.overall_comment ?? "");
|
||||
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||||
setError(null);
|
||||
@@ -199,36 +296,31 @@ export function MatchDetailPage() {
|
||||
setEditing(false);
|
||||
setBlocks(null);
|
||||
setComments(null);
|
||||
setCounts(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
if (!winReason) {
|
||||
setError("Выберите причину победы.");
|
||||
return;
|
||||
}
|
||||
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))],
|
||||
participants: resultRows().map((r) => ({
|
||||
...r,
|
||||
faction_id: editFactions[r.user_id],
|
||||
was_random: wasRandom[r.user_id] ?? false,
|
||||
})),
|
||||
win_reason: winReason,
|
||||
end_round: endRound,
|
||||
overall_comment: overall.trim() || null,
|
||||
expected_version: match.version,
|
||||
},
|
||||
@@ -276,6 +368,13 @@ export function MatchDetailPage() {
|
||||
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||||
];
|
||||
|
||||
const placeHint = (
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы
|
||||
разделить место (ничья). Цели и миры — на конец партии.
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
@@ -294,6 +393,9 @@ export function MatchDetailPage() {
|
||||
{!inProgress && (
|
||||
<div className="small" style={{ marginTop: 4 }}>
|
||||
Победа: <b>{winReasonLabel(match.win_reason)}</b>
|
||||
{match.end_round != null && (
|
||||
<span className="muted"> · конец в {match.end_round}-м раунде из {match.max_rounds}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
||||
@@ -304,20 +406,16 @@ export function MatchDetailPage() {
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Правка результатов</h3>
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
{placeHint}
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
eliminated={elim}
|
||||
comments={finishComments}
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
}}
|
||||
counts={finishCounts}
|
||||
onChange={applyLayout}
|
||||
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
||||
onCount={applyCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -345,16 +443,14 @@ export function MatchDetailPage() {
|
||||
</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>
|
||||
<MatchOutcomeFields
|
||||
winReason={winReason}
|
||||
onReason={setWinReason}
|
||||
endRound={endRound}
|
||||
onEndRound={setEndRound}
|
||||
maxRounds={match.max_rounds}
|
||||
warnings={warnings()}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<h3>О партии</h3>
|
||||
@@ -413,6 +509,11 @@ export function MatchDetailPage() {
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
{p.was_random && <span className="badge">🎲</span>}
|
||||
</PlayerLink>
|
||||
{(p.objectives != null || p.worlds != null) && (
|
||||
<div className="small muted" style={{ marginLeft: 32 }}>
|
||||
цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"}
|
||||
</div>
|
||||
)}
|
||||
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
|
||||
</div>
|
||||
))}
|
||||
@@ -448,10 +549,7 @@ export function MatchDetailPage() {
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Места</h3>
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
{placeHint}
|
||||
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||||
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||||
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||||
@@ -464,10 +562,10 @@ export function MatchDetailPage() {
|
||||
blocks={finishBlocks}
|
||||
eliminated={elim}
|
||||
comments={finishComments}
|
||||
counts={finishCounts}
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
queueDraft(draftOf({ blocks: b, eliminated: e }));
|
||||
const reason = applyLayout(b, e);
|
||||
queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason }));
|
||||
}}
|
||||
onComment={(uid, text) => {
|
||||
const next = { ...finishComments, [uid]: text };
|
||||
@@ -480,23 +578,33 @@ export function MatchDetailPage() {
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</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);
|
||||
queueDraft(draftOf({ win_reason: o.id }));
|
||||
onCount={(uid, field, value) => {
|
||||
const next = applyCount(uid, field, value);
|
||||
queueDraft(
|
||||
draftOf({
|
||||
objectives: countDict(next, "objectives"),
|
||||
worlds: countDict(next, "worlds"),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MatchOutcomeFields
|
||||
winReason={winReason}
|
||||
onReason={(reason) => {
|
||||
setWinReason(reason);
|
||||
queueDraft(draftOf({ win_reason: reason }));
|
||||
}}
|
||||
endRound={endRound}
|
||||
onEndRound={(round) => {
|
||||
setEndRound(round);
|
||||
queueDraft(draftOf({ end_round: round }));
|
||||
}}
|
||||
maxRounds={match.max_rounds}
|
||||
warnings={warnings()}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<h3>О партии</h3>
|
||||
<textarea
|
||||
|
||||
@@ -5,7 +5,14 @@ import { ApiError } from "../../api/client";
|
||||
import { MatchMedia } from "../../components/MatchMedia";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { Switch } from "../../components/Switch";
|
||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||
import { finishWarnings } from "../../domain/finishWarnings";
|
||||
import {
|
||||
LAST_STANDING,
|
||||
reasonForSurvivors,
|
||||
WIN_REASONS,
|
||||
type WinReason,
|
||||
winReasonLabel,
|
||||
} from "../../domain/winReasons";
|
||||
import {
|
||||
useAdminDeleteAttachment,
|
||||
useAdminFactions,
|
||||
@@ -23,8 +30,16 @@ interface Row {
|
||||
eliminated: boolean;
|
||||
was_random: boolean;
|
||||
comment: string;
|
||||
objectives: number | null;
|
||||
worlds: number | null;
|
||||
}
|
||||
|
||||
// Пустое поле — «не указано» (null); иначе целое 0..99.
|
||||
const parseCount = (raw: string): number | null => {
|
||||
const num = Number(raw.trim());
|
||||
return raw.trim() === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num)));
|
||||
};
|
||||
|
||||
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||
const { data: match, isLoading } = useAdminMatch(matchId);
|
||||
const { data: factions } = useAdminFactions();
|
||||
@@ -37,7 +52,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [playedAt, setPlayedAt] = useState("");
|
||||
const [overall, setOverall] = useState("");
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
|
||||
const [endRound, setEndRound] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,19 +67,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
eliminated: p.eliminated,
|
||||
was_random: p.was_random,
|
||||
comment: p.comment ?? "",
|
||||
objectives: p.objectives ?? null,
|
||||
worlds: p.worlds ?? null,
|
||||
})),
|
||||
);
|
||||
setPlayedAt(match.played_at);
|
||||
setOverall(match.overall_comment ?? "");
|
||||
if (match.win_reason) setWinReason(match.win_reason);
|
||||
setWinReason(match.win_reason ?? null);
|
||||
setEndRound(match.end_round ?? null);
|
||||
}
|
||||
}, [match]);
|
||||
|
||||
const upd = (i: number, patch: Partial<Row>) =>
|
||||
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
const upd = (i: number, patch: Partial<Row>) => {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||
setRows(next);
|
||||
// Выбывание меняет число выживших: «последний выживший» ставится и снимается сам.
|
||||
if ("eliminated" in patch) {
|
||||
setWinReason(reasonForSurvivors(winReason, next.filter((r) => !r.eliminated).length));
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
if (!winReason) {
|
||||
setError("Выберите причину победы.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
matchId,
|
||||
@@ -71,6 +100,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
played_at: playedAt,
|
||||
overall_comment: overall.trim() || null,
|
||||
win_reason: winReason,
|
||||
end_round: endRound,
|
||||
participants: rows.map((r) => ({
|
||||
user_id: r.user_id,
|
||||
faction_id: r.faction_id,
|
||||
@@ -78,6 +108,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
eliminated: r.eliminated,
|
||||
was_random: r.was_random,
|
||||
comment: r.comment.trim() || null,
|
||||
objectives: r.objectives,
|
||||
worlds: r.eliminated ? null : r.worlds,
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -87,6 +119,22 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
}
|
||||
};
|
||||
|
||||
const warnings = match
|
||||
? finishWarnings({
|
||||
seats: rows.map((r) => ({
|
||||
userId: r.user_id,
|
||||
nickname: r.nickname,
|
||||
place: r.place,
|
||||
eliminated: r.eliminated,
|
||||
objectives: r.objectives,
|
||||
worlds: r.eliminated ? 0 : r.worlds,
|
||||
})),
|
||||
winReason,
|
||||
endRound,
|
||||
maxRounds: match.max_rounds,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
|
||||
<div
|
||||
@@ -159,23 +207,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
выбыл
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий об игроке"
|
||||
value={r.comment}
|
||||
onChange={(e) => upd(i, { comment: e.target.value })}
|
||||
/>
|
||||
<div className="row">
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Комментарий об игроке"
|
||||
value={r.comment}
|
||||
onChange={(e) => upd(i, { comment: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
style={{ flex: "0 0 70px" }}
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
placeholder="цели"
|
||||
title="Цели на конец партии"
|
||||
value={r.objectives ?? ""}
|
||||
onChange={(e) => upd(i, { objectives: parseCount(e.target.value) })}
|
||||
/>
|
||||
<input
|
||||
style={{ flex: "0 0 70px" }}
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
placeholder="миры"
|
||||
title="Миры на конец партии"
|
||||
disabled={r.eliminated}
|
||||
value={r.eliminated ? 0 : (r.worlds ?? "")}
|
||||
onChange={(e) => upd(i, { worlds: parseCount(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Причина победы</label>
|
||||
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}>
|
||||
{WIN_REASONS.map((w) => (
|
||||
<option key={w.code} value={w.code}>
|
||||
{w.label}
|
||||
{winReason === LAST_STANDING ? (
|
||||
<select value={LAST_STANDING} disabled>
|
||||
<option value={LAST_STANDING}>{winReasonLabel(LAST_STANDING)}</option>
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={winReason ?? ""}
|
||||
onChange={(e) => setWinReason(e.target.value as WinReason)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
— выберите причину —
|
||||
</option>
|
||||
{WIN_REASONS.map((w) => (
|
||||
<option key={w.code} value={w.code}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Раунд окончания (из {match.max_rounds})</label>
|
||||
<select
|
||||
value={endRound ?? ""}
|
||||
onChange={(e) => setEndRound(e.target.value === "" ? null : Number(e.target.value))}
|
||||
>
|
||||
<option value="">не указан</option>
|
||||
{Array.from({ length: match.max_rounds }, (_, k) => k + 1).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}-й
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{warnings.length > 0 && (
|
||||
<ul className="warn-list">
|
||||
{warnings.map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
|
||||
Reference in New Issue
Block a user