34 KiB
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:
#[ORM\Column(length: 255, nullable: true)]
private ?string $profilePath = null;
After the setTmdbId() method, add:
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
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
make migrate
Expected: [OK] Successfully executed 1 migrations.
- Step 4: Commit
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
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
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:
public const string STATUS_WON = 'won';
After the abandon() method, add:
public function win(): static
{
$this->status = self::STATUS_WON;
$this->endedAt = new \DateTimeImmutable();
return $this;
}
- Step 4: Run test to verify it passes
make test
Expected: PASS
- Step 5: Commit
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
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
make test
Expected: FAIL — TMDBPerson class not found
- Step 3: Create TMDBPerson model
Create src/Model/TMDB/TMDBPerson.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:
private const string PERSON_URI = '/person/{id}';
Add the method after getMovieCredits():
/**
* @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:
use App\Model\TMDB\TMDBPerson;
- Step 5: Run test to verify it passes
make test
Expected: PASS
- Step 6: Commit
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
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
make test
Expected: FAIL — TMDBMovieCredit constructor does not accept a 5th argument
- Step 3: Add
profile_pathto TMDBMovieCredit
In src/Model/TMDB/TMDBMovieCredit.php, add the field:
<?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():
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
make test
Expected: PASS
- Step 6: Commit
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:
/**
* @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
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
make test
Expected: all PASS
- Step 4: Commit
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
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
make test
Expected: all PASS
- Step 3: Commit
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:
<div {{ react_component('GameGrid', {
grid: grid,
width: width,
middle: middle,
}) }}></div>
Replace with:
<div {{ react_component('GameGrid', {
grid: grid,
width: width,
middle: middle,
gameId: game.id,
}) }}></div>
- Step 2: Commit
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:
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:
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
# In your local dev environment:
# Open the game page and verify the grid still renders and you can type letters
- Step 4: Commit
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:
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
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:
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
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:
/* ── 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
make test
Expected: all PASS
- Step 3: Commit
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: falseis returned and grid stays interactive - Fill highlighted cells partially → verify no premature API call
- Verify the "Nouvelle partie" button returns to the start screen