Партии: «выбывшие» игроки (красный крестик) = последнее место
Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг 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:
Vendored
+22
-2
@@ -1566,7 +1566,12 @@ export interface components {
|
||||
/** User Id */
|
||||
user_id: number;
|
||||
/** Place */
|
||||
place: number;
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Comment */
|
||||
comment?: string | null;
|
||||
/** Faction Id */
|
||||
@@ -1626,6 +1631,11 @@ export interface components {
|
||||
faction_name: string;
|
||||
/** Place */
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Was Random */
|
||||
was_random: boolean;
|
||||
/** Comment */
|
||||
@@ -1643,6 +1653,11 @@ export interface components {
|
||||
faction_name: string;
|
||||
/** Place */
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Was Random */
|
||||
was_random: boolean;
|
||||
/** Comment */
|
||||
@@ -1816,7 +1831,12 @@ export interface components {
|
||||
/** Faction Id */
|
||||
faction_id: number;
|
||||
/** Place */
|
||||
place: number;
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/**
|
||||
* Was Random
|
||||
* @default false
|
||||
|
||||
@@ -33,8 +33,14 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>
|
||||
{p.place ?? "—"}
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||||
(p.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||||
</span>
|
||||
<span>{p.nickname}</span>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="Комментарий об игроке"
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
|
||||
.place-badge.eliminated { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ─── Боковое меню (выезжает справа) ─────────────────────────────────────────── */
|
||||
.drawer-overlay {
|
||||
|
||||
Reference in New Issue
Block a user