Compare commits
7 Commits
admin-side
...
abc3712d97
| Author | SHA1 | Date | |
|---|---|---|---|
| abc3712d97 | |||
|
|
66cb356955 | ||
|
|
ffd20f5535 | ||
| 1b52f80448 | |||
|
|
b965f0f085 | ||
|
|
c4c989db4c | ||
| 37507bd169 |
4
.env.dev
4
.env.dev
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
###> symfony/framework-bundle ###
|
|
||||||
APP_SECRET=620e9ce5f88a714b636179eb39d5be4f
|
|
||||||
###< symfony/framework-bundle ###
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
### Compiled or real environment variables should be used in production.
|
|
||||||
### Configure MAILER_DSN to use SendGrid API transport.
|
|
||||||
### Prefer storing SENDGRID_API_KEY using Symfony Secrets or real env vars.
|
|
||||||
|
|
||||||
###> symfony/mailer ###
|
|
||||||
# Example using SendGrid API key (replace with real secret via vault/secrets):
|
|
||||||
# SENDGRID_API_KEY=SG.xxxxx
|
|
||||||
MAILER_DSN=sendgrid+api://%env(resolve:SENDGRID_API_KEY)%@default
|
|
||||||
###< symfony/mailer ###
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# define your env variables for the test env here
|
|
||||||
KERNEL_CLASS='App\Kernel'
|
|
||||||
APP_SECRET='$ecretf0rt3st'
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,9 @@
|
|||||||
/.env.local
|
/.env.local
|
||||||
/.env.local.php
|
/.env.local.php
|
||||||
/.env.*.local
|
/.env.*.local
|
||||||
|
/.env.dev
|
||||||
|
/.env.prod
|
||||||
|
/.env.test
|
||||||
/config/secrets/prod/prod.decrypt.private.php
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
/public/bundles/
|
/public/bundles/
|
||||||
/var/
|
/var/
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const screen = cfgEl.dataset.screen;
|
const screen = cfgEl.dataset.screen;
|
||||||
const apiPingUrl = cfgEl.dataset.apiPingUrl;
|
const apiPingUrl = cfgEl.dataset.apiPingUrl;
|
||||||
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
||||||
|
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
|
||||||
|
const lostUrl = cfgEl.dataset.lostUrl;
|
||||||
|
|
||||||
if (mercurePublicUrl && topic) {
|
if (mercurePublicUrl && topic) {
|
||||||
subscribeToMercure(mercurePublicUrl, topic, screen);
|
subscribeToMercure(mercurePublicUrl, topic, screen);
|
||||||
@@ -138,6 +140,52 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timer logic
|
||||||
|
const timerEl = document.getElementById('game-timer');
|
||||||
|
if (timerEl && timerEl.dataset.endTime) {
|
||||||
|
const endTime = parseInt(timerEl.dataset.endTime) * 1000;
|
||||||
|
|
||||||
|
const updateTimer = async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = endTime - now;
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
timerEl.textContent = '00:00:00';
|
||||||
|
|
||||||
|
// Timer reached zero, check with server
|
||||||
|
if (apiCheckFinishedUrl && lostUrl) {
|
||||||
|
try {
|
||||||
|
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
|
||||||
|
if (response && response.finished) {
|
||||||
|
window.location.href = lostUrl;
|
||||||
|
return; // Stop the timer loop
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API][game1] Failed to check finished status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even if check failed or not finished, stop the loop if diff <= 0
|
||||||
|
// (though technically if the server says not finished, we might want to keep checking,
|
||||||
|
// but 00:00:00 usually means it's over).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
const hDisplay = hours.toString().padStart(2, '0');
|
||||||
|
const mDisplay = minutes.toString().padStart(2, '0');
|
||||||
|
const sDisplay = seconds.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
timerEl.textContent = `${hDisplay}:${mDisplay}:${sDisplay}`;
|
||||||
|
setTimeout(updateTimer, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
// Demo API calls
|
// Demo API calls
|
||||||
try {
|
try {
|
||||||
if (apiPingUrl) {
|
if (apiPingUrl) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Game\Controller;
|
namespace App\Game\Controller;
|
||||||
|
|
||||||
|
use App\Game\Entity\Session;
|
||||||
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Service\GameResponseService;
|
use App\Game\Service\GameResponseService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -15,7 +18,9 @@ final class GameApiController extends AbstractController
|
|||||||
{
|
{
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected GameResponseService $gameResponseService) {
|
protected GameResponseService $gameResponseService,
|
||||||
|
private EntityManagerInterface $entityManager
|
||||||
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +34,30 @@ final class GameApiController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/check-finished/{session}', name: 'check_finished', methods: ['POST'])]
|
||||||
|
public function checkFinished(Session $session): JsonResponse
|
||||||
|
{
|
||||||
|
$now = (new \DateTime())->getTimestamp();
|
||||||
|
$isFinished = false;
|
||||||
|
|
||||||
|
if ($session->getStatus() === SessionStatus::PLAYING) {
|
||||||
|
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
|
||||||
|
$session->setStatus(SessionStatus::LOST);
|
||||||
|
$this->entityManager->persist($session);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$isFinished = true;
|
||||||
|
}
|
||||||
|
} elseif ($session->getStatus() === SessionStatus::LOST || $session->getStatus() === SessionStatus::WON) {
|
||||||
|
$isFinished = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'ok' => true,
|
||||||
|
'finished' => $isFinished,
|
||||||
|
'status' => $session->getStatus()->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/message', name: 'message', methods: ['POST'])]
|
#[Route('/message', name: 'message', methods: ['POST'])]
|
||||||
public function message(Request $request): JsonResponse
|
public function message(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Game\Controller;
|
namespace App\Game\Controller;
|
||||||
|
|
||||||
|
use App\Game\Entity\Player;
|
||||||
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 App\Game\Service\PlayerService;
|
||||||
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 +21,17 @@ 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,
|
||||||
|
private \Doctrine\ORM\EntityManagerInterface $entityManager
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
#[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,22 +107,125 @@ 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;
|
||||||
|
$session_id = $session->getId();
|
||||||
|
|
||||||
return $this->render('game/index.html.twig', [
|
return $this->render('game/index.html.twig', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'screen' => $screen,
|
'screen' => $screen,
|
||||||
|
'session_id' => $session_id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/lost/{session}', name: 'game_lost', methods: ['GET', 'POST'])]
|
||||||
|
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
|
||||||
|
#[IsGranted('SESSION_VIEW', subject: 'session')]
|
||||||
|
public function lost(
|
||||||
|
Session $session,
|
||||||
|
Request $request,
|
||||||
|
Security $security,
|
||||||
|
PlayerService $playerService,
|
||||||
|
GameDashboardService $dashboardService
|
||||||
|
): Response {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $security->getUser();
|
||||||
|
$player = $playerService->GetCurrentlyActiveAsPlayer($user);
|
||||||
|
|
||||||
|
if ($request->isMethod('POST')) {
|
||||||
|
$difficulty = $request->request->get('difficulty');
|
||||||
|
$entertaining = $request->request->get('entertaining');
|
||||||
|
$theme = $request->request->get('theme');
|
||||||
|
$feedback = $request->request->get('feedback');
|
||||||
|
|
||||||
|
// Save feedback
|
||||||
|
if ($player) {
|
||||||
|
$this->saveFeedback($session, $player, $difficulty, $entertaining, $theme, $feedback);
|
||||||
|
$this->addFlash('success', 'Thank you for your feedback!');
|
||||||
|
return $this->redirectToRoute('game_dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('game/lost.html.twig', [
|
||||||
|
'session' => $session,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveFeedback(Session $session, Player $player, $difficulty, $entertaining, $theme, $feedback): void
|
||||||
|
{
|
||||||
|
$settings = [
|
||||||
|
SessionSettingType::FEEDBACK_DIFFICULTY,
|
||||||
|
SessionSettingType::FEEDBACK_ENTERTAINING,
|
||||||
|
SessionSettingType::FEEDBACK_THEME,
|
||||||
|
SessionSettingType::FEEDBACK_TEXT,
|
||||||
|
];
|
||||||
|
|
||||||
|
$values = [
|
||||||
|
$difficulty,
|
||||||
|
$entertaining,
|
||||||
|
$theme,
|
||||||
|
$feedback,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($settings as $index => $type) {
|
||||||
|
$value = $values[$index];
|
||||||
|
if ($value === null || $value === '') continue;
|
||||||
|
|
||||||
|
$setting = new SessionSetting();
|
||||||
|
$setting->setSession($session);
|
||||||
|
$setting->setPlayer($player);
|
||||||
|
$setting->setName($type);
|
||||||
|
$setting->setValue((string)$value);
|
||||||
|
$this->entityManager->persist($setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -60,4 +60,18 @@ 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';
|
||||||
|
case FEEDBACK_DIFFICULTY = 'FeedbackDifficulty';
|
||||||
|
case FEEDBACK_ENTERTAINING = 'FeedbackEntertaining';
|
||||||
|
case FEEDBACK_THEME = 'FeedbackTheme';
|
||||||
|
case FEEDBACK_TEXT = 'FeedbackText';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,143 @@ 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);
|
||||||
|
|
||||||
|
// Set the end timer
|
||||||
|
$game = $session->getGame();
|
||||||
|
/** @var \App\Game\Repository\GameSettingRepository $gameSettingRepo */
|
||||||
|
$gameSettingRepo = $this->entityManager->getRepository(\App\Game\Entity\GameSetting::class);
|
||||||
|
$totalTimeSetting = $gameSettingRepo->getSetting($game, \App\Game\Enum\GameSettingType::TOTAL_TIME);
|
||||||
|
$totalTime = $totalTimeSetting ? (int)$totalTimeSetting->getValue() : 3600; // Default to 60 minutes if not set
|
||||||
|
|
||||||
|
$session->setTimer((new \DateTime())->getTimestamp() + $totalTime);
|
||||||
|
|
||||||
|
$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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,14 @@
|
|||||||
data-topic="{{ (mercure_topic_base ~ '/game/hub-' ~ session.id)|e('html_attr') }}"
|
data-topic="{{ (mercure_topic_base ~ '/game/hub-' ~ session.id)|e('html_attr') }}"
|
||||||
data-api-ping-url="{{ path('game_api_ping')|e('html_attr') }}"
|
data-api-ping-url="{{ path('game_api_ping')|e('html_attr') }}"
|
||||||
data-api-echo-url="{{ path('game_api_message')|e('html_attr') }}"
|
data-api-echo-url="{{ path('game_api_message')|e('html_attr') }}"
|
||||||
|
data-api-check-finished-url="{{ path('game_api_check_finished', {session: session.id})|e('html_attr') }}"
|
||||||
|
data-lost-url="{{ path('game_lost', {session: session.id})|e('html_attr') }}"
|
||||||
data-screen="{{ screen|e('html_attr') }}"
|
data-screen="{{ screen|e('html_attr') }}"
|
||||||
style="display:none">
|
style="display:none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="game-timer">
|
<div id="game-timer" data-end-time="{{ session.timer }}">
|
||||||
00:30:00
|
--:--:--
|
||||||
</div>
|
</div>
|
||||||
<div id="message-container">
|
<div id="message-container">
|
||||||
|
|
||||||
|
|||||||
127
templates/game/lost.html.twig
Normal file
127
templates/game/lost.html.twig
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Game Lost - {{ session.game.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.story-container {
|
||||||
|
font-style: italic;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.donation-section {
|
||||||
|
background-color: #333;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.form-range::-webkit-slider-runnable-track {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
.form-range::-moz-range-track {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
.form-range::-webkit-slider-thumb {
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
.form-range::-moz-range-thumb {
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="container mt-5 mb-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h3 class="card-title mb-0">Game Over - Time's Up!</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{{ session.game.name }}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="story-container">
|
||||||
|
<p>
|
||||||
|
The screens flickered one last time before going completely dark. The hum of the servers ceased, replaced by an eerie silence.
|
||||||
|
The server seems to have shut itself down before the AI virus could complete its task in decoding the undercover agents.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In the days after some people are arrested for sabotaging the agency, but in the end nobody is convicted for the actions.
|
||||||
|
Lets hope the agents which were undercover are safe and the information needed is still saved to put the other criminals behind bars.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="donation-section">
|
||||||
|
<h5>Support the Developer</h5>
|
||||||
|
<p>If you enjoyed the experience (even if you lost!), please consider a small donation to help me create more games.</p>
|
||||||
|
<a href="https://www.paypal.com/donate?hosted_button_id=X9X8KB6R6GMRU" target="_blank" class="btn btn-primary">
|
||||||
|
<i class="bi bi-paypal"></i> Donate via PayPal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feedback-form mt-4">
|
||||||
|
<h5>Feedback</h5>
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
|
||||||
|
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
|
||||||
|
<div class="d-flex justify-content-between mt-1">
|
||||||
|
<small class="text-muted">Absolutely not</small>
|
||||||
|
-
|
||||||
|
<small class="text-muted">Absolutely</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="entertaining" class="form-label">How entertaining was it? (<span id="entertaining-val">5</span>/10)</label>
|
||||||
|
<input type="range" class="form-range" min="1" max="10" step="1" id="entertaining" name="entertaining" value="5" oninput="document.getElementById('entertaining-val').innerText = this.value">
|
||||||
|
<div class="d-flex justify-content-between mt-1">
|
||||||
|
<small class="text-muted">Absolutely not</small>
|
||||||
|
-
|
||||||
|
<small class="text-muted">Absolutely</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="theme" class="form-label">How was the theme? (<span id="theme-val">5</span>/10)</label>
|
||||||
|
<input type="range" class="form-range" min="1" max="10" step="1" id="theme" name="theme" value="5" oninput="document.getElementById('theme-val').innerText = this.value">
|
||||||
|
<div class="d-flex justify-content-between mt-1">
|
||||||
|
<small class="text-muted">Absolutely not</small>
|
||||||
|
-
|
||||||
|
<small class="text-muted">Absolutely</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="feedback" class="form-label">Additional Feedback</label>
|
||||||
|
<textarea class="form-control" id="feedback" name="feedback" rows="4" placeholder="Tell us more about your experience..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-success">Submit Feedback & Return to Dashboard</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
125
templates/game/waiting.html.twig
Normal file
125
templates/game/waiting.html.twig
Normal 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 %}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user