Private
Public Access
1
0

chg: dev: massive refactor on fetches - create centralized dataProvider #7

This commit is contained in:
2026-04-19 20:56:51 +02:00
parent 5da8a04c18
commit d9059acb78
6 changed files with 202 additions and 98 deletions

View File

@@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { useLobbyDataProvider } from '@mine-hooks';
const DIALOG_SX = { const DIALOG_SX = {
'& .MuiDialog-paper': { '& .MuiDialog-paper': {
@@ -39,7 +40,7 @@ const formatSince = isoStr => {
return `${diff} min ago`; return `${diff} min ago`;
}; };
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => { const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
const [players, setPlayers] = useState([]); const [players, setPlayers] = useState([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -49,6 +50,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [declinedMsg, setDeclinedMsg] = useState(''); const [declinedMsg, setDeclinedMsg] = useState('');
const [waitingCountdown, setWaitingCountdown] = useState(0); const [waitingCountdown, setWaitingCountdown] = useState(0);
const declinedTimerRef = useRef(null); const declinedTimerRef = useRef(null);
const { waitingPlayersQuery, challengeMutation } = useLobbyDataProvider();
const addPlayer = useCallback(entry => { const addPlayer = useCallback(entry => {
setPlayers(prev => setPlayers(prev =>
@@ -66,20 +68,21 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
if (!open) return; if (!open) return;
setLoading(true); setLoading(true);
setSnapshotLoaded(false); setSnapshotLoaded(false);
fetch('/api/game/waiting')
.then(r => r.json()) waitingPlayersQuery.refetch().then(result => {
.then(data => { if (result.data) {
// Filter out current user's game from the snapshot // Filter out current user's game from the snapshot
const filtered = data.filter(p => p.gameAssoc !== currentGameAssoc); const filtered = result.data.filter(p => p.gameAssoc !== currentGameAssoc);
setPlayers(filtered); setPlayers(filtered);
}
setSnapshotLoaded(true); setSnapshotLoaded(true);
setLoading(false); setLoading(false);
}) }).catch(() => {
.catch(() => {
setPlayers([]); setPlayers([]);
setSnapshotLoaded(true); setSnapshotLoaded(true);
setLoading(false); setLoading(false);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refreshKey, currentGameAssoc]); }, [open, refreshKey, currentGameAssoc]);
useEffect(() => { useEffect(() => {
@@ -107,6 +110,13 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
return () => es.close(); return () => es.close();
}, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]); }, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]);
useEffect(() => {
if (challengeMutation.isError) {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
}
}, [challengeMutation.isError]);
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
setChallengingGameAssoc(null); setChallengingGameAssoc(null);
@@ -138,14 +148,10 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
setChallengingGameAssoc(player.gameAssoc); setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg(''); setDeclinedMsg('');
setWaitingCountdown(30); setWaitingCountdown(30);
fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST', challengeMutation.mutate(
headers: { 'Content-Type': 'application/json' }, { targetGameAssoc: player.gameAssoc, challengerGameAssoc: currentGameAssoc }
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }), );
}).catch(() => {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
});
}; };
const visible = players const visible = players
@@ -156,8 +162,9 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const hasMore = 5 < visible.length; const hasMore = 5 < visible.length;
// Debug: log if currentGameAssoc is undefined or if current user appears // Debug: log if currentGameAssoc is undefined or if current user appears
if ('development' === process.env.NODE_ENV && 0 < players.length) { if (isEnvDev && 0 < players.length) {
const userInList = players.find(p => p.gameAssoc === currentGameAssoc); const userInList = players.find(p => p.gameAssoc === currentGameAssoc);
if (userInList) { if (userInList) {
console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList }); console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList });
} }

View File

@@ -22,7 +22,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
} = useGame(); } = useGame();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null; const shareUrl = gameAssoc ? `${window.location.origin}/play/${gameAssoc}` : null;
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated; const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const handleShare = () => { const handleShare = () => {

View File

@@ -11,4 +11,4 @@ export { default as useGameRefs } from './useGameRefs';
export { default as useGameState } from './useGameState'; export { default as useGameState } from './useGameState';
export { default as useServerCommunication } from './useServerCommunication'; export { default as useServerCommunication } from './useServerCommunication';
export { default as useStepTimer } from './useStepTimer'; export { default as useStepTimer } from './useStepTimer';
export { default as useGameDataProvider, useLobbyDataProvider } from './useGameDataProvider';

View File

@@ -0,0 +1,114 @@
/**
* 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 { useQuery, useMutation } from '@tanstack/react-query';
/**
* Game Data Provider Hook
* Centralized API communication layer for game-related queries and mutations
*/
const useGameDataProvider = gameAssoc => {
// 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,
});
// Mutations
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' },
}),
});
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()),
});
const heartbeatMutation = useMutation({
mutationFn: color => fetch(`/api/game/heartbeat/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color }),
}).then(r => r.json()),
});
const challengeRespondMutation = useMutation({
mutationFn: ({ challengerGameAssoc, accepted, targetGameAssoc }) => fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, targetGameAssoc }),
}).then(r => r.json()),
});
const leaveMutation = useMutation({
mutationFn: () => fetch(`/api/game/leave/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
});
return {
// Queries
connectQuery,
// Mutations
startMutation,
joinMutation,
stepMutation,
heartbeatMutation,
challengeRespondMutation,
leaveMutation,
};
};
/**
* Lobby Data Provider Hook
* Centralized API communication layer for lobby-related queries and mutations
*/
export const useLobbyDataProvider = () => {
const waitingPlayersQuery = useQuery({
queryKey: ['game-waiting'],
queryFn: () => fetch('/api/game/waiting')
.then(r => r.json()),
});
const challengeMutation = useMutation({
mutationFn: ({ targetGameAssoc, challengerGameAssoc }) => fetch(`/api/game/challenge/${targetGameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc }),
}).then(r => r.json()),
});
return {
// Queries
waitingPlayersQuery,
// Mutations
challengeMutation,
};
};
export default useGameDataProvider;

View File

@@ -8,10 +8,10 @@
*/ */
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import { DESC, IMAGES } from '@mine-utils'; import { DESC, IMAGES } from '@mine-utils';
import useStepTimer from './useStepTimer'; import useStepTimer from './useStepTimer';
import useGameDataProvider from './useGameDataProvider';
import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components'; import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => { const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
@@ -28,6 +28,17 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
cells, cells,
} = useGame(); } = 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 eventSourceRef = useRef(null);
const rpcUsersRef = useRef(null); const rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]); const stepCacheRef = useRef([]);
@@ -42,40 +53,6 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const HEARTBEAT_INTERVAL_MS = 1500; const HEARTBEAT_INTERVAL_MS = 1500;
/** 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) */ /** Game-start helpers (triggered by server events) */
const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => { const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
@@ -176,7 +153,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
overlayTitle = 'Choose an opponent!'; overlayTitle = 'Choose an opponent!';
overlaySubtitle = gameAssoc ? ( overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent <WaitingOverlayContent
shareUrl={`${window.location.origin}/battle/${gameAssoc}`} shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''; ) : '';
@@ -188,7 +165,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
Promise.resolve().then(() => setGridReady(true)); Promise.resolve().then(() => setGridReady(true));
}; };
const makeGameStart = (payload, lastStep = {}) => { const makeGameStart = payload => {
/** Don't start a finished game */ /** Don't start a finished game */
if (isGameFinishedRef.current) { if (isGameFinishedRef.current) {
return; return;
@@ -242,11 +219,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const publishHeartbeat = () => { const publishHeartbeat = () => {
const me = webPlayerRef.current; const me = webPlayerRef.current;
if (!me || endRef.current) return; if (!me || endRef.current) return;
fetch('/api/game/heartbeat/' + gameAssoc, { heartbeatMutation.mutate(me);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color: me }),
}).catch(e => isEnvDev && console.warn('Heartbeat publish failed', e));
}; };
const startHeartbeat = () => { const startHeartbeat = () => {
@@ -264,7 +237,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
/** Mercure / SSE message handlers */ /** Mercure / SSE message handlers */
const wSubscribe = (payload, rpcUsers = null, lastStep = null) => { const wSubscribe = (payload, rpcUsers = null) => {
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed'); isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
const firstUser = !rpcUsers; const firstUser = !rpcUsers;
@@ -279,7 +252,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
&& (!connectionLostRef.current && (!connectionLostRef.current
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current)) || (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
) { ) {
makeGameStart(payload, lastStep); makeGameStart(payload);
} }
}; };
@@ -315,31 +288,31 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const handleAccept = () => { const handleAccept = () => {
clearTimeout(declineTimeout); clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { challengeRespondMutation.mutate(
method: 'POST', { challengerGameAssoc, accepted: true, targetGameAssoc: gameAssoc },
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }), onSuccess: () => {
}).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...'); showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => { },
}); }
);
}; };
const handleDecline = () => { const handleDecline = () => {
clearTimeout(declineTimeout); clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, { challengeRespondMutation.mutate(
method: 'POST', { challengerGameAssoc, accepted: false, targetGameAssoc: gameAssoc },
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }), onSuccess: () => {
}).then(() => {
showOverlay('We are waiting for your opponent...', gameAssoc ? ( showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent <WaitingOverlayContent
shareUrl={window.location.origin + '/play/' + gameAssoc} shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''); ) : '');
}).catch(() => { },
}); }
);
}; };
declineTimeout = setTimeout(handleDecline, 30000); declineTimeout = setTimeout(handleDecline, 30000);
@@ -404,7 +377,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
if (undefined !== payload.data) { if (undefined !== payload.data) {
wTopic(payload); wTopic(payload);
} else if (undefined === payload.msg) { } else if (undefined === payload.msg) {
wSubscribe(payload, rpcUsersRef.current, lastStepRef.current); wSubscribe(payload, rpcUsersRef.current);
} else { } else {
wUnsubscribe(payload); wUnsubscribe(payload);
} }
@@ -423,7 +396,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt; const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
const url = new URL(hubUrl, window.location.origin); const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc); 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(); if (eventSourceRef.current) eventSourceRef.current.close();
@@ -452,6 +425,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
openEventSource(); openEventSource();
return; return;
} }
try { try {
if (gameInherited) { if (gameInherited) {
const serverData = await connectQuery.refetch().then(r => { const serverData = await connectQuery.refetch().then(r => {
@@ -497,7 +471,9 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
} }
})(); })();
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc)); window.addEventListener('pagehide', () => {
leaveMutation.mutate();
});
return () => { return () => {
stopHeartbeat(); stopHeartbeat();
@@ -525,9 +501,11 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
try { try {
const result = await stepMutation.mutateAsync(dataPack); const result = await stepMutation.mutateAsync(dataPack);
applyStep(result); applyStep(result);
if (result.uuid && !endRef.current) { if (result.uuid && !endRef.current) {
setGameUuid(result.uuid); setGameUuid(result.uuid);
} }
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines); makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) { } catch (e) {
isEnvDev && console.error('Step error', e); isEnvDev && console.error('Step error', e);
@@ -537,6 +515,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const clickResign = () => { const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red'; const color = activePlayerRef.current ? 'blue' : 'red';
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current); const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
stepMutation.mutate( stepMutation.mutate(
{ resign: color, stepElapsed }, { resign: color, stepElapsed },
{ {
@@ -551,7 +530,9 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const resign = () => { const resign = () => {
const activeColor = activePlayerRef.current ? 'blue' : 'red'; const activeColor = activePlayerRef.current ? 'blue' : 'red';
if (webPlayerRef.current !== activeColor) return; if (webPlayerRef.current !== activeColor) return;
showOverlay('Are u sure u want to resign?!', ( showOverlay('Are u sure u want to resign?!', (
<div className="resign"> <div className="resign">
<a onClick={clickResign}>Yes</a> <a onClick={clickResign}>Yes</a>

View File

@@ -120,9 +120,11 @@ class MercureController extends AbstractController
{ {
$data = $request->toArray(); $data = $request->toArray();
$color = $data['color'] ?? ''; $color = $data['color'] ?? '';
if ('red' !== $color && 'blue' !== $color) { if ('red' !== $color && 'blue' !== $color) {
return $this->json(['success' => false], Response::HTTP_BAD_REQUEST); return $this->json(['success' => false], Response::HTTP_BAD_REQUEST);
} }
$this->topicManager->publishHeartbeat($gameAssoc, $color); $this->topicManager->publishHeartbeat($gameAssoc, $color);
return $this->json(['success' => true]); return $this->json(['success' => true]);