ltbxd-actorle/docs/superpowers/plans/2026-04-03-victory-condition.md
2026-04-03 21:43:30 +02:00

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_path to 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: 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