171 lines
10 KiB
Markdown
171 lines
10 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>]` |
|
||
|
||
### 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 (реестр завершённых партий уже
|
||
гарантирует, что партия завершается ровно один раз);
|
||
- очистка состояния начавшихся партий после их завершения.
|