diff --git a/assets/css/mineseeker/_waiting-dialog.scss b/assets/css/mineseeker/_waiting-dialog.scss
index 746198d..8b9983b 100644
--- a/assets/css/mineseeker/_waiting-dialog.scss
+++ b/assets/css/mineseeker/_waiting-dialog.scss
@@ -255,13 +255,45 @@
transition: all 200ms ease;
flex-shrink: 0;
- &:hover {
+ &:hover:not(:disabled) {
background: linear-gradient(to bottom, rgba(45, 138, 168, 0.9) 0%, rgba(35, 111, 135, 0.95) 100%);
border-color: rgba(149, 207, 245, 0.5);
color: #fff;
box-shadow: 0 0 14px rgba(35, 111, 135, 0.5);
transform: translateY(-1px);
}
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.55;
+ }
+
+ &.opd-join--waiting {
+ background: linear-gradient(to bottom, rgba(26, 80, 104, 0.6) 0%, rgba(15, 50, 70, 0.7) 100%);
+ border-color: rgba(35, 111, 135, 0.3);
+ color: rgba(149, 207, 245, 0.6);
+ opacity: 1;
+ letter-spacing: 1px;
+ }
+}
+
+.opd-declined {
+ font: 600 12px 'Rajdhani', sans-serif;
+ color: rgba(255, 120, 120, 0.85);
+ letter-spacing: 0.5px;
+ padding: 8px 12px;
+ margin-bottom: 8px;
+ border-radius: 6px;
+ border: 1px solid rgba(180, 60, 60, 0.3);
+ background: rgba(180, 60, 60, 0.08);
+ display: flex;
+ align-items: center;
+ gap: 7px;
+
+ i {
+ font-size: 14px;
+ flex-shrink: 0;
+ }
}
.opd-note {
diff --git a/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx b/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx
index 797ded5..ee1bb4c 100644
--- a/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx
+++ b/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx
@@ -7,7 +7,7 @@
* file that was distributed with this source code.
*/
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import Dialog from '@mui/material/Dialog';
const DIALOG_SX = {
@@ -45,6 +45,9 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [loading, setLoading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [snapshotLoaded, setSnapshotLoaded] = useState(false);
+ const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
+ const [declinedMsg, setDeclinedMsg] = useState('');
+ const declinedTimerRef = useRef(null);
const addPlayer = useCallback(entry => {
setPlayers(prev =>
@@ -103,6 +106,31 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
return () => es.close();
}, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]);
+ useEffect(() => {
+ const handler = () => {
+ setChallengingGameAssoc(null);
+ clearTimeout(declinedTimerRef.current);
+ setDeclinedMsg('Challenge was not accepted.');
+ declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
+ };
+ window.addEventListener('challenge-declined', handler);
+ return () => {
+ window.removeEventListener('challenge-declined', handler);
+ clearTimeout(declinedTimerRef.current);
+ };
+ }, []);
+
+ const handleChallenge = player => {
+ if (challengingGameAssoc) return;
+ setChallengingGameAssoc(player.gameAssoc);
+ setDeclinedMsg('');
+ fetch('/api/game/challenge/' + player.gameAssoc, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
+ }).catch(() => setChallengingGameAssoc(null));
+ };
+
const visible = players
.filter(p => p.gameAssoc !== currentGameAssoc)
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
@@ -177,24 +205,38 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
)}
- {!loading && shown.map(player => (
-
-
- {player.name.slice(0, 2).toUpperCase()}
-
-
- {player.name}
-
-
- {' '}Waiting {formatSince(player.since)}
-
-
-
-
- Join
-
+ {declinedMsg && (
+
+
+ {' '}{declinedMsg}
- ))}
+ )}
+
+ {!loading && shown.map(player => {
+ const isWaiting = challengingGameAssoc === player.gameAssoc;
+ return (
+
+
+ {player.name.slice(0, 2).toUpperCase()}
+
+
+ {player.name}
+
+
+ {' '}Waiting {formatSince(player.since)}
+
+
+
+
+ );
+ })}
{!loading && hasMore && (
diff --git a/assets/js/mine-seeker/hooks/useServerCommunication.jsx b/assets/js/mine-seeker/hooks/useServerCommunication.jsx
index 22df0eb..78b71d0 100644
--- a/assets/js/mine-seeker/hooks/useServerCommunication.jsx
+++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx
@@ -130,6 +130,51 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!');
};
+ const wChallenge = payload => {
+ const { challengerName, challengerGameAssoc } = payload;
+
+ const handleAccept = () => {
+ fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
+ }).then(() => {
+ showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
+ }).catch(() => {});
+ };
+
+ const handleDecline = () => {
+ fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }),
+ }).then(() => {
+ showOverlay('We are waiting for your opponent...', gameAssoc ? (
+
+ ) : '');
+ }).catch(() => {});
+ };
+
+ showOverlay(
+ challengerName + ' wants to challenge you!',
+ ,
+ );
+ };
+
+ const wChallengeResponse = payload => {
+ if (payload.accepted) {
+ window.location.href = '/play/' + payload.targetGameAssoc;
+ } else {
+ window.dispatchEvent(new CustomEvent('challenge-declined'));
+ }
+ };
+
const wTopic = payload => {
if (webPlayerRef.current !== payload.data.player) {
if (null === payload.data.resign) {
@@ -152,6 +197,12 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
};
const handleMercureMessage = payload => {
+ if (undefined !== payload.type) {
+ if ('challenge' === payload.type) wChallenge(payload);
+ else if ('challenge-response' === payload.type) wChallengeResponse(payload);
+ return;
+ }
+
if (undefined !== payload.data) {
wTopic(payload);
} else if (undefined === payload.msg) {
diff --git a/composer.json b/composer.json
index b03287a..1936668 100644
--- a/composer.json
+++ b/composer.json
@@ -2,7 +2,7 @@
"type": "project",
"license": "proprietary",
"require": {
- "php": ">=8.2",
+ "php": ">=8.5",
"ext-iconv": "*",
"ext-json": "*",
"doctrine/dbal": "^3.7",
diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php
index f62a538..a1e685c 100644
--- a/src/Controller/GameController.php
+++ b/src/Controller/GameController.php
@@ -13,6 +13,7 @@ namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -25,6 +26,7 @@ use Symfony\Component\Routing\Attribute\Route;
* @link www.splendidbear.org
* @since 2026. 04. 09.
*/
+#[AsController]
class GameController extends AbstractController
{
public function __construct(
diff --git a/src/Controller/MercureController.php b/src/Controller/MercureController.php
index bf39901..328c0cc 100644
--- a/src/Controller/MercureController.php
+++ b/src/Controller/MercureController.php
@@ -18,6 +18,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -34,6 +35,7 @@ use Symfony\Component\Routing\Attribute\Route;
* @link www.splendidbear.org
* @since 2026. 04. 09.
*/
+#[AsController]
class MercureController extends AbstractController
{
public function __construct(
@@ -83,6 +85,27 @@ class MercureController extends AbstractController
return $this->json(['success' => true]);
}
+ #[Route('/api/game/challenge/{targetGameAssoc}', name: 'MineSeekerBundle_api_game_challenge', methods: ['POST'])]
+ public function challenge(string $targetGameAssoc, Request $request): JsonResponse
+ {
+ $data = $request->toArray();
+ $challengerGameAssoc = $data['challengerGameAssoc'] ?? '';
+ $this->topicManager->publishChallenge($targetGameAssoc, $challengerGameAssoc);
+
+ return $this->json(['success' => true]);
+ }
+
+ #[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();
+ $accepted = (bool)($data['accepted'] ?? false);
+ $targetGameAssoc = $data['targetGameAssoc'] ?? '';
+ $this->topicManager->publishChallengeResponse($challengerGameAssoc, $accepted, $targetGameAssoc);
+
+ return $this->json(['success' => true]);
+ }
+
#[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
public function waiting(PlayedGameRepository $repo): JsonResponse
{
diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php
index c32703e..0dc522e 100644
--- a/src/Controller/ProfileController.php
+++ b/src/Controller/ProfileController.php
@@ -11,9 +11,10 @@
namespace App\Controller;
use App\Entity\User;
-use Doctrine\ORM\EntityManagerInterface;
+use App\Repository\PlayedGameRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -26,58 +27,26 @@ use Symfony\Component\Routing\Attribute\Route;
* @link www.splendidbear.org
* @since 2026. 04. 11.
*/
+#[AsController]
class ProfileController extends AbstractController
{
- #[Route('/profile', name: 'MineSeekerBundle_profile')]
- public function index(EntityManagerInterface $em): Response
- {
- $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
+ public function __construct(private readonly PlayedGameRepository $repo) { }
+ #[Route('/profile', name: 'MineSeekerBundle_profile')]
+ public function index(): Response
+ {
/** @var User $user */
$user = $this->getUser();
-
- $finished = '(g.redPoints IS NOT NULL OR g.resign IS NOT NULL)';
-
- $total = (int) $em->createQuery(
- "SELECT COUNT(g.id) FROM App\Entity\PlayedGame g
- WHERE (g.red = :u OR g.blue = :u) AND {$finished}"
- )->setParameter('u', $user)->getSingleScalarResult();
-
- $wins = (int) $em->createQuery(
- "SELECT COUNT(g.id) FROM App\Entity\PlayedGame g WHERE (
- (g.red = :u AND g.redPoints > g.bluePoints AND g.resign IS NULL) OR
- (g.blue = :u AND g.bluePoints > g.redPoints AND g.resign IS NULL) OR
- (g.red = :u AND g.resign = 'blue') OR
- (g.blue = :u AND g.resign = 'red')
- )"
- )->setParameter('u', $user)->getSingleScalarResult();
-
- $losses = (int) $em->createQuery(
- "SELECT COUNT(g.id) FROM App\Entity\PlayedGame g WHERE (
- (g.red = :u AND g.bluePoints > g.redPoints AND g.resign IS NULL) OR
- (g.blue = :u AND g.redPoints > g.bluePoints AND g.resign IS NULL) OR
- (g.red = :u AND g.resign = 'red') OR
- (g.blue = :u AND g.resign = 'blue')
- )"
- )->setParameter('u', $user)->getSingleScalarResult();
-
- $bombs = (int) $em->createQuery(
- "SELECT COUNT(g.id) FROM App\Entity\PlayedGame g WHERE
- (g.red = :u AND g.redExplodedBomb = true) OR
- (g.blue = :u AND g.blueExplodedBomb = true)"
- )->setParameter('u', $user)->getSingleScalarResult();
-
- $recent = $em->createQuery(
- "SELECT g FROM App\Entity\PlayedGame g
- LEFT JOIN g.red rr LEFT JOIN g.blue bb
- LEFT JOIN g.redAnon ra LEFT JOIN g.blueAnon ba
- WHERE (g.red = :u OR g.blue = :u) AND {$finished}
- ORDER BY g.updated DESC"
- )->setParameter('u', $user)->setMaxResults(10)->getResult();
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
return $this->render('Security/profile.html.twig', [
- 'stats' => compact('total', 'wins', 'losses', 'bombs'),
- 'recent' => $recent,
+ 'stats' => [
+ 'total' => $this->repo->countFinishedForUser($user),
+ 'wins' => $this->repo->countWinsForUser($user),
+ 'losses' => $this->repo->countLossesForUser($user),
+ 'bombs' => $this->repo->countBombsForUser($user),
+ ],
+ 'recent' => $this->repo->findRecentFinishedForUser($user),
]);
}
}
diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php
index d5f6f9b..0e7723c 100644
--- a/src/Controller/SecurityController.php
+++ b/src/Controller/SecurityController.php
@@ -16,6 +16,7 @@ use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -32,6 +33,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
* @link www.splendidbear.org
* @since 2026. 04. 11.
*/
+#[AsController]
class SecurityController extends AbstractController
{
#[Route('/login', name: 'MineSeekerBundle_login')]
@@ -93,7 +95,7 @@ class SecurityController extends AbstractController
if (empty($errors)) {
$token = bin2hex(random_bytes(32));
- $user = (new User())
+ $user = new User()
->setUsername($username)
->setEmail($email)
->setIsVerified(false)
@@ -111,7 +113,7 @@ class SecurityController extends AbstractController
);
$mailer->send(
- (new TemplatedEmail())
+ new TemplatedEmail()
->from('noreply@mineseeker.ninja')
->to($email)
->subject('Activate your MineSeeker account')
@@ -152,4 +154,4 @@ class SecurityController extends AbstractController
return $this->redirectToRoute('MineSeekerBundle_login');
}
-}
\ No newline at end of file
+}
diff --git a/src/Repository/PlayedGameRepository.php b/src/Repository/PlayedGameRepository.php
index 4f4d5b3..f269b75 100644
--- a/src/Repository/PlayedGameRepository.php
+++ b/src/Repository/PlayedGameRepository.php
@@ -11,9 +11,14 @@
namespace App\Repository;
use App\Entity\PlayedGame;
+use App\Entity\User;
use DateTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\NonUniqueResultException;
+use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
/**
* Class PlayedGameRepository
@@ -29,37 +34,267 @@ use Doctrine\Persistence\ManagerRegistry;
* @method PlayedGame|null findOneBy(array $criteria, array $orderBy = null)
* @method PlayedGame[] findAll()
* @method PlayedGame[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
- * @method PlayedGame|null findOneByGameAssoc($gameAssoc)
*/
class PlayedGameRepository extends ServiceEntityRepository
{
- /**
- * PlayedGameRepository constructor.
- *
- * @param ManagerRegistry $registry
- */
- public function __construct(ManagerRegistry $registry)
+ public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
{
parent::__construct($registry, PlayedGame::class);
}
+ public function findOneByGameAssoc(string $gameAssoc): ?PlayedGame
+ {
+ $qb = $this->createQueryBuilder('p');
+
+ try {
+ return $qb
+ ->where($qb->expr()->eq('p.gameAssoc', ':gameAssoc'))
+ ->setParameter('gameAssoc', $gameAssoc)
+ ->getQuery()
+ ->getOneOrNullResult();
+ } catch (NonUniqueResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly multiple results found when looking up gameAssoc: $gameAssoc",
+ 0,
+ $e,
+ );
+ }
+ }
+
+ public function countFinishedForUser(User $user): int
+ {
+ $qb = $this->createQueryBuilder('g');
+
+ try {
+ return (int) $qb
+ ->select('COUNT(g.id)')
+ ->where($qb->expr()->andX(
+ $qb->expr()->orX(
+ $qb->expr()->eq('g.red', ':u'),
+ $qb->expr()->eq('g.blue', ':u'),
+ ),
+ $qb->expr()->orX(
+ $qb->expr()->isNotNull('g.redPoints'),
+ $qb->expr()->isNotNull('g.resign'),
+ ),
+ ))
+ ->setParameter('u', $user)
+ ->getQuery()
+ ->getSingleScalarResult();
+ } catch (NoResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly no result found when counting finished games for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ } catch (NonUniqueResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly multiple results found when counting finished games for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ }
+ }
+
+ public function countWinsForUser(User $user): int
+ {
+ $qb = $this->createQueryBuilder('g');
+
+ try {
+ return (int) $qb
+ ->select('COUNT(g.id)')
+ ->where($qb->expr()->orX(
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.red', ':u'),
+ $qb->expr()->gt('g.redPoints', 'g.bluePoints'),
+ $qb->expr()->isNull('g.resign'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.blue', ':u'),
+ $qb->expr()->gt('g.bluePoints', 'g.redPoints'),
+ $qb->expr()->isNull('g.resign'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.red', ':u'),
+ $qb->expr()->eq('g.resign', ':blue'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.blue', ':u'),
+ $qb->expr()->eq('g.resign', ':red'),
+ ),
+ ))
+ ->setParameter('u', $user)
+ ->setParameter('blue', 'blue')
+ ->setParameter('red', 'red')
+ ->getQuery()
+ ->getSingleScalarResult();
+ } catch (NoResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly no result found when counting wins for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ } catch (NonUniqueResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly multiple results found when counting wins for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ }
+ }
+
+ public function countLossesForUser(User $user): int
+ {
+ $qb = $this->createQueryBuilder('g');
+
+ try {
+ return (int) $qb
+ ->select('COUNT(g.id)')
+ ->where($qb->expr()->orX(
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.red', ':u'),
+ $qb->expr()->gt('g.bluePoints', 'g.redPoints'),
+ $qb->expr()->isNull('g.resign'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.blue', ':u'),
+ $qb->expr()->gt('g.redPoints', 'g.bluePoints'),
+ $qb->expr()->isNull('g.resign'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.red', ':u'),
+ $qb->expr()->eq('g.resign', ':red'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.blue', ':u'),
+ $qb->expr()->eq('g.resign', ':blue'),
+ ),
+ ))
+ ->setParameter('u', $user)
+ ->setParameter('red', 'red')
+ ->setParameter('blue', 'blue')
+ ->getQuery()
+ ->getSingleScalarResult();
+ } catch (NoResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly no result found when counting losses for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ } catch (NonUniqueResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly multiple results found when counting losses for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ }
+ }
+
+ public function countBombsForUser(User $user): int
+ {
+ $qb = $this->createQueryBuilder('g');
+
+ try {
+ return (int) $qb
+ ->select('COUNT(g.id)')
+ ->where($qb->expr()->orX(
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.red', ':u'),
+ $qb->expr()->eq('g.redExplodedBomb', 'true'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->eq('g.blue', ':u'),
+ $qb->expr()->eq('g.blueExplodedBomb', 'true'),
+ ),
+ ))
+ ->setParameter('u', $user)
+ ->getQuery()
+ ->getSingleScalarResult();
+ } catch (NoResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly no result found when counting bombs for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ } catch (NonUniqueResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException(
+ "Unexpectedly multiple results found when counting bombs for user: {$user->getUsername()}",
+ 0,
+ $e,
+ );
+ }
+ }
+
+ /**
+ * @return PlayedGame[]
+ */
+ public function findRecentFinishedForUser(User $user, int $limit = 10): array
+ {
+ $qb = $this->createQueryBuilder('g');
+
+ return $qb
+ ->addSelect('rr', 'bb', 'ra', 'ba')
+ ->leftJoin('g.red', 'rr')
+ ->leftJoin('g.blue', 'bb')
+ ->leftJoin('g.redAnon', 'ra')
+ ->leftJoin('g.blueAnon', 'ba')
+ ->where($qb->expr()->andX(
+ $qb->expr()->orX(
+ $qb->expr()->eq('g.red', ':u'),
+ $qb->expr()->eq('g.blue', ':u'),
+ ),
+ $qb->expr()->orX(
+ $qb->expr()->isNotNull('g.redPoints'),
+ $qb->expr()->isNotNull('g.resign'),
+ ),
+ ))
+ ->setParameter('u', $user)
+ ->orderBy('g.updated', 'DESC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult();
+ }
+
public function findWaitingGames(int $limit = 20): array
{
// Any legitimately waiting game was updated within the last 10 minutes.
// Abandoned games are stamped with updated = 2000-01-01, so they fail this filter.
- $cutoff = new DateTime('-10 minutes');
+ $qb = $this->createQueryBuilder('p');
- return $this->createQueryBuilder('p')
- ->where('p.resign IS NULL')
- ->andWhere('p.updated > :cutoff')
- ->andWhere(
- '(p.red IS NOT NULL OR p.redAnon IS NOT NULL) AND (p.blue IS NULL AND p.blueAnon IS NULL)
- OR (p.blue IS NOT NULL OR p.blueAnon IS NOT NULL) AND (p.red IS NULL AND p.redAnon IS NULL)'
- )
+ return $qb
+ ->where($qb->expr()->isNull('p.resign'))
+ ->andWhere($qb->expr()->gt('p.updated', ':cutoff'))
+ ->andWhere($qb->expr()->orX(
+ $qb->expr()->andX(
+ $qb->expr()->orX(
+ $qb->expr()->isNotNull('p.red'),
+ $qb->expr()->isNotNull('p.redAnon'),
+ ),
+ $qb->expr()->isNull('p.blue'),
+ $qb->expr()->isNull('p.blueAnon'),
+ ),
+ $qb->expr()->andX(
+ $qb->expr()->orX(
+ $qb->expr()->isNotNull('p.blue'),
+ $qb->expr()->isNotNull('p.blueAnon'),
+ ),
+ $qb->expr()->isNull('p.red'),
+ $qb->expr()->isNull('p.redAnon'),
+ ),
+ ))
->orderBy('p.updated', 'DESC')
- ->setParameter('cutoff', $cutoff)
+ ->setParameter('cutoff', new DateTime('-10 minutes'))
->setMaxResults($limit)
->getQuery()
->getResult();
}
-}
+}
\ No newline at end of file
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
index 51db8b8..6b2a9de 100644
--- a/src/Repository/UserRepository.php
+++ b/src/Repository/UserRepository.php
@@ -12,7 +12,10 @@ namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
@@ -31,14 +34,25 @@ use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
- public function __construct(ManagerRegistry $registry)
+ public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
{
parent::__construct($registry, User::class);
}
public function findOneByUsername(string $username): ?User
{
- return $this->findOneBy(['username' => $username]);
+ $qb = $this->createQueryBuilder('u');
+
+ try {
+ return $qb
+ ->where($qb->expr()->eq('u.username', ':username'))
+ ->setParameter('username', $username)
+ ->getQuery()
+ ->getOneOrNullResult();
+ } catch (NonUniqueResultException $e) {
+ $this->logger->error($e->getMessage());
+ throw new RuntimeException("Multiple users found with the same username: $username", 0, $e);
+ }
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
diff --git a/src/Util/RpcManager.php b/src/Util/RpcManager.php
index ebda4b0..96fb6b9 100644
--- a/src/Util/RpcManager.php
+++ b/src/Util/RpcManager.php
@@ -14,6 +14,7 @@ use App\Entity\Grid;
use App\Entity\GridRow;
use App\Entity\PlayedGame;
use App\Interfaces\RpcManagerInterface;
+use App\Repository\PlayedGameRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -40,6 +41,7 @@ class RpcManager implements RpcManagerInterface
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
+ private readonly PlayedGameRepository $playedGameRepository,
) {
}
@@ -47,9 +49,7 @@ class RpcManager implements RpcManagerInterface
{
$gameAssoc = is_array($params) ? $params[0] : $params;
- $playedGame = $this->entityManager
- ->getRepository(PlayedGame::class)
- ->findOneByGameAssoc($gameAssoc);
+ $playedGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
if (null === $playedGame) {
try {
@@ -77,9 +77,7 @@ class RpcManager implements RpcManagerInterface
public function saveGrid(string $gameAssoc): bool
{
- $existingGame = $this->entityManager
- ->getRepository(PlayedGame::class)
- ->findOneByGameAssoc($gameAssoc);
+ $existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
if (null !== $existingGame) {
return true;
diff --git a/src/Util/TopicManager.php b/src/Util/TopicManager.php
index 8b782b8..09f6af3 100644
--- a/src/Util/TopicManager.php
+++ b/src/Util/TopicManager.php
@@ -16,6 +16,8 @@ use App\Entity\PlayedGame;
use App\Entity\Step;
use App\Entity\User;
use App\Interfaces\TopicManagerInterface;
+use App\Repository\PlayedGameRepository;
+use App\Repository\UserRepository;
use DateTimeInterface;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
@@ -37,12 +39,14 @@ use Symfony\Component\Security\Core\User\UserInterface;
* @link www.splendidbear.org
* @since 2026. 04. 09.
*/
-class TopicManager implements TopicManagerInterface
+readonly class TopicManager implements TopicManagerInterface
{
public function __construct(
- private readonly HubInterface $hub,
- private readonly EntityManagerInterface $entityManager,
- private readonly LoggerInterface $logger
+ private HubInterface $hub,
+ private EntityManagerInterface $entityManager,
+ private LoggerInterface $logger,
+ private PlayedGameRepository $playedGameRepository,
+ private UserRepository $userRepository,
) {
}
@@ -403,9 +407,7 @@ class TopicManager implements TopicManagerInterface
private function getPlayedGame(string $gameAssoc): ?PlayedGame
{
- return $this->entityManager
- ->getRepository(PlayedGame::class)
- ->findOneByGameAssoc($gameAssoc);
+ return $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
}
private function getPlayerCount(array $users): int
@@ -475,9 +477,7 @@ class TopicManager implements TopicManagerInterface
private function saveRegisteredUser(string $userName, int $count, PlayedGame $playedGame): void
{
/** @var User $user */
- $user = $this->entityManager
- ->getRepository(User::class)
- ->findOneByUsername($userName);
+ $user = $this->userRepository->findOneByUsername($userName);
try {
if ($count === 1) {
@@ -524,6 +524,45 @@ class TopicManager implements TopicManagerInterface
];
}
+ public function publishChallenge(string $targetGameAssoc, string $challengerGameAssoc): void
+ {
+ $challengerGame = $this->getPlayedGame($challengerGameAssoc);
+ $challengerName = 'Unknown';
+ if (null !== $challengerGame) {
+ $users = $this->getUserCollection($challengerGame);
+ $challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
+ }
+
+ try {
+ $this->hub->publish(new Update(
+ 'mineseeker/channel/' . $targetGameAssoc,
+ json_encode([
+ 'type' => 'challenge',
+ 'challengerName' => $challengerName,
+ 'challengerGameAssoc' => $challengerGameAssoc,
+ ], JSON_THROW_ON_ERROR)
+ ));
+ } catch (JsonException $e) {
+ $this->logger->error('Challenge publish error: ' . $e->getMessage());
+ }
+ }
+
+ public function publishChallengeResponse(string $challengerGameAssoc, bool $accepted, string $targetGameAssoc): void
+ {
+ try {
+ $this->hub->publish(new Update(
+ 'mineseeker/channel/' . $challengerGameAssoc,
+ json_encode([
+ 'type' => 'challenge-response',
+ 'accepted' => $accepted,
+ 'targetGameAssoc' => $targetGameAssoc,
+ ], JSON_THROW_ON_ERROR)
+ ));
+ } catch (JsonException $e) {
+ $this->logger->error('Challenge response publish error: ' . $e->getMessage());
+ }
+ }
+
private function publishToLobby(array $data): void
{
try {