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;
}