27 Commits

Author SHA1 Message Date
Frank
641573842c Settings from env files 2026-01-09 13:08:09 +01:00
abc3712d97 Merge pull request 'timer-af-laten-lopen' (#12) from timer-af-laten-lopen into main
Reviewed-on: #12
2026-01-09 11:23:39 +00:00
Frank
66cb356955 Remove .env.* files from tracking and update .gitignore 2026-01-09 12:13:31 +01:00
Frank
ffd20f5535 Lost page 2026-01-08 20:32:21 +01:00
1b52f80448 Merge pull request 'start-all-at-the-same-time' (#11) from start-all-at-the-same-time into main
Reviewed-on: #11
2026-01-08 19:12:17 +00:00
Frank
b965f0f085 Added mercure to update when everyone is ready 2026-01-08 20:11:14 +01:00
Frank
c4c989db4c Trying to add waiting pages 2026-01-08 19:32:13 +01:00
37507bd169 Merge pull request 'admin-side' (#10) from admin-side into main
Reviewed-on: #10
2026-01-08 17:34:48 +00:00
Frank
732148a533 Look into session logfiles 2026-01-08 18:26:32 +01:00
Frank
50d7ce745c Logfiles for sessions 2026-01-08 18:14:56 +01:00
a475c1a268 Merge pull request 'set-correct-screens-for-players' (#9) from set-correct-screens-for-players into main
Reviewed-on: #9
2026-01-08 17:02:10 +00:00
Frank
1ae0f651d8 Most of the cat command 2026-01-08 17:02:16 +01:00
Frank
8e933631b2 Dynamic number of players 2026-01-08 15:49:47 +01:00
f8746207e6 Merge pull request 'plaatsen-return-messages' (#8) from plaatsen-return-messages into main
Reviewed-on: #8
2026-01-08 14:35:12 +00:00
Frank
e42966e618 Message when everyone is verified 2026-01-08 15:34:43 +01:00
Frank
e4976e51fa JWT token in env file 2026-01-08 14:40:51 +01:00
Frank van den Berg
65070688ec Make mercure work correctly 2026-01-08 13:05:40 +01:00
Frank
e54c870f05 Post return messages 2026-01-08 11:41:46 +01:00
f5aabafcc5 Merge pull request 'Verification done' (#7) from Rechten into main
Reviewed-on: #7
2026-01-07 19:53:58 +00:00
Frank
de4b7bca6a Verification done 2026-01-07 20:06:28 +01:00
c6adb00219 Merge pull request 'continue-puzzle-progress' (#6) from continue-puzzle-progress into main
Reviewed-on: #6
2026-01-07 16:50:36 +00:00
Frank
5f6ea89179 ls 2026-01-07 17:45:17 +01:00
Frank
c384fc3dd5 Sudo, rm and setup for ls 2026-01-07 15:00:28 +01:00
Frank
78b570bd75 Hint for first part 2026-01-07 14:33:10 +01:00
74f52db002 Merge pull request 'Created a dashboard and created an invite code for game sessions.' (#5) from game-pages into main
Reviewed-on: #5
2026-01-06 19:24:15 +00:00
Frank
56590a901f Created a dashboard and created an invite code for game sessions. 2026-01-06 20:23:46 +01:00
49045bc696 Merge pull request 'Game1-layout' (#4) from Game1-layout into main
Reviewed-on: #4
2026-01-06 19:21:58 +00:00
34 changed files with 2985 additions and 91 deletions

16
.env
View File

@@ -25,7 +25,15 @@ APP_SECRET=
# #
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://escapepage:b.0nqrxJ%%2FD%%2ALuf9N@localhost:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4" DB_DRIVER=pdo_mysql
DB_SERVER_VERSION=8.0.32
DB_CHARSET=utf8mb4
DB_USER=escapepage
DB_PASSWORD="b.0nqrxJ/D*Luf9N"
DB_HOST=localhost
DB_PORT=3306
DB_NAME=escapepage
DATABASE_URL="${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?serverVersion=${DB_SERVER_VERSION}&charset=${DB_CHARSET}"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> symfony/messenger ### ###> symfony/messenger ###
@@ -57,7 +65,9 @@ MERCURE_URL=http://mercure/.well-known/mercure
# Public hub URL used by browsers # Public hub URL used by browsers
MERCURE_PUBLIC_URL=http://localhost:8090/.well-known/mercure MERCURE_PUBLIC_URL=http://localhost:8090/.well-known/mercure
# Shared secret for signing JWTs (dev only). In prod, set via real env/secrets. # Shared secret for signing JWTs (dev only). In prod, set via real env/secrets.
MERCURE_JWT_SECRET=!ChangeThisMercureJWT! MERCURE_JWT_SECRET=!ChangeThisMercureJWTSignedBySymfonySecretKey!
# Base URL for Mercure topics. Use .dev in development; override to .com in prod via .env.prod or real env. # CORS allowed origins (default)
MERCURE_CORS_ALLOWED_ORIGINS=http://localhost:8080
# Base URL for Mercure topics.
MERCURE_TOPIC_BASE=https://escapepage.dev MERCURE_TOPIC_BASE=https://escapepage.dev
###< mercure ### ###< mercure ###

View File

@@ -1,4 +0,0 @@
###> symfony/framework-bundle ###
APP_SECRET=620e9ce5f88a714b636179eb39d5be4f
###< symfony/framework-bundle ###

View File

@@ -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 ###

View File

@@ -1,3 +0,0 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

View File

@@ -1,7 +1,10 @@
/* Game1 entry point built with Webpack Encore */ /* Game1 entry point built with Webpack Encore */
import './styles/game1.css'; import './styles/game1.css';
function subscribeToMercure(mercurePublicUrl, topic) { let sequenceFinished = false;
let stillPlayingSound = true;
function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
try { try {
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic); const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
const es = new EventSource(url); const es = new EventSource(url);
@@ -10,6 +13,33 @@ function subscribeToMercure(mercurePublicUrl, topic) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log('[Mercure][game1] Update:', data); console.log('[Mercure][game1] Update:', data);
// data is [sendTo, message]
if (Array.isArray(data) && data.length >= 2) {
const sendTo = parseInt(data[0]);
// Filter: 0 means everyone, otherwise must match myScreen
if (sendTo !== 0 && sendTo !== parseInt(myScreen)) {
console.log('[Mercure][game1] Message not for this player, skipping.');
return;
}
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = data[1];
msgEl.style.color = '#0F0'; // Green for incoming messages
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
if(stillPlayingSound)
playSound();
console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished);
if (sequenceFinished) {
flashRed();
}
}
}
} catch (e) { } catch (e) {
console.log('[Mercure][game1] Raw event:', event.data); console.log('[Mercure][game1] Raw event:', event.data);
} }
@@ -25,6 +55,28 @@ function subscribeToMercure(mercurePublicUrl, topic) {
} }
} }
function playSound() {
const sound = document.getElementById('message-sound');
if (sound) {
sound.currentTime = 0;
sound.play().catch(e => console.warn('[Audio] Playback failed:', e));
}
}
function flashRed() {
console.log('[Game1] Triggering flashRed');
const body = document.body;
body.classList.remove('flash-red');
void body.offsetWidth; // Trigger reflow to restart animation
body.classList.add('flash-red');
// Also remove it after animation finishes so it's clean for inspection
setTimeout(() => {
body.classList.remove('flash-red');
console.log('[Game1] Removed flash-red class');
}, 150);
}
async function fetchJson(url, options = {}) { async function fetchJson(url, options = {}) {
const opts = { ...options }; const opts = { ...options };
const headers = new Headers(opts.headers || {}); const headers = new Headers(opts.headers || {});
@@ -76,15 +128,64 @@ document.addEventListener('DOMContentLoaded', async () => {
const mercurePublicUrl = cfgEl.dataset.mercurePublicUrl; const mercurePublicUrl = cfgEl.dataset.mercurePublicUrl;
const topic = cfgEl.dataset.topic; const topic = cfgEl.dataset.topic;
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); subscribeToMercure(mercurePublicUrl, topic, screen);
} else { } else {
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) {
@@ -125,16 +226,27 @@ document.addEventListener('DOMContentLoaded', async () => {
const printNextMessage = () => { const printNextMessage = () => {
if (currentMessageIndex < messages.length) { if (currentMessageIndex < messages.length) {
const msg = messages[currentMessageIndex]; const msg = messages[currentMessageIndex];
const msgEl = document.createElement('div'); const msgEl = document.createElement('div');
msgEl.className = 'message';
let extraClass = '';
if(msg[2])
extraClass = msg[2];
msgEl.className = 'message ' + extraClass;
msgEl.textContent = msg[0]; msgEl.textContent = msg[0];
msgEl.style.color = '#F00';
msgEl.style.marginBottom = '10px'; msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl); messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
playSound();
currentMessageIndex++; currentMessageIndex++;
setTimeout(printNextMessage, msg[1]); setTimeout(printNextMessage, msg[1]);
if (sequenceFinished) {
flashRed();
}
} else { } else {
// After it has printed a set of messages, it has to start a timer of 2 seconds // After it has printed a set of messages, it has to start a timer of 2 seconds
console.log('[Game1] All messages printed. Starting 2s timer to expand message-container height...'); console.log('[Game1] All messages printed. Starting 2s timer to expand message-container height...');
@@ -146,7 +258,16 @@ document.addEventListener('DOMContentLoaded', async () => {
// Add event listener for Enter key // Add event listener for Enter key
inputField.addEventListener('keypress', async (e) => { inputField.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
stillPlayingSound = false;
sequenceFinished = false;
const message = inputField.value.trim(); const message = inputField.value.trim();
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = message;
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
if (message && apiEchoUrl) { if (message && apiEchoUrl) {
inputField.value = ''; inputField.value = '';
try { try {
@@ -155,6 +276,16 @@ document.addEventListener('DOMContentLoaded', async () => {
body: { message, ts: new Date().toISOString() }, body: { message, ts: new Date().toISOString() },
}); });
console.log('[API][game1] message sent →', response); console.log('[API][game1] message sent →', response);
if (response && response.result && Array.isArray(response.result.result)) {
response.result.result.forEach(text => {
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = text;
msgEl.style.marginBottom = '10px';
messageContainer.appendChild(msgEl);
});
window.scrollTo(0, document.body.scrollHeight);
}
} catch (err) { } catch (err) {
console.error('[API][game1] Failed to send message:', err); console.error('[API][game1] Failed to send message:', err);
} }
@@ -163,6 +294,8 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
console.log('[Game1] message-container height changed to 400vh and input enabled'); console.log('[Game1] message-container height changed to 400vh and input enabled');
sequenceFinished = true;
console.log('[Game1] sequenceFinished is now TRUE');
}, 2000); }, 2000);
} }
}; };

View File

@@ -29,8 +29,11 @@ body {
div#game-timer { div#game-timer {
position: fixed; position: fixed;
top: 20px; top: 0;
left: 20px; left: 0;
width: 100%;
padding: 20px;
background-color: #000;
color: #F00; color: #F00;
font-size: 28px; font-size: 28px;
z-index: 100; z-index: 100;
@@ -38,7 +41,7 @@ div#game-timer {
div#message-container { div#message-container {
padding: 20px; padding: 20px;
padding-top: 60px; /* Space for fixed timer */ padding-top: 80px; /* Space for fixed timer */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
@@ -47,6 +50,11 @@ div#message-container {
font-size: 20px; font-size: 20px;
} }
div.message {
color: #C0C0C0;
white-space: pre-wrap;
}
div#input { div#input {
padding: 20px; padding: 20px;
} }
@@ -54,10 +62,32 @@ div#input {
input#input-message { input#input-message {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
background: #000; background: #111;
border: 1px solid #F00; border: 1px solid #A00000;
color: #F00; color: #C0C0C0;
font-size: 18px; font-size: 18px;
box-sizing: border-box; box-sizing: border-box;
font-family: monospace; font-family: monospace;
} }
.flash-red::after {
content: '';
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100px;
pointer-events: none;
background: linear-gradient(to top, rgba(255, 0, 0, 0.8), transparent);
animation: flash-red-anim 0.1s linear forwards;
z-index: 9999;
}
@keyframes flash-red-anim {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -7,11 +7,11 @@ services:
environment: environment:
# Uncomment the following line to disable HTTPS, # Uncomment the following line to disable HTTPS,
#SERVER_NAME: ':80' #SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET}
# Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive # Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive
MERCURE_EXTRA_DIRECTIVES: | MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://127.0.0.1:8000 cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS:-http://localhost:8080}
# Comment the following line to disable the development mode # Comment the following line to disable the development mode
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
healthcheck: healthcheck:

View File

@@ -1,6 +1,14 @@
doctrine: doctrine:
dbal: dbal:
url: '%env(resolve:DATABASE_URL)%' # url: '%env(resolve:DATABASE_URL)%'
driver: '%env(DB_DRIVER)%'
server_version: '%env(DB_SERVER_VERSION)%'
host: '%env(DB_HOST)%'
port: '%env(DB_PORT)%'
user: '%env(DB_USER)%'
password: '%env(DB_PASSWORD)%'
dbname: '%env(DB_NAME)%'
charset: '%env(DB_CHARSET)%'
# IMPORTANT: You MUST configure your server version, # IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file) # either here or in the DATABASE_URL env var (see .env file)

View File

@@ -3,4 +3,6 @@ mercure:
default: default:
url: '%env(MERCURE_URL)%' url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%' public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt: '%env(MERCURE_JWT_SECRET)%' jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: ['*']

View File

@@ -16,5 +16,9 @@ services:
App\: App\:
resource: '../src/' resource: '../src/'
App\Game\Service\GameResponseService:
arguments:
$projectDir: '%kernel.project_dir%'
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones

View File

@@ -63,12 +63,12 @@ services:
container_name: escapepage-mercure container_name: escapepage-mercure
environment: environment:
SERVER_NAME: ":80" SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureJWT!} MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureJWT!} MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET}
MERCURE_CORS_ALLOWED_ORIGINS: http://localhost:8080 MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS:-http://localhost:8080}
MERCURE_PUBLISH_ALLOWED_ORIGINS: http://localhost:8080 MERCURE_PUBLISH_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS:-http://localhost:8080}
MERCURE_EXTRA_DIRECTIVES: | MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://localhost:8080 cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS:-http://localhost:8080}
# Allow anonymous subscribers in dev only # Allow anonymous subscribers in dev only
anonymous anonymous
ports: ports:
@@ -82,9 +82,9 @@ services:
image: mysql:8.0 image: mysql:8.0
container_name: escapepage-db container_name: escapepage-db
environment: environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-app} MYSQL_DATABASE: ${DB_NAME:-app}
MYSQL_USER: ${MYSQL_USER:-app} MYSQL_USER: ${DB_USER:-app}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-!ChangeMe!} MYSQL_PASSWORD: ${DB_PASSWORD:-!ChangeMe!}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}"] test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}"]

