Private
Public Access
1
0

new: usr: a new feature came up - the abandoned plays can be restored, if both users are registered users #7

This commit is contained in:
2026-04-19 18:04:01 +02:00
parent c79584c7d2
commit 991b114a3c
23 changed files with 910 additions and 251 deletions

View File

@@ -435,7 +435,7 @@
.profile-game { .profile-game {
display: grid; display: grid;
grid-template-columns: 26px 76px 22px 1fr 18px auto; grid-template-columns: 60px 76px 22px 1fr 18px auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 11px 16px; padding: 11px 16px;
@@ -464,17 +464,27 @@
&--draw { &--draw {
border-left-color: rgba(149, 207, 245, 0.25); 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 { .profile-game__badge {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 100%;
min-width: 0;
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
font: 800 10px 'Rajdhani', sans-serif; font: 800 10px 'Rajdhani', sans-serif;
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
gap: 4px;
.profile-game--win & { .profile-game--win & {
background: rgba(42, 158, 96, 0.18); background: rgba(42, 158, 96, 0.18);
@@ -490,12 +500,49 @@
background: rgba(149, 207, 245, 0.1); background: rgba(149, 207, 245, 0.1);
color: rgba(149, 207, 245, 0.65); 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 { .profile-game__score {
font: 700 14px 'Rajdhani', sans-serif; font: 700 14px 'Rajdhani', sans-serif;
color: #fff; color: #fff;
letter-spacing: 1px; letter-spacing: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
} }
.profile-game__vs { .profile-game__vs {
@@ -525,6 +572,9 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
.profile-charts { .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 { .bd-close {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);

View File

@@ -206,16 +206,24 @@
} }
.bsd-stat-label { .bsd-stat-label {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bsd-stat-desc { .bsd-stat-desc {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.48); color: rgba(255, 255, 255, 0.48);
line-height: 1.25; line-height: 1.25;
} overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.bsd-stat-value { .bsd-stat-value {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;

View File

@@ -21,21 +21,23 @@
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window { #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%); 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); border: 2px solid rgba(35, 111, 135, 0.4);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
font-family: 'Rajdhani', sans-serif; font-family: 'Rajdhani', sans-serif;
color: #fff; color: #fff;
width: 100%; width: 100%;
max-width: 680px; max-width: 680px;
padding: 40px; padding: 40px;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.7), 0 0 40px rgba(35, 111, 135, 0.15);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
} overflow: hidden;
max-height: 90vh;
}
@keyframes slideUp { @keyframes slideUp {
from { from {
@@ -49,12 +51,17 @@
} }
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h1 { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window h1 {
font-weight: 800; font-weight: 800;
font-size: 32px; font-size: 32px;
color: #fff; color: #fff;
margin: 0 0 50px 0; margin: 0 0 50px 0;
letter-spacing: 1px; 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 { #mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 {
font-size: 14px; font-size: 14px;
@@ -183,6 +190,10 @@
width: 100%; width: 100%;
animation: fadeInUp 0.6s ease-out 0.2s both; animation: fadeInUp 0.6s ease-out 0.2s both;
&.waiting-options--invite-only {
grid-template-columns: 1fr;
}
@media (max-width: 600px) { @media (max-width: 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 20px; gap: 20px;
@@ -259,12 +270,17 @@
} }
.waiting-option-desc { .waiting-option-desc {
font: 600 12px 'Rajdhani', sans-serif; font: 600 12px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.75); color: rgba(149, 207, 245, 0.75);
margin: 0; margin: 0;
letter-spacing: 0.4px; letter-spacing: 0.4px;
line-height: 1.4; line-height: 1.4;
} overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.waiting-divider { .waiting-divider {
display: flex; display: flex;

View File

@@ -100,16 +100,18 @@
} }
#mine-wrapper .game-wrapper .users .user-container .user-name { #mine-wrapper .game-wrapper .users .user-container .user-name {
min-height: 30px; min-height: 30px;
font-weight: normal; font-weight: normal;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 3px 0; padding: 3px 5px;
margin: 0 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 { #mine-wrapper .game-wrapper .users .user-container.user-blue .user-name {
border-top: 1px dashed #0b3776; border-top: 1px dashed #0b3776;
@@ -139,10 +141,17 @@
} }
#mine-wrapper .game-wrapper .users .user-container .user-desc { #mine-wrapper .game-wrapper .users .user-container .user-desc {
height: 65px; height: 65px;
font-size: 14px; font-size: 14px;
text-align: center; 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 { #mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc {
color: #0b3776; color: #0b3776;

View File

@@ -17,5 +17,6 @@ createRoot(wrapper).render(
<MineSeeker <MineSeeker
env={wrapper.dataset.env} env={wrapper.dataset.env}
gameId={wrapper.dataset.gameId} gameId={wrapper.dataset.gameId}
opponentName={wrapper.dataset.opponentName || ''}
/>, />,
); );

View File

@@ -78,10 +78,13 @@ export default function BattleDialog({ games }) {
const meta = RESULT_META[game.result] ?? RESULT_META.draw; const meta = RESULT_META[game.result] ?? RESULT_META.draw;
const resign = game.resign; const resign = game.resign;
const maxPoints = Math.max(game.redPoints ?? 0, game.bluePoints ?? 0);
const endReason = resign const endReason = resign
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned` ? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
: 'Points'; : 26 <= maxPoints ? 'Points' : 'Abandoned';
const shareUrl = `${window.location.origin}/battle/${game.uuid}`; 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) => { const formatDuration = (from, to) => {
if (!from || !to) return null; if (!from || !to) return null;
@@ -120,15 +123,27 @@ export default function BattleDialog({ games }) {
</h2> </h2>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button {canContinue ? (
className={`bd-share${copied ? ' bd-share--copied' : ''}`} <a
onClick={handleShare} className="bd-continue"
aria-label="Copy share link" href={playUrl}
title="Copy share link" aria-label="Continue the game"
> title="Continue the game"
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} /> >
{copied ? 'Copied!' : 'Share'} <i className="fa fa-play" />
</button> Continue
</a>
) : (
<button
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare}
aria-label="Copy share link"
title="Copy share link"
>
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'}
</button>
)}
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close"> <button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>

View File

@@ -14,7 +14,7 @@ import { GameBoard } from '@mine-components';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const MineSeeker = ({ env, gameId }) => { const MineSeeker = ({ env, gameId, opponentName = '' }) => {
const isEnvDev = 'dev' === env; const isEnvDev = 'dev' === env;
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current; const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
const gameInherited = '' !== gameId; const gameInherited = '' !== gameId;
@@ -25,6 +25,7 @@ const MineSeeker = ({ env, gameId }) => {
<GameBoard <GameBoard
gameAssoc={gameAssoc} gameAssoc={gameAssoc}
gameInherited={gameInherited} gameInherited={gameInherited}
opponentName={opponentName}
isEnvDev={isEnvDev} isEnvDev={isEnvDev}
/> />
</GameProvider> </GameProvider>

View File

@@ -12,9 +12,9 @@ import { useGame } from '@mine-contexts';
import { useServerCommunication } from '@mine-hooks'; import { useServerCommunication } from '@mine-hooks';
import GridControl from './grid/GridControl'; import GridControl from './grid/GridControl';
export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => { export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => {
const { gridReady } = useGame(); const { gridReady } = useGame();
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, isEnvDev); const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
if (!gridReady) { if (!gridReady) {
return ( return (

View File

@@ -256,7 +256,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
<div className="opd-info"> <div className="opd-info">
<span className="opd-name">{player.name}</span> <span className="opd-name">{player.name}</span>
<span className="opd-since"> <span className="opd-since">
<i className="fa fa-clock-o" /> <i className="fa fa-clock" />
{' '}Waiting {formatSince(player.since)} {' '}Waiting {formatSince(player.since)}
</span> </span>
</div> </div>

View File

@@ -9,46 +9,55 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { OnlinePlayersDialog } from '@mine-components'; import { OnlinePlayersDialog } from '@mine-components';
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => { const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const inviteHeader = inviteOnly && opponentName
? `Invite ${opponentName}`
: 'Invite a Friend';
return ( return (
<Fragment> <Fragment>
<div className="waiting-options"> <div className={`waiting-options${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
<div className="waiting-option"> <div className="waiting-option">
<div className="waiting-option-header"> <div className="waiting-option-header">
<i className="fa fa-link" /> <i className="fa fa-link" />
<span>Invite a Friend</span> <span>{inviteHeader}</span>
</div> </div>
<p className="waiting-option-desc">Share this link with your opponent</p> <p className="waiting-option-desc">Share this link with your opponent</p>
<ShareLinkBox <ShareLinkBox
url={shareUrl} url={shareUrl}
/> />
</div> </div>
<div className="waiting-divider"> {!inviteOnly && (
<span>OR</span> <Fragment>
</div> <div className="waiting-divider">
<div className="waiting-option"> <span>OR</span>
<div className="waiting-option-header"> </div>
<i className="fa fa-users" /> <div className="waiting-option">
<span>Challenge a Player</span> <div className="waiting-option-header">
</div> <i className="fa fa-users" />
<p className="waiting-option-desc">Browse online players and challenge them</p> <span>Challenge a Player</span>
<button </div>
className="browse-players-btn" <p className="waiting-option-desc">Browse online players and challenge them</p>
onClick={() => setDialogOpen(true)} <button
> className="browse-players-btn"
<i className="fa fa-search" /> onClick={() => setDialogOpen(true)}
Browse Players >
</button> <i className="fa fa-search" />
</div> Browse Players
</button>
</div>
</Fragment>
)}
</div> </div>
<OnlinePlayersDialog {!inviteOnly && (
open={dialogOpen} <OnlinePlayersDialog
onClose={() => setDialogOpen(false)} open={dialogOpen}
currentGameAssoc={currentGameAssoc} onClose={() => setDialogOpen(false)}
/> currentGameAssoc={currentGameAssoc}
/>
)}
</Fragment> </Fragment>
); );
}; };
@@ -57,10 +66,12 @@ const ShareLinkBox = ({ url }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url)
setCopied(true); .then(() => {
setTimeout(() => setCopied(false), 2500); setCopied(true);
}).catch(() => {}); setTimeout(() => setCopied(false), 2500);
})
.catch(() => null);
}; };
return ( return (

View File

@@ -257,7 +257,7 @@ export const GameProvider = ({ children }) => {
// Setters needed by useServerComm // Setters needed by useServerComm
setCells, setGridReady, setGameUuid, setCells, setGridReady, setGameUuid,
// Refs (needed by useServerComm for async-safe reads) // Refs (needed by useServerComm for async-safe reads)
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
// Sync helpers // Sync helpers
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic called by useServerComm // Game logic called by useServerComm

View File

@@ -10,24 +10,20 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import { DESC } from '@mine-utils'; import { DESC, IMAGES } from '@mine-utils';
import useStepTimer from './useStepTimer'; import useStepTimer from './useStepTimer';
import { WaitingOverlayContent } from '@mine-components'; import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
import { ChallengeCountdown } from '@mine-components'; const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const { const {
/** Async-safe refs */ /** Async-safe refs */
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
/** State setters */ /** State setters */
setGridReady, setGameUuid, setCells, setGridReady, setGameUuid,
/** Sync helpers */ /** Sync helpers */
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
/** Game logic */ /** Game logic */
showOverlay, hideOverlay, showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
/** Current cells snapshot (for active-check in onClick) */ /** Current cells snapshot (for active-check in onClick) */
cells, cells,
} = useGame(); } = useGame();
@@ -35,9 +31,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const eventSourceRef = useRef(null); const eventSourceRef = useRef(null);
const rpcUsersRef = useRef(null); const rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]); const stepCacheRef = useRef([]);
const lastStepRef = useRef(null);
const isGameFinishedRef = useRef(false);
const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer(); const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer();
const isGameRunningRef = useRef(false); const isGameRunningRef = useRef(false);
const lastActivePlayerRef = useRef(null); const lastActivePlayerRef = useRef(null);
const heartbeatPubIntervalRef = useRef(null);
const opponentLastSeenRef = useRef(0);
const isTrueRestoredRef = useRef(false);
const HEARTBEAT_INTERVAL_MS = 1500;
/** REST mutations / queries */ /** REST mutations / queries */
@@ -75,43 +78,193 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
/** Game-start helpers (triggered by server events) */ /** Game-start helpers (triggered by server events) */
const wInit = (revealedCells = []) => { const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
setGridReady(true); /** Detect if this is a restored game */
showOverlay('Choose an opponent!', gameAssoc ? ( const isRestoredGame = 0 < revealedCells.length;
<WaitingOverlayContent isTrueRestoredRef.current = isRestoredGame;
shareUrl={`${window.location.href}/${gameAssoc}`}
currentGameAssoc={gameAssoc} /** Store game finished status */
/> isGameFinishedRef.current = isGameFinished;
) : '');
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0); /** Apply game state (points, bonus) immediately for restored games */
if (0 < Object.keys(gameState).length) {
const {
redPoints = 0,
bluePoints = 0,
redBonusPoints = 0,
blueBonusPoints = 0,
redBonusStats = {},
blueBonusStats = {},
} = gameState;
syncRed(p => ({
...p,
mines: redPoints,
bonusPoints: redBonusPoints,
bonusStats: redBonusStats,
}));
syncBlue(p => ({
...p,
mines: bluePoints,
bonusPoints: blueBonusPoints,
bonusStats: blueBonusStats,
}));
}
/** Apply revealed cells immediately (not in setTimeout) */
if (0 < revealedCells.length) {
setCells(prev => {
let next = prev.map(r => [...r]);
revealedCells.forEach(({ row, col, value, player }) => {
if (next[row][col].active) return;
/** Check if this cell is the last step for either player */
const isRedLastStep = lastStep.red && lastStep.red.player === player && lastStep.red.row === row && lastStep.red.col === col;
const isBlueLastStep = lastStep.blue && lastStep.blue.player === player && lastStep.blue.row === row && lastStep.blue.col === col;
const patch = 'm' === value
? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true }
: { currentImage: value, currentObj: value, active: true };
if (isRedLastStep || isBlueLastStep) {
patch.lastClickedRed = 'red' === player;
patch.lastClickedBlue = 'blue' === player;
}
next[row][col] = { ...next[row][col], ...patch };
});
return next;
});
}
/** Update the lastClickedRef so applyStep knows about it */
if (lastStep.red) {
lastClickedRef.current = {
...lastClickedRef.current,
red: [lastStep.red.row, lastStep.red.col],
};
}
if (lastStep.blue) {
lastClickedRef.current = {
...lastClickedRef.current,
blue: [lastStep.blue.row, lastStep.blue.col],
};
}
/** Determine overlay message */
let overlayTitle, overlaySubtitle;
if (isGameFinished) {
/** Game is finished - show game over message */
const redPoints = gameState.redPoints ?? 0;
const bluePoints = gameState.bluePoints ?? 0;
const winner = redPoints > bluePoints ? 'Red' : 'Blue';
overlayTitle = `${winner} wins the game!`;
overlaySubtitle = 'Play again!';
/** Mark the game as ended */
endRef.current = true;
} else if (isRestoredGame) {
overlayTitle = 'Waiting for opponent to reconnect...';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
opponentName={opponentName}
inviteOnly
/>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
<p>Waiting for opponent to join...</p>
</div>
);
} else {
overlayTitle = 'Choose an opponent!';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/battle/${gameAssoc}`}
currentGameAssoc={gameAssoc}
/>
) : '';
}
showOverlay(overlayTitle, overlaySubtitle);
/** Use Promise.resolve to defer setGridReady slightly to ensure overlay is rendered first */
Promise.resolve().then(() => setGridReady(true));
}; };
const makeGameStart = payload => { const makeGameStart = (payload, lastStep = {}) => {
syncActivePlayer(1); /** 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 => ({ syncRed(p => ({
...p, ...p,
name: payload.users.red || payload.users.redAnon || p.name, name: payload.users.red || payload.users.redAnon || p.name,
registered: !!payload.users.red, registered: !!payload.users.red,
avatar: payload.users.redAvatar ?? null, avatar: payload.users.redAvatar ?? null,
desc: 'red' === starterColor ? starterDesc : '',
active: 'red' === starterColor,
})); }));
syncBlue(p => ({ syncBlue(p => ({
...p, ...p,
name: payload.users.blue || payload.users.blueAnon || p.name, name: payload.users.blue || payload.users.blueAnon || p.name,
registered: !!payload.users.blue, registered: !!payload.users.blue,
avatar: payload.users.blueAvatar ?? null, avatar: payload.users.blueAvatar ?? null,
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy, desc: 'blue' === starterColor ? starterDesc : '',
active: true, active: 'blue' === starterColor,
})); }));
isGameRunningRef.current = true; isGameRunningRef.current = true;
lastActivePlayerRef.current = 1; // Blue starts lastActivePlayerRef.current = starterVal;
startNewTurn(); startNewTurn();
resetStepTimer(); 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 */ /** Mercure / SSE message handlers */
const wSubscribe = (payload, rpcUsers = null) => { const wSubscribe = (payload, rpcUsers = null, lastStep = null) => {
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed'); isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
const firstUser = !rpcUsers; const firstUser = !rpcUsers;
@@ -126,7 +279,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
&& (!connectionLostRef.current && (!connectionLostRef.current
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.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 }), body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
}).then(() => { }).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...'); showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => {}); }).catch(() => {
});
}; };
const handleDecline = () => { const handleDecline = () => {
@@ -163,7 +317,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
currentGameAssoc={gameAssoc} currentGameAssoc={gameAssoc}
/> />
) : ''); ) : '');
}).catch(() => {}); }).catch(() => {
});
}; };
declineTimeout = setTimeout(handleDecline, 30000); declineTimeout = setTimeout(handleDecline, 30000);
@@ -188,8 +343,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(',')); isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
syncBombSelected(payload.data.bomb); 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) { if (lastActivePlayerRef.current !== activePlayerRef.current) {
startNewTurn(); startNewTurn();
lastActivePlayerRef.current = activePlayerRef.current; lastActivePlayerRef.current = activePlayerRef.current;
@@ -210,13 +367,23 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
if (undefined !== payload.type) { if (undefined !== payload.type) {
if ('challenge' === payload.type) wChallenge(payload); if ('challenge' === payload.type) wChallenge(payload);
else if ('challenge-response' === payload.type) wChallengeResponse(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; return;
} }
if (undefined !== payload.data) { if (undefined !== payload.data) {
wTopic(payload); wTopic(payload);
} else if (undefined === payload.msg) { } else if (undefined === payload.msg) {
wSubscribe(payload, rpcUsersRef.current); wSubscribe(payload, rpcUsersRef.current, lastStepRef.current);
} else { } else {
wUnsubscribe(payload); wUnsubscribe(payload);
} }
@@ -236,8 +403,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
const url = new URL(hubUrl, window.location.origin); const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc); url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (eventSourceRef.current) eventSourceRef.current.close(); if (eventSourceRef.current) eventSourceRef.current.close();
const es = new EventSource(url.toString()); const es = new EventSource(url.toString());
@@ -278,8 +445,22 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
} }
rpcUsersRef.current = serverData.users; 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(); openEventSource();
wInit(serverData.revealedCells || []);
} else { } else {
await startMutation.mutateAsync(); await startMutation.mutateAsync();
openEventSource(); openEventSource();
@@ -288,6 +469,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
isEnvDev && console.info('Connection initialised — joining channel'); isEnvDev && console.info('Connection initialised — joining channel');
await joinMutation.mutateAsync(); await joinMutation.mutateAsync();
startHeartbeat();
} catch (e) { } catch (e) {
isEnvDev && console.error('Connection error', e); isEnvDev && console.error('Connection error', e);
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
@@ -295,6 +477,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
})(); })();
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc)); window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
return () => {
stopHeartbeat();
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -338,7 +524,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
resignProcess(webPlayerRef.current, result.uuid); resignProcess(webPlayerRef.current, result.uuid);
} }
}, },
} },
); );
}; };

View File

@@ -12,16 +12,15 @@ namespace App\Controller;
use App\Entity\ContactMessage; use App\Entity\ContactMessage;
use App\Form\ContactFormType; use App\Form\ContactFormType;
use App\Service\Email\SendContactMailService;
use App\Service\MercureJwtService;
use App\Service\ResolveUserNamesService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -40,14 +39,12 @@ class GameController extends AbstractController
{ {
public function __construct( public function __construct(
#[Autowire(env: 'APP_ENV')] #[Autowire(env: 'APP_ENV')]
private readonly string $env, private readonly string $env,
#[Autowire(env: 'MERCURE_PUBLIC_URL')] #[Autowire(env: 'MERCURE_PUBLIC_URL')]
private readonly string $mercurePublicUrl, private readonly string $mercurePublicUrl,
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')] private readonly MercureJwtService $mercureJwtService,
private readonly string $mercureSubscriberJwt, private readonly ResolveUserNamesService $opponentNameService,
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] private readonly SendContactMailService $contactMailService,
private readonly string $appContactMailAddress,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -59,12 +56,15 @@ class GameController extends AbstractController
#[Route('/play', name: 'MineSeekerBundle_gamePlay')] #[Route('/play', name: 'MineSeekerBundle_gamePlay')]
#[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')] #[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')]
public function play(): Response public function play(?string $gameAssoc = null): Response
{ {
return $this->render('Game/play.html.twig', [ return $this->render('Game/play.html.twig', [
'env' => $this->env, 'env' => $this->env,
'mercure_hub_url' => $this->mercurePublicUrl, '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()) { if ($form->isSubmitted() && $form->isValid()) {
$contactMessage->setIpAddress($request->getClientIp()); $contactMessage->setIpAddress($request->getClientIp());
$em->persist($contactMessage); $em->persist($contactMessage);
$em->flush(); $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.'); $this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
return $this->redirectToRoute('MineSeekerBundle_contact'); return $this->redirectToRoute('MineSeekerBundle_contact');
@@ -116,31 +118,4 @@ class GameController extends AbstractController
{ {
return $this->render('Official/rules.html.twig'); 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());
}
}
} }

View File

@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Service\ResolveUserNamesService;
use App\Util\RpcManager; use App\Util\RpcManager;
use App\Util\TopicManager; use App\Util\TopicManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -39,8 +40,9 @@ use Symfony\Component\Routing\Attribute\Route;
class MercureController extends AbstractController class MercureController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly TopicManager $topicManager, private readonly TopicManager $topicManager,
private readonly RpcManager $rpcManager, 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'])] #[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])]
public function connect(string $gameAssoc): Response public function connect(string $gameAssoc): Response
{ {
$payload = $this->rpcManager->getConnectInformation($gameAssoc); try {
$payload = $this->rpcManager->getConnectInformation($gameAssoc);
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']); 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'])] #[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]); 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'])] #[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])]
public function step(string $gameAssoc, Request $request): JsonResponse 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); return $this->json($result);
} }
#[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])] #[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]); return $this->json(['success' => true]);
} }
@@ -95,7 +100,11 @@ class MercureController extends AbstractController
return $this->json(['success' => true]); 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 public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse
{ {
$data = $request->toArray(); $data = $request->toArray();
@@ -106,6 +115,19 @@ class MercureController extends AbstractController
return $this->json(['success' => true]); 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'])] #[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
public function waiting(PlayedGameRepository $repo): JsonResponse public function waiting(PlayedGameRepository $repo): JsonResponse
{ {
@@ -113,10 +135,10 @@ class MercureController extends AbstractController
$result = array_map(static function (PlayedGame $g): array { $result = array_map(static function (PlayedGame $g): array {
$name = match (true) { $name = match (true) {
null !== $g->getRed() => $g->getRed()->getUsername(), null !== $g->getRed() => $g->getRed()->getUsername(),
null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(), null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(),
null !== $g->getBlue() => $g->getBlue()->getUsername(), null !== $g->getBlue() => $g->getBlue()->getUsername(),
default => $g->getBlueAnon()?->getUserName() ?? 'Unknown', default => $g->getBlueAnon()?->getUserName() ?? 'Unknown',
}; };
return [ return [
@@ -128,20 +150,4 @@ class MercureController extends AbstractController
return $this->json($result); 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;
}
} }

View File

@@ -10,9 +10,6 @@
namespace App\Interfaces; namespace App\Interfaces;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* Interface TopicManagerInterface * Interface TopicManagerInterface
* *
@@ -25,7 +22,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
*/ */
interface TopicManagerInterface 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; public function unSubscribe(string $gameAssoc, string $userName): void;

View File

@@ -10,9 +10,12 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\PlayedGame;
use App\Entity\Step; use App\Entity\Step;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use RuntimeException;
/** /**
* Class StepRepository * Class StepRepository
@@ -35,4 +38,47 @@ class StepRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, Step::class); 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,
);
}
}
} }

View File

@@ -0,0 +1,67 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service\Email;
use App\Entity\ContactMessage;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
/**
* Class SendContactMailService
*
* @package App\Service\Email
* @author Lang <https://www.splendidbear.org>
* @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());
}
}
}

View File

@@ -0,0 +1,53 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service;
use Firebase\JWT\JWT;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Class MercureJwtService
*
* Mints Mercure subscriber JWTs carrying an identifying payload so the hub's
* /subscriptions endpoint can report which known player is connected.
*
* @package App\Service
* @author Lang <https://www.splendidbear.org>
* @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'
);
}
}

View File

@@ -0,0 +1,91 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service;
use App\Entity\PlayedGame;
use App\Repository\PlayedGameRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Class ResolveUserNamesService
*
* This only works when a restored game is started
*
* @package App\Service
* @author Lang <https://www.splendidbear.org>
* @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 '';
}
}

View File

@@ -13,8 +13,10 @@ namespace App\Util;
use App\Entity\Grid; use App\Entity\Grid;
use App\Entity\GridRow; use App\Entity\GridRow;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use App\Entity\Step;
use App\Interfaces\RpcManagerInterface; use App\Interfaces\RpcManagerInterface;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Repository\StepRepository;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Exception; use Exception;
@@ -36,14 +38,15 @@ use Symfony\Component\Uid\Uuid;
*/ */
class RpcManager implements RpcManagerInterface class RpcManager implements RpcManagerInterface
{ {
private const int ROWS = 16; private const int ROWS = 16;
private const int COLS = 16; private const int COLS = 16;
private const int MINES = 51; private const int MINES = 51;
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly PlayedGameRepository $playedGameRepository, private readonly PlayedGameRepository $playedGameRepository,
private readonly StepRepository $stepRepository,
) { ) {
} }
@@ -56,8 +59,17 @@ class RpcManager implements RpcManagerInterface
if (null === $playedGame) { if (null === $playedGame) {
try { try {
return base64_encode(json_encode([ return base64_encode(json_encode([
'users' => null, 'users' => null,
'revealedCells' => 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)); ], JSON_THROW_ON_ERROR));
} catch (JsonException $e) { } catch (JsonException $e) {
throw new RuntimeException($e->getMessage()); throw new RuntimeException($e->getMessage());
@@ -68,15 +80,42 @@ class RpcManager implements RpcManagerInterface
$revealedCells = $this->aggregateRevealedCells($playedGame); $revealedCells = $this->aggregateRevealedCells($playedGame);
try { try {
$redPoints = $playedGame->getRedPoints() ?? 0;
$bluePoints = $playedGame->getBluePoints() ?? 0;
$gameFinished = $redPoints > 25 || $bluePoints > 25;
return base64_encode(json_encode([ return base64_encode(json_encode([
'users' => $users, 'users' => $users,
'revealedCells' => $revealedCells, '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)); ], JSON_THROW_ON_ERROR));
} catch (JsonException $e) { } catch (JsonException $e) {
throw new RuntimeException($e->getMessage()); 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 public function saveGrid(string $gameAssoc): bool
{ {
$existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc); $existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
@@ -94,20 +133,20 @@ class RpcManager implements RpcManagerInterface
$gridRow = new GridRow(); $gridRow = new GridRow();
$gridRow->setGridCol($row); $gridRow->setGridCol($row);
$gridRow->setGrid($grid); $gridRow->setGrid($grid);
$this->entityManager->persist($gridRow); $this->em->persist($gridRow);
} }
$grid->setPlayedGame($playedGame); $grid->setPlayedGame($playedGame);
$this->entityManager->persist($grid); $this->em->persist($grid);
$playedGame->setGameAssoc($gameAssoc); $playedGame->setGameAssoc($gameAssoc);
$playedGame->setUuid(Uuid::fromString($gameAssoc)); $playedGame->setUuid(Uuid::fromString($gameAssoc));
$playedGame->setGrid($grid); $playedGame->setGrid($grid);
$playedGame->setCreated(new DateTime()); $playedGame->setCreated(new DateTime());
$playedGame->setUpdated(new DateTime()); $playedGame->setUpdated(new DateTime());
$this->entityManager->persist($playedGame); $this->em->persist($playedGame);
$this->entityManager->flush(); $this->em->flush();
} catch (Exception $e) { } catch (Exception $e) {
$this->logger->error($e->getMessage()); $this->logger->error($e->getMessage());
} }
@@ -128,6 +167,7 @@ class RpcManager implements RpcManagerInterface
/** /**
* Fisher-Yates shuffle * Fisher-Yates shuffle
*
* @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle * @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
*/ */
for ($i = count($set) - 1; $i > 0; $i--) { for ($i = count($set) - 1; $i > 0; $i--) {
@@ -185,6 +225,37 @@ class RpcManager implements RpcManagerInterface
return $all; 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 private function getUserCollection(PlayedGame $playedGame): array
{ {
return [ return [

View File

@@ -26,10 +26,9 @@ use JsonException;
use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException; use RuntimeException;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* Class TopicManager * Class TopicManager
@@ -50,12 +49,14 @@ readonly class TopicManager implements TopicManagerInterface
private CacheManager $cacheManager, private CacheManager $cacheManager,
private PlayedGameRepository $playedGameRepository, private PlayedGameRepository $playedGameRepository,
private UserRepository $userRepository, 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); $playedGame = $this->getPlayedGame($gameAssoc);
if (null === $playedGame) { if (null === $playedGame) {
return; return;
} }
@@ -71,7 +72,7 @@ readonly class TopicManager implements TopicManagerInterface
/** Save the player to the database on a fresh join */ /** Save the player to the database on a fresh join */
if (!$isKnown && $count < 2) { if (!$isKnown && $count < 2) {
$users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1, $request); $users = $this->saveUserToDb($gameAssoc, $userName, $count + 1);
$count = $this->getPlayerCount($users); $count = $this->getPlayerCount($users);
} }
@@ -96,6 +97,7 @@ readonly class TopicManager implements TopicManagerInterface
if ($count === 1) { if ($count === 1) {
// One player waiting — mark as active and announce to the lobby // One player waiting — mark as active and announce to the lobby
$playedGame->setUpdated(new DateTime()); $playedGame->setUpdated(new DateTime());
$this->em->persist($playedGame); $this->em->persist($playedGame);
$this->em->flush(); $this->em->flush();
@@ -634,18 +636,13 @@ readonly class TopicManager implements TopicManagerInterface
} }
} }
private function saveUserToDb( private function saveUserToDb(string $gameAssoc, string $userName, int $count): array
string $gameAssoc, {
string $userName,
?UserInterface $user,
int $count,
Request $request
): array {
$playedGame = $this->getPlayedGame($gameAssoc); $playedGame = $this->getPlayedGame($gameAssoc);
null !== $user null !== $this->requestStack->getCurrentRequest()->getUser()
? $this->saveRegisteredUser($userName, $count, $playedGame) ? $this->saveRegisteredUser($userName, $count, $playedGame)
: $this->saveAnonUser($userName, $count, $playedGame, $request); : $this->saveAnonUser($userName, $count, $playedGame);
$this->em->persist($playedGame); $this->em->persist($playedGame);
$this->em->flush(); $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 { try {
$anon = new Gamer(); $anon = new Gamer();
$anon->setUserName($userName); $anon->setUserName($userName);
$anon->setIp($request->getClientIp()); $anon->setIp($this->requestStack->getCurrentRequest()->getClientIp());
$anon->setCountry($this->extractCountry($request)); $anon->setCountry($this->extractCountry());
$anon->setUserAgent($request->headers->get('User-Agent')); $anon->setUserAgent($this->requestStack->getCurrentRequest()->headers->get('User-Agent'));
$anon->setConnTimestamp(new DateTime()); $anon->setConnTimestamp(new DateTime());
$this->em->persist($anon); $this->em->persist($anon);
if ($count === 1) { if ($count === 1) {
@@ -719,6 +717,7 @@ readonly class TopicManager implements TopicManagerInterface
{ {
$challengerGame = $this->getPlayedGame($challengerGameAssoc); $challengerGame = $this->getPlayedGame($challengerGameAssoc);
$challengerName = 'Unknown'; $challengerName = 'Unknown';
if (null !== $challengerGame) { if (null !== $challengerGame) {
$users = $this->getUserCollection($challengerGame); $users = $this->getUserCollection($challengerGame);
$challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown'; $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 private function publishToLobby(array $data): void
{ {
try { 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 */ /** Common headers used by CDNs and proxies to pass country information */
$countryHeaders = [ $countryHeaders = [
@@ -777,7 +792,7 @@ readonly class TopicManager implements TopicManagerInterface
]; ];
foreach ($countryHeaders as $header) { foreach ($countryHeaders as $header) {
$country = $request->headers->get($header); $country = $this->requestStack->getCurrentRequest()->headers->get($header);
if (empty($country)) { if (empty($country)) {
continue; continue;

View File

@@ -10,6 +10,7 @@
<div id="mine-wrapper" <div id="mine-wrapper"
data-env="{{ env }}" data-env="{{ env }}"
data-game-id="{{ app.request.get('gameAssoc') }}" data-game-id="{{ app.request.get('gameAssoc') }}"
data-opponent-name="{{ opponent_name }}"
data-mercure-hub-url="{{ mercure_hub_url }}" data-mercure-hub-url="{{ mercure_hub_url }}"
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}" data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
data-recaptcha-site-key="{{ recaptcha_site_key }}"> data-recaptcha-site-key="{{ recaptcha_site_key }}">

View File

@@ -128,23 +128,37 @@
{% set opp = is_red ? game.blue : game.red %} {% set opp = is_red ? game.blue : game.red %}
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %} {% set opp_anon = is_red ? game.blueAnon : game.redAnon %}
{% set result = 'draw' %} {% set result = 'draw' %}
{% if game.resign == (is_red ? 'red' : 'blue') %} {% set is_finished = false %}
{% set result = 'loss' %} {% set is_anonymous = not opp and opp_anon %}
{% elseif game.resign == (is_red ? 'blue' : 'red') %} {% if game.resign == (is_red ? 'red' : 'blue') %}
{% set result = 'win' %} {% set result = 'loss' %}
{% elseif my_points is not null and opp_points is not null %} {% set is_finished = true %}
{% if my_points > opp_points %} {% elseif game.resign == (is_red ? 'blue' : 'red') %}
{% set result = 'win' %} {% set result = 'win' %}
{% elseif my_points < opp_points %} {% set is_finished = true %}
{% set result = 'loss' %} {% elseif my_points is not null and opp_points is not null %}
{% endif %} {% if my_points > opp_points %}
{% endif %} {% 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 %}
<div class="profile-game profile-game--{{ result }}" data-game-index="{{ loop.index0 }}"> <div class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}" data-game-index="{{ loop.index0 }}">
<span class="profile-game__badge"> <span class="profile-game__badge">
{{ result == 'win' ? 'W' : (result == 'loss' ? 'L' : 'D') }} {% if is_finished %}
</span> {{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}
{% elseif is_anonymous %}
Abandoned
{% else %}
Ongoing
{% endif %}
</span>
<span class="profile-game__score"> <span class="profile-game__score">
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }} {{ my_points ?? '—' }} : {{ opp_points ?? '—' }}
</span> </span>