create a websocket game server
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
# 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 (реестр завершённых партий уже
|
||||
гарантирует, что партия завершается ровно один раз);
|
||||
- очистка состояния начавшихся партий после их завершения.
|
||||
Reference in New Issue
Block a user