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