Files
ltbxd-actorle/docs/superpowers/plans/2026-03-30-game-hints.md
thibaud-leclere cdcd3312ef docs: add game hints implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:26:32 +02:00

15 KiB

Game Hints System 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: Each game row displays a typed hint (film, character, award) about the main actor, replacing the "?" popover button with a type-specific icon.

Architecture: Two new columns (hint_type, hint_data) on GameRow. Hints are generated at game creation time in GameGridGenerator. A new WikidataAwardGateway service fetches awards via SPARQL. The React ActorPopover component shows a type icon instead of "?" and displays the resolved hint text in the popover.

Tech Stack: Symfony 8 / Doctrine ORM (backend), React 19 (frontend), Wikidata SPARQL API (awards), Font Awesome (icons)


Task 1: Add hintType and hintData columns to GameRow entity

Files:

  • Modify: src/Entity/GameRow.php

  • Step 1: Add properties and mapping to GameRow

Add after the $rowOrder property (line 30):

#[ORM\Column(length: 20)]
private ?string $hintType = null;

#[ORM\Column(length: 255)]
private ?string $hintData = null;

Add getters and setters after setRowOrder():

public function getHintType(): ?string
{
    return $this->hintType;
}

public function setHintType(string $hintType): static
{
    $this->hintType = $hintType;

    return $this;
}

public function getHintData(): ?string
{
    return $this->hintData;
}

public function setHintData(string $hintData): static
{
    $this->hintData = $hintData;

    return $this;
}
  • Step 2: Generate and run migration
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction

Expected: new migration adding hint_type and hint_data columns to game_row.

  • Step 3: Commit
git add src/Entity/GameRow.php migrations/
git commit -m "feat: add hintType and hintData columns to GameRow entity"

Task 2: Add MovieRoleRepository::findOneRandomByActor() method

Files:

  • Modify: src/Repository/MovieRoleRepository.php

  • Step 1: Write the repository method

Replace the commented-out methods with:

/**
 * @param list<int> $excludeMovieRoleIds MovieRole IDs to exclude
 * @return MovieRole|null
 */
public function findOneRandomByActor(int $actorId, array $excludeMovieRoleIds = []): ?MovieRole
{
    $qb = $this->createQueryBuilder('mr')
        ->andWhere('mr.actor = :actorId')
        ->setParameter('actorId', $actorId);

    if (!empty($excludeMovieRoleIds)) {
        $qb->andWhere('mr.id NOT IN (:excludeIds)')
            ->setParameter('excludeIds', $excludeMovieRoleIds);
    }

    return $qb
        ->orderBy('RANDOM()')
        ->setMaxResults(1)
        ->getQuery()
        ->getOneOrNullResult();
}
  • Step 2: Commit
git add src/Repository/MovieRoleRepository.php
git commit -m "feat: add findOneRandomByActor to MovieRoleRepository"

Task 3: Create WikidataAwardGateway service

Files:

  • Create: src/Service/WikidataAwardGateway.php

  • Step 1: Create the gateway service

<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Actor;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class WikidataAwardGateway
{
    private const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';

    public function __construct(
        private readonly HttpClientInterface $httpClient,
    ) {}

    /**
     * Fetch awards for an actor from Wikidata.
     *
     * @return list<array{name: string, year: int}>
     */
    public function getAwards(Actor $actor): array
    {
        $sparql = $this->buildQuery($actor->getName());

        $response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
            'query' => [
                'query' => $sparql,
                'format' => 'json',
            ],
            'headers' => [
                'Accept' => 'application/sparql-results+json',
                'User-Agent' => 'LtbxdActorle/1.0',
            ],
        ]);

        $data = $response->toArray();
        $awards = [];

        foreach ($data['results']['bindings'] ?? [] as $binding) {
            $name = $binding['awardLabel']['value'] ?? null;
            $year = $binding['year']['value'] ?? null;

            if ($name && $year) {
                $awards[] = [
                    'name' => $name,
                    'year' => (int) substr($year, 0, 4),
                ];
            }
        }

        return $awards;
    }

    private function buildQuery(string $actorName): string
    {
        $escaped = str_replace('"', '\\"', $actorName);

        return <<<SPARQL
        SELECT ?awardLabel ?year WHERE {
          ?person rdfs:label "{$escaped}"@en .
          ?person wdt:P31 wd:Q5 .
          ?person p:P166 ?awardStatement .
          ?awardStatement ps:P166 ?award .
          ?awardStatement pq:P585 ?date .
          BIND(YEAR(?date) AS ?year)
          SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
        }
        ORDER BY DESC(?year)
        SPARQL;
    }
}
  • Step 2: Verify the service is autowired
