Add configurable game start filters

This commit is contained in:
thibaud-leclere 2026-04-11 12:40:54 +02:00
parent 0698589d5b
commit 15760ebc08
9 changed files with 618 additions and 18 deletions

View file

@ -1,7 +1,26 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['hintType', 'awardSection', 'allAwards', 'awardType'];
static values = {
bucketSize: { type: Number, default: 20 },
};
static targets = [
'hintType',
'awardSection',
'allAwards',
'awardType',
'popularityMinInput',
'popularityMaxInput',
'popularityValue',
'popularityFill',
'popularitySummary',
];
connect() {
this.toggleAwardSection();
this.syncPopularityRange();
}
enforceMinOneChecked(event) {
const checked = this.hintTypeTargets.filter((e) => e.checked);
@ -30,4 +49,64 @@ export default class extends Controller {
const allChecked = this.awardTypeTargets.every((el) => el.checked);
this.allAwardsTarget.checked = allChecked;
}
syncPopularityRange(event) {
if (
!this.hasPopularityMinInputTarget
|| !this.hasPopularityMaxInputTarget
|| !this.hasPopularityValueTarget
) {
return;
}
let min = Number(this.popularityMinInputTarget.value);
let max = Number(this.popularityMaxInputTarget.value);
if (event?.target === this.popularityMinInputTarget && min > max) {
max = min;
this.popularityMaxInputTarget.value = String(max);
} else if (event?.target === this.popularityMaxInputTarget && max < min) {
min = max;
this.popularityMinInputTarget.value = String(min);
} else if (min > max) {
[min, max] = [max, min];
this.popularityMinInputTarget.value = String(min);
this.popularityMaxInputTarget.value = String(max);
}
this.popularityValueTarget.textContent = `${min}-${max}`;
if (this.hasPopularityFillTarget) {
const left = ((min - 1) / 9) * 100;
const right = ((10 - max) / 9) * 100;
this.popularityFillTarget.style.left = `${left}%`;
this.popularityFillTarget.style.right = `${right}%`;
}
if (this.hasPopularitySummaryTarget) {
this.popularitySummaryTarget.textContent =
this.buildPopularitySummary(min, max);
}
}
buildPopularitySummary(min, max) {
const bucketSize = this.bucketSizeValue;
if (min === 1 && max === 10) {
return 'Tous les acteurs.';
}
const startRank = ((10 - max) * bucketSize) + 1;
const endRank = min === 1 ? null : ((10 - min + 1) * bucketSize);
if (max === 10) {
return `Top ${(11 - min) * bucketSize} des acteurs les plus populaires.`;
}
if (endRank === null) {
return `A partir du rang ${startRank} en popularite.`;
}
return `Rangs ${startRank} a ${endRank} en popularite.`;
}
}

View file

@ -788,6 +788,128 @@ body {
margin: 10px 0 6px;
}
.config-range {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.config-range-label {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.config-range-value {
min-width: 28px;
padding: 3px 8px;
border-radius: 999px;
background: var(--surface-tint);
color: var(--orange);
font-size: 13px;
font-weight: 700;
text-align: center;
}
.config-range-input {
width: 100%;
accent-color: var(--orange);
}
.config-dual-range {
position: relative;
height: 32px;
display: flex;
align-items: center;
}
.config-dual-range-track,
.config-dual-range-fill {
position: absolute;
left: 0;
right: 0;
height: 6px;
border-radius: 999px;
}
.config-dual-range-track {
background: var(--border);
}
.config-dual-range-fill {
background: linear-gradient(90deg, var(--orange-light), var(--orange));
}
.config-dual-range .config-range-input {
position: absolute;
inset: 0;
margin: 0;
background: none;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
}
.config-dual-range .config-range-input::-webkit-slider-runnable-track {
height: 6px;
background: transparent;
}
.config-dual-range .config-range-input::-moz-range-track {
height: 6px;
background: transparent;
}
.config-dual-range .config-range-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
margin-top: -6px;
border: 2px solid var(--orange);
border-radius: 50%;
background: var(--surface);
box-shadow: 0 2px 8px var(--shadow-warm);
cursor: pointer;
pointer-events: auto;
}
.config-dual-range .config-range-input::-moz-range-thumb {
width: 18px;
height: 18px;
border: 2px solid var(--orange);
border-radius: 50%;
background: var(--surface);
box-shadow: 0 2px 8px var(--shadow-warm);
cursor: pointer;
pointer-events: auto;
}
.config-range-input-min {
z-index: 2;
}
.config-range-input-max {
z-index: 3;
}
.config-range-scale {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 11px;
color: var(--text-faint);
}
.config-help {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
}
/* Toggle switch */
.config-toggle {

View file

@ -16,6 +16,11 @@ use Symfony\Component\Routing\Attribute\Route;
class GameController extends AbstractController
{
private const int DEFAULT_MIN_POPULARITY_BUCKET = 1;
private const int DEFAULT_MAX_POPULARITY_BUCKET = 10;
private const int MIN_POPULARITY_BUCKET = 1;
private const int MAX_POPULARITY_BUCKET = 10;
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
public function start(
Request $request,
@ -50,6 +55,8 @@ class GameController extends AbstractController
$config['watchedOnly'] = true;
}
$config['popularityRange'] = $this->extractPopularityRange($request);
$hintTypes = [];
if ($request->request->getBoolean('hint_film', true)) {
$hintTypes[] = 'film';
@ -126,4 +133,36 @@ class GameController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
}
/**
* @return array{minBucket: int, maxBucket: int}
*/
private function extractPopularityRange(Request $request): array
{
$minBucket = $this->extractPopularityBucket($request->request->get('popularity_min_bucket'), self::DEFAULT_MIN_POPULARITY_BUCKET);
$maxBucket = $this->extractPopularityBucket($request->request->get('popularity_max_bucket'), self::DEFAULT_MAX_POPULARITY_BUCKET);
if ($minBucket > $maxBucket) {
[$minBucket, $maxBucket] = [$maxBucket, $minBucket];
}
return [
'minBucket' => $minBucket,
'maxBucket' => $maxBucket,
];
}
private function extractPopularityBucket(mixed $rawValue, int $default): int
{
if (!is_scalar($rawValue) || $rawValue === '') {
return $default;
}
$bucket = filter_var((string) $rawValue, FILTER_VALIDATE_INT);
if ($bucket === false) {
return $default;
}
return max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $bucket));
}
}

View file

