Wire up decode puzzle chain and lock terminal on file removal

Connects the previously-disconnected /decode command to per-player
messages: player 1 unlocks sudo for everyone, player 2 unlocks a scan
command that reveals which files the AI virus has locked, player 3
learns those files should be removed.

Also adds a retaliation mechanic: successfully removing a file locks
the player's terminal. The AI virus locks it out in red immediately;
the mainframe reveals a 12-character recovery code in green after 30
seconds, which can be submitted via /unlock to restore access early,
otherwise the terminal auto-recovers after 45 seconds.
This commit is contained in:
Frank
2026-07-11 16:48:15 +02:00
parent 1e644eb13b
commit 6db9d42852
7 changed files with 461 additions and 27 deletions
+130 -7
View File
@@ -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);
}
};
+42
View File
@@ -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;
}
+5 -1
View File
@@ -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,
]);
}
+3 -3
View File
@@ -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';
}
+10
View File
@@ -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';
}
+258 -12
View File
@@ -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,13 +71,15 @@ class GameResponseService
$this->logSessionActivity($player, 'PLAYER: ' . $message);
$data = [];
$data = $this->handleLockedPlayer($message, $player);
if(str_starts_with($message, '/')) {
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'])) {
@@ -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;
}
$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');
}
foreach (DecodeMessage::cases() as $decodeMessage) {
if ($decodeMessage->name === $message) {
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;
}
return $randomString;
$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
@@ -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
+9
View File
@@ -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">
</div>
<div id="game-timer" data-end-time="{{ session.timer }}">
--:--:--
</div>
<div id="lock-banner" style="display:none">
<div id="lock-banner-text">AI VIRUS: SYSTEM LOCKED</div>
<div id="lock-countdown">--</div>
</div>
<div id="message-container">
</div>