php bin/console debug:container WikidataAwardGateway

Expected: service App\Service\WikidataAwardGateway is listed.

  • Step 3: Commit
git add src/Service/WikidataAwardGateway.php
git commit -m "feat: add WikidataAwardGateway for actor awards from Wikidata SPARQL"

Task 4: Integrate hint generation into GameGridGenerator

Files:

  • Modify: src/Service/GameGridGenerator.php

  • Step 1: Add dependencies to constructor

Update the constructor at line 16-19:

public function __construct(
    private readonly ActorRepository $actorRepository,
    private readonly MovieRoleRepository $movieRoleRepository,
    private readonly WikidataAwardGateway $wikidataAwardGateway,
    private readonly EntityManagerInterface $em,
) {}

Add the import at the top:

use App\Repository\MovieRoleRepository;
  • Step 2: Add hint generation methods

Add these private methods after computeGridData():

/**
 * @param list<int> $usedMovieRoleIds MovieRole IDs already used (for DB exclusion)
 * @param list<string> $usedHintKeys Semantic keys like "film:42" to avoid duplicate hints
 * @return array{type: string, data: string}|null
 */
private function generateHint(Actor $mainActor, array &$usedMovieRoleIds, array &$usedHintKeys): ?array
{
    $types = ['film', 'character', 'award'];
    shuffle($types);

    foreach ($types as $type) {
        $hint = $this->resolveHint($type, $mainActor, $usedMovieRoleIds, $usedHintKeys);
        if ($hint !== null) {
            return $hint;
        }
    }

    return null;
}

/**
 * @param list<int> $usedMovieRoleIds
 * @param list<string> $usedHintKeys
 * @return array{type: string, data: string}|null
 */
private function resolveHint(string $type, Actor $mainActor, array &$usedMovieRoleIds, array &$usedHintKeys): ?array
{
    switch ($type) {
        case 'film':
            $role = $this->movieRoleRepository->findOneRandomByActor(
                $mainActor->getId(),
                $usedMovieRoleIds,
            );
            if ($role === null) {
                return null;
            }
            $movieId = (string) $role->getMovie()->getId();
            $key = 'film:' . $movieId;
            if (in_array($key, $usedHintKeys)) {
                return null;
            }
            $usedMovieRoleIds[] = $role->getId();
            $usedHintKeys[] = $key;
            return ['type' => 'film', 'data' => $movieId];

        case 'character':
            $role = $this->movieRoleRepository->findOneRandomByActor(
                $mainActor->getId(),
                $usedMovieRoleIds,
            );
            if ($role === null) {
                return null;
            }
            $roleId = (string) $role->getId();
            $key = 'character:' . $roleId;
            if (in_array($key, $usedHintKeys)) {
                return null;
            }
            $usedMovieRoleIds[] = $role->getId();
            $usedHintKeys[] = $key;
            return ['type' => 'character', 'data' => $roleId];

        case 'award':
            try {
                $awards = $this->wikidataAwardGateway->getAwards($mainActor);
            } catch (\Throwable) {
                return null;
            }
            foreach ($awards as $award) {
                $text = $award['name'] . ' (' . $award['year'] . ')';
                $key = 'award:' . $text;
                if (!in_array($key, $usedHintKeys)) {
                    $usedHintKeys[] = $key;
                    return ['type' => 'award', 'data' => $text];
                }
            }
            return null;
    }

    return null;
}
  • Step 3: Wire hint generation into generate()

Add $usedMovieRoleIds = []; and $usedHintKeys = []; after $rowOrder = 0; (line 31), and add hint assignment after $row->setRowOrder($rowOrder); (line 51):

$hint = $this->generateHint($mainActor, $usedMovieRoleIds, $usedHintKeys);
if ($hint !== null) {
    $row->setHintType($hint['type']);
    $row->setHintData($hint['data']);
}
  • Step 4: Commit
git add src/Service/GameGridGenerator.php
git commit -m "feat: generate hints per row in GameGridGenerator"

Task 5: Resolve hint display text in computeGridData()

Files:

  • Modify: src/Service/GameGridGenerator.php

  • Step 1: Add repositories needed for resolution

Add import:

use App\Repository\MovieRepository;

Update constructor:

