Compare commits
2
Commits
656fc44fd8
...
df2a334d7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df2a334d7c | ||
|
|
9987407187 |
+37
-1
@@ -1,9 +1,17 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
import CodeInput from './components/CodeInput'
|
import CodeInput from './components/CodeInput'
|
||||||
import ProfileBar from './components/ProfileBar'
|
import ProfileBar from './components/ProfileBar'
|
||||||
|
import SearchScreen from './components/SearchScreen'
|
||||||
|
import GameScreen from './components/GameScreen'
|
||||||
|
import { useAuth } from './auth'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [screen, setScreen] = useState('menu') // 'menu' | 'search' | 'game'
|
||||||
|
const [gameSession, setGameSession] = useState(null)
|
||||||
|
|
||||||
const handleRandomBattle = () => {
|
const handleRandomBattle = () => {
|
||||||
// TODO: матчмейкинг случайного боя
|
setScreen('search')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInvite = () => {
|
const handleInvite = () => {
|
||||||
@@ -15,6 +23,34 @@ export default function App() {
|
|||||||
console.log('join code:', code)
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<main className="menu">
|
<main className="menu">
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ async function request(path, { method = 'GET', body, token, formData } = {}) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wsUrl(path = '') {
|
||||||
|
return `${API_URL.replace(/^http/, 'ws')}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(message, status) {
|
constructor(message, status) {
|
||||||
super(message)
|
super(message)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -204,7 +204,87 @@ input {
|
|||||||
cursor: default;
|
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 {
|
.profile-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
# 5. Disconnecting (or sending { action: "stop" }) cancels the search.
|
# 5. Disconnecting (or sending { action: "stop" }) cancels the search.
|
||||||
class MatchmakingChannel < ApplicationCable::Channel
|
class MatchmakingChannel < ApplicationCable::Channel
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_for current_user
|
# 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
|
# If a match was found while this user had no subscription
|
||||||
# (e.g. brief reconnect), deliver it now — exactly once.
|
# (e.g. brief reconnect), deliver it now — exactly once.
|
||||||
|
|||||||
Reference in New Issue
Block a user