diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 2cb10a8..a643c90 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -29,4 +29,27 @@ jobs:
run: |
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
- docker image prune -f
+
+ # Если сборка/подъём упали — шаг упадёт, а старые контейнеры продолжат
+ # работать (up -d не выполнился). Проверяем, что новое поднялось по
+ # healthcheck'ам обоих сервисов, и что nginx реально отдаёт главную.
+ - name: Smoke check
+ working-directory: /data/docker/compose/cactoz
+ run: |
+ n=starting
+ g=starting
+ for i in $(seq 1 30); do
+ n=$(docker inspect -f '{{.State.Health.Status}}' cactoz-nginx 2>/dev/null || echo starting)
+ g_id=$(docker compose -f docker-compose.prod.yml ps -q gameserver)
+ g=$([ -n "$g_id" ] && docker inspect -f '{{.State.Health.Status}}' "$g_id" 2>/dev/null || echo starting)
+ if [ "$n" = "healthy" ] && [ "$g" = "healthy" ]; then break; fi
+ sleep 3
+ done
+ docker compose -f docker-compose.prod.yml ps
+ [ "$n" = "healthy" ]
+ [ "$g" = "healthy" ]
+ docker exec cactoz-nginx wget -qO /dev/null http://127.0.0.1/
+
+ - name: Prune dangling images
+ working-directory: /data/docker/compose/cactoz
+ run: docker image prune -f
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1d5622d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,77 @@
+# cactoz.su
+
+Личный сайт-визитка на `cactoz.su` в Matrix-стилистике: резюме, проекты и
+браузерные игры. Три страницы статики + мультиплеерный сервер для `./tanks`.
+Исходный замысел и игровой протокол — в [TZ.md](TZ.md) (⚠️ местами устарел).
+
+## Архитектура
+
+```
+ ┌────────── homelab (cacto-homelab) ──────────┐
+ push в main │ │
+ в Gitea ────────────► │ gitea-actions runner (docker.sock хоста) │
+ git.cactoz.su │ │ клонирует, синкает, собирает │
+ │ ▼ │
+ │ /data/docker/compose/cactoz │
+ │ docker compose -f docker-compose.prod.yml │
+ │ ├── nginx (статика + прокси /ws) │
+ │ └── gameserver (C++/uWebSockets, :9001) │
+ │ │ │
+ │ ▼ (external docker-сеть proxy) │
+ │ Caddy-стек (TLS-терминация 80/443) │
+ └──────────────────────────────────────────────┘
+```
+
+- **Деплой**: push в `main` → workflow `.gitea/workflows/deploy.yml` → build +
+ `up -d` + smoke-проверка по healthcheck'ам. Отката нет: если сборка/подъём
+ падают, шлётся уведомление, старые контейнеры продолжают работать.
+- **TLS** терминирует отдельный Caddy-стек, сеть `proxy` создаётся там
+ (`external: true`) — на этом хосте только подключаемся.
+- Gameserver изнутри доступен только по docker-сети `internal`; наружу
+ смотрит только nginx (через Caddy).
+
+## Локальный запуск
+
+```bash
+docker compose up --build # http://localhost, /ws на ws://localhost/ws
+```
+
+Сборка gameserver тянет зависимости через CMake FetchContent (uWebSockets,
+uSockets, nlohmann/json) — первый билд долгий, дальше кэшируется слоями Docker.
+
+## Структура
+
+```
+frontend/ чистый HTML/CSS/JS, без сборки
+ vendor/fonts/ self-hosted JetBrains Mono (OFL), 4 сабсета × 4 насыщенности
+ js/game/ клиент ./tanks: network (WS + ping 25с), lobby, battle
+ js/blocks.js, js/invaders.js одиночные мини-игры (canvas, без сервера)
+gameserver/
+ src/main.cpp uWS::App, /ws, лимиты фреймов/таймауты
+ src/Lobby.* лобби: комнаты, broadcast, ping/pong, события в stdout
+ src/Game.* авторитетная симуляция (20 Гц), pvp и coop с AI
+nginx/ статика + проксирование /ws (rate-limit на соединения)
+```
+
+## Runbook
+
+```bash
+# на homelab:
+cd /data/docker/compose/cactoz
+docker compose -f docker-compose.prod.yml ps # статус + healthcheck'и
+docker logs -f cactoz-gameserver-... 2>&1 | less # события лобби/игр
+docker compose -f docker-compose.prod.yml up -d --build # ручной перезапуск
+```
+
+- Логи ротируются (`max-size 10m × 3`), лимиты ресурсов: nginx 128M/0.5cpu,
+ gameserver 256M/1cpu.
+- Gameserver не умеет graceful shutdown — при рестарте активные матчи обрываются
+ (комнаты сами поднимаются заново).
+- Статика без хешей в именах: js/css отдаются с `no-cache`, `/assets/` — час.
+
+## Доступ и безопасность
+
+- SSH на homelab: ключи, вход только с доверенных сетей; sudo — по необходимости.
+- Gitea (`git.cactoz.su`) — источник истины для кода; секретов в репо нет.
+- За пределами compose на хосте живут Caddy-стек, Gitea и её runner — их
+ конфиги не входят в этот репозиторий и бэкапятся отдельно.
diff --git a/TZ.md b/TZ.md
index ad6aca6..299a5ca 100644
--- a/TZ.md
+++ b/TZ.md
@@ -1,5 +1,10 @@
# ТЗ: cactoz.su
+> ⚠️ Это ТЗ исходного замысла (MVP). Реальность разошлась: появились мини-игры
+> `./blocks` и `./invaders`, самостоятельная страница `./tanks` со своим JS,
+> шрифты self-hosted, healthcheck'и в compose, структура `gameserver/src` компактнее
+> описанной. Актуальная архитектура деплоя и runbook — в [README.md](README.md).
+
## 1. Общее
Личный сайт-визитка на домене `cactoz.su`. Три страницы, единая Matrix-стилистика.
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 43233e8..78739fe 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -10,12 +10,54 @@ services:
depends_on:
- gameserver
restart: unless-stopped
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ cap_add:
+ - NET_BIND_SERVICE
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/ || exit 1"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 5s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+ deploy:
+ resources:
+ limits:
+ memory: 128M
+ cpus: "0.5"
gameserver:
build: ./gameserver
networks:
- internal
restart: unless-stopped
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ healthcheck:
+ test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/9001'"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+ cpus: "1.0"
networks:
internal:
diff --git a/frontend/blocks.html b/frontend/blocks.html
index 0cb894c..6088782 100644
--- a/frontend/blocks.html
+++ b/frontend/blocks.html
@@ -5,9 +5,7 @@
./blocks — cacto
-
-
-
+
diff --git a/frontend/css/fonts.css b/frontend/css/fonts.css
new file mode 100644
index 0000000..70fb486
--- /dev/null
+++ b/frontend/css/fonts.css
@@ -0,0 +1,149 @@
+/* Self-hosted JetBrains Mono (сабсеты cyrillic/cyrillic-ext/latin/latin-ext).
+
+ Источник: Google Fonts, OFL-лицензия. Заменяет fonts.googleapis.com —
+
+ убирает внешнюю зависимость и лишние хосты из CSP. */
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-400-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-400-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-400-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-400-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-500-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-500-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-500-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-500-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-600-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-600-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-600-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-600-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-700-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-700-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-700-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('/vendor/fonts/jbmono-700-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/frontend/game.html b/frontend/game.html
index ad5fb35..6565937 100644
--- a/frontend/game.html
+++ b/frontend/game.html
@@ -5,9 +5,7 @@
./tanks — cacto
-
-
-
+
diff --git a/frontend/index.html b/frontend/index.html
index 75298e7..71f85ba 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -13,9 +13,7 @@
-
-
-
+
diff --git a/frontend/invaders.html b/frontend/invaders.html
index 9e88467..e834ed3 100644
--- a/frontend/invaders.html
+++ b/frontend/invaders.html
@@ -5,9 +5,7 @@
./invaders — cacto
-
-
-
+
diff --git a/frontend/js/game/network.js b/frontend/js/game/network.js
index fe6784e..7109c92 100644
--- a/frontend/js/game/network.js
+++ b/frontend/js/game/network.js
@@ -12,6 +12,15 @@ export class Network {
const msg = JSON.parse(event.data);
this._emit(msg.type, msg.payload);
});
+
+ // NAT-таблицы на промежуточных роутерах гаснут раньше, чем серверный
+ // idleTimeout (300с) — application-ping раз в 25с держит соединение живым
+ // и на случай обрыва держим интервал заметно меньше таймаута.
+ setInterval(() => {
+ if (this.socket.readyState === WebSocket.OPEN) {
+ this.send(ClientMessage.PING);
+ }
+ }, 25000);
}
on(type, handler) {
diff --git a/frontend/js/game/protocol.js b/frontend/js/game/protocol.js
index bc7ebcf..51c180f 100644
--- a/frontend/js/game/protocol.js
+++ b/frontend/js/game/protocol.js
@@ -1,6 +1,7 @@
// Типы сообщений из TZ.md, раздел 5.
export const ClientMessage = {
HELLO: 'hello',
+ PING: 'ping',
LOBBY_LIST_ROOMS: 'lobby.list_rooms',
LOBBY_CREATE_ROOM: 'lobby.create_room',
LOBBY_JOIN_ROOM: 'lobby.join_room',
@@ -11,6 +12,7 @@ export const ClientMessage = {
export const ServerMessage = {
HELLO_ACK: 'hello.ack',
+ PONG: 'pong',
ERROR: 'error',
LOBBY_ROOMS: 'lobby.rooms',
LOBBY_ROOM_JOINED: 'lobby.room_joined',
diff --git a/frontend/projects.html b/frontend/projects.html
index 4619bbc..bfc59a1 100644
--- a/frontend/projects.html
+++ b/frontend/projects.html
@@ -7,9 +7,7 @@
-
-
-
+
diff --git a/frontend/vendor/fonts/jbmono-400-cyrillic-ext.woff2 b/frontend/vendor/fonts/jbmono-400-cyrillic-ext.woff2
new file mode 100644
index 0000000..7f62749
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-400-cyrillic-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-400-cyrillic.woff2 b/frontend/vendor/fonts/jbmono-400-cyrillic.woff2
new file mode 100644
index 0000000..885d480
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-400-cyrillic.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-400-latin-ext.woff2 b/frontend/vendor/fonts/jbmono-400-latin-ext.woff2
new file mode 100644
index 0000000..11da8b4
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-400-latin-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-400-latin.woff2 b/frontend/vendor/fonts/jbmono-400-latin.woff2
new file mode 100644
index 0000000..5b9b02f
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-400-latin.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-500-cyrillic-ext.woff2 b/frontend/vendor/fonts/jbmono-500-cyrillic-ext.woff2
new file mode 100644
index 0000000..cef2dc4
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-500-cyrillic-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-500-cyrillic.woff2 b/frontend/vendor/fonts/jbmono-500-cyrillic.woff2
new file mode 100644
index 0000000..2a1f220
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-500-cyrillic.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-500-latin-ext.woff2 b/frontend/vendor/fonts/jbmono-500-latin-ext.woff2
new file mode 100644
index 0000000..87940cd
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-500-latin-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-500-latin.woff2 b/frontend/vendor/fonts/jbmono-500-latin.woff2
new file mode 100644
index 0000000..a43f1c3
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-500-latin.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-600-cyrillic-ext.woff2 b/frontend/vendor/fonts/jbmono-600-cyrillic-ext.woff2
new file mode 100644
index 0000000..f9b3e90
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-600-cyrillic-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-600-cyrillic.woff2 b/frontend/vendor/fonts/jbmono-600-cyrillic.woff2
new file mode 100644
index 0000000..b015629
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-600-cyrillic.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-600-latin-ext.woff2 b/frontend/vendor/fonts/jbmono-600-latin-ext.woff2
new file mode 100644
index 0000000..d34ede9
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-600-latin-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-600-latin.woff2 b/frontend/vendor/fonts/jbmono-600-latin.woff2
new file mode 100644
index 0000000..71ab3e6
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-600-latin.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-700-cyrillic-ext.woff2 b/frontend/vendor/fonts/jbmono-700-cyrillic-ext.woff2
new file mode 100644
index 0000000..77ddfd7
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-700-cyrillic-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-700-cyrillic.woff2 b/frontend/vendor/fonts/jbmono-700-cyrillic.woff2
new file mode 100644
index 0000000..499bf4b
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-700-cyrillic.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-700-latin-ext.woff2 b/frontend/vendor/fonts/jbmono-700-latin-ext.woff2
new file mode 100644
index 0000000..c1e5a07
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-700-latin-ext.woff2 differ
diff --git a/frontend/vendor/fonts/jbmono-700-latin.woff2 b/frontend/vendor/fonts/jbmono-700-latin.woff2
new file mode 100644
index 0000000..3728c4b
Binary files /dev/null and b/frontend/vendor/fonts/jbmono-700-latin.woff2 differ
diff --git a/gameserver/src/Lobby.cpp b/gameserver/src/Lobby.cpp
index 07307bd..affbd7c 100644
--- a/gameserver/src/Lobby.cpp
+++ b/gameserver/src/Lobby.cpp
@@ -1,5 +1,6 @@
#include "Lobby.hpp"
#include "Game.hpp"
+#include "Log.hpp"
#include
#include
@@ -74,6 +75,10 @@ void Lobby::onOpen(WS *ws) {
}
void Lobby::onClose(WS *ws) {
+ if (!ws->getUserData()->room_id.empty()) {
+ logLine("connection closed in-room: " + ws->getUserData()->room_id + " " +
+ ws->getUserData()->nickname);
+ }
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
sockets_.erase(ws);
@@ -107,6 +112,8 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
handleGameReady(ws);
} else if (type == "game.input") {
handleGameInput(ws, payload);
+ } else if (type == "ping") {
+ send(ws, "pong", {});
} else {
sendError(ws, "unknown_type", "unknown message type: " + type);
}
@@ -186,6 +193,8 @@ void Lobby::handleCreateRoom(WS *ws, const json &payload) {
std::string room_id = room.id;
rooms_[room_id] = std::move(room);
+ logLine("room created: " + room_id + " mode=" + mode + " by=" + data->nickname);
+
send(ws, "lobby.room_joined", {
{"room_id", room_id},
{"name", room_id},
@@ -227,6 +236,9 @@ void Lobby::handleJoinRoom(WS *ws, const json &payload) {
room.players.push_back({data->player_id, data->nickname, ws});
data->room_id = room_id;
+ logLine("player joined: " + room_id + " " + data->nickname + " (" +
+ std::to_string(room.players.size()) + "/" + std::to_string(kMaxPlayersPerRoom) + ")");
+
send(ws, "lobby.room_joined", {
{"room_id", room_id},
{"name", room_id},
@@ -243,6 +255,7 @@ void Lobby::handleLeaveRoom(WS *ws) {
sendError(ws, "not_in_room", "you are not in a room");
return;
}
+ logLine("player left: " + ws->getUserData()->room_id + " " + ws->getUserData()->nickname);
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
broadcastRoomsToLobby();
@@ -301,7 +314,11 @@ void Lobby::maybeStartGame(const std::string &room_id) {
room.state = "playing";
+ logLine("game started: " + room_id + " mode=" + room.mode + " players=" +
+ std::to_string(room.players.size()));
+
auto game = std::make_unique(room, [this, room_id]() {
+ logLine("game finished: " + room_id);
auto rit = rooms_.find(room_id);
if (rit != rooms_.end()) {
rit->second.state = "waiting";
@@ -358,6 +375,7 @@ void Lobby::removePlayerFromRoom(WS *ws) {
room.players.end());
if (room.players.empty()) {
+ logLine("room removed: " + it->first + " (empty)");
rooms_.erase(it);
} else {
broadcastRoomUpdated(room);
diff --git a/gameserver/src/Log.hpp b/gameserver/src/Log.hpp
new file mode 100644
index 0000000..07aa30d
--- /dev/null
+++ b/gameserver/src/Log.hpp
@@ -0,0 +1,18 @@
+#pragma once
+
+#include
+#include
+#include
+
+// События лобби/игр пишутся в stdout — их забирает `docker logs`, это единственный
+// способ понять в проде, что происходило с комнатами, не заходя в контейнер.
+inline void logLine(const std::string &message) {
+ std::time_t now = std::time(nullptr);
+ std::tm tm{};
+ localtime_r(&now, &tm);
+ char ts[20];
+ std::strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm);
+ // endl, а не "\n": stdout в контейнере идёт в пайп и полностью
+ // буферизуется — без сброса docker logs видит строки только на exit
+ std::cout << "[" << ts << "] " << message << std::endl;
+}
diff --git a/gameserver/src/main.cpp b/gameserver/src/main.cpp
index ea65b6b..3ed59c3 100644
--- a/gameserver/src/main.cpp
+++ b/gameserver/src/main.cpp
@@ -4,12 +4,23 @@
#include
#include "Lobby.hpp"
+#include "Log.hpp"
int main() {
Lobby lobby;
uWS::App()
.ws("/ws", {
+ // сообщения у нас мелкие (input/lobby) — 16 МиБ дефолта не нужен,
+ // лимит режет попытки завалить сервер гигантскими фреймами
+ .compression = uWS::DISABLED,
+ .maxPayloadLength = 8 * 1024,
+ // сервер сам шлёт WS-ping в пределах idleTimeout; клиент дополнительно
+ // шлёт application-ping раз в 25с — соединение переживает NAT-таймауты
+ .idleTimeout = 300,
+ .maxBackpressure = 16 * 1024,
+ .closeOnBackpressureLimit = true,
+ .sendPingsAutomatically = true,
.open = [&lobby](auto *ws) {
lobby.onOpen(ws);
},
@@ -22,7 +33,7 @@ int main() {
})
.listen(9001, [](auto *token) {
if (token) {
- std::cout << "gameserver listening on :9001\n";
+ logLine("gameserver listening on :9001");
} else {
std::cerr << "failed to listen on :9001\n";
}
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 42b6b8d..f0384ee 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -13,10 +13,16 @@ server {
# не светим точную версию nginx в заголовке Server и в error-страницах
server_tokens off;
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 256;
+ gzip_comp_level 5;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
- add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; base-uri 'none'; frame-ancestors 'none'" always;
+ add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'" always;
location / {
try_files $uri $uri.html $uri/ =404;
@@ -28,6 +34,13 @@ server {
try_files $uri =404;
}
+ # статичные ассеты меняются редко (лого, og-картинка, CV) — час кэша
+ # компромисс между скоростью и актуальностью без хешей в именах
+ location /assets/ {
+ add_header Cache-Control "public, max-age=3600";
+ try_files $uri =404;
+ }
+
location /ws {
limit_req zone=ws_connect burst=10 nodelay;
limit_conn ws_conn 20;