343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
/**
|
|
* 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('Choose an opponent!', gameAssoc ? (
|
|
<WaitingOverlayContent
|
|
shareUrl={`${window.location.href}/${gameAssoc}`}
|
|
currentGameAssoc={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,
|
|
registered: !!payload.users.red,
|
|
avatar: payload.users.redAvatar ?? null,
|
|
}));
|
|
syncBlue(p => ({
|
|
...p,
|
|
name: payload.users.blue || payload.users.blueAnon || p.name,
|
|
registered: !!payload.users.blue,
|
|
avatar: payload.users.blueAvatar ?? null,
|
|
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 wChallenge = payload => {
|
|
const { challengerName, challengerGameAssoc } = payload;
|
|
|
|
const handleAccept = () => {
|
|
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
|
|
}).then(() => {
|
|
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
|
|
}).catch(() => {});
|
|
};
|
|
|
|
const handleDecline = () => {
|
|
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }),
|
|
}).then(() => {
|
|
showOverlay('We are waiting for your opponent...', gameAssoc ? (
|
|
<WaitingOverlayContent
|
|
shareUrl={window.location.origin + '/play/' + gameAssoc}
|
|
currentGameAssoc={gameAssoc}
|
|
/>
|
|
) : '');
|
|
}).catch(() => {});
|
|
};
|
|
|
|
showOverlay(
|
|
challengerName + ' wants to challenge you!',
|
|
<div className="resign">
|
|
<a onClick={handleAccept}>Accept</a>
|
|
<a onClick={handleDecline}>Decline</a>
|
|
</div>,
|
|
);
|
|
};
|
|
|
|
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);
|
|
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
|
|
} else {
|
|
resignProcess(payload.data.resign);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleMercureMessage = payload => {
|
|
if (undefined !== payload.type) {
|
|
if ('challenge' === payload.type) wChallenge(payload);
|
|
else if ('challenge-response' === payload.type) wChallengeResponse(payload);
|
|
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!', <a href="/play" target="_self">Restart game!</a>);
|
|
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?!', (
|
|
<div className="resign">
|
|
<a onClick={clickResign}>Yes</a>
|
|
<a onClick={hideOverlay}>No!</a>
|
|
</div>
|
|
));
|
|
};
|
|
|
|
return { onClick, resign };
|
|
};
|
|
|
|
export default useServerCommunication;
|