From 2951a392041b537e32cd4daced585f43d64ac392 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 16 Aug 2026 17:56:16 +0200 Subject: [PATCH] Add mainframe hint system and cron-driven timeout handling The cron (app:hints:check, every minute) now walks each playing player through a strict dependency chain - help, communication, verification, navigation, rapports/decode, endgame urgency - and sends at most one hint per player per run: the first step that's both unresolved and old enough to nudge about. Later steps genuinely depend on earlier ones (e.g. /verify isn't usable until a player has finished contacting everyone), so a player never gets a hint for something they can't act on yet. Two bits of new tracking were needed: - Help command usage wasn't recorded anywhere, so it's now saved to a new HelpUsedForPlayer{N} session setting. - "All messages decoded" needed a reliable session-wide signal, so the rm right (previously granted alongside sudo when player 1 decodes) now comes from player 3's decode instead, making sudo+scan+rm together mean "the whole team has decoded". The cron also now finishes sessions whose countdown has run out: sets the session to LOST and broadcasts the same game_finished Mercure event the win path already uses, so every connected player gets pushed to the lost page within a minute of the game actually ending - not just whichever player's own client-side timer happens to notice first. Co-Authored-By: Claude Sonnet 5 --- src/Command/SendMainframeHintsCommand.php | 263 ++++++++++++++++++---- src/Game/Enum/SessionSettingType.php | 10 + src/Game/Service/GameResponseService.php | 44 +++- 3 files changed, 270 insertions(+), 47 deletions(-) diff --git a/src/Command/SendMainframeHintsCommand.php b/src/Command/SendMainframeHintsCommand.php index 368467a..572078f 100644 --- a/src/Command/SendMainframeHintsCommand.php +++ b/src/Command/SendMainframeHintsCommand.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace App\Command; +use App\Game\Entity\Player; use App\Game\Entity\Session; use App\Game\Enum\GameSettingType; use App\Game\Enum\SessionSettingType; @@ -10,6 +11,8 @@ use App\Game\Enum\SessionStatus; use App\Game\Repository\GameSettingRepository; use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionSettingRepository; +use App\Game\Service\PlayerService; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -25,10 +28,24 @@ final class SendMainframeHintsCommand extends Command { private const DEFAULT_TOTAL_TIME = 3600; + private const HELP_THRESHOLD_SECONDS = 2 * 60; + private const BROADCAST_CHAT_THRESHOLD_SECONDS = 5 * 60; + private const PRIVATE_CHAT_THRESHOLD_SECONDS = 7 * 60; + private const VERIFY_THRESHOLD_SECONDS = 10 * 60; + private const VERIFY_ESCALATION_THRESHOLD_SECONDS = 13 * 60; + private const CD_THRESHOLD_SECONDS = 15 * 60; + private const RAPPORTS_THRESHOLD_SECONDS = 17 * 60; + private const RAPPORTS_ESCALATION_THRESHOLD_SECONDS = 19 * 60; + private const ENDGAME_NUDGE_THRESHOLD_SECONDS = 23 * 60; + private const ENDGAME_HURRY_THRESHOLD_SECONDS = 26 * 60; + private const ENDGAME_URGENT_THRESHOLD_SECONDS = 28 * 60; + public function __construct( private readonly SessionRepository $sessionRepository, private readonly SessionSettingRepository $sessionSettingRepository, private readonly GameSettingRepository $gameSettingRepository, + private readonly PlayerService $playerService, + private readonly EntityManagerInterface $entityManager, private readonly HubInterface $hub, ) { parent::__construct(); @@ -38,39 +55,146 @@ final class SendMainframeHintsCommand extends Command { $sessions = $this->sessionRepository->findBy(['status' => SessionStatus::PLAYING]); $hintsSent = 0; + $sessionsFinished = 0; foreach ($sessions as $session) { - if ($this->checkContactHint($session)) { - $hintsSent++; + if ($this->finishSessionIfTimeUp($session)) { + $sessionsFinished++; + continue; + } + + $elapsed = $this->getElapsedPlayingSeconds($session); + if ($elapsed === null) { + continue; + } + + foreach ($session->getPlayers() as $player) { + if ($this->sendNextHint($session, $player, $elapsed)) { + $hintsSent++; + } } } - $output->writeln(sprintf('Checked %d running session(s), sent %d hint(s).', count($sessions), $hintsSent)); + $output->writeln(sprintf('Checked %d running session(s), sent %d hint(s), finished %d session(s).', count($sessions), $hintsSent, $sessionsFinished)); return Command::SUCCESS; } /** - * "Get in touch" hint: if 5 minutes into the game the players haven't messaged - * everyone (a private message to each other player, plus one broadcast), nudge them. - * Repeats every run of this command for as long as the condition still holds. + * Catches sessions whose countdown ran out. Players' own browsers also poll for + * this independently the moment their local timer hits zero, but that's a + * per-player, best-effort check (background tabs get throttled and may lag) with + * no broadcast to the others. Running every minute, this is the reliable fallback + * that guarantees every player gets the Mercure "game_finished" push within a + * minute of the game actually ending, regardless of tab state. */ - private function checkContactHint(Session $session): bool + private function finishSessionIfTimeUp(Session $session): bool { - $elapsed = $this->getElapsedPlayingSeconds($session); - if ($elapsed === null || $elapsed < 300) { + $timer = $session->getTimer(); + if ($timer === null || time() < $timer) { return false; } - if ($this->allPlayersHaveContactedEveryone($session)) { - return false; - } + $session->setStatus(SessionStatus::LOST); + $session->setFinishedAt(new \DateTime()); + $this->entityManager->persist($session); + $this->entityManager->flush(); - $this->publishHint($session, 'Get in contact with your fellow agents to work together on defeating this AI virus.'); + try { + $topic = '/game/hub/' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'lost']))); + } catch (\Exception $e) { + // Mercure might be down + } return true; } + /** + * Walks this player's progress in strict dependency order and sends at most one + * hint: the one for the first step that's both unresolved and old enough to nudge + * about. Later steps genuinely depend on earlier ones (e.g. /verify isn't even + * usable until a player has finished contacting everyone), so we stop at the + * first unresolved step instead of also evaluating - and possibly firing - hints + * for steps that aren't actionable yet. + */ + private function sendNextHint(Session $session, Player $player, int $elapsed): bool + { + if (!$this->hasUsedHelp($session, $player)) { + if ($elapsed >= self::HELP_THRESHOLD_SECONDS) { + $this->publishHint($session, 'Have you already checked which functions are available to you through the help command?', $player); + return true; + } + return false; + } + + $hasBroadcast = $this->hasBroadcast($session, $player); + $hasContactedEveryonePrivately = $this->hasContactedEveryonePrivately($session, $player); + + if (!$hasBroadcast || !$hasContactedEveryonePrivately) { + if ($elapsed >= self::PRIVATE_CHAT_THRESHOLD_SECONDS) { + $this->publishHint($session, 'The AI virus can see all communication if you are using open communication channels. Contact each other privately as well.', $player); + return true; + } + if (!$hasBroadcast && $elapsed >= self::BROADCAST_CHAT_THRESHOLD_SECONDS) { + $this->publishHint($session, 'You should establish communications with your fellow agents.', $player); + return true; + } + return false; + } + + if (!$this->isFullyVerified($session, $player)) { + if ($elapsed >= self::VERIFY_ESCALATION_THRESHOLD_SECONDS) { + $this->publishHint($session, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', $player); + return true; + } + if ($elapsed >= self::VERIFY_THRESHOLD_SECONDS) { + $this->publishHint($session, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', $player); + return true; + } + return false; + } + + if ($this->isStillInHomeDirectory($player)) { + if ($elapsed >= self::CD_THRESHOLD_SECONDS) { + $this->publishHint($session, 'You can change the folder you are working in. Check the help command for more information', $player); + return true; + } + return false; + } + + if (!$this->allMessagesDecoded($session, $player)) { + if ($elapsed >= self::RAPPORTS_ESCALATION_THRESHOLD_SECONDS) { + $this->publishHint($session, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", $player); + return true; + } + if ($elapsed >= self::RAPPORTS_THRESHOLD_SECONDS && !$this->isInRapportsFolder($player)) { + $this->publishHint($session, 'You should go to the rapports directory to check out the rapports.', $player); + return true; + } + return false; + } + + // Every earlier step is resolved for this player - only the endgame urgency + // nudges are left, gated purely on elapsed time. + if ($elapsed >= self::ENDGAME_URGENT_THRESHOLD_SECONDS) { + $this->publishHint($session, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', $player); + return true; + } + + if ($elapsed >= self::ENDGAME_HURRY_THRESHOLD_SECONDS) { + $this->publishHint($session, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', $player); + return true; + } + + if ($elapsed >= self::ENDGAME_NUDGE_THRESHOLD_SECONDS) { + $this->publishHint($session, 'Have you checked out the help command to see what you can do?', $player); + return true; + } + + return false; + } + private function getElapsedPlayingSeconds(Session $session): ?int { $timer = $session->getTimer(); @@ -86,49 +210,104 @@ final class SendMainframeHintsCommand extends Command return time() - $startedAt; } - private function allPlayersHaveContactedEveryone(Session $session): bool + private function hasUsedHelp(Session $session, Player $player): bool { - $players = $session->getPlayers(); - $screens = []; - - foreach ($players as $player) { - if ($player->getScreen() === null) { - return false; - } - $screens[] = $player->getScreen(); + $settingName = SessionSettingType::tryFrom('HelpUsedForPlayer' . $player->getScreen()); + if (!$settingName) { + return false; } - foreach ($players as $player) { - $screen = $player->getScreen(); - $trackingSettingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $screen); - if (!$trackingSettingName) { - return false; + $setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player); + + return $setting !== null && $setting->getValue() === 'true'; + } + + private function getChatTracking(Session $session, Player $player): array + { + $settingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $player->getScreen()); + if (!$settingName) { + return []; + } + + $setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player); + + return $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : []; + } + + private function hasBroadcast(Session $session, Player $player): bool + { + return in_array(0, $this->getChatTracking($session, $player), true); + } + + private function hasContactedEveryonePrivately(Session $session, Player $player): bool + { + $tracking = $this->getChatTracking($session, $player); + + foreach ($session->getPlayers() as $otherPlayer) { + $otherScreen = $otherPlayer->getScreen(); + if ($otherScreen === $player->getScreen()) { + continue; } - - $setting = $this->sessionSettingRepository->getSetting($session, $trackingSettingName, $player); - $tracking = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : []; - - if (!in_array(0, $tracking)) { + if ($otherScreen === null || !in_array($otherScreen, $tracking, true)) { return false; } - - foreach ($screens as $otherScreen) { - if ($otherScreen !== $screen && !in_array($otherScreen, $tracking)) { - return false; - } - } } return true; } - private function publishHint(Session $session, string $message): void + private function isFullyVerified(Session $session, Player $player): bool + { + $settingName = SessionSettingType::tryFrom('VerificationProgressForPlayer' . $player->getScreen()); + if (!$settingName) { + return false; + } + + $setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player); + $progress = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : []; + + return count($progress) >= $session->getGame()->getNumberOfPlayers() - 1; + } + + private function isStillInHomeDirectory(Player $player): bool + { + $pwd = $this->playerService->getCurrentPwdOfPlayer($player); + + return $pwd === '/var/home/' . $player->getUser()->getUsername(); + } + + private function isInRapportsFolder(Player $player): bool + { + $pwd = $this->playerService->getCurrentPwdOfPlayer($player); + + return $pwd !== null && str_starts_with($pwd, '/var/rapports'); + } + + /** + * True once all three agents have decoded their personal message - decoding + * player 1's grants 'sudo' to everyone, player 2's grants 'scan', player 3's + * grants 'rm', so checking for all three on any player reflects the whole team. + */ + private function allMessagesDecoded(Session $session, Player $player): bool + { + $settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen()); + if (!$settingName) { + return false; + } + + $setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player); + $rights = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : []; + + return in_array('sudo', $rights, true) && in_array('scan', $rights, true) && in_array('rm', $rights, true); + } + + private function publishHint(Session $session, string $message, Player $player): void { - $topic = '/game/hub/' . $session->getId(); try { - $this->hub->publish(new Update($topic, json_encode([0, $message, 'hint']))); + $topic = '/game/hub/' . $session->getId(); + $this->hub->publish(new Update($topic, json_encode([$player->getScreen() ?? 0, $message, 'hint']))); } catch (\Exception $e) { - // Mercure might be down + // Mercure might be down, but we don't want to crash the game } } } diff --git a/src/Game/Enum/SessionSettingType.php b/src/Game/Enum/SessionSettingType.php index ece0938..f799440 100644 --- a/src/Game/Enum/SessionSettingType.php +++ b/src/Game/Enum/SessionSettingType.php @@ -85,4 +85,14 @@ enum SessionSettingType: string case LOCK_FOR_PLAYER9 = 'LockForPlayer9'; case LOCK_FOR_PLAYER10 = 'LockForPlayer10'; case LOCKED_FILES_REMOVAL_DEADLINE = 'LockedFilesRemovalDeadline'; + case HELP_USED_FOR_PLAYER1 = 'HelpUsedForPlayer1'; + case HELP_USED_FOR_PLAYER2 = 'HelpUsedForPlayer2'; + case HELP_USED_FOR_PLAYER3 = 'HelpUsedForPlayer3'; + case HELP_USED_FOR_PLAYER4 = 'HelpUsedForPlayer4'; + case HELP_USED_FOR_PLAYER5 = 'HelpUsedForPlayer5'; + case HELP_USED_FOR_PLAYER6 = 'HelpUsedForPlayer6'; + case HELP_USED_FOR_PLAYER7 = 'HelpUsedForPlayer7'; + case HELP_USED_FOR_PLAYER8 = 'HelpUsedForPlayer8'; + case HELP_USED_FOR_PLAYER9 = 'HelpUsedForPlayer9'; + case HELP_USED_FOR_PLAYER10 = 'HelpUsedForPlayer10'; } diff --git a/src/Game/Service/GameResponseService.php b/src/Game/Service/GameResponseService.php index 280601d..15e8c06 100644 --- a/src/Game/Service/GameResponseService.php +++ b/src/Game/Service/GameResponseService.php @@ -136,6 +136,35 @@ class GameResponseService return json_decode($setting->getValue(), true) ?? []; } + /** + * Records that a player has used the help command, so SendMainframeHintsCommand + * can stop nudging them towards it. Absence of this setting means "not used yet". + */ + private function markHelpCommandUsed(Player $player): void + { + $settingName = SessionSettingType::tryFrom('HelpUsedForPlayer' . $player->getScreen()); + + if (!$settingName) { + return; + } + + $setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player); + if ($setting && $setting->getValue() === 'true') { + return; + } + + if (!$setting) { + $setting = new SessionSetting(); + $setting->setSession($player->getSession()); + $setting->setPlayer($player); + $setting->setName($settingName); + } + + $setting->setValue('true'); + $this->entityManager->persist($setting); + $this->entityManager->flush(); + } + private function checkGameCommando(string $message, Player $player) : array { $messagePart = explode(' ', $message); @@ -152,7 +181,8 @@ class GameResponseService else return ['result' => ['Error sending']]; case '/help': - return ['result' => $this->getHelpCommand($rechten)]; + $this->markHelpCommandUsed($player); + return ['result' => $this->getHelpCommand($rechten, $player)]; case '/decode': if(!in_array('decode', $rechten)) return ['result' => ['Unknown command']]; @@ -175,7 +205,8 @@ class GameResponseService $rechten = $this->getRechten($player); switch($messagePart[0]) { case 'help': - return ['result' => $this->getHelpCommand($rechten)]; + $this->markHelpCommandUsed($player); + return ['result' => $this->getHelpCommand($rechten, $player)]; case 'ls': if(!in_array('ls', $rechten)) return ['result' => ['Unknown command']]; @@ -281,9 +312,9 @@ class GameResponseService } } - private function getHelpCommand(mixed $rechten) : array + private function getHelpCommand(mixed $rechten, Player $player) : array { - $messages = []; + $messages = ['Help function for Agent ' . $player->getScreen() . ':']; foreach($rechten as $recht) { switch($recht) { @@ -566,13 +597,16 @@ class GameResponseService 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'); } + if ($decodeMessage === DecodeMessage::PLAYER_3) { + $this->grantRightToAllPlayers($player->getSession(), 'rm'); + } + return $decodeMessage->value; }