Files
ltbxd-actorle/docs/superpowers/plans/2026-04-01-game-config-panel.md
thibaud-leclere 468b72b419 docs: add game config panel implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:49:16 +02:00

34 KiB

Game Configuration Panel Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a configuration panel above the "Commencer une partie" button that lets players filter actors by watched films, choose hint types, and select which award categories to use.

Architecture: New repository method to query eligible AwardTypes, config parameters passed through the existing POST form to GameGridProvider::generate() which gains retry logic. A Stimulus controller handles UI interactions (toggle visibility, prevent last-unchecked). CSS-only toggle switch.

Tech Stack: Symfony 8 / Twig / Stimulus.js / CSS


File Map

Action File Responsibility
Modify src/Repository/AwardTypeRepository.php Add findWithMinActors() query
Modify src/Repository/ActorRepository.php Add findOneRandomInWatchedFilms() method
Modify src/Repository/AwardRepository.php Add findOneRandomByActorAndTypes() method
Modify src/Controller/HomepageController.php Pass eligible AwardTypes to template
Modify src/Controller/GameController.php Extract config from POST, pass to generator
Modify src/Provider/GameGridProvider.php Accept config, filter hints, retry logic
Modify templates/homepage/index.html.twig Render config panel in form
Create assets/controllers/game_config_controller.js Stimulus controller for UI interactions
Modify assets/styles/app.css Toggle switch, config panel styles
Modify tests/Provider/GameGridProviderTest.php Test hint filtering and retry logic

Task 1: AwardTypeRepository — findWithMinActors()

Files:

  • Modify: src/Repository/AwardTypeRepository.php

  • Step 1: Write the failing test

Create tests/Repository/AwardTypeRepositoryTest.php:

<?php

declare(strict_types=1);

namespace App\Tests\Repository;

use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Repository\AwardTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class AwardTypeRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private AwardTypeRepository $repo;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
        $this->repo = self::getContainer()->get(AwardTypeRepository::class);

        // Clean slate
        $this->em->createQuery('DELETE FROM App\Entity\Award')->execute();
        $this->em->createQuery('DELETE FROM App\Entity\AwardType')->execute();
        $this->em->createQuery('DELETE FROM App\Entity\Actor')->execute();
    }

    public function testFindWithMinActorsFiltersCorrectly(): void
    {
        // Create AwardType with 3 distinct actors (below threshold of 5)
        $smallType = new AwardType();
        $smallType->setName('Small Award')->setPattern('small');
        $this->em->persist($smallType);

        // Create AwardType with 6 distinct actors (above threshold)
        $bigType = new AwardType();
        $bigType->setName('Big Award')->setPattern('big');
        $this->em->persist($bigType);

        for ($i = 0; $i < 6; $i++) {
            $actor = new Actor();
            $actor->setName("Actor $i");
            $this->em->persist($actor);

            $award = new Award();
            $award->setName("Big Award $i");
            $award->setActor($actor);
            $award->setAwardType($bigType);
            $this->em->persist($award);

            if ($i < 3) {
                $awardSmall = new Award();
                $awardSmall->setName("Small Award $i");
                $awardSmall->setActor($actor);
                $awardSmall->setAwardType($smallType);
                $this->em->persist($awardSmall);
            }
        }

        $this->em->flush();

        $result = $this->repo->findWithMinActors(5);

        $this->assertCount(1, $result);
        $this->assertSame('Big Award', $result[0]->getName());
    }
}
  • Step 2: Run test to verify it fails

Run: php bin/phpunit tests/Repository/AwardTypeRepositoryTest.php -v Expected: FAIL — method findWithMinActors does not exist.

  • Step 3: Implement findWithMinActors()

In src/Repository/AwardTypeRepository.php, add this method after the existing findAll():

/** @return list<AwardType> */
public function findWithMinActors(int $minActors): array
{
    return $this->createQueryBuilder('at')
        ->join('at.awards', 'a')
        ->groupBy('at.id')
        ->having('COUNT(DISTINCT a.actor) >= :minActors')
        ->setParameter('minActors', $minActors)
        ->orderBy('at.name', 'ASC')
        ->getQuery()
        ->getResult();
}
  • Step 4: Run test to verify it passes

