fix: preserve attempt feedback when abandoning game

This commit is contained in:
thibaud-leclere 2026-04-11 12:47:52 +02:00
parent 15760ebc08
commit 2c5c9899bd
8 changed files with 178 additions and 7 deletions

View file

@ -1,7 +1,7 @@
import React from 'react';
import GameRow from './GameRow';
export default function GameGrid({ grid, width, middle, revealed = false }) {
export default function GameGrid({ grid, width, middle, revealed = false, attemptedLetters = {} }) {
return (
<div className="game-grid-scroll">
<table id="actors">
@ -33,6 +33,8 @@ export default function GameGrid({ grid, width, middle, revealed = false }) {
totalWidth={width}
hintType={row.hintType}
hintText={row.hintText}
rowIndex={rowIndex}
attemptedLetters={attemptedLetters[rowIndex] ?? null}
revealed={revealed}
/>
);

View file

@ -6,7 +6,7 @@ function isLetter(ch) {
return /[a-zA-Z]/.test(ch);
}
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText, revealed = false }) {
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText, rowIndex, attemptedLetters = null, revealed = false }) {
const inputRefs = useRef([]);
const letters = actorName.split('');
@ -50,14 +50,29 @@ export default function GameRow({ actorName, pos, colStart, totalWidth, hintType
);
}
const attemptedLetter = typeof attemptedLetters?.[charIndex] === 'string'
? attemptedLetters[charIndex].toUpperCase()
: '';
const correctLetter = ch.toUpperCase();
const revealState = !revealed
? null
: attemptedLetter === ''
? null
: attemptedLetter === correctLetter
? 'correct'
: 'wrong';
return (
<LetterInput
key={colIndex}
highlighted={charIndex === pos}
rowIndex={rowIndex}
charIndex={charIndex}
highlighted={!revealed && charIndex === pos}
inputRef={setInputRef(charIndex)}
onNext={() => focusNextInput(charIndex, 1)}
onPrev={() => focusNextInput(charIndex, -1)}
value={revealed ? ch.toUpperCase() : undefined}
value={revealed ? (revealState === 'correct' ? attemptedLetter : correctLetter) : undefined}
revealState={revealState}
disabled={revealed}
/>
);

View file

@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
export default function LetterInput({ highlighted, onNext, onPrev, inputRef, value, disabled = false }) {
export default function LetterInput({ highlighted, onNext, onPrev, inputRef, value, rowIndex, charIndex, revealState = null, disabled = false }) {
const handleKeyUp = useCallback((e) => {
if (disabled || value !== undefined) {
return;
@ -21,11 +21,13 @@ export default function LetterInput({ highlighted, onNext, onPrev, inputRef, val
ref={inputRef}
type="text"
maxLength={1}
className={`letter-input${highlighted ? ' letter-highlighted' : ''}`}
className={`letter-input${highlighted ? ' letter-highlighted' : ''}${revealState ? ` letter-${revealState}` : ''}`}
value={value}
disabled={disabled}
readOnly={value !== undefined}
onKeyUp={handleKeyUp}
data-row-index={rowIndex}
data-char-index={charIndex}
autoComplete="off"
/>
</td>

View file

@ -91,6 +91,18 @@ body {
color: var(--orange);
}
.letter-correct {
background: #f0fdf4;
border-color: #16a34a;
color: #166534;
}
.letter-wrong {
background: #fef2f2;
border-color: #dc2626;
color: #b91c1c;
}
.letter-static {
width: var(--cell);
height: var(--cell);

View file

@ -29,6 +29,7 @@ class GameController extends AbstractController
): Response {
$this->validateCsrfToken('game_start', $request);
$request->getSession()->remove('revealed_game_id');
$request->getSession()->remove('revealed_attempted_letters');
/** @var User|null $user */
$user = $this->getUser();
@ -115,6 +116,7 @@ class GameController extends AbstractController
}
}
$request->getSession()->set('revealed_attempted_letters', $this->extractAttemptedLetters($request));
$game->abandon();
$em->flush();
$request->getSession()->set('revealed_game_id', $game->getId());
@ -165,4 +167,60 @@ class GameController extends AbstractController
return max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $bucket));
}
/**
* @return array<int, array<int, string>>
*/
private function extractAttemptedLetters(Request $request): array
{
$rawAttemptedLetters = $request->request->get('attempted_letters');
if (!is_string($rawAttemptedLetters) || $rawAttemptedLetters === '') {
return [];
}
$decodedAttemptedLetters = json_decode($rawAttemptedLetters, true);
if (!is_array($decodedAttemptedLetters)) {
return [];
}
$normalizedAttemptedLetters = [];
foreach ($decodedAttemptedLetters as $rowIndex => $rowAttempts) {
if (
(!is_int($rowIndex) && !ctype_digit((string) $rowIndex))
|| !is_array($rowAttempts)
) {
continue;
}
$normalizedRowAttempts = [];
foreach ($rowAttempts as $charIndex => $attemptedLetter) {
if (
(!is_int($charIndex) && !ctype_digit((string) $charIndex))
|| !is_scalar($attemptedLetter)
) {
continue;
}
$normalizedLetter = strtoupper(substr(trim((string) $attemptedLetter), 0, 1));
if (!preg_match('/^[A-Z]$/', $normalizedLetter)) {
continue;
}
$normalizedRowAttempts[(int) $charIndex] = $normalizedLetter;
}
if ($normalizedRowAttempts === []) {
continue;
}
ksort($normalizedRowAttempts);
$normalizedAttemptedLetters[(int) $rowIndex] = $normalizedRowAttempts;
}
ksort($normalizedAttemptedLetters);
return $normalizedAttemptedLetters;
}
}

View file

@ -56,9 +56,11 @@ class HomepageController extends AbstractController
$game = $revealedGame;
} else {
$request->getSession()->remove('revealed_game_id');
$request->getSession()->remove('revealed_attempted_letters');
}
} else {
$request->getSession()->remove('revealed_game_id');
$request->getSession()->remove('revealed_attempted_letters');
}
}
@ -76,6 +78,7 @@ class HomepageController extends AbstractController
'grid' => $gridData['grid'],
'width' => $gridData['width'],
'middle' => $gridData['middle'],
'attemptedLetters' => $request->getSession()->get('revealed_attempted_letters', []),
]);
}
}

