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
+262 -16
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,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