Run: php bin/phpunit tests/Repository/AwardTypeRepositoryTest.php -v Expected: PASS

  • Step 5: Commit
git add src/Repository/AwardTypeRepository.php tests/Repository/AwardTypeRepositoryTest.php
git commit -m "feat: add AwardTypeRepository::findWithMinActors()"

Task 2: ActorRepository — findOneRandomInWatchedFilms()

Files:

  • Modify: src/Repository/ActorRepository.php

  • Step 1: Write the failing test

Create tests/Repository/ActorRepositoryTest.php:

<?php

declare(strict_types=1);

namespace App\Tests\Repository;

use App\Entity\Actor;
use App\Entity\Movie;
use App\Entity\MovieRole;
use App\Entity\User;
use App\Entity\UserMovie;
use App\Repository\ActorRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class ActorRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private ActorRepository $repo;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
        $this->repo = self::getContainer()->get(ActorRepository::class);
    }

    public function testFindOneRandomInWatchedFilmsReturnsOnlyWatchedActors(): void
    {
        // Create user
        $user = new User();
        $user->setEmail('test-watched-' . uniqid() . '@example.com');
        $user->setPassword('test');
        $this->em->persist($user);

        // Actor in a watched film
        $watchedActor = new Actor();
        $watchedActor->setName('Watched Actor');
        $watchedActor->setPopularity(10.0);
        $this->em->persist($watchedActor);

        $movie = new Movie();
        $movie->setTmdbId(99990);
        $movie->setLtbxdRef('watched-test');
        $movie->setTitle('Watched Film');
        $this->em->persist($movie);

        $role = new MovieRole();
        $role->setActor($watchedActor);
        $role->setMovie($movie);
        $role->setCharacter('Hero');
        $this->em->persist($role);

        $userMovie = new UserMovie();
        $userMovie->setUser($user);
        $userMovie->setMovie($movie);
        $this->em->persist($userMovie);

        // Actor NOT in any watched film
        $unwatchedActor = new Actor();
        $unwatchedActor->setName('Unwatched Actor');
        $unwatchedActor->setPopularity(10.0);
        $this->em->persist($unwatchedActor);

        $movie2 = new Movie();
        $movie2->setTmdbId(99991);
        $movie2->setLtbxdRef('unwatched-test');
        $movie2->setTitle('Unwatched Film');
        $this->em->persist($movie2);

        $role2 = new MovieRole();
        $role2->setActor($unwatchedActor);
        $role2->setMovie($movie2);
        $role2->setCharacter('Villain');
        $this->em->persist($role2);

        $this->em->flush();

        // Run many times to be sure — should always return watchedActor
        for ($i = 0; $i < 10; $i++) {
            $result = $this->repo->findOneRandomInWatchedFilms($user, 0, 'w');
            $this->assertNotNull($result);
            $this->assertSame($watchedActor->getId(), $result->getId());
        }
    }
}
  • Step 2: Run test to verify it fails

Run: php bin/phpunit tests/Repository/ActorRepositoryTest.php -v Expected: FAIL — method findOneRandomInWatchedFilms does not exist.

  • Step 3: Implement findOneRandomInWatchedFilms()

In src/Repository/ActorRepository.php, add after findOneRandom():

public function findOneRandomInWatchedFilms(User $user, ?float $popularity = null, ?string $char = null): ?Actor
{
    $qb = $this->createQueryBuilder('a')
        ->join('a.movieRoles', 'mr')
        ->join('mr.movie', 'm')
        ->join(UserMovie::class, 'um', 'WITH', 'um.movie = m AND um.user = :user')
        ->setParameter('user', $user);

    if (!empty($popularity)) {
        $qb->andWhere('a.popularity >= :popularity')
            ->setParameter('popularity', $popularity);
    }

    if (!empty($char)) {
        $qb->andWhere('a.name LIKE :name')
            ->setParameter('name', '%' . $char . '%');
    }

    return $qb
        ->orderBy('RANDOM()')
        ->setMaxResults(1)
        ->getQuery()
        ->getOneOrNullResult();
}

Add the missing import at the top of the file:

use App\Entity\User;
use App\Entity\UserMovie;
  • Step 4: Run test to verify it passes

Run: php bin/phpunit tests/Repository/ActorRepositoryTest.php -v Expected: PASS

  • Step 5: Commit
