Private
Public Access
1
0

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:
2026-04-19 18:04:01 +02:00
parent c79584c7d2
commit 991b114a3c
23 changed files with 910 additions and 251 deletions

View File

@@ -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);
}
},
}
},
);
};