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 ?? '—' }}