git add src/Repository/ActorRepository.php tests/Repository/ActorRepositoryTest.php
git commit -m "feat: add ActorRepository::findOneRandomInWatchedFilms()"

Task 3: AwardRepository — findOneRandomByActorAndTypes()

Files:

  • Modify: src/Repository/AwardRepository.php

  • Step 1: Write the failing test

Create tests/Repository/AwardRepositoryTest.php:

<?php

declare(strict_types=1);

namespace App\Tests\Repository;

use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Repository\AwardRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class AwardRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private AwardRepository $repo;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
        $this->repo = self::getContainer()->get(AwardRepository::class);
    }

    public function testFindOneRandomByActorAndTypesFiltersCorrectly(): void
    {
        $actor = new Actor();
        $actor->setName('Award Actor Test');
        $this->em->persist($actor);

        $oscarType = new AwardType();
        $oscarType->setName('Oscar')->setPattern('oscar');
        $this->em->persist($oscarType);

        $globeType = new AwardType();
        $globeType->setName('Golden Globe')->setPattern('globe');
        $this->em->persist($globeType);

        $oscar = new Award();
        $oscar->setName('Best Actor Oscar');
        $oscar->setActor($actor);
        $oscar->setAwardType($oscarType);
        $this->em->persist($oscar);

        $globe = new Award();
        $globe->setName('Best Actor Globe');
        $globe->setActor($actor);
        $globe->setAwardType($globeType);
        $this->em->persist($globe);

        $this->em->flush();

        // Filter to only Oscar type
        for ($i = 0; $i < 10; $i++) {
            $result = $this->repo->findOneRandomByActorAndTypes($actor->getId(), [$oscarType->getId()]);
            $this->assertNotNull($result);
            $this->assertSame('Best Actor Oscar', $result->getName());
        }

        // Null awardTypeIds = all types allowed
        $result = $this->repo->findOneRandomByActorAndTypes($actor->getId(), null);
        $this->assertNotNull($result);
    }
}
  • Step 2: Run test to verify it fails

Run: php bin/phpunit tests/Repository/AwardRepositoryTest.php -v Expected: FAIL — method findOneRandomByActorAndTypes does not exist.

  • Step 3: Implement findOneRandomByActorAndTypes()

In src/Repository/AwardRepository.php, add after findOneRandomByActor():

/**
 * @param list<int>|null $awardTypeIds null means all types
 */
public function findOneRandomByActorAndTypes(int $actorId, ?array $awardTypeIds): ?Award
{
    $qb = $this->createQueryBuilder('a')
        ->andWhere('a.actor = :actorId')
        ->setParameter('actorId', $actorId);

    if ($awardTypeIds !== null) {
        $qb->andWhere('a.awardType IN (:typeIds)')
            ->setParameter('typeIds', $awardTypeIds);
    }

    return $qb
        ->orderBy('RANDOM()')
        ->setMaxResults(1)
        ->getQuery()
        ->getOneOrNullResult();
}
  • Step 4: Run test to verify it passes

Run: php bin/phpunit tests/Repository/AwardRepositoryTest.php -v Expected: PASS

  • Step 5: Commit
git add src/Repository/AwardRepository.php tests/Repository/AwardRepositoryTest.php
git commit -m "feat: add AwardRepository::findOneRandomByActorAndTypes()"

Task 4: GameGridProvider — accept config and add retry logic

Files:

  • Modify: src/Provider/GameGridProvider.php

  • Modify: tests/Provider/GameGridProviderTest.php

  • Step 1: Write the failing test for hint type filtering

Add to tests/Provider/GameGridProviderTest.php:

public function testGenerateHintRespectsAllowedTypes(): void
{
    $movieRoleRepo = $this->createMock(MovieRoleRepository::class);
    $movieRoleRepo->method('findOneRandomByActor')->willReturn(null);

    $awardRepo = $this->createMock(AwardRepository::class);
    $awardRepo->method('findOneRandomByActor')->willReturn(null);
    $awardRepo->method('findOneRandomByActorAndTypes')->willReturn(null);

    $generator = new GameGridProvider(
        $this->createMock(ActorRepository::class),
        $movieRoleRepo,
        $this->createMock(MovieRepository::class),
        $awardRepo,
        $this->createMock(EntityManagerInterface::class),
    );

    $actor = new Actor();
    $actor->setName('Test');

    // Only allow 'award' type, but no awards exist → should return null
    $method = new \ReflectionMethod($generator, 'generateHint');
    $result = $method->invoke($generator, $actor, ['award'], null);

    $this->assertNull($result);
}
  • Step 2: Run test to verify it fails

