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.
This commit is contained in:
Frank
2026-08-23 21:35:05 +02:00
parent 552c71b718
commit c63af7becc
11 changed files with 614 additions and 93 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260810140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create hint table and seed it with the previously-hardcoded mainframe hints for every existing game';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+44 -84
View File
@@ -6,10 +6,12 @@ namespace App\Command;
use App\Game\Entity\Player; use App\Game\Entity\Player;
use App\Game\Entity\Session; use App\Game\Entity\Session;
use App\Game\Enum\GameSettingType; use App\Game\Enum\GameSettingType;
use App\Game\Enum\HintCondition;
use App\Game\Enum\SessionSettingType; use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus; use App\Game\Enum\SessionStatus;
use App\Game\Event\PushMercureMessageEvent; use App\Game\Event\PushMercureMessageEvent;
use App\Game\Repository\GameSettingRepository; use App\Game\Repository\GameSettingRepository;
use App\Game\Repository\HintRepository;
use App\Game\Repository\SessionRepository; use App\Game\Repository\SessionRepository;
use App\Game\Repository\SessionSettingRepository; use App\Game\Repository\SessionSettingRepository;
use App\Game\Service\PlayerService; use App\Game\Service\PlayerService;
@@ -28,22 +30,11 @@ final class SendMainframeHintsCommand extends Command
{ {
private const DEFAULT_TOTAL_TIME = 3600; 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( public function __construct(
private readonly SessionRepository $sessionRepository, private readonly SessionRepository $sessionRepository,
private readonly SessionSettingRepository $sessionSettingRepository, private readonly SessionSettingRepository $sessionSettingRepository,
private readonly GameSettingRepository $gameSettingRepository, private readonly GameSettingRepository $gameSettingRepository,
private readonly HintRepository $hintRepository,
private readonly PlayerService $playerService, private readonly PlayerService $playerService,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher, 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 * Walks this game's admin-configured hints in order. For the first distinct
* hint: the one for the first step that's both unresolved and old enough to nudge * condition that's still unresolved for this player, fires the most-escalated
* about. Later steps genuinely depend on earlier ones (e.g. /verify isn't even * hint sharing that condition whose threshold has been reached, then stops -
* usable until a player has finished contacting everyone), so we stop at the * later conditions are only reached once every one before them resolves, since
* first unresolved step instead of also evaluating - and possibly firing - hints * they genuinely depend on it (e.g. /verify isn't even usable until a player has
* for steps that aren't actionable yet. * finished contacting everyone).
*/ */
private function sendNextHint(Session $session, Player $player, int $elapsed): bool private function sendNextHint(Session $session, Player $player, int $elapsed): bool
{ {
if (!$this->hasUsedHelp($session, $player)) { $hints = $this->hintRepository->findByGameOrdered($session->getGame());
if ($elapsed >= self::HELP_THRESHOLD_SECONDS) {
$this->publishHint($session, 'Have you already checked which functions are available to you through the help command?', $player); $seenConditions = [];
return true; foreach ($hints as $hint) {
$condition = $hint->getCondition();
if (in_array($condition, $seenConditions, true)) {
continue; // already handled this condition's group further up the list
} }
return false; $seenConditions[] = $condition;
if ($condition !== HintCondition::NONE && $this->isConditionResolved($condition, $session, $player)) {
continue; // move on to the next distinct condition
} }
$hasBroadcast = $this->hasBroadcast($session, $player); $group = array_filter($hints, static fn ($h) => $h->getCondition() === $condition);
$hasContactedEveryonePrivately = $this->hasContactedEveryonePrivately($session, $player);
if (!$hasBroadcast || !$hasContactedEveryonePrivately) { $best = null;
if ($elapsed >= self::PRIVATE_CHAT_THRESHOLD_SECONDS) { foreach ($group as $candidate) {
$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); if ($elapsed < $candidate->getThresholdSeconds()) {
return true; continue;
} }
if (!$hasBroadcast && $elapsed >= self::BROADCAST_CHAT_THRESHOLD_SECONDS) { if ($best === null || $candidate->getThresholdSeconds() > $best->getThresholdSeconds()) {
$this->publishHint($session, 'You should establish communications with your fellow agents.', $player); $best = $candidate;
return true;
} }
return false;
} }
if (!$this->isFullyVerified($session, $player)) { if ($best !== null) {
if ($elapsed >= self::VERIFY_ESCALATION_THRESHOLD_SECONDS) { $this->publishHint($session, $best->getMessage(), $player);
$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; return true;
} }
if ($elapsed >= self::ENDGAME_HURRY_THRESHOLD_SECONDS) { return false; // condition unresolved, but no threshold reached yet
$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; 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 private function getElapsedPlayingSeconds(Session $session): ?int
{ {
$timer = $session->getTimer(); $timer = $session->getTimer();
@@ -275,13 +242,6 @@ final class SendMainframeHintsCommand extends Command
return $pwd === '/var/home/' . $player->getUser()->getUsername(); 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 * 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 * player 1's grants 'sudo' to everyone, player 2's grants 'scan', player 3's
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Game;
use App\Game\Entity\Hint;
use App\Game\Form\AdminHintType;
use App\Game\Repository\HintRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/games/{game}/hints')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminHintController extends AbstractController
{
#[Route('', name: 'game_admin_hints', methods: ['GET'])]
public function index(Game $game, HintRepository $hintRepository): Response
{
return $this->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()]);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Game\Entity;
use App\Game\Enum\HintCondition;
use App\Game\Repository\HintRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: HintRepository::class)]
#[ORM\Table(name: 'hint')]
class Hint
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Game::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Game $game = null;
#[ORM\Column(name: 'hint_condition', type: 'string', length: 30, enumType: HintCondition::class)]
private ?HintCondition $condition = null;
#[ORM\Column]
private int $thresholdSeconds = 0;
#[ORM\Column(type: 'text')]
private ?string $message = null;
#[ORM\Column]
private int $sortOrder = 0;
public function getId(): ?int
{
return $this->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;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Game\Enum;
/**
* The fixed set of game-progress checks a hint can be gated behind. A hint fires
* only once its condition is still unresolved for that player - admins compose,
* order, and word hints freely, but the underlying checks are real game state and
* stay in code (see SendMainframeHintsCommand::isConditionResolved()).
*/
enum HintCondition: string
{
case HELP_USED = 'help_used';
case BROADCAST_DONE = 'broadcast_done';
case CONTACTED_ALL_PRIVATELY = 'contacted_all_privately';
case VERIFIED = 'verified';
case LEFT_HOME_DIRECTORY = 'left_home_directory';
case ALL_DECODED = 'all_decoded';
case NONE = 'none';
public function label(): string
{
return match ($this) {
self::HELP_USED => "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)',
};
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Game\Form;
use App\Game\Entity\Hint;
use App\Game\Enum\HintCondition;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\PositiveOrZero;
class AdminHintType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Game\Repository;
use App\Game\Entity\Game;
use App\Game\Entity\Hint;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Hint>
*/
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']);
}
}
+2 -1
View File
@@ -57,7 +57,8 @@
</td> </td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ game.sessions|length }}</td> <td style="padding: 0.75rem 1rem; color: #475569;">{{ game.sessions|length }}</td>
<td style="padding: 0.75rem 1rem;"> <td style="padding: 0.75rem 1rem;">
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Edit</a> <a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">Edit</a>
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Hints</a>
</td> </td>
</tr> </tr>
{% else %} {% else %}
+65
View File
@@ -0,0 +1,65 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Edit Hint — {{ game.name }} — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Edit hint</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
<div>
{{ 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) }}
</div>
<div>
{{ 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;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
{{ form_errors(form.thresholdSeconds) }}
</div>
<div>
{{ 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) }}
</div>
<div>
{{ 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;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
{{ form_errors(form.sortOrder) }}
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" style="
padding: 0.6rem 1.25rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
">Save changes</button>
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
padding: 0.6rem 1.25rem;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
@@ -0,0 +1,78 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Hints — {{ game.name }} — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Hints — {{ game.name }}</h1>
<div style="display: flex; align-items: center; gap: 1rem;">
<span style="color: #64748b; font-size: 0.9rem;">{{ hints|length }} total</span>
<a href="{{ path('game_admin_hint_new', {game: game.id}) }}" style="
padding: 0.5rem 1rem;
background: #3b82f6;
color: #fff;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
">+ New Hint</a>
</div>
</div>
<p style="color: #64748b; font-size: 0.85rem; margin-top: -1rem; margin-bottom: 1.5rem;">
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.
</p>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Order</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Condition</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Threshold</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Message</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
</tr>
</thead>
<tbody>
{% for hint in hints %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ hint.sortOrder }}</td>
<td style="padding: 0.75rem 1rem; color: #0f172a;">{{ hint.condition.label }}</td>
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ (hint.thresholdSeconds / 60)|round(1, 'floor') }} min</td>
<td style="padding: 0.75rem 1rem; color: #475569; max-width: 420px;">{{ hint.message|length > 90 ? hint.message|slice(0, 90) ~ '…' : hint.message }}</td>
<td style="padding: 0.75rem 1rem; white-space: nowrap;">
<a href="{{ path('game_admin_hint_edit', {game: game.id, id: hint.id}) }}" style="
color: #3b82f6;
text-decoration: none;
font-size: 0.85rem;
margin-right: 0.75rem;
">Edit</a>
<form method="post" action="{{ path('game_admin_hint_delete', {game: game.id, id: hint.id}) }}" style="display: inline;" onsubmit="return confirm('Delete this hint?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_hint_' ~ hint.id) }}">
<button type="submit" style="
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No hints configured for this game yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}New Hint — {{ game.name }} — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">New hint</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
<div>
{{ 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) }}
</div>
<div>
{{ 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;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
{{ form_errors(form.thresholdSeconds) }}
</div>
<div>
{{ 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) }}
</div>
<div>
{{ 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;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
{{ form_errors(form.sortOrder) }}
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" style="
padding: 0.6rem 1.25rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
">Create hint</button>
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
padding: 0.6rem 1.25rem;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}