Итоги партии: общий разбор целей/миров и один расчёт мест

parseCount и граница 99 — в domain/matchCounts.ts вместо двух копий в PlaceEditor
и AdminMatchEdit. В MatchDetailPage места по раскладке считает placeRows, из него
строятся и предупреждения, и строки для API. Поведение не меняется. #89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-18 23:23:02 +03:00
co-authored by Claude Opus 5
parent 63f541d61f
commit 6c35cb04d4
4 changed files with 38 additions and 54 deletions
+3 -10
View File
@@ -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))}
/>
</label>
);
+10
View File
@@ -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)));
}
+22 -36
View File
@@ -52,6 +52,18 @@ const countDict = (counts: Counts, field: CountField): Record<string, number> =>
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;
+3 -8
View File
@@ -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}