Merge pull request 'start-all-at-the-same-time' (#11) from start-all-at-the-same-time into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-01-08 19:12:17 +00:00
7 changed files with 582 additions and 19 deletions

View File

@@ -4,9 +4,14 @@ declare(strict_types=1);
namespace App\Game\Controller; namespace App\Game\Controller;
use App\Game\Entity\Session; use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository; use App\Game\Repository\GameRepository;
use App\Game\Repository\PlayerRepository;
use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionRepository;
use App\Game\Service\GameDashboardService; use App\Game\Service\GameDashboardService;
use App\Tech\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -14,9 +19,16 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class GameController extends AbstractController final class GameController extends AbstractController
{ {
public function __construct(
#[Autowire('%env(MERCURE_PUBLIC_URL)%')]
private string $mercurePublicUrl
) {
}
#[Route(path: '', name: 'game_dashboard', methods: ['GET', 'POST'])] #[Route(path: '', name: 'game_dashboard', methods: ['GET', 'POST'])]
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))] #[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
public function dashboard( public function dashboard(
@@ -92,17 +104,54 @@ final class GameController extends AbstractController
]); ]);
} }
#[Route(path: '/{session}', name: 'game')] #[Route(path: '/{session}', name: 'game', methods: ['GET', 'POST'])]
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))] #[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
#[IsGranted('SESSION_VIEW', subject: 'session')] #[IsGranted('SESSION_VIEW', subject: 'session')]
public function index( public function index(
Session $session, Session $session,
Request $request,
Security $security, Security $security,
\App\Game\Repository\PlayerRepository $playerRepository PlayerRepository $playerRepository,
GameDashboardService $dashboardService
): Response ): Response
{ {
$user = $security->getUser(); $user = $security->getUser();
if (!$user instanceof User) {
throw $this->createAccessDeniedException();
}
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]); $player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
if ($request->isMethod('POST') && $request->request->has('toggle_ready')) {
$dashboardService->toggleReady($session, $user);
return $this->redirectToRoute('game', ['session' => $session->getId()]);
}
// Periodically check readiness timeout
$dashboardService->checkAllPlayersReady($session);
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();
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,
]);
}
$screen = $player ? $player->getScreen() : 0; $screen = $player ? $player->getScreen() : 0;
return $this->render('game/index.html.twig', [ return $this->render('game/index.html.twig', [

View File

@@ -60,7 +60,7 @@ class Player
return $this->screen; return $this->screen;
} }
public function setScreen(int $screen): static public function setScreen(?int $screen): static
{ {
$this->screen = $screen; $this->screen = $screen;

View File

@@ -60,4 +60,14 @@ enum SessionSettingType: string
case SPECIAL_REPORT_CODE_DOYLE = 'SpecialReportCodeDoyle'; case SPECIAL_REPORT_CODE_DOYLE = 'SpecialReportCodeDoyle';
case SPECIAL_REPORT_CODE_VEGA = 'SpecialReportCodeVega'; case SPECIAL_REPORT_CODE_VEGA = 'SpecialReportCodeVega';
case SPECIAL_REPORT_CODE_LENNOX = 'SpecialReportCodeLennox'; case SPECIAL_REPORT_CODE_LENNOX = 'SpecialReportCodeLennox';
case READY_AT_FOR_PLAYER1 = 'ReadyAtForPlayer1';
case READY_AT_FOR_PLAYER2 = 'ReadyAtForPlayer2';
case READY_AT_FOR_PLAYER3 = 'ReadyAtForPlayer3';
case READY_AT_FOR_PLAYER4 = 'ReadyAtForPlayer4';
case READY_AT_FOR_PLAYER5 = 'ReadyAtForPlayer5';
case READY_AT_FOR_PLAYER6 = 'ReadyAtForPlayer6';
case READY_AT_FOR_PLAYER7 = 'ReadyAtForPlayer7';
case READY_AT_FOR_PLAYER8 = 'ReadyAtForPlayer8';
case READY_AT_FOR_PLAYER9 = 'ReadyAtForPlayer9';
case READY_AT_FOR_PLAYER10 = 'ReadyAtForPlayer10';
} }

View File

@@ -15,6 +15,9 @@ use App\Game\Repository\SessionRepository;
use App\Tech\Entity\User; use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class GameDashboardService final class GameDashboardService
{ {
@@ -22,6 +25,9 @@ final class GameDashboardService
private readonly GameRepository $gameRepository, private readonly GameRepository $gameRepository,
private readonly SessionRepository $sessionRepository, private readonly SessionRepository $sessionRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private readonly string $mercureTopicBase,
) { ) {
} }
@@ -66,13 +72,17 @@ final class GameDashboardService
$player = new Player(); $player = new Player();
$player->setUser($user); $player->setUser($user);
$player->setSession($session); $session->addPlayer($player);
$this->entityManager->persist($session); $this->entityManager->persist($session);
$this->entityManager->persist($player); $this->entityManager->persist($player);
$this->entityManager->flush(); $this->entityManager->flush();
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
$this->startSession($session);
}
return $session; return $session;
} }
@@ -107,12 +117,16 @@ final class GameDashboardService
$player = new Player(); $player = new Player();
$player->setUser($user); $player->setUser($user);
$player->setSession($session); $session->addPlayer($player);
$this->entityManager->persist($player); $this->entityManager->persist($player);
$this->entityManager->flush(); $this->entityManager->flush();
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
$this->startSession($session);
}
return true; return true;
} }
@@ -122,7 +136,7 @@ final class GameDashboardService
return false; return false;
} }
if ($session->getStatus() !== SessionStatus::CREATED || $session->getTimer() > 0) { if (!in_array($session->getStatus(), [SessionStatus::CREATED, SessionStatus::READY]) || $session->getTimer() > 0) {
return false; return false;
} }
@@ -138,6 +152,14 @@ final class GameDashboardService
return false; return false;
} }
if ($session->getStatus() === SessionStatus::READY) {
$session->setStatus(SessionStatus::CREATED);
// Clear assignments for all remaining players since the game needs to be "started" again
foreach ($session->getPlayers() as $player) {
$player->setScreen(null);
}
}
// Remove player specific settings (like rights) // Remove player specific settings (like rights)
foreach ($session->getSettings() as $setting) { foreach ($session->getSettings() as $setting) {
if ($setting->getPlayer() === $playerToDelete) { if ($setting->getPlayer() === $playerToDelete) {
@@ -240,6 +262,14 @@ final class GameDashboardService
return false; return false;
} }
// Clean up any existing assignments (e.g. if we reverted from READY to CREATED)
foreach ($session->getSettings() as $setting) {
if ($setting->getPlayer() !== null) {
$session->removeSetting($setting);
$this->entityManager->remove($setting);
}
}
// Shuffle players to assign random screens // Shuffle players to assign random screens
shuffle($players); shuffle($players);
@@ -257,6 +287,133 @@ final class GameDashboardService
return true; return true;
} }
public function toggleReady(Session $session, User $user): bool
{
if ($session->getStatus() !== SessionStatus::READY) {
return false;
}
$player = null;
foreach ($session->getPlayers() as $p) {
if ($p->getUser() === $user) {
$player = $p;
break;
}
}
if (!$player) {
return false;
}
$settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen());
if (!$settingName) {
return false;
}
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
$setting = $settingRepo->getSetting($session, $settingName, $player);
if ($setting) {
$session->removeSetting($setting);
$this->entityManager->remove($setting);
} else {
$setting = new SessionSetting();
$setting->setSession($session);
$setting->setPlayer($player);
$setting->setName($settingName);
$setting->setValue((string)(new \DateTime())->getTimestamp());
$this->entityManager->persist($setting);
}
$this->checkAllPlayersReady($session);
$this->entityManager->flush();
try {
$topic = $this->mercureTopicBase . '/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;
}
public function checkAllPlayersReady(Session $session): void
{
if ($session->getStatus() !== SessionStatus::READY) {
return;
}
$players = $session->getPlayers();
$numPlayers = $session->getGame()->getNumberOfPlayers();
if (count($players) < $numPlayers) {
return;
}
$readyPlayersCount = 0;
$now = new \DateTime();
$anyReset = false;
/** @var \App\Game\Repository\SessionSettingRepository $settingRepo */
$settingRepo = $this->entityManager->getRepository(SessionSetting::class);
foreach ($players as $player) {
$settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen());
if (!$settingName) {
continue;
}
$setting = $settingRepo->getSetting($session, $settingName, $player);
if ($setting) {
$readyAtTimestamp = (int)$setting->getValue();
// Check timeout: 1 minute = 60 seconds
if (($now->getTimestamp() - $readyAtTimestamp) > 60) {
$session->removeSetting($setting);
$this->entityManager->remove($setting);
$anyReset = true;
} else {
$readyPlayersCount++;
}
}
}
if ($anyReset) {
$this->entityManager->flush();
try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready'])));
} catch (\Exception $e) {
// Mercure might be down
}
}
if ($readyPlayersCount === $numPlayers) {
$session->setStatus(SessionStatus::PLAYING);
$this->entityManager->persist($session);
// Clean up ready settings
foreach ($players as $player) {
$settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen());
if ($settingName) {
$setting = $settingRepo->getSetting($session, $settingName, $player);
if ($setting) {
$session->removeSetting($setting);
$this->entityManager->remove($setting);
}
}
}
try {
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready'])));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
}
}
}
public function generateInviteCode(Session $session, UserInterface $user, bool $isAdmin): ?string public function generateInviteCode(Session $session, UserInterface $user, bool $isAdmin): ?string
{ {
// Security check: is user part of this session? // Security check: is user part of this session?

View File

@@ -12,6 +12,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class GameResponseService class GameResponseService
{ {
@@ -22,6 +23,8 @@ class GameResponseService
private HubInterface $hub, private HubInterface $hub,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private string $projectDir, private string $projectDir,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private string $mercureTopicBase,
) { ) {
} }
@@ -333,8 +336,12 @@ class GameResponseService
if(is_null($activeGame)) if(is_null($activeGame))
return false; return false;
$topic = $_ENV['MERCURE_TOPIC_BASE'] . '/game/hub-' . $activeGame; $topic = $this->mercureTopicBase . '/game/hub-' . $activeGame;
$this->hub->publish(new Update($topic, json_encode([$sendTo, $message]))); try {
$this->hub->publish(new Update($topic, json_encode([$sendTo, $message])));
} catch (\Exception $e) {
// Mercure might be down
}
$this->updateChatTracking($player, (int)$sendTo); $this->updateChatTracking($player, (int)$sendTo);
@@ -375,10 +382,14 @@ class GameResponseService
$this->entityManager->flush(); $this->entityManager->flush();
// Notify the player that their codes have changed // Notify the player that their codes have changed
$topic = $_ENV['MERCURE_TOPIC_BASE'] . '/game/hub-' . $session->getId(); $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$notification = "Security Alert: One of your verify codes was shared and has been regenerated."; $notification = "Security Alert: One of your verify codes was shared and has been regenerated.";
// We send it only to this player (screen) // We send it only to this player (screen)
$this->hub->publish(new Update($topic, json_encode([$screen, $notification]))); try {
$this->hub->publish(new Update($topic, json_encode([$screen, $notification])));
} catch (\Exception $e) {
// Mercure might be down
}
} }
} }
@@ -662,9 +673,13 @@ class GameResponseService
$this->entityManager->persist($everyoneVerifiedSetting); $this->entityManager->persist($everyoneVerifiedSetting);
$this->entityManager->flush(); $this->entityManager->flush();
$topic = $_ENV['MERCURE_TOPIC_BASE'] . '/game/hub-' . $session->getId(); $topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages."; $message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages.";
$this->hub->publish(new Update($topic, json_encode([0, $message]))); try {
$this->hub->publish(new Update($topic, json_encode([0, $message])));
} catch (\Exception $e) {
// Mercure might be down
}
} }
} }

