# 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): ```php #[ORM\Column(length: 20)] private ?string $hintType = null; #[ORM\Column(length: 255)] private ?string $hintData = null; ``` Add getters and setters after `setRowOrder()`: ```php 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** ```bash 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** ```bash 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: ```php /** * @param list $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** ```bash 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 */ 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 << $usedMovieRoleIds MovieRole IDs already used (for DB exclusion) * @param list $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 $usedMovieRoleIds * @param list $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): ```php $hint = $this->generateHint($mainActor, $usedMovieRoleIds, $usedHintKeys); if ($hint !== null) { $row->setHintType($hint['type']); $row->setHintData($hint['data']); } ``` - [ ] **Step 4: Commit** ```bash 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: ```php use App\Repository\MovieRepository; ``` Update constructor: ```php 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: ```php $hintText = $this->resolveHintText($row); $grid[] = [ 'actorName' => $actor->getName(), 'actorId' => $actor->getId(), 'pos' => $pos, 'hintType' => $row->getHintType(), 'hintText' => $hintText, ]; ``` - [ ] **Step 3: Add `resolveHintText()` method** ```php 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()`: ```php /** * Compute display data (grid, width, middle) from a Game entity for the React component. * * @return array{grid: list, width: int, middle: int} */ ``` - [ ] **Step 5: Commit** ```bash 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** ```bash 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: ```bash head -5 assets/app.js ``` Add at the top of `assets/app.js`: ```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`: ```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 ( <> {isOpen && (
{hintText}
)} ); } ``` - [ ] **Step 4: Update `GameRow` to pass hint props** In `assets/react/controllers/GameRow.jsx`, update the component signature (line 9): ```jsx export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) { ``` Update the `ActorPopover` usage (line 33): ```jsx ``` - [ ] **Step 5: Update `GameGrid` to pass hint props** In `assets/react/controllers/GameGrid.jsx`, update the `GameRow` rendering (lines 27-33): ```jsx ``` - [ ] **Step 6: Commit** ```bash 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** ```bash 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**