From c4c989db4ccc2c8348e8b9701cd33b176c410e2f Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 8 Jan 2026 19:32:13 +0100 Subject: [PATCH 1/2] Trying to add waiting pages --- src/Game/Controller/GameController.php | 47 ++++- src/Game/Entity/Player.php | 2 +- src/Game/Enum/SessionSettingType.php | 10 + src/Game/Service/GameDashboardService.php | 143 +++++++++++++- src/Game/Service/GameResponseService.php | 7 +- templates/game/waiting.html.twig | 96 ++++++++++ tests/Game/GameDashboardServiceTest.php | 215 +++++++++++++++++++++- 7 files changed, 505 insertions(+), 15 deletions(-) create mode 100644 templates/game/waiting.html.twig diff --git a/src/Game/Controller/GameController.php b/src/Game/Controller/GameController.php index 9a1ab78..cfc9ef6 100644 --- a/src/Game/Controller/GameController.php +++ b/src/Game/Controller/GameController.php @@ -4,9 +4,14 @@ declare(strict_types=1); namespace App\Game\Controller; use App\Game\Entity\Session; +use App\Game\Entity\SessionSetting; +use App\Game\Enum\SessionSettingType; +use App\Game\Enum\SessionStatus; use App\Game\Repository\GameRepository; +use App\Game\Repository\PlayerRepository; use App\Game\Repository\SessionRepository; use App\Game\Service\GameDashboardService; +use App\Tech\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; @@ -14,9 +19,16 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\DependencyInjection\Attribute\Autowire; final class GameController extends AbstractController { + public function __construct( + #[Autowire('%env(MERCURE_PUBLIC_URL)%')] + private string $mercurePublicUrl + ) { + } + #[Route(path: '', name: 'game_dashboard', methods: ['GET', 'POST'])] #[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))] public function dashboard( @@ -92,17 +104,48 @@ final class GameController extends AbstractController ]); } - #[Route(path: '/{session}', name: 'game')] + #[Route(path: '/{session}', name: 'game', methods: ['GET', 'POST'])] #[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))] #[IsGranted('SESSION_VIEW', subject: 'session')] public function index( Session $session, + Request $request, Security $security, - \App\Game\Repository\PlayerRepository $playerRepository + PlayerRepository $playerRepository, + GameDashboardService $dashboardService ): Response { $user = $security->getUser(); + if (!$user instanceof User) { + throw $this->createAccessDeniedException(); + } + $player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]); + + if ($request->isMethod('POST') && $request->request->has('toggle_ready')) { + $dashboardService->toggleReady($session, $user); + return $this->redirectToRoute('game', ['session' => $session->getId()]); + } + + // Periodically check readiness timeout + $dashboardService->checkAllPlayersReady($session); + + if ($session->getStatus() === SessionStatus::READY) { + $isReady = false; + if ($player) { + $settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen()); + if ($settingName) { + $isReady = $session->getSettings()->exists(fn($i, SessionSetting $s) => $s->getName() === $settingName && $s->getPlayer() === $player); + } + } + + return $this->render('game/waiting.html.twig', [ + 'session' => $session, + 'isReady' => $isReady, + 'mercure_public_url' => $this->mercurePublicUrl, + ]); + } + $screen = $player ? $player->getScreen() : 0; return $this->render('game/index.html.twig', [ diff --git a/src/Game/Entity/Player.php b/src/Game/Entity/Player.php index e9802f6..a99bd7c 100644 --- a/src/Game/Entity/Player.php +++ b/src/Game/Entity/Player.php @@ -60,7 +60,7 @@ class Player return $this->screen; } - public function setScreen(int $screen): static + public function setScreen(?int $screen): static { $this->screen = $screen; diff --git a/src/Game/Enum/SessionSettingType.php b/src/Game/Enum/SessionSettingType.php index 36279fb..7788245 100644 --- a/src/Game/Enum/SessionSettingType.php +++ b/src/Game/Enum/SessionSettingType.php @@ -60,4 +60,14 @@ enum SessionSettingType: string case SPECIAL_REPORT_CODE_DOYLE = 'SpecialReportCodeDoyle'; case SPECIAL_REPORT_CODE_VEGA = 'SpecialReportCodeVega'; case SPECIAL_REPORT_CODE_LENNOX = 'SpecialReportCodeLennox'; + case READY_AT_FOR_PLAYER1 = 'ReadyAtForPlayer1'; + case READY_AT_FOR_PLAYER2 = 'ReadyAtForPlayer2'; + case READY_AT_FOR_PLAYER3 = 'ReadyAtForPlayer3'; + case READY_AT_FOR_PLAYER4 = 'ReadyAtForPlayer4'; + case READY_AT_FOR_PLAYER5 = 'ReadyAtForPlayer5'; + case READY_AT_FOR_PLAYER6 = 'ReadyAtForPlayer6'; + case READY_AT_FOR_PLAYER7 = 'ReadyAtForPlayer7'; + case READY_AT_FOR_PLAYER8 = 'ReadyAtForPlayer8'; + case READY_AT_FOR_PLAYER9 = 'ReadyAtForPlayer9'; + case READY_AT_FOR_PLAYER10 = 'ReadyAtForPlayer10'; } diff --git a/src/Game/Service/GameDashboardService.php b/src/Game/Service/GameDashboardService.php index efa2887..b53aa7b 100644 --- a/src/Game/Service/GameDashboardService.php +++ b/src/Game/Service/GameDashboardService.php @@ -15,6 +15,9 @@ use App\Game\Repository\SessionRepository; use App\Tech\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\Mercure\Update; +use Symfony\Component\DependencyInjection\Attribute\Autowire; final class GameDashboardService { @@ -22,6 +25,9 @@ final class GameDashboardService private readonly GameRepository $gameRepository, private readonly SessionRepository $sessionRepository, private readonly EntityManagerInterface $entityManager, + private readonly HubInterface $hub, + #[Autowire('%env(MERCURE_TOPIC_BASE)%')] + private readonly string $mercureTopicBase, ) { } @@ -66,13 +72,17 @@ final class GameDashboardService $player = new Player(); $player->setUser($user); - $player->setSession($session); + $session->addPlayer($player); $this->entityManager->persist($session); $this->entityManager->persist($player); $this->entityManager->flush(); + if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) { + $this->startSession($session); + } + return $session; } @@ -107,12 +117,16 @@ final class GameDashboardService $player = new Player(); $player->setUser($user); - $player->setSession($session); + $session->addPlayer($player); $this->entityManager->persist($player); $this->entityManager->flush(); + if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) { + $this->startSession($session); + } + return true; } @@ -122,7 +136,7 @@ final class GameDashboardService return false; } - if ($session->getStatus() !== SessionStatus::CREATED || $session->getTimer() > 0) { + if (!in_array($session->getStatus(), [SessionStatus::CREATED, SessionStatus::READY]) || $session->getTimer() > 0) { return false; } @@ -138,6 +152,14 @@ final class GameDashboardService return false; } + if ($session->getStatus() === SessionStatus::READY) { + $session->setStatus(SessionStatus::CREATED); + // Clear assignments for all remaining players since the game needs to be "started" again + foreach ($session->getPlayers() as $player) { + $player->setScreen(null); + } + } + // Remove player specific settings (like rights) foreach ($session->getSettings() as $setting) { if ($setting->getPlayer() === $playerToDelete) { @@ -240,6 +262,14 @@ final class GameDashboardService return false; } + // Clean up any existing assignments (e.g. if we reverted from READY to CREATED) + foreach ($session->getSettings() as $setting) { + if ($setting->getPlayer() !== null) { + $session->removeSetting($setting); + $this->entityManager->remove($setting); + } + } + // Shuffle players to assign random screens shuffle($players); @@ -257,6 +287,113 @@ final class GameDashboardService return true; } + public function toggleReady(Session $session, User $user): bool + { + if ($session->getStatus() !== SessionStatus::READY) { + return false; + } + + $player = null; + foreach ($session->getPlayers() as $p) { + if ($p->getUser() === $user) { + $player = $p; + break; + } + } + + if (!$player) { + return false; + } + + $settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen()); + if (!$settingName) { + return false; + } + + /** @var \App\Game\Repository\SessionSettingRepository $settingRepo */ + $settingRepo = $this->entityManager->getRepository(SessionSetting::class); + $setting = $settingRepo->getSetting($session, $settingName, $player); + + if ($setting) { + $session->removeSetting($setting); + $this->entityManager->remove($setting); + } else { + $setting = new SessionSetting(); + $setting->setSession($session); + $setting->setPlayer($player); + $setting->setName($settingName); + $setting->setValue((string)(new \DateTime())->getTimestamp()); + $this->entityManager->persist($setting); + } + + $this->checkAllPlayersReady($session); + $this->entityManager->flush(); + + $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting]))); + + return true; + } + + public function checkAllPlayersReady(Session $session): void + { + if ($session->getStatus() !== SessionStatus::READY) { + return; + } + + $players = $session->getPlayers(); + $numPlayers = $session->getGame()->getNumberOfPlayers(); + + if (count($players) < $numPlayers) { + return; + } + + $readyPlayersCount = 0; + $now = new \DateTime(); + + /** @var \App\Game\Repository\SessionSettingRepository $settingRepo */ + $settingRepo = $this->entityManager->getRepository(SessionSetting::class); + + foreach ($players as $player) { + $settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen()); + if (!$settingName) { + continue; + } + + $setting = $settingRepo->getSetting($session, $settingName, $player); + if ($setting) { + $readyAtTimestamp = (int)$setting->getValue(); + // Check timeout: 1 minute = 60 seconds + if (($now->getTimestamp() - $readyAtTimestamp) > 60) { + $session->removeSetting($setting); + $this->entityManager->remove($setting); + } else { + $readyPlayersCount++; + } + } + } + + if ($readyPlayersCount === $numPlayers) { + $session->setStatus(SessionStatus::PLAYING); + $this->entityManager->persist($session); + + // Clean up ready settings + foreach ($players as $player) { + $settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen()); + if ($settingName) { + $setting = $settingRepo->getSetting($session, $settingName, $player); + if ($setting) { + $session->removeSetting($setting); + $this->entityManager->remove($setting); + } + } + } + + $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready']))); + } + } + public function generateInviteCode(Session $session, UserInterface $user, bool $isAdmin): ?string { // Security check: is user part of this session? diff --git a/src/Game/Service/GameResponseService.php b/src/Game/Service/GameResponseService.php index b298e27..e2e746c 100644 --- a/src/Game/Service/GameResponseService.php +++ b/src/Game/Service/GameResponseService.php @@ -12,6 +12,7 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; +use Symfony\Component\DependencyInjection\Attribute\Autowire; class GameResponseService { @@ -22,6 +23,8 @@ class GameResponseService private HubInterface $hub, private EntityManagerInterface $entityManager, private string $projectDir, + #[Autowire('%env(MERCURE_TOPIC_BASE)%')] + private string $mercureTopicBase, ) { } @@ -375,7 +378,7 @@ class GameResponseService $this->entityManager->flush(); // Notify the player that their codes have changed - $topic = $_ENV['MERCURE_TOPIC_BASE'] . '/game/hub-' . $session->getId(); + $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); $notification = "Security Alert: One of your verify codes was shared and has been regenerated."; // We send it only to this player (screen) $this->hub->publish(new Update($topic, json_encode([$screen, $notification]))); @@ -662,7 +665,7 @@ class GameResponseService $this->entityManager->persist($everyoneVerifiedSetting); $this->entityManager->flush(); - $topic = $_ENV['MERCURE_TOPIC_BASE'] . '/game/hub-' . $session->getId(); + $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); $message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages."; $this->hub->publish(new Update($topic, json_encode([0, $message]))); } diff --git a/templates/game/waiting.html.twig b/templates/game/waiting.html.twig new file mode 100644 index 0000000..b2c26f0 --- /dev/null +++ b/templates/game/waiting.html.twig @@ -0,0 +1,96 @@ +{% extends 'base.html.twig' %} + +{% block title %}Waiting for players - {{ session.game.name }}{% endblock %} + +{% block body %} +
+
+
+
+
+

Waiting for all players to be ready

+
+
+

{{ session.game.name }}

+

Welcome to the game! Please wait for all players to join and signal they are ready to start.

+ +
+ Game Information: +
    +
  • Number of players: {{ session.game.numberOfPlayers }}
  • +
  • Current players: {{ session.players|length }} / {{ session.game.numberOfPlayers }}
  • +
+
+ +
+
Players status:
+
    + {% for player in session.players %} +
  • + {{ player.user.username }} + {% set playerReady = false %} + {% set settingName = 'ReadyAtForPlayer' ~ player.screen %} + {% for setting in session.settings %} + {% if setting.name.value == settingName and setting.player == player %} + {% set playerReady = true %} + {% endif %} + {% endfor %} + + {% if playerReady %} + Ready + {% else %} + Not ready + {% endif %} +
  • + {% endfor %} +
+
+ +
+ +
+
+ + +
+

+ Note: If all players are not ready within 1 minute of you checking this, your status will be reset automatically. +

+
+ + +
+
+
+
+
+ + + + +{% endblock %} diff --git a/tests/Game/GameDashboardServiceTest.php b/tests/Game/GameDashboardServiceTest.php index 53caef5..644308e 100644 --- a/tests/Game/GameDashboardServiceTest.php +++ b/tests/Game/GameDashboardServiceTest.php @@ -8,12 +8,14 @@ use App\Game\Entity\Player; use App\Game\Entity\Session; use App\Game\Entity\SessionSetting; use App\Game\Enum\GameStatus; +use App\Game\Enum\SessionStatus; use App\Game\Enum\SessionSettingType; use App\Game\Service\GameDashboardService; use App\Tech\Entity\User; use Doctrine\ORM\EntityManagerInterface; use App\Game\Repository\GameRepository; use App\Game\Repository\SessionRepository; +use Symfony\Component\Mercure\HubInterface; use PHPUnit\Framework\TestCase; class GameDashboardServiceTest extends TestCase @@ -21,6 +23,7 @@ class GameDashboardServiceTest extends TestCase private $entityManager; private $gameRepository; private $sessionRepository; + private $hub; private $service; protected function setUp(): void @@ -28,11 +31,14 @@ class GameDashboardServiceTest extends TestCase $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->gameRepository = $this->createMock(GameRepository::class); $this->sessionRepository = $this->createMock(SessionRepository::class); + $this->hub = $this->createMock(HubInterface::class); $this->service = new GameDashboardService( $this->gameRepository, $this->sessionRepository, - $this->entityManager + $this->entityManager, + $this->hub, + 'http://localhost/topic' ); } @@ -193,24 +199,219 @@ class GameDashboardServiceTest extends TestCase $this->assertCount(1, $session->getPlayers()); } - public function testLeaveSessionDeletesSessionIfLastPlayer(): void + public function testToggleReady(): void { $user = new User(); + $game = new Game(); + $game->setNumberOfPlayers(1); $session = new Session(); - $session->setStatus(\App\Game\Enum\SessionStatus::CREATED); - $session->setTimer(0); + $session->setGame($game); + $session->setStatus(SessionStatus::READY); $player = new Player(); $player->setUser($user); - $player->setSession($session); + $player->setScreen(1); $session->addPlayer($player); - $this->entityManager->expects($this->exactly(2)) + $repo = $this->createMock(\App\Game\Repository\SessionSettingRepository::class); + $this->entityManager->method('getRepository') + ->willReturn($repo); + + // First call: toggle ON + $repo->expects($this->atLeastOnce()) + ->method('getSetting') + ->willReturn(null); // No setting initially + + $this->entityManager->expects($this->atLeastOnce()) + ->method('persist') + ->with($this->callback(function($entity) { + return $entity instanceof SessionSetting && $entity->getName() === SessionSettingType::READY_AT_FOR_PLAYER1; + })); + + $result = $this->service->toggleReady($session, $user); + $this->assertTrue($result); + } + + public function testCheckAllPlayersReadyTransitionsStatus(): void + { + $game = new Game(); + $game->setNumberOfPlayers(2); + $session = new Session(); + $session->setGame($game); + $session->setStatus(SessionStatus::READY); + + $player1 = new Player(); + $player1->setScreen(1); + $session->addPlayer($player1); + + $player2 = new Player(); + $player2->setScreen(2); + $session->addPlayer($player2); + + $repo = $this->createMock(\App\Game\Repository\SessionSettingRepository::class); + $this->entityManager->method('getRepository') + ->willReturn($repo); + + $setting1 = new SessionSetting(); + $setting1->setName(SessionSettingType::READY_AT_FOR_PLAYER1); + $setting1->setValue((string)time()); + $setting1->setPlayer($player1); + + $setting2 = new SessionSetting(); + $setting2->setName(SessionSettingType::READY_AT_FOR_PLAYER2); + $setting2->setValue((string)time()); + $setting2->setPlayer($player2); + + $repo->method('getSetting') + ->willReturnCallback(function($s, $name, $p) use ($setting1, $setting2) { + if ($name === SessionSettingType::READY_AT_FOR_PLAYER1) return $setting1; + if ($name === SessionSettingType::READY_AT_FOR_PLAYER2) return $setting2; + return null; + }); + + $this->service->checkAllPlayersReady($session); + + $this->assertEquals(SessionStatus::PLAYING, $session->getStatus()); + } + + public function testCheckAllPlayersReadyTimeouts(): void + { + $game = new Game(); + $game->setNumberOfPlayers(2); + $session = new Session(); + $session->setGame($game); + $session->setStatus(SessionStatus::READY); + + $player1 = new Player(); + $player1->setScreen(1); + $session->addPlayer($player1); + + $player2 = new Player(); + $player2->setScreen(2); + $session->addPlayer($player2); + + $repo = $this->createMock(\App\Game\Repository\SessionSettingRepository::class); + $this->entityManager->method('getRepository') + ->willReturn($repo); + + $setting1 = new SessionSetting(); + $setting1->setName(SessionSettingType::READY_AT_FOR_PLAYER1); + $setting1->setValue((string)(time() - 70)); // Timed out + $setting1->setPlayer($player1); + $session->addSetting($setting1); + + $repo->method('getSetting') + ->willReturnCallback(function($s, $name, $p) use ($setting1) { + if ($name === SessionSettingType::READY_AT_FOR_PLAYER1) return $setting1; + return null; + }); + + $this->entityManager->expects($this->once()) + ->method('remove') + ->with($setting1); + + $this->service->checkAllPlayersReady($session); + + $this->assertEquals(SessionStatus::READY, $session->getStatus()); + } + + public function testJoinSessionTriggersStartSessionWhenFull(): void + { + $user = new User(); + $user->setUsername('testuser'); + $game = new Game(); + $game->setNumberOfPlayers(2); + $session = new Session(); + $session->setGame($game); + $session->setStatus(\App\Game\Enum\SessionStatus::CREATED); + + $existingUser = new User(); + $existingUser->setUsername('existing'); + $existingPlayer = new Player(); + $existingPlayer->setUser($existingUser); + $session->addPlayer($existingPlayer); + + $setting = new SessionSetting(); + $setting->setSession($session); + $setting->setName(SessionSettingType::INVITE_CODE); + $setting->setValue('abc-123'); + + $repo = $this->createMock(\Doctrine\ORM\EntityRepository::class); + $this->entityManager->method('getRepository') + ->willReturn($repo); + + $repo->method('findOneBy') + ->willReturn($setting); + + // Expectation: + // 1. Persist new Player + // 2. Persist Player 1 (existing) screen update + // 3. Persist Player 2 (new) screen update + // 4. Persist Session status update + // 5. 5 Settings for Player 1 + // 6. 5 Settings for Player 2 + // Total = 1 + 1 + 1 + 1 + 5 + 5 = 14 persists + $this->entityManager->expects($this->exactly(14)) + ->method('persist'); + + $result = $this->service->joinSession('abc-123', $user); + + $this->assertTrue($result); + $this->assertEquals(\App\Game\Enum\SessionStatus::READY, $session->getStatus()); + } + + public function testLeaveSessionRevertsFromReady(): void + { + $user = new User(); + $user->setUsername('testuser'); + $session = new Session(); + $session->setStatus(\App\Game\Enum\SessionStatus::READY); + $session->setTimer(0); + + $player1 = new Player(); + $player1->setUser($user); + $player1->setScreen(1); + $player1->setSession($session); + $session->addPlayer($player1); + + $player2 = new Player(); + $player2->setUser(new User()); + $player2->setScreen(2); + $player2->setSession($session); + $session->addPlayer($player2); + + $this->entityManager->expects($this->exactly(1)) ->method('remove'); - // 1. Player, 2. Session + // 1. Player1, any settings (none in this test setup) $result = $this->service->leaveSession($session, $user); $this->assertTrue($result); + $this->assertEquals(\App\Game\Enum\SessionStatus::CREATED, $session->getStatus()); + $this->assertNull($player2->getScreen()); + } + + public function testCreateSessionForOnePlayerGame(): void + { + $game = new Game(); + $game->setStatus(GameStatus::OPEN); + $game->setNumberOfPlayers(1); + + $user = new User(); + $user->setUsername('testuser'); + + // 1. Session persist + // 2. Player persist + // 3. Player screen persist (during startSession) + // 4. Session status persist (during startSession) + // 5. 5 Settings for Player (during startSession) + // Total = 1 + 1 + 1 + 1 + 5 = 9 persists + $this->entityManager->expects($this->exactly(9)) + ->method('persist'); + + $session = $this->service->createSession($game, $user, false); + + $this->assertInstanceOf(Session::class, $session); + $this->assertEquals(\App\Game\Enum\SessionStatus::READY, $session->getStatus()); } } From b965f0f085570097e85bf7739236a33c82851615 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 8 Jan 2026 20:11:14 +0100 Subject: [PATCH 2/2] Added mercure to update when everyone is ready --- src/Game/Controller/GameController.php | 8 ++++++- src/Game/Service/GameDashboardService.php | 28 ++++++++++++++++++---- src/Game/Service/GameResponseService.php | 20 ++++++++++++---- templates/game/waiting.html.twig | 29 +++++++++++++++++++++++ tests/Game/GameDashboardServiceTest.php | 6 +++++ 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/Game/Controller/GameController.php b/src/Game/Controller/GameController.php index cfc9ef6..1da07e3 100644 --- a/src/Game/Controller/GameController.php +++ b/src/Game/Controller/GameController.php @@ -132,16 +132,22 @@ final class GameController extends AbstractController if ($session->getStatus() === SessionStatus::READY) { $isReady = false; + $readyAt = null; if ($player) { $settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen()); if ($settingName) { - $isReady = $session->getSettings()->exists(fn($i, SessionSetting $s) => $s->getName() === $settingName && $s->getPlayer() === $player); + $setting = $session->getSettings()->filter(fn(SessionSetting $s) => $s->getName() === $settingName && $s->getPlayer() === $player)->first(); + if ($setting) { + $isReady = true; + $readyAt = (int)$setting->getValue(); + } } } return $this->render('game/waiting.html.twig', [ 'session' => $session, 'isReady' => $isReady, + 'readyAt' => $readyAt, 'mercure_public_url' => $this->mercurePublicUrl, ]); } diff --git a/src/Game/Service/GameDashboardService.php b/src/Game/Service/GameDashboardService.php index b53aa7b..db8c7b1 100644 --- a/src/Game/Service/GameDashboardService.php +++ b/src/Game/Service/GameDashboardService.php @@ -329,8 +329,12 @@ final class GameDashboardService $this->checkAllPlayersReady($session); $this->entityManager->flush(); - $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); - $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting]))); + try { + $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $player->getScreen(), 'ready' => !$setting]))); + } catch (\Exception $e) { + // Mercure might be down, but we don't want to crash the game + } return true; } @@ -350,6 +354,7 @@ final class GameDashboardService $readyPlayersCount = 0; $now = new \DateTime(); + $anyReset = false; /** @var \App\Game\Repository\SessionSettingRepository $settingRepo */ $settingRepo = $this->entityManager->getRepository(SessionSetting::class); @@ -367,12 +372,23 @@ final class GameDashboardService if (($now->getTimestamp() - $readyAtTimestamp) > 60) { $session->removeSetting($setting); $this->entityManager->remove($setting); + $anyReset = true; } else { $readyPlayersCount++; } } } + if ($anyReset) { + $this->entityManager->flush(); + try { + $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready']))); + } catch (\Exception $e) { + // Mercure might be down + } + } + if ($readyPlayersCount === $numPlayers) { $session->setStatus(SessionStatus::PLAYING); $this->entityManager->persist($session); @@ -389,8 +405,12 @@ final class GameDashboardService } } - $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); - $this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready']))); + try { + $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready']))); + } catch (\Exception $e) { + // Mercure might be down, but we don't want to crash the game + } } } diff --git a/src/Game/Service/GameResponseService.php b/src/Game/Service/GameResponseService.php index e2e746c..0e1a221 100644 --- a/src/Game/Service/GameResponseService.php +++ b/src/Game/Service/GameResponseService.php @@ -336,8 +336,12 @@ class GameResponseService if(is_null($activeGame)) return false; - $topic = $_ENV['MERCURE_TOPIC_BASE'] . '/game/hub-' . $activeGame; - $this->hub->publish(new Update($topic, json_encode([$sendTo, $message]))); + $topic = $this->mercureTopicBase . '/game/hub-' . $activeGame; + try { + $this->hub->publish(new Update($topic, json_encode([$sendTo, $message]))); + } catch (\Exception $e) { + // Mercure might be down + } $this->updateChatTracking($player, (int)$sendTo); @@ -381,7 +385,11 @@ class GameResponseService $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); $notification = "Security Alert: One of your verify codes was shared and has been regenerated."; // We send it only to this player (screen) - $this->hub->publish(new Update($topic, json_encode([$screen, $notification]))); + try { + $this->hub->publish(new Update($topic, json_encode([$screen, $notification]))); + } catch (\Exception $e) { + // Mercure might be down + } } } @@ -667,7 +675,11 @@ class GameResponseService $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId(); $message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages."; - $this->hub->publish(new Update($topic, json_encode([0, $message]))); + try { + $this->hub->publish(new Update($topic, json_encode([0, $message]))); + } catch (\Exception $e) { + // Mercure might be down + } } } diff --git a/templates/game/waiting.html.twig b/templates/game/waiting.html.twig index b2c26f0..6e65de3 100644 --- a/templates/game/waiting.html.twig +++ b/templates/game/waiting.html.twig @@ -14,6 +14,15 @@

{{ session.game.name }}

Welcome to the game! Please wait for all players to join and signal they are ready to start.

+
+ Please keep the following things in mind: +
    +
  • This game is best played in full screen mode. For windows, press F11. For Mac, press Cmd+Ctrl+F.
  • +
  • There is no need to reload the page. There is even a chance this could break the game.
  • +
  • If your internet connection is lost, you can get back in the game after internet has been fixed.
  • +
+
+
Game Information:
    @@ -72,6 +81,7 @@ @@ -79,6 +89,7 @@ const config = document.getElementById('mercure-config'); const publicUrl = config.dataset.mercurePublicUrl; const topic = config.dataset.topic; + const readyAt = config.dataset.readyAt; if (publicUrl && topic) { const url = new URL(publicUrl); @@ -92,5 +103,23 @@ } }; } + + // Client-side timeout for ready status + if (readyAt) { + const timeoutMs = 61000; // 61 seconds (slightly more than server-side 60s) + const now = Date.now(); + const readyAtMs = readyAt * 1000; + const timeElapsed = now - readyAtMs; + const timeLeft = timeoutMs - timeElapsed; + + if (timeLeft > 0) { + setTimeout(() => { + window.location.reload(); + }, timeLeft); + } else { + // Already timed out, reload to sync with server + window.location.reload(); + } + } {% endblock %} diff --git a/tests/Game/GameDashboardServiceTest.php b/tests/Game/GameDashboardServiceTest.php index 644308e..32dc4fe 100644 --- a/tests/Game/GameDashboardServiceTest.php +++ b/tests/Game/GameDashboardServiceTest.php @@ -310,6 +310,12 @@ class GameDashboardServiceTest extends TestCase ->method('remove') ->with($setting1); + $this->entityManager->expects($this->atLeastOnce()) + ->method('flush'); + + $this->hub->expects($this->once()) + ->method('publish'); + $this->service->checkAllPlayersReady($session); $this->assertEquals(SessionStatus::READY, $session->getStatus());