Compare commits
15 Commits
1f80b554fd
...
1fd6dcc5d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd6dcc5d3 | ||
|
|
0706d99c82 | ||
|
|
273ea49ed0 | ||
|
|
8d413b5c57 | ||
|
|
91f45448f0 | ||
|
|
42a3567e1c | ||
|
|
32ae77da53 | ||
|
|
c2efdd4eeb | ||
|
|
7f3738007d | ||
|
|
e3ee26e070 | ||
|
|
cdcd3312ef | ||
|
|
4fb1a25469 | ||
|
|
335a55562f | ||
|
|
ba9a3fba5d | ||
|
|
86bf2eb1d3 |
@@ -1,2 +1,3 @@
|
|||||||
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||||
import './bootstrap.js';
|
import './bootstrap.js';
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react';
|
import { useFloating, useClick, useDismiss, useInteractions, offset, shift, size } from '@floating-ui/react';
|
||||||
|
|
||||||
export default function ActorPopover({ actorName }) {
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { refs, floatingStyles, context } = useFloating({
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
open: isOpen,
|
open: isOpen,
|
||||||
onOpenChange: setIsOpen,
|
onOpenChange: setIsOpen,
|
||||||
middleware: [offset(8), flip(), shift()],
|
middleware: [
|
||||||
placement: 'top',
|
offset(8),
|
||||||
|
size({
|
||||||
|
apply({ availableWidth, elements }) {
|
||||||
|
Object.assign(elements.floating.style, {
|
||||||
|
maxWidth: `${availableWidth - 16}px`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
shift({ padding: 8 }),
|
||||||
|
],
|
||||||
|
placement: 'left',
|
||||||
});
|
});
|
||||||
|
|
||||||
const click = useClick(context);
|
const click = useClick(context);
|
||||||
const dismiss = useDismiss(context);
|
const dismiss = useDismiss(context);
|
||||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
||||||
|
|
||||||
|
if (!hintText) return null;
|
||||||
|
|
||||||
|
const iconClass = HINT_ICONS[hintType] || 'fa-solid fa-circle-question';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -23,7 +43,7 @@ export default function ActorPopover({ actorName }) {
|
|||||||
className="popover-trigger"
|
className="popover-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
?
|
<i className={iconClass}></i>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -32,7 +52,7 @@ export default function ActorPopover({ actorName }) {
|
|||||||
{...getFloatingProps()}
|
{...getFloatingProps()}
|
||||||
className="actor-popover"
|
className="actor-popover"
|
||||||
>
|
>
|
||||||
<strong>{actorName}</strong>
|
{hintText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,15 +5,36 @@ export default function GameGrid({ grid, width, middle }) {
|
|||||||
return (
|
return (
|
||||||
<table id="actors">
|
<table id="actors">
|
||||||
<tbody>
|
<tbody>
|
||||||
{grid.map((row, rowIndex) => (
|
{grid.map((row, rowIndex) => {
|
||||||
|
if (row.separator !== undefined) {
|
||||||
|
return (
|
||||||
|
<tr key={rowIndex} className="separator-row">
|
||||||
|
<td />
|
||||||
|
{Array.from({ length: middle }, (_, i) => (
|
||||||
|
<td key={i} />
|
||||||
|
))}
|
||||||
|
<td className="letter-static separator-char">
|
||||||
|
{row.separator === ' ' ? '' : row.separator}
|
||||||
|
</td>
|
||||||
|
{Array.from({ length: width - middle }, (_, i) => (
|
||||||
|
<td key={middle + 1 + i} />
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<GameRow
|
<GameRow
|
||||||
key={rowIndex}
|
key={rowIndex}
|
||||||
actorName={row.actorName}
|
actorName={row.actorName}
|
||||||
pos={row.pos}
|
pos={row.pos}
|
||||||
colStart={middle - row.pos}
|
colStart={middle - row.pos}
|
||||||
totalWidth={width}
|
totalWidth={width}
|
||||||
|
hintType={row.hintType}
|
||||||
|
hintText={row.hintText}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import React, { useRef, useCallback } from 'react';
|
import React, { useRef, useCallback, useMemo } from 'react';
|
||||||
import LetterInput from './LetterInput';
|
import LetterInput from './LetterInput';
|
||||||
import ActorPopover from './ActorPopover';
|
import ActorPopover from './ActorPopover';
|
||||||
|
|
||||||
export default function GameRow({ actorName, pos, colStart, totalWidth }) {
|
function isLetter(ch) {
|
||||||
|
return /[a-zA-Z]/.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
|
||||||
const inputRefs = useRef([]);
|
const inputRefs = useRef([]);
|
||||||
|
const letters = actorName.split('');
|
||||||
|
|
||||||
|
const letterIndices = useMemo(
|
||||||
|
() => letters.reduce((acc, ch, i) => { if (isLetter(ch)) acc.push(i); return acc; }, []),
|
||||||
|
[actorName]
|
||||||
|
);
|
||||||
|
|
||||||
const setInputRef = useCallback((index) => (el) => {
|
const setInputRef = useCallback((index) => (el) => {
|
||||||
inputRefs.current[index] = el;
|
inputRefs.current[index] = el;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const focusInput = useCallback((index) => {
|
const focusNextInput = useCallback((charIndex, direction) => {
|
||||||
inputRefs.current[index]?.focus();
|
const currentPos = letterIndices.indexOf(charIndex);
|
||||||
}, []);
|
const nextPos = currentPos + direction;
|
||||||
|
if (nextPos >= 0 && nextPos < letterIndices.length) {
|
||||||
const letters = actorName.split('');
|
inputRefs.current[letterIndices[nextPos]]?.focus();
|
||||||
|
}
|
||||||
|
}, [letterIndices]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<ActorPopover hintType={hintType} hintText={hintText} />
|
||||||
|
</td>
|
||||||
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
|
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
|
||||||
const charIndex = colIndex - colStart;
|
const charIndex = colIndex - colStart;
|
||||||
const isInRange = charIndex >= 0 && charIndex < letters.length;
|
const isInRange = charIndex >= 0 && charIndex < letters.length;
|
||||||
@@ -25,19 +40,26 @@ export default function GameRow({ actorName, pos, colStart, totalWidth }) {
|
|||||||
return <td key={colIndex} />;
|
return <td key={colIndex} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ch = letters[charIndex];
|
||||||
|
|
||||||
|
if (!isLetter(ch)) {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="letter-static">
|
||||||
|
{ch}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LetterInput
|
<LetterInput
|
||||||
key={colIndex}
|
key={colIndex}
|
||||||
highlighted={charIndex === pos}
|
highlighted={charIndex === pos}
|
||||||
inputRef={setInputRef(charIndex)}
|
inputRef={setInputRef(charIndex)}
|
||||||
onNext={() => focusInput(charIndex + 1)}
|
onNext={() => focusNextInput(charIndex, 1)}
|
||||||
onPrev={() => focusInput(charIndex - 1)}
|
onPrev={() => focusNextInput(charIndex, -1)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<td>
|
|
||||||
<ActorPopover actorName={actorName} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ body {
|
|||||||
color: var(--orange);
|
color: var(--orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.letter-static {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator-row td {
|
||||||
|
height: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator-char {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Popover ── */
|
/* ── Popover ── */
|
||||||
|
|
||||||
.popover-trigger {
|
.popover-trigger {
|
||||||
@@ -109,6 +130,8 @@ body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Auth pages ── */
|
/* ── Auth pages ── */
|
||||||
|
|||||||
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**
|
||||||
71
docs/superpowers/specs/2026-03-30-game-hints-design.md
Normal file
71
docs/superpowers/specs/2026-03-30-game-hints-design.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Game Hints System — Design Spec
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Each row of the game grid provides a hint about the main actor to guess. Hints are pre-generated when the grid is created and stored on `GameRow`. Each hint has a type (film, character, award) represented by a distinct icon. Clicking the icon opens a popover showing the hint text.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Two new columns on `game_row`:
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `hint_type` | `VARCHAR(20)`, NOT NULL | One of: `film`, `character`, `award` |
|
||||||
|
| `hint_data` | `VARCHAR(255)`, NOT NULL | film → `movie.id`, character → `movie_role.id`, award → text "Nom du prix (année)" |
|
||||||
|
|
||||||
|
No foreign key constraints on `hint_data` — the column stores either an id (resolved at read time) or raw text depending on `hint_type`.
|
||||||
|
|
||||||
|
## Hint Generation
|
||||||
|
|
||||||
|
Happens in `GameGridGenerator::generate()`, for each `GameRow`:
|
||||||
|
|
||||||
|
1. Pick a random type from `[film, character, award]`
|
||||||
|
2. Resolve based on type:
|
||||||
|
- **film**: pick a random `MovieRole` of the main actor → store `movie.id` in `hint_data`
|
||||||
|
- **character**: pick a random `MovieRole` of the main actor → store `movieRole.id` in `hint_data`
|
||||||
|
- **award**: call `WikidataAwardGateway` to fetch an award → store `"Nom du prix (année)"` in `hint_data`
|
||||||
|
3. If the chosen type yields no result (e.g., no awards found), fallback to another random type
|
||||||
|
4. Avoid duplicate hints across rows (don't show the same film/character/award twice)
|
||||||
|
|
||||||
|
## Wikidata Award Gateway
|
||||||
|
|
||||||
|
New service: `WikidataAwardGateway`
|
||||||
|
|
||||||
|
- Input: actor (name or `tmdb_id`)
|
||||||
|
- Output: list of awards, each with name and year
|
||||||
|
- Uses Wikidata SPARQL API to query awards associated with the person
|
||||||
|
- Storage format in `hint_data`: `"Oscar du meilleur second rôle (2014)"`
|
||||||
|
- Results can be cached to avoid repeated Wikidata queries
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Icon Button
|
||||||
|
|
||||||
|
The current "?" button in `ActorPopover` is replaced by an icon representing the hint type:
|
||||||
|
|
||||||
|
| hint_type | Icon | Font Awesome class |
|
||||||
|
|-----------|------|--------------------|
|
||||||
|
| `film` | Film/clap | `fa-film` |
|
||||||
|
| `character` | Theater masks | `fa-masks-theater` |
|
||||||
|
| `award` | Trophy | `fa-trophy` |
|
||||||
|
|
||||||
|
### Popover Content
|
||||||
|
|
||||||
|
On click, the popover displays only the hint text:
|
||||||
|
- **film**: movie title (resolved from `movie.id`)
|
||||||
|
- **character**: character name (resolved from `movie_role.id`)
|
||||||
|
- **award**: the raw text from `hint_data`
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. Backend resolves `hint_data` to display text in `GameGridGenerator::computeGridData()`
|
||||||
|
2. Hint type + resolved text are passed to the Twig template as part of the grid data
|
||||||
|
3. Twig passes them as props to the React `GameGrid` component
|
||||||
|
4. `ActorPopover` receives `hintType` and `hintText` props instead of `actorName`
|
||||||
|
|
||||||
|
## Future Extensibility
|
||||||
|
|
||||||
|
New hint types can be added by:
|
||||||
|
1. Adding a new value for `hint_type`
|
||||||
|
2. Adding resolution logic in `GameGridGenerator`
|
||||||
|
3. Adding a new icon mapping in the frontend
|
||||||
33
migrations/Version20260330203017.php
Normal file
33
migrations/Version20260330203017.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260330203017 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE game_row ADD hint_type VARCHAR(20) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE game_row ADD hint_data VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP hint_type');
|
||||||
|
$this->addSql('ALTER TABLE game_row DROP hint_data');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "ltbxd-actorle",
|
"name": "ltbxd-actorle",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27",
|
"@floating-ui/react": "^0.27",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@hotwired/stimulus": "^3.2",
|
"@hotwired/stimulus": "^3.2",
|
||||||
"@hotwired/turbo": "^7.3",
|
"@hotwired/turbo": "^7.3",
|
||||||
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
||||||
@@ -952,6 +953,15 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==",
|
||||||
|
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hotwired/stimulus": {
|
"node_modules/@hotwired/stimulus": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27",
|
"@floating-ui/react": "^0.27",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@hotwired/stimulus": "^3.2",
|
"@hotwired/stimulus": "^3.2",
|
||||||
"@hotwired/turbo": "^7.3",
|
"@hotwired/turbo": "^7.3",
|
||||||
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class GameRow
|
|||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private int $rowOrder;
|
private int $rowOrder;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
private ?string $hintType = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $hintData = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -81,4 +87,28 @@ class GameRow
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,28 +16,25 @@ class MovieRoleRepository extends ServiceEntityRepository
|
|||||||
parent::__construct($registry, MovieRole::class);
|
parent::__construct($registry, MovieRole::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
// /**
|
/**
|
||||||
// * @return MovieRole[] Returns an array of MovieRole objects
|
* @param list<int> $excludeMovieRoleIds MovieRole IDs to exclude
|
||||||
// */
|
* @return MovieRole|null
|
||||||
// public function findByExampleField($value): array
|
*/
|
||||||
// {
|
public function findOneRandomByActor(int $actorId, array $excludeMovieRoleIds = []): ?MovieRole
|
||||||
// return $this->createQueryBuilder('m')
|
{
|
||||||
// ->andWhere('m.exampleField = :val')
|
$qb = $this->createQueryBuilder('mr')
|
||||||
// ->setParameter('val', $value)
|
->andWhere('mr.actor = :actorId')
|
||||||
// ->orderBy('m.id', 'ASC')
|
->setParameter('actorId', $actorId);
|
||||||
// ->setMaxResults(10)
|
|
||||||
// ->getQuery()
|
|
||||||
// ->getResult()
|
|
||||||
// ;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// public function findOneBySomeField($value): ?MovieRole
|
if (!empty($excludeMovieRoleIds)) {
|
||||||
// {
|
$qb->andWhere('mr.id NOT IN (:excludeIds)')
|
||||||
// return $this->createQueryBuilder('m')
|
->setParameter('excludeIds', $excludeMovieRoleIds);
|
||||||
// ->andWhere('m.exampleField = :val')
|
}
|
||||||
// ->setParameter('val', $value)
|
|
||||||
// ->getQuery()
|
return $qb
|
||||||
// ->getOneOrNullResult()
|
->orderBy('RANDOM()')
|
||||||
// ;
|
->setMaxResults(1)
|
||||||
// }
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ use App\Entity\Game;
|
|||||||
use App\Entity\GameRow;
|
use App\Entity\GameRow;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\ActorRepository;
|
use App\Repository\ActorRepository;
|
||||||
|
use App\Repository\MovieRepository;
|
||||||
|
use App\Repository\MovieRoleRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
class GameGridGenerator
|
class GameGridGenerator
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActorRepository $actorRepository,
|
private readonly ActorRepository $actorRepository,
|
||||||
|
private readonly MovieRoleRepository $movieRoleRepository,
|
||||||
|
private readonly MovieRepository $movieRepository,
|
||||||
|
private readonly WikidataAwardGateway $wikidataAwardGateway,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -50,6 +55,12 @@ class GameGridGenerator
|
|||||||
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
||||||
$row->setRowOrder($rowOrder);
|
$row->setRowOrder($rowOrder);
|
||||||
|
|
||||||
|
$hint = $this->generateHint($actor);
|
||||||
|
if ($hint !== null) {
|
||||||
|
$row->setHintType($hint['type']);
|
||||||
|
$row->setHintData($hint['data']);
|
||||||
|
}
|
||||||
|
|
||||||
$game->addRow($row);
|
$game->addRow($row);
|
||||||
++$rowOrder;
|
++$rowOrder;
|
||||||
}
|
}
|
||||||
@@ -63,7 +74,7 @@ class GameGridGenerator
|
|||||||
/**
|
/**
|
||||||
* Compute display data (grid, width, middle) from a Game entity for the React component.
|
* 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}>, width: int, middle: int}
|
* @return array{grid: list<array{actorName: string, actorId: int, pos: int, hintType: ?string, hintText: ?string}>, width: int, middle: int}
|
||||||
*/
|
*/
|
||||||
public function computeGridData(Game $game): array
|
public function computeGridData(Game $game): array
|
||||||
{
|
{
|
||||||
@@ -71,7 +82,25 @@ class GameGridGenerator
|
|||||||
$rightSize = 0;
|
$rightSize = 0;
|
||||||
$grid = [];
|
$grid = [];
|
||||||
|
|
||||||
foreach ($game->getRows() as $row) {
|
$mainActorChars = str_split($game->getMainActor()->getName());
|
||||||
|
$rows = $game->getRows()->toArray();
|
||||||
|
$rowIndex = 0;
|
||||||
|
|
||||||
|
foreach ($mainActorChars as $char) {
|
||||||
|
if (!preg_match('/[a-zA-Z]/', $char)) {
|
||||||
|
$grid[] = [
|
||||||
|
'separator' => $char,
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $rows[$rowIndex] ?? null;
|
||||||
|
++$rowIndex;
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$actor = $row->getActor();
|
$actor = $row->getActor();
|
||||||
$pos = $row->getPosition();
|
$pos = $row->getPosition();
|
||||||
|
|
||||||
@@ -84,10 +113,14 @@ class GameGridGenerator
|
|||||||
$rightSize = $rightSizeActor;
|
$rightSize = $rightSizeActor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$hintText = $this->resolveHintText($row);
|
||||||
|
|
||||||
$grid[] = [
|
$grid[] = [
|
||||||
'actorName' => $actor->getName(),
|
'actorName' => $actor->getName(),
|
||||||
'actorId' => $actor->getId(),
|
'actorId' => $actor->getId(),
|
||||||
'pos' => $pos,
|
'pos' => $pos,
|
||||||
|
'hintType' => $row->getHintType(),
|
||||||
|
'hintText' => $hintText,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,4 +130,77 @@ class GameGridGenerator
|
|||||||
'middle' => $leftSize,
|
'middle' => $leftSize,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single hint for a row actor.
|
||||||
|
*
|
||||||
|
* @return array{type: string, data: string}|null
|
||||||
|
*/
|
||||||
|
private function generateHint(Actor $rowActor): ?array
|
||||||
|
{
|
||||||
|
$types = ['film', 'character', 'award'];
|
||||||
|
shuffle($types);
|
||||||
|
|
||||||
|
foreach ($types as $type) {
|
||||||
|
$hint = $this->resolveHint($type, $rowActor);
|
||||||
|
if ($hint !== null) {
|
||||||
|
return $hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{type: string, data: string}|null
|
||||||
|
*/
|
||||||
|
private function resolveHint(string $type, Actor $rowActor): ?array
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case 'film':
|
||||||
|
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
|
||||||
|
if ($role === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()];
|
||||||
|
|
||||||
|
case 'character':
|
||||||
|
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
|
||||||
|
if ($role === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['type' => 'character', 'data' => (string) $role->getId()];
|
||||||
|
|
||||||
|
case 'award':
|
||||||
|
try {
|
||||||
|
$awards = $this->wikidataAwardGateway->getAwards($rowActor);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!empty($awards)) {
|
||||||
|
$award = $awards[array_rand($awards)];
|
||||||
|
return ['type' => 'award', 'data' => $award['name'] . ' (' . $award['year'] . ')'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/Service/WikidataAwardGateway.php
Normal file
74
src/Service/WikidataAwardGateway.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?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',
|
||||||
|
],
|
||||||
|
'timeout' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,8 @@
|
|||||||
<div class="navbar-right">
|
<div class="navbar-right">
|
||||||
{# Gitea repo #}
|
{# Gitea repo #}
|
||||||
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
|
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 92 92" fill="currentColor">
|
||||||
<polyline points="16 18 22 12 16 6"/>
|
<path d="M90.156 41.965 50.036 1.848a5.918 5.918 0 0 0-8.372 0l-8.328 8.332 10.566 10.566a7.03 7.03 0 0 1 7.23 1.684 7.034 7.034 0 0 1 1.669 7.277l10.187 10.184a7.028 7.028 0 0 1 7.278 1.672 7.04 7.04 0 0 1 0 9.957 7.05 7.05 0 0 1-9.965 0 7.044 7.044 0 0 1-1.528-7.66l-9.5-9.497V59.36a7.04 7.04 0 0 1 1.86 11.29 7.04 7.04 0 0 1-9.957 0 7.04 7.04 0 0 1 0-9.958 7.06 7.06 0 0 1 2.304-1.539V33.926a7.049 7.049 0 0 1-3.82-9.234L29.242 14.272 1.73 41.777a5.925 5.925 0 0 0 0 8.371L41.852 90.27a5.925 5.925 0 0 0 8.37 0l39.934-39.934a5.925 5.925 0 0 0 0-8.371"/>
|
||||||
<polyline points="8 6 2 12 8 18"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -73,9 +72,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="navbar-right">
|
<div class="navbar-right">
|
||||||
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
|
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 92 92" fill="currentColor">
|
||||||
<polyline points="16 18 22 12 16 6"/>
|
<path d="M90.156 41.965 50.036 1.848a5.918 5.918 0 0 0-8.372 0l-8.328 8.332 10.566 10.566a7.03 7.03 0 0 1 7.23 1.684 7.034 7.034 0 0 1 1.669 7.277l10.187 10.184a7.028 7.028 0 0 1 7.278 1.672 7.04 7.04 0 0 1 0 9.957 7.05 7.05 0 0 1-9.965 0 7.044 7.044 0 0 1-1.528-7.66l-9.5-9.497V59.36a7.04 7.04 0 0 1 1.86 11.29 7.04 7.04 0 0 1-9.957 0 7.04 7.04 0 0 1 0-9.958 7.06 7.06 0 0 1 2.304-1.539V33.926a7.049 7.049 0 0 1-3.82-9.234L29.242 14.272 1.73 41.777a5.925 5.925 0 0 0 0 8.371L41.852 90.27a5.925 5.925 0 0 0 8.37 0l39.934-39.934a5.925 5.925 0 0 0 0-8.371"/>
|
||||||
<polyline points="8 6 2 12 8 18"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ path('app_login') }}" class="btn btn-primary">Se connecter</a>
|
<a href="{{ path('app_login') }}" class="btn btn-primary">Se connecter</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user