View file

@ -10,8 +10,9 @@
<div class="abandon-popover" id="abandon-popover">
<p class="abandon-popover-text">Êtes-vous sûr de vouloir abandonner ?</p>
<div class="abandon-popover-actions">
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}" id="abandon-form">
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
<input type="hidden" name="attempted_letters" id="abandon-attempted-letters" value="">
<button type="submit" class="btn btn-abandon-confirm">Abandonner</button>
</form>
<button type="button" class="btn btn-abandon-cancel" id="abandon-cancel">Non, continuer</button>
@ -23,6 +24,34 @@
var trigger = document.getElementById('abandon-trigger');
var popover = document.getElementById('abandon-popover');
var cancel = document.getElementById('abandon-cancel');
var form = document.getElementById('abandon-form');
var attemptedLettersInput = document.getElementById('abandon-attempted-letters');
function collectAttemptedLetters() {
if (!attemptedLettersInput) {
return;
}
var attemptedLetters = {};
document.querySelectorAll('#actors input[data-row-index][data-char-index]').forEach(function(input) {
var value = (input.value || '').trim().toUpperCase();
if (!/^[A-Z]$/.test(value)) {
return;
}
var rowIndex = input.getAttribute('data-row-index');
var charIndex = input.getAttribute('data-char-index');
if (!attemptedLetters[rowIndex]) {
attemptedLetters[rowIndex] = {};
}
attemptedLetters[rowIndex][charIndex] = value;
});
attemptedLettersInput.value = JSON.stringify(attemptedLetters);
}
trigger.addEventListener('click', function() {
popover.classList.toggle('open');
});
@ -34,6 +63,7 @@
popover.classList.remove('open');
}
});
form.addEventListener('submit', collectAttemptedLetters);
})();
</script>
{% else %}
@ -49,6 +79,7 @@
width: width,
middle: middle,
revealed: game.status == constant('App\\Entity\\Game::STATUS_ABANDONED'),
attemptedLetters: attemptedLetters,
}) }}></div>
<div class="game-footer">

View file

@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
class GameControllerTest extends TestCase
{
@ -75,6 +76,53 @@ class GameControllerTest extends TestCase
$this->assertSame(42, $request->getSession()->get('current_game_id'));
}
public function testAbandonStoresAttemptedLettersForReveal(): void
{
$game = new Game();
$idProperty = new \ReflectionProperty(Game::class, 'id');
$idProperty->setValue($game, 42);
$em = $this->createMock(EntityManagerInterface::class);
$em->expects($this->once())->method('flush');
$controller = new class extends GameController {
protected function isCsrfTokenValid(string $id, ?string $token): bool
{
return true;
}
public function getUser(): ?UserInterface
{
return null;
}
};
$controller->setContainer($this->createContainer());
$request = Request::create('/game/42/abandon', 'POST', [
'_token' => 'test-token',
'attempted_letters' => '{"0":{"2":"a","3":"!","4":"bc"},"4":{"1":" z "},"oops":"x"}',
]);
$request->setSession(new Session(new MockArraySessionStorage()));
$request->getSession()->set('current_game_id', 42);
$response = $controller->abandon($game, $request, $em);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/?revealed=1', $response->getTargetUrl());
$this->assertSame(Game::STATUS_ABANDONED, $game->getStatus());
$this->assertSame(42, $request->getSession()->get('revealed_game_id'));
$this->assertNull($request->getSession()->get('current_game_id'));
$this->assertSame([
0 => [
2 => 'A',
4 => 'B',
],
4 => [
1 => 'Z',
],
], $request->getSession()->get('revealed_attempted_letters'));
}
private function createContainer(): ContainerInterface
{
$router = $this->createStub(UrlGeneratorInterface::class);