@ -16,6 +16,9 @@ use Doctrine\ORM\EntityManagerInterface;
class GameGridProvider
{
private const int DEFAULT_MIN_POPULARITY_BUCKET = 1;
private const int DEFAULT_MAX_POPULARITY_BUCKET = 10;
public function __construct(
private readonly ActorRepository $actorRepository,
private readonly MovieRoleRepository $movieRoleRepository,
@ -25,16 +28,19 @@ class GameGridProvider
) {}
/**
* @param array{watchedOnly?: bool, hintTypes?: list<string>, awardTypeIds?: list<int>|null} $config
* @param array{watchedOnly?: bool, popularityRange?: array{minBucket?: int, maxBucket?: int}, hintTypes?: list<string>, awardTypeIds?: list<int>|null} $config
*/
public function generate(?User $user = null, array $config = []): ?Game
{
$watchedOnly = $config['watchedOnly'] ?? false;
$popularityRange = $config['popularityRange'] ?? [];
$minPopularityBucket = $popularityRange['minBucket'] ?? self::DEFAULT_MIN_POPULARITY_BUCKET;
$maxPopularityBucket = $popularityRange['maxBucket'] ?? self::DEFAULT_MAX_POPULARITY_BUCKET;
$hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award'];
$awardTypeIds = $config['awardTypeIds'] ?? null;
for ($attempt = 0; $attempt < 5; $attempt++) {
$game = $this->tryGenerate($user, $watchedOnly, $hintTypes, $awardTypeIds);
$game = $this->tryGenerate($user, $watchedOnly, (int) $minPopularityBucket, (int) $maxPopularityBucket, $hintTypes, $awardTypeIds);
if ($game !== null) {
return $game;
}
@ -47,12 +53,12 @@ class GameGridProvider
* @param list<string> $hintTypes
* @param list<int>|null $awardTypeIds
*/
private function tryGenerate(?User $user, bool $watchedOnly, array $hintTypes, ?array $awardTypeIds): ?Game
private function tryGenerate(?User $user, bool $watchedOnly, int $minPopularityBucket, int $maxPopularityBucket, array $hintTypes, ?array $awardTypeIds): ?Game
{
if ($watchedOnly && $user !== null) {
$mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4);
$mainActor = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket);
} else {
$mainActor = $this->actorRepository->findOneRandom(4);
$mainActor = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket);
}
if ($mainActor === null) {
@ -74,9 +80,9 @@ class GameGridProvider
$actor = null;
for ($try = 0; $try < 5; $try++) {
if ($watchedOnly && $user !== null) {
$candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char);
$candidate = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket, $char);
} else {
$candidate = $this->actorRepository->findOneRandom(4, $char);
$candidate = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket, $char);
}
if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {

View file

@ -13,19 +13,76 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class ActorRepository extends ServiceEntityRepository
{
private const int MIN_POPULARITY_BUCKET = 1;
private const int MAX_POPULARITY_BUCKET = 10;
private const int POPULARITY_BUCKET_SIZE = 20;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Actor::class);
}
public function findOneRandom(?float $popularity = null, ?string $char = null): Actor
public function findOneRandomByPopularityBucketRange(int $minBucket, int $maxBucket, ?string $char = null): ?Actor
{
$actorId = $this->findRandomActorIdByPopularityBucketRange(
<<<'SQL'
SELECT a.id, a.name, a.popularity
FROM actor a
WHERE a.popularity IS NOT NULL
SQL,
[],
$minBucket,
$maxBucket,
$char,
);
if ($actorId === null) {
return null;
}
$actor = $this->find($actorId);
return $actor instanceof Actor ? $actor : null;
}
public function findOneRandomInWatchedFilmsByPopularityBucketRange(User $user, int $minBucket, int $maxBucket, ?string $char = null): ?Actor
{
$actorId = $this->findRandomActorIdByPopularityBucketRange(
<<<'SQL'
SELECT DISTINCT a.id, a.name, a.popularity
FROM actor a
JOIN movie_role mr ON mr.actor_id = a.id
JOIN user_movie um ON um.movie_id = mr.movie_id AND um.user_id = :userId
WHERE a.popularity IS NOT NULL
SQL,
['userId' => $user->getId()],
$minBucket,
$maxBucket,
$char,
);
if ($actorId === null) {
return null;
}
$actor = $this->find($actorId);
return $actor instanceof Actor ? $actor : null;
}
public function findOneRandom(?float $minPopularity = null, ?float $maxPopularity = null, ?string $char = null): ?Actor
{
$qb = $this->createQueryBuilder('o');
$expr = $qb->expr();
if (!empty($popularity)) {
$qb->andWhere($expr->gte('o.popularity', ':popularity'))
->setParameter('popularity', $popularity);
if (null !== $minPopularity) {
$qb->andWhere($expr->gte('o.popularity', ':minPopularity'))
->setParameter('minPopularity', $minPopularity);
}
if (null !== $maxPopularity) {
$qb->andWhere($expr->lte('o.popularity', ':maxPopularity'))
->setParameter('maxPopularity', $maxPopularity);
}
if (!empty($char)) {
@ -41,7 +98,7 @@ class ActorRepository extends ServiceEntityRepository
;
}
public function findOneRandomInWatchedFilms(User $user, ?float $popularity = null, ?string $char = null): ?Actor
public function findOneRandomInWatchedFilms(User $user, ?float $minPopularity = null, ?float $maxPopularity = null, ?string $char = null): ?Actor
{
$qb = $this->createQueryBuilder('a')
->join('a.movieRoles', 'mr')
@ -49,9 +106,14 @@ class ActorRepository extends ServiceEntityRepository
->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 (null !== $minPopularity) {
$qb->andWhere('a.popularity >= :minPopularity')
->setParameter('minPopularity', $minPopularity);
}
if (null !== $maxPopularity) {
$qb->andWhere('a.popularity <= :maxPopularity')
->setParameter('maxPopularity', $maxPopularity);
}
if (!empty($char)) {
@ -65,4 +127,82 @@ class ActorRepository extends ServiceEntityRepository
->getQuery()
->getOneOrNullResult();
}
private function findRandomActorIdByPopularityBucketRange(
string $baseSql,
array $params,
int $minBucket,
int $maxBucket,
?string $char = null,
): ?int
{
[$minBucket, $maxBucket] = $this->normalizePopularityBuckets($minBucket, $maxBucket);
[$startRank, $endRank] = $this->resolvePopularityRankBounds($minBucket, $maxBucket);
$sql = <<<'SQL'
WITH ranked AS (
SELECT
base.id,
base.name,
ROW_NUMBER() OVER (ORDER BY base.popularity DESC, base.id DESC) AS popularity_rank
FROM (
SQL;
$sql .= "\n" . $baseSql . "\n";
$sql .= <<<'SQL'
) AS base
)
SELECT ranked.id
FROM ranked
WHERE ranked.popularity_rank >= :startRank
SQL;
$queryParams = [
...$params,
'startRank' => $startRank,
];
if ($endRank !== null) {
$sql .= "\nAND ranked.popularity_rank <= :endRank";
$queryParams['endRank'] = $endRank;
}
if ($char !== null && $char !== '') {
$sql .= "\nAND LOWER(ranked.name) LIKE LOWER(:name)";
$queryParams['name'] = '%' . $char . '%';
}
$sql .= "\nORDER BY RANDOM()\nLIMIT 1";
$result = $this->getEntityManager()->getConnection()->fetchOne($sql, $queryParams);
return $result === false ? null : (int) $result;
}
/**
* @return array{0: int, 1: int}
*/
private function normalizePopularityBuckets(int $minBucket, int $maxBucket): array
{
$minBucket = max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $minBucket));
$maxBucket = max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $maxBucket));
if ($minBucket > $maxBucket) {
[$minBucket, $maxBucket] = [$maxBucket, $minBucket];
}
return [$minBucket, $maxBucket];
}
/**
* @return array{0: int, 1: ?int}
*/
private function resolvePopularityRankBounds(int $minBucket, int $maxBucket): array
{
$startRank = ((self::MAX_POPULARITY_BUCKET - $maxBucket) * self::POPULARITY_BUCKET_SIZE) + 1;
$endRank = $minBucket === self::MIN_POPULARITY_BUCKET
? null
: ((self::MAX_POPULARITY_BUCKET - $minBucket + 1) * self::POPULARITY_BUCKET_SIZE);
return [$startRank, $endRank];
}
}