Run: php bin/phpunit tests/Provider/GameGridProviderTest.php --filter=testGenerateHintRespectsAllowedTypes -v Expected: FAIL — generateHint signature doesn't accept extra arguments.

  • Step 3: Update generateHint() and resolveHint() signatures

In src/Provider/GameGridProvider.php, change generateHint():

/**
 * @param list<string> $allowedTypes
 * @param list<int>|null $awardTypeIds
 * @return array{type: string, data: string}|null
 */
private function generateHint(Actor $rowActor, array $allowedTypes = ['film', 'character', 'award'], ?array $awardTypeIds = null): ?array
{
    $types = $allowedTypes;
    shuffle($types);

    foreach ($types as $type) {
        $hint = $this->resolveHint($type, $rowActor, $awardTypeIds);
        if ($hint !== null) {
            return $hint;
        }
    }

    return null;
}

Change resolveHint():

/**
 * @param list<int>|null $awardTypeIds
 * @return array{type: string, data: string}|null
 */
private function resolveHint(string $type, Actor $rowActor, ?array $awardTypeIds = null): ?array
{
    switch ($type) {
        case 'film':
            $role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
            if ($role === null) {
                return null;
            }
            return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()];

        case 'character':
            $role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
            if ($role === null) {
                return null;
            }
            return ['type' => 'character', 'data' => (string) $role->getId()];

        case 'award':
            $award = $this->awardRepository->findOneRandomByActorAndTypes($rowActor->getId(), $awardTypeIds);
            if ($award === null) {
                return null;
            }
            return ['type' => 'award', 'data' => (string) $award->getId()];
    }

    return null;
}
  • Step 4: Run test to verify it passes

Run: php bin/phpunit tests/Provider/GameGridProviderTest.php -v Expected: PASS (both old and new tests)

  • Step 5: Update generate() to accept config and add retry logic

Replace the generate() method in src/Provider/GameGridProvider.php:

/**
 * @param array{watchedOnly?: bool, hintTypes?: list<string>, awardTypeIds?: list<int>|null} $config
 */
public function generate(?User $user = null, array $config = []): ?Game
{
    $watchedOnly = $config['watchedOnly'] ?? false;
    $hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award'];
    $awardTypeIds = $config['awardTypeIds'] ?? null;

    for ($attempt = 0; $attempt < 5; $attempt++) {
        $game = $this->tryGenerate($user, $watchedOnly, $hintTypes, $awardTypeIds);
        if ($game !== null) {
            return $game;
        }
    }

    return null;
}

/**
 * @param list<string> $hintTypes
 * @param list<int>|null $awardTypeIds
 */
private function tryGenerate(?User $user, bool $watchedOnly, array $hintTypes, ?array $awardTypeIds): ?Game
{
    if ($watchedOnly && $user !== null) {
        $mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4);
    } else {
        $mainActor = $this->actorRepository->findOneRandom(4);
    }

    if ($mainActor === null) {
        return null;
    }

    $game = new Game();
    $game->setMainActor($mainActor);
    $game->setUser($user);

    $usedActors = [$mainActor->getId()];
    $rowOrder = 0;

    foreach (str_split(strtolower($mainActor->getName())) as $char) {
        if (!preg_match('/[a-z]/', $char)) {
            continue;
        }

        $actor = null;
        for ($try = 0; $try < 5; $try++) {
            if ($watchedOnly && $user !== null) {
                $candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char);
            } else {
                $candidate = $this->actorRepository->findOneRandom(4, $char);
            }

            if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {
                $actor = $candidate;
                break;
            }
        }

        if ($actor === null) {
            return null;
        }

        $usedActors[] = $actor->getId();

        $row = new GameRow();
        $row->setActor($actor);
        $row->setPosition(strpos(strtolower($actor->getName()), $char));
        $row->setRowOrder($rowOrder);

        $hint = $this->generateHint($actor, $hintTypes, $awardTypeIds);
        if ($hint === null) {
            return null; // Every row must have a hint
        }
        $row->setHintType($hint['type']);
        $row->setHintData($hint['data']);

        $game->addRow($row);
        ++$rowOrder;
    }

    $this->em->persist($game);
    $this->em->flush();

    return $game;
}
  • Step 6: Run all tests

