fix: preserve attempt feedback when abandoning game
This commit is contained in:
parent
15760ebc08
commit
2c5c9899bd
8 changed files with 178 additions and 7 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import GameRow from './GameRow';
|
import GameRow from './GameRow';
|
||||||
|
|
||||||
export default function GameGrid({ grid, width, middle, revealed = false }) {
|
export default function GameGrid({ grid, width, middle, revealed = false, attemptedLetters = {} }) {
|
||||||
return (
|
return (
|
||||||
<div className="game-grid-scroll">
|
<div className="game-grid-scroll">
|
||||||
<table id="actors">
|
<table id="actors">
|
||||||
|
|
@ -33,6 +33,8 @@ export default function GameGrid({ grid, width, middle, revealed = false }) {
|
||||||
totalWidth={width}
|
totalWidth={width}
|
||||||
hintType={row.hintType}
|
hintType={row.hintType}
|
||||||
hintText={row.hintText}
|
hintText={row.hintText}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
attemptedLetters={attemptedLetters[rowIndex] ?? null}
|
||||||
revealed={revealed}
|
revealed={revealed}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ function isLetter(ch) {
|
||||||
return /[a-zA-Z]/.test(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 inputRefs = useRef([]);
|
||||||
const letters = actorName.split('');
|
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 (
|
return (
|
||||||
<LetterInput
|
<LetterInput
|
||||||
key={colIndex}
|
key={colIndex}
|
||||||
highlighted={charIndex === pos}
|
rowIndex={rowIndex}
|
||||||
|
charIndex={charIndex}
|
||||||
|
highlighted={!revealed && charIndex === pos}
|
||||||
inputRef={setInputRef(charIndex)}
|
inputRef={setInputRef(charIndex)}
|
||||||
onNext={() => focusNextInput(charIndex, 1)}
|
onNext={() => focusNextInput(charIndex, 1)}
|
||||||
onPrev={() => focusNextInput(charIndex, -1)}
|
onPrev={() => focusNextInput(charIndex, -1)}
|
||||||
value={revealed ? ch.toUpperCase() : undefined}
|
value={revealed ? (revealState === 'correct' ? attemptedLetter : correctLetter) : undefined}
|
||||||
|
revealState={revealState}
|
||||||
disabled={revealed}
|
disabled={revealed}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useCallback } from 'react';
|
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) => {
|
const handleKeyUp = useCallback((e) => {
|
||||||
if (disabled || value !== undefined) {
|
if (disabled || value !== undefined) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -21,11 +21,13 @@ export default function LetterInput({ highlighted, onNext, onPrev, inputRef, val
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={1}
|
maxLength={1}
|
||||||
className={`letter-input${highlighted ? ' letter-highlighted' : ''}`}
|
className={`letter-input${highlighted ? ' letter-highlighted' : ''}${revealState ? ` letter-${revealState}` : ''}`}
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={value !== undefined}
|
readOnly={value !== undefined}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
|
data-row-index={rowIndex}
|
||||||
|
data-char-index={charIndex}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,18 @@ body {
|
||||||
color: var(--orange);
|
color: var(--orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.letter-correct {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #16a34a;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-wrong {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.letter-static {
|
.letter-static {
|
||||||
width: var(--cell);
|
width: var(--cell);
|
||||||
height: var(--cell);
|
height: var(--cell);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class GameController extends AbstractController
|
||||||
): Response {
|
): Response {
|
||||||
$this->validateCsrfToken('game_start', $request);
|
$this->validateCsrfToken('game_start', $request);
|
||||||
$request->getSession()->remove('revealed_game_id');
|
$request->getSession()->remove('revealed_game_id');
|
||||||
|
$request->getSession()->remove('revealed_attempted_letters');
|
||||||
|
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
|
|
@ -115,6 +116,7 @@ class GameController extends AbstractController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$request->getSession()->set('revealed_attempted_letters', $this->extractAttemptedLetters($request));
|
||||||
$game->abandon();
|
$game->abandon();
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$request->getSession()->set('revealed_game_id', $game->getId());
|
$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 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,11 @@ class HomepageController extends AbstractController
|
||||||
$game = $revealedGame;
|
$game = $revealedGame;
|
||||||
} else {
|
} else {
|
||||||
$request->getSession()->remove('revealed_game_id');
|
$request->getSession()->remove('revealed_game_id');
|
||||||
|
$request->getSession()->remove('revealed_attempted_letters');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$request->getSession()->remove('revealed_game_id');
|
$request->getSession()->remove('revealed_game_id');
|
||||||
|
$request->getSession()->remove('revealed_attempted_letters');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +78,7 @@ class HomepageController extends AbstractController
|
||||||
'grid' => $gridData['grid'],
|
'grid' => $gridData['grid'],
|
||||||
'width' => $gridData['width'],
|
'width' => $gridData['width'],
|
||||||
'middle' => $gridData['middle'],
|
'middle' => $gridData['middle'],
|
||||||
|
'attemptedLetters' => $request->getSession()->get('revealed_attempted_letters', []),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@
|
||||||
<div class="abandon-popover" id="abandon-popover">
|
<div class="abandon-popover" id="abandon-popover">
|
||||||
<p class="abandon-popover-text">Êtes-vous sûr de vouloir abandonner ?</p>
|
<p class="abandon-popover-text">Êtes-vous sûr de vouloir abandonner ?</p>
|
||||||
<div class="abandon-popover-actions">
|
<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="_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>
|
<button type="submit" class="btn btn-abandon-confirm">Abandonner</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" class="btn btn-abandon-cancel" id="abandon-cancel">Non, continuer</button>
|
<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 trigger = document.getElementById('abandon-trigger');
|
||||||
var popover = document.getElementById('abandon-popover');
|
var popover = document.getElementById('abandon-popover');
|
||||||
var cancel = document.getElementById('abandon-cancel');
|
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() {
|
trigger.addEventListener('click', function() {
|
||||||
popover.classList.toggle('open');
|
popover.classList.toggle('open');
|
||||||
});
|
});
|
||||||
|
|
@ -34,6 +63,7 @@
|
||||||
popover.classList.remove('open');
|
popover.classList.remove('open');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
form.addEventListener('submit', collectAttemptedLetters);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -49,6 +79,7 @@
|
||||||
width: width,
|
width: width,
|
||||||
middle: middle,
|
middle: middle,
|
||||||
revealed: game.status == constant('App\\Entity\\Game::STATUS_ABANDONED'),
|
revealed: game.status == constant('App\\Entity\\Game::STATUS_ABANDONED'),
|
||||||
|
attemptedLetters: attemptedLetters,
|
||||||
}) }}></div>
|
}) }}></div>
|
||||||
|
|
||||||
<div class="game-footer">
|
<div class="game-footer">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\Session\Session;
|
||||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
class GameControllerTest extends TestCase
|
class GameControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -75,6 +76,53 @@ class GameControllerTest extends TestCase
|
||||||
$this->assertSame(42, $request->getSession()->get('current_game_id'));
|
$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
|
private function createContainer(): ContainerInterface
|
||||||
{
|
{
|
||||||
$router = $this->createStub(UrlGeneratorInterface::class);
|
$router = $this->createStub(UrlGeneratorInterface::class);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue