Bring back 1-minute ready-status expiry with live per-player broadcasts

A player's own browser now starts a 60s timer the moment they check
"ready" (with a visible countdown) and proactively tells the server
when it's up via a new expire_ready action - idempotent, so it only
actually clears the status if the deadline has genuinely passed.
Everyone else finds out live through a Mercure player_ready broadcast
that now carries which player and their new ready/not-ready state
(previously the broadcast payload's `ready` flag was always false due
to a stale-variable bug, though nothing consumed it yet).

checkAllPlayersReady() still does the same expiry check server-side
and broadcasts on anyone else's request in the meantime, so a stalled
frontend timer doesn't leave a stale "ready" badge showing forever.

This only affects the pre-PLAYING ready phase: checkAllPlayersReady()
still flips the session to PLAYING and stops touching ready state the
instant everyone is simultaneously ready, so the earlier fix (a reload
should never send an already-started game back to the waiting room)
is unaffected.

Also fixed the ready checkbox itself: unchecking it submitted a POST
without `toggle_ready` in the body (unchecked checkboxes aren't sent),
so un-readying silently did nothing server-side. Added the standard
hidden-fallback-input pattern to fix it.
This commit is contained in:
Frank
2026-07-11 23:45:41 +02:00
parent c28abef5b7
commit 1be07440e3
3 changed files with 137 additions and 14 deletions
+83 -12
View File
@@ -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) {