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() {
-