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 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-08-16 17:56:16 +02:00
co-authored by Claude Sonnet 5
parent 2bda70b32b
commit 2951a39204
3 changed files with 270 additions and 47 deletions
+221 -42
View File
@@ -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('<info>Checked %d running session(s), sent %d hint(s).</info>', count($sessions), $hintsSent));
$output->writeln(sprintf('<info>Checked %d running session(s), sent %d hint(s), finished %d session(s).</info>', 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
}
}
}
+10
View File
@@ -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';
}
+39 -5
View File
@@ -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;
}