From 6c35cb04d4bbb8948432c85a600eb054cbca1da0 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 23:23:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=82=D0=BE=D0=B3=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D0=B8:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D1=86=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9/=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCount и граница 99 — в domain/matchCounts.ts вместо двух копий в PlaceEditor и AdminMatchEdit. В MatchDetailPage места по раскладке считает placeRows, из него строятся и предупреждения, и строки для API. Поведение не меняется. #89 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- frontend/src/components/PlaceEditor.tsx | 13 ++--- frontend/src/domain/matchCounts.ts | 10 ++++ frontend/src/pages/MatchDetailPage.tsx | 58 ++++++++------------- frontend/src/pages/admin/AdminMatchEdit.tsx | 11 ++-- 4 files changed, 38 insertions(+), 54 deletions(-) create mode 100644 frontend/src/domain/matchCounts.ts 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}