Files
2026-11-02 21:24:32 +03:00

272 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>]` |
| 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 секунд после апгрейда). Параметры:
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, <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). Повторные сообщения от уже расставившегося игрока или сообщения в фазе
ходов отклоняются.
Формат сообщения:
```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, <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
```
## Тесты
```sh
go test -race ./...
```
Покрыты: валидация токенов, разбор/сборка сообщений (включая расстановку кораблей),
сценарии менеджера (ожидание, старт, таймаут, повторный вход, замена соединения, ACK,
расстановка кораблей с переходами состояний) и сквозные интеграционные тесты через настоящий websocket.
## TODO (следующие этапы)
- игровая логика стрельбы/ходов (расстановка кораблей и переходы к фазе ходов уже реализованы);
- завершение партии по итогам игры с персонифицированными кодами
победы/поражения;
- репорт итогов партии на main-server (реестр завершённых партий уже
гарантирует, что партия завершается ровно один раз).