/** * 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 { useGame } from '@mine-contexts'; import { DESC, IMAGES } from '@mine-utils'; import useStepTimer from './useStepTimer'; import useGameDataProvider from './useGameDataProvider'; import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components'; const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => { const { /** Async-safe refs */ webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef, /** State setters */ setCells, setGridReady, setGameUuid, /** Sync helpers */ syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, /** Game logic */ showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess, /** Current cells snapshot (for active-check in onClick) */ cells, } = useGame(); /** Get all API queries and mutations from data provider */ const { connectQuery, startMutation, joinMutation, stepMutation, heartbeatMutation, challengeRespondMutation, leaveMutation, } = useGameDataProvider(gameAssoc); const eventSourceRef = useRef(null); const rpcUsersRef = useRef(null); const stepCacheRef = useRef([]); const lastStepRef = useRef(null); const isGameFinishedRef = useRef(false); const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer(); const isGameRunningRef = useRef(false); const lastActivePlayerRef = useRef(null); const heartbeatPubIntervalRef = useRef(null); const opponentLastSeenRef = useRef(0); const isTrueRestoredRef = useRef(false); const HEARTBEAT_INTERVAL_MS = 1500; /** Game-start helpers (triggered by server events) */ const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => { /** Detect if this is a restored game */ const isRestoredGame = 0 < revealedCells.length; isTrueRestoredRef.current = isRestoredGame; /** Store game finished status */ isGameFinishedRef.current = isGameFinished; /** Apply game state (points, bonus) immediately for restored games */ if (0 < Object.keys(gameState).length) { const { redPoints = 0, bluePoints = 0, redBonusPoints = 0, blueBonusPoints = 0, redBonusStats = {}, blueBonusStats = {}, } = gameState; syncRed(p => ({ ...p, mines: redPoints, bonusPoints: redBonusPoints, bonusStats: redBonusStats, })); syncBlue(p => ({ ...p, mines: bluePoints, bonusPoints: blueBonusPoints, bonusStats: blueBonusStats, })); } /** Apply revealed cells immediately (not in setTimeout) */ if (0 < revealedCells.length) { setCells(prev => { let next = prev.map(r => [...r]); revealedCells.forEach(({ row, col, value, player }) => { if (next[row][col].active) return; /** Check if this cell is the last step for either player */ const isRedLastStep = lastStep.red && lastStep.red.player === player && lastStep.red.row === row && lastStep.red.col === col; const isBlueLastStep = lastStep.blue && lastStep.blue.player === player && lastStep.blue.row === row && lastStep.blue.col === col; const patch = 'm' === value ? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true } : { currentImage: value, currentObj: value, active: true }; if (isRedLastStep || isBlueLastStep) { patch.lastClickedRed = 'red' === player; patch.lastClickedBlue = 'blue' === player; } next[row][col] = { ...next[row][col], ...patch }; }); return next; }); } /** Update the lastClickedRef so applyStep knows about it */ if (lastStep.red) { lastClickedRef.current = { ...lastClickedRef.current, red: [lastStep.red.row, lastStep.red.col], }; } if (lastStep.blue) { lastClickedRef.current = { ...lastClickedRef.current, blue: [lastStep.blue.row, lastStep.blue.col], }; } /** Determine overlay message */ let overlayTitle, overlaySubtitle; if (isGameFinished) { /** Game is finished - show game over message */ const redPoints = gameState.redPoints ?? 0; const bluePoints = gameState.bluePoints ?? 0; const winner = redPoints > bluePoints ? 'Red' : 'Blue'; overlayTitle = `${winner} wins the game!`; overlaySubtitle = 'Play again!'; /** Mark the game as ended */ endRef.current = true; } else if (isRestoredGame) { overlayTitle = 'Waiting for opponent to reconnect...'; overlaySubtitle = gameAssoc ? ( ) : (

Waiting for opponent to join...

); } else { overlayTitle = 'Choose an opponent!'; overlaySubtitle = gameAssoc ? ( ) : ''; } showOverlay(overlayTitle, overlaySubtitle); /** Use Promise.resolve to defer setGridReady slightly to ensure overlay is rendered first */ Promise.resolve().then(() => setGridReady(true)); }; const makeGameStart = payload => { /** Don't start a finished game */ if (isGameFinishedRef.current) { return; } /** If game is being restored and has a most recent step, determine starter based on that */ let starterIsBlue; /** lastStepRef contains the single most recent step from the server */ if (lastStepRef.current && lastStepRef.current.player) { /** The NEXT player is opposite of who made the last step */ starterIsBlue = 'red' === lastStepRef.current.player; // If red played last, blue plays next } else { /** New game: blue always starts */ starterIsBlue = true; } const starterColor = starterIsBlue ? 'blue' : 'red'; const starterVal = starterIsBlue ? 1 : 0; const starterDesc = starterColor === webPlayerRef.current ? DESC.you : DESC.buddy; syncActivePlayer(starterVal); syncRed(p => ({ ...p, name: payload.users.red || payload.users.redAnon || p.name, registered: !!payload.users.red, avatar: payload.users.redAvatar ?? null, desc: 'red' === starterColor ? starterDesc : '', active: 'red' === starterColor, })); syncBlue(p => ({ ...p, name: payload.users.blue || payload.users.blueAnon || p.name, registered: !!payload.users.blue, avatar: payload.users.blueAvatar ?? null, desc: 'blue' === starterColor ? starterDesc : '', active: 'blue' === starterColor, })); isGameRunningRef.current = true; lastActivePlayerRef.current = starterVal; startNewTurn(); resetStepTimer(); /** * For a truly restored game, keep the "Waiting for opponent..." overlay * up until we actually see a heartbeat from the other player. */ if (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current) { hideOverlay(); } }; const publishHeartbeat = () => { const me = webPlayerRef.current; if (!me || endRef.current) return; heartbeatMutation.mutate(me); }; const startHeartbeat = () => { if (heartbeatPubIntervalRef.current) return; publishHeartbeat(); heartbeatPubIntervalRef.current = setInterval(publishHeartbeat, HEARTBEAT_INTERVAL_MS); }; const stopHeartbeat = () => { if (heartbeatPubIntervalRef.current) { clearInterval(heartbeatPubIntervalRef.current); heartbeatPubIntervalRef.current = null; } }; /** 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); const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated; const redirectPath = isAuthenticated ? '/profile' : '/'; const buttonText = isAuthenticated ? 'My Profile' : 'Homepage'; const buttonIcon = isAuthenticated ? 'fa-user' : 'fa-house'; showOverlay( 'The connection has been lost w/ your friend...', (

Please, restart the game!

{buttonText}
), ); }; const wChallenge = payload => { const { challengerName, challengerGameAssoc } = payload; let declineTimeout = null; const handleAccept = () => { clearTimeout(declineTimeout); challengeRespondMutation.mutate( { challengerGameAssoc, accepted: true, targetGameAssoc: gameAssoc }, { onSuccess: () => { showOverlay('Challenge accepted!', 'Waiting for the challenger to join...'); }, } ); }; const handleDecline = () => { clearTimeout(declineTimeout); challengeRespondMutation.mutate( { challengerGameAssoc, accepted: false, targetGameAssoc: gameAssoc }, { onSuccess: () => { showOverlay('We are waiting for your opponent...', gameAssoc ? ( ) : ''); }, } ); }; declineTimeout = setTimeout(handleDecline, 30000); showOverlay( challengerName + ' wants to challenge you!', , ); }; const wChallengeResponse = payload => { if (payload.accepted) { window.location.href = '/play/' + payload.targetGameAssoc; } else { window.dispatchEvent(new CustomEvent('challenge-declined')); } }; 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); if (payload.data.uuid && !endRef.current) { setGameUuid(payload.data.uuid); } makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines); } else { resignProcess(payload.data.resign, payload.data.uuid); } } }; const handleMercureMessage = payload => { if (undefined !== payload.type) { if ('challenge' === payload.type) wChallenge(payload); else if ('challenge-response' === payload.type) wChallengeResponse(payload); else if ('heartbeat' === payload.type) { const me = webPlayerRef.current; if (me && payload.color && payload.color !== me) { const wasFirst = 0 === opponentLastSeenRef.current; opponentLastSeenRef.current = Date.now(); if (wasFirst && isTrueRestoredRef.current) { hideOverlay(); } } } return; } 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; lastStepRef.current = serverData.mostRecentStep || null; /** Pass game state (points, bonus) to wInit */ const gameState = { redPoints: serverData.redPoints ?? 0, bluePoints: serverData.bluePoints ?? 0, redBonusPoints: serverData.redBonusPoints ?? 0, blueBonusPoints: serverData.blueBonusPoints ?? 0, redBonusStats: serverData.redBonusStats ?? {}, blueBonusStats: serverData.blueBonusStats ?? {}, }; const isGameFinished = serverData.gameFinished ?? false; wInit(serverData.revealedCells || [], serverData.lastStep || {}, gameState, isGameFinished); /** Open event source after showing overlay */ openEventSource(); } else { await startMutation.mutateAsync(); openEventSource(); wInit(); } isEnvDev && console.info('Connection initialised — joining channel'); await joinMutation.mutateAsync(); startHeartbeat(); } catch (e) { isEnvDev && console.error('Connection error', e); setTimeout(() => window.location.reload(), 500); } })(); window.addEventListener('pagehide', () => { leaveMutation.mutate(); }); return () => { stopHeartbeat(); }; // 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); if (result.uuid && !endRef.current) { setGameUuid(result.uuid); } 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 }, { onSuccess: result => { if (result?.uuid && !endRef.current) { resignProcess(webPlayerRef.current, result.uuid); } }, }, ); }; 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;