Private
Public Access
1
0
Files
MineSeeker/assets/js/mine-seeker/hooks/useServerCommunication.jsx

276 lines
8.6 KiB
JavaScript

import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '../contexts/GameContext';
import { DESC } from '../utils/constants';
/** 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([]);
/** HELPERS */
const correctGridSize = () => {
let $f = $('#mine-wrapper .grid');
$f.height($f.width());
$f = $('#mine-wrapper .grid .field-wrapper');
$f.height($f.width());
$('#mine-wrapper .grid .field-wrapper .field').height($f.width()).css('line-height', ($f.width() - 2) + 'px');
};
/** 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('We are waiting for your opponent...', gameAssoc ? (
<div>
<h3>Share this unique link w/ your opponent</h3>
<div className="clippy">
<input id="foo" defaultValue={`${window.location.href}/${gameAssoc}`} />
</div>
<a href={`/play/${gameAssoc}`} target="_blank">Play w/ me!</a>
</div>
) : '');
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,
}));
syncBlue(p => ({
...p,
name: payload.users.blue || payload.users.blueAnon || p.name,
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy,
active: true,
}));
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');
}
900 > $(document).width() && correctGridSize();
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 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);
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.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 dataPack = { coords, player: activeColor, bomb: bombSelectedRef.current, resign: null };
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';
stepMutation.mutate({ resign: color });
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;