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)) {
|
||||
$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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user