Keep pregame lobby chat open for an hour after the game ends
The chat used to disappear the moment a session left CREATED status.
Sessions now record a finishedAt timestamp when they're won or lost,
and the lobby (with chat) stays reachable via /game/{session} for an
hour afterward instead of immediately redirecting to the win/lose
feedback page. The lobby template shows a distinct "game finished"
header with a link to that feedback page during this window.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260810130000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add finished_at column to session table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE session ADD finished_at DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE session DROP finished_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ final class GameAdminSessionController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$session->setStatus(SessionStatus::LOST);
|
$session->setStatus(SessionStatus::LOST);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
|
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ final class GameApiController extends AbstractController
|
|||||||
if ($session->getStatus() === SessionStatus::PLAYING) {
|
if ($session->getStatus() === SessionStatus::PLAYING) {
|
||||||
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
|
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
|
||||||
$session->setStatus(SessionStatus::LOST);
|
$session->setStatus(SessionStatus::LOST);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
$isFinished = true;
|
$isFinished = true;
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ final class GameController extends AbstractController
|
|||||||
// 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);
|
||||||
|
|
||||||
if ($session->getStatus() === SessionStatus::CREATED) {
|
if ($dashboardService->isLobbyChatOpen($session)) {
|
||||||
return $this->render('game/lobby.html.twig', [
|
return $this->render('game/lobby.html.twig', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'messages' => $dashboardService->getLobbyMessages($session),
|
'messages' => $dashboardService->getLobbyMessages($session),
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class Session
|
|||||||
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||||
private ?\DateTimeInterface $created = null;
|
private ?\DateTimeInterface $created = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeInterface $finishedAt = null;
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
|
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
|
||||||
private Collection $players;
|
private Collection $players;
|
||||||
|
|
||||||
@@ -97,6 +100,18 @@ class Session
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getFinishedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFinishedAt(?\DateTimeInterface $finishedAt): static
|
||||||
|
{
|
||||||
|
$this->finishedAt = $finishedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Player>
|
* @return Collection<int, Player>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class GameDashboardService
|
|||||||
{
|
{
|
||||||
private const READY_TIMEOUT_SECONDS = 60;
|
private const READY_TIMEOUT_SECONDS = 60;
|
||||||
private const LOBBY_MESSAGE_MAX_LENGTH = 500;
|
private const LOBBY_MESSAGE_MAX_LENGTH = 500;
|
||||||
|
private const LOBBY_CHAT_GRACE_PERIOD_SECONDS = 3600;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GameRepository $gameRepository,
|
private readonly GameRepository $gameRepository,
|
||||||
@@ -302,9 +303,32 @@ final class GameDashboardService
|
|||||||
return $this->lobbyMessageRepository->findForSession($session);
|
return $this->lobbyMessageRepository->findForSession($session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lobby chat is open while a session is still gathering players, and stays
|
||||||
|
* open for a grace period after the game ends so players can wrap up the
|
||||||
|
* conversation before it disappears.
|
||||||
|
*/
|
||||||
|
public function isLobbyChatOpen(Session $session): bool
|
||||||
|
{
|
||||||
|
if ($session->getStatus() === SessionStatus::CREATED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($session->getStatus(), [SessionStatus::WON, SessionStatus::LOST], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finishedAt = $session->getFinishedAt();
|
||||||
|
if ($finishedAt === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new \DateTime())->getTimestamp() - $finishedAt->getTimestamp() < self::LOBBY_CHAT_GRACE_PERIOD_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage
|
public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage
|
||||||
{
|
{
|
||||||
if ($session->getStatus() !== SessionStatus::CREATED) {
|
if (!$this->isLobbyChatOpen($session)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1118,6 +1118,7 @@ class GameResponseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$session->setStatus(SessionStatus::WON);
|
$session->setStatus(SessionStatus::WON);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
{% extends 'layout/site.html.twig' %}
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
{% block title %}Waiting for players - {{ session.game.name }}{% endblock %}
|
{% set isCreated = session.status.value == 'created' %}
|
||||||
|
{% set isFinished = session.status.value in ['won', 'lost'] %}
|
||||||
|
|
||||||
|
{% block title %}{{ isCreated ? 'Waiting for players' : 'Post-game chat' }} - {{ session.game.name }}{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
|
{% if isCreated %}
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-header bg-primary text-white">
|
<div class="card-header bg-primary text-white">
|
||||||
<h3 class="card-title mb-0">Waiting for more players to join</h3>
|
<h3 class="card-title mb-0">Waiting for more players to join</h3>
|
||||||
@@ -33,6 +37,20 @@
|
|||||||
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
|
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% elseif isFinished %}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header {{ session.status.value == 'won' ? 'bg-success' : 'bg-secondary' }} text-white">
|
||||||
|
<h3 class="card-title mb-0">{{ session.status.value == 'won' ? 'You won!' : 'Game over' }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{{ session.game.name }}</h4>
|
||||||
|
<p>The game has ended, but the chat is still open for a little while — feel free to keep talking.</p>
|
||||||
|
|
||||||
|
<a href="{{ path(session.status.value == 'won' ? 'game_won' : 'game_lost', {session: session.id}) }}" class="btn btn-primary btn-sm">View results</a>
|
||||||
|
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|||||||
Reference in New Issue
Block a user