From cc64bb46444dfb653d0b58a2d8af34bc7feabcad Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 22:45:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:=20?= =?UTF-8?q?=D0=B2=D0=B2=D0=BE=D0=B4=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B8=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форма завершения и правка результатов (MatchDetailPage): у каждого игрока в PlaceEditor поля «цели» и «миры» на конец партии (у выбывшего миры заблокированы нулём), общий блок «Итог партии» — причина победы и раунд окончания из max_rounds партии. Всё новое уходит в общий черновик. Причина «последний выживший» ставится сама при одном невыбывшем, выбор заблокирован; вернули второго — причина сбрасывается и её нужно выбрать. То же в админской правке партии. Предупреждения (domain/finishWarnings.ts, отправку не блокируют): тип победы и цели/миры лидеров не согласуются, досрочный конец без N целей у победителя, у соперника целей больше, чем у победителя. Настройки группы: переключатель «9 раундов при 5–6 игроках» через PATCH /groups/{id}. schema.d.ts перегенерирован. #23 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- frontend/src/api/schema.d.ts | 85 +++++- .../src/components/MatchOutcomeFields.tsx | 76 +++++ frontend/src/components/PlaceEditor.tsx | 59 +++- frontend/src/domain/finishWarnings.ts | 101 +++++++ frontend/src/domain/winReasons.ts | 22 +- frontend/src/hooks/groups.ts | 7 +- frontend/src/pages/GroupSettingsPage.tsx | 33 ++- frontend/src/pages/MatchDetailPage.tsx | 272 ++++++++++++------ frontend/src/pages/admin/AdminMatchEdit.tsx | 134 ++++++++- frontend/src/styles/global.css | 24 ++ 10 files changed, 683 insertions(+), 130 deletions(-) create mode 100644 frontend/src/components/MatchOutcomeFields.tsx create mode 100644 frontend/src/domain/finishWarnings.ts diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d245781..427a5eb 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -377,8 +377,11 @@ export interface paths { delete?: never; options?: never; head?: never; - /** Rename Group */ - patch: operations["rename_group_api_groups__group_id__patch"]; + /** + * Update Group + * @description Название и домашние правила группы; меняются только переданные поля. + */ + patch: operations["update_group_api_groups__group_id__patch"]; trace?: never; }; "/api/groups/{group_id}/expansions": { @@ -1307,7 +1310,7 @@ export interface components { /** Duration Minutes */ duration_minutes?: number | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; /** Player Count */ player_count: number; /** Created By */ @@ -1576,6 +1579,11 @@ export interface components { * @default [] */ expansion_ids: number[]; + /** + * Nine Rounds Rule + * @default false + */ + nine_rounds_rule: boolean; }; /** GroupExpansionsUpdate */ GroupExpansionsUpdate: { @@ -1585,11 +1593,6 @@ export interface components { */ expansion_ids: number[]; }; - /** GroupRename */ - GroupRename: { - /** Name */ - name: string; - }; /** GroupStats */ GroupStats: { /** Group Id */ @@ -1621,6 +1624,13 @@ export interface components { /** Min Games */ min_games: number; }; + /** GroupUpdate */ + GroupUpdate: { + /** Name */ + name?: string | null; + /** Nine Rounds Rule */ + nine_rounds_rule?: boolean | null; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -1730,7 +1740,9 @@ export interface components { * Win Reason * @enum {string} */ - win_reason: "objectives" | "worlds" | "plastic" | "resources"; + win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing"; + /** End Round */ + end_round?: number | null; /** Overall Comment */ overall_comment?: string | null; /** Expected Version */ @@ -1761,9 +1773,25 @@ export interface components { [key: string]: string; }; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; /** Overall Comment */ overall_comment?: string | null; + /** End Round */ + end_round?: number | null; + /** + * Objectives + * @default {} + */ + objectives: { + [key: string]: number; + }; + /** + * Worlds + * @default {} + */ + worlds: { + [key: string]: number; + }; }; /** MatchFinishDraftRead */ MatchFinishDraftRead: { @@ -1790,6 +1818,10 @@ export interface components { comment?: string | null; /** Faction Id */ faction_id?: number | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; }; /** * MatchHistory @@ -1844,7 +1876,7 @@ export interface components { /** Duration Minutes */ duration_minutes?: number | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; /** Player Count */ player_count: number; /** Overall Comment */ @@ -1878,6 +1910,10 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; }; /** MatchParticipantRead */ MatchParticipantRead: { @@ -1900,6 +1936,10 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; /** Avatar Url */ avatar_url?: string | null; }; @@ -1923,7 +1963,16 @@ export interface components { /** Duration Minutes */ duration_minutes?: number | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; + /** End Round */ + end_round?: number | null; + /** + * Nine Rounds Rule + * @default false + */ + nine_rounds_rule: boolean; + /** Max Rounds */ + max_rounds: number; /** Player Count */ player_count: number; /** Overall Comment */ @@ -1956,7 +2005,9 @@ export interface components { /** Overall Comment */ overall_comment?: string | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; + /** End Round */ + end_round?: number | null; /** Participants */ participants?: components["schemas"]["ParticipantInput"][] | null; /** Expected Version */ @@ -2104,6 +2155,10 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; }; /** PasswordChange */ PasswordChange: { @@ -2987,7 +3042,7 @@ export interface operations { }; }; }; - rename_group_api_groups__group_id__patch: { + update_group_api_groups__group_id__patch: { parameters: { query?: never; header?: never; @@ -2998,7 +3053,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GroupRename"]; + "application/json": components["schemas"]["GroupUpdate"]; }; }; responses: { diff --git a/frontend/src/components/MatchOutcomeFields.tsx b/frontend/src/components/MatchOutcomeFields.tsx new file mode 100644 index 0000000..347e31c --- /dev/null +++ b/frontend/src/components/MatchOutcomeFields.tsx @@ -0,0 +1,76 @@ +import { PickerSelect } from "./PickerSelect"; +import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; + +const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); +const NO_ROUND = 0; + +/** + * Итог партии в форме завершения и правки результатов: причина победы, раунд окончания + * и предупреждения о несогласованном вводе. «Последний выживший» не выбирается — + * пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors). + */ +export function MatchOutcomeFields({ + winReason, + onReason, + endRound, + onEndRound, + maxRounds, + warnings, +}: { + winReason: WinReason | null; + onReason: (reason: WinReason) => void; + endRound: number | null; + onEndRound: (round: number | null) => void; + maxRounds: number; + warnings: string[]; +}) { + const roundOptions = [ + { id: NO_ROUND, label: "не указан" }, + ...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })), + ]; + return ( +
+

Итог партии

+
+ + {winReason === LAST_STANDING ? ( +
+ {winReasonLabel(LAST_STANDING)} + + все соперники выбыли + +
+ ) : ( + o.id === winReason) ?? null} + options={REASON_OPTIONS} + placeholder="— выберите причину —" + renderOption={(o) => o.label} + onPick={(o) => onReason(o.id)} + /> + )} +
+
+ + o.id === (endRound ?? NO_ROUND)) ?? null} + options={roundOptions} + placeholder="не указан" + renderOption={(o) => o.label} + onPick={(o) => onEndRound(o.id === NO_ROUND ? null : o.id)} + /> +
+

+ Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа + весит больше. +

+ {warnings.length > 0 && ( +
    + {warnings.map((w) => ( +
  • {w}
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx index ebee8ba..4fe74a3 100644 --- a/frontend/src/components/PlaceEditor.tsx +++ b/frontend/src/components/PlaceEditor.tsx @@ -10,6 +10,10 @@ export interface PlacePlayer { avatar_url?: string | null; } +export type CountField = "objectives" | "worlds"; +/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */ +export type Counts = Record; + type Target = | { type: "merge"; idx: number } | { type: "insert"; idx: number } @@ -28,7 +32,8 @@ interface DragState { * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — - * выбывший. В каждом блоке — строка комментария об игроке. + * выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts) + * цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём. * * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, @@ -41,6 +46,8 @@ export function PlaceEditor({ comments, onChange, onComment, + counts, + onCount, }: { players: PlacePlayer[]; blocks: number[][]; @@ -48,6 +55,8 @@ export function PlaceEditor({ comments: Record; onChange: (blocks: number[][], eliminated: number[]) => void; onComment: (userId: number, text: string) => void; + counts?: Counts; + onCount?: (userId: number, field: CountField, value: number | null) => void; }) { const listRef = useRef(null); const elimRef = useRef(null); @@ -225,15 +234,49 @@ export function PlaceEditor({ ); }; - const commentInput = (userId: number) => ( - onComment(userId, e.target.value)} - /> + const countInput = (userId: number, field: CountField, label: string, locked = false) => ( + ); + const commentInput = (userId: number) => { + const comment = ( + onComment(userId, e.target.value)} + /> + ); + if (!counts) return comment; + return ( +
+ {comment} + {countInput(userId, "objectives", "цели")} + {countInput(userId, "worlds", "миры", eliminated.includes(userId))} +
+ ); + }; + return (
diff --git a/frontend/src/domain/finishWarnings.ts b/frontend/src/domain/finishWarnings.ts new file mode 100644 index 0000000..042941f --- /dev/null +++ b/frontend/src/domain/finishWarnings.ts @@ -0,0 +1,101 @@ +import { LAST_STANDING, type WinReason, winReasonLabel } from "./winReasons"; + +export interface OutcomeSeat { + userId: number; + nickname: string; + /** Место невыбывшего; у выбывшего не учитывается. */ + place: number; + eliminated: boolean; + objectives: number | null; + worlds: number | null; +} + +/** + * Подсказки о несогласованных итогах партии. Это предупреждения, а не отказ: сервер + * проверяет только диапазоны и явные противоречия, а здесь — сочетания, которые по + * правилам почти наверняка опечатка. Проверка идёт только по заполненным полям. + */ +export function finishWarnings({ + seats, + winReason, + endRound, + maxRounds, +}: { + seats: OutcomeSeat[]; + winReason: WinReason | null; + endRound: number | null; + maxRounds: number; +}): string[] { + const out: string[] = []; + const n = seats.length; + const survivors = seats.filter((s) => !s.eliminated).sort((a, b) => a.place - b.place); + const winners = survivors.filter((s) => s.place === 1); + if (winners.length === 0 || winReason === LAST_STANDING) return out; + + // Досрочно партия заканчивается, только когда кто-то набрал столько целей, сколько + // игроков за столом. + const winnerObjectives = winners.map((w) => w.objectives).filter((v): v is number => v != null); + if ( + endRound != null && + endRound < maxRounds && + winnerObjectives.length > 0 && + Math.max(...winnerObjectives) < n + ) { + out.push( + `Партия закончилась в ${endRound}-м раунде из ${maxRounds}, но у победителя меньше ${n} целей — ` + + `досрочно побеждает тот, кто набрал ${n}.`, + ); + } + + // Остальное сравнивает победителя с соперниками — при ничьей за 1-е место не с кем. + if (winners.length > 1) return out; + const winner = winners[0]; + const secondPlace = survivors.find((s) => s.place > 1)?.place; + const runnersUp = survivors.filter((s) => s.place === secondPlace); + const label = winReasonLabel(winReason).toLowerCase(); + + for (const r of runnersUp) { + const objKnown = winner.objectives != null && r.objectives != null; + const worldsKnown = winner.worlds != null && r.worlds != null; + if (winReason === "objectives" && objKnown && winner.objectives === r.objectives) { + out.push( + `У ${winner.nickname} и ${r.nickname} поровну целей (${winner.objectives}) — ` + + "при равенстве победа определяется по мирам, пластику или ресурсам.", + ); + } + if (winReason && winReason !== "objectives" && objKnown && winner.objectives !== r.objectives) { + out.push( + `Победа ${label} предполагает равные цели у лидеров: у ${winner.nickname} — ` + + `${winner.objectives}, у ${r.nickname} — ${r.objectives}.`, + ); + } + if (winReason === "worlds" && worldsKnown && winner.worlds! <= r.worlds!) { + out.push( + `Победа по мирам, но у ${winner.nickname} миров не больше, чем у ${r.nickname} ` + + `(${winner.worlds} и ${r.worlds}).`, + ); + } + if ( + (winReason === "plastic" || winReason === "resources") && + worldsKnown && + winner.worlds !== r.worlds + ) { + out.push( + `Победа ${label} предполагает равные миры у лидеров: у ${winner.nickname} — ` + + `${winner.worlds}, у ${r.nickname} — ${r.worlds}.`, + ); + } + } + + if (winner.objectives != null) { + for (const s of seats) { + if (s.userId !== winner.userId && s.objectives != null && s.objectives > winner.objectives) { + out.push( + `У ${s.nickname} целей больше, чем у победителя ${winner.nickname} ` + + `(${s.objectives} против ${winner.objectives}).`, + ); + } + } + } + return out; +} diff --git a/frontend/src/domain/winReasons.ts b/frontend/src/domain/winReasons.ts index 048e46c..cfa2501 100644 --- a/frontend/src/domain/winReasons.ts +++ b/frontend/src/domain/winReasons.ts @@ -2,7 +2,10 @@ import type { components } from "../api/schema"; export type WinReason = components["schemas"]["MatchFinish"]["win_reason"]; -// Фиксированный порядок (сверху вниз). +export const LAST_STANDING: WinReason = "last_standing"; + +// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший» +// сюда не входит: она ставится сама, когда невыбывший игрок остался один. export const WIN_REASONS: { code: WinReason; label: string }[] = [ { code: "objectives", label: "По целям" }, { code: "worlds", label: "По мирам" }, @@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [ { code: "resources", label: "По ресурсам" }, ]; -const LABELS: Record = Object.fromEntries( - WIN_REASONS.map((w) => [w.code, w.label]), -); +const LABELS: Record = { + ...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])), + [LAST_STANDING]: "Последний выживший", +}; export function winReasonLabel(code: string | null | undefined): string { return code ? LABELS[code] ?? code : "—"; } + +/** + * Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно + * один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда + * выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать. + */ +export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null { + if (survivors === 1) return LAST_STANDING; + return current === LAST_STANDING ? null : current; +} diff --git a/frontend/src/hooks/groups.ts b/frontend/src/hooks/groups.ts index 27a3132..6779b0e 100644 --- a/frontend/src/hooks/groups.ts +++ b/frontend/src/hooks/groups.ts @@ -75,14 +75,15 @@ export function useCreateGroup() { }); } -export function useRenameGroup(groupId: number) { +export function useUpdateGroup(groupId: number) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (name: string) => + // Частичная правка: название и/или домашние правила (например, 9 раундов). + mutationFn: async (body: { name?: string; nine_rounds_rule?: boolean }) => unwrap( await api.PATCH("/api/groups/{group_id}", { params: { path: { group_id: groupId } }, - body: { name }, + body, }), ), onSuccess: () => { diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index 878852a..4e0eb8e 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -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() { @@ -127,6 +136,22 @@ export function GroupSettingsPage() { )}
+
+

Домашние правила

+ +

+ Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных + партий не меняется. +

+
+
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}

Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})

diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 4a0fc31..c2164ae 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -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 => + 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(null); const [elim, setElim] = useState([]); const [comments, setComments] = useState | null>(null); - const [winReason, setWinReason] = useState("objectives"); + const [counts, setCounts] = useState(null); + // null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors). + const [winReason, setWinReason] = useState("objectives"); + const [endRound, setEndRound] = useState(null); const [overall, setOverall] = useState(""); const [error, setError] = useState(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 ; if (!match) return
Партия не найдена.
; - 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 = 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 = ( +

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

+ ); + return (
@@ -294,6 +393,9 @@ export function MatchDetailPage() { {!inProgress && (
Победа: {winReasonLabel(match.win_reason)} + {match.end_round != null && ( + · конец в {match.end_round}-м раунде из {match.max_rounds} + )}
)} {match.overall_comment &&

{match.overall_comment}

} @@ -304,20 +406,16 @@ export function MatchDetailPage() { <>

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

-

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

+ {placeHint} { - setBlocks(b); - setElim(e); - }} + counts={finishCounts} + onChange={applyLayout} onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })} + onCount={applyCount} />
@@ -345,16 +443,14 @@ export function MatchDetailPage() {
-
-

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

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

О партии

@@ -413,6 +509,11 @@ export function MatchDetailPage() { · {p.faction_name} {p.was_random && 🎲} + {(p.objectives != null || p.worlds != null) && ( +
+ цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"} +
+ )} {p.comment &&
{p.comment}
}
))} @@ -448,10 +549,7 @@ export function MatchDetailPage() { <>

Места

-

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

+ {placeHint} {match.finish_draft && match.finish_draft.updated_by !== me?.id && (

Результаты заполняет также {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() { }), ); }} - /> -

- -
-

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

- 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"), + }), + ); }} />
+ { + setWinReason(reason); + queueDraft(draftOf({ win_reason: reason })); + }} + endRound={endRound} + onEndRound={(round) => { + setEndRound(round); + queueDraft(draftOf({ end_round: round })); + }} + maxRounds={match.max_rounds} + warnings={warnings()} + /> +

О партии