public function __construct(
    private readonly ActorRepository $actorRepository,
    private readonly MovieRoleRepository $movieRoleRepository,
    private readonly MovieRepository $movieRepository,
    private readonly WikidataAwardGateway $wikidataAwardGateway,
    private readonly EntityManagerInterface $em,
) {}
  • Step 2: Add hint data to grid output in computeGridData()

In the computeGridData() method, update the $grid[] assignment (around line 105-109) to include hint data:

$hintText = $this->resolveHintText($row);

$grid[] = [
    'actorName' => $actor->getName(),
    'actorId' => $actor->getId(),
    'pos' => $pos,
    'hintType' => $row->getHintType(),
    'hintText' => $hintText,
];
  • Step 3: Add resolveHintText() method
private function resolveHintText(GameRow $row): ?string
{
    $type = $row->getHintType();
    $data = $row->getHintData();

    if ($type === null || $data === null) {
        return null;
    }

    return match ($type) {
        'film' => $this->movieRepository->find((int) $data)?->getTitle(),
        'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
        'award' => $data,
        default => null,
    };
}
  • Step 4: Update the return type docblock

Update the @return annotation of computeGridData():

/**
 * Compute display data (grid, width, middle) from a Game entity for the React component.
 *
 * @return array{grid: list<array{actorName: string, actorId: int, pos: int, hintType: ?string, hintText: ?string}>, width: int, middle: int}
 */
  • Step 5: Commit
git add src/Service/GameGridGenerator.php src/Repository/MovieRepository.php
git commit -m "feat: resolve hint display text in computeGridData"

Task 6: Install Font Awesome and update ActorPopover component

Files:

  • Modify: assets/react/controllers/ActorPopover.jsx

  • Modify: assets/react/controllers/GameGrid.jsx

  • Modify: assets/react/controllers/GameRow.jsx

  • Step 1: Install Font Awesome

cd /home/thibaud/ltbxd-actorle && npm install @fortawesome/fontawesome-free
  • Step 2: Import Font Awesome CSS in the app entrypoint

Check the main JS entrypoint:

head -5 assets/app.js

Add at the top of assets/app.js:

import '@fortawesome/fontawesome-free/css/all.min.css';
  • Step 3: Update ActorPopover to accept hint props

Replace the full content of assets/react/controllers/ActorPopover.jsx:

import React, { useState } from 'react';
import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react';

const HINT_ICONS = {
    film: 'fa-solid fa-film',
    character: 'fa-solid fa-masks-theater',
    award: 'fa-solid fa-trophy',
};

export default function ActorPopover({ hintType, hintText }) {
    const [isOpen, setIsOpen] = useState(false);

    const { refs, floatingStyles, context } = useFloating({
        open: isOpen,
        onOpenChange: setIsOpen,
        middleware: [offset(8), flip(), shift()],
        placement: 'left',
    });

    const click = useClick(context);
    const dismiss = useDismiss(context);
    const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);

    const iconClass = HINT_ICONS[hintType] || 'fa-solid fa-circle-question';

    return (
        <>
            <button
                ref={refs.setReference}
                {...getReferenceProps()}
                className="popover-trigger"
                type="button"
            >
                <i className={iconClass}></i>
            </button>
            {isOpen && (
                <div
                    ref={refs.setFloating}
                    style={floatingStyles}
                    {...getFloatingProps()}
                    className="actor-popover"
                >
                    {hintText}
                </div>
            )}
        </>
    );
}
  • Step 4: Update GameRow to pass hint props

In assets/react/controllers/GameRow.jsx, update the component signature (line 9):

export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {

Update the ActorPopover usage (line 33):

<ActorPopover hintType={hintType} hintText={hintText} />
  • Step 5: Update GameGrid to pass hint props

In assets/react/controllers/GameGrid.jsx, update the GameRow rendering (lines 27-33):

<GameRow
    key={rowIndex}
    actorName={row.actorName}
    pos={row.pos}
    colStart={middle - row.pos}
    totalWidth={width}
    hintType={row.hintType}
    hintText={row.hintText}
/>
  • Step 6: Commit
git add assets/ package.json package-lock.json
git commit -m "feat: replace ? button with hint type icons in ActorPopover"

Task 7: Manual end-to-end verification

  • Step 1: Build frontend assets
cd /home/thibaud/ltbxd-actorle && npm run build
  • Step 2: Start a new game and verify

Open the app in a browser, start a new game. Verify:

  • Each row has an icon (film, masks, or trophy) instead of "?"

  • Clicking the icon opens a popover with the hint text

  • Different rows can have different hint types

  • Award hints show the format "Nom du prix (année)"

  • Step 3: Final commit if any adjustments needed