Compare commits

Author SHA1 Message Date
cacto c47391b6db Укрепление эксплуатации: healthcheck'и, лимиты ресурсов, логи, self-hosted шрифты, smoke-проверка в CI
Deploy / deploy (push) Failing after 0s
2026-11-10 00:59:14 +05:00
cacto 6746d89233 Merge dev в main: две новые мини-игры ./blocks и ./invaders
Deploy / deploy (push) Failing after 0s
2026-08-12 12:13:38 +05:00
cacto cb25497bb8 Merge dev в main: home_automatization_controllers в списке проектов
Deploy / deploy (push) Failing after 0s
2026-08-12 00:52:57 +05:00
cacto 9c7caf50a7 Merge dev в main: server_tokens off
Deploy / deploy (push) Failing after 0s
2026-08-12 00:36:17 +05:00
cacto df24edd0ab Merge dev в main: аудит сайта (og-теги, whoami, prerender, ./tanks)
Deploy / deploy (push) Failing after 0s
2026-08-10 20:44:52 +05:00
cacto fb18a6f289 Merge dev в main: полный перенос дизайна и текстов из макета
Deploy / deploy (push) Failing after 1s
2026-08-10 00:42:11 +05:00
cacto 01eec92d48 Merge dev в main: редизайн + RU/EN
Deploy / deploy (push) Failing after 1s
2026-08-09 21:56:44 +05:00
cacto e1bfc2b5d7 Слияние dev: фикс клавиатуры, зелёное дуло
Deploy / deploy (push) Failing after 0s
2026-08-06 18:51:44 +05:00
cacto 0a47ac9234 Слияние dev: security-хардening, спавн-фикс
Deploy / deploy (push) Failing after 1s
2026-08-06 14:01:49 +05:00
cacto de6f0438c5 Слияние dev: coop с AI, звук, чистые URL
Deploy / deploy (push) Failing after 1s
2026-08-06 12:44:59 +05:00
cacto 01cefbbe77 Слияние dev: PvP-логика
Deploy / deploy (push) Failing after 1s
2026-08-04 12:26:30 +05:00
cacto 7b48c01a93 Merge pull request 'Dev' (#3) from dev into main
Deploy / deploy (push) Failing after 0s
Reviewed-on: #3
2026-07-26 18:25:56 +00:00
cacto d626a66ff7 Merge pull request 'Dev' (#2) from dev into main
Deploy / deploy (push) Failing after 0s
Reviewed-on: #2
2026-07-26 18:19:47 +00:00
cacto 128c5cf16e Merge pull request 'Dev' (#1) from dev into main
Deploy / deploy (push) Failing after 1s
Reviewed-on: #1
2026-07-26 18:03:36 +00:00
cacto a1c7cfdc9b Слияние dev: фикс DNS в деплое
Deploy / deploy (push) Failing after 2m15s
2026-07-26 22:31:04 +05:00
cacto b5566ec73e Слияние dev: каркас, резюме/проекты, лобби, автодеплой
Deploy / deploy (push) Failing after 2m40s
2026-07-26 22:06:56 +05:00
32 changed files with 375 additions and 18 deletions
+24 -1
View File
@@ -29,4 +29,27 @@ jobs:
run: | run: |
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d 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
+77
View File
@@ -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 — их
конфиги не входят в этот репозиторий и бэкапятся отдельно.
+5
View File
@@ -1,5 +1,10 @@
# ТЗ: cactoz.su # ТЗ: cactoz.su
> ⚠️ Это ТЗ исходного замысла (MVP). Реальность разошлась: появились мини-игры
> `./blocks` и `./invaders`, самостоятельная страница `./tanks` со своим JS,
> шрифты self-hosted, healthcheck'и в compose, структура `gameserver/src` компактнее
> описанной. Актуальная архитектура деплоя и runbook — в [README.md](README.md).
## 1. Общее ## 1. Общее
Личный сайт-визитка на домене `cactoz.su`. Три страницы, единая Matrix-стилистика. Личный сайт-визитка на домене `cactoz.su`. Три страницы, единая Matrix-стилистика.
+42
View File
@@ -10,12 +10,54 @@ services:
depends_on: depends_on:
- gameserver - gameserver
restart: unless-stopped 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: gameserver:
build: ./gameserver build: ./gameserver
networks: networks:
- internal - internal
restart: unless-stopped 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: networks:
internal: internal:
+1 -3
View File
@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>./blocks — cacto</title> <title>./blocks — cacto</title>
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="stylesheet" href="/css/fonts.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/theme.css" /> <link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/arcade.css" /> <link rel="stylesheet" href="/css/arcade.css" />
</head> </head>
+149
View File
@@ -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;
}
+1 -3
View File
@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>./tanks — cacto</title> <title>./tanks — cacto</title>
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="stylesheet" href="/css/fonts.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/theme.css" /> <link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/game.css" /> <link rel="stylesheet" href="/css/game.css" />
</head> </head>
+1 -3
View File
@@ -13,9 +13,7 @@
<meta property="og:image" content="https://cactoz.su/assets/og.png" /> <meta property="og:image" content="https://cactoz.su/assets/og.png" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="stylesheet" href="/css/fonts.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/theme.css" /> <link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/resume.css" /> <link rel="stylesheet" href="/css/resume.css" />
</head> </head>
+1 -3
View File
@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>./invaders — cacto</title> <title>./invaders — cacto</title>
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="stylesheet" href="/css/fonts.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/theme.css" /> <link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/arcade.css" /> <link rel="stylesheet" href="/css/arcade.css" />
</head> </head>
+9
View File
@@ -12,6 +12,15 @@ export class Network {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
this._emit(msg.type, msg.payload); 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) { on(type, handler) {
+2
View File
@@ -1,6 +1,7 @@
// Типы сообщений из TZ.md, раздел 5. // Типы сообщений из TZ.md, раздел 5.
export const ClientMessage = { export const ClientMessage = {
HELLO: 'hello', HELLO: 'hello',
PING: 'ping',
LOBBY_LIST_ROOMS: 'lobby.list_rooms', LOBBY_LIST_ROOMS: 'lobby.list_rooms',
LOBBY_CREATE_ROOM: 'lobby.create_room', LOBBY_CREATE_ROOM: 'lobby.create_room',
LOBBY_JOIN_ROOM: 'lobby.join_room', LOBBY_JOIN_ROOM: 'lobby.join_room',
@@ -11,6 +12,7 @@ export const ClientMessage = {
export const ServerMessage = { export const ServerMessage = {
HELLO_ACK: 'hello.ack', HELLO_ACK: 'hello.ack',
PONG: 'pong',
ERROR: 'error', ERROR: 'error',
LOBBY_ROOMS: 'lobby.rooms', LOBBY_ROOMS: 'lobby.rooms',
LOBBY_ROOM_JOINED: 'lobby.room_joined', LOBBY_ROOM_JOINED: 'lobby.room_joined',
+1 -3
View File
@@ -7,9 +7,7 @@
<meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." /> <meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." />
<link rel="canonical" href="https://cactoz.su/projects" /> <link rel="canonical" href="https://cactoz.su/projects" />
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="stylesheet" href="/css/fonts.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/theme.css" /> <link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/projects.css" /> <link rel="stylesheet" href="/css/projects.css" />
</head> </head>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -1,5 +1,6 @@
#include "Lobby.hpp" #include "Lobby.hpp"
#include "Game.hpp" #include "Game.hpp"
#include "Log.hpp"
#include <algorithm> #include <algorithm>
#include <iomanip> #include <iomanip>
@@ -74,6 +75,10 @@ void Lobby::onOpen(WS *ws) {
} }
void Lobby::onClose(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); endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws); removePlayerFromRoom(ws);
sockets_.erase(ws); sockets_.erase(ws);
@@ -107,6 +112,8 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
handleGameReady(ws); handleGameReady(ws);
} else if (type == "game.input") { } else if (type == "game.input") {
handleGameInput(ws, payload); handleGameInput(ws, payload);
} else if (type == "ping") {
send(ws, "pong", {});
} else { } else {
sendError(ws, "unknown_type", "unknown message type: " + type); 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; std::string room_id = room.id;
rooms_[room_id] = std::move(room); rooms_[room_id] = std::move(room);
logLine("room created: " + room_id + " mode=" + mode + " by=" + data->nickname);
send(ws, "lobby.room_joined", { send(ws, "lobby.room_joined", {
{"room_id", room_id}, {"room_id", room_id},
{"name", 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}); room.players.push_back({data->player_id, data->nickname, ws});
data->room_id = room_id; 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", { send(ws, "lobby.room_joined", {
{"room_id", room_id}, {"room_id", room_id},
{"name", 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"); sendError(ws, "not_in_room", "you are not in a room");
return; return;
} }
logLine("player left: " + ws->getUserData()->room_id + " " + ws->getUserData()->nickname);
endActiveGameIfAny(ws->getUserData()->room_id, ws); endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws); removePlayerFromRoom(ws);
broadcastRoomsToLobby(); broadcastRoomsToLobby();
@@ -301,7 +314,11 @@ void Lobby::maybeStartGame(const std::string &room_id) {
room.state = "playing"; room.state = "playing";
logLine("game started: " + room_id + " mode=" + room.mode + " players=" +
std::to_string(room.players.size()));
auto game = std::make_unique<Game>(room, [this, room_id]() { auto game = std::make_unique<Game>(room, [this, room_id]() {
logLine("game finished: " + room_id);
auto rit = rooms_.find(room_id); auto rit = rooms_.find(room_id);
if (rit != rooms_.end()) { if (rit != rooms_.end()) {
rit->second.state = "waiting"; rit->second.state = "waiting";
@@ -358,6 +375,7 @@ void Lobby::removePlayerFromRoom(WS *ws) {
room.players.end()); room.players.end());
if (room.players.empty()) { if (room.players.empty()) {
logLine("room removed: " + it->first + " (empty)");
rooms_.erase(it); rooms_.erase(it);
} else { } else {
broadcastRoomUpdated(room); broadcastRoomUpdated(room);
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <ctime>
#include <iostream>
#include <string>
// События лобби/игр пишутся в 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;
}
+12 -1
View File
@@ -4,12 +4,23 @@
#include <iostream> #include <iostream>
#include "Lobby.hpp" #include "Lobby.hpp"
#include "Log.hpp"
int main() { int main() {
Lobby lobby; Lobby lobby;
uWS::App() uWS::App()
.ws<PerSocketData>("/ws", { .ws<PerSocketData>("/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) { .open = [&lobby](auto *ws) {
lobby.onOpen(ws); lobby.onOpen(ws);
}, },
@@ -22,7 +33,7 @@ int main() {
}) })
.listen(9001, [](auto *token) { .listen(9001, [](auto *token) {
if (token) { if (token) {
std::cout << "gameserver listening on :9001\n"; logLine("gameserver listening on :9001");
} else { } else {
std::cerr << "failed to listen on :9001\n"; std::cerr << "failed to listen on :9001\n";
} }
+14 -1
View File
@@ -13,10 +13,16 @@ server {
# не светим точную версию nginx в заголовке Server и в error-страницах # не светим точную версию nginx в заголовке Server и в error-страницах
server_tokens off; 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-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin 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 / { location / {
try_files $uri $uri.html $uri/ =404; try_files $uri $uri.html $uri/ =404;
@@ -28,6 +34,13 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# статичные ассеты меняются редко (лого, og-картинка, CV) — час кэша
# компромисс между скоростью и актуальностью без хешей в именах
location /assets/ {
add_header Cache-Control "public, max-age=3600";
try_files $uri =404;
}
location /ws { location /ws {
limit_req zone=ws_connect burst=10 nodelay; limit_req zone=ws_connect burst=10 nodelay;
limit_conn ws_conn 20; limit_conn ws_conn 20;