diff --git a/backend/app/main.py b/backend/app/main.py
index a1d0d1e..6b53aaa 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -208,9 +208,13 @@ def _mount_spa(app: FastAPI) -> None:
full_path
and _STATIC_DIR in candidate.parents
and candidate.is_file()
+ and candidate.name != "index.html"
):
+ # Хэшированные ассеты Vite — обычное кэширование (имя меняется при сборке).
return FileResponse(candidate)
- return FileResponse(index_file)
+ # index.html (корень и SPA-маршруты) — НЕ кэшируем: иначе браузер отдаёт старый
+ # документ из кэша и при недоступности хоста не доходит до Caddy с заглушкой.
+ return FileResponse(index_file, headers={"Cache-Control": "no-store"})
app = create_app()
diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile
index 678927f..e469638 100644
--- a/deploy/vps/Caddyfile
+++ b/deploy/vps/Caddyfile
@@ -12,12 +12,21 @@
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
# буферизация задержат поток событий (реал-тайм перестанет работать).
#
-# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет
-# (offline) подменяет ответ на свою страницу-заглушку (/etc/caddy/maintenance/maintenance.html),
-# вместо «зависшей загрузки» SPA. Файл заглушки — deploy/vps/maintenance.html.
+# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет (edge)
+# подменяет ответ на страницу-заглушку (/etc/caddy/maintenance/maintenance.html) вместо
+# «зависшей загрузки» SPA. Тот же сниппет запрещает кэш HTML-документа (Cache-Control: no-store),
+# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
+# Файл заглушки — deploy/vps/maintenance.html.
-# Заглушка «сайт недоступен» при недоступности апстрима. Импортируется в каждый сайт.
-(offline) {
+# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима.
+(edge) {
+ # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
+ # SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
+ # Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
+ @html header Accept *text/html*
+ header @html Cache-Control "no-store"
+
+ # Туннель лёг (апстрим 502/503/504) → отдаём свою страницу-заглушку со статусом 503.
handle_errors {
@down expression `{http.error.status_code} in [502, 503, 504]`
handle @down {
@@ -43,7 +52,7 @@ forbiddenstars.ru {
encode zstd gzip
reverse_proxy 127.0.0.1:9000
}
- import offline
+ import edge
}
forbidden-stars.ru {
@@ -58,5 +67,5 @@ forbidden-stars.ru {
encode zstd gzip
reverse_proxy 127.0.0.1:9001
}
- import offline
+ import edge
}
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx
index 99ab836..871edfe 100644
--- a/frontend/src/app/router.tsx
+++ b/frontend/src/app/router.tsx
@@ -6,6 +6,7 @@ import { AccountPage } from "../pages/AccountPage";
import { CreateMatchPage } from "../pages/CreateMatchPage";
import { GroupPage } from "../pages/GroupPage";
import { GroupSettingsPage } from "../pages/GroupSettingsPage";
+import { HelpPage } from "../pages/HelpPage";
import { HomePage } from "../pages/HomePage";
import { LoginPage } from "../pages/LoginPage";
import { MatchDetailPage } from "../pages/MatchDetailPage";
@@ -52,6 +53,7 @@ export const router = createBrowserRouter([
),
},
{ path: "account", element: },
+ { path: "help", element: },
],
},
{
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index a079818..0b2460a 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -15,6 +15,7 @@ const TITLES: Record = {
"/account": "Аккаунт",
"/match/new": "Новая партия",
"/stats": "Общая статистика",
+ "/help": "Справка",
};
export function AppShell() {
diff --git a/frontend/src/components/SideMenu.tsx b/frontend/src/components/SideMenu.tsx
index 70abd08..f9215e3 100644
--- a/frontend/src/components/SideMenu.tsx
+++ b/frontend/src/components/SideMenu.tsx
@@ -1,4 +1,4 @@
-import { LogOut } from "lucide-react";
+import { HelpCircle, LogOut } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useLogout, useMe } from "../hooks/auth";
@@ -18,6 +18,11 @@ export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void
navigate("/account");
};
+ const goHelp = () => {
+ onClose();
+ navigate("/help");
+ };
+
const doLogout = async () => {
onClose();
await logout.mutateAsync().catch(() => {});
@@ -41,6 +46,9 @@ export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void
)}
+
diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx
new file mode 100644
index 0000000..06d0ff1
--- /dev/null
+++ b/frontend/src/pages/HelpPage.tsx
@@ -0,0 +1,76 @@
+/**
+ * Справка. Пока — только как считаются очки игроков (рейтинг). В дальнейшем сюда
+ * добавятся правила, подсказки и прочее. Текст соответствует backend/app/services/scoring.py.
+ */
+export function HelpPage() {
+ return (
+
+
+
Справка
+
+ Здесь со временем появятся правила, подсказки и пояснения. Пока — о том, как
+ считается рейтинг игроков.
+
+
+
+
+
Очки игроков (рейтинг)
+
+ Рейтинг считается только по завершённым партиям. Незавершённые и отменённые
+ в зачёт не идут.
+
+
+ За каждую партию игрок получает «очки за место» — они нормированы по числу игроков
+ за столом, поэтому победа за большим столом ценится выше, чем за маленьким:
+
+ где N — число игроков в партии. Первое место даёт 1.0, последнее — 0.0.
+
+
+
Если несколько игроков делят место (ничья) — они делят сумму очков поровну.
+
Выбывшие из партии считаются как последнее место.
+
+
+ Итоговый рейтинг игрока = среднее «очков за место» по всем его партиям × 100
+ (шкала от 0 до 100).
+
+
+
+
+
Пример (стол на 4 игроков)
+
+
1-е место(4−1−0)/3 = 1.00 → 100
+
2-е место(4−2−0)/3 = 0.67 → 67
+
3-е место(4−3−0)/3 = 0.33 → 33
+
4-е место(4−4−0)/3 = 0.00 → 0
+
+ Ничья за 1-е (двое)(4−1−0.5)/3 = 0.83 каждому
+
+
+
+
+
+
Попадание в топ
+
+ Чтобы попасть в ранжированный топ, нужно сыграть минимум 3 партии. До этого
+ игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.
+
+
+
+
+
Другие показатели
+
+
Винрейт — доля партий, в которых игрок занял 1-е место.
+
Среднее место — среднее по завершённым партиям.
+
+ При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
+ и затем по нику.
+