554 lines
18 KiB
JavaScript
554 lines
18 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 { 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 ? (
|
|
<WaitingOverlayContent
|
|
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
|
|
currentGameAssoc={gameAssoc}
|
|
opponentName={opponentName}
|
|
inviteOnly
|
|
/>
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
|
<p>Waiting for opponent to join...</p>
|
|
</div>
|
|
);
|
|
} else {
|
|
overlayTitle = 'Choose an opponent!';
|
|
overlaySubtitle = gameAssoc ? (
|
|
<WaitingOverlayContent
|
|
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
|
|
currentGameAssoc={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 (!endRef.current && (!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...',
|
|
(
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px', width: '100%' }}>
|
|
<p style={{ margin: 0 }}>Please, restart the game!</p>
|
|
<a
|
|
className="game-overlay-profile"
|
|
href={redirectPath}
|
|
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
|
|
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
|
|
>
|
|
<i className={`fa ${buttonIcon}`} />
|
|
{buttonText}
|
|
</a>
|
|
</div>
|
|
),
|
|
);
|
|
};
|
|
|
|
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 ? (
|
|
<WaitingOverlayContent
|
|
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
|
|
currentGameAssoc={gameAssoc}
|
|
/>
|
|
) : '');
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
declineTimeout = setTimeout(handleDecline, 30000);
|
|
|
|
showOverlay(
|
|
challengerName + ' wants to challenge you!',
|
|
<ChallengeCountdown onAccept={handleAccept} onDecline={handleDecline} />,
|
|
);
|
|
};
|
|
|
|
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 && !endRef.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!', <a href="/play" target="_self">Restart game!</a>);
|
|
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 {
|
|
const startResponse = await startMutation.mutateAsync();
|
|
if (!startResponse?.success) {
|
|
showOverlay('Error', 'Failed to start game. Please try again.');
|
|
isEnvDev && console.error('Start game failed:', startResponse);
|
|
return;
|
|
}
|
|
openEventSource();
|
|
wInit();
|
|
}
|
|
|
|
isEnvDev && console.info('Connection initialised — joining channel');
|
|
await joinMutation.mutateAsync();
|
|
startHeartbeat();
|
|
} catch (e) {
|
|
isEnvDev && console.error('Connection error', e);
|
|
showOverlay('Error', 'Connection failed. Please try again.');
|
|
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?!', (
|
|
<div className="resign">
|
|
<a onClick={clickResign}>Yes</a>
|
|
<a onClick={hideOverlay}>No!</a>
|
|
</div>
|
|
));
|
|
};
|
|
|
|
return { onClick, resign };
|
|
};
|
|
|
|
export default useServerCommunication;
|