1177 lines
34 KiB
Markdown
1177 lines
34 KiB
Markdown
# 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
|
|
<?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()`:
|
|
|
|
```php
|
|
/** @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**
|
|
|
|
```bash
|
|
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
|
|
<?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()`:
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
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**
|
|
|
|
```bash
|
|
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
|
|
<?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()`:
|
|
|
|
```php
|
|
/**
|
|
* @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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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()`:
|
|
|
|
```php
|
|
/**
|
|
* @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()`:
|
|
|
|
```php
|
|
/**
|
|
* @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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```php
|
|
#[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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```php
|
|
use App\Repository\AwardTypeRepository;
|
|
```
|
|
|
|
Change the method signature to:
|
|
|
|
```php
|
|
public function index(
|
|
Request $request,
|
|
GameRepository $gameRepository,
|
|
GameGridProvider $gridGenerator,
|
|
AwardTypeRepository $awardTypeRepository,
|
|
): Response {
|
|
```
|
|
|
|
Change the no-game render block (around line 43) from:
|
|
|
|
```php
|
|
return $this->render('homepage/index.html.twig', [
|
|
'game' => null,
|
|
]);
|
|
```
|
|
|
|
To:
|
|
|
|
```php
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```twig
|
|
{% 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```js
|
|
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**
|
|
|
|
```bash
|
|
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`):
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: integration fixes for game config panel"
|
|
```
|