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:
Frank
2026-07-11 21:41:16 +02:00
parent eee6c3a369
commit 3984a33282
5 changed files with 237 additions and 8 deletions
+34 -4
View File
@@ -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 = [
+50
View File
@@ -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;