diff --git a/src/Game/Controller/GameController.php b/src/Game/Controller/GameController.php index c7d03ab..bc504a5 100644 --- a/src/Game/Controller/GameController.php +++ b/src/Game/Controller/GameController.php @@ -136,6 +136,11 @@ final class GameController extends AbstractController return $this->redirectToRoute('game', ['session' => $session->getId()]); } + if ($request->isMethod('POST') && $request->request->has('expire_ready')) { + $dashboardService->expireOwnReadyIfDue($session, $user); + return $this->redirectToRoute('game', ['session' => $session->getId()]); + } + // Lazily pick up readiness changes from other players since our last request $dashboardService->checkAllPlayersReady($session); @@ -154,17 +159,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) { $setting = $session->getSettings()->filter(fn(SessionSetting $s) => $s->getName() === $settingName && $s->getPlayer() === $player)->first(); - $isReady = $setting !== null; + 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 fee6b6b..6da63d6 100644 --- a/src/Game/Service/GameDashboardService.php +++ b/src/Game/Service/GameDashboardService.php @@ -20,6 +20,8 @@ use Symfony\Component\Mercure\Update; final class GameDashboardService { + private const READY_TIMEOUT_SECONDS = 60; + public function __construct( private readonly GameRepository $gameRepository, private readonly SessionRepository $sessionRepository, @@ -313,11 +315,12 @@ final class GameDashboardService /** @var \App\Game\Repository\SessionSettingRepository $settingRepo */ $settingRepo = $this->entityManager->getRepository(SessionSetting::class); - $setting = $settingRepo->getSetting($session, $settingName, $player); + $existingSetting = $settingRepo->getSetting($session, $settingName, $player); + $nowReady = $existingSetting === null; - if ($setting) { - $session->removeSetting($setting); - $this->entityManager->remove($setting); + if ($existingSetting) { + $session->removeSetting($existingSetting); + $this->entityManager->remove($existingSetting); } else { $setting = new SessionSetting(); $setting->setSession($session); @@ -334,17 +337,71 @@ final class GameDashboardService // transitioned the session out of READY and published 'all_ready' — // don't also publish a redundant 'player_ready'. if ($session->getStatus() === SessionStatus::READY) { - try { - $topic = '/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 - } + $this->publishPlayerReady($session, $player->getScreen(), $nowReady); } return true; } + /** + * Explicitly (and idempotently) expires the current user's own ready status once + * their 60-second window is genuinely up. Called by the ready player's own browser + * on a timer, so the "not ready" broadcast goes out as close to the deadline as + * possible instead of waiting for someone else's request to lazily discover it. + */ + public function expireOwnReadyIfDue(Session $session, User $user): void + { + if ($session->getStatus() !== SessionStatus::READY) { + return; + } + + $player = null; + foreach ($session->getPlayers() as $p) { + if ($p->getUser() === $user) { + $player = $p; + break; + } + } + + if (!$player) { + return; + } + + $settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen()); + if (!$settingName) { + return; + } + + /** @var \App\Game\Repository\SessionSettingRepository $settingRepo */ + $settingRepo = $this->entityManager->getRepository(SessionSetting::class); + $setting = $settingRepo->getSetting($session, $settingName, $player); + + if (!$setting) { + return; // Already not ready + } + + $readyAtTimestamp = (int)$setting->getValue(); + if ((new \DateTime())->getTimestamp() - $readyAtTimestamp < self::READY_TIMEOUT_SECONDS) { + return; // Not actually due yet + } + + $session->removeSetting($setting); + $this->entityManager->remove($setting); + $this->entityManager->flush(); + + $this->publishPlayerReady($session, $player->getScreen(), false); + } + + private function publishPlayerReady(Session $session, int $screen, bool $ready): void + { + try { + $topic = '/game/hub/' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $screen, 'ready' => $ready]))); + } catch (\Exception $e) { + // Mercure might be down, but we don't want to crash the game + } + } + public function checkAllPlayersReady(Session $session): void { if ($session->getStatus() !== SessionStatus::READY) { @@ -359,6 +416,7 @@ final class GameDashboardService } $readyPlayersCount = 0; + $now = new \DateTime(); /** @var \App\Game\Repository\SessionSettingRepository $settingRepo */ $settingRepo = $this->entityManager->getRepository(SessionSetting::class); @@ -369,9 +427,22 @@ final class GameDashboardService continue; } - if ($settingRepo->getSetting($session, $settingName, $player)) { - $readyPlayersCount++; + $setting = $settingRepo->getSetting($session, $settingName, $player); + if (!$setting) { + continue; } + + $readyAtTimestamp = (int)$setting->getValue(); + if (($now->getTimestamp() - $readyAtTimestamp) >= self::READY_TIMEOUT_SECONDS) { + $session->removeSetting($setting); + $this->entityManager->remove($setting); + $this->entityManager->flush(); + + $this->publishPlayerReady($session, $player->getScreen(), false); + continue; + } + + $readyPlayersCount++; } if ($readyPlayersCount === $numPlayers) { diff --git a/templates/game/waiting.html.twig b/templates/game/waiting.html.twig index 44e1a64..69f66b5 100644 --- a/templates/game/waiting.html.twig +++ b/templates/game/waiting.html.twig @@ -63,14 +63,18 @@ {% endif %}
@@ -82,9 +86,14 @@ + + @@ -92,6 +101,7 @@ const config = document.getElementById('mercure-config'); const publicUrl = config.dataset.mercurePublicUrl; const topic = config.dataset.topic; + const readyAt = config.dataset.readyAt; let reloading = false; const reloadOnce = (eventSource) => { @@ -117,5 +127,37 @@ } }; } + + // Our own ready status expires 60s after we set it - proactively tell the + // server as close to that deadline as possible, so the other players find + // out live instead of only whenever someone else's request happens to + // trigger the lazy check. + if (readyAt) { + const timeoutMs = 61000; // slightly more than the server-side 60s + const readyAtMs = readyAt * 1000; + const countdownEl = document.getElementById('ready-countdown'); + + const updateCountdown = () => { + const remaining = Math.max(0, Math.ceil((readyAtMs + timeoutMs - Date.now()) / 1000)); + if (countdownEl) { + const m = Math.floor(remaining / 60); + const s = remaining % 60; + countdownEl.textContent = m + ':' + s.toString().padStart(2, '0'); + } + return remaining; + }; + + const remaining = updateCountdown(); + if (remaining <= 0) { + document.getElementById('expire-ready-form').submit(); + } else { + const countdownInterval = setInterval(() => { + if (updateCountdown() <= 0) { + clearInterval(countdownInterval); + document.getElementById('expire-ready-form').submit(); + } + }, 1000); + } + } {% endblock %}