/** * This file is part of the SplendidBear Websites' projects. * * Copyright (c) 2026 @ www.splendidbear.org * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import React, { useEffect, useRef } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useGame } from '@mine-contexts'; import { DESC } from '@mine-utils'; import useStepTimer from './useStepTimer'; /** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const { /** Async-safe refs */ webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, /** State setters */ setGridReady, /** Sync helpers */ syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, /** Game logic */ showOverlay, hideOverlay, applyRevealedCell, applyStep, makeGameEndIfItEnds, resignProcess, /** Current cells snapshot (for active-check in onClick) */ cells, } = useGame(); const eventSourceRef = useRef(null); const rpcUsersRef = useRef(null); const stepCacheRef = useRef([]); const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer(); const isGameRunningRef = useRef(false); const lastActivePlayerRef = useRef(null); /** REST mutations / queries */ const connectQuery = useQuery({ queryKey: ['game-connect', gameAssoc], queryFn: () => fetch('/api/game/connect/' + gameAssoc) .then(r => r.text()) .then(b64 => JSON.parse(window.atob(b64))), enabled: false, retry: false, }); const startMutation = useMutation({ mutationFn: () => fetch('/api/game/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gameAssoc }), }), }); const joinMutation = useMutation({ mutationFn: () => fetch('/api/game/join/' + gameAssoc, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }).catch(e => isEnvDev && console.error('Join error', e)), }); const stepMutation = useMutation({ mutationFn: dataPack => fetch('/api/game/step/' + gameAssoc, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(dataPack), }).then(r => r.json()), }); /** Game-start helpers (triggered by server events) */ const wInit = (revealedCells = []) => { setGridReady(true); showOverlay('We are waiting for your opponent...', gameAssoc ? (
) : ''); setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0); }; const makeGameStart = payload => { syncActivePlayer(1); syncRed(p => ({ ...p, name: payload.users.red || payload.users.redAnon || p.name, })); syncBlue(p => ({ ...p, name: payload.users.blue || payload.users.blueAnon || p.name, desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy, active: true, })); isGameRunningRef.current = true; lastActivePlayerRef.current = 1; // Blue starts startNewTurn(); resetStepTimer(); hideOverlay(); }; /** Mercure / SSE message handlers */ const wSubscribe = (payload, rpcUsers = null) => { isEnvDev && console.info((payload.user ?? 'user') + ' subscribed'); const firstUser = !rpcUsers; if (null === webPlayerRef.current) { const isBlue = payload.user === payload.users.blue || (firstUser ? '' !== payload.users.blueAnon : '' === rpcUsers.blueAnon && '' === rpcUsers.blue); syncWebPlayer(isBlue ? 'blue' : 'red'); } if ( 2 === payload.userCnt && (!connectionLostRef.current || (connectionLostRef.current && false === activePlayerRef.current && !endRef.current)) ) { makeGameStart(payload); } }; const wUnsubscribe = payload => { isEnvDev && console.info(payload.msg); showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!'); }; const wTopic = payload => { if (webPlayerRef.current !== payload.data.player) { if (null === payload.data.resign) { isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(',')); syncBombSelected(payload.data.bomb); // Detect if turn switched (other player made a move) // After their move, it's now our turn (or the opposite player's turn) if (lastActivePlayerRef.current !== activePlayerRef.current) { startNewTurn(); lastActivePlayerRef.current = activePlayerRef.current; } applyStep(payload.data); makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines); } else { resignProcess(payload.data.resign); } } }; const handleMercureMessage = payload => { if (undefined !== payload.data) { wTopic(payload); } else if (undefined === payload.msg) { wSubscribe(payload, rpcUsersRef.current); } else { wUnsubscribe(payload); } if (2 === payload.userCnt && connectionLostRef.current) { isEnvDev && console.info('Reconnection'); stepCacheRef.current.forEach(item => stepMutation.mutate(item)); stepCacheRef.current = []; syncConnLost(false); } }; const openEventSource = () => { const wrapper = document.getElementById('mine-wrapper'); const hubUrl = wrapper.dataset.mercureHubUrl; const subscriberJwt = wrapper.dataset.mercureSubscriberJwt; const url = new URL(hubUrl, window.location.origin); url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc); if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt); if (eventSourceRef.current) eventSourceRef.current.close(); const es = new EventSource(url.toString()); es.onmessage = e => handleMercureMessage(JSON.parse(e.data)); es.onopen = () => { isEnvDev && console.info('SSE opened'); if (connectionLostRef.current) { isEnvDev && console.info('SSE reconnected'); joinMutation.mutate(); } }; es.onerror = () => { isEnvDev && console.error('SSE error'); syncConnLost(true); }; eventSourceRef.current = es; }; /** Initialization */ useEffect(() => { (async () => { if (connectionLostRef.current) { openEventSource(); return; } try { if (gameInherited) { const serverData = await connectQuery.refetch().then(r => { if (r.error) throw r.error; return r.data; }); if ('undefined' === typeof serverData.users || null === serverData.users) { showOverlay('This channel does not exists!', Restart game!); console.error('This channel does not exists!'); return; } rpcUsersRef.current = serverData.users; openEventSource(); wInit(serverData.revealedCells || []); } else { await startMutation.mutateAsync(); openEventSource(); wInit(); } isEnvDev && console.info('Connection initialised — joining channel'); await joinMutation.mutateAsync(); } catch (e) { isEnvDev && console.error('Connection error', e); setTimeout(() => window.location.reload(), 500); } })(); window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc)); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** UI-facing callbacks */ const onClick = async coords => { const activeColor = activePlayerRef.current ? 'blue' : 'red'; if (activeColor !== webPlayerRef.current) return; const [r, c] = coords; if (cells[r]?.[c]?.active) return; const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current); const dataPack = { coords, player: activeColor, bomb: bombSelectedRef.current, resign: null, stepElapsed }; if (connectionLostRef.current) { stepCacheRef.current.push(dataPack); return; } try { const result = await stepMutation.mutateAsync(dataPack); applyStep(result); makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines); } catch (e) { isEnvDev && console.error('Step error', e); } }; const clickResign = () => { const color = activePlayerRef.current ? 'blue' : 'red'; const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current); stepMutation.mutate({ resign: color, stepElapsed }); resignProcess(webPlayerRef.current); }; const resign = () => { const activeColor = activePlayerRef.current ? 'blue' : 'red'; if (webPlayerRef.current !== activeColor) return; showOverlay('Are u sure u want to resign?!', ( )); }; return { onClick, resign }; }; export default useServerCommunication;