BIN
public/audio/message.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Repository\SessionRepository;
use App\Tech\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[Route('/admin')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminController extends AbstractController
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir
) {
}
#[Route('', name: 'game_admin_dashboard', methods: ['GET'])]
public function index(
UserRepository $userRepository,
SessionRepository $sessionRepository
): Response {
$players = $userRepository->findByRole('ROLE_PLAYER');
$sessions = $sessionRepository->findAll();
return $this->render('game/admin/index.html.twig', [
'players' => $players,
'sessions' => $sessions,
]);
}
#[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])]
public function viewSession(Session $session): Response
{
$playersLogs = [];
foreach ($session->getPlayers() as $player) {
$username = $player->getUser()->getUsername();
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
$logs = '';
if (file_exists($logFile)) {
$logs = file_get_contents($logFile);
}
$playersLogs[] = [
'username' => $username,
'logs' => $logs,
];
}
return $this->render('game/admin/session.html.twig', [
'session' => $session,
'playersLogs' => $playersLogs,
]);
}
}

View File

@@ -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
{ {

View File

@@ -3,17 +3,229 @@ 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\SessionSetting;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository;
use App\Game\Repository\PlayerRepository;
use App\Game\Repository\SessionRepository;
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\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; 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\ExpressionLanguage\Expression;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class GameController extends AbstractController final class GameController extends AbstractController
{ {
#[Route(path: '', name: 'game')] public function __construct(
public function index(): Response #[Autowire('%env(MERCURE_PUBLIC_URL)%')]
{ private string $mercurePublicUrl,
return $this->render('game/index.html.twig', [ private \Doctrine\ORM\EntityManagerInterface $entityManager
'user_id' => $this->getUser()?->getId(), ) {
}
#[Route(path: '', name: 'game_dashboard', methods: ['GET', 'POST'])]
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
public function dashboard(
Request $request,
GameRepository $gameRepository,
SessionRepository $sessionRepository,
GameDashboardService $dashboardService,
Security $security
): Response {
$user = $security->getUser();
$isAdmin = $this->isGranted('ROLE_ADMIN');
if ($request->isMethod('POST')) {
if ($request->request->has('create_session')) {
$gameId = $request->request->get('game_id');
$game = $gameRepository->find($gameId);
if ($game) {
if ($dashboardService->createSession($game, $user, $isAdmin)) {
$this->addFlash('success', 'New session created!');
}
}
} elseif ($request->request->has('join_session')) {
$inviteCode = $request->request->get('invite_code');
if ($dashboardService->joinSession($inviteCode, $user)) {
$this->addFlash('success', 'Joined session successfully!');
} else {
$this->addFlash('error', 'Invalid invite code or session full.');
}
} elseif ($request->request->has('create_invite')) {
$sessionId = $request->request->get('session_id');
$session = $sessionRepository->find($sessionId);
if (!$session) {
$this->addFlash('error', 'Session not found.');
return $this->redirectToRoute('game_dashboard');
}
$inviteCode = $dashboardService->generateInviteCode($session, $user, $isAdmin);
if ($inviteCode) {
$this->addFlash('success', 'Invite link created: ' . $inviteCode);
}
} elseif ($request->request->has('leave_session')) {
$sessionId = $request->request->get('session_id');
$session = $sessionRepository->find($sessionId);
if ($session) {
if ($dashboardService->leaveSession($session, $user)) {
$this->addFlash('success', 'Left session successfully.');
} else {
$this->addFlash('error', 'Could not leave session (game might have started).');
}
}
} elseif ($request->request->has('start_session')) {
$sessionId = $request->request->get('session_id');
$session = $sessionRepository->find($sessionId);
if ($session) {
if ($dashboardService->startSession($session)) {
$this->addFlash('success', 'Session started! Screens have been assigned.');
} else {
$this->addFlash('error', 'Could not start session. Make sure all players have joined.');
}
}
}
return $this->redirectToRoute('game_dashboard');
}
return $this->render('game/dashboard.html.twig', [
'sessions' => $dashboardService->getSessionsForUser($user),
'availableGames' => $dashboardService->getAvailableGames($isAdmin),
]); ]);
} }
#[Route(path: '/{session}', name: 'game', methods: ['GET', 'POST'])]
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
#[IsGranted('SESSION_VIEW', subject: 'session')]
public function index(
Session $session,
Request $request,
Security $security,
PlayerRepository $playerRepository,
GameDashboardService $dashboardService
): Response
{
$user = $security->getUser();
if (!$user instanceof User) {
throw $this->createAccessDeniedException();
}
$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;
$session_id = $session->getId();
return $this->render('game/index.html.twig', [
'session' => $session,
'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();
}
} }

View File

@@ -23,7 +23,7 @@ class Player
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
private ?User $user = null; private ?User $user = null;
#[ORM\Column] #[ORM\Column(nullable: true)]
private ?int $screen = null; private ?int $screen = null;
public function getId(): ?int public function getId(): ?int
@@ -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

@@ -7,7 +7,71 @@ enum SessionSettingType: string
case PWD_FOR_PLAYER1 = 'PwdForPlayer1'; case PWD_FOR_PLAYER1 = 'PwdForPlayer1';
case PWD_FOR_PLAYER2 = 'PwdForPlayer2'; case PWD_FOR_PLAYER2 = 'PwdForPlayer2';
case PWD_FOR_PLAYER3 = 'PwdForPlayer3'; case PWD_FOR_PLAYER3 = 'PwdForPlayer3';
case PWD_FOR_PLAYER4 = 'PwdForPlayer4';
case PWD_FOR_PLAYER5 = 'PwdForPlayer5';
case PWD_FOR_PLAYER6 = 'PwdForPlayer6';
case PWD_FOR_PLAYER7 = 'PwdForPlayer7';
case PWD_FOR_PLAYER8 = 'PwdForPlayer8';
case PWD_FOR_PLAYER9 = 'PwdForPlayer9';
case PWD_FOR_PLAYER10 = 'PwdForPlayer10';
case RIGHTS_FOR_PLAYER1 = 'RightsForPlayer1'; case RIGHTS_FOR_PLAYER1 = 'RightsForPlayer1';
case RIGHTS_FOR_PLAYER2 = 'RightsForPlayer2'; case RIGHTS_FOR_PLAYER2 = 'RightsForPlayer2';
case RIGHTS_FOR_PLAYER3 = 'RightsForPlayer3'; case RIGHTS_FOR_PLAYER3 = 'RightsForPlayer3';
case RIGHTS_FOR_PLAYER4 = 'RightsForPlayer4';
case RIGHTS_FOR_PLAYER5 = 'RightsForPlayer5';
case RIGHTS_FOR_PLAYER6 = 'RightsForPlayer6';
case RIGHTS_FOR_PLAYER7 = 'RightsForPlayer7';
case RIGHTS_FOR_PLAYER8 = 'RightsForPlayer8';
case RIGHTS_FOR_PLAYER9 = 'RightsForPlayer9';
case RIGHTS_FOR_PLAYER10 = 'RightsForPlayer10';
case INVITE_CODE = 'InviteCode';
case SET_OF_DELETED_FILES = 'SetOfDeletedFiles';
case CHAT_TRACKING_FOR_PLAYER1 = 'ChatTrackingForPlayer1';
case CHAT_TRACKING_FOR_PLAYER2 = 'ChatTrackingForPlayer2';
case CHAT_TRACKING_FOR_PLAYER3 = 'ChatTrackingForPlayer3';
case CHAT_TRACKING_FOR_PLAYER4 = 'ChatTrackingForPlayer4';
case CHAT_TRACKING_FOR_PLAYER5 = 'ChatTrackingForPlayer5';
case CHAT_TRACKING_FOR_PLAYER6 = 'ChatTrackingForPlayer6';
case CHAT_TRACKING_FOR_PLAYER7 = 'ChatTrackingForPlayer7';
case CHAT_TRACKING_FOR_PLAYER8 = 'ChatTrackingForPlayer8';
case CHAT_TRACKING_FOR_PLAYER9 = 'ChatTrackingForPlayer9';
case CHAT_TRACKING_FOR_PLAYER10 = 'ChatTrackingForPlayer10';
case VERIFY_CODES_FOR_PLAYER1 = 'VerifyCodesForPlayer1';
case VERIFY_CODES_FOR_PLAYER2 = 'VerifyCodesForPlayer2';
case VERIFY_CODES_FOR_PLAYER3 = 'VerifyCodesForPlayer3';
case VERIFY_CODES_FOR_PLAYER4 = 'VerifyCodesForPlayer4';
case VERIFY_CODES_FOR_PLAYER5 = 'VerifyCodesForPlayer5';
case VERIFY_CODES_FOR_PLAYER6 = 'VerifyCodesForPlayer6';
case VERIFY_CODES_FOR_PLAYER7 = 'VerifyCodesForPlayer7';
case VERIFY_CODES_FOR_PLAYER8 = 'VerifyCodesForPlayer8';
case VERIFY_CODES_FOR_PLAYER9 = 'VerifyCodesForPlayer9';
case VERIFY_CODES_FOR_PLAYER10 = 'VerifyCodesForPlayer10';
case VERIFICATION_PROGRESS_FOR_PLAYER1 = 'VerificationProgressForPlayer1';
case VERIFICATION_PROGRESS_FOR_PLAYER2 = 'VerificationProgressForPlayer2';
case VERIFICATION_PROGRESS_FOR_PLAYER3 = 'VerificationProgressForPlayer3';
case VERIFICATION_PROGRESS_FOR_PLAYER4 = 'VerificationProgressForPlayer4';
case VERIFICATION_PROGRESS_FOR_PLAYER5 = 'VerificationProgressForPlayer5';
case VERIFICATION_PROGRESS_FOR_PLAYER6 = 'VerificationProgressForPlayer6';
case VERIFICATION_PROGRESS_FOR_PLAYER7 = 'VerificationProgressForPlayer7';
case VERIFICATION_PROGRESS_FOR_PLAYER8 = 'VerificationProgressForPlayer8';
case VERIFICATION_PROGRESS_FOR_PLAYER9 = 'VerificationProgressForPlayer9';
case VERIFICATION_PROGRESS_FOR_PLAYER10 = 'VerificationProgressForPlayer10';
case EVERYONE_VERIFIED = 'EveryoneVerified';
case SPECIAL_REPORT_CODE_DOYLE = 'SpecialReportCodeDoyle';
case SPECIAL_REPORT_CODE_VEGA = 'SpecialReportCodeVega';
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';
} }

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Game\Security\Voter;
use App\Game\Entity\Session;
use App\Tech\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class SessionVoter extends Voter
{
public const VIEW = 'SESSION_VIEW';
public function __construct(
private readonly Security $security,
) {
}
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::VIEW && $subject instanceof Session;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
/** @var Session $session */
$session = $subject;
foreach ($session->getPlayers() as $player) {
if ($player->getUser() === $user) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,464 @@
<?php
declare(strict_types=1);
namespace App\Game\Service;
use App\Game\Entity\Game;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Enum\GameStatus;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Repository\GameRepository;
use App\Game\Repository\SessionRepository;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
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
{
public function __construct(
private readonly GameRepository $gameRepository,
private readonly SessionRepository $sessionRepository,
private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private readonly string $mercureTopicBase,
) {
}
/**
* @return Session[]
*/
public function getSessionsForUser(UserInterface $user): array
{
return $this->sessionRepository->createQueryBuilder('s')
->innerJoin('s.players', 'p')
->where('p.user = :user')
->setParameter('user', $user)
->getQuery()
->getResult();
}
/**
* @return Game[]
*/
public function getAvailableGames(bool $isAdmin): array
{
if ($isAdmin) {
return $this->gameRepository->findAll();
}
return $this->gameRepository->findBy(['status' => GameStatus::OPEN]);
}
public function createSession(Game $game, UserInterface $user, bool $isAdmin): ?Session
{
if ($game->getStatus() !== GameStatus::OPEN && !$isAdmin) {
return null;
}
if(!$user instanceof User)
return null;
$session = new Session();
$session->setGame($game);
$session->setStatus(SessionStatus::CREATED);
$session->setTimer(0);
$player = new Player();
$player->setUser($user);
$session->addPlayer($player);
$this->entityManager->persist($session);
$this->entityManager->persist($player);
$this->entityManager->flush();
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
$this->startSession($session);
}
return $session;
}
public function joinSession(string $inviteCode, UserInterface $user): bool
{
if (!$user instanceof User) {
return false;
}
$setting = $this->entityManager->getRepository(SessionSetting::class)->findOneBy([
'name' => SessionSettingType::INVITE_CODE,
'value' => $inviteCode,
]);
if (!$setting) {
return false;
}
$session = $setting->getSession();
// Check if user is already in this session
foreach ($session->getPlayers() as $player) {
if ($player->getUser() === $user) {
return true; // Already joined
}
}
$playerCount = count($session->getPlayers());
if ($playerCount >= $session->getGame()->getNumberOfPlayers()) {
return false; // Session full
}
$player = new Player();
$player->setUser($user);
$session->addPlayer($player);
$this->entityManager->persist($player);
$this->entityManager->flush();
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
$this->startSession($session);
}
return true;
}
public function leaveSession(Session $session, UserInterface $user): bool
{
if (!$user instanceof User) {
return false;
}
if (!in_array($session->getStatus(), [SessionStatus::CREATED, SessionStatus::READY]) || $session->getTimer() > 0) {
return false;
}
$playerToDelete = null;
foreach ($session->getPlayers() as $player) {
if ($player->getUser() === $user) {
$playerToDelete = $player;
break;
}
}
if (!$playerToDelete) {
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)
foreach ($session->getSettings() as $setting) {
if ($setting->getPlayer() === $playerToDelete) {
$session->removeSetting($setting);
$this->entityManager->remove($setting);
}
}
$session->removePlayer($playerToDelete);
$this->entityManager->remove($playerToDelete);
// If no players left, we might want to delete the session and its remaining settings
if ($session->getPlayers()->isEmpty()) {
foreach ($session->getSettings() as $setting) {
$this->entityManager->remove($setting);
}
$this->entityManager->remove($session);
}
$this->entityManager->flush();
return true;
}
private function initializePlayerSettings(Player $player): void
{
$screen = $player->getScreen();
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
if ($rightsSettingName) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($rightsSettingName);
$setting->setValue(json_encode(['chat', 'help', 'ls', 'pwd']));
$this->entityManager->persist($setting);
}
$pwdSettingName = SessionSettingType::tryFrom('PwdForPlayer' . $screen);
if ($pwdSettingName) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($pwdSettingName);
$setting->setValue('var/home/' . $player->getUser()->getUsername());
$this->entityManager->persist($setting);
}
$chatTrackingSettingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $screen);
if ($chatTrackingSettingName) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($chatTrackingSettingName);
$setting->setValue(json_encode([]));
$this->entityManager->persist($setting);
}
$verifyCodesSettingName = SessionSettingType::tryFrom('VerifyCodesForPlayer' . $screen);
if ($verifyCodesSettingName) {
$codes = [];
$numPlayers = $player->getSession()->getGame()->getNumberOfPlayers();
for ($i = 1; $i <= $numPlayers; $i++) {
if ($i !== $screen) {
$codes[$i] = bin2hex(random_bytes(3)); // 6 characters code
}
}
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($verifyCodesSettingName);
$setting->setValue(json_encode($codes));
$this->entityManager->persist($setting);
}
$verificationProgressSettingName = SessionSettingType::tryFrom('VerificationProgressForPlayer' . $screen);
if ($verificationProgressSettingName) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($verificationProgressSettingName);
$setting->setValue(json_encode([]));
$this->entityManager->persist($setting);
}
}
public function startSession(Session $session): bool
{
if ($session->getStatus() !== SessionStatus::CREATED) {
return false;
}
$players = $session->getPlayers()->toArray();
if (count($players) < $session->getGame()->getNumberOfPlayers()) {
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);
foreach ($players as $index => $player) {
$screen = $index + 1;
$player->setScreen($screen);
$this->initializePlayerSettings($player);
$this->entityManager->persist($player);
}
$session->setStatus(SessionStatus::READY);
$this->entityManager->persist($session);
$this->entityManager->flush();
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
{
// Security check: is user part of this session?
$isPlayer = false;
foreach ($session->getPlayers() as $player) {
if ($player->getUser() === $user) {
$isPlayer = true;
break;
}
}
if (!$isPlayer && !$isAdmin) {
return null;
}
$inviteCode = bin2hex(random_bytes(4));
$setting = null;
foreach ($session->getSettings() as $s) {
if ($s->getName() === SessionSettingType::INVITE_CODE) {
$setting = $s;
break;
}
}
if (!$setting) {
$setting = new SessionSetting();
$setting->setSession($session);
$setting->setName(SessionSettingType::INVITE_CODE);
}
$setting->setValue($inviteCode);
$this->entityManager->persist($setting);
$this->entityManager->flush();
return $inviteCode;
}
}

View File

@@ -5,9 +5,14 @@ namespace App\Game\Service;
use App\Game\Enum\DecodeMessage; use App\Game\Enum\DecodeMessage;
use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionSettingType;
use App\Game\Entity\Player; use App\Game\Entity\Player;
use App\Game\Entity\SessionSetting;
use App\Game\Repository\SessionSettingRepository; use App\Game\Repository\SessionSettingRepository;
use App\Tech\Entity\User; use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class GameResponseService class GameResponseService
{ {
@@ -15,10 +20,15 @@ class GameResponseService
private Security $security, private Security $security,
private PlayerService $playerService, private PlayerService $playerService,
private SessionSettingRepository $sessionSettingRepository, private SessionSettingRepository $sessionSettingRepository,
private HubInterface $hub,
private EntityManagerInterface $entityManager,
private string $projectDir,
#[Autowire('%env(MERCURE_TOPIC_BASE)%')]
private string $mercureTopicBase,
) { ) {
} }
public function getGameResponse(string $raw) public function getGameResponse(string $raw) : array
{ {
$info = json_decode($raw, true); $info = json_decode($raw, true);
@@ -38,7 +48,7 @@ class GameResponseService
if(!$player) if(!$player)
return ['error' => 'You are not in a game.']; return ['error' => 'You are not in a game.'];
// TODO: Here i need to add a message handler to save the message in a big log. $this->logSessionActivity($player, 'PLAYER: ' . $message);
$data = []; $data = [];
@@ -48,17 +58,46 @@ class GameResponseService
$data = $this->checkConsoleCommando($message, $player); $data = $this->checkConsoleCommando($message, $player);
} }
$responseLog = '';
if (isset($data['result']) && is_array($data['result'])) {
foreach ($data['result'] as $line) {
if (is_array($line)) {
$responseLog .= json_encode($line) . "\n";
} elseif (is_string($line) || is_numeric($line)) {
$responseLog .= (string)$line . "\n";
}
}
} elseif (isset($data['error'])) {
$responseLog = 'ERROR: ' . $data['error'];
}
if ($responseLog !== '') {
$this->logSessionActivity($player, 'SERVER: ' . trim($responseLog));
}
return $data; return $data;
} }
private function logSessionActivity(Player $player, string $content): void
{
$sessionId = $player->getSession()->getId();
$username = $player->getUser()->getUsername();
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
if (!is_dir($logDir)) {
mkdir($logDir, 0777, true);
}
$logFile = $logDir . '/' . $username . '.txt';
$timestamp = date('Y-m-d H:i:s');
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
file_put_contents($logFile, $logMessage, FILE_APPEND);
}
private function getRechten(Player $player): array private function getRechten(Player $player): array
{ {
$settingName = match($player->getScreen()) { $settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen());
1 => SessionSettingType::RIGHTS_FOR_PLAYER1,
2 => SessionSettingType::RIGHTS_FOR_PLAYER2,
3 => SessionSettingType::RIGHTS_FOR_PLAYER3,
default => null,
};
if (!$settingName) { if (!$settingName) {
return []; return [];
@@ -83,8 +122,10 @@ class GameResponseService
if(!in_array('chat', $rechten)) if(!in_array('chat', $rechten))
return ['result' => ['Unknown command']]; return ['result' => ['Unknown command']];
$this->handleChatMessage($message); if($this->handleChatMessage($message, $player))
break; return ['result' => ['succesfully send']];
else
return ['result' => ['Error sending']];
case '/help': case '/help':
return ['result' => $this->getHelpCommand($rechten)]; return ['result' => $this->getHelpCommand($rechten)];
case '/decode': case '/decode':
@@ -96,12 +137,11 @@ class GameResponseService
if(!in_array('verify', $rechten)) if(!in_array('verify', $rechten))
return ['result' => ['Unknown command']]; return ['result' => ['Unknown command']];
$result = $this->handleVerifyMessage($message); $result = $this->handleVerifyMessage($message, $player);
return ['result' => [$result]]; return ['result' => [$result]];
default: default:
return ['result' => ['Unknown command']]; return ['result' => ['Unknown command']];
} }
return [];
} }
private function checkConsoleCommando(string $message, Player $player, bool $sudo = false) : array private function checkConsoleCommando(string $message, Player $player, bool $sudo = false) : array
@@ -112,8 +152,15 @@ class GameResponseService
case 'help': case 'help':
return ['result' => $this->getHelpCommand($rechten)]; return ['result' => $this->getHelpCommand($rechten)];
case 'ls': case 'ls':
break; if(!in_array('ls', $rechten))
return ['result' => ['Unknown command']];
$files = $this->getAllCurrentFilesInDirectory($player);
return ['result' => $files];
case 'cd': case 'cd':
if(!in_array('cd', $rechten))
return ['result' => ['Unknown command']];
$pwd = $this->playerService->getCurrentPwdOfPlayer($player); $pwd = $this->playerService->getCurrentPwdOfPlayer($player);
if(!$pwd) if(!$pwd)
return ['result' => ['Unknown command']]; return ['result' => ['Unknown command']];
@@ -124,14 +171,52 @@ class GameResponseService
$this->playerService->saveCurrentPwdOfPlayer($player, $newLocation); $this->playerService->saveCurrentPwdOfPlayer($player, $newLocation);
return ['result' => ['Path: ' . $newLocation]]; return ['result' => ['Path: ' . $newLocation]];
case 'cat':
if(!in_array('cat', $rechten))
return ['result' => ['Unknown command']];
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
$fileContent = $this->getFileContent($player, $pwd.'/'.$messagePart[1]);
return ['result' => $fileContent];
case 'pwd':
if(!in_array('pwd', $rechten))
return ['result' => ['Unknown command']];
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
return ['result' => ['Path: ' . $pwd]];
case 'rm': case 'rm':
break; if(!in_array('rm', $rechten))
return ['result' => ['Unknown command']];
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
if(!$pwd)
return ['result' => ['Unknown command']];
if (!isset($messagePart[1])) {
return ['result' => ['Usage: rm {filename}']];
}
$filename = $messagePart[1];
$fullPath = ($pwd === '/' ? '' : $pwd) . '/' . $filename;
if(!$this->isAllowedToRemove($fullPath, $player, $sudo))
return ['result' => ['You are not allowed to remove this file.']];
$this->playerService->addDeletedFileToSession($player, $fullPath);
return ['result' => ['File removed: ' . $filename]];
case 'sudo': case 'sudo':
break; if(!in_array('sudo', $rechten))
return ['result' => ['Unknown command']];
$sudo = array_shift($messagePart);
$message = implode(' ', $messagePart);
return $this->checkConsoleCommando($message, $player, true);
default: default:
return ['result' => ['Unknown command']]; return ['result' => ['Unknown command']];
} }
return [];
} }
private function getHelpCommand(mixed $rechten) : array private function getHelpCommand(mixed $rechten) : array
@@ -146,6 +231,7 @@ class GameResponseService
$messages[] = ' If you want to send a message specifically to one other agent, use the id of the agent after /chat, like /chat 6 {message}'; $messages[] = ' If you want to send a message specifically to one other agent, use the id of the agent after /chat, like /chat 6 {message}';
$messages[] = ' This will send the message only to agent with id 6.'; $messages[] = ' This will send the message only to agent with id 6.';
$messages[] = ' USAGE: /chat {message}'; $messages[] = ' USAGE: /chat {message}';
$messages[] = ' USAGE: /chat 6 {message}';
$messages[] = ''; $messages[] = '';
break; break;
case 'help': case 'help':
@@ -161,6 +247,13 @@ class GameResponseService
$messages[] = ' USAGE: /decode {message}'; $messages[] = ' USAGE: /decode {message}';
$messages[] = ''; $messages[] = '';
break; break;
case 'pwd':
$messages[] = 'pwd';
$messages[] = ' This message will let you know what your current location is.';
$messages[] = ' It will show you the folder you are in so you can continue navigating the server.';
$messages[] = ' USAGE: pwd';
$messages[] = '';
break;
case 'cat': case 'cat':
$messages[] = 'cat'; $messages[] = 'cat';
$messages[] = ' To read a file, use cat {filename}.'; $messages[] = ' To read a file, use cat {filename}.';
@@ -210,9 +303,174 @@ class GameResponseService
return $messages; return $messages;
} }
private function handleChatMessage(string $message) private function handleChatMessage(string $message, Player $player) : bool
{ {
$messageParts = explode(' ', $message);
$toSingle = false;
if (isset($messageParts[1]) &&
is_numeric($messageParts[1]) &&
$messageParts[1] >= 1 &&
$messageParts[1] <= $player->getSession()->getGame()->getNumberOfPlayers()) {
$toSingle = true;
}
$chatMessage = array_shift($messageParts);
$sendTo = 0;
if ($toSingle) {
$sendTo = array_shift($messageParts);
$chatMessage = array_shift($messageParts);
}
$message = $player->getUser()->getUsername() . ': ' . $chatMessage . ' ';
foreach($messageParts as $messagePart) {
$message .= $messagePart . ' ';
}
$message = trim($message);
$activeGame = $player->getSession()?->getId();
if(is_null($activeGame))
return false;
$topic = $this->mercureTopicBase . '/game/hub-' . $activeGame;
try {
$this->hub->publish(new Update($topic, json_encode([$sendTo, $message])));
} catch (\Exception $e) {
// Mercure might be down
}
$this->updateChatTracking($player, (int)$sendTo);
$this->checkAndRegenerateVerifyCodes($player, $chatMessage . ' ' . implode(' ', $messageParts));
return true;
}
private function checkAndRegenerateVerifyCodes(Player $player, string $messageContent): void
{
$screen = $player->getScreen();
$session = $player->getSession();
$verifyCodesSettingName = SessionSettingType::tryFrom('VerifyCodesForPlayer' . $screen);
if (!$verifyCodesSettingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($session, $verifyCodesSettingName, $player);
if (!$setting) {
return;
}
$codes = json_decode($setting->getValue() ?? '[]', true) ?? [];
$regenerated = false;
foreach ($codes as $targetPlayerScreen => $code) {
if (str_contains($messageContent, (string)$code)) {
$codes[$targetPlayerScreen] = bin2hex(random_bytes(3));
$regenerated = true;
}
}
if ($regenerated) {
$setting->setValue(json_encode($codes));
$this->entityManager->persist($setting);
$this->entityManager->flush();
// Notify the player that their codes have changed
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$notification = "Security Alert: One of your verify codes was shared and has been regenerated.";
// We send it only to this player (screen)
try {
$this->hub->publish(new Update($topic, json_encode([$screen, $notification])));
} catch (\Exception $e) {
// Mercure might be down
}
}
}
private function updateChatTracking(Player $player, int $sendTo): void
{
$rights = $this->getRechten($player);
if(in_array('verify', $rights))
return;
$trackingSettingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $player->getScreen());
if (!$trackingSettingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $trackingSettingName, $player);
if (!$setting) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($trackingSettingName);
$setting->setValue(json_encode([]));
}
$tracking = json_decode($setting->getValue() ?? '[]', true) ?? [];
if (!in_array($sendTo, $tracking)) {
$tracking[] = $sendTo;
$setting->setValue(json_encode($tracking));
$this->entityManager->persist($setting);
$this->entityManager->flush();
$this->checkAndGrantVerifyRight($player, $tracking);
}
}
private function checkAndGrantVerifyRight(Player $player, array $tracking): void
{
$screen = $player->getScreen();
$requiredTargets = [0]; // Everyone
$numPlayers = $player->getSession()->getGame()->getNumberOfPlayers();
for ($i = 1; $i <= $numPlayers; $i++) {
if ($i !== $screen) {
$requiredTargets[] = $i;
}
}
// Check if all required targets are in tracking
foreach ($requiredTargets as $target) {
if (!in_array($target, $tracking)) {
return;
}
}
// Grant verify right
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
if (!$rightsSettingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
if (!$setting) {
return; // Should have been initialized
}
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
$newRights = ['verify', 'cat'];
$updated = false;
foreach ($newRights as $newRight) {
if (!in_array($newRight, $rights)) {
$rights[] = $newRight;
$updated = true;
}
}
if ($updated) {
$setting->setValue(json_encode($rights));
$this->entityManager->persist($setting);
$this->entityManager->flush();
}
} }
private function handleDecodeMessage(string $message, Player $player) private function handleDecodeMessage(string $message, Player $player)
@@ -247,9 +505,182 @@ class GameResponseService
return $randomString; return $randomString;
} }
private function handleVerifyMessage(string $message) : string private function generateSpecialCode(int $firstDigit, int $min, int $max): string
{ {
return ''; $code = $this->generateRandomString($min, $max);
// Ensure the first numeric digit is the specified $firstDigit
$found = false;
$codeArray = str_split($code);
for ($i = 0; $i < count($codeArray); $i++) {
if (ctype_digit($codeArray[$i])) {
$codeArray[$i] = (string)$firstDigit;
$found = true;
break;
}
}
$code = implode('', $codeArray);
// If no digit was found (highly unlikely given the character set), prepend it
if (!$found) {
$code = (string)$firstDigit . substr($code, 1);
}
return $code;
}
private function handleVerifyMessage(string $message, Player $player) : string
{
$messageParts = explode(' ', $message);
if (count($messageParts) < 2) {
return 'Usage: /verify {code}';
}
$code = $messageParts[1];
$screen = $player->getScreen();
$session = $player->getSession();
$progressSettingName = SessionSettingType::tryFrom('VerificationProgressForPlayer' . $screen);
if (!$progressSettingName) {
return 'Error: Invalid player screen.';
}
$progressSetting = $this->sessionSettingRepository->getSetting($session, $progressSettingName, $player);
if (!$progressSetting) {
return 'Error: Verification progress setting not found.';
}
$progress = json_decode($progressSetting->getValue() ?? '[]', true) ?? [];
$verifiedBy = null;
foreach ($session->getPlayers() as $otherPlayer) {
if ($otherPlayer->getId() === $player->getId()) {
continue;
}
$otherScreen = $otherPlayer->getScreen();
$codesSettingName = SessionSettingType::tryFrom('VerifyCodesForPlayer' . $otherScreen);
if (!$codesSettingName) {
continue;
}
$codesSetting = $this->sessionSettingRepository->getSetting($session, $codesSettingName, $otherPlayer);
if (!$codesSetting) {
continue;
}
$codes = json_decode($codesSetting->getValue() ?? '[]', true) ?? [];
if (isset($codes[$screen]) && $codes[$screen] === $code) {
$verifiedBy = $otherScreen;
break;
}
}
if ($verifiedBy !== null) {
if (!in_array($verifiedBy, $progress)) {
$progress[] = $verifiedBy;
$progressSetting->setValue(json_encode($progress));
$this->entityManager->persist($progressSetting);
$this->entityManager->flush();
$response = 'You have been successfully verified by Agent ' . $verifiedBy . '.';
if (count($progress) >= 2) {
$this->grantVerificationRights($player);
$response .= ' You have received additional rights!';
}
return $response;
} else {
return 'You were already verified by Agent ' . $verifiedBy . '.';
}
}
return 'Invalid verification code.';
}
private function grantVerificationRights(Player $player): void
{
$screen = $player->getScreen();
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
if (!$rightsSettingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
if (!$setting) {
return;
}
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
$newRights = ['cd', 'decode'];
$updated = false;
foreach ($newRights as $newRight) {
if (!in_array($newRight, $rights)) {
$rights[] = $newRight;
$updated = true;
}
}
if ($updated) {
$setting->setValue(json_encode($rights));
$this->entityManager->persist($setting);
$this->entityManager->flush();
$this->checkIfAllPlayersVerified($player);
}
}
private function checkIfAllPlayersVerified(Player $player): void
{
$session = $player->getSession();
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::EVERYONE_VERIFIED, $player);
if ($everyoneVerifiedSetting && $everyoneVerifiedSetting->getValue() === 'true') {
return;
}
$allVerified = true;
foreach ($session->getPlayers() as $otherPlayer) {
$otherScreen = $otherPlayer->getScreen();
$progressSettingName = SessionSettingType::tryFrom('VerificationProgressForPlayer' . $otherScreen);
if (!$progressSettingName) {
continue;
}
$progressSetting = $this->sessionSettingRepository->getSetting($session, $progressSettingName, $otherPlayer);
$progress = json_decode($progressSetting?->getValue() ?? '[]', true) ?? [];
if (count($progress) < $session->getGame()->getNumberOfPlayers() - 1) {
$allVerified = false;
break;
}
}
if ($allVerified) {
if (!$everyoneVerifiedSetting) {
$everyoneVerifiedSetting = new SessionSetting();
$everyoneVerifiedSetting->setSession($session);
$everyoneVerifiedSetting->setPlayer($player);
$everyoneVerifiedSetting->setName(SessionSettingType::EVERYONE_VERIFIED);
}
$everyoneVerifiedSetting->setValue('true');
$this->entityManager->persist($everyoneVerifiedSetting);
$this->entityManager->flush();
$topic = $this->mercureTopicBase . '/game/hub-' . $session->getId();
$message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages.";
try {
$this->hub->publish(new Update($topic, json_encode([0, $message])));
} catch (\Exception $e) {
// Mercure might be down
}
}
} }
private function goToNewDir(string $pwd, string $newPwd, Player $player) : string|bool private function goToNewDir(string $pwd, string $newPwd, Player $player) : string|bool
@@ -311,22 +742,282 @@ class GameResponseService
$paths[] = '/etc/freak'; $paths[] = '/etc/freak';
$paths[] = '/etc/host'; $paths[] = '/etc/host';
$paths[] = '/home'; $paths[] = '/var/home';
$playerNames = ['root', 'Luke', 'Charles', 'William', 'Peter']; $playerNames = ['root', 'Luke', 'Charles', 'William', 'Peter'];
$players = $player->getSession()->getPlayers(); $players = $player->getSession()->getPlayers();
foreach($players as $player) { foreach($players as $p) {
$playerNames[] = $player->getUser()->getUsername(); $playerNames[] = $p->getUser()->getUsername();
} }
$playerNames = array_unique($playerNames); $playerNames = array_unique($playerNames);
foreach($playerNames as $name) { foreach($playerNames as $name) {
$paths[] = '/home/' . $name; $paths[] = '/var/home/' . $name;
} }
return $paths; return $paths;
} }
private function isAllowedToRemove(string $file, Player $player, bool $sudo) : bool
{
if(!$this->fileExists($file, $player))
return false;
if(str_starts_with($file, '/var/rapports/'))
return false;
$rights = $this->getRechten($player);
if(in_array('sudo', $rights) || $sudo)
return true;
$sudoFiles = [
'/var/arrest/handle.sh',
'/var/arrest/cell.sh',
'/var/marriage/divorce.sh',
];
return !in_array($file, $sudoFiles);
}
private function fileExists(string $file, Player $player) : bool
{
$files = $this->getAllPossibleFiles($player);
if(in_array($file, $files))
return true;
return false;
}
private function getAllPossibleFiles(Player $player = null) : array
{
$files = [];
$files[] = '/var/arrest/handle.sh';
$files[] = '/var/arrest/bars.sh';
$files[] = '/var/arrest/cell.sh';
$files[] = '/var/marriage/share.sh';
$files[] = '/var/marriage/divorce.sh';
$files[] = '/var/rapports/095_07-14.txt';
$files[] = '/var/rapports/007_19-52.txt';
$files[] = '/var/rapports/083_25-39.txt';
$files[] = '/var/rapports/019_31-11.txt';
$files[] = '/var/rapports/075_46-77.txt';
$files[] = '/var/rapports/031_53-28.txt';
$files[] = '/var/rapports/072_61-05.txt';
$files[] = '/var/rapports/064_72-90.txt';
$files[] = '/var/rapports/091_81-33.txt';
$files[] = '/var/rapports/079_89-47.txt';
$files[] = '/var/rapports/098_92-14.txt';
$files[] = '/var/rapports/012_94-31.txt';
$files[] = '/var/rapports/016_98-07.txt';
$files[] = '/var/rapports/087_102-45.txt';
$files[] = '/var/rapports/094_110-19.txt';
$files[] = '/var/rapports/063_117-56.txt';
$files[] = '/var/rapports/017_123-88.txt';
$files[] = '/var/rapports/093_138-24.txt';
$files[] = '/var/rapports/001_145-93.txt';
$files[] = '/var/rapports/011_130-62.txt';
$files[] = '/var/rapports/index.txt';
if ($player === null) {
return $files;
}
$players = $player->getSession()->getPlayers();
foreach($players as $p) {
$files[] = '/var/home/' . $p->getUser()->getUsername() . '/verifyCodes.txt';
}
return $files;
}
private function getAllCurrentFilesInDirectory(Player $player) : array
{
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
if (!$pwd) {
return [];
}
$allPaths = $this->getAllPossiblePaths($player);
$allFiles = $this->getAllPossibleFiles($player);
$deletedFiles = $this->playerService->getDeletedFilesOfSession($player);
$entries = [];
// Find directories in current pwd
foreach ($allPaths as $path) {
if ($path === $pwd) {
continue;
}
// Check if $path is a direct child of $pwd
$parent = $this->getPrevPath($path);
if ($parent === $pwd) {
$parts = explode('/', $path);
$entries[] = [end($parts) . '/', 'dir'];
}
}
// Find files in current pwd
foreach ($allFiles as $file) {
if (in_array($file, $deletedFiles)) {
continue;
}
$parent = $this->getPrevPath($file);
if ($parent === $pwd) {
$parts = explode('/', $file);
$entries[] = [end($parts), 'file'];
}
}
sort($entries);
return $entries;
}
public function getFileContent(Player $player, string $file) : array
{
$allPossibleFiles = $this->getAllPossibleFiles($player);
if (!in_array($file, $allPossibleFiles)) {
return ['File does not exist'];
}
if (str_ends_with($file, '.sh')) {
return ['It is not possible to read this file'];
}
if (str_ends_with($file, 'verifyCodes.txt')) {
return $this->readVerificationFile($player, $file);
}
$physicalPath = 'assets/game1/filesystem' . $file;
if (!file_exists($physicalPath)) {
return ['File does not exist'];
}
$content = file($physicalPath);
if ($content === false) {
return ['Error reading file'];
}
$specialFiles = [
'/var/rapports/083_25-39.txt' => [
'setting' => SessionSettingType::SPECIAL_REPORT_CODE_DOYLE,
'digit' => 1
],
'/var/rapports/019_31-11.txt' => [
'setting' => SessionSettingType::SPECIAL_REPORT_CODE_VEGA,
'digit' => 2
],
'/var/rapports/011_130-62.txt' => [
'setting' => SessionSettingType::SPECIAL_REPORT_CODE_LENNOX,
'digit' => 3
],
];
if (isset($specialFiles[$file])) {
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($player->getSession(), SessionSettingType::EVERYONE_VERIFIED);
if ($everyoneVerifiedSetting && $everyoneVerifiedSetting->getValue() === 'true') {
$settingInfo = $specialFiles[$file];
$codeSetting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingInfo['setting']);
if (!$codeSetting) {
$codeSetting = new SessionSetting();
$codeSetting->setSession($player->getSession());
$codeSetting->setName($settingInfo['setting']);
$codeSetting->setValue($this->generateSpecialCode($settingInfo['digit'], 75, 100));
$this->entityManager->persist($codeSetting);
$this->entityManager->flush();
}
$specialCode = $codeSetting->getValue();
$newContent = [];
foreach ($content as $line) {
$newContent[] = $line;
if (str_starts_with(trim($line), 'Date:')) {
$newContent[] = $specialCode . "\n";
}
}
$content = $newContent;
}
}
return $content;
}
private function readVerificationFile(Player $player, string $file)
{
$parts = explode('/', $file);
$ownerUsername = $parts[3] ?? null;
$ownerPlayer = null;
foreach ($player->getSession()->getPlayers() as $p) {
if ($p->getUser()->getUsername() === $ownerUsername) {
$ownerPlayer = $p;
break;
}
}
if (!$ownerPlayer) {
return 'File does not exist';
}
$screen = $ownerPlayer->getScreen();
$settingName = SessionSettingType::tryFrom('VerifyCodesForPlayer' . $screen);
if (!$settingName) {
return 'Error: Invalid player screen.';
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $ownerPlayer);
if (!$setting) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($ownerPlayer);
$setting->setName($settingName);
}
$codes = json_decode($setting->getValue() ?? '[]', true) ?? [];
$playerNames = ['Luke', 'Charles', 'William', 'Peter'];
foreach ($player->getSession()->getPlayers() as $p) {
$playerNames[] = $p->getUser()->getUsername();
}
$playerNames = array_unique($playerNames);
sort($playerNames);
$content = [];
$content[] = "Verification codes:";
$content[] = "";
foreach ($playerNames as $name) {
$key = null;
foreach ($player->getSession()->getPlayers() as $p) {
if ($p->getUser()->getUsername() === $name) {
$key = (string)$p->getScreen();
break;
}
}
if ($key === null) {
$key = $name;
}
if (!isset($codes[$key])) {
$codes[$key] = bin2hex(random_bytes(3));
}
$content[] = $name . ": " . $codes[$key] . "\n";
}
return $content;
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Game\Service;
use App\Game\Entity\Game; use App\Game\Entity\Game;
use App\Game\Entity\Player; use App\Game\Entity\Player;
use App\Game\Entity\SessionSetting;
use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus; use App\Game\Enum\SessionStatus;
use App\Game\Repository\PlayerRepository; use App\Game\Repository\PlayerRepository;
@@ -39,12 +40,7 @@ class PlayerService
} }
public function getCurrentPwdOfPlayer(Player $player) : ?string { public function getCurrentPwdOfPlayer(Player $player) : ?string {
$settingName = match($player->getScreen()) { $settingName = SessionSettingType::tryFrom('PwdForPlayer' . $player->getScreen());
1 => SessionSettingType::PWD_FOR_PLAYER1,
2 => SessionSettingType::PWD_FOR_PLAYER2,
3 => SessionSettingType::PWD_FOR_PLAYER3,
default => null,
};
if (!$settingName) { if (!$settingName) {
return null; return null;
@@ -56,12 +52,7 @@ class PlayerService
public function saveCurrentPwdOfPlayer(Player $player, string $newLocation) public function saveCurrentPwdOfPlayer(Player $player, string $newLocation)
{ {
$settingName = match($player->getScreen()) { $settingName = SessionSettingType::tryFrom('PwdForPlayer' . $player->getScreen());
1 => SessionSettingType::PWD_FOR_PLAYER1,
2 => SessionSettingType::PWD_FOR_PLAYER2,
3 => SessionSettingType::PWD_FOR_PLAYER3,
default => null,
};
if (!$settingName) { if (!$settingName) {
return; return;
@@ -73,4 +64,33 @@ class PlayerService
$this->entityManager->persist($setting); $this->entityManager->persist($setting);
$this->entityManager->flush(); $this->entityManager->flush();
} }
public function getDeletedFilesOfSession(Player $player): array
{
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), SessionSettingType::SET_OF_DELETED_FILES);
if (!$setting || !$setting->getValue()) {
return [];
}
return json_decode($setting->getValue(), true) ?? [];
}
public function addDeletedFileToSession(Player $player, string $filename): void
{
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), SessionSettingType::SET_OF_DELETED_FILES);
if (!$setting) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setName(SessionSettingType::SET_OF_DELETED_FILES);
}
$deletedFiles = json_decode($setting->getValue() ?? '[]', true) ?? [];
if (!in_array($filename, $deletedFiles)) {
$deletedFiles[] = $filename;
$setting->setValue(json_encode($deletedFiles));
$this->entityManager->persist($setting);
$this->entityManager->flush();
}
}
} }

View File

@@ -31,6 +31,8 @@ class RegistrationController extends AbstractController
) )
); );
$user->setRoles(['ROLE_USER', 'ROLE_PLAYER']);
$entityManager->persist($user); $entityManager->persist($user);
$entityManager->flush(); $entityManager->flush();

View File

@@ -32,4 +32,16 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$this->getEntityManager()->persist($user); $this->getEntityManager()->persist($user);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
/**
* @return User[]
*/
public function findByRole(string $role): array
{
return $this->createQueryBuilder('u')
->andWhere('u.roles LIKE :role')
->setParameter('role', '%"' . $role . '"%')
->getQuery()
->getResult();
}
} }

View File

@@ -14,7 +14,7 @@
<nav> <nav>
{% set pathinfo = app.request.pathinfo %} {% set pathinfo = app.request.pathinfo %}
<a href="/">{{ 'nav.home'|trans }}</a> | <a href="/">{{ 'nav.home'|trans }}</a> |
<a href="/game">{{ 'nav.game'|trans }}</a> | <a href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a> |
{% if app.user %} {% if app.user %}
<a href="{{ path('app_logout') }}">Logout</a> <a href="{{ path('app_logout') }}">Logout</a>
{% else %} {% else %}

View File

@@ -0,0 +1,82 @@
{% extends 'base.html.twig' %}
{% block title %}Game Admin Dashboard{% endblock %}
{% block body %}
<h1>Game Admin Dashboard</h1>
<div style="display: flex; gap: 2rem;">
<div style="flex: 1;">
<h2>All Players ({{ players|length }})</h2>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #f2f2f2;">
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Roles</th>
<th>Verified</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr>
<td>{{ player.id }}</td>
<td>{{ player.username }}</td>
<td>{{ player.email }}</td>
<td>{{ player.roles|join(', ') }}</td>
<td>{{ player.isVerified ? 'Yes' : 'No' }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">No players found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="flex: 2;">
<h2>All Sessions ({{ sessions|length }})</h2>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #f2f2f2;">
<th>ID</th>
<th>Game</th>
<th>Status</th>
<th>Players Joined</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td>{{ session.id }}</td>
<td>{{ session.game.name }}</td>
<td>{{ session.status.value }}</td>
<td>
<ul>
{% for p in session.players %}
<li>{{ p.user.username }} (Screen: {{ p.screen ?? 'N/A' }})</li>
{% else %}
<li>No players</li>
{% endfor %}
</ul>
({{ session.players|length }} / {{ session.game.numberOfPlayers }})
</td>
<td>{{ session.created|date('Y-m-d H:i') }}</td>
<td>
<a href="{{ path('game_admin_view_session', {session: session.id}) }}">View Game Logs</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No sessions found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends 'base.html.twig' %}
{% block title %}View Session Logs - {{ session.id }}{% endblock %}
{% block body %}
<h1>Session: {{ session.game.name }} (#{{ session.id }})</h1>
<p><a href="{{ path('game_admin_dashboard') }}">Back to Dashboard</a></p>
<div class="tabs">
<ul style="display: flex; list-style: none; padding: 0; border-bottom: 1px solid #ccc;">
{% for playerLog in playersLogs %}
<li style="margin-right: 10px;">
<button
onclick="openTab(event, 'player-{{ loop.index }}')"
class="tablinks {{ loop.first ? 'active' : '' }}"
style="padding: 10px; cursor: pointer; border: 1px solid #ccc; border-bottom: none; background: {{ loop.first ? '#eee' : '#fff' }};"
>
{{ playerLog.username }}
</button>
</li>
{% endfor %}
</ul>
</div>
{% for playerLog in playersLogs %}
<div id="player-{{ loop.index }}" class="tabcontent" style="display: {{ loop.first ? 'block' : 'none' }}; border: 1px solid #ccc; border-top: none; padding: 20px;">
<h3>Logs for {{ playerLog.username }}</h3>
<pre style="background: #f4f4f4; padding: 15px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
</div>
{% endfor %}
<script>
function openTab(evt, playerName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
tablinks[i].style.background = "#fff";
}
document.getElementById(playerName).style.display = "block";
evt.currentTarget.className += " active";
evt.currentTarget.style.background = "#eee";
}
</script>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends 'base.html.twig' %}
{% block title %}Game Dashboard{% endblock %}
{% block body %}
<h1>Game Dashboard</h1>
{% if is_granted('ROLE_ADMIN') %}
<p><a href="{{ path('game_admin_dashboard') }}">Go to Game Admin Dashboard</a></p>
{% endif %}
<h2>Create New Session</h2>
{% if availableGames is not empty %}
<form method="post">
<select name="game_id">
{% for game in availableGames %}
<option value="{{ game.id }}">
{{ game.name }} ({{ game.numberOfPlayers }} players)
{% if is_granted('ROLE_ADMIN') %}
[{{ game.status.value }}]
{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" name="create_session">Create Session</button>
</form>
{% else %}
<p>No games available to start.</p>
{% endif %}
<h2>Join Session</h2>
<form method="post">
<input type="text" name="invite_code" placeholder="Enter Invite Code" required>
<button type="submit" name="join_session">Join Session</button>
</form>
<h2>Your Sessions</h2>
{% if sessions is not empty %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Game</th>
<th>Status</th>
<th>Created At</th>
<th>Invite Code</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td>{{ session.id }}</td>
<td>{{ session.game.name }}</td>
<td>{{ session.status.value }}</td>
<td>{{ session.created|date('Y-m-d H:i') }}</td>
<td>
{% set inviteCode = '' %}
{% for setting in session.settings %}
{% if setting.name.value == 'InviteCode' %}
{% set inviteCode = setting.value %}
{% endif %}
{% endfor %}
{% if inviteCode %}
<code>{{ inviteCode }}</code>
{% else %}
<form method="post" style="display:inline;">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="create_invite">Generate Invite</button>
</form>
{% endif %}
</td>
<td>
<a href="{{ path('game', {session: session.id}) }}">Enter Game</a>
{% if session.status.value == 'created' %}
{% if session.players|length >= session.game.numberOfPlayers %}
<form method="post" style="display:inline;">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="start_session">Start Session</button>
</form>
{% endif %}
{% if session.timer == 0 %}
<form method="post" style="display:inline;">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="leave_session" onclick="return confirm('Are you sure you want to leave this session?')">Leave Session</button>
</form>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>You are not part of any sessions.</p>
{% endif %}
{% endblock %}

View File

@@ -15,19 +15,24 @@
<div id="game-container"> <div id="game-container">
<div id="mercure-config" <div id="mercure-config"
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}" data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
data-topic="{{ (mercure_topic_base ~ '/game/hub')|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-user-id="{{ user_id|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') }}"
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">
</div> </div>
<audio id="message-sound" style="display:none">
<source src="{{ asset('audio/message.mp3') }}" type="audio/mpeg">
</audio>
<div id="input"> <div id="input">
<input type="text" disabled id="input-message"> <input type="text" disabled id="input-message">
</div> </div>

View 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 %}

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

@@ -5,5 +5,5 @@
{% block body %} {% block body %}
<h1>{{ 'home.h1'|trans({'%site%': ('site.name'|trans)}) }}</h1> <h1>{{ 'home.h1'|trans({'%site%': ('site.name'|trans)}) }}</h1>
<p>{{ 'home.description'|trans }}</p> <p>{{ 'home.description'|trans }}</p>
<p><a href="{{ path('game') }}">{{ 'link.enter_game'|trans }}</a></p> <p><a href="{{ path('game_dashboard') }}">{{ 'link.enter_game'|trans }}</a></p>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,423 @@
<?php
declare(strict_types=1);
namespace App\Tests\Game;
use App\Game\Entity\Game;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Enum\GameStatus;
use App\Game\Enum\SessionStatus;
use App\Game\Enum\SessionSettingType;
use App\Game\Service\GameDashboardService;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use App\Game\Repository\GameRepository;
use App\Game\Repository\SessionRepository;
use Symfony\Component\Mercure\HubInterface;
use PHPUnit\Framework\TestCase;
class GameDashboardServiceTest extends TestCase
{
private $entityManager;
private $gameRepository;
private $sessionRepository;
private $hub;
private $service;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->gameRepository = $this->createMock(GameRepository::class);
$this->sessionRepository = $this->createMock(SessionRepository::class);
$this->hub = $this->createMock(HubInterface::class);
$this->service = new GameDashboardService(
$this->gameRepository,
$this->sessionRepository,
$this->entityManager,
$this->hub,
'http://localhost/topic'
);
}
public function testCreateSessionDoesNotInitializeSettings(): void
{
$game = new Game();
$game->setStatus(GameStatus::OPEN);
$user = new User();
$user->setUsername('testuser');
$this->entityManager->expects($this->exactly(2))
->method('persist');
// 1. Session, 2. Player
$session = $this->service->createSession($game, $user, false);
$this->assertInstanceOf(Session::class, $session);
}
public function testJoinSessionDoesNotInitializeSettings(): void
{
$user = new User();
$user->setUsername('testuser');
$game = new Game();
$game->setNumberOfPlayers(3);
$session = new Session();
$session->setGame($game);
$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);
$this->entityManager->expects($this->exactly(1))
->method('persist');
// 1. Player
$result = $this->service->joinSession('abc-123', $user);
$this->assertTrue($result);
}
public function testStartSessionAssignsScreensAndInitializesSettings(): void
{
$game = new Game();
$game->setNumberOfPlayers(2);
$session = new Session();
$session->setGame($game);
$session->setStatus(\App\Game\Enum\SessionStatus::CREATED);
$user1 = new User();
$user1->setUsername('user1');
$player1 = new Player();
$player1->setUser($user1);
$session->addPlayer($player1);
$user2 = new User();
$user2->setUsername('user2');
$player2 = new Player();
$player2->setUser($user2);
$session->addPlayer($player2);
// For each player (2):
// Player (persist)
// SessionSetting (rights)
// SessionSetting (pwd)
// SessionSetting (chat tracking)
// SessionSetting (verify codes)
// SessionSetting (verification progress)
// Total = 2 * 6 = 12
// PLUS Session (persist)
// Total = 13
$this->entityManager->expects($this->exactly(13))
->method('persist');
$result = $this->service->startSession($session);
$this->assertTrue($result);
$this->assertEquals(\App\Game\Enum\SessionStatus::READY, $session->getStatus());
$this->assertNotNull($player1->getScreen());
$this->assertNotNull($player2->getScreen());
$this->assertNotEquals($player1->getScreen(), $player2->getScreen());
}
public function testStartSessionWithFourPlayers(): void
{
$game = new Game();
$game->setNumberOfPlayers(4);
$session = new Session();
$session->setGame($game);
$session->setStatus(\App\Game\Enum\SessionStatus::CREATED);
for ($i = 1; $i <= 4; $i++) {
$user = new User();
$user->setUsername('user' . $i);
$player = new Player();
$player->setUser($user);
$session->addPlayer($player);
}
// For each player (4):
// Player (persist) + 5 Settings = 6 persists
// Total = 4 * 6 = 24
// PLUS Session (persist)
// Total = 25
$this->entityManager->expects($this->exactly(25))
->method('persist');
$result = $this->service->startSession($session);
$this->assertTrue($result);
foreach ($session->getPlayers() as $player) {
$this->assertNotNull($player->getScreen());
$this->assertGreaterThanOrEqual(1, $player->getScreen());
$this->assertLessThanOrEqual(4, $player->getScreen());
}
}
public function testLeaveSession(): void
{
$user = new User();
$session = new Session();
$session->setStatus(\App\Game\Enum\SessionStatus::CREATED);
$session->setTimer(0);
$player1 = new Player();
$player1->setUser($user);
$player1->setSession($session);
$session->addPlayer($player1);
$player2 = new Player();
$player2->setUser(new User());
$player2->setSession($session);
$session->addPlayer($player2);
$setting = new SessionSetting();
$setting->setPlayer($player1);
$setting->setSession($session);
$session->addSetting($setting);
$this->entityManager->expects($this->exactly(2))
->method('remove');
// 1. SessionSetting, 2. Player
$result = $this->service->leaveSession($session, $user);
$this->assertTrue($result);
$this->assertCount(1, $session->getPlayers());
}
public function testToggleReady(): void
{
$user = new User();
$game = new Game();
$game->setNumberOfPlayers(1);
$session = new Session();
$session->setGame($game);
$session->setStatus(SessionStatus::READY);
$player = new Player();
$player->setUser($user);
$player->setScreen(1);
$session->addPlayer($player);
$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');
// 1. Player1, any settings (none in this test setup)
$result = $this->service->leaveSession($session, $user);
$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());
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Game;
use App\Game\Entity\Game;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Enum\SessionSettingType;
use App\Game\Repository\SessionSettingRepository;
use App\Game\Service\GameResponseService;
use App\Game\Service\PlayerService;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
class GameResponseServiceChatVerifyCodeTest extends TestCase
{
private $security;
private $playerService;
private $sessionSettingRepository;
private $hub;
private $entityManager;
private $service;
protected function setUp(): void
{
$this->security = $this->createMock(Security::class);
$this->playerService = $this->createMock(PlayerService::class);
$this->sessionSettingRepository = $this->createMock(SessionSettingRepository::class);
$this->hub = $this->createMock(HubInterface::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->service = new GameResponseService(
$this->security,
$this->playerService,
$this->sessionSettingRepository,
$this->hub,
$this->entityManager,
'H:\escapepage'
);
$_ENV['MERCURE_TOPIC_BASE'] = 'http://test';
}
public function testChatRegeneratesVerifyCodesIfShared(): void
{
$user = new User();
$user->setUsername('testuser');
$game = $this->createMock(Game::class);
$game->method('getNumberOfPlayers')->willReturn(4);
$session = $this->createMock(Session::class);
$session->method('getId')->willReturn(123);
$session->method('getGame')->willReturn($game);
$player = $this->createMock(Player::class);
$player->method('getUser')->willReturn($user);
$player->method('getScreen')->willReturn(1);
$player->method('getSession')->willReturn($session);
$this->security->method('getUser')->willReturn($user);
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);
// Mock rights
$rightsSetting = new SessionSetting();
$rightsSetting->setValue(json_encode(['chat']));
$this->sessionSettingRepository->method('getSetting')
->willReturnMap([
[$session, SessionSettingType::RIGHTS_FOR_PLAYER1, $player, $rightsSetting],
]);
// Mock verify codes
$verifyCodesSetting = new SessionSetting();
$initialCodes = ['2' => 'secret123', '3' => 'secret456'];
$verifyCodesSetting->setValue(json_encode($initialCodes));
// Setting repository map for multiple calls
$this->sessionSettingRepository->method('getSetting')
->willReturnCallback(function($s, $t, $p = null) use ($rightsSetting, $verifyCodesSetting) {
if ($t === SessionSettingType::RIGHTS_FOR_PLAYER1) return $rightsSetting;
if ($t === SessionSettingType::VERIFY_CODES_FOR_PLAYER1) return $verifyCodesSetting;
return null;
});
// Expect Mercure updates: 1 for chat, 1 for notification
$this->hub->expects($this->exactly(2))
->method('publish');
$this->entityManager->expects($this->once())
->method('flush');
$raw = json_encode(['message' => '/chat Hello look at my code secret123', 'ts' => '123']);
$result = $this->service->getGameResponse($raw);
$this->assertEquals(['result' => ['succesfully send']], $result);
$newCodes = json_decode($verifyCodesSetting->getValue(), true);
$this->assertNotEquals('secret123', $newCodes['2']);
$this->assertEquals('secret456', $newCodes['3']);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Game;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Enum\SessionSettingType;
use App\Game\Repository\SessionSettingRepository;
use App\Game\Service\GameResponseService;
use App\Game\Service\PlayerService;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Mercure\HubInterface;
class SessionLoggingTest extends TestCase
{
private string $tempDir;
private $security;
private $playerService;
private $sessionSettingRepository;
private $hub;
private $entityManager;
private $service;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/escapepage_test_' . uniqid();
mkdir($this->tempDir, 0777, true);
$this->security = $this->createMock(Security::class);
$this->playerService = $this->createMock(PlayerService::class);
$this->sessionSettingRepository = $this->createMock(SessionSettingRepository::class);
$this->hub = $this->createMock(HubInterface::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->service = new GameResponseService(
$this->security,
$this->playerService,
$this->sessionSettingRepository,
$this->hub,
$this->entityManager,
$this->tempDir
);
}
protected function tearDown(): void
{
$this->removeDir($this->tempDir);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
(is_dir("$dir/$file")) ? $this->removeDir("$dir/$file") : unlink("$dir/$file");
}
rmdir($dir);
}
public function testLogging(): void
{
$user = new User();
$user->setUsername('player1');
$session = $this->createMock(Session::class);
$session->method('getId')->willReturn(456);
$player = $this->createMock(Player::class);
$player->method('getUser')->willReturn($user);
$player->method('getSession')->willReturn($session);
$player->method('getScreen')->willReturn(1);
$this->security->method('getUser')->willReturn($user);
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);
// Mock rights
$rightsSetting = new SessionSetting();
$rightsSetting->setValue(json_encode(['chat']));
$this->sessionSettingRepository->method('getSetting')
->willReturnMap([
[$session, SessionSettingType::RIGHTS_FOR_PLAYER1, $player, $rightsSetting],
]);
// Simulate 'help' command (always returns something)
$raw = json_encode(['message' => 'help', 'ts' => '123']);
$result = $this->service->getGameResponse($raw);
$this->assertNotEmpty($result);
$logFilePath = $this->tempDir . '/var/log/sessions/456/player1.txt';
$this->assertFileExists($logFilePath);
$logContent = file_get_contents($logFilePath);
$this->assertStringContainsString('PLAYER: help', $logContent);
$this->assertStringContainsString('SERVER:', $logContent);
}
}