From 444f54b6c6fae672156c8df964c53205c171d996 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 10 Aug 2026 15:32:23 +0200 Subject: [PATCH] Add pregame lobby with live chat Players used to get bounced back to the dashboard when a session wasn't full yet. Now they land on a lobby page showing who has joined, and can chat with each other while waiting - messages are broadcast live over the existing Mercure hub, and the session auto-starts (and the lobby notifies everyone) once it fills up. Co-Authored-By: Claude Sonnet 5 --- migrations/Version20260810120000.php | 30 +++++++ src/Game/Controller/GameController.php | 13 ++- src/Game/Entity/LobbyMessage.php | 88 +++++++++++++++++++ .../Repository/LobbyMessageRepository.php | 33 +++++++ src/Game/Service/GameDashboardService.php | 64 +++++++++++++- templates/game/lobby.html.twig | 72 +++++++++++++++ tests/Game/GameDashboardServiceTest.php | 5 +- 7 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 migrations/Version20260810120000.php create mode 100644 src/Game/Entity/LobbyMessage.php create mode 100644 src/Game/Repository/LobbyMessageRepository.php create mode 100644 templates/game/lobby.html.twig diff --git a/migrations/Version20260810120000.php b/migrations/Version20260810120000.php new file mode 100644 index 0000000..e8d6d4e --- /dev/null +++ b/migrations/Version20260810120000.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE lobby_message (id INT AUTO_INCREMENT NOT NULL, session_id INT NOT NULL, player_id INT NOT NULL, content VARCHAR(500) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_LOBBY_MESSAGE_SESSION (session_id), INDEX IDX_LOBBY_MESSAGE_PLAYER (player_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_SESSION FOREIGN KEY (session_id) REFERENCES session (id)'); + $this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_PLAYER FOREIGN KEY (player_id) REFERENCES player (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_SESSION'); + $this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_PLAYER'); + $this->addSql('DROP TABLE lobby_message'); + } +} diff --git a/src/Game/Controller/GameController.php b/src/Game/Controller/GameController.php index bc504a5..d9929cc 100644 --- a/src/Game/Controller/GameController.php +++ b/src/Game/Controller/GameController.php @@ -141,12 +141,21 @@ final class GameController extends AbstractController return $this->redirectToRoute('game', ['session' => $session->getId()]); } + if ($request->isMethod('POST') && $request->request->has('send_message')) { + $dashboardService->postLobbyMessage($session, $user, (string) $request->request->get('content', '')); + return $this->redirectToRoute('game', ['session' => $session->getId()]); + } + // Lazily pick up readiness changes from other players since our last request $dashboardService->checkAllPlayersReady($session); if ($session->getStatus() === SessionStatus::CREATED) { - $this->addFlash('info', 'This session is still waiting for more players to join.'); - return $this->redirectToRoute('game_dashboard'); + return $this->render('game/lobby.html.twig', [ + 'session' => $session, + 'messages' => $dashboardService->getLobbyMessages($session), + 'player' => $player, + 'mercure_public_url' => $this->mercurePublicUrl, + ]); } if ($session->getStatus() === SessionStatus::WON) { diff --git a/src/Game/Entity/LobbyMessage.php b/src/Game/Entity/LobbyMessage.php new file mode 100644 index 0000000..7dedd7d --- /dev/null +++ b/src/Game/Entity/LobbyMessage.php @@ -0,0 +1,88 @@ +createdAt = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getSession(): ?Session + { + return $this->session; + } + + public function setSession(?Session $session): static + { + $this->session = $session; + + return $this; + } + + public function getPlayer(): ?Player + { + return $this->player; + } + + public function setPlayer(?Player $player): static + { + $this->player = $player; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(string $content): static + { + $this->content = $content; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } +} diff --git a/src/Game/Repository/LobbyMessageRepository.php b/src/Game/Repository/LobbyMessageRepository.php new file mode 100644 index 0000000..f2a51a7 --- /dev/null +++ b/src/Game/Repository/LobbyMessageRepository.php @@ -0,0 +1,33 @@ + + */ +class LobbyMessageRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, LobbyMessage::class); + } + + /** + * @return LobbyMessage[] + */ + public function findForSession(Session $session, int $limit = 100): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.session = :session') + ->setParameter('session', $session) + ->orderBy('m.createdAt', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Game/Service/GameDashboardService.php b/src/Game/Service/GameDashboardService.php index 6da63d6..a0ebb4a 100644 --- a/src/Game/Service/GameDashboardService.php +++ b/src/Game/Service/GameDashboardService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Game\Service; use App\Game\Entity\Game; +use App\Game\Entity\LobbyMessage; use App\Game\Entity\Player; use App\Game\Entity\Session; use App\Game\Entity\SessionSetting; @@ -11,6 +12,7 @@ use App\Game\Enum\GameStatus; use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionStatus; use App\Game\Repository\GameRepository; +use App\Game\Repository\LobbyMessageRepository; use App\Game\Repository\SessionRepository; use App\Tech\Entity\User; use Doctrine\ORM\EntityManagerInterface; @@ -21,10 +23,12 @@ use Symfony\Component\Mercure\Update; final class GameDashboardService { private const READY_TIMEOUT_SECONDS = 60; + private const LOBBY_MESSAGE_MAX_LENGTH = 500; public function __construct( private readonly GameRepository $gameRepository, private readonly SessionRepository $sessionRepository, + private readonly LobbyMessageRepository $lobbyMessageRepository, private readonly EntityManagerInterface $entityManager, private readonly HubInterface $hub, ) { @@ -122,6 +126,8 @@ final class GameDashboardService $this->entityManager->flush(); + $this->publishLobbyEvent($session, 'player_joined'); + if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) { $this->startSession($session); } @@ -283,9 +289,60 @@ final class GameDashboardService $this->entityManager->persist($session); $this->entityManager->flush(); + $this->publishLobbyEvent($session, 'session_started'); + return true; } + /** + * @return LobbyMessage[] + */ + public function getLobbyMessages(Session $session): array + { + return $this->lobbyMessageRepository->findForSession($session); + } + + public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage + { + if ($session->getStatus() !== SessionStatus::CREATED) { + return null; + } + + $player = null; + foreach ($session->getPlayers() as $sessionPlayer) { + if ($sessionPlayer->getUser() === $user) { + $player = $sessionPlayer; + break; + } + } + + if (!$player) { + return null; + } + + $content = trim($content); + if ($content === '') { + return null; + } + $content = mb_substr($content, 0, self::LOBBY_MESSAGE_MAX_LENGTH); + + $message = new LobbyMessage(); + $message->setSession($session); + $message->setPlayer($player); + $message->setContent($content); + + $this->entityManager->persist($message); + $this->entityManager->flush(); + + $this->publishLobbyEvent($session, 'lobby_message', [ + 'username' => $user->getUserIdentifier(), + 'content' => $content, + 'createdAt' => $message->getCreatedAt()->format(DATE_ATOM), + ]); + + return $message; + } + public function toggleReady(Session $session, User $user): bool { if ($session->getStatus() !== SessionStatus::READY) { @@ -393,10 +450,15 @@ final class GameDashboardService } private function publishPlayerReady(Session $session, int $screen, bool $ready): void + { + $this->publishLobbyEvent($session, 'player_ready', ['player' => $screen, 'ready' => $ready]); + } + + private function publishLobbyEvent(Session $session, string $type, array $extra = []): void { try { $topic = '/game/hub/' . $session->getId(); - $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $screen, 'ready' => $ready]))); + $this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra)))); } catch (\Exception $e) { // Mercure might be down, but we don't want to crash the game } diff --git a/templates/game/lobby.html.twig b/templates/game/lobby.html.twig new file mode 100644 index 0000000..427fd49 --- /dev/null +++ b/templates/game/lobby.html.twig @@ -0,0 +1,72 @@ +{% extends 'layout/site.html.twig' %} + +{% block title %}Waiting for players - {{ session.game.name }}{% endblock %} + +{% block body %} +
+
+
+
+

