diff --git a/assets/game1.js b/assets/game1.js index b891b30..f43f6db 100644 --- a/assets/game1.js +++ b/assets/game1.js @@ -77,6 +77,112 @@ function flashRed() { }, 150); } +let lockRevealTimer = null; +let lockExpireTimer = null; +let lockCountdownTimer = null; +let currentLockedAt = null; + +function lockMessageClass(messageType) { + if (messageType === 'virus') return 'message-virus'; + if (messageType === 'mainframe') return 'message-mainframe'; + return ''; +} + +function appendResultMessage(container, text, messageType) { + const msgEl = document.createElement('div'); + msgEl.className = ('message ' + lockMessageClass(messageType)).trim(); + msgEl.textContent = text; + msgEl.style.marginBottom = '10px'; + container.appendChild(msgEl); +} + +function setInputDisabled(disabled) { + const inputField = document.getElementById('input-message'); + if (inputField) { + inputField.disabled = disabled; + } +} + +function clearLockTimers() { + if (lockRevealTimer) { clearTimeout(lockRevealTimer); lockRevealTimer = null; } + if (lockExpireTimer) { clearTimeout(lockExpireTimer); lockExpireTimer = null; } + if (lockCountdownTimer) { clearInterval(lockCountdownTimer); lockCountdownTimer = null; } +} + +function clearLock() { + clearLockTimers(); + currentLockedAt = null; + document.body.classList.remove('locked'); + const banner = document.getElementById('lock-banner'); + if (banner) banner.style.display = 'none'; + setInputDisabled(false); +} + +function updateLockCountdown(unlockAtMs) { + const countdownEl = document.getElementById('lock-countdown'); + if (!countdownEl) return; + const remaining = Math.max(0, Math.ceil((unlockAtMs - Date.now()) / 1000)); + countdownEl.textContent = remaining + 's'; +} + +async function fetchLockReveal(apiEchoUrl, messageContainer) { + if (!apiEchoUrl) return; + try { + const response = await fetchJson(apiEchoUrl, { + method: 'POST', + body: { message: '', ts: new Date().toISOString() }, + }); + const result = response && response.result; + if (result && Array.isArray(result.result)) { + result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType)); + window.scrollTo(0, document.body.scrollHeight); + } + if (result && result.locked === false) { + clearLock(); + return; + } + // Code has been revealed (or already was), let the player try /unlock + setInputDisabled(false); + } catch (e) { + console.error('[Game1] Failed to fetch lock reveal:', e); + } +} + +function applyLock(lockData, apiEchoUrl, messageContainer) { + if (currentLockedAt === lockData.lockedAt) { + return; // already tracking this lock, avoid re-fetching/duplicating messages + } + currentLockedAt = lockData.lockedAt; + + clearLockTimers(); + + const banner = document.getElementById('lock-banner'); + if (banner) banner.style.display = 'flex'; + document.body.classList.add('locked'); + + const revealAtMs = lockData.revealAt * 1000; + const unlockAtMs = lockData.unlockAt * 1000; + const now = Date.now(); + + if (now < revealAtMs) { + setInputDisabled(true); + lockRevealTimer = setTimeout(() => fetchLockReveal(apiEchoUrl, messageContainer), revealAtMs - now); + } else { + fetchLockReveal(apiEchoUrl, messageContainer); + } + + lockExpireTimer = setTimeout(() => clearLock(), Math.max(0, unlockAtMs - now)); + + updateLockCountdown(unlockAtMs); + lockCountdownTimer = setInterval(() => { + updateLockCountdown(unlockAtMs); + if (Date.now() >= unlockAtMs) { + clearInterval(lockCountdownTimer); + lockCountdownTimer = null; + } + }, 1000); +} + async function fetchJson(url, options = {}) { const opts = { ...options }; const headers = new Headers(opts.headers || {}); @@ -133,6 +239,9 @@ document.addEventListener('DOMContentLoaded', async () => { const apiEchoUrl = cfgEl.dataset.apiEchoUrl; const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl; const lostUrl = cfgEl.dataset.lostUrl; + const lockLockedAt = cfgEl.dataset.lockLockedAt; + const lockRevealAt = cfgEl.dataset.lockRevealAt; + const lockUnlockAt = cfgEl.dataset.lockUnlockAt; if (mercurePublicUrl && topic) { subscribeToMercure(mercurePublicUrl, topic, screen); @@ -277,15 +386,20 @@ document.addEventListener('DOMContentLoaded', async () => { }); console.log('[API][game1] message sent →', response); if (response && response.result && Array.isArray(response.result.result)) { - response.result.result.forEach(text => { - const msgEl = document.createElement('div'); - msgEl.className = 'message'; - msgEl.textContent = text; - msgEl.style.marginBottom = '10px'; - messageContainer.appendChild(msgEl); - }); + response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType)); window.scrollTo(0, document.body.scrollHeight); } + if (response && response.result) { + if (response.result.locked === true) { + applyLock({ + lockedAt: response.result.lockedAt, + revealAt: response.result.revealAt, + unlockAt: response.result.unlockAt, + }, apiEchoUrl, messageContainer); + } else if (response.result.locked === false) { + clearLock(); + } + } } catch (err) { console.error('[API][game1] Failed to send message:', err); } @@ -296,6 +410,15 @@ document.addEventListener('DOMContentLoaded', async () => { console.log('[Game1] message-container height changed to 400vh and input enabled'); sequenceFinished = true; console.log('[Game1] sequenceFinished is now TRUE'); + + // Restore an in-progress lock after a page refresh + if (lockUnlockAt && parseInt(lockUnlockAt, 10) * 1000 > Date.now()) { + applyLock({ + lockedAt: parseInt(lockLockedAt, 10), + revealAt: parseInt(lockRevealAt, 10), + unlockAt: parseInt(lockUnlockAt, 10), + }, apiEchoUrl, messageContainer); + } }, 2000); } }; diff --git a/assets/styles/game1.css b/assets/styles/game1.css index cf1a6bf..62fec56 100644 --- a/assets/styles/game1.css +++ b/assets/styles/game1.css @@ -55,6 +55,48 @@ div.message { white-space: pre-wrap; } +div.message-virus { + color: #F00; + font-weight: bold; +} + +div.message-mainframe { + color: #0F0; +} + +div#lock-banner { + position: fixed; + top: 68px; + left: 0; + width: 100%; + padding: 12px 20px; + background-color: #200; + border-top: 1px solid #F00; + border-bottom: 1px solid #F00; + color: #F00; + font-size: 18px; + font-weight: bold; + letter-spacing: 1px; + z-index: 99; + display: flex; + justify-content: space-between; + align-items: center; + animation: lock-banner-pulse 1s ease-in-out infinite; +} + +@keyframes lock-banner-pulse { + 0%, 100% { + background-color: #200; + } + 50% { + background-color: #400; + } +} + +body.locked div#message-container { + padding-top: 130px; +} + div#input { padding: 20px; } diff --git a/src/Game/Controller/GameController.php b/src/Game/Controller/GameController.php index b2edde8..cc07c06 100644 --- a/src/Game/Controller/GameController.php +++ b/src/Game/Controller/GameController.php @@ -12,6 +12,7 @@ use App\Game\Repository\GameRepository; use App\Game\Repository\PlayerRepository; 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; @@ -115,7 +116,8 @@ final class GameController extends AbstractController Request $request, Security $security, PlayerRepository $playerRepository, - GameDashboardService $dashboardService + GameDashboardService $dashboardService, + GameResponseService $gameResponseService ): Response { $user = $security->getUser(); @@ -157,11 +159,13 @@ final class GameController extends AbstractController $screen = $player ? $player->getScreen() : 0; $session_id = $session->getId(); + $lock = $player ? $gameResponseService->getPublicLockState($player) : null; return $this->render('game/index.html.twig', [ 'session' => $session, 'screen' => $screen, 'session_id' => $session_id, + 'lock' => $lock, ]); } diff --git a/src/Game/Enum/DecodeMessage.php b/src/Game/Enum/DecodeMessage.php index 40f8546..3cb64c2 100644 --- a/src/Game/Enum/DecodeMessage.php +++ b/src/Game/Enum/DecodeMessage.php @@ -4,7 +4,7 @@ namespace App\Game\Enum; enum DecodeMessage: string { - case TEST = 'This is a test decoding message.'; - case SECRET = 'The secret code is 42.'; - case WELCOME = 'Welcome to the system, agent.'; + case PLAYER_1 = 'Sudo is now available'; + case PLAYER_2 = 'AI virus protects its own files by replacing them'; + case PLAYER_3 = 'The locked up bash files should be removed to lock it up'; } diff --git a/src/Game/Enum/SessionSettingType.php b/src/Game/Enum/SessionSettingType.php index c700b91..acc3720 100644 --- a/src/Game/Enum/SessionSettingType.php +++ b/src/Game/Enum/SessionSettingType.php @@ -74,4 +74,14 @@ enum SessionSettingType: string case FEEDBACK_ENTERTAINING = 'FeedbackEntertaining'; case FEEDBACK_THEME = 'FeedbackTheme'; case FEEDBACK_TEXT = 'FeedbackText'; + case LOCK_FOR_PLAYER1 = 'LockForPlayer1'; + case LOCK_FOR_PLAYER2 = 'LockForPlayer2'; + case LOCK_FOR_PLAYER3 = 'LockForPlayer3'; + case LOCK_FOR_PLAYER4 = 'LockForPlayer4'; + case LOCK_FOR_PLAYER5 = 'LockForPlayer5'; + case LOCK_FOR_PLAYER6 = 'LockForPlayer6'; + case LOCK_FOR_PLAYER7 = 'LockForPlayer7'; + case LOCK_FOR_PLAYER8 = 'LockForPlayer8'; + case LOCK_FOR_PLAYER9 = 'LockForPlayer9'; + case LOCK_FOR_PLAYER10 = 'LockForPlayer10'; } diff --git a/src/Game/Service/GameResponseService.php b/src/Game/Service/GameResponseService.php index e90194a..c457066 100644 --- a/src/Game/Service/GameResponseService.php +++ b/src/Game/Service/GameResponseService.php @@ -5,6 +5,7 @@ namespace App\Game\Service; use App\Game\Enum\DecodeMessage; use App\Game\Enum\SessionSettingType; use App\Game\Entity\Player; +use App\Game\Entity\Session; use App\Game\Entity\SessionSetting; use App\Game\Repository\SessionSettingRepository; use App\Tech\Entity\User; @@ -15,6 +16,10 @@ use Symfony\Component\Mercure\Update; class GameResponseService { + private const LOCK_REVEAL_AFTER_SECONDS = 30; + private const LOCK_DURATION_SECONDS = 45; + private const LOCK_PASSCODE_LENGTH = 12; + public function __construct( private Security $security, private PlayerService $playerService, @@ -25,6 +30,25 @@ class GameResponseService ) { } + /** + * Public, code-free view of a player's current lock state, safe to expose on page load + * (e.g. so the UI can restore the lock banner/countdown after a refresh). + */ + public function getPublicLockState(Player $player): ?array + { + $lock = $this->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); @@ -47,12 +71,14 @@ class GameResponseService $this->logSessionActivity($player, 'PLAYER: ' . $message); - $data = []; + $data = $this->handleLockedPlayer($message, $player); - if(str_starts_with($message, '/')) { - $data = $this->checkGameCommando($message, $player); - } else { - $data = $this->checkConsoleCommando($message, $player); + if ($data === null) { + if (str_starts_with($message, '/')) { + $data = $this->checkGameCommando($message, $player); + } else { + $data = $this->checkConsoleCommando($message, $player); + } } $responseLog = ''; @@ -202,7 +228,19 @@ class GameResponseService return ['result' => ['You are not allowed to remove this file.']]; $this->playerService->addDeletedFileToSession($player, $fullPath); - return ['result' => ['File removed: ' . $filename]]; + $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'], + ]; case 'sudo': if(!in_array('sudo', $rechten)) return ['result' => ['Unknown command']]; @@ -211,6 +249,17 @@ class GameResponseService $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']]; } @@ -287,6 +336,12 @@ class GameResponseService $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.'; @@ -470,24 +525,212 @@ class GameResponseService } } - private function handleDecodeMessage(string $message, Player $player) + private function handleDecodeMessage(string $message, Player $player): string { $userNumber = $player->getScreen(); - preg_match('/\d+/', $message, $matches); + preg_match('/\d/', $message, $matches); $num = $matches[0] ?? null; $randomString = $this->generateRandomString(250, 500); - if(is_null($num) || $num != $userNumber) + if (is_null($num) || (int)$num !== $userNumber) { return $randomString; + } - foreach (DecodeMessage::cases() as $decodeMessage) { - if ($decodeMessage->name === $message) { - return $decodeMessage->value; + $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'); + } + + 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; } } - return $randomString; + 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 @@ -770,13 +1013,16 @@ class GameResponseService if(in_array('sudo', $rights) || $sudo) return true; - $sudoFiles = [ + return !in_array($file, $this->getLockedFiles()); + } + + private function getLockedFiles() : array + { + return [ '/var/arrest/handle.sh', '/var/arrest/cell.sh', '/var/marriage/divorce.sh', ]; - - return !in_array($file, $sudoFiles); } private function fileExists(string $file, Player $player) : bool diff --git a/templates/game/index.html.twig b/templates/game/index.html.twig index 4b2b427..0f31c04 100644 --- a/templates/game/index.html.twig +++ b/templates/game/index.html.twig @@ -21,12 +21,21 @@ 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-screen="{{ screen|e('html_attr') }}" + {% if lock %} + data-lock-locked-at="{{ lock.lockedAt }}" + data-lock-reveal-at="{{ lock.revealAt }}" + data-lock-unlock-at="{{ lock.unlockAt }}" + {% endif %} style="display:none">
--:--:--
+