+ {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()}
+ />
+
))}
-
+
+
+
+
+ {warnings.length > 0 && (
+
+ {warnings.map((w) => (
+ - {w}
+ ))}
+
+ )}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 295df2d..9c5c269 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -371,6 +371,30 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
padding: 7px 10px;
border-radius: 8px;
}
+/* Строка под игроком: комментарий + цели и миры на конец партии */
+.rank-details { display: flex; gap: 6px; align-items: flex-end; }
+.rank-details .rank-comment { flex: 1; min-width: 0; }
+.rank-count { flex: 0 0 58px; margin-top: 8px; }
+.rank-count span { display: block; font-size: 10px; color: var(--text-muted); text-align: center; }
+.rank-count input {
+ width: 100%;
+ font-size: 13px;
+ padding: 7px 4px;
+ border-radius: 8px;
+ text-align: center;
+}
+
+/* Предупреждения о несогласованном вводе (не блокируют отправку) */
+.warn-list {
+ margin: 10px 0 0;
+ padding: 8px 10px 8px 26px;
+ border: 1px solid var(--accent-2);
+ border-radius: var(--radius-sm);
+ background: rgba(240, 160, 75, 0.08);
+ color: var(--accent-2);
+ font-size: 13px;
+}
+.warn-list li + li { margin-top: 4px; }
.rank-unlink {
flex: none;
width: 30px; height: 30px;