diff --git a/migrations/Version20260810140000.php b/migrations/Version20260810140000.php new file mode 100644 index 0000000..401bcef --- /dev/null +++ b/migrations/Version20260810140000.php @@ -0,0 +1,53 @@ +addSql('CREATE TABLE hint (id INT AUTO_INCREMENT NOT NULL, game_id INT NOT NULL, hint_condition VARCHAR(30) NOT NULL, threshold_seconds INT NOT NULL, message LONGTEXT NOT NULL, sort_order INT NOT NULL, INDEX IDX_HINT_GAME (game_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE hint ADD CONSTRAINT FK_HINT_GAME FOREIGN KEY (game_id) REFERENCES `game` (id) ON DELETE CASCADE'); + + // Seed every existing game with the hints that used to be hardcoded in + // SendMainframeHintsCommand, so behavior doesn't regress the moment the + // command switches to reading from this table. + $seed = [ + ['help_used', 120, 'Have you already checked which functions are available to you through the help command?', 10], + ['broadcast_done', 300, 'You should establish communications with your fellow agents.', 20], + ['contacted_all_privately', 420, 'The AI virus can see all communication if you are using open communication channels. Make sure you send private messages to all other agents as well, so I know you can.', 30], + ['verified', 600, '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.', 40], + ['verified', 780, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', 50], + ['left_home_directory', 900, 'You can change the folder you are working in. Check the help command for more information', 60], + ['all_decoded', 1020, 'You should go to the rapports directory to check out the rapports.', 70], + ['all_decoded', 1140, "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.", 80], + ['none', 1380, 'Have you checked out the help command to see what you can do?', 90], + ['none', 1560, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', 100], + ['none', 1680, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', 110], + ]; + + foreach ($seed as [$condition, $threshold, $message, $sortOrder]) { + $this->addSql( + 'INSERT INTO hint (game_id, hint_condition, threshold_seconds, message, sort_order) ' + . 'SELECT id, ?, ?, ?, ? FROM `game`', + [$condition, $threshold, $message, $sortOrder] + ); + } + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE hint DROP FOREIGN KEY FK_HINT_GAME'); + $this->addSql('DROP TABLE hint'); + } +} diff --git a/src/Command/SendMainframeHintsCommand.php b/src/Command/SendMainframeHintsCommand.php index 2787ff1..e9de5d8 100644 --- a/src/Command/SendMainframeHintsCommand.php +++ b/src/Command/SendMainframeHintsCommand.php @@ -6,10 +6,12 @@ namespace App\Command; use App\Game\Entity\Player; use App\Game\Entity\Session; use App\Game\Enum\GameSettingType; +use App\Game\Enum\HintCondition; use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionStatus; use App\Game\Event\PushMercureMessageEvent; use App\Game\Repository\GameSettingRepository; +use App\Game\Repository\HintRepository; use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionSettingRepository; use App\Game\Service\PlayerService; @@ -28,22 +30,11 @@ 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 HintRepository $hintRepository, private readonly PlayerService $playerService, private readonly EntityManagerInterface $entityManager, private readonly EventDispatcherInterface $eventDispatcher, @@ -110,90 +101,66 @@ final class SendMainframeHintsCommand extends Command } /** - * 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. + * Walks this game's admin-configured hints in order. For the first distinct + * condition that's still unresolved for this player, fires the most-escalated + * hint sharing that condition whose threshold has been reached, then stops - + * later conditions are only reached once every one before them resolves, since + * they genuinely depend on it (e.g. /verify isn't even usable until a player has + * finished contacting everyone). */ 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); + $hints = $this->hintRepository->findByGameOrdered($session->getGame()); + + $seenConditions = []; + foreach ($hints as $hint) { + $condition = $hint->getCondition(); + + if (in_array($condition, $seenConditions, true)) { + continue; // already handled this condition's group further up the list + } + $seenConditions[] = $condition; + + if ($condition !== HintCondition::NONE && $this->isConditionResolved($condition, $session, $player)) { + continue; // move on to the next distinct condition + } + + $group = array_filter($hints, static fn ($h) => $h->getCondition() === $condition); + + $best = null; + foreach ($group as $candidate) { + if ($elapsed < $candidate->getThresholdSeconds()) { + continue; + } + if ($best === null || $candidate->getThresholdSeconds() > $best->getThresholdSeconds()) { + $best = $candidate; + } + } + + if ($best !== null) { + $this->publishHint($session, $best->getMessage(), $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. Make sure you send private messages to all other agents as well, so I know you can.', $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; // condition unresolved, but no threshold reached yet } return false; } + private function isConditionResolved(HintCondition $condition, Session $session, Player $player): bool + { + return match ($condition) { + HintCondition::HELP_USED => $this->hasUsedHelp($session, $player), + HintCondition::BROADCAST_DONE => $this->hasBroadcast($session, $player), + HintCondition::CONTACTED_ALL_PRIVATELY => $this->hasContactedEveryonePrivately($session, $player), + HintCondition::VERIFIED => $this->isFullyVerified($session, $player), + HintCondition::LEFT_HOME_DIRECTORY => !$this->isStillInHomeDirectory($player), + HintCondition::ALL_DECODED => $this->allMessagesDecoded($session, $player), + HintCondition::NONE => false, + }; + } + private function getElapsedPlayingSeconds(Session $session): ?int { $timer = $session->getTimer(); @@ -275,13 +242,6 @@ final class SendMainframeHintsCommand extends Command 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 diff --git a/src/Game/Controller/GameAdminHintController.php b/src/Game/Controller/GameAdminHintController.php new file mode 100644 index 0000000..fcfd73f --- /dev/null +++ b/src/Game/Controller/GameAdminHintController.php @@ -0,0 +1,91 @@ +render('game/admin/hints/index.html.twig', [ + 'game' => $game, + 'hints' => $hintRepository->findByGameOrdered($game), + ]); + } + + #[Route('/new', name: 'game_admin_hint_new', methods: ['GET', 'POST'])] + public function new(Game $game, Request $request, EntityManagerInterface $em): Response + { + $hint = new Hint(); + $hint->setGame($game); + + $form = $this->createForm(AdminHintType::class, $hint); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->persist($hint); + $em->flush(); + + $this->addFlash('success', 'Hint created.'); + + return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]); + } + + return $this->render('game/admin/hints/new.html.twig', [ + 'game' => $game, + 'form' => $form, + ]); + } + + #[Route('/{id}/edit', name: 'game_admin_hint_edit', methods: ['GET', 'POST'])] + public function edit(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response + { + $form = $this->createForm(AdminHintType::class, $hint); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->flush(); + + $this->addFlash('success', 'Hint updated.'); + + return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]); + } + + return $this->render('game/admin/hints/edit.html.twig', [ + 'game' => $game, + 'hint' => $hint, + 'form' => $form, + ]); + } + + #[Route('/{id}/delete', name: 'game_admin_hint_delete', methods: ['POST'])] + public function delete(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response + { + if (!$this->isCsrfTokenValid('delete_hint_' . $hint->getId(), $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]); + } + + $em->remove($hint); + $em->flush(); + + $this->addFlash('success', 'Hint deleted.'); + + return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]); + } +} diff --git a/src/Game/Entity/Hint.php b/src/Game/Entity/Hint.php new file mode 100644 index 0000000..d31220d --- /dev/null +++ b/src/Game/Entity/Hint.php @@ -0,0 +1,98 @@ +id; + } + + public function getGame(): ?Game + { + return $this->game; + } + + public function setGame(?Game $game): static + { + $this->game = $game; + + return $this; + } + + public function getCondition(): ?HintCondition + { + return $this->condition; + } + + public function setCondition(HintCondition $condition): static + { + $this->condition = $condition; + + return $this; + } + + public function getThresholdSeconds(): int + { + return $this->thresholdSeconds; + } + + public function setThresholdSeconds(int $thresholdSeconds): static + { + $this->thresholdSeconds = $thresholdSeconds; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): static + { + $this->message = $message; + + return $this; + } + + public function getSortOrder(): int + { + return $this->sortOrder; + } + + public function setSortOrder(int $sortOrder): static + { + $this->sortOrder = $sortOrder; + + return $this; + } +} diff --git a/src/Game/Enum/HintCondition.php b/src/Game/Enum/HintCondition.php new file mode 100644 index 0000000..3b75428 --- /dev/null +++ b/src/Game/Enum/HintCondition.php @@ -0,0 +1,33 @@ + "Player hasn't used the help command yet", + self::BROADCAST_DONE => "Player hasn't sent an open (broadcast) chat message yet", + self::CONTACTED_ALL_PRIVATELY => "Player hasn't privately messaged every other agent yet", + self::VERIFIED => "Player isn't fully verified yet", + self::LEFT_HOME_DIRECTORY => "Player hasn't left their home directory", + self::ALL_DECODED => "Player hasn't decoded all messages", + self::NONE => 'No condition - always eligible once earlier hints are resolved (e.g. endgame nudges)', + }; + } +} diff --git a/src/Game/Form/AdminHintType.php b/src/Game/Form/AdminHintType.php new file mode 100644 index 0000000..9b76360 --- /dev/null +++ b/src/Game/Form/AdminHintType.php @@ -0,0 +1,50 @@ +add('condition', ChoiceType::class, [ + 'label' => 'Fires while...', + 'choices' => array_combine( + array_map(fn (HintCondition $c) => $c->label(), HintCondition::cases()), + HintCondition::cases(), + ), + ]) + ->add('thresholdSeconds', IntegerType::class, [ + 'label' => 'Threshold (seconds since game start)', + 'constraints' => [new NotBlank(), new PositiveOrZero()], + ]) + ->add('message', TextareaType::class, [ + 'constraints' => [new NotBlank()], + 'attr' => ['rows' => 4], + ]) + ->add('sortOrder', IntegerType::class, [ + 'label' => 'Order (lower fires first)', + 'constraints' => [new NotBlank()], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Hint::class, + ]); + } +} diff --git a/src/Game/Repository/HintRepository.php b/src/Game/Repository/HintRepository.php new file mode 100644 index 0000000..e8e5fd5 --- /dev/null +++ b/src/Game/Repository/HintRepository.php @@ -0,0 +1,27 @@ + + */ +class HintRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Hint::class); + } + + /** + * @return Hint[] + */ + public function findByGameOrdered(Game $game): array + { + return $this->findBy(['game' => $game], ['sortOrder' => 'ASC']); + } +} diff --git a/templates/game/admin/games/index.html.twig b/templates/game/admin/games/index.html.twig index eac2c84..bdf43a5 100644 --- a/templates/game/admin/games/index.html.twig +++ b/templates/game/admin/games/index.html.twig @@ -57,7 +57,8 @@
+ Hints are checked in order. For each row, if its condition is still unresolved for a player, the + highest threshold reached among hints sharing that same condition fires and the check stops there + for that pass — later rows are only reached once every condition before them is resolved. +
+ + +{% endblock %} diff --git a/templates/game/admin/hints/new.html.twig b/templates/game/admin/hints/new.html.twig new file mode 100644 index 0000000..7d3f55e --- /dev/null +++ b/templates/game/admin/hints/new.html.twig @@ -0,0 +1,65 @@ +{% extends 'game/admin/base.html.twig' %} + +{% block title %}New Hint — {{ game.name }} — Admin{% endblock %} + +{% block admin_body %} + + +