diff --git a/assets/css/homepage/_profile.scss b/assets/css/homepage/_profile.scss index 3a0c8ee..9adbea1 100644 --- a/assets/css/homepage/_profile.scss +++ b/assets/css/homepage/_profile.scss @@ -435,7 +435,7 @@ .profile-game { display: grid; - grid-template-columns: 26px 76px 22px 1fr 18px auto; + grid-template-columns: 60px 76px 22px 1fr 18px auto; align-items: center; gap: 10px; padding: 11px 16px; @@ -464,17 +464,27 @@ &--draw { border-left-color: rgba(149, 207, 245, 0.25); } + + &--ongoing { + border-left-color: rgba(255, 193, 7, 0.4); + opacity: 0.85; + } } .profile-game__badge { display: flex; align-items: center; justify-content: center; - width: 20px; + width: 100%; + min-width: 0; height: 20px; border-radius: 4px; font: 800 10px 'Rajdhani', sans-serif; letter-spacing: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + gap: 4px; .profile-game--win & { background: rgba(42, 158, 96, 0.18); @@ -490,12 +500,49 @@ background: rgba(149, 207, 245, 0.1); color: rgba(149, 207, 245, 0.65); } + + .profile-game--ongoing & { + background: rgba(255, 193, 7, 0.12); + color: #ffc107; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + + &::before { + content: ''; + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid transparent; + border-top-color: #ffc107; + border-right-color: #ffc107; + border-radius: 50%; + animation: spin 1s linear infinite; + flex-shrink: 0; + } + } + + .profile-game--abandoned & { + background: rgba(107, 114, 126, 0.18); + color: #6b727e; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } } .profile-game__score { font: 700 14px 'Rajdhani', sans-serif; color: #fff; letter-spacing: 1px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .profile-game__vs { @@ -525,6 +572,9 @@ letter-spacing: 0.5px; text-align: right; white-space: nowrap; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .profile-charts { @@ -640,6 +690,32 @@ } } +.bd-continue { + background: linear-gradient(135deg, rgba(42, 158, 96, 0.35) 0%, rgba(94, 232, 154, 0.35) 100%); + border: 1px solid rgba(94, 232, 154, 0.6); + border-radius: 6px; + color: #5ee89a; + height: 32px; + padding: 0 14px; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font: 700 11px 'Rajdhani', sans-serif; + text-transform: uppercase; + letter-spacing: 1.5px; + text-decoration: none; + transition: all 180ms ease; + white-space: nowrap; + box-shadow: 0 0 14px rgba(94, 232, 154, 0.25); + + &:hover { + background: linear-gradient(135deg, rgba(42, 158, 96, 0.55) 0%, rgba(94, 232, 154, 0.55) 100%); + color: #fff; + box-shadow: 0 0 20px rgba(94, 232, 154, 0.45); + } +} + .bd-close { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); diff --git a/assets/css/mineseeker/_bonus-box.scss b/assets/css/mineseeker/_bonus-box.scss index ff00ce5..f4cefb4 100644 --- a/assets/css/mineseeker/_bonus-box.scss +++ b/assets/css/mineseeker/_bonus-box.scss @@ -206,16 +206,24 @@ } .bsd-stat-label { - font-size: 13px; - font-weight: 600; - color: rgba(255, 255, 255, 0.92); -} + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .bsd-stat-desc { - font-size: 11px; - color: rgba(255, 255, 255, 0.48); - line-height: 1.25; -} + font-size: 11px; + color: rgba(255, 255, 255, 0.48); + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } .bsd-stat-value { font-family: 'Courier New', monospace; diff --git a/assets/css/mineseeker/_overlay.scss b/assets/css/mineseeker/_overlay.scss index a9f1e94..9e3ca51 100644 --- a/assets/css/mineseeker/_overlay.scss +++ b/assets/css/mineseeker/_overlay.scss @@ -21,21 +21,23 @@ } #mine-wrapper .game-wrapper .game-overlay .game-overlay-window { - background: linear-gradient(135deg, rgba(7, 9, 13, 0.98) 0%, rgba(10, 20, 35, 0.98) 100%); - border: 2px solid rgba(35, 111, 135, 0.4); - backdrop-filter: blur(12px); - font-family: 'Rajdhani', sans-serif; - color: #fff; - width: 100%; - max-width: 680px; - padding: 40px; - border-radius: 16px; - box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15); - display: flex; - flex-direction: column; - gap: 0; - animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); -} + background: linear-gradient(135deg, rgba(7, 9, 13, 0.98) 0%, rgba(10, 20, 35, 0.98) 100%); + border: 2px solid rgba(35, 111, 135, 0.4); + backdrop-filter: blur(12px); + font-family: 'Rajdhani', sans-serif; + color: #fff; + width: 100%; + max-width: 680px; + padding: 40px; + border-radius: 16px; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15); + display: flex; + flex-direction: column; + gap: 0; + animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); + overflow: hidden; + max-height: 90vh; + } @keyframes slideUp { from { @@ -49,12 +51,17 @@ } #mine-wrapper .game-wrapper .game-overlay .game-overlay-window h1 { - font-weight: 800; - font-size: 32px; - color: #fff; - margin: 0 0 50px 0; - letter-spacing: 1px; -} + font-weight: 800; + font-size: 32px; + color: #fff; + margin: 0 0 50px 0; + letter-spacing: 1px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } #mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 { font-size: 14px; @@ -183,6 +190,10 @@ width: 100%; animation: fadeInUp 0.6s ease-out 0.2s both; + &.waiting-options--invite-only { + grid-template-columns: 1fr; + } + @media (max-width: 600px) { grid-template-columns: 1fr; gap: 20px; @@ -259,12 +270,17 @@ } .waiting-option-desc { - font: 600 12px 'Rajdhani', sans-serif; - color: rgba(149, 207, 245, 0.75); - margin: 0; - letter-spacing: 0.4px; - line-height: 1.4; -} + font: 600 12px 'Rajdhani', sans-serif; + color: rgba(149, 207, 245, 0.75); + margin: 0; + letter-spacing: 0.4px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + } .waiting-divider { display: flex; diff --git a/assets/css/mineseeker/_users.scss b/assets/css/mineseeker/_users.scss index e3ca6af..98db70a 100644 --- a/assets/css/mineseeker/_users.scss +++ b/assets/css/mineseeker/_users.scss @@ -100,16 +100,18 @@ } #mine-wrapper .game-wrapper .users .user-container .user-name { - min-height: 30px; - font-weight: normal; - text-align: center; - white-space: nowrap; - text-overflow: ellipsis; - padding: 3px 0; - margin: 0 5px; + min-height: 30px; + font-weight: normal; + text-align: center; + white-space: nowrap; + text-overflow: ellipsis; + padding: 3px 5px; + margin: 0; - overflow: hidden; -} + overflow: hidden; + word-break: break-word; + max-width: 100%; + } #mine-wrapper .game-wrapper .users .user-container.user-blue .user-name { border-top: 1px dashed #0b3776; @@ -139,10 +141,17 @@ } #mine-wrapper .game-wrapper .users .user-container .user-desc { - height: 65px; - font-size: 14px; - text-align: center; -} + height: 65px; + font-size: 14px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + word-break: break-word; + padding: 0 5px; + } #mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc { color: #0b3776; @@ -150,4 +159,4 @@ #mine-wrapper .game-wrapper .users .user-container.user-red .user-desc { color: #fdf612; -} \ No newline at end of file +} diff --git a/assets/js/app.jsx b/assets/js/app.jsx index cba2136..ec96a6e 100644 --- a/assets/js/app.jsx +++ b/assets/js/app.jsx @@ -17,5 +17,6 @@ createRoot(wrapper).render( , ); diff --git a/assets/js/components/BattleDialog.jsx b/assets/js/components/BattleDialog.jsx index a4f9148..f14ee7f 100644 --- a/assets/js/components/BattleDialog.jsx +++ b/assets/js/components/BattleDialog.jsx @@ -78,10 +78,13 @@ export default function BattleDialog({ games }) { const meta = RESULT_META[game.result] ?? RESULT_META.draw; const resign = game.resign; + const maxPoints = Math.max(game.redPoints ?? 0, game.bluePoints ?? 0); const endReason = resign ? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned` - : 'Points'; + : 26 <= maxPoints ? 'Points' : 'Abandoned'; const shareUrl = `${window.location.origin}/battle/${game.uuid}`; + const canContinue = !resign && 26 > maxPoints; + const playUrl = `${window.location.origin}/play/${game.uuid}`; const formatDuration = (from, to) => { if (!from || !to) return null; @@ -120,15 +123,27 @@ export default function BattleDialog({ games }) {
- + {canContinue ? ( + + + Continue + + ) : ( + + )} diff --git a/assets/js/mine-seeker/MineSeeker.jsx b/assets/js/mine-seeker/MineSeeker.jsx index 66e982d..7401ce7 100644 --- a/assets/js/mine-seeker/MineSeeker.jsx +++ b/assets/js/mine-seeker/MineSeeker.jsx @@ -14,7 +14,7 @@ import { GameBoard } from '@mine-components'; const queryClient = new QueryClient(); -const MineSeeker = ({ env, gameId }) => { +const MineSeeker = ({ env, gameId, opponentName = '' }) => { const isEnvDev = 'dev' === env; const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current; const gameInherited = '' !== gameId; @@ -25,6 +25,7 @@ const MineSeeker = ({ env, gameId }) => { diff --git a/assets/js/mine-seeker/components/GameBoard.jsx b/assets/js/mine-seeker/components/GameBoard.jsx index 216f2f7..106a64f 100644 --- a/assets/js/mine-seeker/components/GameBoard.jsx +++ b/assets/js/mine-seeker/components/GameBoard.jsx @@ -12,9 +12,9 @@ import { useGame } from '@mine-contexts'; import { useServerCommunication } from '@mine-hooks'; import GridControl from './grid/GridControl'; -export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => { +export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => { const { gridReady } = useGame(); - const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, isEnvDev); + const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev); if (!gridReady) { return ( diff --git a/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx b/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx index b93d2f9..5b79a0d 100644 --- a/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx +++ b/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx @@ -256,7 +256,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
{player.name} - + {' '}Waiting {formatSince(player.since)}
diff --git a/assets/js/mine-seeker/components/WaitingOverlayContent.jsx b/assets/js/mine-seeker/components/WaitingOverlayContent.jsx index b64bf72..879da1f 100644 --- a/assets/js/mine-seeker/components/WaitingOverlayContent.jsx +++ b/assets/js/mine-seeker/components/WaitingOverlayContent.jsx @@ -9,46 +9,55 @@ import { Fragment, useState } from 'react'; import { OnlinePlayersDialog } from '@mine-components'; -const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => { +const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => { const [dialogOpen, setDialogOpen] = useState(false); + const inviteHeader = inviteOnly && opponentName + ? `Invite ${opponentName}` + : 'Invite a Friend'; return ( -
+
- Invite a Friend + {inviteHeader}

Share this link with your opponent

-
- OR -
-
-
- - Challenge a Player -
-

Browse online players and challenge them

- -
+ {!inviteOnly && ( + +
+ OR +
+
+
+ + Challenge a Player +
+

Browse online players and challenge them

+ +
+
+ )}
- setDialogOpen(false)} - currentGameAssoc={currentGameAssoc} - /> + {!inviteOnly && ( + setDialogOpen(false)} + currentGameAssoc={currentGameAssoc} + /> + )} ); }; @@ -57,10 +66,12 @@ const ShareLinkBox = ({ url }) => { const [copied, setCopied] = useState(false); const handleCopy = () => { - navigator.clipboard.writeText(url).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2500); - }).catch(() => {}); + navigator.clipboard.writeText(url) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2500); + }) + .catch(() => null); }; return ( diff --git a/assets/js/mine-seeker/contexts/GameProvider.jsx b/assets/js/mine-seeker/contexts/GameProvider.jsx index 77a2630..37dde5a 100644 --- a/assets/js/mine-seeker/contexts/GameProvider.jsx +++ b/assets/js/mine-seeker/contexts/GameProvider.jsx @@ -257,7 +257,7 @@ export const GameProvider = ({ children }) => { // Setters needed by useServerComm setCells, setGridReady, setGameUuid, // Refs (needed by useServerComm for async-safe reads) - webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, + webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef, // Sync helpers syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, // Game logic called by useServerComm diff --git a/assets/js/mine-seeker/hooks/useServerCommunication.jsx b/assets/js/mine-seeker/hooks/useServerCommunication.jsx index f94d9f7..e7dc0ce 100644 --- a/assets/js/mine-seeker/hooks/useServerCommunication.jsx +++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx @@ -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 ? ( - - ) : ''); - 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 ? ( + + ) : ( +
+

Waiting for opponent to join...

+
+ ); + } else { + overlayTitle = 'Choose an opponent!'; + overlaySubtitle = 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); } }, - } + }, ); }; diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index 26c44d1..a21410c 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -12,16 +12,15 @@ namespace App\Controller; use App\Entity\ContactMessage; use App\Form\ContactFormType; +use App\Service\Email\SendContactMailService; +use App\Service\MercureJwtService; +use App\Service\ResolveUserNamesService; use Doctrine\ORM\EntityManagerInterface; -use Psr\Log\LoggerInterface; -use RuntimeException; -use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; -use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Routing\Attribute\Route; @@ -40,14 +39,12 @@ class GameController extends AbstractController { public function __construct( #[Autowire(env: 'APP_ENV')] - private readonly string $env, + private readonly string $env, #[Autowire(env: 'MERCURE_PUBLIC_URL')] - private readonly string $mercurePublicUrl, - #[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')] - private readonly string $mercureSubscriberJwt, - #[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] - private readonly string $appContactMailAddress, - private readonly LoggerInterface $logger, + private readonly string $mercurePublicUrl, + private readonly MercureJwtService $mercureJwtService, + private readonly ResolveUserNamesService $opponentNameService, + private readonly SendContactMailService $contactMailService, ) { } @@ -59,12 +56,15 @@ class GameController extends AbstractController #[Route('/play', name: 'MineSeekerBundle_gamePlay')] #[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')] - public function play(): Response + public function play(?string $gameAssoc = null): Response { return $this->render('Game/play.html.twig', [ 'env' => $this->env, 'mercure_hub_url' => $this->mercurePublicUrl, - 'mercure_subscriber_jwt' => $this->mercureSubscriberJwt, + 'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken( + $gameAssoc ?? '', $this->opponentNameService->resolveUserName(), + ), + 'opponent_name' => $this->opponentNameService->opponentName($gameAssoc), ]); } @@ -92,9 +92,11 @@ class GameController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $contactMessage->setIpAddress($request->getClientIp()); + $em->persist($contactMessage); $em->flush(); - $this->sendMail($mailer, $contactMessage); + + $this->contactMailService->send($contactMessage); $this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.'); return $this->redirectToRoute('MineSeekerBundle_contact'); @@ -116,31 +118,4 @@ class GameController extends AbstractController { return $this->render('Official/rules.html.twig'); } - - public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void - { - try { - $mailer->send( - new TemplatedEmail() - ->from('noreply@mineseeker.hu') - ->to($this->appContactMailAddress) - ->replyTo($contactMessage->getEmail()) - ->subject('New Contact Message from ' . $contactMessage->getName()) - ->htmlTemplate('emails/contact_notification.html.twig') - ->context(['message' => $contactMessage]) - ); - } catch (\Exception $e) { - $this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [ - 'exception' => $e, - 'message' => $contactMessage, - ]); - throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage()); - } catch (TransportExceptionInterface $e) { - $this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [ - 'exception' => $e, - 'message' => $contactMessage, - ]); - throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage()); - } - } } diff --git a/src/Controller/MercureController.php b/src/Controller/MercureController.php index c77993d..4f53c5f 100644 --- a/src/Controller/MercureController.php +++ b/src/Controller/MercureController.php @@ -12,6 +12,7 @@ namespace App\Controller; use App\Entity\PlayedGame; use App\Repository\PlayedGameRepository; +use App\Service\ResolveUserNamesService; use App\Util\RpcManager; use App\Util\TopicManager; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -39,8 +40,9 @@ use Symfony\Component\Routing\Attribute\Route; class MercureController extends AbstractController { public function __construct( - private readonly TopicManager $topicManager, - private readonly RpcManager $rpcManager, + private readonly TopicManager $topicManager, + private readonly RpcManager $rpcManager, + private readonly ResolveUserNamesService $userNamesService, ) { } @@ -56,15 +58,18 @@ class MercureController extends AbstractController #[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])] public function connect(string $gameAssoc): Response { - $payload = $this->rpcManager->getConnectInformation($gameAssoc); - - return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']); + try { + $payload = $this->rpcManager->getConnectInformation($gameAssoc); + return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']); + } catch (\Exception $e) { + return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR); + } } #[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])] - public function join(string $gameAssoc, Request $request): JsonResponse + public function join(string $gameAssoc): JsonResponse { - $this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser(), $request); + $this->topicManager->subscribe($gameAssoc, $this->userNamesService->resolveUserName()); return $this->json(['success' => true]); } @@ -72,15 +77,15 @@ class MercureController extends AbstractController #[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])] public function step(string $gameAssoc, Request $request): JsonResponse { - $result = $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray()); + $result = $this->topicManager->publish($gameAssoc, $this->userNamesService->resolveUserName(), $request->toArray()); return $this->json($result); } #[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])] - public function leave(string $gameAssoc, Request $request): JsonResponse + public function leave(string $gameAssoc): JsonResponse { - $this->topicManager->unSubscribe($gameAssoc, $this->resolveUserName($request)); + $this->topicManager->unSubscribe($gameAssoc, $this->userNamesService->resolveUserName()); return $this->json(['success' => true]); } @@ -95,7 +100,11 @@ class MercureController extends AbstractController return $this->json(['success' => true]); } - #[Route('/api/game/challenge/respond/{challengerGameAssoc}', name: 'MineSeekerBundle_api_game_challenge_respond', methods: ['POST'])] + #[Route( + '/api/game/challenge/respond/{challengerGameAssoc}', + name: 'MineSeekerBundle_api_game_challenge_respond', + methods: ['POST'], + )] public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse { $data = $request->toArray(); @@ -106,6 +115,19 @@ class MercureController extends AbstractController return $this->json(['success' => true]); } + #[Route('/api/game/heartbeat/{gameAssoc}', name: 'MineSeekerBundle_api_game_heartbeat', methods: ['POST'])] + public function heartbeat(string $gameAssoc, Request $request): JsonResponse + { + $data = $request->toArray(); + $color = $data['color'] ?? ''; + if ('red' !== $color && 'blue' !== $color) { + return $this->json(['success' => false], Response::HTTP_BAD_REQUEST); + } + $this->topicManager->publishHeartbeat($gameAssoc, $color); + + return $this->json(['success' => true]); + } + #[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])] public function waiting(PlayedGameRepository $repo): JsonResponse { @@ -113,10 +135,10 @@ class MercureController extends AbstractController $result = array_map(static function (PlayedGame $g): array { $name = match (true) { - null !== $g->getRed() => $g->getRed()->getUsername(), - null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(), - null !== $g->getBlue() => $g->getBlue()->getUsername(), - default => $g->getBlueAnon()?->getUserName() ?? 'Unknown', + null !== $g->getRed() => $g->getRed()->getUsername(), + null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(), + null !== $g->getBlue() => $g->getBlue()->getUsername(), + default => $g->getBlueAnon()?->getUserName() ?? 'Unknown', }; return [ @@ -128,20 +150,4 @@ class MercureController extends AbstractController return $this->json($result); } - - private function resolveUserName(Request $request): string - { - $user = $this->getUser(); - - if (null !== $user) { - return $user->getUserIdentifier(); - } - - $sessionId = $request->getSession()->getId(); - if (empty($sessionId)) { - $sessionId = bin2hex(random_bytes(16)); - } - - return 'anon_' . $sessionId; - } } diff --git a/src/Interfaces/TopicManagerInterface.php b/src/Interfaces/TopicManagerInterface.php index 065f382..4ea36f0 100644 --- a/src/Interfaces/TopicManagerInterface.php +++ b/src/Interfaces/TopicManagerInterface.php @@ -10,9 +10,6 @@ namespace App\Interfaces; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Security\Core\User\UserInterface; - /** * Interface TopicManagerInterface * @@ -25,7 +22,7 @@ use Symfony\Component\Security\Core\User\UserInterface; */ interface TopicManagerInterface { - public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void; + public function subscribe(string $gameAssoc, string $userName): void; public function unSubscribe(string $gameAssoc, string $userName): void; diff --git a/src/Repository/StepRepository.php b/src/Repository/StepRepository.php index 52d21fb..a0cbdd6 100644 --- a/src/Repository/StepRepository.php +++ b/src/Repository/StepRepository.php @@ -10,9 +10,12 @@ namespace App\Repository; +use App\Entity\PlayedGame; use App\Entity\Step; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\NonUniqueResultException; use Doctrine\Persistence\ManagerRegistry; +use RuntimeException; /** * Class StepRepository @@ -35,4 +38,47 @@ class StepRepository extends ServiceEntityRepository { parent::__construct($registry, Step::class); } + + public function findMostRecent(PlayedGame $playedGame): ?Step + { + try { + return $this->createQueryBuilder('s') + ->andWhere('s.playedGame = :game') + ->setParameter('game', $playedGame) + ->orderBy('s.created', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } catch (NonUniqueResultException $e) { + throw new RuntimeException( + sprintf( + 'Expected at most one result for the most recent step of game ID %d, but got multiple.', + $playedGame->getId(), + ), + 0, + $e, + ); + } + } + + public function findMostRecentForPlayer(PlayedGame $playedGame, string $player): ?Step + { + try { + return $this->createQueryBuilder('s') + ->andWhere('s.playedGame = :game') + ->andWhere('s.player = :player') + ->setParameter('game', $playedGame) + ->setParameter('player', $player) + ->orderBy('s.created', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } catch (NonUniqueResultException $e) { + throw new RuntimeException( + 'Expected at most one result for the most recent step of player "%s" in game ID %d, but got multiple.', + 0, + $e, + ); + } + } } diff --git a/src/Service/Email/SendContactMailService.php b/src/Service/Email/SendContactMailService.php new file mode 100644 index 0000000..207c9bb --- /dev/null +++ b/src/Service/Email/SendContactMailService.php @@ -0,0 +1,67 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 19. + */ +readonly final class SendContactMailService +{ + public function __construct( + #[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] + private string $appContactMailAddress, + private LoggerInterface $logger, + private MailerInterface $mailer, + ) { + } + + public function send(ContactMessage $contactMessage): void + { + try { + $this->mailer->send( + new TemplatedEmail() + ->from('noreply@mineseeker.hu') + ->to($this->appContactMailAddress) + ->replyTo($contactMessage->getEmail()) + ->subject('New Contact Message from ' . $contactMessage->getName()) + ->htmlTemplate('emails/contact_notification.html.twig') + ->context(['message' => $contactMessage]) + ); + } catch (\Exception $e) { + $this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [ + 'exception' => $e, + 'message' => $contactMessage, + ]); + throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage()); + } catch (TransportExceptionInterface $e) { + $this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [ + 'exception' => $e, + 'message' => $contactMessage, + ]); + throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage()); + } + } +} diff --git a/src/Service/MercureJwtService.php b/src/Service/MercureJwtService.php new file mode 100644 index 0000000..3c303ae --- /dev/null +++ b/src/Service/MercureJwtService.php @@ -0,0 +1,53 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 19. + */ +final readonly class MercureJwtService +{ + public function __construct( + #[Autowire(env: 'MERCURE_JWT_SECRET')] + private string $secret, + ) { + } + + public function mintSubscriberToken(string $gameAssoc, string $userName): string + { + return JWT::encode( + [ + 'mercure' => [ + 'subscribe' => ['*'], + 'payload' => [ + 'username' => $userName, + 'gameAssoc' => $gameAssoc, + ], + ], + ], + $this->secret, + 'HS256' + ); + } +} diff --git a/src/Service/ResolveUserNamesService.php b/src/Service/ResolveUserNamesService.php new file mode 100644 index 0000000..6fd999d --- /dev/null +++ b/src/Service/ResolveUserNamesService.php @@ -0,0 +1,91 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 19. + */ +readonly final class ResolveUserNamesService +{ + public function __construct( + private RequestStack $requestStack, + private Security $security, + private PlayedGameRepository $playedGameRepository, + ) { + } + + public function opponentName(?string $gameAssoc = null): string + { + $userName = $this->resolveUserName(); + + if (null === $gameAssoc) { + return ''; + } + + if (null === $game = $this->playedGameRepository->findOneByGameAssoc($gameAssoc)) { + return ''; + } + + return $this->resolveOpponentName($game, $userName); + } + + public function resolveUserName(): string + { + $user = $this->security->getUser(); + + if (null !== $user) { + return $user->getUserIdentifier(); + } + + $session = $this->requestStack->getCurrentRequest()->getSession(); + + if (!$session->isStarted()) { + $session->start(); + } + + return "anon_{$session->getId()}"; + } + + private function resolveOpponentName(PlayedGame $game, string $myUserName): string + { + $redName = $game->getRed()?->getUsername(); + $blueName = $game->getBlue()?->getUsername(); + $redAnonName = $game->getRedAnon()?->getUserName(); + $blueAnonName = $game->getBlueAnon()?->getUserName(); + + $isRed = $myUserName === $redName || $myUserName === $redAnonName; + $isBlue = $myUserName === $blueName || $myUserName === $blueAnonName; + + if ($isRed) { + return $blueName ?? ('' !== ($blueAnonName ?? '') ? 'Guest' : ''); + } + + if ($isBlue) { + return $redName ?? ('' !== ($redAnonName ?? '') ? 'Guest' : ''); + } + + return ''; + } +} diff --git a/src/Util/RpcManager.php b/src/Util/RpcManager.php index 32a24af..ae0e652 100644 --- a/src/Util/RpcManager.php +++ b/src/Util/RpcManager.php @@ -13,8 +13,10 @@ namespace App\Util; use App\Entity\Grid; use App\Entity\GridRow; use App\Entity\PlayedGame; +use App\Entity\Step; use App\Interfaces\RpcManagerInterface; use App\Repository\PlayedGameRepository; +use App\Repository\StepRepository; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Exception; @@ -36,14 +38,15 @@ use Symfony\Component\Uid\Uuid; */ class RpcManager implements RpcManagerInterface { - private const int ROWS = 16; + private const int ROWS = 16; private const int COLS = 16; private const int MINES = 51; public function __construct( - private readonly EntityManagerInterface $entityManager, + private readonly EntityManagerInterface $em, private readonly LoggerInterface $logger, private readonly PlayedGameRepository $playedGameRepository, + private readonly StepRepository $stepRepository, ) { } @@ -56,8 +59,17 @@ class RpcManager implements RpcManagerInterface if (null === $playedGame) { try { return base64_encode(json_encode([ - 'users' => null, - 'revealedCells' => null, + 'users' => null, + 'revealedCells' => null, + 'lastStep' => ['red' => null, 'blue' => null], + 'mostRecentStep' => null, + 'redPoints' => 0, + 'bluePoints' => 0, + 'redBonusPoints' => 0, + 'blueBonusPoints' => 0, + 'redBonusStats' => [], + 'blueBonusStats' => [], + 'gameFinished' => false, ], JSON_THROW_ON_ERROR)); } catch (JsonException $e) { throw new RuntimeException($e->getMessage()); @@ -68,15 +80,42 @@ class RpcManager implements RpcManagerInterface $revealedCells = $this->aggregateRevealedCells($playedGame); try { + $redPoints = $playedGame->getRedPoints() ?? 0; + $bluePoints = $playedGame->getBluePoints() ?? 0; + $gameFinished = $redPoints > 25 || $bluePoints > 25; + return base64_encode(json_encode([ - 'users' => $users, - 'revealedCells' => $revealedCells, + 'users' => $users, + 'revealedCells' => $revealedCells, + 'lastStep' => $this->getLastStepPerPlayer($playedGame), + 'mostRecentStep' => $this->getMostRecentStep($playedGame), + 'redPoints' => $redPoints, + 'bluePoints' => $bluePoints, + 'redBonusPoints' => $playedGame->getRedBonusPoints() ?? 0, + 'blueBonusPoints' => $playedGame->getBlueBonusPoints() ?? 0, + 'redBonusStats' => $playedGame->getRedBonusStats() ?? [], + 'blueBonusStats' => $playedGame->getBlueBonusStats() ?? [], + 'gameFinished' => $gameFinished, ], JSON_THROW_ON_ERROR)); } catch (JsonException $e) { throw new RuntimeException($e->getMessage()); } } + /** + * Get the most recent step of the game (if any). + * Returns an array with player, row, col information or null if no steps exist. + */ + private function getMostRecentStep(PlayedGame $playedGame): ?array + { + try { + return $this->stepToArray($this->stepRepository->findMostRecent($playedGame)); + } catch (Exception $e) { + $this->logger->error('Error getting most recent step: ' . $e->getMessage()); + return null; + } + } + public function saveGrid(string $gameAssoc): bool { $existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc); @@ -94,20 +133,20 @@ class RpcManager implements RpcManagerInterface $gridRow = new GridRow(); $gridRow->setGridCol($row); $gridRow->setGrid($grid); - $this->entityManager->persist($gridRow); + $this->em->persist($gridRow); } $grid->setPlayedGame($playedGame); - $this->entityManager->persist($grid); + $this->em->persist($grid); $playedGame->setGameAssoc($gameAssoc); $playedGame->setUuid(Uuid::fromString($gameAssoc)); $playedGame->setGrid($grid); $playedGame->setCreated(new DateTime()); $playedGame->setUpdated(new DateTime()); - $this->entityManager->persist($playedGame); + $this->em->persist($playedGame); - $this->entityManager->flush(); + $this->em->flush(); } catch (Exception $e) { $this->logger->error($e->getMessage()); } @@ -128,6 +167,7 @@ class RpcManager implements RpcManagerInterface /** * Fisher-Yates shuffle + * * @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle */ for ($i = count($set) - 1; $i > 0; $i--) { @@ -185,6 +225,37 @@ class RpcManager implements RpcManagerInterface return $all; } + /** + * Get the last step for each player. + * Returns an array with 'red' and 'blue' keys, each containing row, col information or null if no steps exist for + * that player. + */ + private function getLastStepPerPlayer(PlayedGame $playedGame): array + { + try { + return [ + 'red' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'red')), + 'blue' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'blue')), + ]; + } catch (Exception $e) { + $this->logger->error('Error getting last step per player: ' . $e->getMessage()); + return ['red' => null, 'blue' => null]; + } + } + + private function stepToArray(?Step $step): ?array + { + if (null === $step) { + return null; + } + + return [ + 'player' => $step->getPlayer(), + 'row' => (int)$step->getRow(), + 'col' => (int)$step->getCol(), + ]; + } + private function getUserCollection(PlayedGame $playedGame): array { return [ diff --git a/src/Util/TopicManager.php b/src/Util/TopicManager.php index 7f1f821..63b1f38 100644 --- a/src/Util/TopicManager.php +++ b/src/Util/TopicManager.php @@ -26,10 +26,9 @@ use JsonException; use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Psr\Log\LoggerInterface; use RuntimeException; -use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; -use Symfony\Component\Security\Core\User\UserInterface; /** * Class TopicManager @@ -50,12 +49,14 @@ readonly class TopicManager implements TopicManagerInterface private CacheManager $cacheManager, private PlayedGameRepository $playedGameRepository, private UserRepository $userRepository, + private RequestStack $requestStack, ) { } - public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void + public function subscribe(string $gameAssoc, string $userName): void { $playedGame = $this->getPlayedGame($gameAssoc); + if (null === $playedGame) { return; } @@ -71,7 +72,7 @@ readonly class TopicManager implements TopicManagerInterface /** Save the player to the database on a fresh join */ if (!$isKnown && $count < 2) { - $users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1, $request); + $users = $this->saveUserToDb($gameAssoc, $userName, $count + 1); $count = $this->getPlayerCount($users); } @@ -96,6 +97,7 @@ readonly class TopicManager implements TopicManagerInterface if ($count === 1) { // One player waiting — mark as active and announce to the lobby $playedGame->setUpdated(new DateTime()); + $this->em->persist($playedGame); $this->em->flush(); @@ -634,18 +636,13 @@ readonly class TopicManager implements TopicManagerInterface } } - private function saveUserToDb( - string $gameAssoc, - string $userName, - ?UserInterface $user, - int $count, - Request $request - ): array { + private function saveUserToDb(string $gameAssoc, string $userName, int $count): array + { $playedGame = $this->getPlayedGame($gameAssoc); - null !== $user + null !== $this->requestStack->getCurrentRequest()->getUser() ? $this->saveRegisteredUser($userName, $count, $playedGame) - : $this->saveAnonUser($userName, $count, $playedGame, $request); + : $this->saveAnonUser($userName, $count, $playedGame); $this->em->persist($playedGame); $this->em->flush(); @@ -672,15 +669,16 @@ readonly class TopicManager implements TopicManagerInterface } } - private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame, Request $request): void + private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void { try { $anon = new Gamer(); $anon->setUserName($userName); - $anon->setIp($request->getClientIp()); - $anon->setCountry($this->extractCountry($request)); - $anon->setUserAgent($request->headers->get('User-Agent')); + $anon->setIp($this->requestStack->getCurrentRequest()->getClientIp()); + $anon->setCountry($this->extractCountry()); + $anon->setUserAgent($this->requestStack->getCurrentRequest()->headers->get('User-Agent')); $anon->setConnTimestamp(new DateTime()); + $this->em->persist($anon); if ($count === 1) { @@ -719,6 +717,7 @@ readonly class TopicManager implements TopicManagerInterface { $challengerGame = $this->getPlayedGame($challengerGameAssoc); $challengerName = 'Unknown'; + if (null !== $challengerGame) { $users = $this->getUserCollection($challengerGame); $challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown'; @@ -754,6 +753,22 @@ readonly class TopicManager implements TopicManagerInterface } } + public function publishHeartbeat(string $gameAssoc, string $color): void + { + try { + $this->hub->publish(new Update( + 'mineseeker/channel/' . $gameAssoc, + json_encode([ + 'type' => 'heartbeat', + 'color' => $color, + 'ts' => (int)(microtime(true) * 1000), + ], JSON_THROW_ON_ERROR) + )); + } catch (JsonException $e) { + $this->logger->error('Heartbeat publish error: ' . $e->getMessage()); + } + } + private function publishToLobby(array $data): void { try { @@ -766,7 +781,7 @@ readonly class TopicManager implements TopicManagerInterface } } - private function extractCountry(Request $request): ?string + private function extractCountry(): ?string { /** Common headers used by CDNs and proxies to pass country information */ $countryHeaders = [ @@ -777,7 +792,7 @@ readonly class TopicManager implements TopicManagerInterface ]; foreach ($countryHeaders as $header) { - $country = $request->headers->get($header); + $country = $this->requestStack->getCurrentRequest()->headers->get($header); if (empty($country)) { continue; diff --git a/templates/Game/play.html.twig b/templates/Game/play.html.twig index 714451d..9febb4a 100644 --- a/templates/Game/play.html.twig +++ b/templates/Game/play.html.twig @@ -10,6 +10,7 @@
diff --git a/templates/Security/profile.html.twig b/templates/Security/profile.html.twig index 1f73d45..5542d20 100644 --- a/templates/Security/profile.html.twig +++ b/templates/Security/profile.html.twig @@ -128,23 +128,37 @@ {% set opp = is_red ? game.blue : game.red %} {% set opp_anon = is_red ? game.blueAnon : game.redAnon %} - {% set result = 'draw' %} - {% if game.resign == (is_red ? 'red' : 'blue') %} - {% set result = 'loss' %} - {% elseif game.resign == (is_red ? 'blue' : 'red') %} - {% set result = 'win' %} - {% elseif my_points is not null and opp_points is not null %} - {% if my_points > opp_points %} - {% set result = 'win' %} - {% elseif my_points < opp_points %} - {% set result = 'loss' %} - {% endif %} - {% endif %} + {% set result = 'draw' %} + {% set is_finished = false %} + {% set is_anonymous = not opp and opp_anon %} + {% if game.resign == (is_red ? 'red' : 'blue') %} + {% set result = 'loss' %} + {% set is_finished = true %} + {% elseif game.resign == (is_red ? 'blue' : 'red') %} + {% set result = 'win' %} + {% set is_finished = true %} + {% elseif my_points is not null and opp_points is not null %} + {% if my_points > opp_points %} + {% set result = 'win' %} + {% set is_finished = (my_points > 25 or opp_points > 25) %} + {% elseif my_points < opp_points %} + {% set result = 'loss' %} + {% set is_finished = (my_points > 25 or opp_points > 25) %} + {% else %} + {% set is_finished = (my_points > 25 or opp_points > 25) %} + {% endif %} + {% endif %} -
- - {{ result == 'win' ? 'W' : (result == 'loss' ? 'L' : 'D') }} - +
+ + {% if is_finished %} + {{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }} + {% elseif is_anonymous %} + Abandoned + {% else %} + Ongoing + {% endif %} + {{ my_points ?? '—' }} : {{ opp_points ?? '—' }}