# Sea Battle — Main Server API Центральный сервер для браузерной онлайн-игры «Морской бой». Предоставляет REST API для регистрации, аутентификации и матчмейкинга. ## Дополнительная документация - [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур данных Redis, формулы допуска рейтинга, Lua-скриптов и гарантий доставки. - [docs/architecture.md](docs/architecture.md) — архитектура главного сервера, стек компонентов Rails, безопасность и взаимодействие с Game Server. ## Базовый URL ``` http://localhost:3000/api ``` ## Аутентификация Все endpoints (кроме `/register` и `/login`) требуют JWT-токен. Токен может передаваться двумя способами: 1. В HTTP-заголовке (рекомендуемый способ для REST API): ``` Authorization: Bearer ``` 2. В параметрах запроса query string (используется для WebSocket-подключений и поддерживается REST API): ``` ?token= ``` Токен выдаётся при успешной регистрации или входе и действителен **7 дней**. Токен содержит идентификатор пользователя (`user_id`) и время истечения (`exp`). При обращении к защищённым endpoints сервер проверяет подпись токена, срок действия и существование пользователя в базе данных. --- ## Endpoints ### Регистрация Создаёт нового пользователя и возвращает токен. **URL:** `POST /api/register` **Auth:** не требуется **Content-Type:** `application/json` или `multipart/form-data` (при загрузке аватара) **Параметры (JSON):** | Параметр | Тип | Обязательный | Описание | |----------|-----|--------------|----------| | `user.nickname` | string | ✅ | Уникальный никнейм (3–24 символа) | | `user.password` | string | ✅ | Пароль (минимум 6 символов) | | `user.password_confirmation` | string | ✅ | Подтверждение пароля | | `user.avatar` | file | — | Аватар (PNG, JPEG, GIF, WebP) | **Пример запроса (JSON):** ```bash curl -X POST http://localhost:3000/api/register \ -H "Content-Type: application/json" \ -d '{ "user": { "nickname": "CaptainNemo", "password": "secret123", "password_confirmation": "secret123" } }' ``` **Пример запроса (с аватаром):** ```bash curl -X POST http://localhost:3000/api/register \ -F "user[nickname]=CaptainNemo" \ -F "user[password]=secret123" \ -F "user[password_confirmation]=secret123" \ -F "user[avatar]=@avatar.png" ``` **Успешный ответ (201 Created):** ```json { "user": { "id": 1, "nickname": "CaptainNemo", "avatar_url": "http://localhost:3000/rails/active_storage/blobs/...", "created_at": "2026-09-21T10:00:00.000Z" }, "token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjox..." } ``` **Ошибки (422 Unprocessable Entity):** ```json { "errors": [ "Nickname has already been taken", "Password is too short (minimum is 6 characters)" ] } ``` --- ### Вход Аутентифицирует пользователя и возвращает токен. **URL:** `POST /api/login` **Auth:** не требуется **Content-Type:** `application/json` **Параметры:** | Параметр | Тип | Обязательный | Описание | |----------|-----|--------------|----------| | `nickname` | string | ✅ | Никнейм | | `password` | string | ✅ | Пароль | **Пример запроса:** ```bash curl -X POST http://localhost:3000/api/login \ -H "Content-Type: application/json" \ -d '{ "nickname": "CaptainNemo", "password": "secret123" }' ``` **Успешный ответ (200 OK):** ```json { "user": { "id": 1, "nickname": "CaptainNemo", "avatar_url": "http://localhost:3000/rails/active_storage/blobs/...", "created_at": "2026-09-21T10:00:00.000Z" }, "token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjox..." } ``` **Ошибка (401 Unauthorized):** ```json { "error": "Invalid nickname or password" } ``` --- ### Текущий пользователь Возвращает данные авторизованного пользователя. **URL:** `GET /api/me` **Auth:** требуется **Пример запроса:** ```bash curl -X GET http://localhost:3000/api/me \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." ``` **Успешный ответ (200 OK):** ```json { "user": { "id": 1, "nickname": "CaptainNemo", "avatar_url": "http://localhost:3000/rails/active_storage/blobs/...", "created_at": "2026-09-21T10:00:00.000Z" } } ``` **Ошибка (401 Unauthorized):** ```json { "error": "Unauthorized" } ``` --- ### Выход Завершает сессию. JWT является stateless — клиент должен удалить токен. **URL:** `DELETE /api/logout` **Auth:** требуется **Пример запроса:** ```bash curl -X DELETE http://localhost:3000/api/logout \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." ``` **Успешный ответ (200 OK):** ```json { "message": "Signed out successfully" } ``` --- ### Поиск соперника (Матчмейкинг) Поиск соперника происходит через **WebSocket** на базе протокола `Action Cable`. Это устраняет необходимость long-polling: сокет не блокирует HTTP-потоки, а результат поиска доставляется сервером асинхронно push-уведомлением. > Подробное описание структур данных в Redis, формулы допуска рейтинга, двухфазного поиска и Lua-скриптов вынесено в отдельный документ: [docs/matchmaking.md](docs/matchmaking.md). **URL подключения:** `ws://localhost:3000/cable?token=` **Auth:** токен передаётся параметром `token` в query string (или в заголовке `Authorization: Bearer ` при поддержке клиентом). Если токен отсутствует, просрочен или пользователь не найден, сервер немедленно закрывает соединение (`reject_unauthorized_connection`). **Канал:** `MatchmakingChannel` #### Протокол взаимодействия Action Cable Action Cable использует формат JSON-сообщений через WebSocket: 1. **Подключение и рукопожатие:** При открытии сокета сервер отправляет приветственное сообщение: ```json { "type": "welcome" } ``` В процессе работы сервер регулярно отправляет heartbeat-сообщения (пинг): ```json { "type": "ping", "message": 1729864800 } ``` 2. **Подписка на канал:** Клиент отправляет команду подписки на канал `MatchmakingChannel`: ```json { "command": "subscribe", "identifier": "{\"channel\":\"MatchmakingChannel\"}" } ``` При успешной подписке сервер отвечает подтверждением: ```json { "type": "confirm_subscription", "identifier": "{\"channel\":\"MatchmakingChannel\"}" } ``` *Примечание:* Если матч был сформирован в момент временного разрыва соединения (кратковременный реконнект), сервер в момент подписки выполняет операцию `claim` и немедленно передаёт клиенту сообщение `matched` (гарантия exactly-once доставки). 3. **Запуск поиска соперника:** Для входа в очередь матчмейкинга клиент отправляет action `start`: ```json { "command": "message", "identifier": "{\"channel\":\"MatchmakingChannel\"}", "data": "{\"action\":\"start\"}" } ``` Рейтинг для подбора считывается из БД (`users.rating`, по умолчанию `100`). Игрок помещается в Redis (`mm:queue`). Сервер сразу выполняет попытку мгновенного подбора (Phase 1). Если подходящий соперник не найден сразу, фоновый воркер `MatchmakingSweepJob` каждые 2 секунды проверяет очередь (Phase 2), плавно расширяя допустимый диапазон рейтинга по мере ожидания. 4. **Получение результатов поиска:** **Матч найден:** ```json { "identifier": "{\"channel\":\"MatchmakingChannel\"}", "message": { "type": "matched", "match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f", "game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...", "game_server": "ws://localhost:8080", "players": [1, 2] } } ``` | Поле | Описание | |------|----------| | `match_id` | Уникальный UUID матча, сгенерированный сервером | | `game_token` | Подписанный JWT-токен для авторизации на `game-server` (действителен `MATCHMAKING_TOKEN_TTL` сек) | | `game_server` | WebSocket URL игрового сервера Go, на который должны подключиться оба игрока | | `players` | Массив идентификаторов игроков матча `[player1_id, player2_id]` | **Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд, по умолчанию 60 сек): ```json { "identifier": "{\"channel\":\"MatchmakingChannel\"}", "message": { "type": "timeout" } } ``` После таймаута игрок удаляется из очереди поиска. При необходимости поиск можно начать заново командой `start`. 5. **Отмена поиска:** Отменить поиск можно двумя способами: - Отправить команду `stop`: ```json { "command": "message", "identifier": "{\"channel\":\"MatchmakingChannel\"}", "data": "{\"action\":\"stop\"}" } ``` - Отписаться от канала или закрыть WebSocket-соединение: ```json { "command": "unsubscribe", "identifier": "{\"channel\":\"MatchmakingChannel\"}" } ``` Сервер атомарно удаляет игрока из всех очередей и хешей Redis. --- ## Health Check Проверка доступности сервера. **URL:** `GET /up` **Auth:** не требуется **Успешный ответ (200 OK):** ```html ``` --- ## Коды ошибок | Код | Описание | |-----|----------| | `200` | Успешный запрос | | `201` | Ресурс создан (регистрация) | | `401` | Неавторизован (отсутствует/невалиден токен) | | `422` | Ошибка валидации | | `500` | Внутренняя ошибка сервера | --- ## Переменные окружения | Переменная | По умолчанию | Описание | |------------|--------------|----------| | `REDIS_URL` | `redis://localhost:6380/0` | Подключение к Redis (матчмейкинг и pub/sub Action Cable) | | `GAME_SERVER_URL` | `ws://localhost:8080` | Адрес игрового сервера для матчмейкинга | | `MATCHMAKING_QUEUE_TTL` | `60` | Время ожидания соперника до таймаута, сек | | `MATCHMAKING_PENDING_TTL` | `15` | Время ожидания финализации пары, сек | | `MATCHMAKING_MATCH_TTL` | `120` | Время жизни данных матча, сек | | `MATCHMAKING_TOKEN_TTL` | `120` | Время жизни `game_token`, сек | | `MATCHMAKING_BASE_RANGE` | `100` | Начальный допуск по рейтингу | | `MATCHMAKING_RANGE_PER_SECOND` | `50` | Рост допуска за секунду ожидания | | `MATCHMAKING_MAX_RANGE` | `1000` | Максимальный допуск по рейтингу | | `MATCHMAKING_SWEEP_INTERVAL` | `2` | Периодичность фоновой сверки очереди, сек | | `CORS_ORIGINS` | `*` | Разрешённые origins для CORS (через запятую) | | `SECRET_KEY_BASE` | из `credentials.yml.enc` | Секретный ключ для JWT | --- ## Пример workflow: от регистрации до матча ```bash # 1. Регистрация TOKEN=$(curl -s -X POST http://localhost:3000/api/register \ -H "Content-Type: application/json" \ -d '{"user":{"nickname":"Player1","password":"secret123","password_confirmation":"secret123"}}' \ | jq -r '.token') # 2. Проверка профиля curl -s http://localhost:3000/api/me \ -H "Authorization: Bearer $TOKEN" # 3. Поиск матча — WebSocket (например, через wscat): # wscat -c "ws://localhost:3000/cable?token=$TOKEN" # > {"command":"subscribe","identifier":"{\"channel\":\"MatchmakingChannel\"}"} # > {"command":"message","identifier":"{\"channel\":\"MatchmakingChannel\"}","data":"{\"action\":\"start\"}"} ```