Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
656fc44fd8 | ||
|
|
765a3365bd | ||
|
|
acdaebd491 |
+38
-48
@@ -207,31 +207,37 @@ curl -X DELETE http://localhost:3000/api/logout \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Поиск случайного соперника (Матчмейкинг)
|
### Поиск соперника (Матчмейкинг)
|
||||||
|
|
||||||
Начинает поиск соперника. Работает в режиме **long-polling**: запрос зависает до 30 секунд, ожидая второго игрока.
|
Поиск соперника происходит через **WebSocket** (`Action Cable`), а не через long-polling: HTTP-поток не блокируется, а результат доставляется сервером push-уведомлением, как только найден соперник.
|
||||||
|
|
||||||
**URL:** `POST /api/matchmaking`
|
**URL подключения:** `ws://localhost:3000/cable?token=<JWT>`
|
||||||
|
|
||||||
**Auth:** требуется
|
**Auth:** токен передаётся параметром `token` в query string (тот же JWT, что и для HTTP API).
|
||||||
|
|
||||||
**Параметры:** нет
|
**Канал:** `MatchmakingChannel`
|
||||||
|
|
||||||
**Пример запроса:**
|
#### Поток взаимодействия
|
||||||
|
|
||||||
```bash
|
1. Подключиться к `/cable?token=...` и подписаться на канал `MatchmakingChannel`.
|
||||||
curl -X POST http://localhost:3000/api/matchmaking \
|
2. Отправить команду начала поиска:
|
||||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
|
|
||||||
--max-time 35
|
```json
|
||||||
|
{ "action": "start" }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Успешный ответ (200 OK)** — когда найден соперник:
|
Рейтинг для подбора берётся из базы данных (`users.rating`). У новых пользователей он инициализируется нейтральным значением `100` (значение задано в БД); в будущем рейтинг будет пересчитываться по результатам матчей. Игроки подбираются по близости рейтинга; допуск расширяется с ростом времени ожидания.
|
||||||
|
|
||||||
|
3. Дождаться одного из сообщений:
|
||||||
|
|
||||||
|
**Матч найден:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"type": "matched",
|
||||||
"match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
|
"match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
|
||||||
"game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...",
|
"game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...",
|
||||||
"game_server": "ws://game-server.example.com:8080",
|
"game_server": "ws://localhost:8080",
|
||||||
"players": [1, 2]
|
"players": [1, 2]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -243,48 +249,25 @@ curl -X POST http://localhost:3000/api/matchmaking \
|
|||||||
| `game_server` | WebSocket-адрес игрового сервера |
|
| `game_server` | WebSocket-адрес игрового сервера |
|
||||||
| `players` | ID обоих игроков `[player1_id, player2_id]` |
|
| `players` | ID обоих игроков `[player1_id, player2_id]` |
|
||||||
|
|
||||||
**Таймаут (408 Request Timeout)** — если соперник не найден за 30 секунд:
|
**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "type": "timeout" }
|
||||||
"error": "No opponent found. Try again."
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ошибка (401 Unauthorized):**
|
4. Поиск отменяется автоматически при закрытии WebSocket-подписки. Также можно явно отправить:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "action": "stop" }
|
||||||
"error": "Unauthorized"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Примечание:** `game_token` содержит `match_id`, `player1_id`, `player2_id`, `game_server` и действителен **1 час**.
|
**Примечание:** `game_token` содержит `match_id`, `player1_id`, `player2_id`, `game_server` и действителен `MATCHMAKING_TOKEN_TTL` секунд.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Отмена поиска
|
### Отмена поиска
|
||||||
|
|
||||||
Убирает игрока из очереди матчмейкинга.
|
Отмена поиска происходит через WebSocket: достаточно отключиться от `MatchmakingChannel` либо отправить `{ "action": "stop" }`.
|
||||||
|
|
||||||
**URL:** `DELETE /api/matchmaking`
|
|
||||||
|
|
||||||
**Auth:** требуется
|
|
||||||
|
|
||||||
**Пример запроса:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:3000/api/matchmaking \
|
|
||||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
|
|
||||||
```
|
|
||||||
|
|
||||||
**Успешный ответ (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Matchmaking cancelled"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -311,7 +294,6 @@ curl -X DELETE http://localhost:3000/api/matchmaking \
|
|||||||
| `200` | Успешный запрос |
|
| `200` | Успешный запрос |
|
||||||
| `201` | Ресурс создан (регистрация) |
|
| `201` | Ресурс создан (регистрация) |
|
||||||
| `401` | Неавторизован (отсутствует/невалиден токен) |
|
| `401` | Неавторизован (отсутствует/невалиден токен) |
|
||||||
| `408` | Таймаут матчмейкинга (соперник не найден) |
|
|
||||||
| `422` | Ошибка валидации |
|
| `422` | Ошибка валидации |
|
||||||
| `500` | Внутренняя ошибка сервера |
|
| `500` | Внутренняя ошибка сервера |
|
||||||
|
|
||||||
@@ -321,8 +303,16 @@ curl -X DELETE http://localhost:3000/api/matchmaking \
|
|||||||
|
|
||||||
| Переменная | По умолчанию | Описание |
|
| Переменная | По умолчанию | Описание |
|
||||||
|------------|--------------|----------|
|
|------------|--------------|----------|
|
||||||
| `REDIS_URL` | `redis://localhost:6379/0` | Подключение к Redis |
|
| `REDIS_URL` | `redis://localhost:6380/0` | Подключение к Redis (матчмейкинг и pub/sub Action Cable) |
|
||||||
| `GAME_SERVER_URL` | `ws://game-server.example.com:8080` | Адрес игрового сервера для матчмейкинга |
|
| `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 (через запятую) |
|
| `CORS_ORIGINS` | `*` | Разрешённые origins для CORS (через запятую) |
|
||||||
| `SECRET_KEY_BASE` | из `credentials.yml.enc` | Секретный ключ для JWT |
|
| `SECRET_KEY_BASE` | из `credentials.yml.enc` | Секретный ключ для JWT |
|
||||||
|
|
||||||
@@ -341,8 +331,8 @@ TOKEN=$(curl -s -X POST http://localhost:3000/api/register \
|
|||||||
curl -s http://localhost:3000/api/me \
|
curl -s http://localhost:3000/api/me \
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
# 3. Поиск матча (long-polling)
|
# 3. Поиск матча — WebSocket (например, через wscat):
|
||||||
curl -s -X POST http://localhost:3000/api/matchmaking \
|
# wscat -c "ws://localhost:3000/cable?token=$TOKEN"
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
# > {"command":"subscribe","identifier":"{\"channel\":\"MatchmakingChannel\"}"}
|
||||||
--max-time 35 | jq .
|
# > {"command":"message","identifier":"{\"channel\":\"MatchmakingChannel\"}","data":"{\"action\":\"start\"}"}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
class Channel < ActionCable::Channel::Base
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
class Connection < ActionCable::Connection::Base
|
||||||
|
identified_by :current_user
|
||||||
|
|
||||||
|
def connect
|
||||||
|
self.current_user = find_user
|
||||||
|
reject_unauthorized_connection unless current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_user
|
||||||
|
token = request.params[:token].presence ||
|
||||||
|
request.headers["Authorization"]&.split(" ")&.last
|
||||||
|
return unless token
|
||||||
|
|
||||||
|
decoded = JwtService.decode(token)
|
||||||
|
User.find_by(id: decoded[:user_id]) if decoded
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Real-time matchmaking channel.
|
||||||
|
#
|
||||||
|
# Client flow:
|
||||||
|
# 1. Connect: ws(s)://host/cable?token=<JWT>
|
||||||
|
# 2. Subscribe to "MatchmakingChannel"
|
||||||
|
# 3. Send { action: "start" } to enter the queue
|
||||||
|
# 4. Receive { type: "matched", ... } or { type: "timeout" }
|
||||||
|
# 5. Disconnecting (or sending { action: "stop" }) cancels the search.
|
||||||
|
class MatchmakingChannel < ApplicationCable::Channel
|
||||||
|
def subscribed
|
||||||
|
stream_for current_user
|
||||||
|
|
||||||
|
# If a match was found while this user had no subscription
|
||||||
|
# (e.g. brief reconnect), deliver it now — exactly once.
|
||||||
|
if match = MatchmakingService.claim(current_user.id)
|
||||||
|
transmit(match)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def start(_data)
|
||||||
|
MatchmakingService.enqueue(current_user.id, rating: current_user.rating)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stop
|
||||||
|
MatchmakingService.cancel(current_user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsubscribed
|
||||||
|
MatchmakingService.cancel(current_user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,19 +1,6 @@
|
|||||||
class Api::MatchmakingController < ApplicationController
|
class Api::MatchmakingController < ApplicationController
|
||||||
def create
|
# Explicit cancellation. Normally the search is cancelled automatically
|
||||||
result = MatchmakingService.find_match(current_user.id)
|
# when the MatchmakingChannel subscription is closed.
|
||||||
|
|
||||||
if result
|
|
||||||
render json: {
|
|
||||||
match_id: result[:match_id],
|
|
||||||
game_token: result[:game_token],
|
|
||||||
game_server: result[:game_server],
|
|
||||||
players: result[:players]
|
|
||||||
}
|
|
||||||
else
|
|
||||||
render json: { error: "No opponent found. Try again." }, status: :request_timeout
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
MatchmakingService.cancel(current_user.id)
|
MatchmakingService.cancel(current_user.id)
|
||||||
render json: { message: "Matchmaking cancelled" }
|
render json: { message: "Matchmaking cancelled" }
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Periodically re-checks the matchmaking queue: pairs players whose rating
|
||||||
|
# tolerance has widened enough, expires timed-out waiters and requeues
|
||||||
|
# players stuck in the pending state. Scheduled via config/recurring.yml.
|
||||||
|
class MatchmakingSweepJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
MatchmakingService.sweep
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@ class User < ApplicationRecord
|
|||||||
normalizes :nickname, with: ->(n) { n.strip }
|
normalizes :nickname, with: ->(n) { n.strip }
|
||||||
|
|
||||||
validates :nickname, presence: true, uniqueness: { case_sensitive: false }, length: { minimum: 3, maximum: 24 }
|
validates :nickname, presence: true, uniqueness: { case_sensitive: false }, length: { minimum: 3, maximum: 24 }
|
||||||
|
validates :rating, presence: true
|
||||||
validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || password_digest_changed? }
|
validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || password_digest_changed? }
|
||||||
validate :avatar_content_type, if: -> { avatar.attached? }
|
validate :avatar_content_type, if: -> { avatar.attached? }
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +1,286 @@
|
|||||||
|
# Matchmaking via Redis.
|
||||||
|
#
|
||||||
|
# Data structures (all volatile, TTL-backed):
|
||||||
|
# mm:queue sorted set — waiting players, score = rating
|
||||||
|
# mm:since hash — user_id => enqueue timestamp (for dynamic rating range)
|
||||||
|
# mm:rating hash — user_id => rating (to requeue stale pending players)
|
||||||
|
# mm:pending sorted set — paired, awaiting finalize, score = pairing timestamp
|
||||||
|
# mm:match:<id> string — match payload (JSON)
|
||||||
|
# mm:user:<id> string — per-user pointer to the match payload (JSON)
|
||||||
|
#
|
||||||
|
# All state transitions happen inside Lua scripts, so every operation is
|
||||||
|
# atomic with respect to other Redis clients. Delivery to clients happens
|
||||||
|
# over Action Cable; the per-user pointer key guarantees at-most-once
|
||||||
|
# delivery even if the broadcast is missed (client reconnects and claims it).
|
||||||
class MatchmakingService
|
class MatchmakingService
|
||||||
QUEUE_KEY = "matchmaking:queue"
|
QUEUE_KEY = "mm:queue"
|
||||||
LOCK_KEY = "matchmaking:lock"
|
SINCE_KEY = "mm:since"
|
||||||
MATCH_PREFIX = "matchmaking:match:"
|
RATING_KEY = "mm:rating"
|
||||||
MATCH_TTL = 60 # seconds
|
PENDING_KEY = "mm:pending"
|
||||||
POLL_INTERVAL = 0.5 # seconds
|
MATCH_KEY_PREFIX = "mm:match:"
|
||||||
MAX_WAIT = 30 # seconds
|
USER_KEY_PREFIX = "mm:user:"
|
||||||
|
|
||||||
# Game server to redirect players to (hardcoded for now)
|
# Enqueues a player and tries to find an opponent immediately; in "sweep"
|
||||||
GAME_SERVER = ENV.fetch("GAME_SERVER_URL", "ws://game-server.example.com:8080")
|
# mode pairs up everyone in the queue, expires timed-out waiters and
|
||||||
|
# requeues stale pending players. Returns an array of result strings:
|
||||||
|
# "P:<id1>:<id2>" — a pair was formed
|
||||||
|
# "E:<id>" — a player timed out waiting
|
||||||
|
PAIR_SCRIPT = <<~LUA
|
||||||
|
local queue_key = KEYS[1]
|
||||||
|
local since_key = KEYS[2]
|
||||||
|
local rating_key = KEYS[3]
|
||||||
|
local pending_key = KEYS[4]
|
||||||
|
local user_prefix = KEYS[5]
|
||||||
|
|
||||||
|
-- All timestamps are integer milliseconds: Lua turns large floats into
|
||||||
|
-- scientific notation strings which Redis rejects in ZADD/ZRANGEBYSCORE.
|
||||||
|
local mode = ARGV[1]
|
||||||
|
local now = tonumber(ARGV[2])
|
||||||
|
local queue_ttl = tonumber(ARGV[3]) * 1000
|
||||||
|
local pending_ttl = tonumber(ARGV[4]) * 1000
|
||||||
|
local base_range = tonumber(ARGV[5])
|
||||||
|
local range_per_sec = tonumber(ARGV[6])
|
||||||
|
local max_range = tonumber(ARGV[7])
|
||||||
|
|
||||||
|
local function tolerance(ts)
|
||||||
|
local waited = (now - ts) / 1000
|
||||||
|
local r = base_range + waited * range_per_sec
|
||||||
|
return r > max_range and max_range or r
|
||||||
|
end
|
||||||
|
|
||||||
|
local results = {}
|
||||||
|
|
||||||
|
if mode == "enqueue" then
|
||||||
|
local uid = ARGV[8]
|
||||||
|
local rating = tonumber(ARGV[9])
|
||||||
|
|
||||||
|
-- restart a fresh waiting session for this user
|
||||||
|
redis.call("ZREM", queue_key, uid)
|
||||||
|
redis.call("HDEL", since_key, uid)
|
||||||
|
redis.call("HDEL", rating_key, uid)
|
||||||
|
|
||||||
|
local candidates = redis.call("ZRANGEBYSCORE", queue_key,
|
||||||
|
rating - max_range, rating + max_range)
|
||||||
|
local best = nil
|
||||||
|
local best_diff = nil
|
||||||
|
|
||||||
|
for _, other in ipairs(candidates) do
|
||||||
|
if other ~= uid then
|
||||||
|
local other_ts = tonumber(redis.call("HGET", since_key, other) or now)
|
||||||
|
local other_rating = tonumber(redis.call("ZSCORE", queue_key, other))
|
||||||
|
local diff = math.abs(other_rating - rating)
|
||||||
|
if diff <= tolerance(other_ts) and (best_diff == nil or diff < best_diff) then
|
||||||
|
best = other
|
||||||
|
best_diff = diff
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if best then
|
||||||
|
redis.call("ZREM", queue_key, uid, best)
|
||||||
|
redis.call("HDEL", since_key, uid, best)
|
||||||
|
redis.call("HDEL", rating_key, uid, best)
|
||||||
|
redis.call("ZADD", pending_key, now, uid, now, best)
|
||||||
|
results[#results + 1] = "P:" .. uid .. ":" .. best
|
||||||
|
else
|
||||||
|
redis.call("ZADD", queue_key, rating, uid)
|
||||||
|
redis.call("HSET", since_key, uid, now)
|
||||||
|
redis.call("HSET", rating_key, uid, rating)
|
||||||
|
end
|
||||||
|
else -- "sweep"
|
||||||
|
local all = redis.call("ZRANGE", queue_key, 0, -1, "WITHSCORES")
|
||||||
|
local items = {}
|
||||||
|
for i = 1, #all, 2 do
|
||||||
|
items[#items + 1] = {
|
||||||
|
id = all[i],
|
||||||
|
rating = tonumber(all[i + 1]),
|
||||||
|
ts = tonumber(redis.call("HGET", since_key, all[i]) or now)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
table.sort(items, function(a, b) return a.rating < b.rating end)
|
||||||
|
|
||||||
|
local used = {}
|
||||||
|
for i = 1, #items do
|
||||||
|
local a = items[i]
|
||||||
|
if not used[a.id] then
|
||||||
|
if now - a.ts > queue_ttl then
|
||||||
|
used[a.id] = true
|
||||||
|
results[#results + 1] = "E:" .. a.id
|
||||||
|
else
|
||||||
|
local best = nil
|
||||||
|
for j = i + 1, #items do
|
||||||
|
local b = items[j]
|
||||||
|
-- tolerance is based on the longer-waiting player of the pair
|
||||||
|
if not used[b.id] and (b.rating - a.rating) <= tolerance(math.min(a.ts, b.ts)) then
|
||||||
|
best = b
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if best then
|
||||||
|
used[a.id] = true
|
||||||
|
used[best.id] = true
|
||||||
|
results[#results + 1] = "P:" .. a.id .. ":" .. best.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, res in ipairs(results) do
|
||||||
|
local parts = {}
|
||||||
|
for part in string.gmatch(res, "[^:]+") do parts[#parts + 1] = part end
|
||||||
|
if parts[1] == "P" then
|
||||||
|
redis.call("ZREM", queue_key, parts[2], parts[3])
|
||||||
|
redis.call("HDEL", since_key, parts[2], parts[3])
|
||||||
|
redis.call("HDEL", rating_key, parts[2], parts[3])
|
||||||
|
redis.call("ZADD", pending_key, now, parts[2], now, parts[3])
|
||||||
|
else
|
||||||
|
redis.call("ZREM", queue_key, parts[2])
|
||||||
|
redis.call("HDEL", since_key, parts[2])
|
||||||
|
redis.call("HDEL", rating_key, parts[2])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- requeue players whose pairing was never finalized (e.g. the Rails
|
||||||
|
-- process died between pairing and match creation)
|
||||||
|
local stale = redis.call("ZRANGEBYSCORE", pending_key, 0, now - pending_ttl)
|
||||||
|
for _, uid in ipairs(stale) do
|
||||||
|
if redis.call("EXISTS", user_prefix .. uid) == 0 then
|
||||||
|
local r = redis.call("HGET", rating_key, uid)
|
||||||
|
if r then
|
||||||
|
redis.call("ZADD", queue_key, tonumber(r), uid)
|
||||||
|
redis.call("HSET", since_key, uid, now)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
redis.call("ZREM", pending_key, uid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call("EXPIRE", queue_key, math.floor(queue_ttl / 1000) * 4)
|
||||||
|
redis.call("EXPIRE", pending_key, math.floor(queue_ttl / 1000) * 4)
|
||||||
|
redis.call("EXPIRE", since_key, math.floor(queue_ttl / 1000) * 4)
|
||||||
|
redis.call("EXPIRE", rating_key, math.floor(queue_ttl / 1000) * 4)
|
||||||
|
return results
|
||||||
|
LUA
|
||||||
|
|
||||||
|
# Atomically stores the match payload and clears the players' pending state.
|
||||||
|
FINALIZE_SCRIPT = <<~LUA
|
||||||
|
redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2]) -- match payload
|
||||||
|
redis.call("SET", KEYS[2], ARGV[1], "EX", ARGV[2]) -- pointer: player 1
|
||||||
|
redis.call("SET", KEYS[3], ARGV[1], "EX", ARGV[2]) -- pointer: player 2
|
||||||
|
redis.call("ZREM", KEYS[4], ARGV[3], ARGV[4]) -- pending
|
||||||
|
redis.call("HDEL", KEYS[5], ARGV[3], ARGV[4]) -- rating
|
||||||
|
redis.call("HDEL", KEYS[6], ARGV[3], ARGV[4]) -- since
|
||||||
|
return 1
|
||||||
|
LUA
|
||||||
|
|
||||||
|
CANCEL_SCRIPT = <<~LUA
|
||||||
|
redis.call("ZREM", KEYS[1], ARGV[1])
|
||||||
|
redis.call("ZREM", KEYS[2], ARGV[1])
|
||||||
|
redis.call("HDEL", KEYS[3], ARGV[1])
|
||||||
|
redis.call("HDEL", KEYS[4], ARGV[1])
|
||||||
|
redis.call("DEL", KEYS[5] .. ARGV[1])
|
||||||
|
return 1
|
||||||
|
LUA
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Main entry point: waits for an opponent and returns match data
|
# Adds the user to the queue and immediately tries to pair them.
|
||||||
def find_match(user_id)
|
# The result (match or timeout) is delivered asynchronously via
|
||||||
user_id = user_id.to_i
|
# MatchmakingChannel broadcasts.
|
||||||
|
def enqueue(user_id, rating:)
|
||||||
# Try to pair with an existing waiting player
|
results = call_pair_script("enqueue", extra: [ user_id, rating ])
|
||||||
opponent_id = pop_opponent(user_id)
|
process_results(results)
|
||||||
|
|
||||||
if opponent_id
|
|
||||||
create_match(user_id, opponent_id)
|
|
||||||
else
|
|
||||||
# Add self to queue and wait
|
|
||||||
add_to_queue(user_id)
|
|
||||||
wait_for_match(user_id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Cancel matchmaking (remove from queue)
|
# Pairs up everyone possible, expires timed-out waiters, requeues stale
|
||||||
|
# pending players. Called periodically by MatchmakingSweepJob.
|
||||||
|
def sweep
|
||||||
|
results = call_pair_script("sweep")
|
||||||
|
process_results(results)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Removes the user from the queue / pending state.
|
||||||
def cancel(user_id)
|
def cancel(user_id)
|
||||||
RedisConnection.with do |redis|
|
RedisConnection.with do |redis|
|
||||||
redis.lrem(QUEUE_KEY, 0, user_id)
|
redis.eval(CANCEL_SCRIPT, keys: [ QUEUE_KEY, PENDING_KEY, SINCE_KEY, RATING_KEY, USER_KEY_PREFIX ],
|
||||||
|
argv: [ user_id ])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Atomically reads and clears the match pointer for the user.
|
||||||
|
# Used on channel subscribe to deliver a match that was found while
|
||||||
|
# the user had no active subscription (at-most-once delivery).
|
||||||
|
def claim(user_id)
|
||||||
|
payload = RedisConnection.with { |redis| redis.getdel(user_key(user_id)) }
|
||||||
|
JSON.parse(payload, symbolize_names: true) if payload
|
||||||
|
end
|
||||||
|
|
||||||
|
def config
|
||||||
|
Rails.application.config.x.matchmaking
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Atomically pop an opponent from the queue (excluding self)
|
def call_pair_script(mode, extra: [])
|
||||||
def pop_opponent(user_id)
|
|
||||||
RedisConnection.with do |redis|
|
RedisConnection.with do |redis|
|
||||||
redis.watch(QUEUE_KEY) do
|
redis.eval(PAIR_SCRIPT,
|
||||||
queue = redis.lrange(QUEUE_KEY, 0, -1)
|
keys: [ QUEUE_KEY, SINCE_KEY, RATING_KEY, PENDING_KEY, USER_KEY_PREFIX ],
|
||||||
opponent = queue.find { |id| id.to_i != user_id }
|
argv: [ mode, (Time.current.to_f * 1000).to_i, config.queue_ttl, config.pending_ttl,
|
||||||
|
config.base_range, config.range_per_second, config.max_range, *extra ])
|
||||||
if opponent
|
|
||||||
result = redis.multi do |tx|
|
|
||||||
tx.lrem(QUEUE_KEY, 0, opponent)
|
|
||||||
end
|
end
|
||||||
return opponent.to_i if result&.first&.positive?
|
end
|
||||||
|
|
||||||
|
def process_results(results)
|
||||||
|
results.each do |result|
|
||||||
|
kind, id1, id2 = result.split(":")
|
||||||
|
if kind == "P"
|
||||||
|
create_match(id1.to_i, id2.to_i)
|
||||||
else
|
else
|
||||||
redis.unwatch
|
broadcast(id1.to_i, { type: "timeout" })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_to_queue(user_id)
|
|
||||||
RedisConnection.with do |redis|
|
|
||||||
# Remove any stale entries first
|
|
||||||
redis.lrem(QUEUE_KEY, 0, user_id)
|
|
||||||
redis.rpush(QUEUE_KEY, user_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def wait_for_match(user_id)
|
|
||||||
start_time = Time.current
|
|
||||||
match_id = nil
|
|
||||||
|
|
||||||
while Time.current - start_time < MAX_WAIT
|
|
||||||
RedisConnection.with do |redis|
|
|
||||||
match_id = redis.get("#{MATCH_PREFIX}#{user_id}")
|
|
||||||
end
|
|
||||||
break if match_id
|
|
||||||
|
|
||||||
sleep POLL_INTERVAL
|
|
||||||
end
|
|
||||||
|
|
||||||
if match_id
|
|
||||||
# Retrieve and delete the match data
|
|
||||||
data = nil
|
|
||||||
RedisConnection.with do |redis|
|
|
||||||
data = redis.get("#{MATCH_PREFIX}match:#{match_id}")
|
|
||||||
redis.del("#{MATCH_PREFIX}#{user_id}")
|
|
||||||
redis.del("#{MATCH_PREFIX}match:#{match_id}")
|
|
||||||
end
|
|
||||||
JSON.parse(data, symbolize_names: true) if data
|
|
||||||
else
|
|
||||||
# Timeout: remove from queue
|
|
||||||
cancel(user_id)
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
# JWT generation happens in Ruby, so pairing (done in Lua) and match
|
||||||
|
# creation are two steps. Players sit in the "pending" set between them;
|
||||||
|
# the sweep requeues anyone stuck there longer than pending_ttl.
|
||||||
def create_match(player1_id, player2_id)
|
def create_match(player1_id, player2_id)
|
||||||
match_id = SecureRandom.uuid
|
match_id = SecureRandom.uuid
|
||||||
game_token = JwtService.encode(
|
game_token = JwtService.encode(
|
||||||
match_id: match_id,
|
match_id: match_id,
|
||||||
player1_id: player1_id,
|
player1_id: player1_id,
|
||||||
player2_id: player2_id,
|
player2_id: player2_id,
|
||||||
game_server: GAME_SERVER,
|
game_server: config.game_server_url,
|
||||||
exp: 2.minute.from_now.to_i
|
exp: config.token_ttl.seconds.from_now.to_i
|
||||||
)
|
)
|
||||||
|
|
||||||
match_data = {
|
payload = {
|
||||||
|
type: "matched",
|
||||||
match_id: match_id,
|
match_id: match_id,
|
||||||
game_token: game_token,
|
game_token: game_token,
|
||||||
game_server: GAME_SERVER,
|
game_server: config.game_server_url,
|
||||||
players: [ player1_id, player2_id ]
|
players: [ player1_id, player2_id ]
|
||||||
}
|
}.to_json
|
||||||
|
|
||||||
# Store match data for both players
|
|
||||||
RedisConnection.with do |redis|
|
RedisConnection.with do |redis|
|
||||||
redis.set("#{MATCH_PREFIX}match:#{match_id}", match_data.to_json, ex: MATCH_TTL)
|
redis.eval(FINALIZE_SCRIPT,
|
||||||
redis.set("#{MATCH_PREFIX}#{player1_id}", match_id, ex: MATCH_TTL)
|
keys: [ match_key(match_id), user_key(player1_id), user_key(player2_id),
|
||||||
redis.set("#{MATCH_PREFIX}#{player2_id}", match_id, ex: MATCH_TTL)
|
PENDING_KEY, RATING_KEY, SINCE_KEY ],
|
||||||
|
argv: [ payload, config.match_ttl, player1_id, player2_id ])
|
||||||
end
|
end
|
||||||
|
|
||||||
match_data
|
[ player1_id, player2_id ].each do |uid|
|
||||||
|
# Deliver and clear the pointer so a later subscribe-claim
|
||||||
|
# cannot duplicate the match.
|
||||||
|
pointer = RedisConnection.with { |redis| redis.getdel(user_key(uid)) }
|
||||||
|
broadcast(uid, JSON.parse(pointer, symbolize_names: true)) if pointer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def broadcast(user_id, payload)
|
||||||
|
MatchmakingChannel.broadcast_to(user_id, payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_key(match_id) = "#{MATCH_KEY_PREFIX}#{match_id}"
|
||||||
|
def user_key(user_id) = "#{USER_KEY_PREFIX}#{user_id}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
development:
|
||||||
|
adapter: async
|
||||||
|
|
||||||
|
test:
|
||||||
|
adapter: test
|
||||||
|
|
||||||
|
production:
|
||||||
|
adapter: redis
|
||||||
|
url: <%= ENV.fetch("REDIS_URL", "redis://localhost:6380/0") %>
|
||||||
|
channel_prefix: ruby_sea_battle_production
|
||||||
@@ -10,15 +10,25 @@ default: &default
|
|||||||
timeout: 5000
|
timeout: 5000
|
||||||
|
|
||||||
development:
|
development:
|
||||||
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/development.sqlite3
|
database: storage/development.sqlite3
|
||||||
|
queue:
|
||||||
|
<<: *default
|
||||||
|
database: storage/development_queue.sqlite3
|
||||||
|
migrations_paths: db/queue_migrate
|
||||||
|
|
||||||
# Warning: The database defined as "test" will be erased and
|
# Warning: The database defined as "test" will be erased and
|
||||||
# re-generated from your development database when you run "rake".
|
# re-generated from your development database when you run "rake".
|
||||||
# Do not set this db to the same as development or production.
|
# Do not set this db to the same as development or production.
|
||||||
test:
|
test:
|
||||||
|
primary:
|
||||||
<<: *default
|
<<: *default
|
||||||
database: storage/test.sqlite3
|
database: storage/test.sqlite3
|
||||||
|
queue:
|
||||||
|
<<: *default
|
||||||
|
database: storage/test_queue.sqlite3
|
||||||
|
migrations_paths: db/queue_migrate
|
||||||
|
|
||||||
# PostgreSQL for production.
|
# PostgreSQL for production.
|
||||||
#
|
#
|
||||||
@@ -29,6 +39,7 @@ test:
|
|||||||
# DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT
|
# DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT
|
||||||
#
|
#
|
||||||
production:
|
production:
|
||||||
|
primary:
|
||||||
adapter: postgresql
|
adapter: postgresql
|
||||||
encoding: unicode
|
encoding: unicode
|
||||||
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||||
@@ -37,3 +48,13 @@ production:
|
|||||||
password: <%= ENV["DB_PASSWORD"] %>
|
password: <%= ENV["DB_PASSWORD"] %>
|
||||||
host: <%= ENV["DB_HOST"] %>
|
host: <%= ENV["DB_HOST"] %>
|
||||||
port: <%= ENV["DB_PORT"] || 5432 %>
|
port: <%= ENV["DB_PORT"] || 5432 %>
|
||||||
|
queue:
|
||||||
|
adapter: postgresql
|
||||||
|
encoding: unicode
|
||||||
|
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||||
|
database: <%= ENV["QUEUE_DB_NAME"] || ENV["DB_NAME"] %>
|
||||||
|
username: <%= ENV["QUEUE_DB_USER"] || ENV["DB_USER"] %>
|
||||||
|
password: <%= ENV["QUEUE_DB_PASSWORD"] || ENV["DB_PASSWORD"] %>
|
||||||
|
host: <%= ENV["QUEUE_DB_HOST"] || ENV["DB_HOST"] %>
|
||||||
|
port: <%= ENV["QUEUE_DB_PORT"] || ENV["DB_PORT"] || 5432 %>
|
||||||
|
migrations_paths: db/queue_migrate
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ require "active_support/core_ext/integer/time"
|
|||||||
Rails.application.configure do
|
Rails.application.configure do
|
||||||
# Settings specified here will take precedence over those in config/application.rb.
|
# Settings specified here will take precedence over those in config/application.rb.
|
||||||
|
|
||||||
|
# Run background jobs (e.g. the matchmaking sweeper) through Solid Queue,
|
||||||
|
# supervised by the puma plugin.
|
||||||
|
config.active_job.queue_adapter = :solid_queue
|
||||||
|
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||||
|
|
||||||
# Make code changes take effect immediately without server restart.
|
# Make code changes take effect immediately without server restart.
|
||||||
config.enable_reloading = true
|
config.enable_reloading = true
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ Rails.application.configure do
|
|||||||
config.cache_store = :memory_store
|
config.cache_store = :memory_store
|
||||||
|
|
||||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||||
config.active_job.queue_adapter = :async
|
config.active_job.queue_adapter = :solid_queue
|
||||||
|
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# Ignore bad email addresses and do not raise email delivery errors.
|
||||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Matchmaking configuration. All values can be overridden via environment variables.
|
||||||
|
mm = Rails.application.config.x.matchmaking = ActiveSupport::OrderedOptions.new
|
||||||
|
|
||||||
|
mm.game_server_url = ENV.fetch("GAME_SERVER_URL", "ws://localhost:8080")
|
||||||
|
# Players' initial rating lives in the DB (users.rating, default 100),
|
||||||
|
# so no default rating is configured here.
|
||||||
|
|
||||||
|
# How long a found match (and its game token) stays valid, in seconds.
|
||||||
|
mm.match_ttl = ENV.fetch("MATCHMAKING_MATCH_TTL", 120).to_i
|
||||||
|
mm.token_ttl = ENV.fetch("MATCHMAKING_TOKEN_TTL", 120).to_i
|
||||||
|
|
||||||
|
# How long a player waits in the queue before a timeout, in seconds.
|
||||||
|
mm.queue_ttl = ENV.fetch("MATCHMAKING_QUEUE_TTL", 60).to_i
|
||||||
|
|
||||||
|
# How long a found (but not yet finalized) pair is kept in the "pending"
|
||||||
|
# state before the players are put back into the queue, in seconds.
|
||||||
|
mm.pending_ttl = ENV.fetch("MATCHMAKING_PENDING_TTL", 15).to_i
|
||||||
|
|
||||||
|
# Rating tolerance: players within `base_range` are matched immediately;
|
||||||
|
# the tolerance grows by `range_per_second` of waiting up to `max_range`.
|
||||||
|
mm.base_range = ENV.fetch("MATCHMAKING_BASE_RANGE", 100).to_i
|
||||||
|
mm.range_per_second = ENV.fetch("MATCHMAKING_RANGE_PER_SECOND", 50).to_i
|
||||||
|
mm.max_range = ENV.fetch("MATCHMAKING_MAX_RANGE", 1000).to_i
|
||||||
|
|
||||||
|
# How often the background sweep job re-checks the queue, in seconds.
|
||||||
|
mm.sweep_interval = ENV.fetch("MATCHMAKING_SWEEP_INTERVAL", 2).to_i
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
# Development/test: localhost:6379 (default)
|
# Development/test: localhost:6379 (default)
|
||||||
# Production: set REDIS_URL env variable, e.g. redis://redis.example.com:6379/0
|
# Production: set REDIS_URL env variable, e.g. redis://redis.example.com:6379/0
|
||||||
|
|
||||||
|
# Use an isolated Redis logical database in tests to avoid clobbering dev data.
|
||||||
|
url = ENV.fetch("REDIS_URL", "redis://localhost:6380/0")
|
||||||
|
url = url.sub(%r{/\d+$}, "/15") if Rails.env.test?
|
||||||
|
|
||||||
RedisConnection = ConnectionPool.new(size: 5, timeout: 5) do
|
RedisConnection = ConnectionPool.new(size: 5, timeout: 5) do
|
||||||
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6380/0"))
|
Redis.new(url: url)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ port ENV.fetch("PORT", 3000)
|
|||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
||||||
# plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] || ENV.fetch("RAILS_ENV", "development") == "development"
|
||||||
|
|
||||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||||
# In other environments, only set the PID file if requested.
|
# In other environments, only set the PID file if requested.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
default: &default
|
||||||
|
dispatchers:
|
||||||
|
- polling_interval: 1
|
||||||
|
batch_size: 500
|
||||||
|
workers:
|
||||||
|
- queues: "*"
|
||||||
|
threads: 3
|
||||||
|
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
|
||||||
|
polling_interval: 1
|
||||||
|
|
||||||
|
development:
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
test:
|
||||||
|
<<: *default
|
||||||
|
|
||||||
|
production:
|
||||||
|
<<: *default
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# examples:
|
||||||
|
# periodic_cleanup:
|
||||||
|
# class: CleanSoftDeletedRecordsJob
|
||||||
|
# queue: background
|
||||||
|
# args: [ 1000, { batch_size: 500 } ]
|
||||||
|
# schedule: every hour
|
||||||
|
# periodic_cleanup_with_command:
|
||||||
|
# command: "SoftDeletedRecord.due.delete_all"
|
||||||
|
# priority: 2
|
||||||
|
# schedule: at 5am every day
|
||||||
|
|
||||||
|
production:
|
||||||
|
matchmaking_sweep:
|
||||||
|
class: MatchmakingSweepJob
|
||||||
|
schedule: every <%= ENV.fetch("MATCHMAKING_SWEEP_INTERVAL", 2) %> seconds
|
||||||
|
clear_solid_queue_finished_jobs:
|
||||||
|
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||||
|
schedule: every hour at minute 12
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
mount ActionCable.server => "/cable"
|
||||||
|
|
||||||
namespace :api do
|
namespace :api do
|
||||||
post "register", to: "registrations#create"
|
post "register", to: "registrations#create"
|
||||||
post "login", to: "sessions#create"
|
post "login", to: "sessions#create"
|
||||||
delete "logout", to: "sessions#destroy"
|
delete "logout", to: "sessions#destroy"
|
||||||
get "me", to: "sessions#me"
|
get "me", to: "sessions#me"
|
||||||
|
|
||||||
post "matchmaking", to: "matchmaking#create"
|
# Matchmaking starts and stops over the MatchmakingChannel WebSocket
|
||||||
delete "matchmaking", to: "matchmaking#destroy"
|
# (see app/channels/matchmaking_channel.rb).
|
||||||
end
|
end
|
||||||
|
|
||||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class AddRatingToUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
# Neutral initial rating. The value lives in the DB; in the future it
|
||||||
|
# will be recalculated based on player performance.
|
||||||
|
add_column :users, :rating, :integer, null: false, default: 100
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# This file is auto-generated from the current state of the database. Instead
|
||||||
|
# of editing this file, please use the migrations feature of Active Record to
|
||||||
|
# incrementally modify your database, and then regenerate this schema definition.
|
||||||
|
#
|
||||||
|
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||||
|
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||||
|
# be faster and is potentially less error prone than running all of your
|
||||||
|
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||||
|
# migrations use external dependencies or application code.
|
||||||
|
#
|
||||||
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
|
ActiveRecord::Schema[8.1].define(version: 1) do
|
||||||
|
create_table "solid_queue_batch_executions", force: :cascade do |t|
|
||||||
|
t.bigint "batch_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.index ["batch_id"], name: "index_solid_queue_batch_executions_on_batch_id"
|
||||||
|
t.index ["job_id"], name: "index_solid_queue_batch_executions_on_job_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_batches", force: :cascade do |t|
|
||||||
|
t.string "active_job_batch_id"
|
||||||
|
t.integer "completed_jobs", default: 0, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "description"
|
||||||
|
t.datetime "enqueued_at"
|
||||||
|
t.datetime "failed_at"
|
||||||
|
t.integer "failed_jobs", default: 0, null: false
|
||||||
|
t.datetime "finished_at"
|
||||||
|
t.text "metadata"
|
||||||
|
t.text "on_failure"
|
||||||
|
t.text "on_finish"
|
||||||
|
t.text "on_success"
|
||||||
|
t.integer "total_jobs", default: 0, null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["active_job_batch_id"], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true
|
||||||
|
t.index ["finished_at"], name: "index_solid_queue_batches_on_finished_at"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_blocked_executions", force: :cascade do |t|
|
||||||
|
t.string "concurrency_key", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "expires_at", null: false
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
|
||||||
|
t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
|
||||||
|
t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_claimed_executions", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.bigint "process_id"
|
||||||
|
t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
|
||||||
|
t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_failed_executions", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.text "error"
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_jobs", force: :cascade do |t|
|
||||||
|
t.string "active_job_id"
|
||||||
|
t.text "arguments"
|
||||||
|
t.bigint "batch_id"
|
||||||
|
t.string "class_name", null: false
|
||||||
|
t.string "concurrency_key"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "finished_at"
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.datetime "scheduled_at"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
|
||||||
|
t.index ["batch_id"], name: "index_solid_queue_jobs_on_batch_id"
|
||||||
|
t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
|
||||||
|
t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
|
||||||
|
t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
|
||||||
|
t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_pauses", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_processes", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "hostname"
|
||||||
|
t.string "kind", null: false
|
||||||
|
t.datetime "last_heartbeat_at", null: false
|
||||||
|
t.text "metadata"
|
||||||
|
t.string "name", null: false
|
||||||
|
t.integer "pid", null: false
|
||||||
|
t.bigint "supervisor_id"
|
||||||
|
t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
|
||||||
|
t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
|
||||||
|
t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_ready_executions", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
|
||||||
|
t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
|
||||||
|
t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_recurring_executions", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.datetime "run_at", null: false
|
||||||
|
t.string "task_key", null: false
|
||||||
|
t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
|
||||||
|
t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
|
||||||
|
t.text "arguments"
|
||||||
|
t.string "class_name"
|
||||||
|
t.string "command", limit: 2048
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.text "description"
|
||||||
|
t.string "key", null: false
|
||||||
|
t.integer "priority", default: 0
|
||||||
|
t.string "queue_name"
|
||||||
|
t.string "schedule", null: false
|
||||||
|
t.boolean "static", default: true, null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
|
||||||
|
t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.bigint "job_id", null: false
|
||||||
|
t.integer "priority", default: 0, null: false
|
||||||
|
t.string "queue_name", null: false
|
||||||
|
t.datetime "scheduled_at", null: false
|
||||||
|
t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
|
||||||
|
t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "solid_queue_semaphores", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "expires_at", null: false
|
||||||
|
t.string "key", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "value", default: 1, null: false
|
||||||
|
t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
|
||||||
|
t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
|
||||||
|
t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||||
|
end
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_09_20_183621) do
|
ActiveRecord::Schema[8.1].define(version: 2026_10_05_000000) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -52,6 +52,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_20_183621) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "nickname", null: false
|
t.string "nickname", null: false
|
||||||
t.string "password_digest", null: false
|
t.string "password_digest", null: false
|
||||||
|
t.integer "rating", default: 100, null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["nickname"], name: "index_users_on_nickname", unique: true
|
t.index ["nickname"], name: "index_users_on_nickname", unique: true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ RSpec.describe User, type: :model do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "rating" do
|
||||||
|
it "defaults to the neutral initial value of 100" do
|
||||||
|
expect(User.create!(valid_attributes).rating).to eq(100)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires a rating" do
|
||||||
|
user = User.new(valid_attributes)
|
||||||
|
user.rating = nil
|
||||||
|
expect(user).not_to be_valid
|
||||||
|
expect(user.errors[:rating]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "nickname normalization" do
|
describe "nickname normalization" do
|
||||||
it "strips surrounding whitespace" do
|
it "strips surrounding whitespace" do
|
||||||
user = User.create!(valid_attributes.merge(nickname: " player_one "))
|
user = User.create!(valid_attributes.merge(nickname: " player_one "))
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe MatchmakingService do
|
||||||
|
# The service uses the shared RedisConnection (test env points at db 15).
|
||||||
|
def redis
|
||||||
|
RedisConnection.with { |r| return r }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unique user ids across the whole spec run (Action Cable test-adapter
|
||||||
|
# broadcasts persist between examples; example-group instance variables
|
||||||
|
# are reset per example, so a global counter is used).
|
||||||
|
def uid(base)
|
||||||
|
$matchmaking_spec_uid = ($matchmaking_spec_uid || 0) + 1
|
||||||
|
$matchmaking_spec_uid * 1000 + base
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcasts_for(user_id)
|
||||||
|
channel = MatchmakingChannel.broadcasting_for(user_id)
|
||||||
|
ActionCable.server.pubsub.broadcasts(channel)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_payloads(user_id)
|
||||||
|
broadcasts_for(user_id).map { |m| JSON.parse(m, symbolize_names: true) }
|
||||||
|
end
|
||||||
|
|
||||||
|
before { redis.flushdb }
|
||||||
|
|
||||||
|
describe ".enqueue" do
|
||||||
|
it "does not match players whose ratings are outside the tolerance" do
|
||||||
|
a, b = uid(1), uid(2)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.enqueue(b, rating: 5000)
|
||||||
|
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to contain_exactly(a.to_s, b.to_s)
|
||||||
|
expect(broadcasts_for(a)).to be_empty
|
||||||
|
expect(broadcasts_for(b)).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it "matches two compatible players immediately, nearest rating first" do
|
||||||
|
a, b, c = uid(1), uid(2), uid(3)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.enqueue(b, rating: 1400) # outside base range (100)
|
||||||
|
described_class.enqueue(c, rating: 1050) # nearest to a
|
||||||
|
|
||||||
|
match = broadcast_payloads(a).first
|
||||||
|
expect(match[:type]).to eq("matched")
|
||||||
|
expect(match[:players]).to contain_exactly(a, c)
|
||||||
|
expect(match[:match_id]).to be_present
|
||||||
|
expect(match[:game_token]).to be_present
|
||||||
|
expect(match[:game_server]).to eq(Rails.application.config.x.matchmaking.game_server_url)
|
||||||
|
|
||||||
|
expect(broadcasts_for(c)).not_to be_empty
|
||||||
|
# b stays in the queue
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to eq([ b.to_s ])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "restarts the waiting session for a user already in the queue" do
|
||||||
|
a = uid(1)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.enqueue(a, rating: 1200)
|
||||||
|
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to eq([ a.to_s ])
|
||||||
|
expect(redis.zscore(described_class::QUEUE_KEY, a.to_s)).to eq(1200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "delivers the match exactly once (broadcast consumes the pointer)" do
|
||||||
|
a, b = uid(1), uid(2)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.enqueue(b, rating: 1000)
|
||||||
|
|
||||||
|
expect(broadcasts_for(a).size).to eq(1)
|
||||||
|
expect(described_class.claim(a)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".sweep" do
|
||||||
|
it "widens the tolerance over waiting time and pairs players" do
|
||||||
|
a, b = uid(1), uid(2)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.enqueue(b, rating: 1400)
|
||||||
|
# backdate b's enqueue time (in ms, as the script stores it) so its
|
||||||
|
# tolerance has widened enough
|
||||||
|
redis.hset(described_class::SINCE_KEY, b.to_s, (10.seconds.ago.to_f * 1000).to_i)
|
||||||
|
|
||||||
|
described_class.sweep
|
||||||
|
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
|
||||||
|
expect(broadcasts_for(a)).not_to be_empty
|
||||||
|
expect(broadcasts_for(b)).not_to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it "expires players who waited longer than the queue TTL" do
|
||||||
|
a = uid(1)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
redis.hset(described_class::SINCE_KEY, a.to_s,
|
||||||
|
((described_class.config.queue_ttl + 5).seconds.ago.to_f * 1000).to_i)
|
||||||
|
|
||||||
|
described_class.sweep
|
||||||
|
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
|
||||||
|
expect(broadcast_payloads(a).first[:type]).to eq("timeout")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requeues players stuck in the pending state without a match" do
|
||||||
|
a, b = uid(1), uid(2)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.enqueue(b, rating: 1000)
|
||||||
|
|
||||||
|
# simulate a crash between pairing and finalize: back into pending
|
||||||
|
# (scores are pairing timestamps in milliseconds). The pairing step
|
||||||
|
# already removed the players from the queue; restore their ratings as
|
||||||
|
# they would exist before finalize deletes them.
|
||||||
|
redis.hset(described_class::RATING_KEY, a.to_s, 1000, b.to_s, 1000)
|
||||||
|
past = (described_class.config.pending_ttl + 5).seconds.ago.to_f * 1000
|
||||||
|
redis.zadd(described_class::PENDING_KEY, past.to_i, a.to_s)
|
||||||
|
redis.zadd(described_class::PENDING_KEY, past.to_i, b.to_s)
|
||||||
|
# pointers must be absent for the requeue to kick in
|
||||||
|
redis.del("#{described_class::USER_KEY_PREFIX}#{a}", "#{described_class::USER_KEY_PREFIX}#{b}")
|
||||||
|
|
||||||
|
described_class.sweep
|
||||||
|
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to contain_exactly(a.to_s, b.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not requeue pending players whose match was finalized" do
|
||||||
|
a, b = uid(1), uid(2)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.enqueue(b, rating: 1000)
|
||||||
|
|
||||||
|
# emulate a leftover stale pending entry for a user with a live pointer
|
||||||
|
past = (described_class.config.pending_ttl + 5).seconds.ago.to_f * 1000
|
||||||
|
redis.zadd(described_class::PENDING_KEY, past.to_i, a.to_s)
|
||||||
|
|
||||||
|
described_class.sweep
|
||||||
|
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
|
||||||
|
expect(broadcasts_for(a)).not_to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".cancel" do
|
||||||
|
it "removes the player from the queue" do
|
||||||
|
a = uid(1)
|
||||||
|
described_class.enqueue(a, rating: 1000)
|
||||||
|
described_class.cancel(a)
|
||||||
|
|
||||||
|
expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty
|
||||||
|
expect(redis.hget(described_class::SINCE_KEY, a.to_s)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".claim" do
|
||||||
|
it "returns the match payload exactly once" do
|
||||||
|
a = uid(1)
|
||||||
|
payload = { type: "matched", match_id: "m-1", players: [ a, a + 1 ] }.to_json
|
||||||
|
redis.set("#{described_class::USER_KEY_PREFIX}#{a}", payload)
|
||||||
|
|
||||||
|
first = described_class.claim(a)
|
||||||
|
expect(first[:type]).to eq("matched")
|
||||||
|
expect(described_class.claim(a)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user