Run: php bin/phpunit -v Expected: All PASS

  • Step 7: Commit
git add src/Provider/GameGridProvider.php tests/Provider/GameGridProviderTest.php
git commit -m "feat: GameGridProvider accepts config for hint types, watched-only, and retry logic"

Task 5: GameController — extract config from POST

Files:

  • Modify: src/Controller/GameController.php

  • Step 1: Update the start() method

Replace the $game = $generator->generate($user); line (line 45) and surrounding logic. The full updated start() method:

#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
public function start(
    Request $request,
    GameGridProvider $generator,
    GameRepository $gameRepository,
): Response {
    $this->validateCsrfToken('game_start', $request);

    /** @var User|null $user */
    $user = $this->getUser();

    // Check no game already in progress
    if ($user) {
        $existing = $gameRepository->findActiveForUser($user);
    } else {
        $gameId = $request->getSession()->get('current_game_id');
        $existing = $gameId ? $gameRepository->find($gameId) : null;
        if ($existing && $existing->getStatus() !== Game::STATUS_IN_PROGRESS) {
            $existing = null;
        }
    }

    if ($existing) {
        return $this->redirectToRoute('app_homepage');
    }

    // Build config from form parameters
    $config = [];

    if ($user && $request->request->getBoolean('watched_only')) {
        $config['watchedOnly'] = true;
    }

    $hintTypes = [];
    if ($request->request->getBoolean('hint_film', true)) {
        $hintTypes[] = 'film';
    }
    if ($request->request->getBoolean('hint_character', true)) {
        $hintTypes[] = 'character';
    }
    if ($request->request->getBoolean('hint_award', true)) {
        $hintTypes[] = 'award';
    }
    if (empty($hintTypes)) {
        $hintTypes = ['film', 'character', 'award'];
    }
    $config['hintTypes'] = $hintTypes;

    /** @var list<string> $awardTypeIds */
    $awardTypeIds = $request->request->all('award_types');
    if (!empty($awardTypeIds) && in_array('award', $hintTypes)) {
        $config['awardTypeIds'] = array_map('intval', $awardTypeIds);
    }

    $game = $generator->generate($user, $config);

    if ($game === null) {
        $this->addFlash('error', 'Impossible de générer une grille avec ces paramètres. Essayez avec des critères moins restrictifs.');
        return $this->redirectToRoute('app_homepage');
    }

    if (!$user) {
        $request->getSession()->set('current_game_id', $game->getId());
    }

    return $this->redirectToRoute('app_homepage');
}
  • Step 2: Run existing tests to check nothing is broken

Run: php bin/phpunit -v Expected: All PASS

  • Step 3: Commit
git add src/Controller/GameController.php
git commit -m "feat: extract game config from POST and pass to generator"

Task 6: HomepageController — pass AwardTypes to template

Files:

  • Modify: src/Controller/HomepageController.php

  • Step 1: Add AwardTypeRepository and pass eligible types to template

Update the index() method. Add AwardTypeRepository as a parameter and pass the eligible types when rendering the start screen:

use App\Repository\AwardTypeRepository;

Change the method signature to:

