new: usr: a new feature came up - the abandoned plays can be restored, if both users are registered users #7
This commit is contained in:
@@ -10,24 +10,20 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useGame } from '@mine-contexts';
|
||||
import { DESC } from '@mine-utils';
|
||||
import { DESC, IMAGES } from '@mine-utils';
|
||||
import useStepTimer from './useStepTimer';
|
||||
import { WaitingOverlayContent } from '@mine-components';
|
||||
import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
|
||||
|
||||
import { ChallengeCountdown } from '@mine-components';
|
||||
|
||||
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
|
||||
const {
|
||||
/** Async-safe refs */
|
||||
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
|
||||
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
|
||||
/** State setters */
|
||||
setGridReady, setGameUuid,
|
||||
setCells, setGridReady, setGameUuid,
|
||||
/** Sync helpers */
|
||||
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
||||
/** Game logic */
|
||||
showOverlay, hideOverlay,
|
||||
applyRevealedCell, applyStep,
|
||||
makeGameEndIfItEnds, resignProcess,
|
||||
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
|
||||
/** Current cells snapshot (for active-check in onClick) */
|
||||
cells,
|
||||
} = useGame();
|
||||
@@ -35,9 +31,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
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;
|
||||
|
||||
/** REST mutations / queries */
|
||||
|
||||
@@ -75,43 +78,193 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
|
||||
/** 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 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}/battle/${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 => {
|
||||
syncActivePlayer(1);
|
||||
const makeGameStart = (payload, lastStep = {}) => {
|
||||
/** 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' === webPlayerRef.current ? DESC.you : DESC.buddy,
|
||||
active: true,
|
||||
desc: 'blue' === starterColor ? starterDesc : '',
|
||||
active: 'blue' === starterColor,
|
||||
}));
|
||||
isGameRunningRef.current = true;
|
||||
lastActivePlayerRef.current = 1; // Blue starts
|
||||
lastActivePlayerRef.current = starterVal;
|
||||
startNewTurn();
|
||||
resetStepTimer();
|
||||
hideOverlay();
|
||||
/**
|
||||
* 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;
|
||||
fetch('/api/game/heartbeat/' + gameAssoc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ color: me }),
|
||||
}).catch(e => isEnvDev && console.warn('Heartbeat publish failed', e));
|
||||
};
|
||||
|
||||
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) => {
|
||||
const wSubscribe = (payload, rpcUsers = null, lastStep = null) => {
|
||||
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
|
||||
const firstUser = !rpcUsers;
|
||||
|
||||
@@ -126,7 +279,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
&& (!connectionLostRef.current
|
||||
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
|
||||
) {
|
||||
makeGameStart(payload);
|
||||
makeGameStart(payload, lastStep);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +300,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
|
||||
}).then(() => {
|
||||
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
|
||||
}).catch(() => {});
|
||||
}).catch(() => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
@@ -163,7 +317,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
currentGameAssoc={gameAssoc}
|
||||
/>
|
||||
) : '');
|
||||
}).catch(() => {});
|
||||
}).catch(() => {
|
||||
});
|
||||
};
|
||||
|
||||
declineTimeout = setTimeout(handleDecline, 30000);
|
||||
@@ -188,8 +343,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
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)
|
||||
/**
|
||||
* 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;
|
||||
@@ -210,13 +367,23 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
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);
|
||||
wSubscribe(payload, rpcUsersRef.current, lastStepRef.current);
|
||||
} else {
|
||||
wUnsubscribe(payload);
|
||||
}
|
||||
@@ -236,8 +403,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
const url = new URL(hubUrl, window.location.origin);
|
||||
|
||||
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
|
||||
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
|
||||
|
||||
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
|
||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||
|
||||
const es = new EventSource(url.toString());
|
||||
@@ -278,8 +445,22 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
}
|
||||
|
||||
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();
|
||||
wInit(serverData.revealedCells || []);
|
||||
} else {
|
||||
await startMutation.mutateAsync();
|
||||
openEventSource();
|
||||
@@ -288,6 +469,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
|
||||
isEnvDev && console.info('Connection initialised — joining channel');
|
||||
await joinMutation.mutateAsync();
|
||||
startHeartbeat();
|
||||
} catch (e) {
|
||||
isEnvDev && console.error('Connection error', e);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
@@ -295,6 +477,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
})();
|
||||
|
||||
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
|
||||
|
||||
return () => {
|
||||
stopHeartbeat();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -338,7 +524,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
resignProcess(webPlayerRef.current, result.uuid);
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user