23 KiB
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>] |
| 5 | C -> S | PLACE_SHIPS | [5, ship0_orientation, ship0_x, ship0_y, ...] |
| 6 | C -> S | FIRE | [6, <x>, <y>] |
| 7 | S -> C | FIRE_RESULT | [7, <seq>, <result_code>] |
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 > 0иplayer2_id > 0, а такжеplayer1_id != player2_id;user_id > 0из пользовательского токена совпадает сplayer1_idилиplayer2_idигрового токена;- партия не находится в реестре завершённых (см. ниже).
При неудаче соединение закрывается с кодом из диапазона 4xxx (см. ниже).
END (2)
Партия завершена. Формат: [2, <seq>, <code>, "<reason>"].
Коды завершения (отсылаются с точки зрения получателя):
| Код | Причина | Описание |
|---|---|---|
| -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, <seq>]; ACK кумулятивен
(подтверждает все сообщения с seq <= указанного).
Неподтверждённые сообщения хранятся на сервере в очереди игрока и
переотправляются при его переподключении. Если очередь пуста, но игра уже
идёт, при переподключении текущее состояние отправляется заново (с новым
seq), чтобы клиент мог восстановить UI.
Направление S->C зарезервировано для подтверждения будущих сообщений клиента (у которых пока нет номеров).
PLACE_SHIPS (5)
Сообщение расстановки кораблей от клиента серверу. Принимается только от игрока,
от которого ожидается расстановка в текущем состоянии партии (StatePlacingShips для
обоих игроков; StatePlayer1Ready — только для игрока 2; StatePlayer2Ready — только
для игрока 1). Повторные сообщения от уже расставившегося игрока или сообщения в фазе
ходов отклоняются.
Формат сообщения:
[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).
Правила валидации расстановки:
- Количество кораблей строго 10, размеры строго соответствуют схеме 4x1, 3x2, 2x3, 1x4.
- Все клетки каждого корабля должны лежать строго в пределах игрового поля 10x10
(
0 <= x < 10и0 <= y < 10). - Корабли не должны перекрываться и не должны соприкасаться сторонами или углами: между любыми двумя различными кораблями во всех направлениях (включая диагонали) всегда должен оставаться зазор минимум в 1 клетку.
При получении валидного сообщения сервер сохраняет расстановку в структуре матча (в виде сетки 10x10 с идентификаторами кораблей и списка кораблей) и переводит партию в следующее состояние:
- Если первым расстановку завершил игрок 1:
StatePlacingShips(1) ->StatePlayer1Ready(2) ->StatePlayer1Turn(4). - Если первым расстановку завершил игрок 2:
StatePlacingShips(1) ->StatePlayer2Ready(3) ->StatePlayer2Turn(5).
FIRE (6)
Сообщение выстрела от клиента к серверу. Содержит координаты клетки на поле соперника:
[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(промах), а ход переходит противнику (рассылаетсяSTATE5 и 4). - При попадании в корабль, если корабль ещё не уничтожен полностью, клетка помечается поражённой, игроку отправляется результат
2(простое попадание). Стрелявший игрок продолжает свой ход. - Если корабль уничтожен полностью, все клетки корабля и все соседние с ним клетки (включая диагональные) помечаются поражёнными, а игроку отправляется результат
3(корабль потоплен). Если после этого у противника не осталось непотопленных кораблей, партия завершается: сервер отправляет обоим игрокам сообщение END (1victory победителю и2defeat проигравшему) и сразу закрывает соединения. Если у противника ещё остались корабли, стрелявший игрок продолжает ход.
- При промахе (клетка с водой) клетка помечается поражённой, игроку отправляется результат
FIRE_RESULT (7)
Результат выстрела, отправляемый сервером стрелявшему игроку в ответ на выстрел. Содержит порядковый номер сообщения seq для подтверждения через ACK и числовой код результата:
[7, <seq>, <result_code>]
Коды результатов выстрела:
| Код | Результат | Описание |
|---|---|---|
| 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
Тесты
go test -race ./...
Покрыты: валидация токенов, разбор/сборка сообщений (включая расстановку кораблей), сценарии менеджера (ожидание, старт, таймаут, повторный вход, замена соединения, ACK, расстановка кораблей с переходами состояний) и сквозные интеграционные тесты через настоящий websocket.
TODO (следующие этапы)
- игровая логика стрельбы/ходов (расстановка кораблей и переходы к фазе ходов уже реализованы);
- завершение партии по итогам игры с персонифицированными кодами победы/поражения;
- репорт итогов партии на main-server (реестр завершённых партий уже гарантирует, что партия завершается ровно один раз).