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 } }