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 %} +
Welcome to the game! Please wait for all players to join and signal they are ready to start.
+ +