From c63af7becc76aebb35654a3d3f37db8ca1741ed7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 23 Aug 2026 21:35:05 +0200 Subject: [PATCH] Add a per-game hints CRUD in the admin panel Hints used to be 11 hardcoded PHP strings/thresholds in SendMainframeHintsCommand, walked through a fixed if/else chain. They are now Hint rows (game, condition, thresholdSeconds, message, sortOrder) manageable at /admin/games/{game}/hints - freely addable, removable, and reorderable per game, which sets up exactly what's needed for difficulty-dependent hint timing later (Easy/Medium/ Hard are separate games, so each gets its own hint set). The condition a hint fires behind (e.g. "hasn't used help yet", "isn't verified yet") is still a fixed, code-defined check (HintCondition enum) tied to real game state - a true free-form condition editor would need a full rules engine, which is out of scope. What's fully free-form is which hints exist, in what order, gated behind which of those conditions, with what wording and timing. SendMainframeHintsCommand now reads hints from the DB: walking them in sortOrder, it finds the first distinct condition still unresolved for a player, fires the most-escalated hint sharing that condition whose threshold has passed, and stops - matching the original "stop at the first unresolved, dependent step" behavior. Migration seeds every existing game with the previous hardcoded hints so behavior doesn't regress on deploy. Two minor fidelity simplifications from the original hardcoded logic, called out here since they're easy to miss: the "go to rapports directory" hint no longer additionally checks whether the player is already in that folder, and the two chat hints are now independent conditions (broadcast done / contacted everyone privately) instead of one combined check - both are edge-case-only behavior changes, not regressions in the common path. --- migrations/Version20260810140000.php | 53 +++++++ src/Command/SendMainframeHintsCommand.php | 144 +++++++----------- .../Controller/GameAdminHintController.php | 91 +++++++++++ src/Game/Entity/Hint.php | 98 ++++++++++++ src/Game/Enum/HintCondition.php | 33 ++++ src/Game/Form/AdminHintType.php | 50 ++++++ src/Game/Repository/HintRepository.php | 27 ++++ templates/game/admin/games/index.html.twig | 3 +- templates/game/admin/hints/edit.html.twig | 65 ++++++++ templates/game/admin/hints/index.html.twig | 78 ++++++++++ templates/game/admin/hints/new.html.twig | 65 ++++++++ 11 files changed, 614 insertions(+), 93 deletions(-) create mode 100644 migrations/Version20260810140000.php create mode 100644 src/Game/Controller/GameAdminHintController.php create mode 100644 src/Game/Entity/Hint.php create mode 100644 src/Game/Enum/HintCondition.php create mode 100644 src/Game/Form/AdminHintType.php create mode 100644 src/Game/Repository/HintRepository.php create mode 100644 templates/game/admin/hints/edit.html.twig create mode 100644 templates/game/admin/hints/index.html.twig create mode 100644 templates/game/admin/hints/new.html.twig 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 @@ {{ game.sessions|length }} - Edit + Edit + Hints {% else %} diff --git a/templates/game/admin/hints/edit.html.twig b/templates/game/admin/hints/edit.html.twig new file mode 100644 index 0000000..3c1ded0 --- /dev/null +++ b/templates/game/admin/hints/edit.html.twig @@ -0,0 +1,65 @@ +{% extends 'game/admin/base.html.twig' %} + +{% block title %}Edit Hint — {{ game.name }} — Admin{% endblock %} + +{% block admin_body %} +
+ ← Hints — {{ game.name }} +
+ +

Edit hint

+ +
+ {{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }} + +
+ {{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + {{ form_errors(form.condition) }} +
+ +
+ {{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + Seconds since the game started — e.g. 300 = 5 minutes + {{ form_errors(form.thresholdSeconds) }} +
+ +
+ {{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + {{ form_errors(form.message) }} +
+ +
+ {{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later + {{ form_errors(form.sortOrder) }} +
+ +
+ + Cancel +
+ + {{ form_end(form) }} +
+{% endblock %} diff --git a/templates/game/admin/hints/index.html.twig b/templates/game/admin/hints/index.html.twig new file mode 100644 index 0000000..212631b --- /dev/null +++ b/templates/game/admin/hints/index.html.twig @@ -0,0 +1,78 @@ +{% extends 'game/admin/base.html.twig' %} + +{% block title %}Hints — {{ game.name }} — Admin{% endblock %} + +{% block admin_body %} +
+ ← Games +
+ +
+

Hints — {{ game.name }}

+
+ {{ hints|length }} total + + New Hint +
+
+ +

+ 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. +

+ +
+ + + + + + + + + + + + {% for hint in hints %} + + + + + + + + {% else %} + + + + {% endfor %} + +
OrderConditionThresholdMessageActions
{{ hint.sortOrder }}{{ hint.condition.label }}{{ (hint.thresholdSeconds / 60)|round(1, 'floor') }} min{{ hint.message|length > 90 ? hint.message|slice(0, 90) ~ '…' : hint.message }} + Edit +
+ + +
+
No hints configured for this game yet.
+
+{% 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 %} +
+ ← Hints — {{ game.name }} +
+ +

New hint

+ +
+ {{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }} + +
+ {{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + {{ form_errors(form.condition) }} +
+ +
+ {{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + Seconds since the game started — e.g. 300 = 5 minutes + {{ form_errors(form.thresholdSeconds) }} +
+ +
+ {{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + {{ form_errors(form.message) }} +
+ +
+ {{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }} + {{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }} + Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later + {{ form_errors(form.sortOrder) }} +
+ +
+ + Cancel +
+ + {{ form_end(form) }} +
+{% endblock %}