View file

@ -65,10 +65,55 @@
{% else %}
<div class="game-start-container">
<form method="post" action="{{ path('app_game_start') }}" id="start-form"
data-controller="game-config">
data-controller="game-config"
data-game-config-bucket-size-value="20">
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
<div class="config-panel">
<div class="config-section">
<div class="config-section-title">Acteurs</div>
<div class="config-range">
<span class="config-range-label">Tranche de popularité</span>
<span class="config-range-value" data-game-config-target="popularityValue">1-10</span>
</div>
<div class="config-dual-range">
<div class="config-dual-range-track"></div>
<div class="config-dual-range-fill" data-game-config-target="popularityFill"></div>
<input
id="min-popularity"
type="range"
name="popularity_min_bucket"
min="1"
max="10"
step="1"
value="1"
class="config-range-input config-range-input-min"
data-game-config-target="popularityMinInput"
data-action="input->game-config#syncPopularityRange"
>
<input
id="max-popularity"
type="range"
name="popularity_max_bucket"
min="1"
max="10"
step="1"
value="10"
class="config-range-input config-range-input-max"
data-game-config-target="popularityMaxInput"
data-action="input->game-config#syncPopularityRange"
>
</div>
<div class="config-range-scale" aria-hidden="true">
<span>1</span>
<span>5</span>
<span>10</span>
</div>
<p class="config-help" data-game-config-target="popularitySummary">
Tous les acteurs.
</p>
</div>
{% if app.user %}
<div class="config-section">
<label class="config-toggle">

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Controller\GameController;
use App\Entity\Game;
use App\Provider\GameGridProvider;
use App\Repository\GameRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class GameControllerTest extends TestCase
{
public function testStartPassesConfiguredPopularityRangeToGenerator(): void
{
$game = new Game();
$idProperty = new \ReflectionProperty(Game::class, 'id');
$idProperty->setValue($game, 42);
$generator = $this->createMock(GameGridProvider::class);
$generator
->expects($this->once())
->method('generate')
->with(
null,
$this->callback(function (array $config): bool {
$this->assertSame([
'minBucket' => 5,
'maxBucket' => 8,
], $config['popularityRange']);
$this->assertSame(['film', 'character', 'award'], $config['hintTypes']);
$this->assertArrayNotHasKey('watchedOnly', $config);
return true;
})
)
->willReturn($game);
$controller = new class extends GameController {
protected function isCsrfTokenValid(string $id, ?string $token): bool
{
return true;
}
public function getUser(): ?UserInterface
{
return null;
}
};
$controller->setContainer($this->createContainer());
$request = Request::create('/game/start', 'POST', [
'_token' => 'test-token',
'popularity_min_bucket' => '5',
'popularity_max_bucket' => '8',
]);
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->start(
$request,
$generator,
$this->createStub(GameRepository::class),
);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/', $response->getTargetUrl());
$this->assertSame(42, $request->getSession()->get('current_game_id'));
}
private function createContainer(): ContainerInterface
{
$router = $this->createStub(UrlGeneratorInterface::class);
$router->method('generate')->willReturn('/');
$container = $this->createStub(ContainerInterface::class);
$container->method('has')->willReturnCallback(static fn (string $id): bool => match ($id) {
'router' => true,
'security.token_storage' => false,
default => false,
});
$container->method('get')->willReturnCallback(static fn (string $id) => match ($id) {
'router' => $router,
default => null,
});
return $container;
}
}

