# 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, "", ""]` | | 2 | S -> C | END | `[2, , , ""]` | | 3 | S -> C | STATE | `[3, , ]` | | 4 | C <-> S | ACK | `[4, ]` | ### 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, , , ""]`. Коды завершения: | Код | Причина | |-----|---------| | -1 | `join timeout exceeded` — второй игрок не присоединился вовремя | ### STATE (3) Переход игры в новое состояние. Код состояния всегда отсылается **с точки зрения получателя** — клиент не знает, «игрок 1» он или «игрок 2»: | Код | Состояние | |-----|-----------| | 1 | оба игрока расставляют корабли | | 2 | соперник готов | | 3 | ожидайте готовности соперника | | 4 | ваш ход | | 5 | ожидайте ход соперника | Сейчас рассылается только состояние `1` — при старте партии (когда оба игрока впервые присоединились). ### ACK (4) Каждое изменяющее состояние сообщение S->C (END, STATE) несёт `seq` — монотонно возрастающий номер в рамках пары (партия, игрок). Клиент подтверждает доставку сообщением `[4, ]`; 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 (реестр завершённых партий уже гарантирует, что партия завершается ровно один раз); - очистка состояния начавшихся партий после их завершения.