1250 lines
34 KiB
Markdown
1250 lines
34 KiB
Markdown
|
|
# Victory Condition 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:** When all highlighted cells are filled and the main actor name is correct, mark the game won, reveal all rows with colour coding, and display a victory card with the actor's photo.
|
||
|
|
|
||
|
|
**Architecture:** React detects when all highlighted cells (one per non-separator row) are filled, calls `POST /api/game/{id}/check` with the submitted letters, and on a `won: true` response applies a reveal state: highlighted cells turn green, other cells show the correct actor name (red where the player was wrong), all inputs disabled, and a `VictoryCard` renders above the grid.
|
||
|
|
|
||
|
|
**Tech Stack:** Symfony 7 / PHP 8.4 / Doctrine ORM / PHPUnit / React 18 / functional components with hooks
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File map
|
||
|
|
|
||
|
|
| File | Action |
|
||
|
|
|---|---|
|
||
|
|
| `src/Entity/Actor.php` | Add `profilePath` property + getter/setter |
|
||
|
|
| `migrations/Version20260403000001.php` | Add `profile_path` column to `actor` table |
|
||
|
|
| `src/Entity/Game.php` | Add `STATUS_WON` + `win()` method |
|
||
|
|
| `tests/Entity/GameTest.php` | Unit test for `win()` |
|
||
|
|
| `src/Model/TMDB/TMDBMovieCredit.php` | Add `profile_path` field |
|
||
|
|
| `src/Model/TMDB/TMDBPerson.php` | New model for `/person/{id}` response |
|
||
|
|
| `src/Gateway/TMDBGateway.php` | Add `getPersonDetails()` method |
|
||
|
|
| `tests/Gateway/TMDBGatewayTest.php` | Unit test for `getPersonDetails()` |
|
||
|
|
| `src/Import/ActorSyncer.php` | Populate `profilePath` from credit |
|
||
|
|
| `tests/Import/ActorSyncerTest.php` | Unit test for profilePath population |
|
||
|
|
| `src/Repository/ActorRepository.php` | Add `findWithTmdbIdAndNoProfilePath()` |
|
||
|
|
| `src/Command/BackfillActorProfilePathCommand.php` | Backfill command for existing actors |
|
||
|
|
| `src/Controller/Api/GameCheckController.php` | `POST /api/game/{id}/check` endpoint |
|
||
|
|
| `templates/homepage/index.html.twig` | Pass `gameId` to `GameGrid` React props |
|
||
|
|
| `assets/react/controllers/LetterInput.jsx` | Controlled input; add `value`, `onChange`, `disabled`, `colorClass` props |
|
||
|
|
| `assets/react/controllers/GameRow.jsx` | Lift state; compute reveal classes per cell |
|
||
|
|
| `assets/react/controllers/GameGrid.jsx` | Hold all state; victory detection; API call; reveal logic |
|
||
|
|
| `assets/react/controllers/VictoryCard.jsx` | New component: photo + name + new-game button |
|
||
|
|
| `assets/styles/app.css` | Add `.letter-correct`, `.letter-wrong`, `.victory-card` styles |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Add `profilePath` to Actor entity
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/Entity/Actor.php`
|
||
|
|
- Create: `migrations/Version20260403000001.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add property, getter and setter to Actor**
|
||
|
|
|
||
|
|
In `src/Entity/Actor.php`, after the `$tmdbId` column declaration, add:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[ORM\Column(length: 255, nullable: true)]
|
||
|
|
private ?string $profilePath = null;
|
||
|
|
```
|
||
|
|
|
||
|
|
After the `setTmdbId()` method, add:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function getProfilePath(): ?string
|
||
|
|
{
|
||
|
|
return $this->profilePath;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function setProfilePath(?string $profilePath): static
|
||
|
|
{
|
||
|
|
$this->profilePath = $profilePath;
|
||
|
|
|
||
|
|
return $this;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the migration**
|
||
|
|
|
||
|
|
Create `migrations/Version20260403000001.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace DoctrineMigrations;
|
||
|
|
|
||
|
|
use Doctrine\DBAL\Schema\Schema;
|
||
|
|
use Doctrine\Migrations\AbstractMigration;
|
||
|
|
|
||
|
|
final class Version20260403000001 extends AbstractMigration
|
||
|
|
{
|
||
|
|
public function getDescription(): string
|
||
|
|
{
|
||
|
|
return 'Add profile_path column to actor table';
|
||
|
|
}
|
||
|
|
|
||
|
|
public function up(Schema $schema): void
|
||
|
|
{
|
||
|
|
$this->addSql('ALTER TABLE actor ADD profile_path VARCHAR(255) DEFAULT NULL');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function down(Schema $schema): void
|
||
|
|
{
|
||
|
|
$this->addSql('ALTER TABLE actor DROP COLUMN profile_path');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run the migration**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make migrate
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: `[OK] Successfully executed 1 migrations.`
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Entity/Actor.php migrations/Version20260403000001.php
|
||
|
|
git commit -m "feat: add profile_path column to actor"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: Add `STATUS_WON` and `win()` to Game entity
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/Entity/Game.php`
|
||
|
|
- Create: `tests/Entity/GameTest.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
Create `tests/Entity/GameTest.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Tests\Entity;
|
||
|
|
|
||
|
|
use App\Entity\Game;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
|
||
|
|
class GameTest extends TestCase
|
||
|
|
{
|
||
|
|
public function testWinSetsStatusAndEndedAt(): void
|
||
|
|
{
|
||
|
|
$game = new Game();
|
||
|
|
$before = new \DateTimeImmutable();
|
||
|
|
|
||
|
|
$game->win();
|
||
|
|
|
||
|
|
$after = new \DateTimeImmutable();
|
||
|
|
|
||
|
|
$this->assertSame(Game::STATUS_WON, $game->getStatus());
|
||
|
|
$this->assertNotNull($game->getEndedAt());
|
||
|
|
$this->assertGreaterThanOrEqual($before->getTimestamp(), $game->getEndedAt()->getTimestamp());
|
||
|
|
$this->assertLessThanOrEqual($after->getTimestamp(), $game->getEndedAt()->getTimestamp());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: FAIL — `Call to undefined method App\Entity\Game::win()`
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add STATUS_WON and win() to Game**
|
||
|
|
|
||
|
|
In `src/Entity/Game.php`, add the constant alongside the existing ones:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public const string STATUS_WON = 'won';
|
||
|
|
```
|
||
|
|
|
||
|
|
After the `abandon()` method, add:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function win(): static
|
||
|
|
{
|
||
|
|
$this->status = self::STATUS_WON;
|
||
|
|
$this->endedAt = new \DateTimeImmutable();
|
||
|
|
|
||
|
|
return $this;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run test to verify it passes**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Entity/Game.php tests/Entity/GameTest.php
|
||
|
|
git commit -m "feat: add STATUS_WON and win() to Game entity"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: TMDBPerson model + `getPersonDetails()` gateway method
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/Model/TMDB/TMDBMovieCredit.php`
|
||
|
|
- Create: `src/Model/TMDB/TMDBPerson.php`
|
||
|
|
- Modify: `src/Gateway/TMDBGateway.php`
|
||
|
|
- Create: `tests/Gateway/TMDBGatewayTest.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
Create `tests/Gateway/TMDBGatewayTest.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Tests\Gateway;
|
||
|
|
|
||
|
|
use App\Gateway\TMDBGateway;
|
||
|
|
use App\Model\TMDB\TMDBPerson;
|
||
|
|
use App\Exception\GatewayException;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
use Symfony\Component\Serializer\SerializerInterface;
|
||
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||
|
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||
|
|
|
||
|
|
class TMDBGatewayTest extends TestCase
|
||
|
|
{
|
||
|
|
public function testGetPersonDetailsReturnsPerson(): void
|
||
|
|
{
|
||
|
|
$person = new TMDBPerson(1136406, 'Tom Holland', '/abc123.jpg');
|
||
|
|
|
||
|
|
$response = $this->createMock(ResponseInterface::class);
|
||
|
|
$response->method('getContent')->willReturn('{}');
|
||
|
|
|
||
|
|
$httpClient = $this->createMock(HttpClientInterface::class);
|
||
|
|
$httpClient->method('request')
|
||
|
|
->with('GET', 'https://api.themoviedb.org/3/person/1136406')
|
||
|
|
->willReturn($response);
|
||
|
|
|
||
|
|
$serializer = $this->createMock(SerializerInterface::class);
|
||
|
|
$serializer->method('deserialize')
|
||
|
|
->with('{}', TMDBPerson::class, 'json')
|
||
|
|
->willReturn($person);
|
||
|
|
|
||
|
|
$gateway = new TMDBGateway($httpClient, $serializer, 'fake-token', 'https://api.themoviedb.org/3');
|
||
|
|
$result = $gateway->getPersonDetails(1136406);
|
||
|
|
|
||
|
|
$this->assertInstanceOf(TMDBPerson::class, $result);
|
||
|
|
$this->assertSame(1136406, $result->id);
|
||
|
|
$this->assertSame('/abc123.jpg', $result->profile_path);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testGetPersonDetailsReturnsNullOnGatewayException(): void
|
||
|
|
{
|
||
|
|
$httpClient = $this->createMock(HttpClientInterface::class);
|
||
|
|
$httpClient->method('request')->willThrowException(new \RuntimeException('network error'));
|
||
|
|
|
||
|
|
$gateway = new TMDBGateway(
|
||
|
|
$httpClient,
|
||
|
|
$this->createMock(SerializerInterface::class),
|
||
|
|
'fake-token',
|
||
|
|
'https://api.themoviedb.org/3'
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $gateway->getPersonDetails(1136406);
|
||
|
|
|
||
|
|
$this->assertNull($result);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: FAIL — `TMDBPerson` class not found
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create TMDBPerson model**
|
||
|
|
|
||
|
|
Create `src/Model/TMDB/TMDBPerson.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Model\TMDB;
|
||
|
|
|
||
|
|
class TMDBPerson
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
public int $id { get => $this->id; },
|
||
|
|
public string $name { get => $this->name; },
|
||
|
|
public ?string $profile_path { get => $this->profile_path; },
|
||
|
|
) {}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add `getPersonDetails()` to TMDBGateway**
|
||
|
|
|
||
|
|
In `src/Gateway/TMDBGateway.php`, add the constant with the others:
|
||
|
|
|
||
|
|
```php
|
||
|
|
private const string PERSON_URI = '/person/{id}';
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the method after `getMovieCredits()`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
/**
|
||
|
|
* @throws GatewayException
|
||
|
|
*/
|
||
|
|
public function getPersonDetails(int $personId): ?TMDBPerson
|
||
|
|
{
|
||
|
|
$url = $this->host . str_replace('{id}', (string) $personId, self::PERSON_URI);
|
||
|
|
try {
|
||
|
|
return $this->fetchSerialized('GET', $url, TMDBPerson::class);
|
||
|
|
} catch (GatewayException) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the import at the top of the file:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use App\Model\TMDB\TMDBPerson;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run test to verify it passes**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Model/TMDB/TMDBPerson.php src/Gateway/TMDBGateway.php tests/Gateway/TMDBGatewayTest.php
|
||
|
|
git commit -m "feat: add TMDBPerson model and getPersonDetails() to TMDBGateway"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: Populate `profilePath` during actor import
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/Model/TMDB/TMDBMovieCredit.php`
|
||
|
|
- Modify: `src/Import/ActorSyncer.php`
|
||
|
|
- Create: `tests/Import/ActorSyncerTest.php`
|
||
|
|
|
||
|
|
The TMDB movie credits response includes `profile_path` for each cast member. Adding it to `TMDBMovieCredit` avoids an extra API call per actor during import.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
Create `tests/Import/ActorSyncerTest.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Tests\Import;
|
||
|
|
|
||
|
|
use App\Entity\Actor;
|
||
|
|
use App\Entity\Movie;
|
||
|
|
use App\Gateway\TMDBGateway;
|
||
|
|
use App\Import\ActorSyncer;
|
||
|
|
use App\Model\TMDB\TMDBMovieCredit;
|
||
|
|
use App\Context\TMDB\MovieCreditsContext;
|
||
|
|
use Doctrine\ORM\EntityManagerInterface;
|
||
|
|
use Doctrine\ORM\EntityRepository;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
|
||
|
|
class ActorSyncerTest extends TestCase
|
||
|
|
{
|
||
|
|
public function testSyncSetsProfilePathOnNewActor(): void
|
||
|
|
{
|
||
|
|
$credit = new TMDBMovieCredit(42, 'Tom Holland', 9.5, 'Spider-Man', '/abc123.jpg');
|
||
|
|
$context = new MovieCreditsContext([$credit]);
|
||
|
|
|
||
|
|
$gateway = $this->createMock(TMDBGateway::class);
|
||
|
|
$gateway->method('getMovieCredits')->willReturn($context);
|
||
|
|
|
||
|
|
$actorRepo = $this->createMock(EntityRepository::class);
|
||
|
|
$actorRepo->method('findOneBy')->with(['tmdbId' => 42])->willReturn(null);
|
||
|
|
|
||
|
|
$roleRepo = $this->createMock(EntityRepository::class);
|
||
|
|
$roleRepo->method('count')->willReturn(0);
|
||
|
|
|
||
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
||
|
|
$em->method('getRepository')->willReturnMap([
|
||
|
|
[Actor::class, $actorRepo],
|
||
|
|
[\App\Entity\MovieRole::class, $roleRepo],
|
||
|
|
]);
|
||
|
|
|
||
|
|
$persisted = [];
|
||
|
|
$em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||
|
|
$persisted[] = $entity;
|
||
|
|
});
|
||
|
|
|
||
|
|
$syncer = new ActorSyncer($gateway, $em);
|
||
|
|
|
||
|
|
$movie = new Movie();
|
||
|
|
$movie->setTmdbId(999);
|
||
|
|
$movie->setTitle('A Film');
|
||
|
|
$movie->setLtbxdRef('a-film');
|
||
|
|
$syncer->syncActorsForMovie($movie);
|
||
|
|
|
||
|
|
$actor = array_values(array_filter($persisted, fn($e) => $e instanceof Actor))[0];
|
||
|
|
$this->assertSame('/abc123.jpg', $actor->getProfilePath());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: FAIL — `TMDBMovieCredit` constructor does not accept a 5th argument
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add `profile_path` to TMDBMovieCredit**
|
||
|
|
|
||
|
|
In `src/Model/TMDB/TMDBMovieCredit.php`, add the field:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Model\TMDB;
|
||
|
|
|
||
|
|
class TMDBMovieCredit
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
public int $id { get => $this->id; },
|
||
|
|
public string $name { get => $this->name; },
|
||
|
|
public float $popularity { get => $this->popularity; },
|
||
|
|
public string $character { get => $this->character; },
|
||
|
|
public ?string $profile_path { get => $this->profile_path; } = null,
|
||
|
|
) {}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Populate profilePath in ActorSyncer**
|
||
|
|
|
||
|
|
In `src/Import/ActorSyncer.php`, update the actor creation block inside `syncActorsForMovie()`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
if (!$actor instanceof Actor) {
|
||
|
|
$actor = new Actor()
|
||
|
|
->setPopularity($actorModel->popularity)
|
||
|
|
->setName($actorModel->name)
|
||
|
|
->setTmdbId($actorModel->id)
|
||
|
|
->setProfilePath($actorModel->profile_path);
|
||
|
|
|
||
|
|
$this->em->persist($actor);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run test to verify it passes**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Model/TMDB/TMDBMovieCredit.php src/Import/ActorSyncer.php tests/Import/ActorSyncerTest.php
|
||
|
|
git commit -m "feat: populate actor profilePath from TMDB credits during import"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: Backfill command for existing actors
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/Repository/ActorRepository.php`
|
||
|
|
- Create: `src/Command/BackfillActorProfilePathCommand.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add `findWithTmdbIdAndNoProfilePath()` to ActorRepository**
|
||
|
|
|
||
|
|
In `src/Repository/ActorRepository.php`, add the method:
|
||
|
|
|
||
|
|
```php
|
||
|
|
/**
|
||
|
|
* @return Actor[]
|
||
|
|
*/
|
||
|
|
public function findWithTmdbIdAndNoProfilePath(): array
|
||
|
|
{
|
||
|
|
return $this->createQueryBuilder('a')
|
||
|
|
->where('a.tmdbId IS NOT NULL')
|
||
|
|
->andWhere('a.profilePath IS NULL')
|
||
|
|
->getQuery()
|
||
|
|
->getResult();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the backfill command**
|
||
|
|
|
||
|
|
Create `src/Command/BackfillActorProfilePathCommand.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Command;
|
||
|
|
|
||
|
|
use App\Gateway\TMDBGateway;
|
||
|
|
use App\Repository\ActorRepository;
|
||
|
|
use Doctrine\ORM\EntityManagerInterface;
|
||
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||
|
|
use Symfony\Component\Console\Command\Command;
|
||
|
|
use Symfony\Component\Console\Input\InputInterface;
|
||
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
||
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||
|
|
|
||
|
|
#[AsCommand(
|
||
|
|
name: 'app:actor:backfill-profile-path',
|
||
|
|
description: 'Fetch and store TMDB profile_path for actors that are missing it',
|
||
|
|
)]
|
||
|
|
class BackfillActorProfilePathCommand extends Command
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly TMDBGateway $tmdbGateway,
|
||
|
|
private readonly ActorRepository $actorRepository,
|
||
|
|
private readonly EntityManagerInterface $em,
|
||
|
|
) {
|
||
|
|
parent::__construct();
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||
|
|
{
|
||
|
|
$io = new SymfonyStyle($input, $output);
|
||
|
|
$actors = $this->actorRepository->findWithTmdbIdAndNoProfilePath();
|
||
|
|
|
||
|
|
if (empty($actors)) {
|
||
|
|
$io->success('No actors to backfill.');
|
||
|
|
return Command::SUCCESS;
|
||
|
|
}
|
||
|
|
|
||
|
|
$io->progressStart(count($actors));
|
||
|
|
$updated = 0;
|
||
|
|
|
||
|
|
foreach ($actors as $actor) {
|
||
|
|
try {
|
||
|
|
$person = $this->tmdbGateway->getPersonDetails($actor->getTmdbId());
|
||
|
|
if ($person !== null && $person->profile_path !== null) {
|
||
|
|
$actor->setProfilePath($person->profile_path);
|
||
|
|
++$updated;
|
||
|
|
}
|
||
|
|
} catch (\Throwable) {
|
||
|
|
// Skip actors that fail — TMDB may throttle or the person may not exist
|
||
|
|
}
|
||
|
|
$io->progressAdvance();
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->em->flush();
|
||
|
|
$io->progressFinish();
|
||
|
|
$io->success(sprintf('Updated %d/%d actors.', $updated, count($actors)));
|
||
|
|
|
||
|
|
return Command::SUCCESS;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run tests to confirm nothing broke**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: all PASS
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Repository/ActorRepository.php src/Command/BackfillActorProfilePathCommand.php
|
||
|
|
git commit -m "feat: add backfill command for actor profile_path"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: `POST /api/game/{id}/check` endpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Controller/Api/GameCheckController.php`
|
||
|
|
|
||
|
|
The endpoint receives the submitted highlighted letters, compares them case-insensitively to the main actor's name alphabetic characters in order, marks the game as won if they match, and returns the full reveal data.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the controller**
|
||
|
|
|
||
|
|
Create `src/Controller/Api/GameCheckController.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Controller\Api;
|
||
|
|
|
||
|
|
use App\Entity\Game;
|
||
|
|
use App\Entity\User;
|
||
|
|
use Doctrine\ORM\EntityManagerInterface;
|
||
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
|
|
use Symfony\Component\HttpFoundation\Request;
|
||
|
|
use Symfony\Component\Routing\Attribute\Route;
|
||
|
|
|
||
|
|
class GameCheckController extends AbstractController
|
||
|
|
{
|
||
|
|
#[Route('/api/game/{id}/check', name: 'api_game_check', methods: ['POST'])]
|
||
|
|
public function check(
|
||
|
|
Game $game,
|
||
|
|
Request $request,
|
||
|
|
EntityManagerInterface $em,
|
||
|
|
): JsonResponse {
|
||
|
|
/** @var User|null $user */
|
||
|
|
$user = $this->getUser();
|
||
|
|
|
||
|
|
// Ownership check
|
||
|
|
if ($user) {
|
||
|
|
if ($game->getUser() !== $user) {
|
||
|
|
return $this->json(['error' => 'Forbidden'], 403);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
$sessionGameId = $request->getSession()->get('current_game_id');
|
||
|
|
if ($game->getId() !== $sessionGameId) {
|
||
|
|
return $this->json(['error' => 'Forbidden'], 403);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($game->getStatus() !== Game::STATUS_IN_PROGRESS) {
|
||
|
|
return $this->json(['error' => 'Game is not in progress'], 400);
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = json_decode($request->getContent(), true);
|
||
|
|
/** @var list<string> $submittedLetters */
|
||
|
|
$submittedLetters = $data['letters'] ?? [];
|
||
|
|
|
||
|
|
$mainActorName = $game->getMainActor()->getName();
|
||
|
|
$alphaChars = array_values(
|
||
|
|
array_filter(str_split($mainActorName), fn(string $c) => ctype_alpha($c))
|
||
|
|
);
|
||
|
|
|
||
|
|
if (count($submittedLetters) !== count($alphaChars)) {
|
||
|
|
return $this->json(['error' => 'Invalid letter count'], 400);
|
||
|
|
}
|
||
|
|
|
||
|
|
$won = true;
|
||
|
|
foreach ($alphaChars as $i => $char) {
|
||
|
|
if (strtoupper($submittedLetters[$i]) !== strtoupper($char)) {
|
||
|
|
$won = false;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$won) {
|
||
|
|
return $this->json(['won' => false]);
|
||
|
|
}
|
||
|
|
|
||
|
|
$game->win();
|
||
|
|
$em->flush();
|
||
|
|
|
||
|
|
$mainActor = $game->getMainActor();
|
||
|
|
$profilePath = $mainActor->getProfilePath();
|
||
|
|
$actorPhotoUrl = $profilePath !== null
|
||
|
|
? 'https://image.tmdb.org/t/p/w500' . $profilePath
|
||
|
|
: null;
|
||
|
|
|
||
|
|
$rows = array_map(
|
||
|
|
fn($row) => [
|
||
|
|
'actorName' => $row->getActor()->getName(),
|
||
|
|
'letters' => str_split($row->getActor()->getName()),
|
||
|
|
],
|
||
|
|
$game->getRows()->toArray()
|
||
|
|
);
|
||
|
|
|
||
|
|
return $this->json([
|
||
|
|
'won' => true,
|
||
|
|
'actorName' => $mainActor->getName(),
|
||
|
|
'actorPhotoUrl' => $actorPhotoUrl,
|
||
|
|
'rows' => $rows,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to confirm nothing broke**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: all PASS
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Controller/Api/GameCheckController.php
|
||
|
|
git commit -m "feat: add POST /api/game/{id}/check endpoint"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 7: Pass `gameId` from Twig to React
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `templates/homepage/index.html.twig`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add gameId to react_component props**
|
||
|
|
|
||
|
|
In `templates/homepage/index.html.twig`, find:
|
||
|
|
|
||
|
|
```twig
|
||
|
|
<div {{ react_component('GameGrid', {
|
||
|
|
grid: grid,
|
||
|
|
width: width,
|
||
|
|
middle: middle,
|
||
|
|
}) }}></div>
|
||
|
|
```
|
||
|
|
|
||
|
|
Replace with:
|
||
|
|
|
||
|
|
```twig
|
||
|
|
<div {{ react_component('GameGrid', {
|
||
|
|
grid: grid,
|
||
|
|
width: width,
|
||
|
|
middle: middle,
|
||
|
|
gameId: game.id,
|
||
|
|
}) }}></div>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add templates/homepage/index.html.twig
|
||
|
|
git commit -m "feat: pass gameId to GameGrid React component"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 8: Lift state in React components
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `assets/react/controllers/LetterInput.jsx`
|
||
|
|
- Modify: `assets/react/controllers/GameRow.jsx`
|
||
|
|
|
||
|
|
`LetterInput` becomes fully controlled: it receives its `value`, reports changes via `onChange`, accepts a `disabled` prop, and renders a CSS `colorClass` on the `<td>`.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Rewrite LetterInput**
|
||
|
|
|
||
|
|
Replace the entire content of `assets/react/controllers/LetterInput.jsx`:
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
import React, { useCallback } from 'react';
|
||
|
|
|
||
|
|
export default function LetterInput({ highlighted, onNext, onPrev, inputRef, value, onChange, disabled, colorClass }) {
|
||
|
|
const handleKeyUp = useCallback((e) => {
|
||
|
|
if (disabled) return;
|
||
|
|
if (e.key === 'Backspace') {
|
||
|
|
onChange('');
|
||
|
|
onPrev?.();
|
||
|
|
} else if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) {
|
||
|
|
onChange(e.key.toUpperCase());
|
||
|
|
onNext?.();
|
||
|
|
}
|
||
|
|
}, [disabled, onChange, onNext, onPrev]);
|
||
|
|
|
||
|
|
const classes = [
|
||
|
|
'letter-input',
|
||
|
|
highlighted ? 'letter-highlighted' : '',
|
||
|
|
colorClass || '',
|
||
|
|
].filter(Boolean).join(' ');
|
||
|
|
|
||
|
|
return (
|
||
|
|
<td>
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
type="text"
|
||
|
|
maxLength={1}
|
||
|
|
value={value}
|
||
|
|
onChange={() => {}}
|
||
|
|
disabled={disabled}
|
||
|
|
className={classes}
|
||
|
|
onKeyUp={handleKeyUp}
|
||
|
|
autoComplete="off"
|
||
|
|
/>
|
||
|
|
</td>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Rewrite GameRow to accept lifted-state props**
|
||
|
|
|
||
|
|
Replace the entire content of `assets/react/controllers/GameRow.jsx`:
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
import React, { useRef, useCallback, useMemo } from 'react';
|
||
|
|
import LetterInput from './LetterInput';
|
||
|
|
import ActorPopover from './ActorPopover';
|
||
|
|
|
||
|
|
function isLetter(ch) {
|
||
|
|
return /[a-zA-Z]/.test(ch);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function GameRow({
|
||
|
|
actorName,
|
||
|
|
pos,
|
||
|
|
colStart,
|
||
|
|
totalWidth,
|
||
|
|
hintType,
|
||
|
|
hintText,
|
||
|
|
playerLetters,
|
||
|
|
onLetterChange,
|
||
|
|
disabled,
|
||
|
|
revealedLetters,
|
||
|
|
}) {
|
||
|
|
const inputRefs = useRef([]);
|
||
|
|
const letters = actorName.split('');
|
||
|
|
|
||
|
|
const letterIndices = useMemo(
|
||
|
|
() => letters.reduce((acc, ch, i) => { if (isLetter(ch)) acc.push(i); return acc; }, []),
|
||
|
|
[actorName]
|
||
|
|
);
|
||
|
|
|
||
|
|
const setInputRef = useCallback((index) => (el) => {
|
||
|
|
inputRefs.current[index] = el;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const focusNextInput = useCallback((charIndex, direction) => {
|
||
|
|
const currentPos = letterIndices.indexOf(charIndex);
|
||
|
|
const nextPos = currentPos + direction;
|
||
|
|
if (nextPos >= 0 && nextPos < letterIndices.length) {
|
||
|
|
inputRefs.current[letterIndices[nextPos]]?.focus();
|
||
|
|
}
|
||
|
|
}, [letterIndices]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<tr>
|
||
|
|
<td className="hint-cell">
|
||
|
|
<ActorPopover hintType={hintType} hintText={hintText} />
|
||
|
|
</td>
|
||
|
|
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
|
||
|
|
const charIndex = colIndex - colStart;
|
||
|
|
const isInRange = charIndex >= 0 && charIndex < letters.length;
|
||
|
|
|
||
|
|
if (!isInRange) {
|
||
|
|
return <td key={colIndex} />;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ch = letters[charIndex];
|
||
|
|
|
||
|
|
if (!isLetter(ch)) {
|
||
|
|
return (
|
||
|
|
<td key={colIndex} className="letter-static">
|
||
|
|
{ch}
|
||
|
|
</td>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const isHighlighted = charIndex === pos;
|
||
|
|
const playerValue = playerLetters?.[charIndex] || '';
|
||
|
|
|
||
|
|
let displayValue = playerValue;
|
||
|
|
let colorClass = '';
|
||
|
|
|
||
|
|
if (revealedLetters) {
|
||
|
|
const correctChar = revealedLetters[charIndex] || '';
|
||
|
|
if (isHighlighted) {
|
||
|
|
// Highlighted cells are always correct on win (victory condition)
|
||
|
|
colorClass = 'letter-correct';
|
||
|
|
displayValue = correctChar;
|
||
|
|
} else {
|
||
|
|
displayValue = correctChar;
|
||
|
|
if (playerValue !== '' && playerValue.toUpperCase() !== correctChar.toUpperCase()) {
|
||
|
|
colorClass = 'letter-wrong';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<LetterInput
|
||
|
|
key={colIndex}
|
||
|
|
highlighted={isHighlighted}
|
||
|
|
inputRef={setInputRef(charIndex)}
|
||
|
|
onNext={() => focusNextInput(charIndex, 1)}
|
||
|
|
onPrev={() => focusNextInput(charIndex, -1)}
|
||
|
|
value={displayValue}
|
||
|
|
onChange={(val) => onLetterChange(charIndex, val)}
|
||
|
|
disabled={disabled}
|
||
|
|
colorClass={colorClass}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</tr>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run the dev server to verify no crash**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# In your local dev environment:
|
||
|
|
# Open the game page and verify the grid still renders and you can type letters
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add assets/react/controllers/LetterInput.jsx assets/react/controllers/GameRow.jsx
|
||
|
|
git commit -m "feat: lift letter state in LetterInput and GameRow"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 9: Victory detection and reveal in GameGrid
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `assets/react/controllers/GameGrid.jsx`
|
||
|
|
|
||
|
|
`GameGrid` holds all letter state, detects when every highlighted cell is filled, calls the check API, and on win applies the reveal state.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Rewrite GameGrid**
|
||
|
|
|
||
|
|
Replace the entire content of `assets/react/controllers/GameGrid.jsx`:
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
|
|
import GameRow from './GameRow';
|
||
|
|
import ActorPopover from './ActorPopover';
|
||
|
|
import VictoryCard from './VictoryCard';
|
||
|
|
|
||
|
|
export default function GameGrid({ grid, width, middle, gameId }) {
|
||
|
|
// letters[gridIndex][charIndex] = typed value
|
||
|
|
const [letters, setLetters] = useState(() =>
|
||
|
|
Object.fromEntries(grid.map((_, i) => [i, {}]))
|
||
|
|
);
|
||
|
|
// 'playing' | 'checking' | 'won'
|
||
|
|
const [gameState, setGameState] = useState('playing');
|
||
|
|
const [wonData, setWonData] = useState(null);
|
||
|
|
|
||
|
|
// Map grid indices of actor rows to their corresponding wonData.rows index
|
||
|
|
const wonRowsByGridIndex = useMemo(() => {
|
||
|
|
if (!wonData) return {};
|
||
|
|
const result = {};
|
||
|
|
let wonIdx = 0;
|
||
|
|
grid.forEach((row, gridIndex) => {
|
||
|
|
if (row.separator === undefined) {
|
||
|
|
result[gridIndex] = wonData.rows[wonIdx++];
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}, [wonData, grid]);
|
||
|
|
|
||
|
|
const handleLetterChange = useCallback((gridIndex, charIndex, value) => {
|
||
|
|
setLetters(prev => ({
|
||
|
|
...prev,
|
||
|
|
[gridIndex]: { ...prev[gridIndex], [charIndex]: value },
|
||
|
|
}));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Trigger check when all highlighted cells are filled
|
||
|
|
useEffect(() => {
|
||
|
|
if (gameState !== 'playing') return;
|
||
|
|
|
||
|
|
const actorRows = grid
|
||
|
|
.map((row, i) => ({ row, i }))
|
||
|
|
.filter(({ row }) => row.separator === undefined);
|
||
|
|
|
||
|
|
const allFilled = actorRows.every(({ row, i }) =>
|
||
|
|
(letters[i]?.[row.pos] || '').length > 0
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!allFilled) return;
|
||
|
|
|
||
|
|
const highlightedLetters = actorRows.map(({ row, i }) => letters[i]?.[row.pos] || '');
|
||
|
|
|
||
|
|
setGameState('checking');
|
||
|
|
|
||
|
|
fetch(`/api/game/${gameId}/check`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ letters: highlightedLetters }),
|
||
|
|
})
|
||
|
|
.then(r => r.json())
|
||
|
|
.then(data => {
|
||
|
|
if (data.won) {
|
||
|
|
setWonData(data);
|
||
|
|
setGameState('won');
|
||
|
|
} else {
|
||
|
|
setGameState('playing');
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.catch(() => setGameState('playing'));
|
||
|
|
}, [letters, gameState, grid, gameId]);
|
||
|
|
|
||
|
|
const isWon = gameState === 'won';
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="game-grid-scroll">
|
||
|
|
{isWon && wonData && (
|
||
|
|
<VictoryCard
|
||
|
|
actorName={wonData.actorName}
|
||
|
|
actorPhotoUrl={wonData.actorPhotoUrl}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<table id="actors">
|
||
|
|
<tbody>
|
||
|
|
{grid.map((row, gridIndex) => {
|
||
|
|
if (row.separator !== undefined) {
|
||
|
|
return (
|
||
|
|
<tr key={gridIndex} className="separator-row">
|
||
|
|
<td className="hint-cell" />
|
||
|
|
{Array.from({ length: middle }, (_, i) => (
|
||
|
|
<td key={i} />
|
||
|
|
))}
|
||
|
|
<td className="letter-static separator-char">
|
||
|
|
{row.separator === ' ' ? '' : row.separator}
|
||
|
|
</td>
|
||
|
|
{Array.from({ length: width - middle }, (_, i) => (
|
||
|
|
<td key={middle + 1 + i} />
|
||
|
|
))}
|
||
|
|
</tr>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const wonRow = wonRowsByGridIndex[gridIndex] ?? null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<GameRow
|
||
|
|
key={gridIndex}
|
||
|
|
actorName={row.actorName}
|
||
|
|
pos={row.pos}
|
||
|
|
colStart={middle - row.pos}
|
||
|
|
totalWidth={width}
|
||
|
|
hintType={row.hintType}
|
||
|
|
hintText={row.hintText}
|
||
|
|
playerLetters={letters[gridIndex] || {}}
|
||
|
|
onLetterChange={(charIndex, value) =>
|
||
|
|
handleLetterChange(gridIndex, charIndex, value)
|
||
|
|
}
|
||
|
|
disabled={isWon}
|
||
|
|
revealedLetters={wonRow ? wonRow.letters : null}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add assets/react/controllers/GameGrid.jsx
|
||
|
|
git commit -m "feat: victory detection and reveal logic in GameGrid"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 10: VictoryCard component
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `assets/react/controllers/VictoryCard.jsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create VictoryCard**
|
||
|
|
|
||
|
|
Create `assets/react/controllers/VictoryCard.jsx`:
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
import React from 'react';
|
||
|
|
|
||
|
|
export default function VictoryCard({ actorName, actorPhotoUrl }) {
|
||
|
|
return (
|
||
|
|
<div className="victory-card">
|
||
|
|
<div className="victory-card__photo-wrapper">
|
||
|
|
{actorPhotoUrl ? (
|
||
|
|
<img
|
||
|
|
src={actorPhotoUrl}
|
||
|
|
alt={actorName}
|
||
|
|
className="victory-card__photo"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div className="victory-card__photo-placeholder">
|
||
|
|
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" width="64" height="64">
|
||
|
|
<circle cx="32" cy="24" r="14" fill="var(--orange-light)" />
|
||
|
|
<ellipse cx="32" cy="56" rx="24" ry="14" fill="var(--orange-light)" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="victory-card__body">
|
||
|
|
<p className="victory-card__label">C'était</p>
|
||
|
|
<h2 className="victory-card__name">{actorName}</h2>
|
||
|
|
<a href="/" className="btn btn-primary victory-card__btn">
|
||
|
|
Nouvelle partie
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add assets/react/controllers/VictoryCard.jsx
|
||
|
|
git commit -m "feat: add VictoryCard component"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 11: CSS for victory states
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `assets/styles/app.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add victory CSS**
|
||
|
|
|
||
|
|
At the end of `assets/styles/app.css`, add:
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* ── Victory states ── */
|
||
|
|
|
||
|
|
.letter-correct {
|
||
|
|
background-color: #dcfce7;
|
||
|
|
border-color: #16a34a;
|
||
|
|
color: #15803d;
|
||
|
|
}
|
||
|
|
|
||
|
|
.letter-wrong {
|
||
|
|
background-color: #fee2e2;
|
||
|
|
border-color: #dc2626;
|
||
|
|
color: #dc2626;
|
||
|
|
}
|
||
|
|
|
||
|
|
.letter-input:disabled {
|
||
|
|
opacity: 1;
|
||
|
|
cursor: default;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Victory card ── */
|
||
|
|
|
||
|
|
.victory-card {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 20px;
|
||
|
|
background: var(--surface);
|
||
|
|
border: 1px solid var(--border-warm);
|
||
|
|
border-radius: var(--radius-lg);
|
||
|
|
padding: 20px 28px;
|
||
|
|
margin: 0 auto 32px;
|
||
|
|
width: fit-content;
|
||
|
|
max-width: 100%;
|
||
|
|
box-shadow: 0 4px 24px var(--shadow-warm);
|
||
|
|
}
|
||
|
|
|
||
|
|
.victory-card__photo-wrapper {
|
||
|
|
flex-shrink: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.victory-card__photo {
|
||
|
|
width: 80px;
|
||
|
|
height: 80px;
|
||
|
|
border-radius: 50%;
|
||
|
|
object-fit: cover;
|
||
|
|
border: 2px solid var(--border-warm);
|
||
|
|
}
|
||
|
|
|
||
|
|
.victory-card__photo-placeholder {
|
||
|
|
width: 80px;
|
||
|
|
height: 80px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--surface-tint);
|
||
|
|
border: 2px solid var(--border-warm);
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.victory-card__body {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.victory-card__label {
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: 600;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.08em;
|
||
|
|
color: var(--text-muted);
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.victory-card__name {
|
||
|
|
font-family: 'Fraunces', serif;
|
||
|
|
font-size: 24px;
|
||
|
|
font-weight: 700;
|
||
|
|
color: var(--text);
|
||
|
|
margin: 0 0 8px;
|
||
|
|
letter-spacing: -0.3px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.victory-card__btn {
|
||
|
|
align-self: flex-start;
|
||
|
|
padding: 8px 18px;
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to confirm nothing broke**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: all PASS
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add assets/styles/app.css
|
||
|
|
git commit -m "feat: add victory card and letter state CSS"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Post-implementation checklist
|
||
|
|
|
||
|
|
- [ ] Run the backfill command in your dev environment: `php bin/console app:actor:backfill-profile-path`
|
||
|
|
- [ ] Start a game, fill all highlighted cells with correct letters → verify victory card appears with photo
|
||
|
|
- [ ] Start a game, fill all highlighted cells with wrong letters → verify `won: false` is returned and grid stays interactive
|
||
|
|
- [ ] Fill highlighted cells partially → verify no premature API call
|
||
|
|
- [ ] Verify the "Nouvelle partie" button returns to the start screen
|