View file

@ -8,6 +8,8 @@ use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Entity\GameRow;
use App\Entity\Movie;
use App\Entity\MovieRole;
use App\Repository\ActorRepository;
use App\Repository\AwardRepository;
use App\Repository\MovieRepository;
@ -80,4 +82,75 @@ class GameGridProviderTest extends TestCase
$this->assertNull($result);
}
public function testGenerateUsesConfiguredMinPopularity(): void
{
$mainActor = $this->createActorWithId(10, 'AB');
$firstRowActor = $this->createActorWithId(11, 'Anna');
$secondRowActor = $this->createActorWithId(12, 'Bob');
$calls = [];
$actors = [$mainActor, $firstRowActor, $secondRowActor];
$actorRepository = $this->createMock(ActorRepository::class);
$actorRepository
->method('findOneRandomByPopularityBucketRange')
->willReturnCallback(function (int $minBucket, int $maxBucket, ?string $char = null) use (&$calls, &$actors) {
$calls[] = [$minBucket, $maxBucket, $char];
return array_shift($actors);
});
$movieRoleRepository = $this->createMock(MovieRoleRepository::class);
$movieRoleRepository
->method('findOneRandomByActor')
->willReturnCallback(function (int $actorId): MovieRole {
$movie = new Movie();
$movie->setTitle('Film ' . $actorId);
$role = new MovieRole();
$role->setCharacter('Character ' . $actorId);
$role->setMovie($movie);
return $role;
});
$em = $this->createMock(EntityManagerInterface::class);
$em->expects($this->once())->method('persist');
$em->expects($this->once())->method('flush');
$generator = new GameGridProvider(
$actorRepository,
$movieRoleRepository,
$this->createMock(MovieRepository::class),
$this->createMock(AwardRepository::class),
$em,
);
$game = $generator->generate(config: [
'popularityRange' => [
'minBucket' => 5,
'maxBucket' => 10,
],
'hintTypes' => ['film'],
]);
$this->assertNotNull($game);
$this->assertSame([
[5, 10, null],
[5, 10, 'a'],
[5, 10, 'b'],
], $calls);
}
private function createActorWithId(int $id, string $name): Actor
{
$actor = new Actor();
$actor->setName($name);
$idProperty = new \ReflectionProperty(Actor::class, 'id');
$idProperty->setValue($actor, $id);
return $actor;
}
}

View file

@ -74,7 +74,7 @@ class ActorRepositoryTest extends KernelTestCase
$this->em->flush();
for ($i = 0; $i < 10; $i++) {
$result = $this->repo->findOneRandomInWatchedFilms($user, 0, 'w');
$result = $this->repo->findOneRandomInWatchedFilms($user, null, null, 'w');
$this->assertNotNull($result);
$this->assertSame($watchedActor->getId(), $result->getId());
}