Compare commits

..
5 Commits
30 changed files with 1353 additions and 162 deletions
+37 -1
View File
@@ -1,9 +1,17 @@
import { useCallback, useState } from 'react'
import CodeInput from './components/CodeInput'
import ProfileBar from './components/ProfileBar'
import SearchScreen from './components/SearchScreen'
import GameScreen from './components/GameScreen'
import { useAuth } from './auth'
export default function App() {
const { token } = useAuth()
const [screen, setScreen] = useState('menu') // 'menu' | 'search' | 'game'
const [gameSession, setGameSession] = useState(null)
const handleRandomBattle = () => {
// TODO: матчмейкинг случайного боя
setScreen('search')
}
const handleInvite = () => {
@@ -15,6 +23,34 @@ export default function App() {
console.log('join code:', code)
}
const handleMatched = useCallback((match) => {
setGameSession({
gameServerUrl: match.gameServerUrl,
gameToken: match.gameToken,
userToken: token,
})
setScreen('game')
}, [token])
const handleLeaveGame = useCallback(() => {
setGameSession(null)
setScreen('menu')
}, [])
if (screen === 'search' && token) {
return (
<SearchScreen
token={token}
onMatched={handleMatched}
onCancel={() => setScreen('menu')}
/>
)
}
if (screen === 'game' && gameSession) {
return <GameScreen session={gameSession} onLeave={handleLeaveGame} />
}
return (
<div className="page">
<main className="menu">
+4
View File
@@ -19,6 +19,10 @@ async function request(path, { method = 'GET', body, token, formData } = {}) {
return data
}
export function wsUrl(path = '') {
return `${API_URL.replace(/^http/, 'ws')}${path}`
}
export class ApiError extends Error {
constructor(message, status) {
super(message)
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react'
import { createGameConnection } from '../game'
export default function GameScreen({ session, onLeave }) {
const [status, setStatus] = useState('connecting') // 'connecting' | 'ready' | 'error'
const [error, setError] = useState(null)
const attemptRef = useRef(0)
useEffect(() => {
const attempt = ++attemptRef.current
let cancelled = false
const connection = createGameConnection(session)
connection.promise
.then(() => {
if (!cancelled && attempt === attemptRef.current) setStatus('ready')
})
.catch((err) => {
if (cancelled || attempt !== attemptRef.current) return
setError(err.message || 'Не удалось подключиться к игре')
setStatus('error')
})
return () => {
cancelled = true
connection.cancel()
}
}, [session])
return (
<div className="page">
<main className="search-screen">
{status === 'connecting' && (
<>
<div className="search-label">Подключение к игре...</div>
<div className="bouncing-dots" aria-hidden="true">
<span />
<span />
<span />
</div>
</>
)}
{status === 'ready' && <div className="search-label">Расстановка кораблей</div>}
{status === 'error' && (
<>
<div className="search-error">{error}</div>
<button className="btn btn-big btn-secondary" onClick={onLeave}>
Назад в меню
</button>
</>
)}
</main>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
import { createMatchmaking, MatchmakingError } from '../matchmaking'
const STAGE_LABELS = {
connecting: 'Подключение...',
searching: 'Подбор соперника...',
}
const ERROR_LABELS = {
timeout: 'Соперник не найден. Попробуйте ещё раз.',
connection: 'Сетевая ошибка. Проверьте подключение и попробуйте снова.',
rejected: 'Не удалось начать поиск. Попробуйте позже.',
}
export default function SearchScreen({ token, onMatched, onCancel }) {
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
const [error, setError] = useState(null)
const [retryToken, setRetryToken] = useState(0)
const attemptRef = useRef(0)
useEffect(() => {
const attempt = ++attemptRef.current
let cancelled = false
let matchmaking = null
setStage('connecting')
setError(null)
matchmaking = createMatchmaking(token, (status) => {
if (!cancelled && attempt === attemptRef.current) setStage(status)
})
matchmaking.promise.then(onMatched).catch((err) => {
if (cancelled || attempt !== attemptRef.current) return
if (err instanceof MatchmakingError) {
setError(ERROR_LABELS[err.code] || ERROR_LABELS.connection)
} else {
setError(ERROR_LABELS.connection)
}
})
return () => {
cancelled = true
matchmaking.cancel()
}
}, [token, onMatched, retryToken])
const handleCancel = () => {
onCancel()
}
return (
<div className="page">
<main className="search-screen">
{error ? (
<>
<div className="search-error">{error}</div>
<div className="search-actions">
<button className="btn btn-big" onClick={() => setRetryToken((n) => n + 1)}>
Ещё раз
</button>
<button className="btn btn-big btn-secondary" onClick={handleCancel}>
Назад
</button>
</div>
</>
) : (
<>
<div className="search-label">{STAGE_LABELS[stage]}</div>
<div className="bouncing-dots" aria-hidden="true">
<span />
<span />
<span />
</div>
<button className="btn btn-big btn-secondary" onClick={handleCancel}>
Отмена
</button>
</>
)}
</main>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
export class GameConnectionError extends Error {
constructor(code, message) {
super(message)
this.code = code // 'connection' | 'join_timeout' | 'closed'
}
}
// Опкоды протокола game-server
const OP_JOIN = 1
const OP_END = 2
const OP_STATE = 3
const OP_ACK = 4
const CLOSE_REASONS = {
4000: 'Соединение заменено новым',
4001: 'Ожидалось сообщение JOIN',
4002: 'Некорректное сообщение',
4003: 'Недействительный игровой токен',
4004: 'Вы не участник этой партии',
}
/**
* Подключается к game-server и отправляет JOIN. Резолвится, когда партия
* началась (получен STATE с кодом 1 — расстановка кораблей). Возвращает
* объект с promise и функцией отмены `cancel()`.
*
* @param {{ gameServerUrl: string, gameToken: string, userToken: string }} session
*/
export function createGameConnection({ gameServerUrl, gameToken, userToken }) {
let ws = null
let settled = false
let lastSeq = 0
const promise = new Promise((resolve, reject) => {
const fail = (err) => {
if (settled) return
settled = true
cleanup()
reject(err)
}
const succeed = (connection) => {
if (settled) return
settled = true
resolve(connection)
// Сокет НЕ закрываем — соединение переиспользуется для игры
ws.onerror = null
ws.onclose = (event) => {
if (connection.onClose) connection.onClose(event.code)
}
}
ws = new WebSocket(gameServerUrl)
ws.onopen = () => {
ws.send(JSON.stringify([OP_JOIN, gameToken, userToken]))
}
ws.onmessage = (event) => {
let msg
try {
msg = JSON.parse(event.data)
} catch {
return
}
if (!Array.isArray(msg) || typeof msg[0] !== 'number') return
const [opcode, seq] = msg
if (typeof seq === 'number' && seq > lastSeq) {
lastSeq = seq
ack(seq)
}
if (opcode === OP_STATE) {
const state = msg[2]
if (state === 1) {
succeed({ socket: ws, send: sendMessage })
}
} else if (opcode === OP_END) {
const code = msg[2]
const reason = msg[3] || 'Партия завершена'
fail(new GameConnectionError(code === -1 ? 'join_timeout' : 'closed', reason))
}
}
ws.onerror = () => {
if (!settled) fail(new GameConnectionError('connection', 'Сетевая ошибка'))
}
ws.onclose = (event) => {
if (!settled) {
const message =
CLOSE_REASONS[event.code] ||
(event.code === 1000 ? 'Соединение закрыто' : 'Соединение прервано')
fail(new GameConnectionError('closed', message))
}
}
})
function sendMessage(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload))
}
}
function ack(seq) {
sendMessage([OP_ACK, seq])
}
function cleanup() {
if (ws) {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null
ws.close()
ws = null
}
}
function cancel() {
if (settled) return
settled = true
cleanup()
}
return { promise, cancel }
}
+136
View File
@@ -0,0 +1,136 @@
import { wsUrl } from './api'
const CHANNEL_IDENTIFIER = JSON.stringify({ channel: 'MatchmakingChannel' })
export class MatchmakingError extends Error {
constructor(code, message) {
super(message)
this.code = code // 'connection' | 'timeout' | 'rejected'
}
}
/**
* Подключается к main-server по WebSocket (Action Cable) и инициирует
* поиск соперника. Возвращает объект с promise (резолвится при событии
* `matched`) и функцией отмены `cancel()`.
*
* @param {string} token JWT пользователя
* @param {(status: 'connecting' | 'searching') => void} onStatus
*/
export function createMatchmaking(token, onStatus) {
let ws = null
let settled = false
const send = (payload) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload))
}
}
const promise = new Promise((resolve, reject) => {
const fail = (err) => {
if (settled) return
settled = true
cleanup()
reject(err)
}
const succeed = (match) => {
if (settled) return
settled = true
cleanup()
resolve(match)
}
onStatus('connecting')
ws = new WebSocket(wsUrl(`/cable?token=${encodeURIComponent(token)}`))
let subscribed = false
ws.onopen = () => {
send({ command: 'subscribe', identifier: CHANNEL_IDENTIFIER })
}
ws.onmessage = (event) => {
let msg
try {
msg = JSON.parse(event.data)
} catch {
return
}
if (msg.type === 'confirm_subscription') {
subscribed = true
onStatus('searching')
send({
command: 'message',
identifier: CHANNEL_IDENTIFIER,
data: JSON.stringify({ action: 'start' }),
})
return
}
if (msg.type === 'reject_subscription') {
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
return
}
if (msg.type === 'ping' || msg.type === 'welcome') return
const message = msg.message
if (!message || typeof message !== 'object') return
if (message.type === 'matched') {
succeed({
matchId: message.match_id,
gameToken: message.game_token,
gameServerUrl: message.game_server,
players: message.players,
})
} else if (message.type === 'timeout') {
fail(new MatchmakingError('timeout', 'Соперник не найден'))
}
}
ws.onerror = () => {
if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка'))
}
ws.onclose = (event) => {
if (!settled) {
const err = new MatchmakingError('connection', 'Соединение прервано')
err.closeCode = event.code
fail(err)
}
}
// Подписка не подтверждена — защита от «зависшего» соединения
setTimeout(() => {
if (!settled && !subscribed) {
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
}
}, 10000)
})
function cleanup() {
if (ws) {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null
ws.close()
ws = null
}
}
/** Явная отмена поиска: команда `stop` + закрытие сокета. */
function cancel() {
if (settled) return
settled = true
send({
command: 'message',
identifier: CHANNEL_IDENTIFIER,
data: JSON.stringify({ action: 'stop' }),
})
cleanup()
}
return { promise, cancel }
}
+80
View File
@@ -204,7 +204,87 @@ input {
cursor: default;
}
/* Экран поиска соперника */
.search-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
padding: 40px 20px 80px;
}
.search-label {
font-family: 'Comfortaa';
font-size: clamp(24px, 4vw, 36px);
font-weight: 700;
color: var(--text);
letter-spacing: 0.5px;
}
.search-error {
font-family: 'Comfortaa';
font-size: clamp(20px, 3.5vw, 28px);
font-weight: 700;
color: var(--danger);
text-align: center;
max-width: min(520px, 90vw);
}
.search-actions {
display: flex;
flex-direction: column;
gap: 14px;
width: min(320px, 90vw);
}
.btn-secondary {
color: var(--primary);
background: #fff;
border: 2px solid var(--primary-light);
box-shadow: 0 4px 0 rgba(29, 107, 216, 0.15);
}
.btn-secondary:hover:not(:disabled) {
background: var(--primary-soft);
}
/* Три прыгающие точки */
.bouncing-dots {
display: flex;
gap: 14px;
}
.bouncing-dots span {
width: 18px;
height: 18px;
border-radius: 50%;
background: linear-gradient(180deg, var(--primary), var(--primary-dark));
animation: dot-bounce 1s infinite ease-in-out;
}
.bouncing-dots span:nth-child(2) {
animation-delay: 0.15s;
}
.bouncing-dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes dot-bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.5;
}
40% {
transform: translateY(-16px);
opacity: 1;
}
}
/* Профиль внизу */
.profile-bar {
display: flex;
justify-content: center;
+38 -48
View File
@@ -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
curl -X POST http://localhost:3000/api/matchmaking \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
--max-time 35
1. Подключиться к `/cable?token=...` и подписаться на канал `MatchmakingChannel`.
2. Отправить команду начала поиска:
```json
{ "action": "start" }
```
**Успешный ответ (200 OK)** — когда найден соперник:
Рейтинг для подбора берётся из базы данных (`users.rating`). У новых пользователей он инициализируется нейтральным значением `100` (значение задано в БД); в будущем рейтинг будет пересчитываться по результатам матчей. Игроки подбираются по близости рейтинга; допуск расширяется с ростом времени ожидания.
3. Дождаться одного из сообщений:
**Матч найден:**
```json
{
"type": "matched",
"match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
"game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...",
"game_server": "ws://game-server.example.com:8080",
"game_server": "ws://localhost:8080",
"players": [1, 2]
}
```
@@ -243,48 +249,25 @@ curl -X POST http://localhost:3000/api/matchmaking \
| `game_server` | WebSocket-адрес игрового сервера |
| `players` | ID обоих игроков `[player1_id, player2_id]` |
**Таймаут (408 Request Timeout)** — если соперник не найден за 30 секунд:
**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд):
```json
{
"error": "No opponent found. Try again."
}
{ "type": "timeout" }
```
**Ошибка (401 Unauthorized):**
4. Поиск отменяется автоматически при закрытии WebSocket-подписки. Также можно явно отправить:
```json
{
"error": "Unauthorized"
}
{ "action": "stop" }
```
**Примечание:** `game_token` содержит `match_id`, `player1_id`, `player2_id`, `game_server` и действителен **1 час**.
**Примечание:** `game_token` содержит `match_id`, `player1_id`, `player2_id`, `game_server` и действителен `MATCHMAKING_TOKEN_TTL` секунд.
---
### Отмена поиска
Убирает игрока из очереди матчмейкинга.
**URL:** `DELETE /api/matchmaking`
**Auth:** требуется
**Пример запроса:**
```bash
curl -X DELETE http://localhost:3000/api/matchmaking \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
```
**Успешный ответ (200 OK):**
```json
{
"message": "Matchmaking cancelled"
}
```
Отмена поиска происходит через WebSocket: достаточно отключиться от `MatchmakingChannel` либо отправить `{ "action": "stop" }`.
---
@@ -311,7 +294,6 @@ curl -X DELETE http://localhost:3000/api/matchmaking \
| `200` | Успешный запрос |
| `201` | Ресурс создан (регистрация) |
| `401` | Неавторизован (отсутствует/невалиден токен) |
| `408` | Таймаут матчмейкинга (соперник не найден) |
| `422` | Ошибка валидации |
| `500` | Внутренняя ошибка сервера |
@@ -321,8 +303,16 @@ curl -X DELETE http://localhost:3000/api/matchmaking \
| Переменная | По умолчанию | Описание |
|------------|--------------|----------|
| `REDIS_URL` | `redis://localhost:6379/0` | Подключение к Redis |
| `GAME_SERVER_URL` | `ws://game-server.example.com:8080` | Адрес игрового сервера для матчмейкинга |
| `REDIS_URL` | `redis://localhost:6380/0` | Подключение к Redis (матчмейкинг и pub/sub Action Cable) |
| `GAME_SERVER_URL` | `ws://localhost:8080` | Адрес игрового сервера для матчмейкинга |
| `MATCHMAKING_QUEUE_TTL` | `60` | Время ожидания соперника до таймаута, сек |
| `MATCHMAKING_PENDING_TTL` | `15` | Время ожидания финализации пары, сек |
| `MATCHMAKING_MATCH_TTL` | `120` | Время жизни данных матча, сек |
| `MATCHMAKING_TOKEN_TTL` | `120` | Время жизни `game_token`, сек |
| `MATCHMAKING_BASE_RANGE` | `100` | Начальный допуск по рейтингу |
| `MATCHMAKING_RANGE_PER_SECOND` | `50` | Рост допуска за секунду ожидания |
| `MATCHMAKING_MAX_RANGE` | `1000` | Максимальный допуск по рейтингу |
| `MATCHMAKING_SWEEP_INTERVAL` | `2` | Периодичность фоновой сверки очереди, сек |
| `CORS_ORIGINS` | `*` | Разрешённые origins для CORS (через запятую) |
| `SECRET_KEY_BASE` | из `credentials.yml.enc` | Секретный ключ для JWT |
@@ -341,8 +331,8 @@ TOKEN=$(curl -s -X POST http://localhost:3000/api/register \
curl -s http://localhost:3000/api/me \
-H "Authorization: Bearer $TOKEN"
# 3. Поиск матча (long-polling)
curl -s -X POST http://localhost:3000/api/matchmaking \
-H "Authorization: Bearer $TOKEN" \
--max-time 35 | jq .
# 3. Поиск матча — WebSocket (например, через wscat):
# wscat -c "ws://localhost:3000/cable?token=$TOKEN"
# > {"command":"subscribe","identifier":"{\"channel\":\"MatchmakingChannel\"}"}
# > {"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,34 @@
# 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
# NB: must mirror MatchmakingService.broadcast, which uses
# broadcast_to(user_id) → "matchmaking:<id>". stream_for(current_user)
# would use the GlobalID-based name and never receive those broadcasts.
stream_from "matchmaking:#{current_user.id}"
# 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
def create
result = MatchmakingService.find_match(current_user.id)
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
# Explicit cancellation. Normally the search is cancelled automatically
# when the MatchmakingChannel subscription is closed.
def destroy
MatchmakingService.cancel(current_user.id)
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
+1
View File
@@ -7,6 +7,7 @@ class User < ApplicationRecord
normalizes :nickname, with: ->(n) { n.strip }
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? }
validate :avatar_content_type, if: -> { avatar.attached? }
+244 -80
View File
@@ -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
QUEUE_KEY = "matchmaking:queue"
LOCK_KEY = "matchmaking:lock"
MATCH_PREFIX = "matchmaking:match:"
MATCH_TTL = 60 # seconds
POLL_INTERVAL = 0.5 # seconds
MAX_WAIT = 30 # seconds
QUEUE_KEY = "mm:queue"
SINCE_KEY = "mm:since"
RATING_KEY = "mm:rating"
PENDING_KEY = "mm:pending"
MATCH_KEY_PREFIX = "mm:match:"
USER_KEY_PREFIX = "mm:user:"
# Game server to redirect players to (hardcoded for now)
GAME_SERVER = ENV.fetch("GAME_SERVER_URL", "ws://game-server.example.com:8080")
# Enqueues a player and tries to find an opponent immediately; in "sweep"
# 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]
class << self
# Main entry point: waits for an opponent and returns match data
def find_match(user_id)
user_id = user_id.to_i
-- 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])
# Try to pair with an existing waiting player
opponent_id = pop_opponent(user_id)
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
if opponent_id
create_match(user_id, opponent_id)
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
# Add self to queue and wait
add_to_queue(user_id)
wait_for_match(user_id)
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
# Cancel matchmaking (remove from queue)
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
# Adds the user to the queue and immediately tries to pair them.
# The result (match or timeout) is delivered asynchronously via
# MatchmakingChannel broadcasts.
def enqueue(user_id, rating:)
results = call_pair_script("enqueue", extra: [ user_id, rating ])
process_results(results)
end
# 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)
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
# 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
# Atomically pop an opponent from the queue (excluding self)
def pop_opponent(user_id)
def call_pair_script(mode, extra: [])
RedisConnection.with do |redis|
redis.watch(QUEUE_KEY) do
queue = redis.lrange(QUEUE_KEY, 0, -1)
opponent = queue.find { |id| id.to_i != user_id }
if opponent
result = redis.multi do |tx|
tx.lrem(QUEUE_KEY, 0, opponent)
end
return opponent.to_i if result&.first&.positive?
else
redis.unwatch
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)
redis.eval(PAIR_SCRIPT,
keys: [ QUEUE_KEY, SINCE_KEY, RATING_KEY, PENDING_KEY, USER_KEY_PREFIX ],
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 ])
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}")
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
broadcast(id1.to_i, { type: "timeout" })
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)
match_id = SecureRandom.uuid
game_token = JwtService.encode(
match_id: match_id,
player1_id: player1_id,
player2_id: player2_id,
game_server: GAME_SERVER,
exp: 2.minute.from_now.to_i
game_server: config.game_server_url,
exp: config.token_ttl.seconds.from_now.to_i
)
match_data = {
payload = {
type: "matched",
match_id: match_id,
game_token: game_token,
game_server: GAME_SERVER,
game_server: config.game_server_url,
players: [ player1_id, player2_id ]
}
}.to_json
# Store match data for both players
RedisConnection.with do |redis|
redis.set("#{MATCH_PREFIX}match:#{match_id}", match_data.to_json, ex: MATCH_TTL)
redis.set("#{MATCH_PREFIX}#{player1_id}", match_id, ex: MATCH_TTL)
redis.set("#{MATCH_PREFIX}#{player2_id}", match_id, ex: MATCH_TTL)
redis.eval(FINALIZE_SCRIPT,
keys: [ match_key(match_id), user_key(player1_id), user_key(player2_id),
PENDING_KEY, RATING_KEY, SINCE_KEY ],
argv: [ payload, config.match_ttl, player1_id, player2_id ])
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
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
+10
View File
@@ -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
+33 -12
View File
@@ -10,15 +10,25 @@ default: &default
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
primary:
<<: *default
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
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/test.sqlite3
primary:
<<: *default
database: storage/test.sqlite3
queue:
<<: *default
database: storage/test_queue.sqlite3
migrations_paths: db/queue_migrate
# PostgreSQL for production.
#
@@ -29,11 +39,22 @@ test:
# DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT
#
production:
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
database: <%= ENV["DB_NAME"] %>
username: <%= ENV["DB_USER"] %>
password: <%= ENV["DB_PASSWORD"] %>
host: <%= ENV["DB_HOST"] %>
port: <%= ENV["DB_PORT"] || 5432 %>
primary:
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
database: <%= ENV["DB_NAME"] %>
username: <%= ENV["DB_USER"] %>
password: <%= ENV["DB_PASSWORD"] %>
host: <%= ENV["DB_HOST"] %>
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
# 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.
config.enable_reloading = true
@@ -50,7 +50,8 @@ Rails.application.configure do
config.cache_store = :memory_store
# 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.
# 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
+5 -1
View File
@@ -2,6 +2,10 @@
# Development/test: localhost:6379 (default)
# 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
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6380/0"))
Redis.new(url: url)
end
+1 -1
View File
@@ -35,7 +35,7 @@ port ENV.fetch("PORT", 3000)
plugin :tmp_restart
# 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.
# In other environments, only set the PID file if requested.
+18
View File
@@ -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
+18
View File
@@ -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
+4 -2
View File
@@ -1,12 +1,14 @@
Rails.application.routes.draw do
mount ActionCable.server => "/cable"
namespace :api do
post "register", to: "registrations#create"
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy"
get "me", to: "sessions#me"
post "matchmaking", to: "matchmaking#create"
delete "matchmaking", to: "matchmaking#destroy"
# Matchmaking starts and stops over the MatchmakingChannel WebSocket
# (see app/channels/matchmaking_channel.rb).
end
# 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
+172
View File
@@ -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
+2 -1
View File
@@ -10,7 +10,7 @@
#
# 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|
t.bigint "blob_id", 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.string "nickname", null: false
t.string "password_digest", null: false
t.integer "rating", default: 100, null: false
t.datetime "updated_at", null: false
t.index ["nickname"], name: "index_users_on_nickname", unique: true
end
+13
View File
@@ -40,6 +40,19 @@ RSpec.describe User, type: :model do
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
it "strips surrounding whitespace" do
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