Партии: «выбывшие» игроки (красный крестик) = последнее место

Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему
авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг
eliminated для отрисовки красным крестиком. В результатах и списке партий
выбывшие показываются крестиком и идут в конец. Подсчёт очков не меняется
(используются обычные числовые места).

Бэкенд: модель MatchParticipant.eliminated + миграция 0009 (идемпотентная);
схемы (place стал необязательным, +eliminated); match_service (хелпер
_resolve_finish_places, finish/update); проброс eliminated в роутерах и stats.
Фронт: выбор места с крестиком в MatchDetailPage и AdminMatchEdit, отрисовка в
результатах и MatchList, стиль .place-badge.eliminated; регенерация schema.d.ts.
Тесты: завершение с выбывшим (последнее место, засчитан), все-выбывшие → 422.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 16:54:37 +03:00
co-authored by Claude Opus 4.8
parent cd7e5d16b4
commit 1438a17441
13 changed files with 226 additions and 17 deletions
+22 -3
View File
@@ -20,6 +20,7 @@ interface FinishRow {
nickname: string;
faction_name: string;
place: number;
eliminated: boolean;
comment: string;
}
@@ -53,6 +54,7 @@ export function MatchDetailPage() {
nickname: p.nickname,
faction_name: p.faction_name,
place: i + 1,
eliminated: false,
comment: p.comment ?? "",
}));
@@ -71,7 +73,8 @@ export function MatchDetailPage() {
body: {
participants: finishRows.map((r) => ({
user_id: r.user_id,
place: r.place,
place: r.eliminated ? null : r.place,
eliminated: r.eliminated,
comment: r.comment.trim() || null,
})),
win_reason: winReason,
@@ -141,7 +144,15 @@ export function MatchDetailPage() {
{sorted.map((p) => (
<div key={p.user_id}>
<div className="row" style={{ gap: 8 }}>
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>{p.place ?? "—"}</span>
<span
className={
"place-badge" +
(p.place === 1 && !p.eliminated ? " first" : "") +
(p.eliminated ? " eliminated" : "")
}
>
{p.eliminated ? "✕" : (p.place ?? "—")}
</span>
<b>{p.nickname}</b>
<span className="muted small">· {p.faction_name}</span>
{p.was_random && <span className="badge">🎲</span>}
@@ -189,12 +200,20 @@ export function MatchDetailPage() {
<div className="row">
<div style={{ flex: "0 0 130px" }}>
<label className="label">Место</label>
<select value={r.place} onChange={(e) => upd(i, { place: Number(e.target.value) })}>
<select
value={r.eliminated ? "elim" : String(r.place)}
onChange={(e) => {
const v = e.target.value;
if (v === "elim") upd(i, { eliminated: true });
else upd(i, { eliminated: false, place: Number(v) });
}}
>
{Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
<option value="elim">✕ выбыл</option>
</select>
</div>
</div>
+14 -1
View File
@@ -19,6 +19,7 @@ interface Row {
nickname: string;
faction_id: number;
place: number;
eliminated: boolean;
was_random: boolean;
comment: string;
}
@@ -46,6 +47,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
nickname: p.nickname,
faction_id: p.faction_id,
place: p.place ?? 1,
eliminated: p.eliminated,
was_random: p.was_random,
comment: p.comment ?? "",
})),
@@ -71,7 +73,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
participants: rows.map((r) => ({
user_id: r.user_id,
faction_id: r.faction_id,
place: r.place,
place: r.eliminated ? null : r.place,
eliminated: r.eliminated,
was_random: r.was_random,
comment: r.comment.trim() || null,
})),
@@ -133,9 +136,19 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
type="number"
min={1}
value={r.place}
disabled={r.eliminated}
onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
/>
</div>
<label className="row small" style={{ flex: "0 0 auto", gap: 4 }}>
<input
type="checkbox"
style={{ width: "auto" }}
checked={r.eliminated}
onChange={(e) => upd(i, { eliminated: e.target.checked })}
/>
выбыл
</label>
</div>
<input
placeholder="Комментарий об игроке"