# 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 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 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 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 $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 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 $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 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 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 $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
``` Replace with: ```twig
``` - [ ] **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 ``. - [ ] **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 ( {}} disabled={disabled} className={classes} onKeyUp={handleKeyUp} autoComplete="off" /> ); } ``` - [ ] **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 ( {Array.from({ length: totalWidth + 1 }, (_, colIndex) => { const charIndex = colIndex - colStart; const isInRange = charIndex >= 0 && charIndex < letters.length; if (!isInRange) { return ; } const ch = letters[charIndex]; if (!isLetter(ch)) { return ( {ch} ); } 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 ( focusNextInput(charIndex, 1)} onPrev={() => focusNextInput(charIndex, -1)} value={displayValue} onChange={(val) => onLetterChange(charIndex, val)} disabled={disabled} colorClass={colorClass} /> ); })} ); } ``` - [ ] **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 (
{isWon && wonData && ( )} {grid.map((row, gridIndex) => { if (row.separator !== undefined) { return ( {Array.from({ length: width - middle }, (_, i) => ( ); } const wonRow = wonRowsByGridIndex[gridIndex] ?? null; return ( handleLetterChange(gridIndex, charIndex, value) } disabled={isWon} revealedLetters={wonRow ? wonRow.letters : null} /> ); })}
{Array.from({ length: middle }, (_, i) => ( ))} {row.separator === ' ' ? '' : row.separator} ))}
); } ``` - [ ] **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 (
{actorPhotoUrl ? ( {actorName} ) : (
)}

C'était

{actorName}

Nouvelle partie
); } ``` - [ ] **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