Waiting for more players to join

+
+
+

{{ session.game.name }}

+

Share the invite code with your friends. Feel free to chat below while you wait — no need to reload the page.

+ +
+ Players joined: {{ session.players|length }} / {{ session.game.numberOfPlayers }} +
+ +
    + {% for sessionPlayer in session.players %} +
  • {{ sessionPlayer.user.username }}
  • + {% endfor %} +
+ + {% if session.players|length >= session.game.numberOfPlayers %} +
+ + +
+ {% endif %} + + Back to Dashboard +
+
+ +
+
+
Lobby chat
+
+
+
+ {% for message in messages %} +
+ {{ message.player.user.username }} + {{ message.createdAt|date('H:i') }} +
{{ message.content }}
+
+ {% else %} +

No messages yet. Say hi!

+ {% endfor %} +
+ + {% if player %} +
+ + +
+ {% else %} +

Only players in this session can chat.

+ {% endif %} +
+
+
+
+ + +{% endblock %} diff --git a/tests/Game/GameDashboardServiceTest.php b/tests/Game/GameDashboardServiceTest.php index 32dc4fe..87cc1c4 100644 --- a/tests/Game/GameDashboardServiceTest.php +++ b/tests/Game/GameDashboardServiceTest.php @@ -10,6 +10,7 @@ use App\Game\Entity\SessionSetting; use App\Game\Enum\GameStatus; use App\Game\Enum\SessionStatus; use App\Game\Enum\SessionSettingType; +use App\Game\Repository\LobbyMessageRepository; use App\Game\Service\GameDashboardService; use App\Tech\Entity\User; use Doctrine\ORM\EntityManagerInterface; @@ -23,6 +24,7 @@ class GameDashboardServiceTest extends TestCase private $entityManager; private $gameRepository; private $sessionRepository; + private $lobbyMessageRepository; private $hub; private $service; @@ -31,14 +33,15 @@ class GameDashboardServiceTest extends TestCase $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->gameRepository = $this->createMock(GameRepository::class); $this->sessionRepository = $this->createMock(SessionRepository::class); + $this->lobbyMessageRepository = $this->createMock(LobbyMessageRepository::class); $this->hub = $this->createMock(HubInterface::class); $this->service = new GameDashboardService( $this->gameRepository, $this->sessionRepository, + $this->lobbyMessageRepository, $this->entityManager, $this->hub, - 'http://localhost/topic' ); }