cover-identity agent name, mirrors assets/game1/filesystem/var/rapports/index.txt private const RAPPORT_AGENTS = [ '/var/rapports/095_07-14.txt' => 'Mason', '/var/rapports/007_19-52.txt' => 'Harper', '/var/rapports/083_25-39.txt' => 'Doyle', '/var/rapports/019_31-11.txt' => 'Vega', '/var/rapports/075_46-77.txt' => 'Ellis', '/var/rapports/031_53-28.txt' => 'Navarro', '/var/rapports/072_61-05.txt' => 'Carter', '/var/rapports/064_72-90.txt' => 'Quinn', '/var/rapports/091_81-33.txt' => 'Raines', '/var/rapports/079_89-47.txt' => 'Dalton', '/var/rapports/098_92-14.txt' => 'Langston', '/var/rapports/012_94-31.txt' => 'Donovan', '/var/rapports/016_98-07.txt' => 'Fletcher', '/var/rapports/087_102-45.txt' => 'Sanders', '/var/rapports/094_110-19.txt' => 'Mitchell', '/var/rapports/063_117-56.txt' => 'O’Connor', '/var/rapports/017_123-88.txt' => 'Holloway', '/var/rapports/011_130-62.txt' => 'Lennox', '/var/rapports/093_138-24.txt' => 'Bennett', '/var/rapports/001_145-93.txt' => 'Carver', ]; private const SPECIAL_REPORT_COUNT = 3; public function __construct( private readonly GameRepository $gameRepository, private readonly SessionRepository $sessionRepository, private readonly LobbyMessageRepository $lobbyMessageRepository, private readonly EntityManagerInterface $entityManager, private readonly EventDispatcherInterface $eventDispatcher, ) { } /** * @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(); $this->publishLobbyEvent($session, 'player_joined'); 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); } } // Picks decoy usernames for /var/home and verifyCodes.txt, excluding any name // already taken by a real player in this session so they can never collide. private function initializeDecoyUsernames(Session $session): void { $realUsernames = array_map( static fn (Player $p) => $p->getUser()->getUsername(), $session->getPlayers()->toArray() ); $availableDecoys = array_values(array_diff(self::DECOY_USERNAME_POOL, $realUsernames)); shuffle($availableDecoys); $selected = array_slice($availableDecoys, 0, self::DECOY_USERNAME_COUNT); $setting = new SessionSetting(); $setting->setSession($session); $setting->setName(SessionSettingType::DECOY_USERNAMES); $setting->setValue(json_encode($selected)); $this->entityManager->persist($setting); } // Picks which 3 rapports carry the coded messages this session, so it isn't // always the same Doyle/Vega/Lennox trio. private function initializeSpecialReportAssignments(Session $session): void { $files = array_keys(self::RAPPORT_AGENTS); shuffle($files); $chosen = array_slice($files, 0, self::SPECIAL_REPORT_COUNT); $assignments = []; foreach ($chosen as $index => $file) { $assignments[] = [ 'file' => $file, 'agent' => self::RAPPORT_AGENTS[$file], 'digit' => $index + 1, ]; } $setting = new SessionSetting(); $setting->setSession($session); $setting->setName(SessionSettingType::SPECIAL_REPORT_ASSIGNMENTS); $setting->setValue(json_encode($assignments)); $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); } $this->initializeDecoyUsernames($session); $this->initializeSpecialReportAssignments($session); $session->setStatus(SessionStatus::READY); $this->entityManager->persist($session); $this->entityManager->flush(); $this->publishLobbyEvent($session, 'session_started'); return true; } /** * @return LobbyMessage[] */ public function getLobbyMessages(Session $session): array { return $this->lobbyMessageRepository->findForSession($session); } /** * The lobby chat is open while a session is still gathering players, and stays * open for a grace period after the game ends so players can wrap up the * conversation before it disappears. */ public function isLobbyChatOpen(Session $session): bool { if ($session->getStatus() === SessionStatus::CREATED) { return true; } if (!in_array($session->getStatus(), [SessionStatus::WON, SessionStatus::LOST], true)) { return false; } $finishedAt = $session->getFinishedAt(); if ($finishedAt === null) { return false; } return (new \DateTime())->getTimestamp() - $finishedAt->getTimestamp() < self::LOBBY_CHAT_GRACE_PERIOD_SECONDS; } public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage { if (!$this->isLobbyChatOpen($session)) { return null; } $player = null; foreach ($session->getPlayers() as $sessionPlayer) { if ($sessionPlayer->getUser() === $user) { $player = $sessionPlayer; break; } } if (!$player) { return null; } $content = trim($content); if ($content === '') { return null; } $content = mb_substr($content, 0, self::LOBBY_MESSAGE_MAX_LENGTH); $message = new LobbyMessage(); $message->setSession($session); $message->setPlayer($player); $message->setContent($content); $this->entityManager->persist($message); $this->entityManager->flush(); $this->publishLobbyEvent($session, 'lobby_message', [ 'username' => $user->getUserIdentifier(), 'content' => $content, 'createdAt' => $message->getCreatedAt()->format(DATE_ATOM), ]); return $message; } public function toggleReady(Session $session, User $user): bool { if ($session->getStatus() !== SessionStatus::READY) { return false; } if (!$user->isVerified()) { 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); $existingSetting = $settingRepo->getSetting($session, $settingName, $player); $nowReady = $existingSetting === null; if ($existingSetting) { $session->removeSetting($existingSetting); $this->entityManager->remove($existingSetting); } 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(); // If this toggle just made everyone ready, checkAllPlayersReady() already // transitioned the session out of READY and published 'all_ready' — // don't also publish a redundant 'player_ready'. if ($session->getStatus() === SessionStatus::READY) { $this->publishPlayerReady($session, $player->getScreen(), $nowReady); } return true; } /** * Explicitly (and idempotently) expires the current user's own ready status once * their 60-second window is genuinely up. Called by the ready player's own browser * on a timer, so the "not ready" broadcast goes out as close to the deadline as * possible instead of waiting for someone else's request to lazily discover it. */ public function expireOwnReadyIfDue(Session $session, User $user): void { if ($session->getStatus() !== SessionStatus::READY) { return; } $player = null; foreach ($session->getPlayers() as $p) { if ($p->getUser() === $user) { $player = $p; break; } } if (!$player) { return; } $settingName = SessionSettingType::tryFrom('ReadyAtForPlayer' . $player->getScreen()); if (!$settingName) { return; } /** @var \App\Game\Repository\SessionSettingRepository $settingRepo */ $settingRepo = $this->entityManager->getRepository(SessionSetting::class); $setting = $settingRepo->getSetting($session, $settingName, $player); if (!$setting) { return; // Already not ready } $readyAtTimestamp = (int)$setting->getValue(); if ((new \DateTime())->getTimestamp() - $readyAtTimestamp < self::READY_TIMEOUT_SECONDS) { return; // Not actually due yet } $session->removeSetting($setting); $this->entityManager->remove($setting); $this->entityManager->flush(); $this->publishPlayerReady($session, $player->getScreen(), false); } private function publishPlayerReady(Session $session, int $screen, bool $ready): void { $this->publishLobbyEvent($session, 'player_ready', ['player' => $screen, 'ready' => $ready]); } private function publishLobbyEvent(Session $session, string $type, array $extra = []): void { $this->eventDispatcher->dispatch(new PushMercureMessageEvent( $session, array_merge(['type' => $type], $extra), $type, )); } 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(); /** @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) { continue; } $readyAtTimestamp = (int)$setting->getValue(); if (($now->getTimestamp() - $readyAtTimestamp) >= self::READY_TIMEOUT_SECONDS) { $session->removeSetting($setting); $this->entityManager->remove($setting); $this->entityManager->flush(); $this->publishPlayerReady($session, $player->getScreen(), false); continue; } $readyPlayersCount++; } 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); } } } $this->entityManager->flush(); $this->eventDispatcher->dispatch(new PushMercureMessageEvent($session, ['type' => 'all_ready'], 'all_ready')); } } 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; } }