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:
@@ -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
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user