Итоги партии: общий разбор целей/миров и один расчёт мест
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:
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
||||||
|
|
||||||
export interface PlacePlayer {
|
export interface PlacePlayer {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -241,19 +242,11 @@ export function PlaceEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
||||||
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))),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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);
|
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() {
|
export function MatchDetailPage() {
|
||||||
const { matchId } = useParams();
|
const { matchId } = useParams();
|
||||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||||
@@ -193,21 +205,14 @@ export function MatchDetailPage() {
|
|||||||
|
|
||||||
const outcomeSeats = (): OutcomeSeat[] => {
|
const outcomeSeats = (): OutcomeSeat[] => {
|
||||||
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
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,
|
userId: uid,
|
||||||
nickname: byId.get(uid)?.nickname ?? "",
|
nickname: byId.get(uid)?.nickname ?? "",
|
||||||
place,
|
place,
|
||||||
eliminated,
|
eliminated,
|
||||||
objectives: finishCounts[uid]?.objectives ?? null,
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? 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 = () =>
|
const warnings = () =>
|
||||||
@@ -218,35 +223,16 @@ export function MatchDetailPage() {
|
|||||||
maxRounds: match.max_rounds,
|
maxRounds: match.max_rounds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
|
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
|
||||||
const resultRows = () => {
|
const resultRows = () =>
|
||||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||||
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) => ({
|
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
place: null,
|
place: eliminated ? null : place,
|
||||||
eliminated: true,
|
eliminated,
|
||||||
comment: commentOf(uid),
|
comment: (finishComments[uid] ?? "").trim() || null,
|
||||||
...countsFor(uid, true),
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
|
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||||
}));
|
}));
|
||||||
return [...survivors, ...eliminated];
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitFinish = async () => {
|
const submitFinish = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MatchMedia } from "../../components/MatchMedia";
|
|||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { Switch } from "../../components/Switch";
|
import { Switch } from "../../components/Switch";
|
||||||
import { finishWarnings } from "../../domain/finishWarnings";
|
import { finishWarnings } from "../../domain/finishWarnings";
|
||||||
|
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
|
||||||
import {
|
import {
|
||||||
LAST_STANDING,
|
LAST_STANDING,
|
||||||
reasonForSurvivors,
|
reasonForSurvivors,
|
||||||
@@ -34,12 +35,6 @@ interface Row {
|
|||||||
worlds: 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 }) {
|
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||||
const { data: match, isLoading } = useAdminMatch(matchId);
|
const { data: match, isLoading } = useAdminMatch(matchId);
|
||||||
const { data: factions } = useAdminFactions();
|
const { data: factions } = useAdminFactions();
|
||||||
@@ -218,7 +213,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="цели"
|
placeholder="цели"
|
||||||
title="Цели на конец партии"
|
title="Цели на конец партии"
|
||||||
value={r.objectives ?? ""}
|
value={r.objectives ?? ""}
|
||||||
@@ -228,7 +223,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="миры"
|
placeholder="миры"
|
||||||
title="Миры на конец партии"
|
title="Миры на конец партии"
|
||||||
disabled={r.eliminated}
|
disabled={r.eliminated}
|
||||||
|
|||||||
Reference in New Issue
Block a user