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:
@@ -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)) {
|
||||
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();
|
||||
}
|
||||
|
||||
foreach ($players as $player) {
|
||||
$screen = $player->getScreen();
|
||||
$trackingSettingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $screen);
|
||||
if (!$trackingSettingName) {
|
||||
$settingName = SessionSettingType::tryFrom('HelpUsedForPlayer' . $player->getScreen());
|
||||
if (!$settingName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$setting = $this->sessionSettingRepository->getSetting($session, $trackingSettingName, $player);
|
||||
$tracking = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
|
||||
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
|
||||
|
||||
if (!in_array(0, $tracking)) {
|
||||
return false;
|
||||
return $setting !== null && $setting->getValue() === 'true';
|
||||
}
|
||||
|
||||
foreach ($screens as $otherScreen) {
|
||||
if ($otherScreen !== $screen && !in_array($otherScreen, $tracking)) {
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
if ($otherScreen === null || !in_array($otherScreen, $tracking, true)) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user