# 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, ]` | | 5 | C -> S | PLACE_SHIPS | `[5, ship0_orientation, ship0_x, ship0_y, ...]` | | 6 | C -> S | FIRE | `[6, , ]` | | 7 | S -> C | FIRE_RESULT | `[7, , ]` | ### 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 > 0` и `player2_id > 0`, а также `player1_id != player2_id`; - `user_id > 0` из пользовательского токена совпадает с `player1_id` или `player2_id` игрового токена; - партия не находится в реестре завершённых (см. ниже). При неудаче соединение закрывается с кодом из диапазона 4xxx (см. ниже). ### END (2) Партия завершена. Формат: `[2, , , ""]`. Коды завершения (отсылаются с точки зрения получателя): | Код | Причина | Описание | |-----|---------|----------| | -1 | `join timeout exceeded` | второй игрок не присоединился вовремя | | 1 | `victory` | победа (все корабли соперника уничтожены) | | 2 | `defeat` | поражение (все корабли игрока уничтожены) | После отправки сообщения END сервер сразу закрывает WebSocket-соединение с кодом 1000 (нормальное завершение). Сохранённое сообщение END хранится в реестре завершённых партий и автоматически воспроизводится игроку при повторном подключении перед закрытием соединения. ### STATE (3) Переход игры в новое состояние. Код состояния всегда отсылается **с точки зрения получателя** — клиент не знает, «игрок 1» он или «игрок 2»: | Код | Состояние | |-----|-----------| | 1 | оба игрока расставляют корабли | | 2 | соперник готов | | 3 | ожидайте готовности соперника | | 4 | ваш ход | | 5 | ожидайте ход соперника | Смена состояний: - Состояние `1` рассылается обоим игрокам при старте партии (когда оба игрока впервые присоединились). - При получении расстановки кораблей от одного из игроков партия переходит в состояние ожидания соперника: игрок, сдавший корабли, получает состояние `3` («ожидайте готовности соперника»), а его противник — состояние `2` («соперник готов»). - Когда расстановку сдаёт второй игрок, игра переходит в фазу ходов: игрок, завершивший расстановку первым, получает состояние `4` («ваш ход»), а второй игрок — состояние `5` («ожидайте ход соперника»). - При промахе ход переходит к противнику: стрелявший игрок получает состояние `5` («ожидайте ход соперника»), а противник — состояние `4` («ваш ход»). - При попадании или потоплении корабля ход остаётся у текущего стрелка (состояние матча не изменяется). - При уничтожении всех кораблей одного из игроков игра завершается победой: партия не переходит в новые состояния STATE — вместо этого обоим игрокам рассылается сообщение END (победителю — с кодом 1 victory, проигравшему — с кодом 2 defeat), после чего соединение сразу закрывается. ### ACK (4) Каждое изменяющее состояние сообщение S->C (END, STATE, FIRE_RESULT) несёт `seq` — монотонно возрастающий номер в рамках пары (партия, игрок). Клиент подтверждает доставку сообщением `[4, ]`; ACK кумулятивен (подтверждает все сообщения с `seq` <= указанного). Неподтверждённые сообщения хранятся на сервере в очереди игрока и переотправляются при его переподключении. Если очередь пуста, но игра уже идёт, при переподключении текущее состояние отправляется заново (с новым `seq`), чтобы клиент мог восстановить UI. Направление S->C зарезервировано для подтверждения будущих сообщений клиента (у которых пока нет номеров). ### PLACE_SHIPS (5) Сообщение расстановки кораблей от клиента серверу. Принимается только от игрока, от которого ожидается расстановка в текущем состоянии партии (`StatePlacingShips` для обоих игроков; `StatePlayer1Ready` — только для игрока 2; `StatePlayer2Ready` — только для игрока 1). Повторные сообщения от уже расставившегося игрока или сообщения в фазе ходов отклоняются. Формат сообщения: ```json [5, ship0_orientation, ship0_x, ship0_y, ship1_orientation, ship1_x, ship1_y, ..., ship9_orientation, ship9_x, ship9_y] ``` Сообщение содержит ровно 30 аргументов — по 3 аргумента на каждый из 10 кораблей в порядке возрастания размера: - 4 однопалубных корабля (размер 1, индексы 0..3) - 3 двухпалубных корабля (размер 2, индексы 4..6) - 2 трёхпалубных корабля (размер 3, индексы 7..8) - 1 четырёхпалубный корабль (размер 4, индекс 9) Каждый корабль описывается 3-кортежем `(orientation, x, y)`: - `orientation` (boolean): `false` — горизонтальная ориентация (корабль продолжается вправо по оси X от начальной клетки `(x, y)`: клетки `(x + i, y)` для `i = 0..size-1`), `true` — вертикальная ориентация (корабль продолжается вниз по оси Y от начальной клетки `(x, y)`: клетки `(x, y + i)` для `i = 0..size-1`). Для однопалубных кораблей допустимо любое булево значение. - `x` (integer): координата X начала корабля (`0 <= x <= 9`). - `y` (integer): координата Y начала корабля (`0 <= y <= 9`). Правила валидации расстановки: 1. Количество кораблей строго 10, размеры строго соответствуют схеме 4x1, 3x2, 2x3, 1x4. 2. Все клетки каждого корабля должны лежать строго в пределах игрового поля 10x10 (`0 <= x < 10` и `0 <= y < 10`). 3. Корабли не должны перекрываться и не должны соприкасаться сторонами или углами: между любыми двумя различными кораблями во всех направлениях (включая диагонали) всегда должен оставаться зазор минимум в 1 клетку. При получении валидного сообщения сервер сохраняет расстановку в структуре матча (в виде сетки 10x10 с идентификаторами кораблей и списка кораблей) и переводит партию в следующее состояние: - Если первым расстановку завершил игрок 1: `StatePlacingShips` (1) -> `StatePlayer1Ready` (2) -> `StatePlayer1Turn` (4). - Если первым расстановку завершил игрок 2: `StatePlacingShips` (1) -> `StatePlayer2Ready` (3) -> `StatePlayer2Turn` (5). ### FIRE (6) Сообщение выстрела от клиента к серверу. Содержит координаты клетки на поле соперника: ```json [6, x, y] ``` - `x` (integer): координата X (колонка, `0 <= x <= 9`). - `y` (integer): координата Y (строка, `0 <= y <= 9`). Обработка сообщения сервером: - Сообщение принимается только от игрока, чей сейчас ход (`StateYourTurn` / серверное состояние `StatePlayer1Turn` или `StatePlayer2Turn`). Сообщения от игрока не в свой ход, во время фазы расстановки или после завершения партии игнорируются без ответа. - Если координаты выходят за границы игрового поля (`x < 0` или `x >= 10`, `y < 0` или `y >= 10`) либо клетка уже была поражена ранее (выстрелом, попаданием или окаймлением потопленного корабля), сервер возвращает стрелявшему игроку результат `4` (`неверная позиция`). Право хода остаётся у текущего игрока. - Если выстрел произведён в валидную ещё не поражённую клетку: - При промахе (клетка с водой) клетка помечается поражённой, игроку отправляется результат `1` (`промах`), а ход переходит противнику (рассылается `STATE` 5 и 4). - При попадании в корабль, если корабль ещё не уничтожен полностью, клетка помечается поражённой, игроку отправляется результат `2` (`простое попадание`). Стрелявший игрок продолжает свой ход. - Если корабль уничтожен полностью, все клетки корабля и все соседние с ним клетки (включая диагональные) помечаются поражёнными, а игроку отправляется результат `3` (`корабль потоплен`). Если после этого у противника не осталось непотопленных кораблей, партия завершается: сервер отправляет обоим игрокам сообщение END (`1` victory победителю и `2` defeat проигравшему) и сразу закрывает соединения. Если у противника ещё остались корабли, стрелявший игрок продолжает ход. ### FIRE_RESULT (7) Результат выстрела, отправляемый сервером стрелявшему игроку в ответ на выстрел. Содержит порядковый номер сообщения `seq` для подтверждения через ACK и числовой код результата: ```json [7, , ] ``` Коды результатов выстрела: | Код | Результат | Описание | |-----|-----------|----------| | 1 | `miss` (промах) | Выстрел в пустую клетку поля соперника. Клетка помечается как поражённая, ход переходит сопернику. | | 2 | `hit` (простое попадание) | Попадание в палубу корабля соперника, но корабль ещё имеет уцелевшие палубы. Клетка помечается как поражённая, право хода остаётся у стрелявшего игрока. | | 3 | `sunk` (корабль потоплен) | Корабль полностью уничтожен последним выстрелом. Все палубы корабля и все окружающие его клетки (включая диагональные) помечаются как поражённые. Если все корабли соперника уничтожены, партия завершается победой (рассылается сообщение END и соединение сразу закрывается). В противном случае право хода остаётся у стрелявшего игрока. | | 4 | `invalid_position` (неверная позиция) | Координаты выходят за пределы поля `10x10` либо указанная клетка уже была поражена ранее. Право хода остаётся у игрока. | ## Коды закрытия соединения | Код | Значение | |------|----------| | 1000 | нормальное завершение (партия окончена, END уже отправлен) | | 1001 | сервер завершает работу (graceful shutdown) | | 4000 | соединение заменено новым соединением того же игрока | | 4001 | первое сообщение не JOIN | | 4002 | сообщение не соответствует формату | | 4003 | недействительный игровой токен / чужой `game_server` / неположительные id | | 4004 | недействительный пользовательский токен / игрок не участник партии / неположительный id | ## Обработка краевых случаев - **Переподключение.** Игрок идентифицируется токенами, а не соединением. Игровой токен запоминается в структуре матча при первом подключении: повторное подключение с тем же игровым токеном полностью пропускает его валидацию (включая проверку срока действия). Новое JOIN от того же игрока закрывает старое соединение (код 4000) и занимает его слот; очередь неподтверждённых сообщений переживает разрыв и переотправляется. Одновременно существует не более одного соединения на игрока. - **Повторный JOIN.** Если клиент присылает повторный JOIN на уже установленном соединении, такое сообщение просто игнорируется без ошибки. - **Повторный JOIN после завершения партии.** Завершённые партии хранятся в реестре (`match_id` -> END-сообщения обоих игроков) до истечения срока действия игрового токена. Поздний JOIN получает в ответ сохранённое END и закрытие соединения — фантомная партия не создаётся, и будущий репорт итогов на main-server не задвоится. Реестр очищается лениво: при каждом обращении к нему стираются записи с истёкшим токеном, фоновых задач нет. - **Дедлайн присоединения** — минимум из `JOIN_TIMEOUT` и `exp` игрового токена: после истечения токена второй игрок всё равно не сможет войти. - **JOIN с токеном, указывающим на другой сервер** (неверный `game_server`) отклоняется с кодом 4003. - **`player1_id == player2_id`** или неположительные ID отклоняются (код 4003). - **Валидация ACK.** Поле `seq` от клиента проверяется на положительность и ненахождение за пределами последнего отправленного `seq`. Некорректный `seq` отклоняется с ошибкой. - **Асинхронная отправка.** Запись сообщений в WebSocket делегирована отдельной per-connection горутине, что предотвращает блокировку мьютексов менеджера во время write timeout. - Неизвестные опкоды после 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 (реестр завершённых партий уже гарантирует, что партия завершается ровно один раз).