diff --git a/assets/js/mine-seeker/hooks/useGameDataProvider.js b/assets/js/mine-seeker/hooks/useGameDataProvider.js index 16d8c31..50573b5 100644 --- a/assets/js/mine-seeker/hooks/useGameDataProvider.js +++ b/assets/js/mine-seeker/hooks/useGameDataProvider.js @@ -30,7 +30,7 @@ const useGameDataProvider = gameAssoc => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gameAssoc }), - }), + }).then(r => r.json()), }); const joinMutation = useMutation({ diff --git a/assets/js/mine-seeker/hooks/useServerCommunication.jsx b/assets/js/mine-seeker/hooks/useServerCommunication.jsx index c8309a2..010cf53 100644 --- a/assets/js/mine-seeker/hooks/useServerCommunication.jsx +++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx @@ -294,7 +294,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev onSuccess: () => { showOverlay('Challenge accepted!', 'Waiting for the challenger to join...'); }, - } + }, ); }; @@ -311,7 +311,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev /> ) : ''); }, - } + }, ); }; @@ -457,7 +457,12 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev /** Open event source after showing overlay */ openEventSource(); } else { - await startMutation.mutateAsync(); + const startResponse = await startMutation.mutateAsync(); + if (!startResponse?.success) { + showOverlay('Error', 'Failed to start game. Please try again.'); + isEnvDev && console.error('Start game failed:', startResponse); + return; + } openEventSource(); wInit(); } @@ -467,6 +472,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev startHeartbeat(); } catch (e) { isEnvDev && console.error('Connection error', e); + showOverlay('Error', 'Connection failed. Please try again.'); setTimeout(() => window.location.reload(), 500); } })(); diff --git a/src/Controller/MercureController.php b/src/Controller/MercureController.php index 503f5f1..e8a57d4 100644 --- a/src/Controller/MercureController.php +++ b/src/Controller/MercureController.php @@ -15,6 +15,8 @@ use App\Repository\PlayedGameRepository; use App\Service\ResolveUserNamesService; use App\Util\RpcManager; use App\Util\TopicManager; +use DateTimeInterface; +use Exception; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -49,10 +51,17 @@ class MercureController extends AbstractController #[Route('/api/game/start', name: 'MineSeekerBundle_api_game_start', methods: ['POST'])] public function start(Request $request): JsonResponse { - $data = $request->toArray(); - $result = $this->rpcManager->saveGrid($data['gameAssoc']); + try { + $data = $request->toArray(); + $result = $this->rpcManager->saveGrid($data['gameAssoc']); - return $this->json(['success' => $result]); + return $this->json(['success' => $result]); + } catch (Exception $e) { + return $this->json( + ['success' => false, 'error' => 'Failed to start game: ' . $e->getMessage()], + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } } #[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])] @@ -61,7 +70,7 @@ class MercureController extends AbstractController try { $payload = $this->rpcManager->getConnectInformation($gameAssoc); return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']); - } catch (\Exception $e) { + } catch (Exception $e) { return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR); } } @@ -146,7 +155,7 @@ class MercureController extends AbstractController return [ 'gameAssoc' => $g->gameAssoc, 'name' => $name, - 'since' => $g->created?->format(\DateTimeInterface::ATOM) ?? '', + 'since' => $g->created?->format(DateTimeInterface::ATOM) ?? '', ]; }, $games); diff --git a/src/Repository/PlayedGameRepository.php b/src/Repository/PlayedGameRepository.php index 26efdfe..4adce9c 100644 --- a/src/Repository/PlayedGameRepository.php +++ b/src/Repository/PlayedGameRepository.php @@ -14,6 +14,7 @@ use App\Entity\PlayedGame; use App\Entity\User; use DateTime; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; use Doctrine\Persistence\ManagerRegistry; @@ -34,6 +35,7 @@ use RuntimeException; * @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(mixed $gameAssoc) */ class PlayedGameRepository extends ServiceEntityRepository { @@ -42,32 +44,12 @@ class PlayedGameRepository extends ServiceEntityRepository 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 + return (int)$qb ->select('COUNT(g.id)') ->where($qb->expr()->andX( $qb->expr()->orX( @@ -104,7 +86,7 @@ class PlayedGameRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('g'); try { - return (int) $qb + return (int)$qb ->select('COUNT(g.id)') ->where($qb->expr()->orX( $qb->expr()->andX( @@ -153,7 +135,7 @@ class PlayedGameRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('g'); try { - return (int) $qb + return (int)$qb ->select('COUNT(g.id)') ->where($qb->expr()->orX( $qb->expr()->andX( @@ -202,18 +184,19 @@ class PlayedGameRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('g'); try { - return (int) $qb + 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()->eq('g.redExplodedBomb', ':true'), ), $qb->expr()->andX( $qb->expr()->eq('g.blue', ':u'), - $qb->expr()->eq('g.blueExplodedBomb', 'true'), + $qb->expr()->eq('g.blueExplodedBomb', ':true'), ), )) + ->setParameter('true', true, Types::BOOLEAN) ->setParameter('u', $user) ->getQuery() ->getSingleScalarResult(); @@ -238,71 +221,69 @@ class PlayedGameRepository extends ServiceEntityRepository { $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()->isNotNull('g.redPoints'), - $qb->expr()->isNotNull('g.bluePoints'), - $qb->expr()->isNull('g.resign'), - 'g.redPoints = g.bluePoints', - )) - ->setParameter('u', $user) - ->getQuery() - ->getSingleScalarResult(); - } catch (NoResultException | NonUniqueResultException $e) { - $this->logger->error($e->getMessage()); - return 0; - } + 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()->isNotNull('g.redPoints'), + $qb->expr()->isNotNull('g.bluePoints'), + $qb->expr()->isNull('g.resign'), + 'g.redPoints = g.bluePoints', + )) + ->setParameter('u', $user) + ->getQuery() + ->getSingleScalarResult(); } public function findTotalMinesForUser(User $user): int { - $conn = $this->getEntityManager()->getConnection(); + $qb = $this->createQueryBuilder('g'); - $result = $conn->executeQuery( - 'SELECT - COALESCE(SUM(CASE WHEN g.red_id = :uid THEN g.red_points ELSE g.blue_points END), 0) AS total_pts - FROM played_game g - WHERE (g.red_id = :uid OR g.blue_id = :uid)', - ['uid' => $user->id], - )->fetchAssociative(); - - return (int) ($result['total_pts'] ?? 0); + return (int)$qb + ->select('COALESCE(SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END), 0)') + ->where($qb->expr()->orX( + $qb->expr()->eq('g.red', ':u'), + $qb->expr()->eq('g.blue', ':u'), + )) + ->setParameter('u', $user) + ->getQuery() + ->getSingleScalarResult(); } public function findAvgScoreForUser(User $user): int { - $conn = $this->getEntityManager()->getConnection(); + $qb = $this->createQueryBuilder('g'); - $result = $conn->executeQuery( - 'SELECT - SUM(CASE WHEN g.red_id = :uid THEN g.red_points ELSE g.blue_points END) AS total_pts, - COUNT(g.id) AS total_games - FROM played_game g - WHERE (g.red_id = :uid OR g.blue_id = :uid) - AND ( - (g.red_id = :uid AND g.red_points IS NOT NULL) - OR (g.blue_id = :uid AND g.blue_points IS NOT NULL) - )', - ['uid' => $user->id], - )->fetchAssociative(); + /** @var array{totalPts: int|string|null, totalGames: int|string} $row */ + $row = $qb + ->select('SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END) AS totalPts') + ->addSelect('COUNT(g.id) AS totalGames') + ->where($qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->eq('g.red', ':u'), + $qb->expr()->isNotNull('g.redPoints'), + ), + $qb->expr()->andX( + $qb->expr()->eq('g.blue', ':u'), + $qb->expr()->isNotNull('g.bluePoints'), + ), + )) + ->setParameter('u', $user) + ->getQuery() + ->getSingleResult(); - if (!$result || (int) $result['total_games'] === 0) { + if ((int)$row['totalGames'] === 0) { return 0; } - return (int) round((float) $result['total_pts'] / (int) $result['total_games']); + return (int)round((float)$row['totalPts'] / (int)$row['totalGames']); } /** * Aggregates bonus points and bonus stats across all finished games for a user. - * - * @return array{totalBonusPoints:float,avgBonusPoints:float,bestChain:int,totalBlindHits:int,totalEdgeMines:int} */ public function findBonusStatsForUser(User $user): array { @@ -324,12 +305,12 @@ class PlayedGameRepository extends ServiceEntityRepository foreach ($games as $game) { $isRed = $game->red?->id === $userId; - $totalBonusPoints += (float) (($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?? 0.0); + $totalBonusPoints += (float)(($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?? 0.0); $stats = ($isRed ? $game->redBonusStats : $game->blueBonusStats) ?? []; - $bestChain = max($bestChain, (int) ($stats['chainBest'] ?? 0)); - $totalBlindHits += (int) ($stats['blindHits'] ?? 0); - $totalEdgeMines += (int) ($stats['edgeMines'] ?? 0); + $bestChain = max($bestChain, (int)($stats['chainBest'] ?? 0)); + $totalBlindHits += (int)($stats['blindHits'] ?? 0); + $totalEdgeMines += (int)($stats['edgeMines'] ?? 0); $gameCount++; } @@ -346,7 +327,7 @@ class PlayedGameRepository extends ServiceEntityRepository { try { $qbRed = $this->createQueryBuilder('g'); - $maxRed = (int) $qbRed + $maxRed = (int)$qbRed ->select('MAX(g.redPoints)') ->where($qbRed->expr()->eq('g.red', ':u')) ->setParameter('u', $user) @@ -354,7 +335,7 @@ class PlayedGameRepository extends ServiceEntityRepository ->getSingleScalarResult(); $qbBlue = $this->createQueryBuilder('g'); - $maxBlue = (int) $qbBlue + $maxBlue = (int)$qbBlue ->select('MAX(g.bluePoints)') ->where($qbBlue->expr()->eq('g.blue', ':u')) ->setParameter('u', $user) @@ -362,15 +343,12 @@ class PlayedGameRepository extends ServiceEntityRepository ->getSingleScalarResult(); return max($maxRed, $maxBlue); - } catch (NoResultException | NonUniqueResultException $e) { + } catch (NoResultException|NonUniqueResultException $e) { $this->logger->error($e->getMessage()); return 0; } } - /** - * @return PlayedGame[] - */ public function findFinishedForUserSince(User $user, DateTime $since): array { $qb = $this->createQueryBuilder('g'); @@ -394,9 +372,6 @@ class PlayedGameRepository extends ServiceEntityRepository ->getResult(); } - /** - * @return PlayedGame[] - */ public function findRecentFinishedForUser(User $user, int $limit = 10): array { $qb = $this->createQueryBuilder('g'); @@ -418,10 +393,12 @@ class PlayedGameRepository extends ServiceEntityRepository ->getResult(); } + /** + * 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. + */ 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. $qb = $this->createQueryBuilder('p'); return $qb diff --git a/src/Service/ResolveUserNamesService.php b/src/Service/ResolveUserNamesService.php index 0b090a3..dd9e813 100644 --- a/src/Service/ResolveUserNamesService.php +++ b/src/Service/ResolveUserNamesService.php @@ -11,7 +11,7 @@ namespace App\Service; use App\Entity\PlayedGame; -use App\Repository\PlayedGameRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; @@ -30,9 +30,9 @@ use Symfony\Component\HttpFoundation\RequestStack; readonly final class ResolveUserNamesService { public function __construct( - private RequestStack $requestStack, - private Security $security, - private PlayedGameRepository $playedGameRepository, + private EntityManagerInterface $em, + private RequestStack $requestStack, + private Security $security, ) { } @@ -44,7 +44,7 @@ readonly final class ResolveUserNamesService return ''; } - if (null === $game = $this->playedGameRepository->findOneByGameAssoc($gameAssoc)) { + if (null === $game = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc)) { return ''; } diff --git a/src/Util/RpcManager.php b/src/Util/RpcManager.php index 464592f..208748a 100644 --- a/src/Util/RpcManager.php +++ b/src/Util/RpcManager.php @@ -15,7 +15,6 @@ use App\Entity\GridRow; use App\Entity\PlayedGame; use App\Entity\Step; use App\Interfaces\RpcManagerInterface; -use App\Repository\PlayedGameRepository; use App\Repository\StepRepository; use DateTime; use Doctrine\ORM\EntityManagerInterface; @@ -45,7 +44,6 @@ class RpcManager implements RpcManagerInterface public function __construct( private readonly EntityManagerInterface $em, private readonly LoggerInterface $logger, - private readonly PlayedGameRepository $playedGameRepository, private readonly StepRepository $stepRepository, ) { } @@ -54,7 +52,7 @@ class RpcManager implements RpcManagerInterface { $gameAssoc = is_array($params) ? $params[0] : $params; - $playedGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); if (null === $playedGame) { try { @@ -118,7 +116,7 @@ class RpcManager implements RpcManagerInterface public function saveGrid(string $gameAssoc): bool { - $existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc); + $existingGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); if (null !== $existingGame) { return true; @@ -128,29 +126,25 @@ class RpcManager implements RpcManagerInterface $playedGame = new PlayedGame(); $grid = new Grid(); - try { - foreach ($grid2d as $row) { - $gridRow = new GridRow(); - $gridRow->gridCol = $row; - $gridRow->grid = $grid; - $this->em->persist($gridRow); - } - - $grid->playedGame = $playedGame; - $this->em->persist($grid); - - $playedGame->gameAssoc = $gameAssoc; - $playedGame->uuid = Uuid::fromString($gameAssoc); - $playedGame->grid = $grid; - $playedGame->created = new DateTime(); - $playedGame->updated = new DateTime(); - $this->em->persist($playedGame); - - $this->em->flush(); - } catch (Exception $e) { - $this->logger->error($e->getMessage()); + foreach ($grid2d as $row) { + $gridRow = new GridRow(); + $gridRow->gridCol = $row; + $gridRow->grid = $grid; + $this->em->persist($gridRow); } + $grid->playedGame = $playedGame; + $this->em->persist($grid); + + $playedGame->gameAssoc = $gameAssoc; + $playedGame->uuid = Uuid::fromString($gameAssoc); + $playedGame->grid = $grid; + $playedGame->created = new DateTime(); + $playedGame->updated = new DateTime(); + $this->em->persist($playedGame); + + $this->em->flush(); + return true; } diff --git a/src/Util/TopicManager.php b/src/Util/TopicManager.php index d650931..d437b8c 100644 --- a/src/Util/TopicManager.php +++ b/src/Util/TopicManager.php @@ -16,7 +16,6 @@ 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 DateTime; use DateTimeInterface; @@ -48,7 +47,6 @@ readonly class TopicManager implements TopicManagerInterface private HubInterface $hub, private LoggerInterface $logger, private CacheManager $cacheManager, - private PlayedGameRepository $playedGameRepository, private UserRepository $userRepository, private RequestStack $requestStack, private Security $security, @@ -57,7 +55,7 @@ readonly class TopicManager implements TopicManagerInterface public function subscribe(string $gameAssoc, string $userName): void { - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); if (null === $playedGame) { return; @@ -120,7 +118,7 @@ readonly class TopicManager implements TopicManagerInterface { // If the game was still waiting for a second player, stamp it as abandoned // so it no longer appears in the waiting-games query, and remove from lobby. - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); if (null !== $playedGame) { $users = $this->getUserCollection($playedGame); if ($this->getPlayerCount($users) === 1) { @@ -148,7 +146,7 @@ readonly class TopicManager implements TopicManagerInterface if (null !== $event['resign']) { $this->saveResignToDb($gameAssoc, $event['resign']); - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); $users = $this->getUserCollection($playedGame); $count = $this->getPlayerCount($users); $topic = 'mineseeker/channel/' . $gameAssoc; @@ -177,7 +175,7 @@ readonly class TopicManager implements TopicManagerInterface $player = $event['player']; // 'red' | 'blue' $isBomb = (bool)$event['bomb']; - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); $grid = $this->loadGrid($gameAssoc); /** Cells already revealed by previous steps (as "row,col" => true map) */ @@ -267,7 +265,7 @@ readonly class TopicManager implements TopicManagerInterface /** Load the grid rows from the database as a 2-D array. */ private function loadGrid(string $gameAssoc): array { - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); $gridEntity = $playedGame?->grid; if (null === $gridEntity) { @@ -569,11 +567,6 @@ readonly class TopicManager implements TopicManagerInterface return $mines; } - private function getPlayedGame(string $gameAssoc): ?PlayedGame - { - return $this->playedGameRepository->findOneByGameAssoc($gameAssoc); - } - private function getPlayerCount(array $users): int { $red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0; @@ -584,7 +577,7 @@ readonly class TopicManager implements TopicManagerInterface private function saveResignToDb(string $gameAssoc, string $color): void { - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); $playedGame->resign = $color; $this->em->persist($playedGame); $this->em->flush(); @@ -600,7 +593,7 @@ readonly class TopicManager implements TopicManagerInterface array $bonusData = [] ): void { try { - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); $step = new Step(); $step->row = $event['coords'][0]; @@ -640,7 +633,7 @@ readonly class TopicManager implements TopicManagerInterface private function saveUserToDb(string $gameAssoc, string $userName, int $count): array { - $playedGame = $this->getPlayedGame($gameAssoc); + $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc); null !== $this->security->getUser() ? $this->saveRegisteredUser($userName, $count, $playedGame) @@ -721,7 +714,7 @@ readonly class TopicManager implements TopicManagerInterface public function publishChallenge(string $targetGameAssoc, string $challengerGameAssoc): void { - $challengerGame = $this->getPlayedGame($challengerGameAssoc); + $challengerGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($challengerGameAssoc); $challengerName = 'Unknown'; if (null !== $challengerGame) {