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
ActorPopoverto 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
GameRowto 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
GameGridto 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