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:
@@ -136,6 +136,11 @@ final class GameController extends AbstractController
|
|||||||
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
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
|
// Lazily pick up readiness changes from other players since our last request
|
||||||
$dashboardService->checkAllPlayersReady($session);
|
$dashboardService->checkAllPlayersReady($session);
|
||||||
|
|
||||||
@@ -154,17 +159,22 @@ final class GameController extends AbstractController
|
|||||||
|
|
||||||
if ($session->getStatus() === SessionStatus::READY) {
|
if ($session->getStatus() === SessionStatus::READY) {
|
||||||
$isReady = false;
|
$isReady = false;
|
||||||
|
$readyAt = null;
|
||||||
if ($player) {
|
if ($player) {
|
||||||
$settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen());
|
$settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen());
|
||||||
if ($settingName) {
|
if ($settingName) {
|
||||||
$setting = $session->getSettings()->filter(fn(SessionSetting $s) => $s->getName() === $settingName && $s->getPlayer() === $player)->first();
|
$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', [
|
return $this->render('game/waiting.html.twig', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'isReady' => $isReady,
|
'isReady' => $isReady,
|
||||||
|
'readyAt' => $readyAt,
|
||||||
'mercure_public_url' => $this->mercurePublicUrl,
|
'mercure_public_url' => $this->mercurePublicUrl,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ use Symfony\Component\Mercure\Update;
|
|||||||
|
|
||||||
final class GameDashboardService
|
final class GameDashboardService
|
||||||
{
|
{
|
||||||
|
private const READY_TIMEOUT_SECONDS = 60;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GameRepository $gameRepository,
|
private readonly GameRepository $gameRepository,
|
||||||
private readonly SessionRepository $sessionRepository,
|
private readonly SessionRepository $sessionRepository,
|
||||||
@@ -313,11 +315,12 @@ final class GameDashboardService
|
|||||||
|
|
||||||
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
||||||
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
||||||
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
$existingSetting = $settingRepo->getSetting($session, $settingName, $player);
|
||||||
|
$nowReady = $existingSetting === null;
|
||||||
|
|
||||||
if ($setting) {
|
if ($existingSetting) {
|
||||||
$session->removeSetting($setting);
|
$session->removeSetting($existingSetting);
|
||||||
$this->entityManager->remove($setting);
|
$this->entityManager->remove($existingSetting);
|
||||||
} else {
|
} else {
|
||||||
$setting = new SessionSetting();
|
$setting = new SessionSetting();
|
||||||
$setting->setSession($session);
|
$setting->setSession($session);
|
||||||
@@ -334,17 +337,71 @@ final class GameDashboardService
|
|||||||
// transitioned the session out of READY and published 'all_ready' —
|
// transitioned the session out of READY and published 'all_ready' —
|
||||||
// don't also publish a redundant 'player_ready'.
|
// don't also publish a redundant 'player_ready'.
|
||||||
if ($session->getStatus() === SessionStatus::READY) {
|
if ($session->getStatus() === SessionStatus::READY) {
|
||||||
try {
|
$this->publishPlayerReady($session, $player->getScreen(), $nowReady);
|
||||||
$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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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
|
public function checkAllPlayersReady(Session $session): void
|
||||||
{
|
{
|
||||||
if ($session->getStatus() !== SessionStatus::READY) {
|
if ($session->getStatus() !== SessionStatus::READY) {
|
||||||
@@ -359,6 +416,7 @@ final class GameDashboardService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$readyPlayersCount = 0;
|
$readyPlayersCount = 0;
|
||||||
|
$now = new \DateTime();
|
||||||
|
|
||||||
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
|
||||||
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
|
||||||
@@ -369,9 +427,22 @@ final class GameDashboardService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($settingRepo->getSetting($session, $settingName, $player)) {
|
$setting = $settingRepo->getSetting($session, $settingName, $player);
|
||||||
$readyPlayersCount++;
|
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) {
|
if ($readyPlayersCount === $numPlayers) {
|
||||||
|
|||||||
@@ -63,14 +63,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" class="mt-4">
|
<form method="post" class="mt-4">
|
||||||
|
<input type="hidden" name="toggle_ready" value="0">
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
<input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }} {{ not app.user.verified ? 'disabled' : '' }}>
|
<input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" value="1" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }} {{ not app.user.verified ? 'disabled' : '' }}>
|
||||||
<label class="form-check-label" for="toggle_ready">
|
<label class="form-check-label" for="toggle_ready">
|
||||||
<strong>I am ready to start!</strong>
|
<strong>I am ready to start!</strong>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small">
|
<p class="text-muted small">
|
||||||
As soon as everyone has checked this box, the game starts automatically for everyone.
|
As soon as everyone has checked this box, the game starts automatically for everyone.
|
||||||
|
{% if isReady %}
|
||||||
|
Your ready status expires in <span id="ready-countdown">1:00</span> if not everyone else is ready by then.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -82,9 +86,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form id="expire-ready-form" method="post" style="display:none">
|
||||||
|
<input type="hidden" name="expire_ready" value="1">
|
||||||
|
</form>
|
||||||
|
|
||||||
<div id="mercure-config"
|
<div id="mercure-config"
|
||||||
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
|
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
|
||||||
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
|
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
|
||||||
|
data-ready-at="{{ readyAt|e('html_attr') }}"
|
||||||
style="display:none">
|
style="display:none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,6 +101,7 @@
|
|||||||
const config = document.getElementById('mercure-config');
|
const config = document.getElementById('mercure-config');
|
||||||
const publicUrl = config.dataset.mercurePublicUrl;
|
const publicUrl = config.dataset.mercurePublicUrl;
|
||||||
const topic = config.dataset.topic;
|
const topic = config.dataset.topic;
|
||||||
|
const readyAt = config.dataset.readyAt;
|
||||||
|
|
||||||
let reloading = false;
|
let reloading = false;
|
||||||
const reloadOnce = (eventSource) => {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user