Compare commits
2
Commits
846cfbc44a
...
a9cb0fa57f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9cb0fa57f | ||
|
|
2bfc2aca1a |
@@ -5,7 +5,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activate = (id) => {
|
const activate = (id) => {
|
||||||
document.querySelectorAll('[id^="player-"]').forEach(el => el.style.display = 'none');
|
document.querySelectorAll('.admin-tab-panel').forEach(el => el.style.display = 'none');
|
||||||
const target = document.getElementById(id);
|
const target = document.getElementById(id);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.style.display = 'block';
|
target.style.display = 'block';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,58 @@
|
|||||||
<h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1>
|
<h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1>
|
||||||
<p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p>
|
<p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.25rem; margin-bottom: 1.5rem;">
|
{# Tab buttons #}
|
||||||
<h2 style="margin: 0 0 1rem; font-size: 1.1rem; color: #0f172a;">Pregame lobby chat</h2>
|
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||||
|
<button
|
||||||
|
data-tab-target="lobby-chat-tab"
|
||||||
|
id="tab-lobby-chat"
|
||||||
|
style="
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
border-bottom: 1px solid #fff;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
"
|
||||||
|
>Lobby Chat</button>
|
||||||
|
{% for playerLog in playersLogs %}
|
||||||
|
<button
|
||||||
|
data-tab-target="player-{{ loop.index }}"
|
||||||
|
id="tab-{{ loop.index }}"
|
||||||
|
style="
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
"
|
||||||
|
>{{ playerLog.username }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tab content #}
|
||||||
|
<div id="lobby-chat-tab" class="admin-tab-panel" style="
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||||
|
">
|
||||||
{% if lobbyMessages is empty %}
|
{% if lobbyMessages is empty %}
|
||||||
<p style="margin: 0; color: #94a3b8;">No lobby chat messages for this session.</p>
|
<p style="margin: 0; color: #94a3b8;">No lobby chat messages for this session.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -28,59 +77,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if playersLogs is empty %}
|
{% for playerLog in playersLogs %}
|
||||||
<div style="background: #fff; border-radius: 8px; padding: 2rem; text-align: center; color: #94a3b8; box-shadow: 0 1px 3px rgba(0,0,0,.07);">
|
<div id="player-{{ loop.index }}" class="admin-tab-panel" style="
|
||||||
No players in this session.
|
display: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||||
|
">
|
||||||
|
<pre style="
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
{# Tab buttons #}
|
|
||||||
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
|
||||||
{% for playerLog in playersLogs %}
|
|
||||||
<button
|
|
||||||
data-tab-target="player-{{ loop.index }}"
|
|
||||||
id="tab-{{ loop.index }}"
|
|
||||||
style="
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0.6rem 1.25rem;
|
|
||||||
border: 1px solid {{ loop.first ? '#3b82f6' : '#e2e8f0' }};
|
|
||||||
border-bottom: {{ loop.first ? '1px solid #fff' : '1px solid #e2e8f0' }};
|
|
||||||
background: {{ loop.first ? '#fff' : '#f8fafc' }};
|
|
||||||
color: {{ loop.first ? '#1e40af' : '#64748b' }};
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: {{ loop.first ? '600' : '400' }};
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
"
|
|
||||||
>{{ playerLog.username }}</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Tab content #}
|
|
||||||
{% for playerLog in playersLogs %}
|
|
||||||
<div id="player-{{ loop.index }}" style="
|
|
||||||
display: {{ loop.first ? 'block' : 'none' }};
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
|
||||||
">
|
|
||||||
<pre style="
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,38 +1,56 @@
|
|||||||
{% 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">
|
||||||
<div class="card shadow-sm mb-4">
|
{% if isCreated %}
|
||||||
<div class="card-header bg-primary text-white">
|
<div class="card shadow-sm mb-4">
|
||||||
<h3 class="card-title mb-0">Waiting for more players to join</h3>
|
<div class="card-header bg-primary text-white">
|
||||||
</div>
|
<h3 class="card-title mb-0">Waiting for more players to join</h3>
|
||||||
<div class="card-body">
|
|
||||||
<h4>{{ session.game.name }}</h4>
|
|
||||||
<p>Share the invite code with your friends. Feel free to chat below while you wait — no need to reload the page.</p>
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<strong>Players joined:</strong> {{ session.players|length }} / {{ session.game.numberOfPlayers }}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{{ session.game.name }}</h4>
|
||||||
|
<p>Share the invite code with your friends. Feel free to chat below while you wait — no need to reload the page.</p>
|
||||||
|
|
||||||
<ul class="list-group mb-3">
|
<div class="alert alert-info">
|
||||||
{% for sessionPlayer in session.players %}
|
<strong>Players joined:</strong> {{ session.players|length }} / {{ session.game.numberOfPlayers }}
|
||||||
<li class="list-group-item">{{ sessionPlayer.user.username }}</li>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{% if session.players|length >= session.game.numberOfPlayers %}
|
<ul class="list-group mb-3">
|
||||||
<form method="post" action="{{ path('game_dashboard') }}" class="mb-3">
|
{% for sessionPlayer in session.players %}
|
||||||
<input type="hidden" name="session_id" value="{{ session.id }}">
|
<li class="list-group-item">{{ sessionPlayer.user.username }}</li>
|
||||||
<button type="submit" name="start_session" class="btn btn-success">Start Session</button>
|
{% endfor %}
|
||||||
</form>
|
</ul>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
|
{% if session.players|length >= session.game.numberOfPlayers %}
|
||||||
|
<form method="post" action="{{ path('game_dashboard') }}" class="mb-3">
|
||||||
|
<input type="hidden" name="session_id" value="{{ session.id }}">
|
||||||
|
<button type="submit" name="start_session" class="btn btn-success">Start Session</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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