Private
Public Access
1
0

Compare commits

...

6 Commits

30 changed files with 1416 additions and 520 deletions

View File

@@ -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);

View File

@@ -209,13 +209,21 @@
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;
}
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.bsd-stat-value {
font-family: 'Courier New', monospace;

View File

@@ -35,7 +35,9 @@
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 {
@@ -54,7 +56,12 @@
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;
@@ -264,7 +275,12 @@
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;
@@ -527,6 +543,19 @@
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-actions {
display: flex;
align-items: stretch;
gap: 12px;
margin-top: 20px;
width: 100%;
> * {
flex: 1 1 0;
margin-top: 0 !important;
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share {
display: flex;
align-items: center;
@@ -590,3 +619,60 @@
}
}
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-profile {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
border: 2px solid #2a9e60;
color: #d0ffe0;
font-family: 'Rajdhani', sans-serif;
font-size: 13px;
font-weight: 800;
letter-spacing: 1px;
text-transform: uppercase;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
width: 100%;
margin-top: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(42, 158, 96, 0.25);
text-decoration: none;
z-index: 10;
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.4s ease;
}
&:hover {
background: linear-gradient(to bottom, #238f5c 0%, #1a6844 100%);
border-color: #5ee89a;
color: #fff;
box-shadow: 0 8px 24px rgba(42, 158, 96, 0.4);
transform: translateY(-2px);
&::before {
left: 100%;
}
}
&:active {
transform: translateY(0);
}
i {
font-size: 15px;
}
}

View File

@@ -105,11 +105,13 @@
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
padding: 3px 0;
margin: 0 5px;
padding: 3px 5px;
margin: 0;
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;
@@ -142,7 +144,14 @@
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;

View File

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

View File

@@ -1,6 +1,9 @@
import React, { useEffect, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Avatar from './battle-dialog/Avatar';
import StatRow from './battle-dialog/StatRow';
import BonusPoints from './battle-dialog/BonusPoints';
const darkTheme = createTheme({ palette: { mode: 'dark' } });
@@ -50,124 +53,6 @@ const RESULT_META = {
},
};
function Avatar({ name, color, avatarUrl, bonusPoints = 0 }) {
const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase();
const gradient = isRed
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
const glow = isRed
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
const border = isRed
? 'rgba(173,10,5,0.5)'
: 'rgba(35,111,135,0.5)';
const textColor = isRed ? '#f67d52' : '#95cff5';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, position: 'relative' }}>
<div style={{ position: 'relative' }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
</div>
{0 < bonusPoints && (
<div style={{
position: 'absolute',
bottom: -6,
right: -6,
background: '#ffd700',
borderRadius: '50%',
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1)',
border: '2px solid rgba(0,0,0,0.5)',
zIndex: 10,
}}
>
<i className="fa fa-star" style={{ color: '#000', fontSize: 14 }} />
</div>
)}
</div>
<span style={{
font: '700 15px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 1,
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
{name}
</span>
<span style={{
font: '600 10px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: 'rgba(255,255,255,0.3)',
}}
>
{isRed ? 'Red' : 'Blue'}
</span>
</div>
);
}
function StatRow({ icon, label, value, valueColor }) {
return (
<div style={{
display: 'flex', alignItems: 'center',
gap: 10, padding: '9px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
<span style={{
font: '500 13px \'Rajdhani\', sans-serif',
color: 'rgba(255,255,255,0.45)',
flex: 1,
letterSpacing: 0.5,
}}
>
{label}
</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: valueColor || 'rgba(255,255,255,0.75)',
letterSpacing: 0.5,
}}
>
{value}
</span>
</div>
);
}
export default function BattleDialog({ games }) {
const [open, setOpen] = useState(false);
const [game, setGame] = useState(null);
@@ -193,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;
@@ -235,6 +123,17 @@ export default function BattleDialog({ games }) {
</h2>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{canContinue ? (
<a
className="bd-continue"
href={playUrl}
aria-label="Continue the game"
title="Continue the game"
>
<i className="fa fa-play" />
Continue
</a>
) : (
<button
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
onClick={handleShare}
@@ -244,13 +143,17 @@ export default function BattleDialog({ games }) {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share'}
</button>
)}
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
</div>
<div className="bd-vs-panel">
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} bonusPoints={game.redBonusPoints > game.blueBonusPoints ? game.redBonusPoints : 0} />
<Avatar
name={game.redName} color="red" avatarUrl={game.redAvatar}
bonusPoints={game.redBonusPoints > game.blueBonusPoints ? game.redBonusPoints : 0}
/>
<div className="bd-vs-center">
<div className="bd-vs-score">
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
@@ -258,11 +161,25 @@ export default function BattleDialog({ games }) {
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</div>
<div className="bd-vs-score" style={{ marginBottom: 8 }}>
<span style={{ font: '700 13px \'Rajdhani\', sans-serif', color: '#f67d52', display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#f67d52',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<i className="fa fa-star" style={{ fontSize: 11 }} /> {(game.redBonusPoints ?? 0).toFixed(1)}
</span>
<span className="bd-vs-score__sep">:</span>
<span style={{ font: '700 13px \'Rajdhani\', sans-serif', color: '#95cff5', display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#95cff5',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" style={{ fontSize: 11 }} />
</span>
</div>
@@ -274,16 +191,25 @@ export default function BattleDialog({ games }) {
<i className={`fa ${meta.icon}`} /> {meta.label}
</div>
</div>
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} bonusPoints={game.blueBonusPoints > game.redBonusPoints ? game.blueBonusPoints : 0} />
<Avatar
name={game.blueName} color="blue" avatarUrl={game.blueAvatar}
bonusPoints={game.blueBonusPoints > game.redBonusPoints ? game.blueBonusPoints : 0}
/>
</div>
<div className="bd-stats">
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
{game.created && game.date && game.created !== game.date && (
<StatRow icon="fa-clock" label="Started" value={game.created} />
)}
{duration && (
<StatRow icon="fa-hourglass-half" label="Match duration" value={duration} />
)}
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
{0 < pointDiff && (
<StatRow icon="fa-balance-scale" label="Winning margin" value={`${pointDiff} mine${1 === pointDiff ? '' : 's'}`} valueColor={winnerColor} />
<StatRow
icon="fa-balance-scale" label="Winning margin"
value={`${pointDiff} mine${1 === pointDiff ? '' : 's'}`} valueColor={winnerColor}
/>
)}
<StatRow
icon="fa-bomb" label="Red used bomb"
@@ -295,66 +221,10 @@ export default function BattleDialog({ games }) {
value={game.blueExplodedBomb ? 'Yes' : 'No'}
valueColor={game.blueExplodedBomb ? '#95cff5' : 'rgba(255,255,255,0.45)'}
/>
{game.created && game.date && game.created !== game.date && (
<StatRow icon="fa-clock-o" label="Started" value={game.created} />
)}
</div>
{(0 < game.redBonusPoints
|| 0 < game.blueBonusPoints
|| game.redBonusStats?.blindHits
|| game.blueBonusStats?.blindHits
) && (
<div style={{ padding: '16px 20px 0', borderTop: '1px solid rgba(255,255,255,0.08)', marginTop: 16, marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Red Bonus */}
<div style={{
padding: 16,
border: '1px solid rgba(173,10,5,0.2)',
borderRadius: 6,
background: 'rgba(173,10,5,0.05)',
}}
>
<span style={{ font: '700 12px \'Rajdhani\', sans-serif', textTransform: 'uppercase', letterSpacing: 2, color: '#ffd700', display: 'block', marginBottom: 12 }}>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Red Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
{0 < game.redBonusStats?.blindHits && <StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />}
{0 < game.redBonusStats?.chainBest && <StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />}
{0 < game.redBonusStats?.edgeMines && <StatRow icon="fa-border" label="Edge mines" value={game.redBonusStats.edgeMines} />}
{0 < game.redBonusStats?.lastMineHits && <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.redBonusStats.lastMineHits} />}
{0 < game.redBonusStats?.biggestReveal && <StatRow icon="fa-expand" label="Biggest reveal" value={game.redBonusStats.biggestReveal} />}
{!game.redBonusStats?.blindHits && !game.redBonusStats?.chainBest && !game.redBonusStats?.edgeMines && !game.redBonusStats?.lastMineHits && !game.redBonusStats?.biggestReveal
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
{/* Blue Bonus */}
<div style={{
padding: 16,
border: '1px solid rgba(149,207,245,0.2)',
borderRadius: 6,
background: 'rgba(149,207,245,0.05)',
}}
>
<span style={{ font: '700 12px \'Rajdhani\', sans-serif', textTransform: 'uppercase', letterSpacing: 2, color: '#ffd700', display: 'block', marginBottom: 12 }}>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Blue Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
{0 < game.blueBonusStats?.blindHits && <StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />}
{0 < game.blueBonusStats?.chainBest && <StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />}
{0 < game.blueBonusStats?.edgeMines && <StatRow icon="fa-border" label="Edge mines" value={game.blueBonusStats.edgeMines} />}
{0 < game.blueBonusStats?.lastMineHits && <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.blueBonusStats.lastMineHits} />}
{0 < game.blueBonusStats?.biggestReveal && <StatRow icon="fa-expand" label="Biggest reveal" value={game.blueBonusStats.biggestReveal} />}
{!game.blueBonusStats?.blindHits && !game.blueBonusStats?.chainBest && !game.blueBonusStats?.edgeMines && !game.blueBonusStats?.lastMineHits && !game.blueBonusStats?.biggestReveal
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
</div>
</div>
)}
<BonusPoints
game={game}
/>
</div>
</Dialog>
</ThemeProvider>

View File

@@ -0,0 +1,98 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
export default function Avatar({ name, color, avatarUrl, bonusPoints = 0 }) {
const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase();
const gradient = isRed
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
const glow = isRed
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
const border = isRed
? 'rgba(173,10,5,0.5)'
: 'rgba(35,111,135,0.5)';
const textColor = isRed ? '#f67d52' : '#95cff5';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, position: 'relative' }}>
<div style={{ position: 'relative' }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
</div>
{0 < bonusPoints && (
<div style={{
position: 'absolute',
bottom: -6,
right: -6,
background: '#ffd700',
borderRadius: '50%',
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1)',
border: '2px solid rgba(0,0,0,0.5)',
zIndex: 10,
}}
>
<i className="fa fa-star" style={{ color: '#000', fontSize: 14 }} />
</div>
)}
</div>
<span style={{
font: '700 15px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 1,
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
{name}
</span>
<span style={{
font: '600 10px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: 'rgba(255,255,255,0.3)',
}}
>
{isRed ? 'Red' : 'Blue'}
</span>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import StatRow from './StatRow';
export default function BonusPoints({ game }) {
const hasBonuspoints = useMemo(
() => 0 < game?.redBonusPoints
|| 0 < game?.blueBonusPoints
|| game?.redBonusStats?.blindHits
|| game?.blueBonusStats?.blindHits,
[
game?.blueBonusPoints,
game?.blueBonusStats?.blindHits,
game?.redBonusPoints,
game?.redBonusStats?.blindHits,
],
);
const hasRedNoBonuses = useMemo(
() => !game.redBonusStats?.blindHits
&& !game.redBonusStats?.chainBest
&& !game.redBonusStats?.edgeMines
&& !game.redBonusStats?.lastMineHits
&& !game.redBonusStats?.biggestReveal,
[
game.redBonusStats?.biggestReveal,
game.redBonusStats?.blindHits,
game.redBonusStats?.chainBest,
game.redBonusStats?.edgeMines,
game.redBonusStats?.lastMineHits,
],
);
const hasBlueNoBonuses = useMemo(
() => !game.blueBonusStats?.blindHits
&& !game.blueBonusStats?.chainBest
&& !game.blueBonusStats?.edgeMines
&& !game.blueBonusStats?.lastMineHits
&& !game.blueBonusStats?.biggestReveal,
[
game.blueBonusStats?.biggestReveal,
game.blueBonusStats?.blindHits,
game.blueBonusStats?.chainBest,
game.blueBonusStats?.edgeMines,
game.blueBonusStats?.lastMineHits,
],
);
if (!hasBonuspoints) {
return '';
}
return (
<div style={{
padding: '16px 20px 0',
borderTop: '1px solid rgba(255,255,255,0.08)',
marginTop: 16,
marginBottom: 16,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Red Bonus */}
<div style={{
padding: 16,
border: '1px solid rgba(173,10,5,0.2)',
borderRadius: 6,
background: 'rgba(173,10,5,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Red Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow
icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)}
valueColor="#ffd700"
/>
{0 < game.redBonusStats?.blindHits
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />}
{0 < game.redBonusStats?.chainBest
&& <StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />}
{0 < game.redBonusStats?.edgeMines
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />}
{0 < game.redBonusStats?.lastMineHits
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.redBonusStats.lastMineHits} />}
{0 < game.redBonusStats?.biggestReveal
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.redBonusStats.biggestReveal} />}
{hasRedNoBonuses
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
<div style={{
padding: 16,
border: '1px solid rgba(149,207,245,0.2)',
borderRadius: 6,
background: 'rgba(149,207,245,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Blue Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow
icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)}
valueColor="#ffd700"
/>
{0 < game.blueBonusStats?.blindHits
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />}
{0 < game.blueBonusStats?.chainBest
&& <StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />}
{0 < game.blueBonusStats?.edgeMines
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />}
{0 < game.blueBonusStats?.lastMineHits
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.blueBonusStats.lastMineHits} />}
{0 < game.blueBonusStats?.biggestReveal
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.blueBonusStats.biggestReveal} />}
{hasBlueNoBonuses
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
export default function StatRow({ icon, label, value, valueColor }) {
return (
<div style={{
display: 'flex', alignItems: 'center',
gap: 10, padding: '9px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
<span style={{
font: '500 13px \'Rajdhani\', sans-serif',
color: 'rgba(255,255,255,0.45)',
flex: 1,
letterSpacing: 0.5,
}}
>
{label}
</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: valueColor || 'rgba(255,255,255,0.75)',
letterSpacing: 0.5,
}}
>
{value}
</span>
</div>
);
}

View File

@@ -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 }) => {
<GameBoard
gameAssoc={gameAssoc}
gameInherited={gameInherited}
opponentName={opponentName}
isEnvDev={isEnvDev}
/>
</GameProvider>

View File

@@ -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 (

View File

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

View File

@@ -9,22 +9,27 @@
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 (
<Fragment>
<div className="waiting-options">
<div className={`waiting-options${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
<div className="waiting-option">
<div className="waiting-option-header">
<i className="fa fa-link" />
<span>Invite a Friend</span>
<span>{inviteHeader}</span>
</div>
<p className="waiting-option-desc">Share this link with your opponent</p>
<ShareLinkBox
url={shareUrl}
/>
</div>
{!inviteOnly && (
<Fragment>
<div className="waiting-divider">
<span>OR</span>
</div>
@@ -42,13 +47,17 @@ const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
Browse Players
</button>
</div>
</Fragment>
)}
</div>
{!inviteOnly && (
<OnlinePlayersDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
currentGameAssoc={currentGameAssoc}
/>
)}
</Fragment>
);
};
@@ -57,10 +66,12 @@ const ShareLinkBox = ({ url }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(url).then(() => {
navigator.clipboard.writeText(url)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2500);
}).catch(() => {});
})
.catch(() => null);
};
return (

View File

@@ -23,6 +23,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const handleShare = () => {
if (!shareUrl) return;
@@ -64,6 +65,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
overlaySubTitle
)}
{gameAssoc && endRef.current && (
<div className="game-overlay-actions">
<button
className={`game-overlay-share${copied ? ' copied' : ''}`}
onClick={handleShare}
@@ -73,6 +75,16 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
{copied ? 'Copied!' : 'Share Battle'}
</button>
<a
className="game-overlay-profile"
href={isAuthenticated ? '/profile' : '/'}
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
>
<i className={`fa ${isAuthenticated ? 'fa-user' : 'fa-house'}`} />
{isAuthenticated ? 'My Profile' : 'Homepage'}
</a>
</div>
)}
</div>
</div>

View File

@@ -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

View File

@@ -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 ? (
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 ? (
<WaitingOverlayContent
shareUrl={`${window.location.href}/${gameAssoc}`}
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}
/>
) : '');
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
) : '';
}
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();
/**
* 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,13 +279,34 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
&& (!connectionLostRef.current
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
) {
makeGameStart(payload);
makeGameStart(payload, lastStep);
}
};
const wUnsubscribe = payload => {
isEnvDev && console.info(payload.msg);
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!');
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const redirectPath = isAuthenticated ? '/profile' : '/';
const buttonText = isAuthenticated ? 'My Profile' : 'Homepage';
const buttonIcon = isAuthenticated ? 'fa-user' : 'fa-house';
showOverlay(
'The connection has been lost w/ your friend...',
(
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px', width: '100%' }}>
<p style={{ margin: 0 }}>Please, restart the game!</p>
<a
className="game-overlay-profile"
href={redirectPath}
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
>
<i className={`fa ${buttonIcon}`} />
{buttonText}
</a>
</div>
),
);
};
const wChallenge = payload => {
@@ -147,7 +321,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 +338,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
currentGameAssoc={gameAssoc}
/>
) : '');
}).catch(() => {});
}).catch(() => {
});
};
declineTimeout = setTimeout(handleDecline, 30000);
@@ -188,8 +364,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 +388,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 +424,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 +466,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 +490,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 +498,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 +545,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
resignProcess(webPlayerRef.current, result.uuid);
}
},
}
},
);
};

View File

@@ -19,6 +19,7 @@
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.6",
"endroid/qr-code": "^6.1",
"firebase/php-jwt": "^7.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.13",
@@ -43,7 +44,6 @@
"web-auth/webauthn-framework": "^5.2"
},
"require-dev": {
"firebase/php-jwt": "^7.0",
"roave/security-advisories": "dev-master",
"symfony/dotenv": "7.4.*",
"symfony/maker-bundle": "^1.5",

137
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "68e0a67890fc1a4a01f1f2154b477054",
"content-hash": "cd6be4d237e7c8f70cc45e42e14eac8a",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -1780,6 +1780,70 @@
],
"time": "2026-02-05T07:01:58+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
"time": "2026-04-01T20:38:03+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.10.0",
@@ -9848,70 +9912,6 @@
}
],
"packages-dev": [
{
"name": "firebase/php-jwt",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
"time": "2026-04-01T20:38:03+00:00"
},
{
"name": "nikic/php-parser",
"version": "v5.7.0",
@@ -11289,16 +11289,17 @@
}
],
"aliases": [],
"minimum-stability": "stable",
"minimum-stability": "dev",
"stability-flags": {
"roave/security-advisories": 20
},
"prefer-stable": false,
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": ">=8.5",
"ext-iconv": "*",
"ext-json": "*"
"ext-json": "*",
"ext-gd": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"

View File

@@ -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;
@@ -43,11 +42,9 @@ class GameController extends AbstractController
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 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());
}
}
}

View File

@@ -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;
@@ -41,6 +42,7 @@ class MercureController extends AbstractController
public function __construct(
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
{
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
{
@@ -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;
}
}

View File

@@ -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;

View File

@@ -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,
);
}
}
}

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\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;
@@ -41,9 +43,10 @@ class RpcManager implements RpcManagerInterface
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,
) {
}
@@ -58,6 +61,15 @@ class RpcManager implements RpcManagerInterface
return base64_encode(json_encode([
'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,
'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 [

View File

@@ -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();
@@ -610,8 +612,13 @@ readonly class TopicManager implements TopicManagerInterface
$playedGame->setRedPoints($redPoints);
$playedGame->setBluePoints($bluePoints);
$playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null);
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null);
if ((bool)$event['bomb']) {
if ('red' === $player) {
$playedGame->setRedExplodedBomb(true);
} elseif ('blue' === $player) {
$playedGame->setBlueExplodedBomb(true);
}
}
$playedGame->setUpdated(new DateTime());
/** Bonus data is already persisted in calculateBonuses, but we ensure it's up to date */
@@ -629,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();
@@ -667,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) {
@@ -714,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';
@@ -749,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 {
@@ -761,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 = [
@@ -772,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;

View File

@@ -127,7 +127,6 @@
{% endif %}
{% set pointDiff = (redPts|default(0) - bluePts|default(0))|abs %}
{% set winnerName = redPts|default(0) > bluePts|default(0) ? redName : (bluePts|default(0) > redPts|default(0) ? blueName : null) %}
<div class="bshare-details">
{% if resign %}
<div class="bshare-detail">
@@ -166,8 +165,6 @@
</div>
{% endif %}
</div>
{# Bonus Stats Section #}
{% set hasRedStats = redBonusStats is not empty and (redBonusStats.blindHits or redBonusStats.chainBest or redBonusStats.edgeMines or redBonusStats.lastMineHits or redBonusStats.biggestReveal) %}
{% set hasBlueStats = blueBonusStats is not empty and (blueBonusStats.blindHits or blueBonusStats.chainBest or blueBonusStats.edgeMines or blueBonusStats.lastMineHits or blueBonusStats.biggestReveal) %}
{% if redBonusPoints > 0 or blueBonusPoints > 0 or hasRedStats or hasBlueStats %}
@@ -220,8 +217,6 @@
{% endif %}
</div>
</div>
{# Blue Bonus #}
<div class="bshare-bonus__player bshare-bonus__player--blue">
<div class="bshare-bonus__header">
<span class="bshare-bonus__points">{{ blueBonusPoints|number_format(1, '.', '') }}</span>

View File

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

View File

@@ -129,21 +129,35 @@
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %}
{% 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 %}
<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">
{{ result == 'win' ? 'W' : (result == 'loss' ? 'L' : 'D') }}
{% if is_finished %}
{{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}
{% elseif is_anonymous %}
Abandoned
{% else %}
Ongoing
{% endif %}
</span>
<span class="profile-game__score">
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }}