diff --git a/assets/game1.js b/assets/game1.js index b4c7952..7e383cc 100644 --- a/assets/game1.js +++ b/assets/game1.js @@ -3,8 +3,14 @@ import './styles/game1.css'; let sequenceFinished = false; let stillPlayingSound = true; +let navigatingAway = false; -function subscribeToMercure(mercurePublicUrl, topic, myScreen) { +function goTo(url) { + navigatingAway = true; + window.location.href = url; +} + +function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl) { try { const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic); const es = new EventSource(url); @@ -14,6 +20,14 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) { const data = JSON.parse(event.data); console.log('[Mercure][game1] Update:', data); + if (data && !Array.isArray(data) && data.type === 'game_finished') { + const destination = data.status === 'won' ? wonUrl : lostUrl; + if (destination) { + goTo(destination); + } + return; + } + // data is [sendTo, message, messageType?] - messageType defaults to 'mainframe' (green) if (Array.isArray(data) && data.length >= 2) { const sendTo = parseInt(data[0]); @@ -249,8 +263,11 @@ document.addEventListener('DOMContentLoaded', async () => { // Look for config injected by Twig in the page const cfgEl = document.getElementById('mercure-config'); - // Prevent/warn on page reload + // Prevent/warn on page reload, except for our own win/lose redirects window.addEventListener('beforeunload', (event) => { + if (navigatingAway) { + return; + } // Standard way to trigger the browser's confirmation dialog event.preventDefault(); // Included for compatibility with older browsers @@ -269,6 +286,7 @@ document.addEventListener('DOMContentLoaded', async () => { const apiEchoUrl = cfgEl.dataset.apiEchoUrl; const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl; const lostUrl = cfgEl.dataset.lostUrl; + const wonUrl = cfgEl.dataset.wonUrl; const lockLockedAt = cfgEl.dataset.lockLockedAt; const lockRevealAt = cfgEl.dataset.lockRevealAt; const lockUnlockAt = cfgEl.dataset.lockUnlockAt; @@ -280,7 +298,7 @@ document.addEventListener('DOMContentLoaded', async () => { } if (mercurePublicUrl && topic) { - subscribeToMercure(mercurePublicUrl, topic, screen); + subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl); } else { console.warn('[Mercure][game1] Missing data attributes on #mercure-config'); } @@ -302,7 +320,7 @@ document.addEventListener('DOMContentLoaded', async () => { try { const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' }); if (response && response.finished) { - window.location.href = lostUrl; + goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl); return; // Stop the timer loop } } catch (e) { @@ -424,6 +442,11 @@ document.addEventListener('DOMContentLoaded', async () => { window.scrollTo(0, document.body.scrollHeight); } if (response && response.result) { + if (response.result.gameWon === true && wonUrl) { + goTo(wonUrl); + return; + } + if (response.result.locked === true) { applyLock({ lockedAt: response.result.lockedAt, diff --git a/src/Game/Controller/GameController.php b/src/Game/Controller/GameController.php index c104677..5c845d6 100644 --- a/src/Game/Controller/GameController.php +++ b/src/Game/Controller/GameController.php @@ -14,7 +14,6 @@ use App\Game\Repository\SessionRepository; use App\Game\Service\GameDashboardService; use App\Game\Service\GameResponseService; use App\Tech\Entity\User; -use App\Game\Service\PlayerService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; @@ -178,12 +177,11 @@ final class GameController extends AbstractController Session $session, Request $request, Security $security, - PlayerService $playerService, - GameDashboardService $dashboardService + PlayerRepository $playerRepository ): Response { /** @var User $user */ $user = $security->getUser(); - $player = $playerService->GetCurrentlyActiveAsPlayer($user); + $player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]); if ($request->isMethod('POST')) { $difficulty = $request->request->get('difficulty'); @@ -204,6 +202,38 @@ final class GameController extends AbstractController ]); } + #[Route(path: '/won/{session}', name: 'game_won', methods: ['GET', 'POST'])] + #[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))] + #[IsGranted('SESSION_VIEW', subject: 'session')] + public function won( + Session $session, + Request $request, + Security $security, + PlayerRepository $playerRepository + ): Response { + /** @var User $user */ + $user = $security->getUser(); + $player = $playerRepository->findOneBy(['session' => $session, 'user' => $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/won.html.twig', [ + 'session' => $session, + ]); + } + private function saveFeedback(Session $session, Player $player, $difficulty, $entertaining, $theme, $feedback): void { $settings = [ diff --git a/src/Game/Service/GameResponseService.php b/src/Game/Service/GameResponseService.php index 2899782..dfee129 100644 --- a/src/Game/Service/GameResponseService.php +++ b/src/Game/Service/GameResponseService.php @@ -4,6 +4,7 @@ namespace App\Game\Service; use App\Game\Enum\DecodeMessage; use App\Game\Enum\SessionSettingType; +use App\Game\Enum\SessionStatus; use App\Game\Entity\Player; use App\Game\Entity\Session; use App\Game\Entity\SessionSetting; @@ -230,6 +231,18 @@ class GameResponseService return ['result' => ['You are not allowed to remove this file.']]; $this->playerService->addDeletedFileToSession($player, $fullPath); + + if ($this->checkFilesRemovalWin($player)) { + return [ + 'result' => [ + 'File removed: ' . $filename, + 'MAINFRAME: All protected files purged. The AI virus has been contained. Well done, agents.', + ], + 'messageType' => 'mainframe', + 'gameWon' => true, + ]; + } + $filesRemovalDeadline = $this->startLockedFilesRemovalDeadline($player->getSession(), $fullPath); $lock = $this->triggerLock($player); @@ -1030,6 +1043,39 @@ class GameResponseService ]; } + /** + * Checks whether all locked files are currently removed and, if so, marks the session + * as won (once) and broadcasts a "game finished" signal to every connected player. + */ + private function checkFilesRemovalWin(Player $player): bool + { + $session = $player->getSession(); + + if ($session->getStatus() !== SessionStatus::PLAYING) { + return $session->getStatus() === SessionStatus::WON; + } + + $deletedFiles = $this->playerService->getDeletedFilesOfSession($player); + $lockedFiles = $this->getLockedFiles(); + + if (count(array_intersect($lockedFiles, $deletedFiles)) < count($lockedFiles)) { + return false; + } + + $session->setStatus(SessionStatus::WON); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + $topic = '/game/hub/' . $session->getId(); + try { + $this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'won']))); + } catch (\Exception $e) { + // Mercure might be down + } + + return true; + } + /** * Starts (if not already running) the 60-second window within which all locked files * must be removed, or the ones already removed get restored by the virus. Returns the @@ -1084,6 +1130,10 @@ class GameResponseService */ private function enforceLockedFilesRemovalDeadline(Session $session): void { + if ($session->getStatus() !== SessionStatus::PLAYING) { + return; + } + $setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE); if (!$setting || !$setting->getValue()) { return; diff --git a/templates/game/index.html.twig b/templates/game/index.html.twig index b37268a..cc87b62 100644 --- a/templates/game/index.html.twig +++ b/templates/game/index.html.twig @@ -20,6 +20,7 @@ data-api-echo-url="{{ path('game_api_message')|e('html_attr') }}" data-api-check-finished-url="{{ path('game_api_check_finished', {session: session.id})|e('html_attr') }}" data-lost-url="{{ path('game_lost', {session: session.id})|e('html_attr') }}" + data-won-url="{{ path('game_won', {session: session.id})|e('html_attr') }}" data-screen="{{ screen|e('html_attr') }}" {% if lock %} data-lock-locked-at="{{ lock.lockedAt }}" diff --git a/templates/game/won.html.twig b/templates/game/won.html.twig new file mode 100644 index 0000000..d844969 --- /dev/null +++ b/templates/game/won.html.twig @@ -0,0 +1,125 @@ +{% extends 'layout/site.html.twig' %} + +{% block title %}Game Won - {{ session.game.name }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + +{% block body %} +
+
+
+
+

Game Won - Virus Contained!

+
+
+

{{ session.game.name }}

+
+ +
+

+ The last protected file dissolves from the directory listing, and for a moment the mainframe goes quiet. + Then the screens flood green: containment confirmed. The AI virus's grip on the server unravels file by file. +

+

+ Agents Doyle, Vega and Lennox are safe. Their identities were never decoded. In the days that follow, + the agency quietly credits an anonymous team of specialists for the save - you. +

+
+ +
+
Support the Developer
+

If you enjoyed the experience, please consider a small donation to help me create more games.

+ + Donate via PayPal + +
+ + +
+
+
+
+{% endblock %}