From f56f9eb47b2b7899f03c351082ab97e33204f9d8 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 26 Jun 2026 23:06:04 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B5=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=B0?= =?UTF-8?q?=D0=B9=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 6 +++++- deploy/vps/Caddyfile | 23 ++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) 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 } From 1e19038b7c09d97c972782a4ed8020365db6ca02 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 26 Jun 2026 23:12:21 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=A1=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0:?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20/help=20?= =?UTF-8?q?=D1=81=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=81=D1=87=D1=91=D1=82=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=BE=D0=B2=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20+=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82=20=C2=AB=D0=A1?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=C2=BB=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=BC=20=D0=BC=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая страница /help: формула League Points, нормировка по числу игроков, ничьи, выбывшие = последнее место, порог 3 партии для топа, прочие показатели. Текст соответствует services/scoring.py. Задел под будущие правила и подсказки. Co-Authored-By: Claude Opus 4.8 --- frontend/src/app/router.tsx | 2 + frontend/src/components/AppShell.tsx | 1 + frontend/src/components/SideMenu.tsx | 10 +++- frontend/src/pages/HelpPage.tsx | 76 ++++++++++++++++++++++++++++ frontend/src/styles/global.css | 12 +++++ 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/HelpPage.tsx 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) / 2) / (N − 1) +
+

+ где 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-е место.
  • +
  • Среднее место — среднее по завершённым партиям.
  • +
  • + При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту + и затем по нику. +
  • +
+
+
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 4d2440d..9937cb1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -94,6 +94,18 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } .error-text { color: var(--danger); font-size: 13px; } +/* Формула в справке */ +.formula { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + text-align: center; + color: var(--text); +} + /* Модальное окно подтверждения */ .modal-overlay { position: fixed;