View File

@@ -0,0 +1,125 @@
{% extends 'base.html.twig' %}
{% block title %}Waiting for players - {{ session.game.name }}{% endblock %}
{% block body %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">Waiting for all players to be ready</h3>
</div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>Welcome to the game! Please wait for all players to join and signal they are ready to start.</p>
<div class="game-info">
Please keep the following things in mind:
<ul>
<li>This game is best played in full screen mode. For windows, press F11. For Mac, press Cmd+Ctrl+F.</li>
<li>There is no need to reload the page. There is even a chance this could break the game.</li>
<li>If your internet connection is lost, you can get back in the game after internet has been fixed.</li>
</ul>
</div>
<div class="alert alert-info">
<strong>Game Information:</strong>
<ul class="mb-0">
<li>Number of players: {{ session.game.numberOfPlayers }}</li>
<li>Current players: {{ session.players|length }} / {{ session.game.numberOfPlayers }}</li>
</ul>
</div>
<div class="mt-4">
<h5>Players status:</h5>
<ul class="list-group">
{% for player in session.players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ player.user.username }}
{% set playerReady = false %}
{% set settingName = 'ReadyAtForPlayer' ~ player.screen %}
{% for setting in session.settings %}
{% if setting.name.value == settingName and setting.player == player %}
{% set playerReady = true %}
{% endif %}
{% endfor %}
{% if playerReady %}
<span class="badge bg-success">Ready</span>
{% else %}
<span class="badge bg-secondary">Not ready</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<hr>
<form method="post" class="mt-4">
<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' : '' }}>
<label class="form-check-label" for="toggle_ready">
<strong>I am ready to start!</strong>
</label>
</div>
<p class="text-muted small">
Note: If all players are not ready within 1 minute of you checking this, your status will be reset automatically.
</p>
</form>
<div class="mt-3">
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="mercure-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
data-topic="{{ (mercure_topic_base ~ '/game/hub-' ~ session.id)|e('html_attr') }}"
data-ready-at="{{ readyAt|e('html_attr') }}"
style="display:none">
</div>
<script>
const config = document.getElementById('mercure-config');
const publicUrl = config.dataset.mercurePublicUrl;
const topic = config.dataset.topic;
const readyAt = config.dataset.readyAt;
if (publicUrl && topic) {
const url = new URL(publicUrl);
url.searchParams.append('topic', topic);
const eventSource = new EventSource(url);
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === 'all_ready' || data.type === 'player_ready') {
window.location.reload();
}
};
}
// Client-side timeout for ready status
if (readyAt) {
const timeoutMs = 61000; // 61 seconds (slightly more than server-side 60s)
const now = Date.now();
const readyAtMs = readyAt * 1000;
const timeElapsed = now - readyAtMs;
const timeLeft = timeoutMs - timeElapsed;
if (timeLeft > 0) {
setTimeout(() => {
window.location.reload();
}, timeLeft);
} else {
// Already timed out, reload to sync with server
window.location.reload();
}
}
</script>
{% endblock %}

