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()andresolveHint()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
- Log out
- Visit homepage
- Verify: "Films vus" toggle is NOT shown
- Verify: Three hint type checkboxes are shown, all checked
- Verify: Award list is shown (since "Récompense" is checked)
- Uncheck "Film" and "Rôle", try to uncheck "Récompense" → prevented
- Click "Commencer une partie" → game starts
- Step 3: Manual integration test — authenticated user
- Log in
- Visit homepage
- Verify: "Films vus" toggle IS shown
- Enable "Films vus", uncheck "Film" hint type
- Click "Commencer une partie" → game starts with only character/award hints
- Verify hints match selected types
- Step 4: Manual integration test — error case
- Log in, enable "Films vus"
- Uncheck "Film" and "Rôle" (keep only "Récompense")
- Select only a rare award type
- Click "Commencer une partie"
- 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"