public function index(
    Request $request,
    GameRepository $gameRepository,
    GameGridProvider $gridGenerator,
    AwardTypeRepository $awardTypeRepository,
): Response {

Change the no-game render block (around line 43) from:

return $this->render('homepage/index.html.twig', [
    'game' => null,
]);

To:

return $this->render('homepage/index.html.twig', [
    'game' => null,
    'awardTypes' => $awardTypeRepository->findWithMinActors(5),
]);
  • Step 2: Run tests

Run: php bin/phpunit -v Expected: All PASS

  • Step 3: Commit
git add src/Controller/HomepageController.php
git commit -m "feat: pass eligible AwardTypes to homepage template"

Task 7: Twig template — render config panel

Files:

  • Modify: templates/homepage/index.html.twig

  • Step 1: Add the config panel HTML inside the form

Replace the {% else %} block (lines 57-76) with:

    {% else %}
        <div class="game-start-container">
            <form method="post" action="{{ path('app_game_start') }}" id="start-form"
                  data-controller="game-config">
                <input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">

                <div class="config-panel">
                    {% if app.user %}
                        <div class="config-section">
                            <label class="config-toggle">
                                <span class="config-toggle-label">Films vus</span>
                                <input type="checkbox" name="watched_only" value="1" class="toggle-switch">
                            </label>
                        </div>
                    {% endif %}

                    <div class="config-section">
                        <div class="config-section-title">Paramètres des indices</div>
                        <div class="config-hint-types">
                            <label class="config-checkbox">
                                <input type="checkbox" name="hint_film" value="1" checked
                                       data-game-config-target="hintType">
                                Film
                            </label>
                            <label class="config-checkbox">
                                <input type="checkbox" name="hint_character" value="1" checked
                                       data-game-config-target="hintType">
                                Rôle
                            </label>
                            <label class="config-checkbox">
                                <input type="checkbox" name="hint_award" value="1" checked
                                       data-game-config-target="hintType"
                                       data-action="change->game-config#toggleAwardSection">
                                Récompense
                            </label>
                        </div>

                        <div class="config-award-types" data-game-config-target="awardSection">
                            <div class="config-section-subtitle">Récompenses</div>
                            <div class="config-award-list">
                                <label class="config-checkbox">
                                    <input type="checkbox" checked
                                           data-game-config-target="allAwards"
                                           data-action="change->game-config#toggleAllAwards">
                                    Toutes les récompenses
                                </label>
                                {% for awardType in awardTypes %}
                                    <label class="config-checkbox">
                                        <input type="checkbox" name="award_types[]"
                                               value="{{ awardType.id }}" checked
                                               data-game-config-target="awardType"
                                               data-action="change->game-config#syncAllAwards">
                                        {{ awardType.name }}
                                    </label>
                                {% endfor %}
                            </div>
                        </div>
                    </div>
                </div>

                <button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
            </form>
            <div class="start-loader" id="start-loader"></div>
            {% if not app.user %}
                <p class="start-login-hint">
                    <a href="{{ path('app_login') }}">Connectez-vous</a> pour importer vos propres films
                </p>
            {% endif %}

            {% for message in app.flashes('error') %}
                <div class="flash-error">{{ message }}</div>
            {% endfor %}

            <script>
                document.getElementById('start-form').addEventListener('submit', function () {
                    this.style.display = 'none';
                    document.getElementById('start-loader').style.display = 'block';
                });
            </script>
        </div>
    {% endif %}
  • Step 2: Verify the page loads in the browser

Run: symfony serve (or existing dev server) and visit the homepage. Verify the config panel renders above the button.

  • Step 3: Commit
git add templates/homepage/index.html.twig
git commit -m "feat: render game config panel in start form"

Task 8: Stimulus controller — game_config_controller.js

Files:

  • Create: assets/controllers/game_config_controller.js

  • Step 1: Create the Stimulus controller

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['hintType', 'awardSection', 'allAwards', 'awardType'];

    toggleAwardSection() {
        const awardChecked = this.hintTypeTargets.find(
            (el) => el.name === 'hint_award'
        )?.checked;

        this.awardSectionTarget.style.display = awardChecked ? '' : 'none';
    }

    toggleAllAwards() {
        const checked = this.allAwardsTarget.checked;
        this.awardTypeTargets.forEach((el) => {
            el.checked = checked;
        });
    }

    syncAllAwards() {
        const allChecked = this.awardTypeTargets.every((el) => el.checked);
        this.allAwardsTarget.checked = allChecked;
    }

    hintTypeTargetConnected() {
        this.#bindMinOneChecked();
    }

    #bindMinOneChecked() {
        this.hintTypeTargets.forEach((el) => {
            el.addEventListener('change', () => {
                const checked = this.hintTypeTargets.filter((e) => e.checked);
                if (checked.length === 0) {
                    el.checked = true;
                }
                this.toggleAwardSection();
            });
        });
    }
}
  • Step 2: Verify in browser

Visit homepage, test:

  • Toggle "Récompense" off → award list disappears

  • Toggle "Récompense" on → award list appears

  • Uncheck all AwardTypes → "Toutes" unchecked

  • Check "Toutes" → all checked

  • Try to uncheck the last hint type → stays checked

  • Step 3: Commit

