getLockState($player); if ($lock === null || time() >= $lock['unlockAt']) { return null; } return [ 'lockedAt' => $lock['lockedAt'], 'revealAt' => $lock['revealAt'], 'unlockAt' => $lock['unlockAt'], ]; } public function getGameResponse(string $raw) : array { $info = json_decode($raw, true); $message = $info['message'] ?? ''; $ts = $info['ts'] ?? ''; if(!is_string($message)) return ['error' => 'Invalid message.']; $user = $this->security->getUser(); if(!$user instanceof User) return ['error' => 'You are not logged in.']; $player = $this->playerService->GetCurrentlyActiveAsPlayer($user); if(!$player) return ['error' => 'You are not in a game.']; $this->enforceLockedFilesRemovalDeadline($player->getSession()); $this->logSessionActivity($player, 'PLAYER: ' . $message); $data = $this->handleLockedPlayer($message, $player); if ($data === null) { if (str_starts_with($message, '/')) { $data = $this->checkGameCommando($message, $player); } else { $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; } 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 { $settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen()); if (!$settingName) { return []; } $setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player); if (!$setting || !$setting->getValue()) { return []; } return json_decode($setting->getValue(), true) ?? []; } private function checkGameCommando(string $message, Player $player) : array { $messagePart = explode(' ', $message); $rechten = $this->getRechten($player); switch($messagePart[0]) { case '/chat': if(!in_array('chat', $rechten)) return ['result' => ['Unknown command']]; if($this->handleChatMessage($message, $player)) return ['result' => ['succesfully send']]; else return ['result' => ['Error sending']]; case '/help': return ['result' => $this->getHelpCommand($rechten)]; case '/decode': if(!in_array('decode', $rechten)) return ['result' => ['Unknown command']]; return ['result' => [$this->handleDecodeMessage($messagePart[1], $player)]]; case '/verify': if(!in_array('verify', $rechten)) return ['result' => ['Unknown command']]; $result = $this->handleVerifyMessage($message, $player); return ['result' => [$result]]; default: return ['result' => ['Unknown command']]; } } private function checkConsoleCommando(string $message, Player $player, bool $sudo = false) : array { $messagePart = explode(' ', $message); $rechten = $this->getRechten($player); switch($messagePart[0]) { case 'help': return ['result' => $this->getHelpCommand($rechten)]; case 'ls': if(!in_array('ls', $rechten)) return ['result' => ['Unknown command']]; $files = $this->getAllCurrentFilesInDirectory($player); return ['result' => $files]; case 'cd': if(!in_array('cd', $rechten)) return ['result' => ['Unknown command']]; $pwd = $this->playerService->getCurrentPwdOfPlayer($player); if(!$pwd) return ['result' => ['Unknown command']]; $newLocation = $this->goToNewDir($pwd, $messagePart[1], $player); if($newLocation === false) return ['result' => ['Unknown path']]; $this->playerService->saveCurrentPwdOfPlayer($player, $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': 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); 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); return [ 'result' => [ 'File removed: ' . $filename, 'AI VIRUS: Intrusion detected. Locking down your terminal...', ], 'messageType' => 'virus', 'locked' => true, 'lockedAt' => $lock['lockedAt'], 'revealAt' => $lock['revealAt'], 'unlockAt' => $lock['unlockAt'], 'filesRemovalDeadline' => $filesRemovalDeadline, ]; case 'sudo': if(!in_array('sudo', $rechten)) return ['result' => ['Unknown command']]; $sudo = array_shift($messagePart); $message = implode(' ', $messagePart); return $this->checkConsoleCommando($message, $player, true); case 'scan': if(!in_array('scan', $rechten)) return ['result' => ['Unknown command']]; $result = ['Running quarantine scan...', 'Locked files detected:']; foreach ($this->getLockedFiles() as $lockedFile) { $result[] = ' ' . $lockedFile; } $result[] = 'These files are protected by the AI virus. Use sudo rm {file} to remove them.'; return ['result' => $result]; default: return ['result' => ['Unknown command']]; } } private function getHelpCommand(mixed $rechten) : array { $messages = []; foreach($rechten as $recht) { switch($recht) { case 'chat': $messages[] = '/chat'; $messages[] = ' Use /chat {message} to send the message to the other agents.'; $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[] = ' USAGE: /chat {message}'; $messages[] = ' USAGE: /chat 6 {message}'; $messages[] = ''; break; case 'help': $messages[] = '/help'; $messages[] = ' This shows this help.'; $messages[] = ' USAGE: /help'; $messages[] = ''; break; case 'decode': $messages[] = '/decode'; $messages[] = ' This message will decode the message followed by it.'; $messages[] = ' Every agent has a different way to decode messages. This is a security measure. The AI Virus has no access to all decoders.'; $messages[] = ' USAGE: /decode {message}'; $messages[] = ''; 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': $messages[] = 'cat'; $messages[] = ' To read a file, use cat {filename}.'; $messages[] = ' This will print the full content of the file on the screen.'; $messages[] = ' USAGE: cat {filename}'; $messages[] = ''; break; case 'ls': $messages[] = 'ls'; $messages[] = ' To show all the files in the current directory, use ls.'; $messages[] = ' This will print the full list of directories and files of the current location on your screen.'; $messages[] = ' USAGE: ls'; $messages[] = ''; break; case 'rm': $messages[] = 'rm'; $messages[] = ' Use rm to delete a file.'; $messages[] = ' Be careful with this command. It can not be undone and we do not want to lose any valuable data.'; $messages[] = ' USAGE: rm {filename}'; $messages[] = ''; break; case 'cd': $messages[] = 'cd'; $messages[] = ' Use cd to move to a different directory.'; $messages[] = ' You can go into a folder by using cd {foldername}, or a folder up by using "cd ..".'; $messages[] = ' Using cd / moves you to the root directory.'; $messages[] = ' USAGE: cd {directory}'; $messages[] = ''; break; case 'sudo': $messages[] = 'sudo'; $messages[] = ' If you do not have enough rights to execute a command, you can use sudo to execute it as root.'; $messages[] = ' This is only possible for verified users. To verify yourself, use the /verify command.'; $messages[] = ' USAGE: sudo {command}'; $messages[] = ''; break; case 'scan': $messages[] = 'scan'; $messages[] = ' Runs a quarantine scan that reveals which files are locked by the AI virus.'; $messages[] = ' USAGE: scan'; $messages[] = ''; break; case 'verify': $messages[] = '/verify'; $messages[] = ' You can verify yourself by using this command.'; $messages[] = ' Use this command and follow instructions to verify yourself.'; $messages[] = ' USAGE: /verify'; $messages[] = ''; break; } } return $messages; } 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 = '/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 = '/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): string { $userNumber = $player->getScreen(); preg_match('/\d/', $message, $matches); $num = $matches[0] ?? null; $randomString = $this->generateRandomString(250, 500); if (is_null($num) || (int)$num !== $userNumber) { return $randomString; } $decodeMessage = match ($userNumber) { 1 => DecodeMessage::PLAYER_1, 2 => DecodeMessage::PLAYER_2, 3 => DecodeMessage::PLAYER_3, default => null, }; if ($decodeMessage === null) { return $randomString; } if ($decodeMessage === DecodeMessage::PLAYER_1) { $this->grantRightToAllPlayers($player->getSession(), 'sudo'); $this->grantRightToAllPlayers($player->getSession(), 'rm'); } if ($decodeMessage === DecodeMessage::PLAYER_2) { $this->grantRightToAllPlayers($player->getSession(), 'scan'); } return $decodeMessage->value; } private function grantRightToAllPlayers(Session $session, string $right): void { $updated = false; foreach ($session->getPlayers() as $sessionPlayer) { $rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $sessionPlayer->getScreen()); if (!$rightsSettingName) { continue; } $setting = $this->sessionSettingRepository->getSetting($session, $rightsSettingName, $sessionPlayer); if (!$setting) { continue; } $rights = json_decode($setting->getValue() ?? '[]', true) ?? []; if (!in_array($right, $rights)) { $rights[] = $right; $setting->setValue(json_encode($rights)); $this->entityManager->persist($setting); $updated = true; } } if ($updated) { $this->entityManager->flush(); } } /** * Returns null if the player isn't locked (or their lock already expired), otherwise * a result array to short-circuit normal command handling. */ private function handleLockedPlayer(string $message, Player $player): ?array { $lock = $this->getLockState($player); if ($lock === null) { return null; } $now = time(); if ($now >= $lock['unlockAt']) { return null; } $trimmed = trim($message); if (stripos($trimmed, '/unlock ') === 0) { $attempt = trim(substr($trimmed, 8)); if ($now >= $lock['revealAt'] && hash_equals($lock['code'], $attempt)) { $this->setLockUnlockAt($player, $now); return [ 'result' => ['ACCESS RESTORED. Welcome back, agent.'], 'messageType' => 'mainframe', 'locked' => false, ]; } return $this->lockStatusResponse('ACCESS DENIED: Incorrect passcode.', 'virus', $lock); } if ($now >= $lock['revealAt']) { return $this->lockStatusResponse( 'MAINFRAME: Recovery code acquired: ' . $lock['code'] . '. Use /unlock ' . $lock['code'] . ' to restore access.', 'mainframe', $lock ); } return $this->lockStatusResponse( 'AI VIRUS: SYSTEM LOCKED. Countermeasures engaged. Stand by for mainframe recovery protocol.', 'virus', $lock ); } private function lockStatusResponse(string $text, string $messageType, array $lock): array { return [ 'result' => [$text], 'messageType' => $messageType, 'locked' => true, 'lockedAt' => $lock['lockedAt'], 'revealAt' => $lock['revealAt'], 'unlockAt' => $lock['unlockAt'], ]; } private function getLockState(Player $player): ?array { $settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen()); if (!$settingName) { return null; } $setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player); if (!$setting || !$setting->getValue()) { return null; } $state = json_decode($setting->getValue(), true); if (!is_array($state) || !isset($state['lockedAt'], $state['revealAt'], $state['unlockAt'], $state['code'])) { return null; } return $state; } private function triggerLock(Player $player): array { $now = time(); $state = [ 'lockedAt' => $now, 'revealAt' => $now + self::LOCK_REVEAL_AFTER_SECONDS, 'unlockAt' => $now + self::LOCK_DURATION_SECONDS, 'code' => $this->generatePasscode(), ]; $settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen()); if ($settingName) { $setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player); if (!$setting) { $setting = new SessionSetting(); $setting->setSession($player->getSession()); $setting->setPlayer($player); $setting->setName($settingName); } $setting->setValue(json_encode($state)); $this->entityManager->persist($setting); $this->entityManager->flush(); } return $state; } private function setLockUnlockAt(Player $player, int $unlockAt): void { $settingName = SessionSettingType::tryFrom('LockForPlayer' . $player->getScreen()); if (!$settingName) { return; } $setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player); if (!$setting || !$setting->getValue()) { return; } $state = json_decode($setting->getValue(), true); if (!is_array($state)) { return; } $state['unlockAt'] = $unlockAt; $setting->setValue(json_encode($state)); $this->entityManager->persist($setting); $this->entityManager->flush(); } private function generatePasscode(): string { $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $charactersLength = strlen($characters); $code = ''; for ($i = 0; $i < self::LOCK_PASSCODE_LENGTH; $i++) { $code .= $characters[random_int(0, $charactersLength - 1)]; } return $code; } private function generateRandomString(int $min, int $max): string { $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ '; $charactersLength = strlen($characters); $randomString = ''; $length = random_int($min, $max); for ($i = 0; $i < $length; $i++) { $randomString .= $characters[random_int(0, $charactersLength - 1)]; } return $randomString; } private function generateSpecialCode(int $firstDigit, int $min, int $max): string { $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); 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->setName(SessionSettingType::EVERYONE_VERIFIED); } $everyoneVerifiedSetting->setValue('true'); $this->entityManager->persist($everyoneVerifiedSetting); $this->entityManager->flush(); $topic = '/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 { $allPossiblePaths = $this->getAllPossiblePaths($player); $dirParts = explode('/', $newPwd); $int = count($dirParts); if($dirParts[0] == '') { $newDir = ''; $startPart = 1; } else { $newDir = $pwd; $startPart = 0; } for($i = $startPart; $i < $int; $i++) { if($dirParts[$i] == '..') $newDir = $this->getPrevPath($newDir); else $newDir .= '/' . $dirParts[$i]; if(!in_array($newDir, $allPossiblePaths)) return false; } return $newDir; } private function getPrevPath(string $pwd) : string { $pwdParts = explode('/', $pwd); array_pop($pwdParts); $pwd = implode('/', $pwdParts); return $pwd; } private function getAllPossiblePaths(Player $player) : array { $paths = []; $paths[] = '/'; $paths[] = '/var'; $paths[] = '/var/arrest'; $paths[] = '/var/www'; $paths[] = '/var/marriage'; $paths[] = '/var/rapports'; $paths[] = '/var/linking'; $paths[] = '/etc'; $paths[] = '/etc/short'; $paths[] = '/etc/long'; $paths[] = '/etc/arrest'; $paths[] = '/etc/power'; $paths[] = '/etc/break'; $paths[] = '/etc/handle'; $paths[] = '/etc/freak'; $paths[] = '/etc/host'; $paths[] = '/etc/ssh'; $paths[] = '/etc/nginx'; $paths[] = '/etc/apache2'; $paths[] = '/etc/systemd'; $paths[] = '/etc/cron.d'; $paths[] = '/etc/network'; $paths[] = '/etc/apt'; $paths[] = '/etc/default'; $paths[] = '/etc/init.d'; $paths[] = '/etc/security'; $paths[] = '/etc/skel'; $paths[] = '/etc/logrotate.d'; $paths[] = '/bin'; $paths[] = '/boot'; $paths[] = '/dev'; $paths[] = '/home'; $paths[] = '/home/admin'; $paths[] = '/home/guest'; $paths[] = '/home/backup'; $paths[] = '/lib'; $paths[] = '/lib64'; $paths[] = '/media'; $paths[] = '/mnt'; $paths[] = '/opt'; $paths[] = '/opt/app'; $paths[] = '/proc'; $paths[] = '/root'; $paths[] = '/root/.ssh'; $paths[] = '/run'; $paths[] = '/sbin'; $paths[] = '/srv'; $paths[] = '/sys'; $paths[] = '/tmp'; $paths[] = '/usr'; $paths[] = '/usr/bin'; $paths[] = '/usr/sbin'; $paths[] = '/usr/lib'; $paths[] = '/usr/local'; $paths[] = '/usr/local/bin'; $paths[] = '/usr/local/sbin'; $paths[] = '/usr/share'; $paths[] = '/usr/share/doc'; $paths[] = '/usr/share/man'; $paths[] = '/usr/include'; $paths[] = '/usr/src'; $paths[] = '/var/log'; $paths[] = '/var/lib'; $paths[] = '/var/cache'; $paths[] = '/var/spool'; $paths[] = '/var/backups'; $paths[] = '/var/tmp'; $paths[] = '/var/mail'; $paths[] = '/var/run'; $paths[] = '/var/home'; $playerNames = ['root', 'Luke', 'Charles', 'William', 'Peter']; $players = $player->getSession()->getPlayers(); foreach($players as $p) { $playerNames[] = $p->getUser()->getUsername(); } $playerNames = array_unique($playerNames); foreach($playerNames as $name) { $paths[] = '/var/home/' . $name; } 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; return !in_array($file, $this->getLockedFiles()); } private function getLockedFiles() : array { return [ '/var/arrest/handle.sh', '/var/arrest/cell.sh', '/var/marriage/divorce.sh', ]; } /** * 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 * (new or already-running) deadline as a unix timestamp, or null if the removed file * wasn't a locked one. */ private function startLockedFilesRemovalDeadline(Session $session, string $removedFile): ?int { if (!in_array($removedFile, $this->getLockedFiles())) { return null; } $setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE); if ($setting && $setting->getValue()) { return (int)$setting->getValue(); // Window already running } if (!$setting) { $setting = new SessionSetting(); $setting->setSession($session); $setting->setName(SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE); } $deadline = time() + 60; $setting->setValue((string)$deadline); $this->entityManager->persist($setting); $this->entityManager->flush(); return $deadline; } /** * Public, session-wide view of the active locked-files removal deadline (if any), safe * to expose on page load so the UI can schedule its auto-check timer after a refresh. */ public function getPublicLockedFilesDeadline(Session $session): ?int { $setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::LOCKED_FILES_REMOVAL_DEADLINE); if (!$setting || !$setting->getValue()) { return null; } $deadline = (int)$setting->getValue(); return $deadline > time() ? $deadline : null; } /** * Lazily checked on every player interaction: if the 60-second window to remove all * locked files has expired without all of them being removed, restore whichever ones * were removed and clear the window. */ 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; } if (time() < (int)$setting->getValue()) { return; } $player = $session->getPlayers()->first() ?: null; $lockedFiles = $this->getLockedFiles(); if ($player) { $deletedFiles = $this->playerService->getDeletedFilesOfSession($player); $stillLocked = array_intersect($lockedFiles, $deletedFiles); if (count($stillLocked) < count($lockedFiles)) { foreach ($stillLocked as $file) { $this->playerService->removeDeletedFileFromSession($player, $file); } if (!empty($stillLocked)) { $topic = '/game/hub/' . $session->getId(); $message = 'AI VIRUS: Integrity check complete. Restored files that were not fully purged in time.'; try { $this->hub->publish(new Update($topic, json_encode([0, $message, 'virus']))); } catch (\Exception $e) { // Mercure might be down } } } } $setting->setValue(null); $this->entityManager->persist($setting); $this->entityManager->flush(); } 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'; $files[] = '/etc/passwd'; $files[] = '/etc/shadow'; $files[] = '/etc/hostname'; $files[] = '/etc/hosts'; $files[] = '/etc/os-release'; $files[] = '/etc/motd'; $files[] = '/etc/issue'; $files[] = '/etc/timezone'; $files[] = '/etc/resolv.conf'; $files[] = '/etc/crontab'; $files[] = '/etc/nsswitch.conf'; $files[] = '/etc/environment'; $files[] = '/etc/fstab'; $files[] = '/etc/ssh/sshd_config'; $files[] = '/etc/ssh/ssh_config'; $files[] = '/etc/nginx/nginx.conf'; $files[] = '/etc/apache2/apache2.conf'; $files[] = '/etc/network/interfaces'; $files[] = '/etc/apt/sources.list'; $files[] = '/etc/cron.d/backup-cron.sh'; $files[] = '/etc/init.d/nginx.sh'; $files[] = '/etc/init.d/ssh.sh'; $files[] = '/etc/init.d/cron.sh'; $files[] = '/etc/logrotate.d/rsyslog.sh'; $files[] = '/etc/logrotate.d/apt.sh'; $files[] = '/etc/skel/bashrc.sh'; $files[] = '/etc/skel/profile.sh'; $files[] = '/var/log/syslog'; $files[] = '/var/log/auth.log'; $files[] = '/var/log/kern.log'; $files[] = '/var/log/boot.log'; $files[] = '/var/log/dmesg'; $files[] = '/var/log/dpkg.log'; $files[] = '/var/log/cron.log'; $files[] = '/var/log/mail.log'; $files[] = '/var/log/daemon.log'; $files[] = '/var/log/alternatives.log'; $files[] = '/var/lib/dpkg-status.sh'; $files[] = '/var/lib/apt-extended-states.sh'; $files[] = '/var/cache/apt-archives.sh'; $files[] = '/var/spool/cron-crontabs.sh'; $files[] = '/var/spool/mail-root.sh'; $files[] = '/var/backups/passwd.bak.sh'; $files[] = '/var/backups/group.bak.sh'; $files[] = '/var/mail/root'; $files[] = '/root/.bashrc'; $files[] = '/root/.bash_history'; $files[] = '/root/.ssh/authorized_keys'; $files[] = '/home/admin/notes.sh'; $files[] = '/home/admin/todo.sh'; $files[] = '/home/guest/readme.sh'; $files[] = '/home/backup/backup.sh'; $files[] = '/opt/app/config.yml'; $files[] = '/opt/app/app.sh'; $files[] = '/opt/app/start.sh'; $files[] = '/usr/bin/ls.sh'; $files[] = '/usr/bin/cat.sh'; $files[] = '/usr/bin/grep.sh'; $files[] = '/usr/bin/awk.sh'; $files[] = '/usr/bin/sed.sh'; $files[] = '/usr/bin/bash.sh'; $files[] = '/usr/bin/python3.sh'; $files[] = '/usr/bin/perl.sh'; $files[] = '/usr/bin/curl.sh'; $files[] = '/usr/bin/wget.sh'; $files[] = '/usr/bin/ssh.sh'; $files[] = '/usr/bin/scp.sh'; $files[] = '/usr/bin/rsync.sh'; $files[] = '/usr/bin/tar.sh'; $files[] = '/usr/bin/gzip.sh'; $files[] = '/usr/bin/vim.sh'; $files[] = '/usr/bin/nano.sh'; $files[] = '/usr/bin/top.sh'; $files[] = '/usr/bin/ps.sh'; $files[] = '/usr/bin/kill.sh'; $files[] = '/usr/bin/chmod.sh'; $files[] = '/usr/bin/chown.sh'; $files[] = '/usr/bin/systemctl.sh'; $files[] = '/usr/bin/docker.sh'; $files[] = '/usr/bin/git.sh'; $files[] = '/usr/bin/find.sh'; $files[] = '/usr/bin/sort.sh'; $files[] = '/usr/bin/uniq.sh'; $files[] = '/usr/bin/head.sh'; $files[] = '/usr/bin/tail.sh'; $files[] = '/bin/sh.sh'; $files[] = '/bin/mount.sh'; $files[] = '/bin/umount.sh'; $files[] = '/bin/ping.sh'; $files[] = '/bin/netstat.sh'; $files[] = '/bin/ifconfig.sh'; $files[] = '/bin/hostname.sh'; $files[] = '/bin/date.sh'; $files[] = '/bin/ln.sh'; $files[] = '/bin/cp.sh'; $files[] = '/bin/mv.sh'; $files[] = '/bin/rm.sh'; $files[] = '/bin/mkdir.sh'; $files[] = '/bin/rmdir.sh'; $files[] = '/bin/touch.sh'; $files[] = '/bin/echo.sh'; $files[] = '/sbin/init.sh'; $files[] = '/sbin/reboot.sh'; $files[] = '/sbin/shutdown.sh'; $files[] = '/sbin/fsck.sh'; $files[] = '/sbin/ifup.sh'; $files[] = '/sbin/ifdown.sh'; $files[] = '/sbin/iptables.sh'; $files[] = '/sbin/sysctl.sh'; $files[] = '/usr/local/bin/composer.sh'; $files[] = '/usr/local/bin/node.sh'; $files[] = '/usr/local/bin/npm.sh'; 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 = $this->projectDir . '/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; } }