diff --git a/backend/alembic/versions/0009_participant_eliminated.py b/backend/alembic/versions/0009_participant_eliminated.py new file mode 100644 index 0000000..4ca5031 --- /dev/null +++ b/backend/alembic/versions/0009_participant_eliminated.py @@ -0,0 +1,40 @@ +"""Участник партии: признак выбытия (eliminated). + +Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) → no-op; +на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite). + +Revision ID: 0009_participant_eliminated +Revises: 0008_notifications +Create Date: 2026-06-26 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0009_participant_eliminated" +down_revision: Union[str, None] = "0008_notifications" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("match_participants")} + if "eliminated" not in cols: + with op.batch_alter_table("match_participants") as b: + b.add_column( + sa.Column("eliminated", sa.Boolean(), nullable=False, server_default="0") + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("match_participants")} + if "eliminated" in cols: + with op.batch_alter_table("match_participants") as b: + b.drop_column("eliminated") diff --git a/backend/app/models.py b/backend/app/models.py index dfb169a..adb9f01 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -305,6 +305,8 @@ class MatchParticipant(SQLModel, table=True): ) ) place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) + # Выбыл из партии: place авто = последнее место (отображается красным крестиком). + eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) comment: str | None = Field(sa_column=Column(Text, nullable=True)) created_at: datetime = Field(default_factory=_utcnow, nullable=False) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 09d2087..44b889f 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -216,6 +216,7 @@ def update_match( user_id=p.user_id, faction_id=p.faction_id, place=p.place, + eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, ) diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 62a048f..719efd6 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -53,6 +53,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False faction_id=f.id, # type: ignore[arg-type] faction_name=f.name_ru, place=p.place, + eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, ) @@ -138,7 +139,13 @@ def finish_match( match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) finish = [ - FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id) + FinishInput( + user_id=p.user_id, + place=p.place, + eliminated=p.eliminated, + comment=p.comment, + faction_id=p.faction_id, + ) for p in body.participants ] match = match_service.finish_match( @@ -194,6 +201,7 @@ def update_match( user_id=p.user_id, faction_id=p.faction_id, place=p.place, + eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, ) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index f14c142..d602f12 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -204,7 +204,8 @@ class MatchCreate(BaseModel): # Этап 2 (завершение): места, комментарии, причина победы. class MatchFinishParticipant(BaseModel): user_id: int - place: int = Field(ge=1) + place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated) + eliminated: bool = False # выбыл из партии → авто-проставится последнее место comment: str | None = None faction_id: int | None = None # опц. смена фракции при завершении @@ -221,7 +222,8 @@ class MatchFinish(BaseModel): class ParticipantInput(BaseModel): user_id: int faction_id: int - place: int = Field(ge=1) + place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated) + eliminated: bool = False was_random: bool = False comment: str | None = None @@ -240,6 +242,7 @@ class MatchParticipantRead(BaseModel): faction_id: int faction_name: str place: int | None = None + eliminated: bool = False was_random: bool comment: str | None = None @@ -357,6 +360,7 @@ class MatchListParticipant(BaseModel): faction_id: int faction_name: str place: int | None = None + eliminated: bool = False was_random: bool comment: str | None = None diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index f522670..00ead8e 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -38,7 +38,8 @@ class FinishInput: """Результат участника на этапе завершения.""" user_id: int - place: int + place: int | None = None # None у выбывшего (eliminated) — место проставится авто + eliminated: bool = False comment: str | None = None faction_id: int | None = None # опц. смена фракции при завершении @@ -49,7 +50,8 @@ class ParticipantInput: user_id: int faction_id: int - place: int + place: int | None = None + eliminated: bool = False was_random: bool = False comment: str | None = None @@ -129,6 +131,22 @@ def _validate_ranking(places: list[int]) -> None: expected = current + tie +def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int, int]: + """rows: (user_id, place, eliminated) → {user_id: место}. + + Выжившим (не eliminated) валидируем competition ranking (1,2,2,4). Выбывшим + проставляем общее последнее место = len(survivors)+1 (ничья последнего ранга), + чтобы подсчёт очков считал их как последнее место без отдельных правок SQL.""" + survivors = [(uid, place) for uid, place, elim in rows if not elim] + if not survivors: + raise InvalidRankingError("Хотя бы один игрок должен остаться в партии (с местом).") + if any(place is None for _uid, place in survivors): + raise InvalidRankingError("Укажите место каждому не выбывшему игроку.") + _validate_ranking([place for _uid, place in survivors]) # type: ignore[misc] + elim_place = len(survivors) + 1 + return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc] + + def _group_member_ids(session: Session, group_id: int) -> set[int]: return { m.user_id @@ -235,11 +253,12 @@ def finish_match( if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)): raise FactionNotAvailableError() - _validate_ranking([f.place for f in finish]) + places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish]) for f in finish: p = existing[f.user_id] - p.place = f.place + p.place = places[f.user_id] + p.eliminated = f.eliminated p.comment = f.comment or None if f.faction_id is not None: p.faction_id = f.faction_id @@ -310,7 +329,9 @@ def update_match( [p.user_id for p in participants], [p.faction_id for p in participants], ) - _validate_ranking([p.place for p in participants]) + places = _resolve_finish_places( + [(p.user_id, p.place, p.eliminated) for p in participants] + ) for old in session.exec( select(MatchParticipant).where(MatchParticipant.match_id == match.id) ).all(): @@ -322,7 +343,8 @@ def update_match( match_id=match.id, # type: ignore[arg-type] user_id=p.user_id, faction_id=p.faction_id, - place=p.place, + place=places[p.user_id], + eliminated=p.eliminated, was_random=p.was_random, comment=p.comment or None, ) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 008c320..a50e03b 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -297,6 +297,7 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i "faction_id": f.id, "faction_name": f.name_ru, "place": p.place, + "eliminated": p.eliminated, "was_random": p.was_random, "comment": p.comment, } @@ -343,6 +344,7 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: "faction_id": f.id, "faction_name": f.name_ru, "place": p.place, + "eliminated": p.eliminated, "was_random": p.was_random, "comment": p.comment, } diff --git a/backend/tests/test_eliminated.py b/backend/tests/test_eliminated.py new file mode 100644 index 0000000..3109121 --- /dev/null +++ b/backend/tests/test_eliminated.py @@ -0,0 +1,71 @@ +"""Выбывшие игроки: красный крестик = последнее место (засчитывается, без отд. «проигрыша»).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + + +def _group(client: TestClient, name: str = "Гр") -> int: + return client.post( + "/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + + +def _start3(client: TestClient, engine): + me = login(client, "Капитан") + gid = _group(client) + b = add_group_member(engine, gid, "Боец") + c = add_group_member(engine, gid, "Третий") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + started = start_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0]}, + {"user_id": b, "faction_id": fids[1]}, + {"user_id": c, "faction_id": fids[2]}, + ], + ) + assert started.status_code == 200, started.text + return me, b, c, gid, started.json()["id"] + + +def test_eliminated_gets_last_place(client: TestClient, engine): + me, b, c, _gid, mid = _start3(client, engine) + r = finish_match( + client, + mid, + [ + {"user_id": me["id"], "place": 1}, + {"user_id": b, "place": 2}, + {"user_id": c, "eliminated": True}, # выбыл + ], + ) + assert r.status_code == 200, r.text + parts = {p["user_id"]: p for p in r.json()["participants"]} + assert parts[c]["eliminated"] is True + assert parts[c]["place"] == 3 # survivors=2 → последнее место 3 + assert parts[me["id"]]["eliminated"] is False + # Выбывший — в конце списка результатов. + assert r.json()["participants"][-1]["user_id"] == c + + # Партия засчитана выбывшему как сыгранная (последнее место, 0 очков). + login(client, "Третий") + stats = client.get("/api/users/me/stats").json() + assert stats["overall"]["games"] == 1 + assert stats["overall"]["wins"] == 0 + + +def test_all_eliminated_rejected(client: TestClient, engine): + me, b, c, _gid, mid = _start3(client, engine) + r = finish_match( + client, + mid, + [ + {"user_id": me["id"], "eliminated": True}, + {"user_id": b, "eliminated": True}, + {"user_id": c, "eliminated": True}, + ], + ) + assert r.status_code == 422, r.text # должен остаться хотя бы один с местом diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index e7f360b..678927f 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -11,6 +11,25 @@ # # SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/ # буферизация задержат поток событий (реал-тайм перестанет работать). +# +# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет +# (offline) подменяет ответ на свою страницу-заглушку (/etc/caddy/maintenance/maintenance.html), +# вместо «зависшей загрузки» SPA. Файл заглушки — deploy/vps/maintenance.html. + +# Заглушка «сайт недоступен» при недоступности апстрима. Импортируется в каждый сайт. +(offline) { + handle_errors { + @down expression `{http.error.status_code} in [502, 503, 504]` + handle @down { + root * /etc/caddy/maintenance + rewrite * /maintenance.html + header Cache-Control "no-store" + file_server { + status 503 + } + } + } +} forbiddenstars.ru { tls /etc/caddy/certs/forbiddenstars.ru/fullchain.pem /etc/caddy/certs/forbiddenstars.ru/privkey.pem @@ -24,6 +43,7 @@ forbiddenstars.ru { encode zstd gzip reverse_proxy 127.0.0.1:9000 } + import offline } forbidden-stars.ru { @@ -38,4 +58,5 @@ forbidden-stars.ru { encode zstd gzip reverse_proxy 127.0.0.1:9001 } + import offline } diff --git a/deploy/vps/README.md b/deploy/vps/README.md index a546449..06307bb 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -85,16 +85,26 @@ rm -rf /root/certs-tmp > как «промежуточную» часть. Если дал один файл, где leaf и chain уже вместе, — это и есть > `fullchain.pem` (склейка не нужна). -## 6. Caddyfile -Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile`, затем: +## 6. Caddyfile + страница-заглушка +Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile` и положи страницу +«сайт недоступен» (показывается, когда туннель к хосту лежит — вместо зависшей «Загрузки» SPA): ```bash +mkdir -p /etc/caddy/maintenance +# с локальной машины: +scp deploy/vps/maintenance.html root@186.246.51.17:/etc/caddy/maintenance/maintenance.html +# на VPS: caddy validate --config /etc/caddy/Caddyfile systemctl reload caddy ``` +> Заглушка живёт в сниппете `(offline)` Caddyfile: при ответе апстрима 502/503/504 +> Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` со статусом 503 и `Cache-Control: no-store`. +> Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен, +> файл читается на каждый запрос). ## 7. Проверка 1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`). 2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`. -3. Пока соответствующий туннель не поднят — Caddy отдаёт `502`, это ожидаемо. +3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки» + (HTTP 503), это ожидаемо. Логи Caddy: `journalctl -u caddy -f`. diff --git a/deploy/vps/maintenance.html b/deploy/vps/maintenance.html new file mode 100644 index 0000000..d6a71fe --- /dev/null +++ b/deploy/vps/maintenance.html @@ -0,0 +1,64 @@ + + + + + + + + + Сайт недоступен — Forbidden Stars + + + +
+
🍫
+

Технические шоколадки

+

Сайт временно недоступен — мы уже чиним. Загляните через минутку.

+
Страница обновится сама, как только всё заработает
+
+ + diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index b1876ce..8ec6f32 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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 diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 196fe46..a079818 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { useMe } from "../hooks/auth"; @@ -5,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents"; import { BottomBar } from "./BottomBar"; import { NotificationBell } from "./NotificationBell"; import { NotificationToaster } from "./NotificationToaster"; +import { SideMenu } from "./SideMenu"; const TITLES: Record = { "/": "Forbidden Stars", @@ -19,6 +21,7 @@ export function AppShell() { const location = useLocation(); const navigate = useNavigate(); const { data: me } = useMe(); + const [menuOpen, setMenuOpen] = useState(false); useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии const title = TITLES[location.pathname] ?? @@ -29,13 +32,9 @@ export function AppShell() {
- {isHome ? ( - {title} - ) : ( - - )} +
{!isHome ? title : ""}
@@ -49,7 +48,8 @@ export function AppShell() { - + setMenuOpen(false)} /> + setMenuOpen(true)} />
); } diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx index 25d6a4c..eab3c57 100644 --- a/frontend/src/components/BottomBar.tsx +++ b/frontend/src/components/BottomBar.tsx @@ -1,16 +1,46 @@ -import { Plus, Settings, Users } from "lucide-react"; +import { Menu, Plus, Users } from "lucide-react"; +import { useRef } from "react"; import { useNavigate } from "react-router-dom"; import { useToast } from "../context/ToastContext"; import { useMe } from "../hooks/auth"; -export function BottomBar() { +const ADMIN_HOLD_MS = 10000; // секретный вход в админку — удержание кнопки меню 10с + +export function BottomBar({ onMenu }: { onMenu: () => void }) { const { data: me } = useMe(); const navigate = useNavigate(); const toast = useToast(); const hasGroup = me?.active_group_id != null; const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id); + // Долгое удержание кнопки меню → /admin/login; короткий тап → открыть боковое меню. + const holdTimer = useRef | null>(null); + const holdFired = useRef(false); + + const startHold = () => { + holdFired.current = false; + holdTimer.current = setTimeout(() => { + holdFired.current = true; + navigate("/admin/login"); + }, ADMIN_HOLD_MS); + }; + const clearHold = () => { + if (holdTimer.current) { + clearTimeout(holdTimer.current); + holdTimer.current = null; + } + }; + // Открытие меню — на нативном onClick (надёжно на мобильных, в отличие от pointerup, + // который не приходит при pointercancel). Долгий зажим подавляет этот клик. + const onMenuClick = () => { + if (holdFired.current) { + holdFired.current = false; + return; + } + onMenu(); + }; + const onCreate = () => { if (!hasGroup) { toast.show("Вы не состоите в группе. Создайте или вступите в группу."); @@ -29,9 +59,17 @@ export function BottomBar() {
- + +
+
+ + ); +} diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index 3b9493e..7aac38b 100644 --- a/frontend/src/components/MatchList.tsx +++ b/frontend/src/components/MatchList.tsx @@ -21,7 +21,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) { onClick={() => navigate(`/match/${m.id}`)} >
- {m.played_at} + {m.played_at} {inProgress ? ( идёт ) : ( @@ -33,8 +33,14 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
{sorted.map((p) => (
- - {p.place ?? "—"} + + {p.eliminated ? "✕" : (p.place ?? "—")} {p.nickname} · {p.faction_name} diff --git a/frontend/src/components/SideMenu.tsx b/frontend/src/components/SideMenu.tsx new file mode 100644 index 0000000..70abd08 --- /dev/null +++ b/frontend/src/components/SideMenu.tsx @@ -0,0 +1,51 @@ +import { LogOut } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { useLogout, useMe } from "../hooks/auth"; +import { useMyStats } from "../hooks/stats"; +import { Avatar } from "./Avatar"; + +/** Боковое меню, выезжающее из правого края. Сверху — кликабельная плашка аккаунта + * (краткая сводка → /account), ниже — выход. Открытием управляет AppShell. */ +export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void }) { + const { data: me } = useMe(); + const { data: stats } = useMyStats(); + const logout = useLogout(); + const navigate = useNavigate(); + + const goAccount = () => { + onClose(); + navigate("/account"); + }; + + const doLogout = async () => { + onClose(); + await logout.mutateAsync().catch(() => {}); + navigate("/login", { replace: true }); + }; + + return ( + <> +
+ + + ); +} diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index bae0b26..7c8a336 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; import { Avatar } from "../components/Avatar"; -import { GroupSwitcher } from "../components/GroupSwitcher"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { useToast } from "../context/ToastContext"; @@ -125,6 +124,7 @@ export function AccountPage() { rows={3} maxLength={500} placeholder="Пара слов о себе…" + style={{ resize: "none" }} value={bioValue} onChange={(e) => setBio(e.target.value)} /> @@ -153,17 +153,9 @@ export function AccountPage() { {stats && } - - - -

- -

); } diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index c2638bc..5e005c6 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -1,5 +1,5 @@ import { Dices, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; @@ -34,6 +34,17 @@ export function CreateMatchPage() { const [rows, setRows] = useState([emptyDraft(), emptyDraft()]); const [error, setError] = useState(null); + // Анимация-«барабан» рандома: для крутящейся строки — текущее мелькающее название. + const [spin, setSpin] = useState>({}); + const reelTimers = useRef>>({}); + + // Снимаем таймеры барабана при размонтировании. + useEffect( + () => () => { + Object.values(reelTimers.current).forEach((t) => clearTimeout(t)); + }, + [], + ); if (lm || lf) return ; if (!groupId) return
Нет активной группы.
; @@ -50,27 +61,72 @@ export function CreateMatchPage() { const addRow = () => setRows((rs) => [...rs, emptyDraft()]); const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); + // «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление), + // затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только + // после этого фракция «выбирается» (onReveal: показываем select с результатом). + const animateReel = ( + idx: number, + names: string[], + finalName: string, + onReveal: () => void, + ): Promise => + new Promise((resolve) => { + let delay = 50; + const tick = () => { + setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] })); + delay *= 1.18; + if (delay < 300) { + reelTimers.current[idx] = setTimeout(tick, delay); + } else { + reelTimers.current[idx] = setTimeout(() => { + setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной + reelTimers.current[idx] = setTimeout(() => { + onReveal(); + setSpin((s) => ({ ...s, [idx]: null })); + resolve(); + }, 500); // пауза перед «выбором» + }, delay); + } + }; + tick(); + }); + const randomizeRow = async (idx: number) => { + const pool = factions ?? []; + if (pool.length === 0 || spin[idx] != null) return; try { const faction = await randomize.mutateAsync({ group_id: groupId, exclude_faction_ids: takenFactionIds(idx), }); - update(idx, { faction_id: faction.id, was_random: true }); + await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () => + update(idx, { faction_id: faction.id, was_random: true }), + ); } catch (e) { + if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]); + setSpin((s) => ({ ...s, [idx]: null })); toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); } }; - const randomizeAll = () => { - const pool = [...(factions ?? [])]; + const randomizeAll = async () => { + const all = factions ?? []; + if (all.length === 0) return; + const pool = [...all]; for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы."); - setRows((rs) => - rs.map((r, i) => ({ ...r, faction_id: pool[i % pool.length]?.id ?? "", was_random: true })), + const names = all.map((f) => f.name_ru); + // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию. + await Promise.all( + rows.map((_, idx) => { + const f = pool[idx % pool.length]; + return animateReel(idx, names, f.name_ru, () => + update(idx, { faction_id: f.id, was_random: true }), + ); + }), ); }; @@ -102,13 +158,14 @@ export function CreateMatchPage() { }; const memberOptions = (members ?? []) as MemberRead[]; + const spinning = Object.values(spin).some((v) => v != null); return (

Этап 1 — старт партии

Выберите игроков и фракции. Места и итоги укажете при завершении.

-
@@ -137,28 +194,36 @@ export function CreateMatchPage() {
- + update(idx, { + faction_id: e.target.value ? Number(e.target.value) : "", + was_random: false, + }) + } + style={{ flex: 1 }} + > + + {(factions ?? []).map((f) => ( + + ))} + + )} +
- {row.was_random && row.faction_id !== "" && 🎲 случайная}
))} diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index ea05dfc..31a4f5f 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -72,21 +72,24 @@ export function GroupSettingsPage() {

Дополнения

- {(expansions ?? []).map((e) => ( - - ))} + {(expansions ?? []) + .filter((e) => !e.is_base) + .map((e) => ( + + ))}
{isOwner && ( -

); diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index e72d97d..303dda0 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; +import { ConfirmDialog } from "../components/ConfirmDialog"; import { MatchMedia } from "../components/MatchMedia"; import { Spinner } from "../components/Spinner"; import { formatDuration, formatTime } from "../domain/format"; @@ -20,6 +21,7 @@ interface FinishRow { nickname: string; faction_name: string; place: number; + eliminated: boolean; comment: string; } @@ -38,6 +40,7 @@ export function MatchDetailPage() { const [winReason, setWinReason] = useState("objectives"); const [overall, setOverall] = useState(""); const [error, setError] = useState(null); + const [confirmRemove, setConfirmRemove] = useState(false); if (isLoading) return ; if (!match) return
Партия не найдена.
; @@ -53,6 +56,7 @@ export function MatchDetailPage() { nickname: p.nickname, faction_name: p.faction_name, place: i + 1, + eliminated: false, comment: p.comment ?? "", })); @@ -71,7 +75,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 +146,15 @@ export function MatchDetailPage() { {sorted.map((p) => (
- {p.place ?? "—"} + + {p.eliminated ? "✕" : (p.place ?? "—")} + {p.nickname} · {p.faction_name} {p.was_random && 🎲} @@ -189,12 +202,20 @@ export function MatchDetailPage() {
- { + 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) => ( ))} +
@@ -252,10 +273,32 @@ export function MatchDetailPage() { )} {canModify && ( - )} + + setConfirmRemove(false)} + onConfirm={() => { + setConfirmRemove(false); + remove(); + }} + />
); } diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index 15b7cd9..07eba02 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -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) })} />
+