git add assets/controllers/game_config_controller.js
git commit -m "feat: add Stimulus game-config controller"

Task 9: CSS — config panel styles and toggle switch

Files:

  • Modify: assets/styles/app.css

  • Step 1: Add config panel CSS

Add the following CSS after the .btn-start block (after line 733 in app.css):

/* ── Game config panel ── */

.config-panel {
    width: 100%;
    max-width: 360px;
    margin-bottom: 24px;
}

.config-section {
    padding: 12px 0;
}

.config-section + .config-section {
    border-top: 1px solid var(--border);
}

.config-section-title {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-muted);
    margin-bottom: 10px;
}

.config-section-subtitle {
    font-size: 11px;
    font-weight: 600;
    color: var(--text-muted);
    margin: 10px 0 6px;
}

/* Toggle switch */

.config-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    cursor: pointer;
}

.config-toggle-label {
    font-size: 14px;
    font-weight: 500;
    color: var(--text);
}

.toggle-switch {
    appearance: none;
    width: 40px;
    height: 22px;
    background: var(--border);
    border-radius: 100px;
    position: relative;
    cursor: pointer;
    transition: background 0.2s;
}

.toggle-switch::before {
    content: '';
    position: absolute;
    top: 2px;
    left: 2px;
    width: 18px;
    height: 18px;
    background: white;
    border-radius: 50%;
    transition: transform 0.2s;
}

.toggle-switch:checked {
    background: var(--orange);
}

.toggle-switch:checked::before {
    transform: translateX(18px);
}

/* Hint type checkboxes */

.config-hint-types {
    display: flex;
    gap: 16px;
}

.config-checkbox {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 13px;
    color: var(--text);
    cursor: pointer;
}

.config-checkbox input[type="checkbox"] {
    accent-color: var(--orange);
}

/* Award type list */

.config-award-types {
    margin-top: 4px;
}

.config-award-list {
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    max-height: 150px;
    overflow-y: auto;
    padding: 6px 0;
}

.config-award-list .config-checkbox {
    padding: 4px 12px;
}

.config-award-list .config-checkbox:first-child {
    padding-bottom: 6px;
    margin-bottom: 2px;
    border-bottom: 1px solid var(--border);
}

/* Flash messages */

.flash-error {
    margin-top: 16px;
    padding: 10px 16px;
    background: #fef2f2;
    color: #991b1b;
    border: 1px solid #fecaca;
    border-radius: var(--radius-sm);
    font-size: 13px;
}
  • Step 2: Verify in browser

Visit homepage and verify:

  • Toggle switch slides with orange color when on

  • Checkboxes are orange-accented

  • Award list scrolls if content overflows

  • Panel is centered and aligned above the start button

  • Responsive on mobile widths

  • Step 3: Commit

git add assets/styles/app.css
git commit -m "feat: add config panel CSS with toggle switch and award list styles"

Task 10: Final integration test

Files:

  • None new — manual verification

  • Step 1: Run all tests

Run: php bin/phpunit -v Expected: All PASS

  • Step 2: Manual integration test — anonymous user
  1. Log out
  2. Visit homepage
  3. Verify: "Films vus" toggle is NOT shown
  4. Verify: Three hint type checkboxes are shown, all checked
  5. Verify: Award list is shown (since "Récompense" is checked)
  6. Uncheck "Film" and "Rôle", try to uncheck "Récompense" → prevented
  7. Click "Commencer une partie" → game starts
  • Step 3: Manual integration test — authenticated user
  1. Log in
  2. Visit homepage
  3. Verify: "Films vus" toggle IS shown
  4. Enable "Films vus", uncheck "Film" hint type
  5. Click "Commencer une partie" → game starts with only character/award hints
  6. Verify hints match selected types
  • Step 4: Manual integration test — error case
  1. Log in, enable "Films vus"
  2. Uncheck "Film" and "Rôle" (keep only "Récompense")
  3. Select only a rare award type
  4. Click "Commencer une partie"
  5. If generation fails: verify flash error message appears
  • Step 5: Commit any final fixes if needed
git add -A
git commit -m "fix: integration fixes for game config panel"