Files
ruby-sea-battle/game-server/README.md
T

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