Реализация новой рейтинговой системы (#23) #75
Vendored
+70
-15
@@ -377,8 +377,11 @@ export interface paths {
|
|||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
/** Rename Group */
|
/**
|
||||||
patch: operations["rename_group_api_groups__group_id__patch"];
|
* Update Group
|
||||||
|
* @description Название и домашние правила группы; меняются только переданные поля.
|
||||||
|
*/
|
||||||
|
patch: operations["update_group_api_groups__group_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/groups/{group_id}/expansions": {
|
"/api/groups/{group_id}/expansions": {
|
||||||
@@ -1307,7 +1310,7 @@ export interface components {
|
|||||||
/** Duration Minutes */
|
/** Duration Minutes */
|
||||||
duration_minutes?: number | null;
|
duration_minutes?: number | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
||||||
/** Player Count */
|
/** Player Count */
|
||||||
player_count: number;
|
player_count: number;
|
||||||
/** Created By */
|
/** Created By */
|
||||||
@@ -1576,6 +1579,11 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
expansion_ids: number[];
|
expansion_ids: number[];
|
||||||
|
/**
|
||||||
|
* Nine Rounds Rule
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
nine_rounds_rule: boolean;
|
||||||
};
|
};
|
||||||
/** GroupExpansionsUpdate */
|
/** GroupExpansionsUpdate */
|
||||||
GroupExpansionsUpdate: {
|
GroupExpansionsUpdate: {
|
||||||
@@ -1585,11 +1593,6 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
expansion_ids: number[];
|
expansion_ids: number[];
|
||||||
};
|
};
|
||||||
/** GroupRename */
|
|
||||||
GroupRename: {
|
|
||||||
/** Name */
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
/** GroupStats */
|
/** GroupStats */
|
||||||
GroupStats: {
|
GroupStats: {
|
||||||
/** Group Id */
|
/** Group Id */
|
||||||
@@ -1621,6 +1624,13 @@ export interface components {
|
|||||||
/** Min Games */
|
/** Min Games */
|
||||||
min_games: number;
|
min_games: number;
|
||||||
};
|
};
|
||||||
|
/** GroupUpdate */
|
||||||
|
GroupUpdate: {
|
||||||
|
/** Name */
|
||||||
|
name?: string | null;
|
||||||
|
/** Nine Rounds Rule */
|
||||||
|
nine_rounds_rule?: boolean | null;
|
||||||
|
};
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
@@ -1730,7 +1740,9 @@ export interface components {
|
|||||||
* Win Reason
|
* Win Reason
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
win_reason: "objectives" | "worlds" | "plastic" | "resources";
|
win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing";
|
||||||
|
/** End Round */
|
||||||
|
end_round?: number | null;
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
overall_comment?: string | null;
|
overall_comment?: string | null;
|
||||||
/** Expected Version */
|
/** Expected Version */
|
||||||
@@ -1761,9 +1773,25 @@ export interface components {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
overall_comment?: string | null;
|
overall_comment?: string | null;
|
||||||
|
/** End Round */
|
||||||
|
end_round?: number | null;
|
||||||
|
/**
|
||||||
|
* Objectives
|
||||||
|
* @default {}
|
||||||
|
*/
|
||||||
|
objectives: {
|
||||||
|
[key: string]: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Worlds
|
||||||
|
* @default {}
|
||||||
|
*/
|
||||||
|
worlds: {
|
||||||
|
[key: string]: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
/** MatchFinishDraftRead */
|
/** MatchFinishDraftRead */
|
||||||
MatchFinishDraftRead: {
|
MatchFinishDraftRead: {
|
||||||
@@ -1790,6 +1818,10 @@ export interface components {
|
|||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** Faction Id */
|
/** Faction Id */
|
||||||
faction_id?: number | null;
|
faction_id?: number | null;
|
||||||
|
/** Objectives */
|
||||||
|
objectives?: number | null;
|
||||||
|
/** Worlds */
|
||||||
|
worlds?: number | null;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* MatchHistory
|
* MatchHistory
|
||||||
@@ -1844,7 +1876,7 @@ export interface components {
|
|||||||
/** Duration Minutes */
|
/** Duration Minutes */
|
||||||
duration_minutes?: number | null;
|
duration_minutes?: number | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
||||||
/** Player Count */
|
/** Player Count */
|
||||||
player_count: number;
|
player_count: number;
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
@@ -1878,6 +1910,10 @@ export interface components {
|
|||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
|
/** Objectives */
|
||||||
|
objectives?: number | null;
|
||||||
|
/** Worlds */
|
||||||
|
worlds?: number | null;
|
||||||
};
|
};
|
||||||
/** MatchParticipantRead */
|
/** MatchParticipantRead */
|
||||||
MatchParticipantRead: {
|
MatchParticipantRead: {
|
||||||
@@ -1900,6 +1936,10 @@ export interface components {
|
|||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
|
/** Objectives */
|
||||||
|
objectives?: number | null;
|
||||||
|
/** Worlds */
|
||||||
|
worlds?: number | null;
|
||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
};
|
};
|
||||||
@@ -1923,7 +1963,16 @@ export interface components {
|
|||||||
/** Duration Minutes */
|
/** Duration Minutes */
|
||||||
duration_minutes?: number | null;
|
duration_minutes?: number | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
||||||
|
/** End Round */
|
||||||
|
end_round?: number | null;
|
||||||
|
/**
|
||||||
|
* Nine Rounds Rule
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
nine_rounds_rule: boolean;
|
||||||
|
/** Max Rounds */
|
||||||
|
max_rounds: number;
|
||||||
/** Player Count */
|
/** Player Count */
|
||||||
player_count: number;
|
player_count: number;
|
||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
@@ -1956,7 +2005,9 @@ export interface components {
|
|||||||
/** Overall Comment */
|
/** Overall Comment */
|
||||||
overall_comment?: string | null;
|
overall_comment?: string | null;
|
||||||
/** Win Reason */
|
/** Win Reason */
|
||||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
|
||||||
|
/** End Round */
|
||||||
|
end_round?: number | null;
|
||||||
/** Participants */
|
/** Participants */
|
||||||
participants?: components["schemas"]["ParticipantInput"][] | null;
|
participants?: components["schemas"]["ParticipantInput"][] | null;
|
||||||
/** Expected Version */
|
/** Expected Version */
|
||||||
@@ -2104,6 +2155,10 @@ export interface components {
|
|||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
|
/** Objectives */
|
||||||
|
objectives?: number | null;
|
||||||
|
/** Worlds */
|
||||||
|
worlds?: number | null;
|
||||||
};
|
};
|
||||||
/** PasswordChange */
|
/** PasswordChange */
|
||||||
PasswordChange: {
|
PasswordChange: {
|
||||||
@@ -2987,7 +3042,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
rename_group_api_groups__group_id__patch: {
|
update_group_api_groups__group_id__patch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -2998,7 +3053,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["GroupRename"];
|
"application/json": components["schemas"]["GroupUpdate"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { PickerSelect } from "./PickerSelect";
|
||||||
|
import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||||
|
|
||||||
|
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||||
|
const NO_ROUND = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Итог партии в форме завершения и правки результатов: причина победы, раунд окончания
|
||||||
|
* и предупреждения о несогласованном вводе. «Последний выживший» не выбирается —
|
||||||
|
* пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors).
|
||||||
|
*/
|
||||||
|
export function MatchOutcomeFields({
|
||||||
|
winReason,
|
||||||
|
onReason,
|
||||||
|
endRound,
|
||||||
|
onEndRound,
|
||||||
|
maxRounds,
|
||||||
|
warnings,
|
||||||
|
}: {
|
||||||
|
winReason: WinReason | null;
|
||||||
|
onReason: (reason: WinReason) => void;
|
||||||
|
endRound: number | null;
|
||||||
|
onEndRound: (round: number | null) => void;
|
||||||
|
maxRounds: number;
|
||||||
|
warnings: string[];
|
||||||
|
}) {
|
||||||
|
const roundOptions = [
|
||||||
|
{ id: NO_ROUND, label: "не указан" },
|
||||||
|
...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Итог партии</h3>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Причина победы</label>
|
||||||
|
{winReason === LAST_STANDING ? (
|
||||||
|
<div className="picker-trigger" aria-disabled>
|
||||||
|
{winReasonLabel(LAST_STANDING)}
|
||||||
|
<span className="muted small" style={{ marginLeft: "auto" }}>
|
||||||
|
все соперники выбыли
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PickerSelect
|
||||||
|
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||||
|
options={REASON_OPTIONS}
|
||||||
|
placeholder="— выберите причину —"
|
||||||
|
renderOption={(o) => o.label}
|
||||||
|
onPick={(o) => onReason(o.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="label">Раунд окончания (из {maxRounds})</label>
|
||||||
|
<PickerSelect
|
||||||
|
selected={roundOptions.find((o) => o.id === (endRound ?? NO_ROUND)) ?? null}
|
||||||
|
options={roundOptions}
|
||||||
|
placeholder="не указан"
|
||||||
|
renderOption={(o) => o.label}
|
||||||
|
onPick={(o) => onEndRound(o.id === NO_ROUND ? null : o.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||||
|
Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа
|
||||||
|
весит больше.
|
||||||
|
</p>
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<ul className="warn-list">
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<li key={w}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ export interface PlacePlayer {
|
|||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CountField = "objectives" | "worlds";
|
||||||
|
/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */
|
||||||
|
export type Counts = Record<number, { objectives: number | null; worlds: number | null }>;
|
||||||
|
|
||||||
type Target =
|
type Target =
|
||||||
| { type: "merge"; idx: number }
|
| { type: "merge"; idx: number }
|
||||||
| { type: "insert"; idx: number }
|
| { type: "insert"; idx: number }
|
||||||
@@ -28,7 +32,8 @@ interface DragState {
|
|||||||
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
|
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
|
||||||
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
|
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
|
||||||
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
|
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
|
||||||
* выбывший. В каждом блоке — строка комментария об игроке.
|
* выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
|
||||||
|
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
|
||||||
*
|
*
|
||||||
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
|
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
|
||||||
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
|
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
|
||||||
@@ -41,6 +46,8 @@ export function PlaceEditor({
|
|||||||
comments,
|
comments,
|
||||||
onChange,
|
onChange,
|
||||||
onComment,
|
onComment,
|
||||||
|
counts,
|
||||||
|
onCount,
|
||||||
}: {
|
}: {
|
||||||
players: PlacePlayer[];
|
players: PlacePlayer[];
|
||||||
blocks: number[][];
|
blocks: number[][];
|
||||||
@@ -48,6 +55,8 @@ export function PlaceEditor({
|
|||||||
comments: Record<number, string>;
|
comments: Record<number, string>;
|
||||||
onChange: (blocks: number[][], eliminated: number[]) => void;
|
onChange: (blocks: number[][], eliminated: number[]) => void;
|
||||||
onComment: (userId: number, text: string) => void;
|
onComment: (userId: number, text: string) => void;
|
||||||
|
counts?: Counts;
|
||||||
|
onCount?: (userId: number, field: CountField, value: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const elimRef = useRef<HTMLDivElement>(null);
|
const elimRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -225,7 +234,32 @@ export function PlaceEditor({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const commentInput = (userId: number) => (
|
const countInput = (userId: number, field: CountField, label: string, locked = false) => (
|
||||||
|
<label className="rank-count">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
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))),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const commentInput = (userId: number) => {
|
||||||
|
const comment = (
|
||||||
<input
|
<input
|
||||||
className="rank-comment"
|
className="rank-comment"
|
||||||
placeholder="Комментарий об игроке"
|
placeholder="Комментарий об игроке"
|
||||||
@@ -233,6 +267,15 @@ export function PlaceEditor({
|
|||||||
onChange={(e) => onComment(userId, e.target.value)}
|
onChange={(e) => onComment(userId, e.target.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
if (!counts) return comment;
|
||||||
|
return (
|
||||||
|
<div className="rank-details">
|
||||||
|
{comment}
|
||||||
|
{countInput(userId, "objectives", "цели")}
|
||||||
|
{countInput(userId, "worlds", "миры", eliminated.includes(userId))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import type { components } from "../api/schema";
|
|||||||
|
|
||||||
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
|
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
|
||||||
|
|
||||||
// Фиксированный порядок (сверху вниз).
|
export const LAST_STANDING: WinReason = "last_standing";
|
||||||
|
|
||||||
|
// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший»
|
||||||
|
// сюда не входит: она ставится сама, когда невыбывший игрок остался один.
|
||||||
export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
||||||
{ code: "objectives", label: "По целям" },
|
{ code: "objectives", label: "По целям" },
|
||||||
{ code: "worlds", label: "По мирам" },
|
{ code: "worlds", label: "По мирам" },
|
||||||
@@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
|||||||
{ code: "resources", label: "По ресурсам" },
|
{ code: "resources", label: "По ресурсам" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LABELS: Record<string, string> = Object.fromEntries(
|
const LABELS: Record<string, string> = {
|
||||||
WIN_REASONS.map((w) => [w.code, w.label]),
|
...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])),
|
||||||
);
|
[LAST_STANDING]: "Последний выживший",
|
||||||
|
};
|
||||||
|
|
||||||
export function winReasonLabel(code: string | null | undefined): string {
|
export function winReasonLabel(code: string | null | undefined): string {
|
||||||
return code ? LABELS[code] ?? code : "—";
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,14 +75,15 @@ export function useCreateGroup() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRenameGroup(groupId: number) {
|
export function useUpdateGroup(groupId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (name: string) =>
|
// Частичная правка: название и/или домашние правила (например, 9 раундов).
|
||||||
|
mutationFn: async (body: { name?: string; nine_rounds_rule?: boolean }) =>
|
||||||
unwrap(
|
unwrap(
|
||||||
await api.PATCH("/api/groups/{group_id}", {
|
await api.PATCH("/api/groups/{group_id}", {
|
||||||
params: { path: { group_id: groupId } },
|
params: { path: { group_id: groupId } },
|
||||||
body: { name },
|
body,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
useGroup,
|
useGroup,
|
||||||
useGroupMembers,
|
useGroupMembers,
|
||||||
useRemoveMember,
|
useRemoveMember,
|
||||||
useRenameGroup,
|
|
||||||
useSetGroupExpansions,
|
useSetGroupExpansions,
|
||||||
|
useUpdateGroup,
|
||||||
} from "../hooks/groups";
|
} from "../hooks/groups";
|
||||||
import { useExpansions } from "../hooks/reference";
|
import { useExpansions } from "../hooks/reference";
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export function GroupSettingsPage() {
|
|||||||
const { data: expansions } = useExpansions();
|
const { data: expansions } = useExpansions();
|
||||||
const { data: members } = useGroupMembers(groupId);
|
const { data: members } = useGroupMembers(groupId);
|
||||||
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
||||||
const renameGroup = useRenameGroup(groupId ?? 0);
|
const updateGroup = useUpdateGroup(groupId ?? 0);
|
||||||
const removeMember = useRemoveMember(groupId ?? 0);
|
const removeMember = useRemoveMember(groupId ?? 0);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export function GroupSettingsPage() {
|
|||||||
const trimmed = nameValue.trim();
|
const trimmed = nameValue.trim();
|
||||||
if (!trimmed || trimmed === group.name) return;
|
if (!trimmed || trimmed === group.name) return;
|
||||||
try {
|
try {
|
||||||
await renameGroup.mutateAsync(trimmed);
|
await updateGroup.mutateAsync({ name: trimmed });
|
||||||
setName(null);
|
setName(null);
|
||||||
toast.show("Название сохранено");
|
toast.show("Название сохранено");
|
||||||
} catch (e) {
|
} 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 () => {
|
const saveExpansions = async () => {
|
||||||
try {
|
try {
|
||||||
await setExpansions.mutateAsync([...selected]);
|
await setExpansions.mutateAsync([...selected]);
|
||||||
@@ -95,7 +104,7 @@ export function GroupSettingsPage() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={saveName}
|
onClick={saveName}
|
||||||
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
|
disabled={updateGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
|
||||||
>
|
>
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
@@ -127,6 +136,22 @@ export function GroupSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Домашние правила</h3>
|
||||||
|
<label className="row" style={{ justifyContent: "space-between" }}>
|
||||||
|
<span>9 раундов при 5–6 игроках</span>
|
||||||
|
<Switch
|
||||||
|
checked={group.nine_rounds_rule}
|
||||||
|
disabled={updateGroup.isPending}
|
||||||
|
onChange={saveNineRounds}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||||
|
Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных
|
||||||
|
партий не меняется.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}
|
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}
|
||||||
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
|
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { MatchMedia } from "../components/MatchMedia";
|
import { MatchMedia } from "../components/MatchMedia";
|
||||||
|
import { MatchOutcomeFields } from "../components/MatchOutcomeFields";
|
||||||
import { PickerSelect } from "../components/PickerSelect";
|
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 { PlayerLink } from "../components/PlayerLink";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
|
import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings";
|
||||||
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
||||||
import type { MatchFinishDraftData } from "../domain/types";
|
import type { MatchFinishDraftData, MatchRead } from "../domain/types";
|
||||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +26,31 @@ import {
|
|||||||
} from "../hooks/matches";
|
} from "../hooks/matches";
|
||||||
import { useGroupFactions } from "../hooks/reference";
|
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<string, number> =>
|
||||||
|
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() {
|
export function MatchDetailPage() {
|
||||||
const { matchId } = useParams();
|
const { matchId } = useParams();
|
||||||
@@ -47,7 +73,10 @@ export function MatchDetailPage() {
|
|||||||
const [blocks, setBlocks] = useState<number[][] | null>(null);
|
const [blocks, setBlocks] = useState<number[][] | null>(null);
|
||||||
const [elim, setElim] = useState<number[]>([]);
|
const [elim, setElim] = useState<number[]>([]);
|
||||||
const [comments, setComments] = useState<Record<number, string> | null>(null);
|
const [comments, setComments] = useState<Record<number, string> | null>(null);
|
||||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
const [counts, setCounts] = useState<Counts | null>(null);
|
||||||
|
// null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors).
|
||||||
|
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
|
||||||
|
const [endRound, setEndRound] = useState<number | null>(null);
|
||||||
const [overall, setOverall] = useState("");
|
const [overall, setOverall] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
@@ -86,23 +115,26 @@ export function MatchDetailPage() {
|
|||||||
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
||||||
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
||||||
const incoming = match?.finish_draft;
|
const incoming = match?.finish_draft;
|
||||||
|
const participants = match?.participants;
|
||||||
useEffect(() => {
|
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_by === me?.id) return;
|
||||||
if (incoming.updated_at === appliedDraftAt.current) return;
|
if (incoming.updated_at === appliedDraftAt.current) return;
|
||||||
if (Date.now() - lastLocalEdit.current < 1500) return;
|
if (Date.now() - lastLocalEdit.current < 1500) return;
|
||||||
setBlocks(incoming.data.blocks);
|
setBlocks(incoming.data.blocks);
|
||||||
setElim(incoming.data.eliminated);
|
setElim(incoming.data.eliminated);
|
||||||
setComments(incoming.data.comments);
|
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 ?? "");
|
setOverall(incoming.data.overall_comment ?? "");
|
||||||
appliedDraftAt.current = incoming.updated_at;
|
appliedDraftAt.current = incoming.updated_at;
|
||||||
}, [incoming, match?.status, me?.id]);
|
}, [incoming, participants, match?.status, me?.id]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||||
|
|
||||||
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin)
|
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
|
||||||
const inProgress = match.status === "in_progress";
|
const inProgress = match.status === "in_progress";
|
||||||
|
|
||||||
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
|
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
|
||||||
@@ -110,6 +142,7 @@ export function MatchDetailPage() {
|
|||||||
const finishComments: Record<number, string> =
|
const finishComments: Record<number, string> =
|
||||||
comments ??
|
comments ??
|
||||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""]));
|
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";
|
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||||||
@@ -123,19 +156,64 @@ export function MatchDetailPage() {
|
|||||||
),
|
),
|
||||||
win_reason: winReason,
|
win_reason: winReason,
|
||||||
overall_comment: overall.trim() || null,
|
overall_comment: overall.trim() || null,
|
||||||
|
end_round: endRound,
|
||||||
|
objectives: countDict(finishCounts, "objectives"),
|
||||||
|
worlds: countDict(finishCounts, "worlds"),
|
||||||
...patch,
|
...patch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitFinish = async () => {
|
// Раскладка изменилась: при одном выжившем причина становится «последний выживший»,
|
||||||
if (!id || !match) return;
|
// при возврате второго — сбрасывается.
|
||||||
setError(null);
|
const applyLayout = (b: number[][], e: number[]): WinReason | null => {
|
||||||
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
const reason = reasonForSurvivors(winReason, survivorsIn(b));
|
||||||
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
setBlocks(b);
|
||||||
if (draftTimer.current) {
|
setElim(e);
|
||||||
clearTimeout(draftTimer.current);
|
setWinReason(reason);
|
||||||
sendDraft();
|
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 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;
|
let place = 1;
|
||||||
const survivors = finishBlocks.flatMap((ids) => {
|
const survivors = finishBlocks.flatMap((ids) => {
|
||||||
const rows = ids.map((uid) => ({
|
const rows = ids.map((uid) => ({
|
||||||
@@ -143,24 +221,41 @@ export function MatchDetailPage() {
|
|||||||
place,
|
place,
|
||||||
eliminated: false,
|
eliminated: false,
|
||||||
comment: commentOf(uid),
|
comment: commentOf(uid),
|
||||||
|
...countsFor(uid, false),
|
||||||
}));
|
}));
|
||||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
try {
|
const eliminated = elim.map((uid) => ({
|
||||||
await finish.mutateAsync({
|
|
||||||
matchId: id,
|
|
||||||
body: {
|
|
||||||
participants: [
|
|
||||||
...survivors,
|
|
||||||
...elim.map((uid) => ({
|
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
place: null,
|
place: null,
|
||||||
eliminated: true,
|
eliminated: true,
|
||||||
comment: commentOf(uid),
|
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: resultRows(),
|
||||||
win_reason: winReason,
|
win_reason: winReason,
|
||||||
|
end_round: endRound,
|
||||||
overall_comment: overall.trim() || null,
|
overall_comment: overall.trim() || null,
|
||||||
expected_version: match.version,
|
expected_version: match.version,
|
||||||
},
|
},
|
||||||
@@ -188,7 +283,9 @@ export function MatchDetailPage() {
|
|||||||
setComments(
|
setComments(
|
||||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
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 ?? "");
|
setOverall(match.overall_comment ?? "");
|
||||||
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -199,36 +296,31 @@ export function MatchDetailPage() {
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
setBlocks(null);
|
setBlocks(null);
|
||||||
setComments(null);
|
setComments(null);
|
||||||
|
setCounts(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitEdit = async () => {
|
const submitEdit = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!winReason) {
|
||||||
|
setError("Выберите причину победы.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wasRandom = Object.fromEntries(
|
const wasRandom = Object.fromEntries(
|
||||||
match.participants.map((p) => [p.user_id, p.was_random]),
|
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 {
|
try {
|
||||||
await updateMatch.mutateAsync({
|
await updateMatch.mutateAsync({
|
||||||
matchId: id,
|
matchId: id,
|
||||||
body: {
|
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,
|
win_reason: winReason,
|
||||||
|
end_round: endRound,
|
||||||
overall_comment: overall.trim() || null,
|
overall_comment: overall.trim() || null,
|
||||||
expected_version: match.version,
|
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 })),
|
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const placeHint = (
|
||||||
|
<p className="muted small">
|
||||||
|
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы
|
||||||
|
разделить место (ничья). Цели и миры — на конец партии.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -294,6 +393,9 @@ export function MatchDetailPage() {
|
|||||||
{!inProgress && (
|
{!inProgress && (
|
||||||
<div className="small" style={{ marginTop: 4 }}>
|
<div className="small" style={{ marginTop: 4 }}>
|
||||||
Победа: <b>{winReasonLabel(match.win_reason)}</b>
|
Победа: <b>{winReasonLabel(match.win_reason)}</b>
|
||||||
|
{match.end_round != null && (
|
||||||
|
<span className="muted"> · конец в {match.end_round}-м раунде из {match.max_rounds}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
||||||
@@ -304,20 +406,16 @@ export function MatchDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Правка результатов</h3>
|
<h3>Правка результатов</h3>
|
||||||
<p className="muted small">
|
{placeHint}
|
||||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
|
||||||
игрока, чтобы разделить место (ничья).
|
|
||||||
</p>
|
|
||||||
<PlaceEditor
|
<PlaceEditor
|
||||||
players={match.participants}
|
players={match.participants}
|
||||||
blocks={finishBlocks}
|
blocks={finishBlocks}
|
||||||
eliminated={elim}
|
eliminated={elim}
|
||||||
comments={finishComments}
|
comments={finishComments}
|
||||||
onChange={(b, e) => {
|
counts={finishCounts}
|
||||||
setBlocks(b);
|
onChange={applyLayout}
|
||||||
setElim(e);
|
|
||||||
}}
|
|
||||||
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
||||||
|
onCount={applyCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -345,16 +443,14 @@ export function MatchDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<MatchOutcomeFields
|
||||||
<h3>Причина победы</h3>
|
winReason={winReason}
|
||||||
<PickerSelect
|
onReason={setWinReason}
|
||||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
endRound={endRound}
|
||||||
options={REASON_OPTIONS}
|
onEndRound={setEndRound}
|
||||||
placeholder="— причина —"
|
maxRounds={match.max_rounds}
|
||||||
renderOption={(o) => o.label}
|
warnings={warnings()}
|
||||||
onPick={(o) => setWinReason(o.id)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>О партии</h3>
|
<h3>О партии</h3>
|
||||||
@@ -413,6 +509,11 @@ export function MatchDetailPage() {
|
|||||||
<span className="muted small">· {p.faction_name}</span>
|
<span className="muted small">· {p.faction_name}</span>
|
||||||
{p.was_random && <span className="badge">🎲</span>}
|
{p.was_random && <span className="badge">🎲</span>}
|
||||||
</PlayerLink>
|
</PlayerLink>
|
||||||
|
{(p.objectives != null || p.worlds != null) && (
|
||||||
|
<div className="small muted" style={{ marginLeft: 32 }}>
|
||||||
|
цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
|
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -448,10 +549,7 @@ export function MatchDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Места</h3>
|
<h3>Места</h3>
|
||||||
<p className="muted small">
|
{placeHint}
|
||||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
|
||||||
игрока, чтобы разделить место (ничья).
|
|
||||||
</p>
|
|
||||||
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||||||
<p className="small" style={{ color: "var(--accent-2)" }}>
|
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||||||
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||||||
@@ -464,10 +562,10 @@ export function MatchDetailPage() {
|
|||||||
blocks={finishBlocks}
|
blocks={finishBlocks}
|
||||||
eliminated={elim}
|
eliminated={elim}
|
||||||
comments={finishComments}
|
comments={finishComments}
|
||||||
|
counts={finishCounts}
|
||||||
onChange={(b, e) => {
|
onChange={(b, e) => {
|
||||||
setBlocks(b);
|
const reason = applyLayout(b, e);
|
||||||
setElim(e);
|
queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason }));
|
||||||
queueDraft(draftOf({ blocks: b, eliminated: e }));
|
|
||||||
}}
|
}}
|
||||||
onComment={(uid, text) => {
|
onComment={(uid, text) => {
|
||||||
const next = { ...finishComments, [uid]: text };
|
const next = { ...finishComments, [uid]: text };
|
||||||
@@ -480,23 +578,33 @@ export function MatchDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
onCount={(uid, field, value) => {
|
||||||
</div>
|
const next = applyCount(uid, field, value);
|
||||||
|
queueDraft(
|
||||||
<div className="card">
|
draftOf({
|
||||||
<h3>Причина победы</h3>
|
objectives: countDict(next, "objectives"),
|
||||||
<PickerSelect
|
worlds: countDict(next, "worlds"),
|
||||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
}),
|
||||||
options={REASON_OPTIONS}
|
);
|
||||||
placeholder="— причина —"
|
|
||||||
renderOption={(o) => o.label}
|
|
||||||
onPick={(o) => {
|
|
||||||
setWinReason(o.id);
|
|
||||||
queueDraft(draftOf({ win_reason: o.id }));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MatchOutcomeFields
|
||||||
|
winReason={winReason}
|
||||||
|
onReason={(reason) => {
|
||||||
|
setWinReason(reason);
|
||||||
|
queueDraft(draftOf({ win_reason: reason }));
|
||||||
|
}}
|
||||||
|
endRound={endRound}
|
||||||
|
onEndRound={(round) => {
|
||||||
|
setEndRound(round);
|
||||||
|
queueDraft(draftOf({ end_round: round }));
|
||||||
|
}}
|
||||||
|
maxRounds={match.max_rounds}
|
||||||
|
warnings={warnings()}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>О партии</h3>
|
<h3>О партии</h3>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { ApiError } from "../../api/client";
|
|||||||
import { MatchMedia } from "../../components/MatchMedia";
|
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 { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
import { finishWarnings } from "../../domain/finishWarnings";
|
||||||
|
import {
|
||||||
|
LAST_STANDING,
|
||||||
|
reasonForSurvivors,
|
||||||
|
WIN_REASONS,
|
||||||
|
type WinReason,
|
||||||
|
winReasonLabel,
|
||||||
|
} from "../../domain/winReasons";
|
||||||
import {
|
import {
|
||||||
useAdminDeleteAttachment,
|
useAdminDeleteAttachment,
|
||||||
useAdminFactions,
|
useAdminFactions,
|
||||||
@@ -23,8 +30,16 @@ interface Row {
|
|||||||
eliminated: boolean;
|
eliminated: boolean;
|
||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
comment: string;
|
comment: string;
|
||||||
|
objectives: 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();
|
||||||
@@ -37,7 +52,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
const [playedAt, setPlayedAt] = useState("");
|
const [playedAt, setPlayedAt] = useState("");
|
||||||
const [overall, setOverall] = useState("");
|
const [overall, setOverall] = useState("");
|
||||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
|
||||||
|
const [endRound, setEndRound] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,19 +67,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
eliminated: p.eliminated,
|
eliminated: p.eliminated,
|
||||||
was_random: p.was_random,
|
was_random: p.was_random,
|
||||||
comment: p.comment ?? "",
|
comment: p.comment ?? "",
|
||||||
|
objectives: p.objectives ?? null,
|
||||||
|
worlds: p.worlds ?? null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setPlayedAt(match.played_at);
|
setPlayedAt(match.played_at);
|
||||||
setOverall(match.overall_comment ?? "");
|
setOverall(match.overall_comment ?? "");
|
||||||
if (match.win_reason) setWinReason(match.win_reason);
|
setWinReason(match.win_reason ?? null);
|
||||||
|
setEndRound(match.end_round ?? null);
|
||||||
}
|
}
|
||||||
}, [match]);
|
}, [match]);
|
||||||
|
|
||||||
const upd = (i: number, patch: Partial<Row>) =>
|
const upd = (i: number, patch: Partial<Row>) => {
|
||||||
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||||
|
setRows(next);
|
||||||
|
// Выбывание меняет число выживших: «последний выживший» ставится и снимается сам.
|
||||||
|
if ("eliminated" in patch) {
|
||||||
|
setWinReason(reasonForSurvivors(winReason, next.filter((r) => !r.eliminated).length));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!winReason) {
|
||||||
|
setError("Выберите причину победы.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
matchId,
|
matchId,
|
||||||
@@ -71,6 +100,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
played_at: playedAt,
|
played_at: playedAt,
|
||||||
overall_comment: overall.trim() || null,
|
overall_comment: overall.trim() || null,
|
||||||
win_reason: winReason,
|
win_reason: winReason,
|
||||||
|
end_round: endRound,
|
||||||
participants: rows.map((r) => ({
|
participants: rows.map((r) => ({
|
||||||
user_id: r.user_id,
|
user_id: r.user_id,
|
||||||
faction_id: r.faction_id,
|
faction_id: r.faction_id,
|
||||||
@@ -78,6 +108,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
eliminated: r.eliminated,
|
eliminated: r.eliminated,
|
||||||
was_random: r.was_random,
|
was_random: r.was_random,
|
||||||
comment: r.comment.trim() || null,
|
comment: r.comment.trim() || null,
|
||||||
|
objectives: r.objectives,
|
||||||
|
worlds: r.eliminated ? null : r.worlds,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -87,6 +119,22 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const warnings = match
|
||||||
|
? finishWarnings({
|
||||||
|
seats: rows.map((r) => ({
|
||||||
|
userId: r.user_id,
|
||||||
|
nickname: r.nickname,
|
||||||
|
place: r.place,
|
||||||
|
eliminated: r.eliminated,
|
||||||
|
objectives: r.objectives,
|
||||||
|
worlds: r.eliminated ? 0 : r.worlds,
|
||||||
|
})),
|
||||||
|
winReason,
|
||||||
|
endRound,
|
||||||
|
maxRounds: match.max_rounds,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
|
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
|
||||||
<div
|
<div
|
||||||
@@ -159,23 +207,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
выбыл
|
выбыл
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row">
|
||||||
<input
|
<input
|
||||||
|
style={{ flex: 1 }}
|
||||||
placeholder="Комментарий об игроке"
|
placeholder="Комментарий об игроке"
|
||||||
value={r.comment}
|
value={r.comment}
|
||||||
onChange={(e) => upd(i, { comment: e.target.value })}
|
onChange={(e) => upd(i, { comment: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
style={{ flex: "0 0 70px" }}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
placeholder="цели"
|
||||||
|
title="Цели на конец партии"
|
||||||
|
value={r.objectives ?? ""}
|
||||||
|
onChange={(e) => upd(i, { objectives: parseCount(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={{ flex: "0 0 70px" }}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
placeholder="миры"
|
||||||
|
title="Миры на конец партии"
|
||||||
|
disabled={r.eliminated}
|
||||||
|
value={r.eliminated ? 0 : (r.worlds ?? "")}
|
||||||
|
onChange={(e) => upd(i, { worlds: parseCount(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Причина победы</label>
|
<label className="label">Причина победы</label>
|
||||||
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}>
|
{winReason === LAST_STANDING ? (
|
||||||
|
<select value={LAST_STANDING} disabled>
|
||||||
|
<option value={LAST_STANDING}>{winReasonLabel(LAST_STANDING)}</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={winReason ?? ""}
|
||||||
|
onChange={(e) => setWinReason(e.target.value as WinReason)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
— выберите причину —
|
||||||
|
</option>
|
||||||
{WIN_REASONS.map((w) => (
|
{WIN_REASONS.map((w) => (
|
||||||
<option key={w.code} value={w.code}>
|
<option key={w.code} value={w.code}>
|
||||||
{w.label}
|
{w.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Раунд окончания (из {match.max_rounds})</label>
|
||||||
|
<select
|
||||||
|
value={endRound ?? ""}
|
||||||
|
onChange={(e) => setEndRound(e.target.value === "" ? null : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">не указан</option>
|
||||||
|
{Array.from({ length: match.max_rounds }, (_, k) => k + 1).map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}-й
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<ul className="warn-list">
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<li key={w}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|||||||
@@ -371,6 +371,30 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: 8px;
|
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 {
|
.rank-unlink {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 30px; height: 30px;
|
width: 30px; height: 30px;
|
||||||
|
|||||||
Reference in New Issue
Block a user