Add win/lose game-ending flow
Removing all 3 locked files while the timer is still running now marks
the session WON immediately (checked right after every successful rm)
and broadcasts a "game_finished" signal over Mercure so every
connected player gets redirected together, not just the one who
removed the last file. A new /won/{session} route + won.html.twig
mirrors the existing lost flow (victory narrative + the same feedback
form).
The existing timer-expiry path already set LOST but always redirected
to lostUrl regardless of actual status; it now picks won/lost based on
the status the server reports.
Also fixes a pre-existing bug on the lost page (and would-be bug on
the new won page): PlayerService::GetCurrentlyActiveAsPlayer() only
matches players in READY/PLAYING sessions, so by the time a session
has ended it always returned null there, silently breaking the
feedback form. Both pages now look the player up directly via
PlayerRepository instead.
Added a navigatingAway flag so the page's "confirm before leaving"
prompt doesn't block our own win/lose redirects.
This commit is contained in:
+27
-4
@@ -3,8 +3,14 @@ import './styles/game1.css';
|
|||||||
|
|
||||||
let sequenceFinished = false;
|
let sequenceFinished = false;
|
||||||
let stillPlayingSound = true;
|
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 {
|
try {
|
||||||
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
|
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
@@ -14,6 +20,14 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen) {
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log('[Mercure][game1] Update:', 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)
|
// data is [sendTo, message, messageType?] - messageType defaults to 'mainframe' (green)
|
||||||
if (Array.isArray(data) && data.length >= 2) {
|
if (Array.isArray(data) && data.length >= 2) {
|
||||||
const sendTo = parseInt(data[0]);
|
const sendTo = parseInt(data[0]);
|
||||||
@@ -249,8 +263,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Look for config injected by Twig in the page
|
// Look for config injected by Twig in the page
|
||||||
const cfgEl = document.getElementById('mercure-config');
|
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) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
|
if (navigatingAway) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Standard way to trigger the browser's confirmation dialog
|
// Standard way to trigger the browser's confirmation dialog
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// Included for compatibility with older browsers
|
// Included for compatibility with older browsers
|
||||||
@@ -269,6 +286,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
||||||
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
|
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
|
||||||
const lostUrl = cfgEl.dataset.lostUrl;
|
const lostUrl = cfgEl.dataset.lostUrl;
|
||||||
|
const wonUrl = cfgEl.dataset.wonUrl;
|
||||||
const lockLockedAt = cfgEl.dataset.lockLockedAt;
|
const lockLockedAt = cfgEl.dataset.lockLockedAt;
|
||||||
const lockRevealAt = cfgEl.dataset.lockRevealAt;
|
const lockRevealAt = cfgEl.dataset.lockRevealAt;
|
||||||
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
|
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
|
||||||
@@ -280,7 +298,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mercurePublicUrl && topic) {
|
if (mercurePublicUrl && topic) {
|
||||||
subscribeToMercure(mercurePublicUrl, topic, screen);
|
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl);
|
||||||
} else {
|
} else {
|
||||||
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
||||||
}
|
}
|
||||||
@@ -302,7 +320,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
|
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
|
||||||
if (response && response.finished) {
|
if (response && response.finished) {
|
||||||
window.location.href = lostUrl;
|
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl);
|
||||||
return; // Stop the timer loop
|
return; // Stop the timer loop
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -424,6 +442,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
window.scrollTo(0, document.body.scrollHeight);
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
}
|
}
|
||||||
if (response && response.result) {
|
if (response && response.result) {
|
||||||
|
if (response.result.gameWon === true && wonUrl) {
|
||||||
|
goTo(wonUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.result.locked === true) {
|
if (response.result.locked === true) {
|
||||||
applyLock({
|
applyLock({
|
||||||
lockedAt: response.result.lockedAt,
|
lockedAt: response.result.lockedAt,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use App\Game\Repository\SessionRepository;
|
|||||||
use App\Game\Service\GameDashboardService;
|
use App\Game\Service\GameDashboardService;
|
||||||
use App\Game\Service\GameResponseService;
|
use App\Game\Service\GameResponseService;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use App\Game\Service\PlayerService;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -178,12 +177,11 @@ final class GameController extends AbstractController
|
|||||||
Session $session,
|
Session $session,
|
||||||
Request $request,
|
Request $request,
|
||||||
Security $security,
|
Security $security,
|
||||||
PlayerService $playerService,
|
PlayerRepository $playerRepository
|
||||||
GameDashboardService $dashboardService
|
|
||||||
): Response {
|
): Response {
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $security->getUser();
|
$user = $security->getUser();
|
||||||
$player = $playerService->GetCurrentlyActiveAsPlayer($user);
|
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST')) {
|
||||||
$difficulty = $request->request->get('difficulty');
|
$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
|
private function saveFeedback(Session $session, Player $player, $difficulty, $entertaining, $theme, $feedback): void
|
||||||
{
|
{
|
||||||
$settings = [
|
$settings = [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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\Enum\SessionStatus;
|
||||||
use App\Game\Entity\Player;
|
use App\Game\Entity\Player;
|
||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Entity\SessionSetting;
|
use App\Game\Entity\SessionSetting;
|
||||||
@@ -230,6 +231,18 @@ class GameResponseService
|
|||||||
return ['result' => ['You are not allowed to remove this file.']];
|
return ['result' => ['You are not allowed to remove this file.']];
|
||||||
|
|
||||||
$this->playerService->addDeletedFileToSession($player, $fullPath);
|
$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);
|
$filesRemovalDeadline = $this->startLockedFilesRemovalDeadline($player->getSession(), $fullPath);
|
||||||
$lock = $this->triggerLock($player);
|
$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
|
* 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
|
* 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
|
private function enforceLockedFilesRemovalDeadline(Session $session): void
|
||||||
{
|
{
|
||||||
|
if ($session->getStatus() !== SessionStatus::PLAYING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
|
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE);
|
||||||
if (!$setting || !$setting->getValue()) {
|
if (!$setting || !$setting->getValue()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
data-api-echo-url="{{ path('game_api_message')|e('html_attr') }}"
|
data-api-echo-url="{{ path('game_api_message')|e('html_attr') }}"
|
||||||
data-api-check-finished-url="{{ path('game_api_check_finished', {session: session.id})|e('html_attr') }}"
|
data-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-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') }}"
|
data-screen="{{ screen|e('html_attr') }}"
|
||||||
{% if lock %}
|
{% if lock %}
|
||||||
data-lock-locked-at="{{ lock.lockedAt }}"
|
data-lock-locked-at="{{ lock.lockedAt }}"
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Game Won - {{ session.game.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<style>
|
||||||
|
.site-main {
|
||||||
|
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 #28a745;
|
||||||
|
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: #28a745;
|
||||||
|
}
|
||||||
|
.form-range::-moz-range-thumb {
|
||||||
|
background-color: #28a745;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h3 class="card-title mb-0">Game Won - Virus Contained!</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{{ session.game.name }}</h4>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="story-container">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="donation-section">
|
||||||
|
<h5>Support the Developer</h5>
|
||||||
|
<p>If you enjoyed the experience, 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>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user