diff --git a/docs/superpowers/plans/2026-03-30-game-hints.md b/docs/superpowers/plans/2026-03-30-game-hints.md new file mode 100644 index 0000000..a7ca708 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-game-hints.md @@ -0,0 +1,571 @@ +# 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**