create a websocket game server

This commit is contained in:
2026-09-22 09:08:13 +03:00
parent 4758ffeeb0
commit d6efb46f87
13 changed files with 1634 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
# game-server
WebSocket-сервер партий морского боя. Принимает подключения игроков,
проверяет JWT-токены, выданные main-server, создаёт и хранит в памяти
состояние партий.
## Сборка и запуск
```sh
go build ./cmd/server
JWT_SECRET=... PUBLIC_ADDR=ws://localhost:8081/ws ./server
```
Конфигурация через переменные окружения:
| Переменная | По умолчанию | Описание |
|--------------------|--------------|----------|
| `LISTEN_ADDR` | `:8081` | Адрес, на котором открывается HTTP/WebSocket-сервер (может быть за reverse proxy). |
| `PUBLIC_ADDR` | — (обяз.) | Публичный адрес сервера, например `ws://localhost:8081/ws`. Используется **только** для идентификации: сравнивается строго (побайтово) с полем `game_server` игрового токена. Main-server должен подписывать в токены ровно эту строку. |
| `JWT_SECRET` | — (обяз.) | Общий с main-server HS256-секрет (`secret_key_base`). Им подписаны и игровой, и пользовательский токены. |
| `JOIN_TIMEOUT` | `2m` | Сколько первый игрок ждёт второго, прежде чем партия будет отменена. Go-duration (`90s`, `2m`) или число секунд. |
| `ALLOWED_ORIGINS` | `*` | Список Origin-паттернов через запятую для проверки заголовка `Origin`; `*` отключает проверку. |
Эндпоинты: `GET /ws` — WebSocket, `GET /healthz` — проверка живости.
## Протокол
Все сообщения — JSON-массивы, первый элемент — числовой опкод.
| Опкод | Направление | Название | Формат |
|-------|-------------|----------|--------|
| 1 | C -> S | JOIN | `[1, "<game_token>", "<user_token>"]` |
| 2 | S -> C | END | `[2, <seq>, <code>, "<reason>"]` |
| 3 | S -> C | STATE | `[3, <seq>, <state_code>]` |
| 4 | C <-> S | ACK | `[4, <seq>]` |
### JOIN (1)
Первое и единственное сообщение, которое сервер принимает на свежем
соединении (в течение 10 секунд после апгрейда). Параметры:
1. `game_token` — JWT партии, полезная нагрузка:
```json
{
"match_id": "uuid партии",
"player1_id": 1,
"player2_id": 2,
"game_server": "ws://...",
"exp": 1234567890
}
```
2. `user_token` — личный JWT игрока: `{"user_id": 1, "exp": ...}`.
Проверки при JOIN:
- подпись (HS256) и срок действия обоих токенов;
- `game_server` строго равен `PUBLIC_ADDR` этого сервера;
- `player1_id != player2_id`;
- `user_id` из пользовательского токена совпадает с `player1_id` или
`player2_id` игрового токена;
- партия не находится в реестре завершённых (см. ниже).
При неудаче соединение закрывается с кодом из диапазона 4xxx (см. ниже).
### END (2)
Партия завершена. Формат: `[2, <seq>, <code>, "<reason>"]`.
Коды завершения:
| Код | Причина |
|-----|---------|
| -1 | `join timeout exceeded` — второй игрок не присоединился вовремя |
### STATE (3)
Переход игры в новое состояние. Код состояния всегда отсылается **с точки
зрения получателя** — клиент не знает, «игрок 1» он или «игрок 2»:
| Код | Состояние |
|-----|-----------|
| 1 | оба игрока расставляют корабли |
| 2 | соперник готов |
| 3 | ожидайте готовности соперника |
| 4 | ваш ход |
| 5 | ожидайте ход соперника |
Сейчас рассылается только состояние `1` — при старте партии (когда оба
игрока впервые присоединились).
### ACK (4)
Каждое изменяющее состояние сообщение S->C (END, STATE) несёт `seq` —
монотонно возрастающий номер в рамках пары (партия, игрок). Клиент
подтверждает доставку сообщением `[4, <seq>]`; ACK кумулятивен
(подтверждает все сообщения с `seq` <= указанного).
Неподтверждённые сообщения хранятся на сервере в очереди игрока и
переотправляются при его переподключении. Если очередь пуста, но игра уже
идёт, при переподключении текущее состояние отправляется заново (с новым
`seq`), чтобы клиент мог восстановить UI.
Направление S->C зарезервировано для подтверждения будущих сообщений
клиента (у которых пока нет номеров).
## Коды закрытия соединения
| Код | Значение |
|------|----------|
| 1000 | нормальное завершение (партия окончена, END уже отправлен) |
| 4000 | соединение заменено новым соединением того же игрока |
| 4001 | первое сообщение не JOIN |
| 4002 | сообщение не соответствует формату |
| 4003 | недействительный игровой токен / чужой `game_server` |
| 4004 | недействительный пользовательский токен / игрок не участник партии |
## Обработка краевых случаев
- **Переподключение.** Игрок идентифицируется токенами, а не соединением.
Новое JOIN от того же игрока закрывает старое соединение (код 4000) и
занимает его слот; очередь неподтверждённых сообщений переживает
разрыв и переотправляется. Одновременно существует не более одного
соединения на игрока.
- **Повторный JOIN после завершения партии.** Завершённые партии хранятся
в реестре (`match_id` -> END-сообщения обоих игроков) до истечения срока
действия игрового токена. Поздний JOIN получает в ответ сохранённое END
и закрытие соединения — фантомная партия не создаётся, и будущий репорт
итогов на main-server не задвоится. Реестр очищается лениво: при каждом
обращении к нему стираются записи с истёкшим токеном, фоновых задач нет.
- **Дедлайн присоединения** — минимум из `JOIN_TIMEOUT` и `exp` игрового
токена: после истечения токена второй игрок всё равно не сможет войти.
- **JOIN с токеном, указывающим на другой сервер** (неверный
`game_server`) отклоняется с кодом 4003.
- **`player1_id == player2_id`** отклоняется (код 4003) — иначе один игрок
мог бы занять оба слота.
- Неизвестные опкоды после JOIN игнорируются (совместимость с будущими
версиями протокола); мусорные/не-JSON сообщения логируются и пропускаются.
- Keepalive: сервер пингует соединение каждые 25 с; размер входящего
сообщения ограничен 16 КБ; все записи в сокет ограничены таймаутом 5 с.
## Структура кода
```
cmd/server/main.go — точка входа, graceful shutdown
internal/config/ — конфигурация из env
internal/protocol/ — опкоды, коды состояний/завершения, сборка/разбор сообщений
internal/auth/ — разбор и валидация JWT (HS256, exp обязателен)
internal/game/ — менеджер партий: слоты игроков, очереди неподтверждённых
сообщений, таймер присоединения, реестр завершённых партий
internal/ws/ — websocket-эндпоинт: апгрейд, JOIN, read-цикл, ping
```
## Тесты
```sh
go test -race ./...
```
Покрыты: валидация токенов, разбор/сборка сообщений, сценарии менеджера
(ожидание, старт, таймаут, повторный вход, замена соединения, ACK) и
сквозные интеграционные тесты через настоящий websocket.
## TODO (следующие этапы)
- игровая логика (расстановка кораблей, ходы) и остальные состояния;
- завершение партии по итогам игры с персонифицированными кодами
победы/поражения;
- репорт итогов партии на main-server (реестр завершённых партий уже
гарантирует, что партия завершается ровно один раз);
- очистка состояния начавшихся партий после их завершения.