/**
* 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';
import { WaitingOverlayContent } from '@mine-components';
/** 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;