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:
@@ -12,16 +12,15 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\ContactMessage;
|
||||
use App\Form\ContactFormType;
|
||||
use App\Service\Email\SendContactMailService;
|
||||
use App\Service\MercureJwtService;
|
||||
use App\Service\ResolveUserNamesService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -40,14 +39,12 @@ class GameController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(env: 'APP_ENV')]
|
||||
private readonly string $env,
|
||||
private readonly string $env,
|
||||
#[Autowire(env: 'MERCURE_PUBLIC_URL')]
|
||||
private readonly string $mercurePublicUrl,
|
||||
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')]
|
||||
private readonly string $mercureSubscriberJwt,
|
||||
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
|
||||
private readonly string $appContactMailAddress,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly string $mercurePublicUrl,
|
||||
private readonly MercureJwtService $mercureJwtService,
|
||||
private readonly ResolveUserNamesService $opponentNameService,
|
||||
private readonly SendContactMailService $contactMailService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -59,12 +56,15 @@ class GameController extends AbstractController
|
||||
|
||||
#[Route('/play', name: 'MineSeekerBundle_gamePlay')]
|
||||
#[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')]
|
||||
public function play(): Response
|
||||
public function play(?string $gameAssoc = null): Response
|
||||
{
|
||||
return $this->render('Game/play.html.twig', [
|
||||
'env' => $this->env,
|
||||
'mercure_hub_url' => $this->mercurePublicUrl,
|
||||
'mercure_subscriber_jwt' => $this->mercureSubscriberJwt,
|
||||
'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken(
|
||||
$gameAssoc ?? '', $this->opponentNameService->resolveUserName(),
|
||||
),
|
||||
'opponent_name' => $this->opponentNameService->opponentName($gameAssoc),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -92,9 +92,11 @@ class GameController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$contactMessage->setIpAddress($request->getClientIp());
|
||||
|
||||
$em->persist($contactMessage);
|
||||
$em->flush();
|
||||
$this->sendMail($mailer, $contactMessage);
|
||||
|
||||
$this->contactMailService->send($contactMessage);
|
||||
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
|
||||
|
||||
return $this->redirectToRoute('MineSeekerBundle_contact');
|
||||
@@ -116,31 +118,4 @@ class GameController extends AbstractController
|
||||
{
|
||||
return $this->render('Official/rules.html.twig');
|
||||
}
|
||||
|
||||
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void
|
||||
{
|
||||
try {
|
||||
$mailer->send(
|
||||
new TemplatedEmail()
|
||||
->from('noreply@mineseeker.hu')
|
||||
->to($this->appContactMailAddress)
|
||||
->replyTo($contactMessage->getEmail())
|
||||
->subject('New Contact Message from ' . $contactMessage->getName())
|
||||
->htmlTemplate('emails/contact_notification.html.twig')
|
||||
->context(['message' => $contactMessage])
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'message' => $contactMessage,
|
||||
]);
|
||||
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'message' => $contactMessage,
|
||||
]);
|
||||
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\PlayedGame;
|
||||
use App\Repository\PlayedGameRepository;
|
||||
use App\Service\ResolveUserNamesService;
|
||||
use App\Util\RpcManager;
|
||||
use App\Util\TopicManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -39,8 +40,9 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
class MercureController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TopicManager $topicManager,
|
||||
private readonly RpcManager $rpcManager,
|
||||
private readonly TopicManager $topicManager,
|
||||
private readonly RpcManager $rpcManager,
|
||||
private readonly ResolveUserNamesService $userNamesService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -56,15 +58,18 @@ class MercureController extends AbstractController
|
||||
#[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])]
|
||||
public function connect(string $gameAssoc): Response
|
||||
{
|
||||
$payload = $this->rpcManager->getConnectInformation($gameAssoc);
|
||||
|
||||
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
|
||||
try {
|
||||
$payload = $this->rpcManager->getConnectInformation($gameAssoc);
|
||||
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
|
||||
} catch (\Exception $e) {
|
||||
return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])]
|
||||
public function join(string $gameAssoc, Request $request): JsonResponse
|
||||
public function join(string $gameAssoc): JsonResponse
|
||||
{
|
||||
$this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser(), $request);
|
||||
$this->topicManager->subscribe($gameAssoc, $this->userNamesService->resolveUserName());
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
@@ -72,15 +77,15 @@ class MercureController extends AbstractController
|
||||
#[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])]
|
||||
public function step(string $gameAssoc, Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray());
|
||||
$result = $this->topicManager->publish($gameAssoc, $this->userNamesService->resolveUserName(), $request->toArray());
|
||||
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
#[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])]
|
||||
public function leave(string $gameAssoc, Request $request): JsonResponse
|
||||
public function leave(string $gameAssoc): JsonResponse
|
||||
{
|
||||
$this->topicManager->unSubscribe($gameAssoc, $this->resolveUserName($request));
|
||||
$this->topicManager->unSubscribe($gameAssoc, $this->userNamesService->resolveUserName());
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
@@ -95,7 +100,11 @@ class MercureController extends AbstractController
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/api/game/challenge/respond/{challengerGameAssoc}', name: 'MineSeekerBundle_api_game_challenge_respond', methods: ['POST'])]
|
||||
#[Route(
|
||||
'/api/game/challenge/respond/{challengerGameAssoc}',
|
||||
name: 'MineSeekerBundle_api_game_challenge_respond',
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
@@ -106,6 +115,19 @@ class MercureController extends AbstractController
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/api/game/heartbeat/{gameAssoc}', name: 'MineSeekerBundle_api_game_heartbeat', methods: ['POST'])]
|
||||
public function heartbeat(string $gameAssoc, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->toArray();
|
||||
$color = $data['color'] ?? '';
|
||||
if ('red' !== $color && 'blue' !== $color) {
|
||||
return $this->json(['success' => false], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
$this->topicManager->publishHeartbeat($gameAssoc, $color);
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
|
||||
public function waiting(PlayedGameRepository $repo): JsonResponse
|
||||
{
|
||||
@@ -113,10 +135,10 @@ class MercureController extends AbstractController
|
||||
|
||||
$result = array_map(static function (PlayedGame $g): array {
|
||||
$name = match (true) {
|
||||
null !== $g->getRed() => $g->getRed()->getUsername(),
|
||||
null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(),
|
||||
null !== $g->getBlue() => $g->getBlue()->getUsername(),
|
||||
default => $g->getBlueAnon()?->getUserName() ?? 'Unknown',
|
||||
null !== $g->getRed() => $g->getRed()->getUsername(),
|
||||
null !== $g->getRedAnon() => $g->getRedAnon()->getUserName(),
|
||||
null !== $g->getBlue() => $g->getBlue()->getUsername(),
|
||||
default => $g->getBlueAnon()?->getUserName() ?? 'Unknown',
|
||||
};
|
||||
|
||||
return [
|
||||
@@ -128,20 +150,4 @@ class MercureController extends AbstractController
|
||||
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
private function resolveUserName(Request $request): string
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if (null !== $user) {
|
||||
return $user->getUserIdentifier();
|
||||
}
|
||||
|
||||
$sessionId = $request->getSession()->getId();
|
||||
if (empty($sessionId)) {
|
||||
$sessionId = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
return 'anon_' . $sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
|
||||
namespace App\Interfaces;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Interface TopicManagerInterface
|
||||
*
|
||||
@@ -25,7 +22,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
*/
|
||||
interface TopicManagerInterface
|
||||
{
|
||||
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void;
|
||||
public function subscribe(string $gameAssoc, string $userName): void;
|
||||
|
||||
public function unSubscribe(string $gameAssoc, string $userName): void;
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\PlayedGame;
|
||||
use App\Entity\Step;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\NonUniqueResultException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Class StepRepository
|
||||
@@ -35,4 +38,47 @@ class StepRepository extends ServiceEntityRepository
|
||||
{
|
||||
parent::__construct($registry, Step::class);
|
||||
}
|
||||
|
||||
public function findMostRecent(PlayedGame $playedGame): ?Step
|
||||
{
|
||||
try {
|
||||
return $this->createQueryBuilder('s')
|
||||
->andWhere('s.playedGame = :game')
|
||||
->setParameter('game', $playedGame)
|
||||
->orderBy('s.created', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
} catch (NonUniqueResultException $e) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'Expected at most one result for the most recent step of game ID %d, but got multiple.',
|
||||
$playedGame->getId(),
|
||||
),
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function findMostRecentForPlayer(PlayedGame $playedGame, string $player): ?Step
|
||||
{
|
||||
try {
|
||||
return $this->createQueryBuilder('s')
|
||||
->andWhere('s.playedGame = :game')
|
||||
->andWhere('s.player = :player')
|
||||
->setParameter('game', $playedGame)
|
||||
->setParameter('player', $player)
|
||||
->orderBy('s.created', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
} catch (NonUniqueResultException $e) {
|
||||
throw new RuntimeException(
|
||||
'Expected at most one result for the most recent step of player "%s" in game ID %d, but got multiple.',
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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\GridRow;
|
||||
use App\Entity\PlayedGame;
|
||||
use App\Entity\Step;
|
||||
use App\Interfaces\RpcManagerInterface;
|
||||
use App\Repository\PlayedGameRepository;
|
||||
use App\Repository\StepRepository;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
@@ -36,14 +38,15 @@ use Symfony\Component\Uid\Uuid;
|
||||
*/
|
||||
class RpcManager implements RpcManagerInterface
|
||||
{
|
||||
private const int ROWS = 16;
|
||||
private const int ROWS = 16;
|
||||
private const int COLS = 16;
|
||||
private const int MINES = 51;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly PlayedGameRepository $playedGameRepository,
|
||||
private readonly StepRepository $stepRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -56,8 +59,17 @@ class RpcManager implements RpcManagerInterface
|
||||
if (null === $playedGame) {
|
||||
try {
|
||||
return base64_encode(json_encode([
|
||||
'users' => null,
|
||||
'revealedCells' => null,
|
||||
'users' => null,
|
||||
'revealedCells' => null,
|
||||
'lastStep' => ['red' => null, 'blue' => null],
|
||||
'mostRecentStep' => null,
|
||||
'redPoints' => 0,
|
||||
'bluePoints' => 0,
|
||||
'redBonusPoints' => 0,
|
||||
'blueBonusPoints' => 0,
|
||||
'redBonusStats' => [],
|
||||
'blueBonusStats' => [],
|
||||
'gameFinished' => false,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
} catch (JsonException $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
@@ -68,15 +80,42 @@ class RpcManager implements RpcManagerInterface
|
||||
$revealedCells = $this->aggregateRevealedCells($playedGame);
|
||||
|
||||
try {
|
||||
$redPoints = $playedGame->getRedPoints() ?? 0;
|
||||
$bluePoints = $playedGame->getBluePoints() ?? 0;
|
||||
$gameFinished = $redPoints > 25 || $bluePoints > 25;
|
||||
|
||||
return base64_encode(json_encode([
|
||||
'users' => $users,
|
||||
'revealedCells' => $revealedCells,
|
||||
'users' => $users,
|
||||
'revealedCells' => $revealedCells,
|
||||
'lastStep' => $this->getLastStepPerPlayer($playedGame),
|
||||
'mostRecentStep' => $this->getMostRecentStep($playedGame),
|
||||
'redPoints' => $redPoints,
|
||||
'bluePoints' => $bluePoints,
|
||||
'redBonusPoints' => $playedGame->getRedBonusPoints() ?? 0,
|
||||
'blueBonusPoints' => $playedGame->getBlueBonusPoints() ?? 0,
|
||||
'redBonusStats' => $playedGame->getRedBonusStats() ?? [],
|
||||
'blueBonusStats' => $playedGame->getBlueBonusStats() ?? [],
|
||||
'gameFinished' => $gameFinished,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
} catch (JsonException $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent step of the game (if any).
|
||||
* Returns an array with player, row, col information or null if no steps exist.
|
||||
*/
|
||||
private function getMostRecentStep(PlayedGame $playedGame): ?array
|
||||
{
|
||||
try {
|
||||
return $this->stepToArray($this->stepRepository->findMostRecent($playedGame));
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Error getting most recent step: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function saveGrid(string $gameAssoc): bool
|
||||
{
|
||||
$existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
|
||||
@@ -94,20 +133,20 @@ class RpcManager implements RpcManagerInterface
|
||||
$gridRow = new GridRow();
|
||||
$gridRow->setGridCol($row);
|
||||
$gridRow->setGrid($grid);
|
||||
$this->entityManager->persist($gridRow);
|
||||
$this->em->persist($gridRow);
|
||||
}
|
||||
|
||||
$grid->setPlayedGame($playedGame);
|
||||
$this->entityManager->persist($grid);
|
||||
$this->em->persist($grid);
|
||||
|
||||
$playedGame->setGameAssoc($gameAssoc);
|
||||
$playedGame->setUuid(Uuid::fromString($gameAssoc));
|
||||
$playedGame->setGrid($grid);
|
||||
$playedGame->setCreated(new DateTime());
|
||||
$playedGame->setUpdated(new DateTime());
|
||||
$this->entityManager->persist($playedGame);
|
||||
$this->em->persist($playedGame);
|
||||
|
||||
$this->entityManager->flush();
|
||||
$this->em->flush();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
}
|
||||
@@ -128,6 +167,7 @@ class RpcManager implements RpcManagerInterface
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle
|
||||
*
|
||||
* @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
|
||||
*/
|
||||
for ($i = count($set) - 1; $i > 0; $i--) {
|
||||
@@ -185,6 +225,37 @@ class RpcManager implements RpcManagerInterface
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last step for each player.
|
||||
* Returns an array with 'red' and 'blue' keys, each containing row, col information or null if no steps exist for
|
||||
* that player.
|
||||
*/
|
||||
private function getLastStepPerPlayer(PlayedGame $playedGame): array
|
||||
{
|
||||
try {
|
||||
return [
|
||||
'red' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'red')),
|
||||
'blue' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'blue')),
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Error getting last step per player: ' . $e->getMessage());
|
||||
return ['red' => null, 'blue' => null];
|
||||
}
|
||||
}
|
||||
|
||||
private function stepToArray(?Step $step): ?array
|
||||
{
|
||||
if (null === $step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'player' => $step->getPlayer(),
|
||||
'row' => (int)$step->getRow(),
|
||||
'col' => (int)$step->getCol(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getUserCollection(PlayedGame $playedGame): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -26,10 +26,9 @@ use JsonException;
|
||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Class TopicManager
|
||||
@@ -50,12 +49,14 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
private CacheManager $cacheManager,
|
||||
private PlayedGameRepository $playedGameRepository,
|
||||
private UserRepository $userRepository,
|
||||
private RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void
|
||||
public function subscribe(string $gameAssoc, string $userName): void
|
||||
{
|
||||
$playedGame = $this->getPlayedGame($gameAssoc);
|
||||
|
||||
if (null === $playedGame) {
|
||||
return;
|
||||
}
|
||||
@@ -71,7 +72,7 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
|
||||
/** Save the player to the database on a fresh join */
|
||||
if (!$isKnown && $count < 2) {
|
||||
$users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1, $request);
|
||||
$users = $this->saveUserToDb($gameAssoc, $userName, $count + 1);
|
||||
$count = $this->getPlayerCount($users);
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
if ($count === 1) {
|
||||
// One player waiting — mark as active and announce to the lobby
|
||||
$playedGame->setUpdated(new DateTime());
|
||||
|
||||
$this->em->persist($playedGame);
|
||||
$this->em->flush();
|
||||
|
||||
@@ -634,18 +636,13 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
private function saveUserToDb(
|
||||
string $gameAssoc,
|
||||
string $userName,
|
||||
?UserInterface $user,
|
||||
int $count,
|
||||
Request $request
|
||||
): array {
|
||||
private function saveUserToDb(string $gameAssoc, string $userName, int $count): array
|
||||
{
|
||||
$playedGame = $this->getPlayedGame($gameAssoc);
|
||||
|
||||
null !== $user
|
||||
null !== $this->requestStack->getCurrentRequest()->getUser()
|
||||
? $this->saveRegisteredUser($userName, $count, $playedGame)
|
||||
: $this->saveAnonUser($userName, $count, $playedGame, $request);
|
||||
: $this->saveAnonUser($userName, $count, $playedGame);
|
||||
|
||||
$this->em->persist($playedGame);
|
||||
$this->em->flush();
|
||||
@@ -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 {
|
||||
$anon = new Gamer();
|
||||
$anon->setUserName($userName);
|
||||
$anon->setIp($request->getClientIp());
|
||||
$anon->setCountry($this->extractCountry($request));
|
||||
$anon->setUserAgent($request->headers->get('User-Agent'));
|
||||
$anon->setIp($this->requestStack->getCurrentRequest()->getClientIp());
|
||||
$anon->setCountry($this->extractCountry());
|
||||
$anon->setUserAgent($this->requestStack->getCurrentRequest()->headers->get('User-Agent'));
|
||||
$anon->setConnTimestamp(new DateTime());
|
||||
|
||||
$this->em->persist($anon);
|
||||
|
||||
if ($count === 1) {
|
||||
@@ -719,6 +717,7 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
{
|
||||
$challengerGame = $this->getPlayedGame($challengerGameAssoc);
|
||||
$challengerName = 'Unknown';
|
||||
|
||||
if (null !== $challengerGame) {
|
||||
$users = $this->getUserCollection($challengerGame);
|
||||
$challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
|
||||
@@ -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
|
||||
{
|
||||
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 */
|
||||
$countryHeaders = [
|
||||
@@ -777,7 +792,7 @@ readonly class TopicManager implements TopicManagerInterface
|
||||
];
|
||||
|
||||
foreach ($countryHeaders as $header) {
|
||||
$country = $request->headers->get($header);
|
||||
$country = $this->requestStack->getCurrentRequest()->headers->get($header);
|
||||
|
||||
if (empty($country)) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user