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:
@@ -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);
|
||||||
|
|||||||
@@ -209,12 +209,20 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
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 {
|
||||||
@@ -54,6 +56,11 @@
|
|||||||
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 {
|
||||||
@@ -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;
|
||||||
@@ -264,6 +275,11 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -105,10 +105,12 @@
|
|||||||
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 {
|
||||||
@@ -142,6 +144,13 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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 || ''}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,6 +123,17 @@ export default function BattleDialog({ games }) {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<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
|
<button
|
||||||
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
|
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
@@ -129,6 +143,7 @@ export default function BattleDialog({ games }) {
|
|||||||
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
||||||
{copied ? 'Copied!' : 'Share'}
|
{copied ? 'Copied!' : 'Share'}
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -9,22 +9,27 @@
|
|||||||
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>
|
||||||
|
{!inviteOnly && (
|
||||||
|
<Fragment>
|
||||||
<div className="waiting-divider">
|
<div className="waiting-divider">
|
||||||
<span>OR</span>
|
<span>OR</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,13 +47,17 @@ const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
|
|||||||
Browse Players
|
Browse Players
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!inviteOnly && (
|
||||||
<OnlinePlayersDialog
|
<OnlinePlayersDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={() => setDialogOpen(false)}
|
onClose={() => setDialogOpen(false)}
|
||||||
currentGameAssoc={currentGameAssoc}
|
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)
|
||||||
|
.then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2500);
|
setTimeout(() => setCopied(false), 2500);
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
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
|
<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}
|
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 => {
|
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();
|
||||||
|
/**
|
||||||
|
* 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();
|
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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -43,11 +42,9 @@ class GameController extends AbstractController
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -41,6 +42,7 @@ 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
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
$payload = $this->rpcManager->getConnectInformation($gameAssoc);
|
$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
|
||||||
{
|
{
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/Service/Email/SendContactMailService.php
Normal file
67
src/Service/Email/SendContactMailService.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Service/MercureJwtService.php
Normal file
53
src/Service/MercureJwtService.php
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/Service/ResolveUserNamesService.php
Normal file
91
src/Service/ResolveUserNamesService.php
Normal 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -41,9 +43,10 @@ class RpcManager implements RpcManagerInterface
|
|||||||
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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +61,15 @@ class RpcManager implements RpcManagerInterface
|
|||||||
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 [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }}">
|
||||||
|
|||||||
@@ -129,21 +129,35 @@
|
|||||||
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %}
|
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %}
|
||||||
|
|
||||||
{% set result = 'draw' %}
|
{% set result = 'draw' %}
|
||||||
|
{% set is_finished = false %}
|
||||||
|
{% set is_anonymous = not opp and opp_anon %}
|
||||||
{% if game.resign == (is_red ? 'red' : 'blue') %}
|
{% if game.resign == (is_red ? 'red' : 'blue') %}
|
||||||
{% set result = 'loss' %}
|
{% set result = 'loss' %}
|
||||||
|
{% set is_finished = true %}
|
||||||
{% elseif game.resign == (is_red ? 'blue' : 'red') %}
|
{% elseif game.resign == (is_red ? 'blue' : 'red') %}
|
||||||
{% set result = 'win' %}
|
{% set result = 'win' %}
|
||||||
|
{% set is_finished = true %}
|
||||||
{% elseif my_points is not null and opp_points is not null %}
|
{% elseif my_points is not null and opp_points is not null %}
|
||||||
{% if my_points > opp_points %}
|
{% if my_points > opp_points %}
|
||||||
{% set result = 'win' %}
|
{% set result = 'win' %}
|
||||||
|
{% set is_finished = (my_points > 25 or opp_points > 25) %}
|
||||||
{% elseif my_points < opp_points %}
|
{% elseif my_points < opp_points %}
|
||||||
{% set result = 'loss' %}
|
{% 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 %}
|
||||||
{% 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 %}
|
||||||
|
{{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}
|
||||||
|
{% elseif is_anonymous %}
|
||||||
|
Abandoned
|
||||||
|
{% else %}
|
||||||
|
Ongoing
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="profile-game__score">
|
<span class="profile-game__score">
|
||||||
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }}
|
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user