diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx index 4fe74a3..b33b2cf 100644 --- a/frontend/src/components/PlaceEditor.tsx +++ b/frontend/src/components/PlaceEditor.tsx @@ -2,6 +2,7 @@ import { Scissors } from "lucide-react"; import { useRef } from "react"; import { Avatar } from "./Avatar"; +import { MAX_COUNT, parseCount } from "../domain/matchCounts"; export interface PlacePlayer { user_id: number; @@ -241,19 +242,11 @@ export function PlaceEditor({ type="number" inputMode="numeric" min={0} - max={99} + max={MAX_COUNT} placeholder="—" disabled={locked} value={locked ? 0 : (counts?.[userId]?.[field] ?? "")} - onChange={(e) => { - const raw = e.target.value.trim(); - const num = Number(raw); - onCount?.( - userId, - field, - raw === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num))), - ); - }} + onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))} /> ); diff --git a/frontend/src/domain/matchCounts.ts b/frontend/src/domain/matchCounts.ts new file mode 100644 index 0000000..83ed05a --- /dev/null +++ b/frontend/src/domain/matchCounts.ts @@ -0,0 +1,10 @@ +/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме API (Count). */ +export const MAX_COUNT = 99; + +/** Поле «цели»/«миры» формы: пустое — не указано (null), иначе целое 0..MAX_COUNT. */ +export function parseCount(raw: string): number | null { + const text = raw.trim(); + const num = Number(text); + if (text === "" || !Number.isFinite(num)) return null; + return Math.min(MAX_COUNT, Math.max(0, Math.trunc(num))); +} diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index a9ef368..304c6bc 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -52,6 +52,18 @@ const countDict = (counts: Counts, field: CountField): Record => const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0); +// Места по раскладке: блоки сверху вниз (competition ranking — ничья съедает следующие +// места), затем выбывшие с общим последним местом. +const placeRows = (blocks: number[][], eliminated: number[]) => { + let place = 1; + const survivors = blocks.flatMap((ids) => { + const rows = ids.map((uid) => ({ uid, place, eliminated: false })); + place += ids.length; + return rows; + }); + return [...survivors, ...eliminated.map((uid) => ({ uid, place, eliminated: true }))]; +}; + export function MatchDetailPage() { const { matchId } = useParams(); // Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN. @@ -193,21 +205,14 @@ export function MatchDetailPage() { const outcomeSeats = (): OutcomeSeat[] => { const byId = new Map(match.participants.map((p) => [p.user_id, p])); - const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({ + return placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({ 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 = () => @@ -218,35 +223,16 @@ export function MatchDetailPage() { 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) => ({ - user_id: uid, - place, - eliminated: false, - comment: commentOf(uid), - ...countsFor(uid, false), - })); - place += ids.length; // competition ranking: ничья съедает следующие места - return rows; - }); - const eliminated = elim.map((uid) => ({ + // Строки результатов для API. Место выбывшего и его миры (0) проставит сервер. + const resultRows = () => + placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({ user_id: uid, - place: null, - eliminated: true, - comment: commentOf(uid), - ...countsFor(uid, true), + place: eliminated ? null : place, + eliminated, + comment: (finishComments[uid] ?? "").trim() || null, + objectives: finishCounts[uid]?.objectives ?? null, + worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null), })); - return [...survivors, ...eliminated]; - }; const submitFinish = async () => { if (!id || !match) return; diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index afe0aba..74c53b7 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -6,6 +6,7 @@ import { MatchMedia } from "../../components/MatchMedia"; import { Spinner } from "../../components/Spinner"; import { Switch } from "../../components/Switch"; import { finishWarnings } from "../../domain/finishWarnings"; +import { MAX_COUNT, parseCount } from "../../domain/matchCounts"; import { LAST_STANDING, reasonForSurvivors, @@ -34,12 +35,6 @@ interface Row { 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(); @@ -218,7 +213,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: style={{ flex: "0 0 70px" }} type="number" min={0} - max={99} + max={MAX_COUNT} placeholder="цели" title="Цели на конец партии" value={r.objectives ?? ""} @@ -228,7 +223,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: style={{ flex: "0 0 70px" }} type="number" min={0} - max={99} + max={MAX_COUNT} placeholder="миры" title="Миры на конец партии" disabled={r.eliminated}