add tests for controllers and ActionCable channels; improve main backend docs

This commit is contained in:
2026-10-27 10:32:40 +03:00
parent b122cbb652
commit 50e8aacfa9
17 changed files with 1127 additions and 63 deletions
+101 -47
View File
@@ -2,6 +2,11 @@
Центральный сервер для браузерной онлайн-игры «Морской бой». Предоставляет REST API для регистрации, аутентификации и матчмейкинга.
## Дополнительная документация
- [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур данных Redis, формулы допуска рейтинга, Lua-скриптов и гарантий доставки.
- [docs/architecture.md](docs/architecture.md) — архитектура главного сервера, стек компонентов Rails, безопасность и взаимодействие с Game Server.
## Базовый URL
```
@@ -10,13 +15,17 @@ http://localhost:3000/api
## Аутентификация
Все endpoints (кроме `/register` и `/login`) требуют JWT-токен в заголовке:
Все endpoints (кроме `/register` и `/login`) требуют JWT-токен. Токен может передаваться двумя способами:
1. В HTTP-заголовке (рекомендуемый способ для REST API):
```
Authorization: Bearer <token>
```
2. В параметрах запроса query string (используется для WebSocket-подключений и поддерживается REST API):
```
?token=<token>
```
Токен выдаётся при регистрации или входе и действителен **7 дней**.
Токен выдаётся при успешной регистрации или входе и действителен **7 дней**. Токен содержит идентификатор пользователя (`user_id`) и время истечения (`exp`). При обращении к защищённым endpoints сервер проверяет подпись токена, срок действия и существование пользователя в базе данных.
---
@@ -209,65 +218,110 @@ curl -X DELETE http://localhost:3000/api/logout \
### Поиск соперника (Матчмейкинг)
Поиск соперника происходит через **WebSocket** (`Action Cable`), а не через long-polling: HTTP-поток не блокируется, а результат доставляется сервером push-уведомлением, как только найден соперник.
Поиск соперника происходит через **WebSocket** на базе протокола `Action Cable`. Это устраняет необходимость long-polling: сокет не блокирует HTTP-потоки, а результат поиска доставляется сервером асинхронно push-уведомлением.
> Подробное описание структур данных в Redis, формулы допуска рейтинга, двухфазного поиска и Lua-скриптов вынесено в отдельный документ: [docs/matchmaking.md](docs/matchmaking.md).
**URL подключения:** `ws://localhost:3000/cable?token=<JWT>`
**Auth:** токен передаётся параметром `token` в query string (тот же JWT, что и для HTTP API).
**Auth:** токен передаётся параметром `token` в query string (или в заголовке `Authorization: Bearer <JWT>` при поддержке клиентом). Если токен отсутствует, просрочен или пользователь не найден, сервер немедленно закрывает соединение (`reject_unauthorized_connection`).
**Канал:** `MatchmakingChannel`
#### Поток взаимодействия
#### Протокол взаимодействия Action Cable
1. Подключиться к `/cable?token=...` и подписаться на канал `MatchmakingChannel`.
2. Отправить команду начала поиска:
Action Cable использует формат JSON-сообщений через WebSocket:
```json
{ "action": "start" }
```
1. **Подключение и рукопожатие:**
При открытии сокета сервер отправляет приветственное сообщение:
```json
{ "type": "welcome" }
```
В процессе работы сервер регулярно отправляет heartbeat-сообщения (пинг):
```json
{ "type": "ping", "message": 1729864800 }
```
Рейтинг для подбора берётся из базы данных (`users.rating`). У новых пользователей он инициализируется нейтральным значением `100` (значение задано в БД); в будущем рейтинг будет пересчитываться по результатам матчей. Игроки подбираются по близости рейтинга; допуск расширяется с ростом времени ожидания.
2. **Подписка на канал:**
Клиент отправляет команду подписки на канал `MatchmakingChannel`:
```json
{
"command": "subscribe",
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
}
```
При успешной подписке сервер отвечает подтверждением:
```json
{
"type": "confirm_subscription",
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
}
```
*Примечание:* Если матч был сформирован в момент временного разрыва соединения (кратковременный реконнект), сервер в момент подписки выполняет операцию `claim` и немедленно передаёт клиенту сообщение `matched` (гарантия exactly-once доставки).
3. Дождаться одного из сообщений:
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
{
"type": "matched",
"match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
"game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...",
"game_server": "ws://localhost:8080",
"players": [1, 2]
}
```
**Матч найден:**
```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` | WebSocket-адрес игрового сервера |
| `players` | ID обоих игроков `[player1_id, player2_id]` |
| Поле | Описание |
|------|----------|
| `match_id` | Уникальный UUID матча, сгенерированный сервером |
| `game_token` | Подписанный JWT-токен для авторизации на `game-server` (действителен `MATCHMAKING_TOKEN_TTL` сек) |
| `game_server` | WebSocket URL игрового сервера Go, на который должны подключиться оба игрока |
| `players` | Массив идентификаторов игроков матча `[player1_id, player2_id]` |
**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд):
**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд, по умолчанию 60 сек):
```json
{
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
"message": {
"type": "timeout"
}
}
```
После таймаута игрок удаляется из очереди поиска. При необходимости поиск можно начать заново командой `start`.
```json
{ "type": "timeout" }
```
4. Поиск отменяется автоматически при закрытии WebSocket-подписки. Также можно явно отправить:
```json
{ "action": "stop" }
```
**Примечание:** `game_token` содержит `match_id`, `player1_id`, `player2_id`, `game_server` и действителен `MATCHMAKING_TOKEN_TTL` секунд.
---
### Отмена поиска
Отмена поиска происходит через WebSocket: достаточно отключиться от `MatchmakingChannel` либо отправить `{ "action": "stop" }`.
5. **Отмена поиска:**
Отменить поиск можно двумя способами:
- Отправить команду `stop`:
```json
{
"command": "message",
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
"data": "{\"action\":\"stop\"}"
}
```
- Отписаться от канала или закрыть WebSocket-соединение:
```json
{
"command": "unsubscribe",
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
}
```
Сервер атомарно удаляет игрока из всех очередей и хешей Redis.
---
+29 -13
View File
@@ -1,24 +1,40 @@
# README
# Ruby Sea Battle — Main Server
This README would normally document whatever steps are necessary to get the
application up and running.
Главный бэкенд многопользовательской онлайн-игры «Морской бой», написанный на Ruby on Rails 8.
Things you may want to cover:
## Документация
* Ruby version
- [API.md](API.md) — спецификация REST API и WebSocket Action Cable
- [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур Redis и Lua-скриптов
- [docs/architecture.md](docs/architecture.md) — общая архитектура сервера, стек компонентов и модель безопасности
* System dependencies
## Требования
* Configuration
- Ruby 3.4+ / 4.0
- SQLite 3 (для разработки и тестов) / PostgreSQL (production)
- Redis 6+ (для очередей матчмейкинга и Action Cable)
* Database creation
## Установка и запуск
* Database initialization
```bash
# 1. Установка зависимостей
bundle install
* How to run the test suite
# 2. Подготовка базы данных
bin/rails db:prepare
* Services (job queues, cache servers, search engines, etc.)
# 3. Запуск сервера разработки (Puma на порту 3000)
bin/rails server
```
* Deployment instructions
## Тестирование
* ...
Проект полностью покрыт тестами на RSpec (модели, сервисы, контроллеры, Action Cable каналы и фоновые задачи).
```bash
# Запуск полного набора тестов
bundle exec rspec
# Запуск тестов со сбором отчета о покрытии кода (SimpleCov)
COVERAGE=true bundle exec rspec
```
@@ -2,7 +2,9 @@ class Api::SessionsController < ApplicationController
skip_before_action :authenticate_request!, only: [:create]
def create
user = User.authenticate_by(params.permit(:nickname, :password))
user = if params[:nickname].present? && params[:password].present?
User.authenticate_by(params.permit(:nickname, :password))
end
if user
token = JwtService.encode(user_id: user.id)
+7
View File
@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end
+135
View File
@@ -0,0 +1,135 @@
# Архитектура Main Server (Ruby on Rails)
Документ описывает архитектуру главного сервера проекта «Ruby Sea Battle», структуру компонентов Rails-приложения, модель безопасности и схему взаимодействия с клиентом и игровым сервером.
---
## 1. Роль Main Server в общей системе
Система «Ruby Sea Battle» состоит из трёх ключевых узлов:
1. **Frontend (React):** SPA-интерфейс в браузере.
2. **Main Server (Ruby on Rails):** центральный сервер бизнес-логики, аккаунтов и матчмейкинга.
3. **Game Server (Go):** сервер игровых комнат в реальном времени по WebSocket.
```mermaid
flowchart LR
Client["React Frontend"]
MainServer["Main Server (Rails 8)"]
RedisStore[("Redis (Очереди и Pub/Sub)")]
DB[(База данных SQLite/PostgreSQL)]
GameServer["Game Server (Go)"]
Client -- "REST API (Auth, Profile)" --> MainServer
Client -- "WebSocket /cable (Matchmaking)" --> MainServer
MainServer -- "State & Lua Scripts" --> RedisStore
MainServer -- "Пользователи и рейтинг" --> DB
MainServer -. "JWT с общим секретом" .-> GameServer
Client -- "WebSocket (Игровой процесс)" --> GameServer
```
### Основные обязанности Main Server:
- Регистрация и аутентификация пользователей с выдачей JWT.
- Хранение профилей и аватарок игроков (через Active Storage).
- Ведение базового рейтинга пользователей (`users.rating`).
- Очередь матчмейкинга и подбор соперников в реальном времени через Action Cable и Redis.
- Выпуск одноразовых билетов матча (`game_token`) для подключения к игровому серверу Go.
---
## 2. Структура слоёв приложения
Главный сервер построен на принципах Rails API-only приложения:
```
app/
├── channels/
│ ├── application_cable/
│ │ ├── channel.rb # Базовый класс каналов
│ │ └── connection.rb # Аутентификация WebSocket по JWT
│ └── matchmaking_channel.rb # Канал поиска соперника (start/stop/claim)
├── controllers/
│ ├── application_controller.rb # Базовый контроллер: проверка JWT и current_user
│ └── api/
│ ├── registrations_controller.rb # Регистрация и загрузка аватара
│ ├── sessions_controller.rb # Вход, выход, профиль (/api/me)
│ └── matchmaking_controller.rb # HTTP-отмена поиска
├── jobs/
│ ├── application_job.rb # Базовый класс фоновых задач
│ └── matchmaking_sweep_job.rb # Периодическая проверка очереди (Solid Queue)
├── models/
│ ├── application_record.rb # Базовый класс моделей
│ └── user.rb # Модель игрока (никнейм, пароль, рейтинг, аватар)
└── services/
├── jwt_service.rb # Кодирование и валидация JWT
└── matchmaking_service.rb # Логика очереди и вызовы Lua в Redis
```
---
## 3. Компоненты приложения
### 3.1. Контроллеры и фильтры аутентификации
Все защищённые маршруты наследуются от `ApplicationController`.
- **`authenticate_request!`:** выполняет проверку токена перед каждым действием.
- **Извлечение токена (`extract_token`):** поддерживает как заголовок `Authorization: Bearer <token>`, так и параметр строки запроса `params[:token]`.
- При отсутствии или невалидности токена клиенту возвращается стандартизированный ответ:
```json
{ "error": "Unauthorized" }
```
со статусом `401 Unauthorized`.
### 3.2. Модель пользователя (`User`)
- Использует механизм `has_secure_password` (BCrypt).
- **Нормализация никнейма:** автоматическая очистка крайних пробелов через `normalizes :nickname, with: ->(n) { n.strip }`.
- **Валидации:**
- Никнейм: обязателен, длина от 3 до 24 символов, регистронезависимая уникальность.
- Пароль: минимум 6 символов.
- Рейтинг: обязателен, значение по умолчанию в БД — `100`.
- Аватар (`has_one_attached :avatar`): проверка допустимых типов файлов (`image/png`, `image/jpeg`, `image/gif`, `image/webp`).
### 3.3. Action Cable и WebSocket
- **Подключение (`ApplicationCable::Connection`):** при коннекте к `/cable?token=...` сокет проверяет подпись токена. Если токен валиден и пользователь найден в базе, сокет идентифицируется как `current_user`. В противном случае вызывается `reject_unauthorized_connection`.
- **Канал `MatchmakingChannel`:**
- При подписке клиент слушает индивидуальный стрим `matchmaking:<user_id>`.
- Метод `claim` обеспечивает атомарную выдачу уже найденного матча, если клиент переподключился.
- Действие `start` запускает подбор.
- Действие `stop` или закрытие соединения (`unsubscribed`) отменяет поиск.
### 3.4. Сервисный слой
- **`JwtService`:**
- Алгоритм: HMAC-SHA256 (`HS256`).
- Секретный ключ: `Rails.application.secret_key_base`.
- Стандартный срок действия токена авторизации: 7 дней.
- Обработка исключений `JWT::DecodeError` и `JWT::ExpiredSignature` с возвратом `nil`.
- **`MatchmakingService`:**
- Инкапсулирует вызовы Redis Lua-скриптов (`PAIR_SCRIPT`, `FINALIZE_SCRIPT`, `CANCEL_SCRIPT`).
- Выполняет создание матчей, подписание игровых токенов и бродкаст уведомлений через `MatchmakingChannel`.
### 3.5. Фоновые задачи (Background Processing)
- В качестве движка фоновых задач используется `Solid Queue` (на базе БД) с периодическим расписанием `config/recurring.yml`.
- `MatchmakingSweepJob` запускается каждые 2 секунды (`MATCHMAKING_SWEEP_INTERVAL`) и вызывает `MatchmakingService.sweep` для постепенного расширения диапазона поиска и очистки таймаутов.
---
## 4. Модель токенов и безопасность
В системе используются два вида JWT:
| Параметр | User Auth Token | Game Match Token (`game_token`) |
|----------|-----------------|---------------------------------|
| **Назначение** | Авторизация запросов к REST API и Action Cable | Доступ к комнате матча на `game-server` |
| **Payload** | `{ "user_id": 1, "exp": ... }` | `{ "match_id": "...", "player1_id": 1, "player2_id": 2, "game_server": "...", "exp": ... }` |
| **Срок жизни** | 7 дней | `MATCHMAKING_TOKEN_TTL` (по умолчанию 120 сек) |
| **Секрет подписи** | `SECRET_KEY_BASE` | `SECRET_KEY_BASE` (общий для Rails и Go) |
| **Алгоритм** | HS256 | HS256 |
### Безопасность делегирования на Game Server:
1. Игровой сервер Go не имеет прямого доступа к базе данных пользователей Main Server.
2. При подключении к Game Server игрок предъявляет `game_token`.
3. Game Server валидирует подпись токена тем же общим секретом `SECRET_KEY_BASE`, сверяет `match_id` и идентификатор подключившегося игрока (`player1_id` или `player2_id`).
4. Токен действует всего 120 секунд, что исключает возможность повторного использования после начала игры.
+227
View File
@@ -0,0 +1,227 @@
# Алгоритм матчмейкинга (Matchmaking Algorithm)
Документ содержит подробное описание алгоритма подбора соперников в `Ruby Sea Battle`, структур данных Redis, математической модели допуска рейтинга, механизмов атомарности и отказоустойчивости.
---
## 1. Цели и требования к системе
Матчмейкинг отвечает за сведение двух игроков в онлайн-матч и решает следующие задачи:
1. **Баланс по навыку (рейтингу):** игроки со схожим рейтингом должны играть друг с другом.
2. **Минимизация времени ожидания:** если идеального соперника нет, допустимая разница в рейтинге со временем плавно увеличивается.
3. **Строгая атомарность и исключение race conditions:** ни один игрок не может быть одновременно взят в два разных матча при конкурентных запросах.
4. **Отказоустойчивость (Fault Tolerance):** сбой процесса сервера между нахождением пары и генерацией JWT-токенов не должен приводить к потере игроков из очереди.
5. **Надёжная доставка результата:** матч должен быть доставлен игроку даже при кратковременном разрыве WebSocket-соединения.
---
## 2. Структуры данных в Redis
Все данные матчмейкинга хранятся в Redis и имеют ограниченное время жизни (TTL):
| Ключ | Тип данных Redis | Значение / Элемент | Назначение |
|------|-------------------|-------------------|------------|
| `mm:queue` | Sorted Set | `score` = `rating`, `member` = `user_id` | Основная очередь игроков, ожидающих матч. Отсортирована по рейтингу для быстрого диапазонного поиска (`ZRANGEBYSCORE`). |
| `mm:since` | Hash | `field` = `user_id`, `value` = `timestamp` (мс) | Время входа игрока в очередь в миллисекундах. Используется для расчёта времени ожидания. |
| `mm:rating` | Hash | `field` = `user_id`, `value` = `rating` | Сохранённый рейтинг игрока. Необходим для возврата игрока в очередь при сбое на этапе финализации (`requeue`). |
| `mm:pending` | Sorted Set | `score` = `timestamp` спаривания (мс), `member` = `user_id` | Игроки, для которых пара найдена, но генерация матча и JWT ещё выполняется в Ruby. |
| `mm:match:<match_id>` | String (JSON) | Полный payload матча | Данные матча (TTL = `MATCHMAKING_MATCH_TTL`, по умолчанию 120 сек). |
| `mm:user:<user_id>` | String (JSON) | Копия payload матча | Индивидуальный указатель для игрока для механизма `claim` при реконнекте (TTL = `MATCHMAKING_MATCH_TTL`). |
---
## 3. Модель динамического допуска рейтинга (Rating Tolerance)
Чтобы игроки быстро находили матч даже при небольшом онлайне, допуск по рейтингу плавно растёт с течением времени ожидания.
### Формула допуска
Для игрока с временем постановки в очередь $t_{\text{start}}$ в текущий момент времени $t_{\text{now}}$:
$$\Delta t = \frac{t_{\text{now}} - t_{\text{start}}}{1000} \quad \text{(секунды ожидания)}$$
$$\text{tolerance}(\Delta t) = \min\Big(\text{base\_range} + \Delta t \times \text{range\_per\_second}, \; \text{max\_range}\Big)$$
### Конфигурационные параметры (из `Rails.application.config.x.matchmaking`)
- `base_range` = `100` — начальный допуск рейтинга в момент входа в очередь.
- `range_per_second` = `50` — прирост допуска за каждую секунду ожидания.
- `max_range` = `1000` — верхний предел допуска рейтинга.
- `queue_ttl` = `60` сек — максимальное время нахождения в очереди до таймаута.
### Динамика расширения диапазона
| Время ожидания ($\Delta t$) | Допуск ($\pm$ очков рейтинга) | Диапазон при рейтинге 1000 |
|-----------------------------|-------------------------------|---------------------------|
| 0 секунд | $\pm 100$ | [900 ... 1100] |
| 2 секунды | $\pm 200$ | [800 ... 1200] |
| 6 секунд | $\pm 400$ | [600 ... 1400] |
| 10 секунд | $\pm 600$ | [400 ... 1600] |
| 18+ секунд | $\pm 1000$ (максимум) | [0 ... 2000] |
### Правило симметрии для пары
При периодической сверке очереди (`sweep`) пара $(A, B)$ считается совместимой, если:
$$| \text{rating}_A - \text{rating}_B | \le \text{tolerance}\big(\min(t_{\text{since}}(A), t_{\text{since}}(B))\big)$$
То есть допуск рассчитывается по **дольше ожидающему** игроку из пары. Это предотвращает дискриминацию игроков, которые долго ждут в очереди.
---
## 4. Двухфазный алгоритм подбора (Two-Phase Matchmaking)
Подбор разделен на две взаимодополняющие фазы:
```
[Игрок отправляет start]
│
▼
Фаза 1: Немедленный поиск (enqueue в Lua)
├─► Подходящий соперник найден ──► [Перевод в pending] ──► [Генерация матча]
└─► Соперник не найден ──────────► [Добавление в mm:queue]
│
▼
Фаза 2: Фоновая сверка (sweep)
(каждые 2 сек)
├─► Расширение допуска и спаривание
├─► Таймаут (waited > queue_ttl)
└─► Requeue зависших игроков
```
### Фаза 1: Синхронная проверка при входе (`enqueue`)
Выполняется мгновенно в контексте команды WebSocket `start`:
1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "enqueue"`.
2. Из очереди принудительно удаляются предыдущие записи этого пользователя (сброс сессии ожидания).
3. Через `ZRANGEBYSCORE mm:queue (rating - max_range) (rating + max_range)` запрашиваются потенциальные кандидаты.
4. Среди кандидатов выбирается игрок с **минимальной разницей в рейтинге**, у которого:
$$| \text{rating} - \text{other\_rating} | \le \text{tolerance}(\text{other\_since})$$
5. **Если кандидат найден:**
- Оба игрока атомарно удаляются из `mm:queue`, `mm:since`, `mm:rating`.
- Оба игрока добавляются в `mm:pending` с текущим временем.
- Скрипт возвращает строку вида `"P:<id1>:<id2>"`.
6. **Если кандидат не найден:**
- Игрок сохраняется в `mm:queue` с весом `rating`.
- В `mm:since` записывается время входа.
- В `mm:rating` сохраняется рейтинг игрока.
### Фаза 2: Периодическая фоновая сверка (`sweep`)
Каждые `MATCHMAKING_SWEEP_INTERVAL` секунд (по умолчанию каждые 2 секунды) запускается фоновая задача `MatchmakingSweepJob`, вызывающая `MatchmakingService.sweep`:
1. Вызывается Redis Lua-скрипт `PAIR_SCRIPT` с аргументом `mode = "sweep"`.
2. Из `mm:queue` считываются все ожидающие игроки со своими рейтингами и временем ожидания.
3. Массив сортируется по возрастанию рейтинга: $O(N \log N)$.
4. Выполняется жадный проход:
- Если текущий игрок ждёт дольше `queue_ttl` (60 сек): он помечается как экспирированный (`"E:<id>"`).
- Иначе ищется ближайший по рейтингу свободный соперник в списке, для которого разница в рейтинге не превышает допуск по времени ожидания более старого игрока.
- При нахождении оба игрока помечаются как использованные и добавляются в результат (`"P:<id1>:<id2>"`).
5. Все найденные пары атомарно переносятся из `mm:queue` в `mm:pending`, а экспирированные игроки удаляются из очереди.
6. **Requeue (защита от падений):**
- Запрашиваются все игроки из `mm:pending`, находящиеся там дольше `pending_ttl` (15 сек).
- Если для игрока ключ матча `mm:user:<uid>` так и не был создан (например, Ruby-процесс упал при генерации токена), игрок автоматически возвращается назад в `mm:queue` со своим исходным рейтингом.
---
## 5. Создание матча и атомарная финализация
Когда Lua-скрипт возвращает сформированную пару `"P:<id1>:<id2>"`:
1. **Генерация идентификаторов и токенов в Ruby:**
- Генерируется уникальный UUID матча: `match_id = SecureRandom.uuid`.
- Генерируется JWT-токен для `game-server`:
```ruby
JwtService.encode(
match_id: match_id,
player1_id: player1_id,
player2_id: player2_id,
game_server: config.game_server_url,
exp: config.token_ttl.seconds.from_now.to_i
)
```
2. **Атомарная финализация в Redis (`FINALIZE_SCRIPT`):**
- Сохраняет `mm:match:<match_id>` с TTL.
- Сохраняет указатели `mm:user:<player1_id>` и `mm:user:<player2_id>` с тем же TTL.
- Удаляет обоих игроков из `mm:pending`, `mm:rating`, `mm:since`.
3. **Push-уведомление через Action Cable:**
- Для каждого игрока выполняется атомарное чтение и удаление указателя: `redis.getdel("mm:user:<id>")`.
- Если указатель успешно прочитан, сообщение с типом `matched` отправляется в персональный поток пользователя:
`MatchmakingChannel.broadcast_to(user_id, payload)`.
---
## 6. Гарантия доставки (At-most-once & Reconnection)
Если в момент формирования матча у пользователя произошёл кратковременный разрыв соединения или переподключение:
- Сообщение broadcast могло быть отправлено в момент отсутствия активного WebSocket-клиента.
- Персональный ключ `mm:user:<user_id>` сохраняется в Redis до момента подтверждённой доставки.
- При повторном подключении к WebSocket метод `MatchmakingChannel#subscribed` вызывает:
```ruby
if match = MatchmakingService.claim(current_user.id)
transmit(match)
end
```
- Метод `claim` выполняет Redis-команду `GETDEL mm:user:<user_id>`, что гарантирует:
1. Матч будет немедленно доставлен клиенту при переподключении.
2. Никакой матч не может быть получен дважды (атомарное удаление при первой же выдаче).
---
## 7. Диаграммы взаимодействия
### Sequence Diagram: Успешный подбор матча
```mermaid
sequenceDiagram
autonumber
actor PlayerA as Игрок A
actor PlayerB as Игрок B
participant AC as ActionCable (MatchmakingChannel)
participant MS as MatchmakingService
participant Redis as Redis (Lua Scripts)
participant Game as Game Server (Go)
PlayerA->>AC: subscribe
PlayerA->>AC: action: "start"
AC->>MS: enqueue(PlayerA)
MS->>Redis: eval PAIR_SCRIPT (enqueue)
Redis-->>MS: соперник не найден -> добавлен в mm:queue
PlayerB->>AC: subscribe
PlayerB->>AC: action: "start"
AC->>MS: enqueue(PlayerB)
MS->>Redis: eval PAIR_SCRIPT (enqueue)
Redis-->>MS: пара сформирована! (P:PlayerB:PlayerA)
MS->>MS: генерация match_id (UUID) и game_token (JWT)
MS->>Redis: eval FINALIZE_SCRIPT (сохранить матч, перевести из pending)
MS->>Redis: getdel mm:user:PlayerA & mm:user:PlayerB
MS->>AC: broadcast_to(PlayerA, matched)
MS->>AC: broadcast_to(PlayerB, matched)
AC-->>PlayerA: message { type: "matched", match_id, game_token, game_server }
AC-->>PlayerB: message { type: "matched", match_id, game_token, game_server }
PlayerA->>Game: WebSocket connect(game_token)
PlayerB->>Game: WebSocket connect(game_token)
```
### State Diagram: Жизненный цикл игрока в матчмейкинге
```mermaid
stateDiagram-v2
[*] --> Idle: Пользователь авторизован
Idle --> Queued: Отправка action "start" (enqueue)
Queued --> Pending: Найден соперник в Phase 1 или Phase 2
Queued --> Idle: Отправка action "stop" / разрыв сокета (cancel)
Queued --> TimedOut: Ожидание > MATCHMAKING_QUEUE_TTL
TimedOut --> Idle: Уведомление { type: "timeout" }
Pending --> Matched: Успешная финализация (генерация токена)
Pending --> Queued: Сбой финализации (requeue через 15 сек)
Matched --> InGame: Подключение к Game Server по game_token
InGame --> Idle: Завершение игры
```
@@ -0,0 +1,44 @@
require "rails_helper"
RSpec.describe ApplicationCable::Connection, type: :channel do
include ActionCable::Connection::TestCase::Behavior
let(:user) { User.create!(nickname: "CaptainCook", password: "password123") }
let(:token) { JwtService.encode(user_id: user.id) }
describe "#connect" do
it "connects successfully with token in query params" do
connect "/cable", params: { token: token }
expect(connection.current_user).to eq(user)
end
it "connects successfully with Bearer token in Authorization header" do
connect "/cable", headers: { "Authorization" => "Bearer #{token}" }
expect(connection.current_user).to eq(user)
end
it "rejects connection when no token is provided" do
expect { connect "/cable" }.to have_rejected_connection
end
it "rejects connection when token is invalid" do
expect {
connect "/cable", params: { token: "invalid.jwt.token" }
}.to have_rejected_connection
end
it "rejects connection when token is expired" do
expired_token = JwtService.encode(user_id: user.id, exp: 1.hour.ago.to_i)
expect {
connect "/cable", params: { token: expired_token }
}.to have_rejected_connection
end
it "rejects connection when user does not exist in the database" do
orphan_token = JwtService.encode(user_id: 999_999)
expect {
connect "/cable", params: { token: orphan_token }
}.to have_rejected_connection
end
end
end
@@ -0,0 +1,68 @@
require "rails_helper"
RSpec.describe MatchmakingChannel, type: :channel do
let(:user) { User.create!(nickname: "Blackbeard", password: "password123", rating: 1250) }
before do
stub_connection current_user: user
end
describe "#subscribed" do
it "subscribes successfully and streams from the user's private channel" do
subscribe
expect(subscription).to be_confirmed
expect(subscription).to have_stream_from("matchmaking:#{user.id}")
expect(transmissions).to be_empty
end
it "transmits a claimed match if one was ready before or during connection" do
match_payload = {
type: "matched",
match_id: "test-match-uuid",
game_token: "jwt-game-token",
game_server: "ws://localhost:8080",
players: [ user.id, 999 ]
}
allow(MatchmakingService).to receive(:claim).with(user.id).and_return(match_payload)
subscribe
expect(subscription).to be_confirmed
expect(transmissions.last).to eq(match_payload.as_json)
end
end
describe "#start" do
it "calls MatchmakingService.enqueue with the user's id and current rating" do
subscribe
expect(MatchmakingService).to receive(:enqueue).with(user.id, rating: 1250)
perform :start
end
it "accepts extra parameters in data hash without error" do
subscribe
expect(MatchmakingService).to receive(:enqueue).with(user.id, rating: 1250)
perform :start, { "custom" => "data" }
end
end
describe "#stop" do
it "calls MatchmakingService.cancel for the current user" do
subscribe
expect(MatchmakingService).to receive(:cancel).with(user.id)
perform :stop
end
end
describe "#unsubscribed" do
it "cancels matchmaking search when unsubscribing" do
subscribe
expect(MatchmakingService).to receive(:cancel).with(user.id)
unsubscribe
end
end
end
@@ -0,0 +1,41 @@
require "rails_helper"
RSpec.describe Api::MatchmakingController, type: :controller do
routes do
ActionDispatch::Routing::RouteSet.new.tap do |r|
r.draw do
delete "destroy", to: "api/matchmaking#destroy"
end
end
end
let(:user) { User.create!(nickname: "CaptainHook", password: "password123") }
let(:token) { JwtService.encode(user_id: user.id) }
describe "DELETE #destroy" do
context "when authenticated" do
it "cancels matchmaking for current user and returns success message" do
request.headers["Authorization"] = "Bearer #{token}"
expect(MatchmakingService).to receive(:cancel).with(user.id)
delete :destroy
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["message"]).to eq("Matchmaking cancelled")
end
end
context "when unauthenticated" do
it "returns 401 unauthorized and does not call cancel" do
expect(MatchmakingService).not_to receive(:cancel)
delete :destroy
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Unauthorized")
end
end
end
end
@@ -0,0 +1,114 @@
require "rails_helper"
RSpec.describe Api::RegistrationsController, type: :controller do
describe "POST #create" do
context "with valid parameters" do
let(:valid_params) do
{
user: {
nickname: "CaptainNemo",
password: "secretpassword"
}
}
end
it "creates a new user and returns 201 with user data and JWT token" do
expect {
post :create, params: valid_params
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json["token"]).to be_present
expect(json["user"]).to include(
"nickname" => "CaptainNemo",
"avatar_url" => nil
)
expect(json["user"]["id"]).to be_present
expect(json["user"]["created_at"]).to be_present
# Verify token is valid and decodes to created user
decoded = JwtService.decode(json["token"])
expect(decoded[:user_id]).to eq(json["user"]["id"])
end
it "attaches avatar and returns avatar_url when avatar is provided" do
avatar = fixture_file_upload(
Rails.root.join("spec", "fixtures", "files", "avatar.png"),
"image/png"
) rescue Rack::Test::UploadedFile.new(
StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"),
"image/png",
original_filename: "avatar.png"
)
params_with_avatar = valid_params.deep_merge(user: { avatar: avatar })
post :create, params: params_with_avatar
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json["user"]["avatar_url"]).to be_present
user = User.find(json["user"]["id"])
expect(user.avatar).to be_attached
end
end
context "with invalid parameters" do
it "returns 422 unprocessable entity when nickname is missing" do
post :create, params: { user: { nickname: "", password: "password123" } }
expect(response).to have_http_status(:unprocessable_content)
json = JSON.parse(response.body)
expect(json["errors"]).to be_an(Array)
expect(json["errors"]).to include("Nickname can't be blank")
end
it "returns 422 unprocessable entity when password is too short" do
post :create, params: { user: { nickname: "CaptainNemo", password: "123" } }
expect(response).to have_http_status(:unprocessable_content)
json = JSON.parse(response.body)
expect(json["errors"]).to include("Password is too short (minimum is 6 characters)")
end
it "returns 422 unprocessable entity when nickname is already taken" do
User.create!(nickname: "CaptainNemo", password: "password123")
post :create, params: { user: { nickname: "captainnemo", password: "password123" } }
expect(response).to have_http_status(:unprocessable_content)
json = JSON.parse(response.body)
expect(json["errors"]).to include("Nickname has already been taken")
end
it "returns 422 when avatar has an unsupported content type" do
invalid_avatar = Rack::Test::UploadedFile.new(
StringIO.new("plain text content"),
"text/plain",
original_filename: "test.txt"
)
post :create, params: {
user: {
nickname: "CaptainNemo",
password: "password123",
avatar: invalid_avatar
}
}
expect(response).to have_http_status(:unprocessable_content)
json = JSON.parse(response.body)
expect(json["errors"]).to include("Avatar must be a PNG, JPEG, GIF or WebP image")
end
it "raises ParameterMissing when user param is omitted" do
expect {
post :create, params: {}
}.to raise_error(ActionController::ParameterMissing)
end
end
end
end
@@ -0,0 +1,154 @@
require "rails_helper"
RSpec.describe Api::SessionsController, type: :controller do
let!(:user) { User.create!(nickname: "AdmiralNelson", password: "securepassword") }
let(:token) { JwtService.encode(user_id: user.id) }
describe "POST #create" do
context "with valid credentials" do
it "returns 200 OK with user info and JWT token" do
post :create, params: { nickname: "AdmiralNelson", password: "securepassword" }
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["token"]).to be_present
expect(json["user"]).to include(
"id" => user.id,
"nickname" => "AdmiralNelson",
"avatar_url" => nil
)
decoded = JwtService.decode(json["token"])
expect(decoded[:user_id]).to eq(user.id)
end
end
context "with invalid credentials" do
it "returns 401 when password does not match" do
post :create, params: { nickname: "AdmiralNelson", password: "wrongpassword" }
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Invalid nickname or password")
expect(json["token"]).to be_nil
end
it "returns 401 when user does not exist" do
post :create, params: { nickname: "GhostCaptain", password: "securepassword" }
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Invalid nickname or password")
end
it "returns 401 when nickname is omitted" do
post :create, params: { password: "securepassword" }
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Invalid nickname or password")
end
it "returns 401 when password is omitted" do
post :create, params: { nickname: "AdmiralNelson" }
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Invalid nickname or password")
end
it "returns 401 when credentials are blank" do
post :create, params: { nickname: "", password: "" }
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Invalid nickname or password")
end
end
end
describe "DELETE #destroy" do
context "when authenticated" do
it "returns 200 with success message" do
request.headers["Authorization"] = "Bearer #{token}"
delete :destroy
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["message"]).to eq("Signed out successfully")
end
end
context "when unauthenticated" do
it "returns 401 unauthorized" do
delete :destroy
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Unauthorized")
end
end
end
describe "GET #me" do
context "when authenticated with Authorization header" do
it "returns 200 with user data" do
request.headers["Authorization"] = "Bearer #{token}"
get :me
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["user"]).to include(
"id" => user.id,
"nickname" => "AdmiralNelson",
"avatar_url" => nil
)
end
it "includes avatar_url when user has an attached avatar" do
user.avatar.attach(
io: StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"),
filename: "avatar.png",
content_type: "image/png"
)
request.headers["Authorization"] = "Bearer #{token}"
get :me
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["user"]["avatar_url"]).to be_present
end
end
context "when authenticated with token in query params" do
it "returns 200 with user data" do
get :me, params: { token: token }
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["user"]["id"]).to eq(user.id)
end
end
context "when unauthenticated" do
it "returns 401 unauthorized" do
get :me
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Unauthorized")
end
end
context "when token is expired" do
it "returns 401 unauthorized" do
expired_token = JwtService.encode(user_id: user.id, exp: 1.hour.ago.to_i)
request.headers["Authorization"] = "Bearer #{expired_token}"
get :me
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -0,0 +1,58 @@
require "rails_helper"
RSpec.describe ApplicationController, type: :controller do
controller do
def index
render json: { user_id: current_user.id }
end
end
let(:user) { User.create!(nickname: "alice", password: "password123") }
let(:token) { JwtService.encode(user_id: user.id) }
describe "#authenticate_request!" do
context "with valid Bearer token in Authorization header" do
it "sets current_user and succeeds" do
request.headers["Authorization"] = "Bearer #{token}"
get :index
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to eq({ "user_id" => user.id })
end
end
context "with valid token in params" do
it "sets current_user and succeeds" do
get :index, params: { token: token }
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to eq({ "user_id" => user.id })
end
end
context "without token" do
it "returns 401 unauthorized" do
get :index
expect(response).to have_http_status(:unauthorized)
expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" })
end
end
context "with invalid token" do
it "returns 401 unauthorized" do
request.headers["Authorization"] = "Bearer invalid_token"
get :index
expect(response).to have_http_status(:unauthorized)
expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" })
end
end
context "with token for nonexistent user" do
it "returns 401 unauthorized" do
orphan_token = JwtService.encode(user_id: 999_999)
request.headers["Authorization"] = "Bearer #{orphan_token}"
get :index
expect(response).to have_http_status(:unauthorized)
expect(JSON.parse(response.body)).to eq({ "error" => "Unauthorized" })
end
end
end
end
@@ -0,0 +1,10 @@
require "rails_helper"
RSpec.describe MatchmakingSweepJob, type: :job do
describe "#perform" do
it "calls MatchmakingService.sweep" do
expect(MatchmakingService).to receive(:sweep)
described_class.new.perform
end
end
end
+21
View File
@@ -38,6 +38,27 @@ RSpec.describe User, type: :model do
expect(duplicate).not_to be_valid
expect(duplicate.errors[:nickname]).to be_present
end
it "accepts valid image formats for avatar" do
user = User.new(valid_attributes)
user.avatar.attach(
io: StringIO.new("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"),
filename: "avatar.png",
content_type: "image/png"
)
expect(user).to be_valid
end
it "rejects unsupported mime types for avatar" do
user = User.new(valid_attributes)
user.avatar.attach(
io: StringIO.new("plain text"),
filename: "test.txt",
content_type: "text/plain"
)
expect(user).not_to be_valid
expect(user.errors[:avatar]).to include("must be a PNG, JPEG, GIF or WebP image")
end
end
describe "rating" do
+2 -2
View File
@@ -2,7 +2,7 @@
if ENV["COVERAGE"]
require "simplecov"
SimpleCov.start "rails" do
add_filter "/spec/"
skip "/spec/"
end
end
require 'spec_helper'
@@ -69,7 +69,7 @@ RSpec.configure do |config|
# behaviour is considered legacy and will be removed in a future version.
#
# To enable this behaviour uncomment the line below.
# config.infer_spec_type_from_file_location!
config.infer_spec_type_from_file_location!
# Filter lines from Rails gems in backtraces.
config.filter_rails_from_backtrace!
@@ -0,0 +1,52 @@
require "rails_helper"
RSpec.describe "Registrations API", type: :request do
describe "POST /api/register" do
let(:valid_attributes) do
{
user: {
nickname: "Sinbad",
password: "password123"
}
}
end
context "with valid parameters" do
it "registers a new user and returns 201 Created with token" do
expect {
post "/api/register", params: valid_attributes, as: :json
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json["token"]).to be_present
expect(json["user"]["nickname"]).to eq("Sinbad")
expect(json["user"]["id"]).to eq(User.last.id)
expect(json["user"]["avatar_url"]).to be_nil
expect(json["user"]["created_at"]).to be_present
end
end
context "with invalid parameters" do
it "returns 422 Unprocessable Content when nickname is taken" do
User.create!(nickname: "Sinbad", password: "password123")
post "/api/register", params: valid_attributes, as: :json
expect(response).to have_http_status(422)
json = JSON.parse(response.body)
expect(json["errors"]).to include("Nickname has already been taken")
end
it "returns 422 Unprocessable Content when password is too short" do
invalid_attrs = { user: { nickname: "Sinbad", password: "123" } }
post "/api/register", params: invalid_attrs, as: :json
expect(response).to have_http_status(422)
json = JSON.parse(response.body)
expect(json["errors"]).to include("Password is too short (minimum is 6 characters)")
end
end
end
end
@@ -0,0 +1,61 @@
require "rails_helper"
RSpec.describe "Sessions API", type: :request do
let!(:user) { User.create!(nickname: "Columbus", password: "securepassword") }
let(:token) { JwtService.encode(user_id: user.id) }
describe "POST /api/login" do
it "logs in with valid credentials and returns 200 OK with token" do
post "/api/login", params: { nickname: "Columbus", password: "securepassword" }, as: :json
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["token"]).to be_present
expect(json["user"]["id"]).to eq(user.id)
expect(json["user"]["nickname"]).to eq("Columbus")
end
it "returns 401 Unauthorized with invalid credentials" do
post "/api/login", params: { nickname: "Columbus", password: "wrongpassword" }, as: :json
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Invalid nickname or password")
end
end
describe "DELETE /api/logout" do
it "signs out successfully when bearer token is supplied" do
delete "/api/logout", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["message"]).to eq("Signed out successfully")
end
it "returns 401 Unauthorized when unauthenticated" do
delete "/api/logout"
expect(response).to have_http_status(:unauthorized)
end
end
describe "GET /api/me" do
it "returns user profile with Bearer authorization header" do
get "/api/me", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["user"]["id"]).to eq(user.id)
expect(json["user"]["nickname"]).to eq("Columbus")
end
it "returns 401 Unauthorized when Authorization header is missing" do
get "/api/me"
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json["error"]).to eq("Unauthorized")
end
end
end