game-server
WebSocket-сервер партий морского боя. Принимает подключения игроков, проверяет JWT-токены, выданные main-server, создаёт и хранит в памяти состояние партий.
Сборка и запуск
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 секунд после апгрейда). Параметры:
game_token— JWT партии, полезная нагрузка:{ "match_id": "uuid партии", "player1_id": 1, "player2_id": 2, "game_server": "ws://...", "exp": 1234567890 }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
Тесты
go test -race ./...
Покрыты: валидация токенов, разбор/сборка сообщений, сценарии менеджера (ожидание, старт, таймаут, повторный вход, замена соединения, ACK) и сквозные интеграционные тесты через настоящий websocket.
TODO (следующие этапы)
- игровая логика (расстановка кораблей, ходы) и остальные состояния;
- завершение партии по итогам игры с персонифицированными кодами победы/поражения;
- репорт итогов партии на main-server (реестр завершённых партий уже гарантирует, что партия завершается ровно один раз);
- очистка состояния начавшихся партий после их завершения.