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!', +
+ Accept + Decline +
, + ); + }; + + 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 {