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

572 lines
15 KiB
Markdown

# 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<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**
```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
<?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**
```bash
php bin/console debug:container WikidataAwardGateway
```
Expected: service `App\Service\WikidataAwardGateway` is listed.
- [ ] **Step 3: Commit**
```bash
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:
```php
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:
```php
use App\Repository\MovieRoleRepository;
```
- [ ] **Step 2: Add hint generation methods**
Add these private methods after `computeGridData()`:
```php
/**
* @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):
```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<array{actorName: string, actorId: int, pos: int, hintType: ?string, hintText: ?string}>, 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 (
<>
<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):
```jsx
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
```
Update the `ActorPopover` usage (line 33):
```jsx
<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):
```jsx
<GameRow
key={rowIndex}
actorName={row.actorName}
pos={row.pos}
colStart={middle - row.pos}
totalWidth={width}
hintType={row.hintType}
hintText={row.hintText}
/>
```
- [ ] **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**