docs: add game hints implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
571
docs/superpowers/plans/2026-03-30-game-hints.md
Normal file
571
docs/superpowers/plans/2026-03-30-game-hints.md
Normal file
@@ -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<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**
|
||||
Reference in New Issue
Block a user