View File

@@ -8,12 +8,14 @@ use App\Game\Entity\Player;
use App\Game\Entity\Session; use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting; use App\Game\Entity\SessionSetting;
use App\Game\Enum\GameStatus; use App\Game\Enum\GameStatus;
use App\Game\Enum\SessionStatus;
use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionSettingType;
use App\Game\Service\GameDashboardService; use App\Game\Service\GameDashboardService;
use App\Tech\Entity\User; use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use App\Game\Repository\GameRepository; use App\Game\Repository\GameRepository;
use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionRepository;
use Symfony\Component\Mercure\HubInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class GameDashboardServiceTest extends TestCase class GameDashboardServiceTest extends TestCase
@@ -21,6 +23,7 @@ class GameDashboardServiceTest extends TestCase
private $entityManager; private $entityManager;
private $gameRepository; private $gameRepository;
private $sessionRepository; private $sessionRepository;
private $hub;
private $service; private $service;
protected function setUp(): void protected function setUp(): void
@@ -28,11 +31,14 @@ class GameDashboardServiceTest extends TestCase
$this->entityManager = $this->createMock(EntityManagerInterface::class); $this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->gameRepository = $this->createMock(GameRepository::class); $this->gameRepository = $this->createMock(GameRepository::class);
$this->sessionRepository = $this->createMock(SessionRepository::class); $this->sessionRepository = $this->createMock(SessionRepository::class);
$this->hub = $this->createMock(HubInterface::class);
$this->service = new GameDashboardService( $this->service = new GameDashboardService(
$this->gameRepository, $this->gameRepository,
$this->sessionRepository, $this->sessionRepository,
$this->entityManager $this->entityManager,
$this->hub,
'http://localhost/topic'
); );
} }
@@ -193,24 +199,225 @@ class GameDashboardServiceTest extends TestCase
$this->assertCount(1, $session->getPlayers()); $this->assertCount(1, $session->getPlayers());
} }
public function testLeaveSessionDeletesSessionIfLastPlayer(): void public function testToggleReady(): void
{ {
$user = new User(); $user = new User();
$game = new Game();
$game->setNumberOfPlayers(1);
$session = new Session(); $session = new Session();
$session->setStatus(\App\Game\Enum\SessionStatus::CREATED); $session->setGame($game);
$session->setTimer(0); $session->setStatus(SessionStatus::READY);
$player = new Player(); $player = new Player();
$player->setUser($user); $player->setUser($user);
$player->setSession($session); $player->setScreen(1);
$session->addPlayer($player); $session->addPlayer($player);
$this->entityManager->expects($this->exactly(2)) $repo = $this->createMock(\App\Game\Repository\SessionSettingRepository::class);
$this->entityManager->method('getRepository')
->willReturn($repo);
// First call: toggle ON
$repo->expects($this->atLeastOnce())
->method('getSetting')
->willReturn(null); // No setting initially
$this->entityManager->expects($this->atLeastOnce())
->method('persist')
->with($this->callback(function($entity) {
return $entity instanceof SessionSetting && $entity->getName() === SessionSettingType::READY_AT_FOR_PLAYER1;
}));
$result = $this->service->toggleReady($session, $user);
$this->assertTrue($result);
}
public function testCheckAllPlayersReadyTransitionsStatus(): void
{
$game = new Game();
$game->setNumberOfPlayers(2);
$session = new Session();
$session->setGame($game);
$session->setStatus(SessionStatus::READY);
$player1 = new Player();
$player1->setScreen(1);
$session->addPlayer($player1);
$player2 = new Player();
$player2->setScreen(2);
$session->addPlayer($player2);
$repo = $this->createMock(\App\Game\Repository\SessionSettingRepository::class);
$this->entityManager->method('getRepository')
->willReturn($repo);
$setting1 = new SessionSetting();
$setting1->setName(SessionSettingType::READY_AT_FOR_PLAYER1);
$setting1->setValue((string)time());
$setting1->setPlayer($player1);
$setting2 = new SessionSetting();
$setting2->setName(SessionSettingType::READY_AT_FOR_PLAYER2);
$setting2->setValue((string)time());
$setting2->setPlayer($player2);
$repo->method('getSetting')
->willReturnCallback(function($s, $name, $p) use ($setting1, $setting2) {
if ($name === SessionSettingType::READY_AT_FOR_PLAYER1) return $setting1;
if ($name === SessionSettingType::READY_AT_FOR_PLAYER2) return $setting2;
return null;
});
$this->service->checkAllPlayersReady($session);
$this->assertEquals(SessionStatus::PLAYING, $session->getStatus());
}
public function testCheckAllPlayersReadyTimeouts(): void
{
$game = new Game();
$game->setNumberOfPlayers(2);
$session = new Session();
$session->setGame($game);
$session->setStatus(SessionStatus::READY);
$player1 = new Player();
$player1->setScreen(1);
$session->addPlayer($player1);
$player2 = new Player();
$player2->setScreen(2);
$session->addPlayer($player2);
$repo = $this->createMock(\App\Game\Repository\SessionSettingRepository::class);
$this->entityManager->method('getRepository')
->willReturn($repo);
$setting1 = new SessionSetting();
$setting1->setName(SessionSettingType::READY_AT_FOR_PLAYER1);
$setting1->setValue((string)(time() - 70)); // Timed out
$setting1->setPlayer($player1);
$session->addSetting($setting1);
$repo->method('getSetting')
->willReturnCallback(function($s, $name, $p) use ($setting1) {
if ($name === SessionSettingType::READY_AT_FOR_PLAYER1) return $setting1;
return null;
});
$this->entityManager->expects($this->once())
->method('remove')
->with($setting1);
$this->entityManager->expects($this->atLeastOnce())
->method('flush');
$this->hub->expects($this->once())
->method('publish');
$this->service->checkAllPlayersReady($session);
$this->assertEquals(SessionStatus::READY, $session->getStatus());
}
public function testJoinSessionTriggersStartSessionWhenFull(): void
{
$user = new User();
$user->setUsername('testuser');
$game = new Game();
$game->setNumberOfPlayers(2);
$session = new Session();
$session->setGame($game);
$session->setStatus(\App\Game\Enum\SessionStatus::CREATED);
$existingUser = new User();
$existingUser->setUsername('existing');
$existingPlayer = new Player();
$existingPlayer->setUser($existingUser);
$session->addPlayer($existingPlayer);
$setting = new SessionSetting();
$setting->setSession($session);
$setting->setName(SessionSettingType::INVITE_CODE);
$setting->setValue('abc-123');
$repo = $this->createMock(\Doctrine\ORM\EntityRepository::class);
$this->entityManager->method('getRepository')
->willReturn($repo);
$repo->method('findOneBy')
->willReturn($setting);
// Expectation:
// 1. Persist new Player
// 2. Persist Player 1 (existing) screen update
// 3. Persist Player 2 (new) screen update
// 4. Persist Session status update
// 5. 5 Settings for Player 1
// 6. 5 Settings for Player 2
// Total = 1 + 1 + 1 + 1 + 5 + 5 = 14 persists
$this->entityManager->expects($this->exactly(14))
->method('persist');
$result = $this->service->joinSession('abc-123', $user);
$this->assertTrue($result);
$this->assertEquals(\App\Game\Enum\SessionStatus::READY, $session->getStatus());
}
public function testLeaveSessionRevertsFromReady(): void
{
$user = new User();
$user->setUsername('testuser');
$session = new Session();
$session->setStatus(\App\Game\Enum\SessionStatus::READY);
$session->setTimer(0);
$player1 = new Player();
$player1->setUser($user);
$player1->setScreen(1);
$player1->setSession($session);
$session->addPlayer($player1);
$player2 = new Player();
$player2->setUser(new User());
$player2->setScreen(2);
$player2->setSession($session);
$session->addPlayer($player2);
$this->entityManager->expects($this->exactly(1))
->method('remove'); ->method('remove');
// 1. Player, 2. Session // 1. Player1, any settings (none in this test setup)
$result = $this->service->leaveSession($session, $user); $result = $this->service->leaveSession($session, $user);
$this->assertTrue($result); $this->assertTrue($result);
$this->assertEquals(\App\Game\Enum\SessionStatus::CREATED, $session->getStatus());
$this->assertNull($player2->getScreen());
}
public function testCreateSessionForOnePlayerGame(): void
{
$game = new Game();
$game->setStatus(GameStatus::OPEN);
$game->setNumberOfPlayers(1);
$user = new User();
$user->setUsername('testuser');
// 1. Session persist
// 2. Player persist
// 3. Player screen persist (during startSession)
// 4. Session status persist (during startSession)
// 5. 5 Settings for Player (during startSession)
// Total = 1 + 1 + 1 + 1 + 5 = 9 persists
$this->entityManager->expects($this->exactly(9))
->method('persist');
$session = $this->service->createSession($game, $user, false);
$this->assertInstanceOf(Session::class, $session);
$this->assertEquals(\App\Game\Enum\SessionStatus::READY, $session->getStatus());
} }
} }