Files
ruby-sea-battle/frontend/src/matchmaking.js
T

137 lines
3.6 KiB
JavaScript

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