Files
Kevin L'Hereec fcad0f5d88 fix: le HUD mettait à jour les compteurs des joueurs trop tôt pendant la résolution du tour
Cause : le serveur émet 'round-result' (déclenche l'animation de révélation
des cartes/gains sur le plateau Phaser, ~3.5s avant le tour suivant) et
'game-state' en phase 'resolve' (déjà avec les jauges/scores post-résolution
via getPlayersInfo) au même instant. Le HUD Vue (alimenté directement par
gameStore.players) sautait donc à ses nouvelles valeurs INSTANTANÉMENT, en
même temps que l'animation de résolution démarrait tout juste sur le
plateau — les compteurs changeaient avant/pendant que le joueur voie
l'animation qui justifie ce changement, au lieu d'être synchronisés avec
elle.

Fix : le store Pinia (client/src/stores/game.js) gèle désormais
players/currentTurn pendant toute la phase 'resolve' — les valeurs reçues
sont stockées dans un buffer interne (_pendingPlayers/_pendingCurrentTurn)
au lieu d'être appliquées immédiatement, et ne sont propagées à l'affichage
qu'au game-state suivant dont la phase n'est PLUS 'resolve' (typiquement
'selecting' du tour suivant, ou 'end'). Le reste de l'état (phase, main,
objets/bâtiments/événements) continue de se mettre à jour normalement et
immédiatement — seul l'affichage des jauges/scores est différé.

6 nouveaux tests unitaires (game.store.test.js) couvrant le gel/dégel du
buffer, la rediffusion de plusieurs game-state en phase 'resolve'
successifs, la transition directe vers 'end', et le nettoyage du buffer
par reset().

Nouveau test E2E (hud-resolution-timing.spec.js) confirmé discriminant :
échoue de façon reproductible (2/2 tentatives) sans le fix ("0 pts" attendu
pendant resolve, "5 pts" déjà affiché), passe systématiquement avec le fix
(stable sur 3 répétitions). Attente active de la phase 'resolve' via
expect.poll plutôt qu'un délai fixe, pour éviter la fenêtre d'observation
manquée sous charge variable.

60 tests unitaires client + 166 tests unitaires serveur + suite
game-flow.spec.js (4 tests, non liée à ce fix) toujours au vert.
2026-08-25 17:24:54 +02:00

304 lines
12 KiB
JavaScript

import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useGameStore } from '../../src/stores/game'
describe('stores/game (Pinia)', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('a un état initial vide/par défaut', () => {
const store = useGameStore()
expect(store.playerName).toBe('')
expect(store.players).toEqual([])
expect(store.currentTurn).toBe(0)
expect(store.maxTurns).toBe(12)
expect(store.phase).toBe('idle')
expect(store.hand).toEqual([])
expect(store.myTeamIndex).toBe(0)
expect(store.selectedCard).toBeNull()
expect(store.myItems).toEqual([])
expect(store.pendingBuyItemId).toBeNull()
expect(store.availableItems).toEqual([])
expect(store.firstPlayerName).toBeNull()
expect(store.myBuildings).toEqual([])
expect(store.pendingBuyBuildingId).toBeNull()
expect(store.availableBuildings).toEqual([])
expect(store.myEventHand).toEqual([])
expect(store.eventDeckSize).toBe(0)
expect(store.hasPendingEventDraw).toBe(false)
expect(store.pendingEventPlayId).toBeNull()
expect(store.gameOverResults).toBeNull()
})
it('setPlayer met à jour playerName', () => {
const store = useGameStore()
store.setPlayer('Alice')
expect(store.playerName).toBe('Alice')
})
describe('updateState', () => {
it('met à jour tous les champs de base depuis un game-state serveur', () => {
const store = useGameStore()
store.updateState({
players: [{ name: 'Alice', score: 5 }],
currentTurn: 3,
maxTurns: 12,
phase: 'selecting',
hand: [{ id: 'queen' }],
myTeamIndex: 1
})
expect(store.players).toEqual([{ name: 'Alice', score: 5 }])
expect(store.currentTurn).toBe(3)
expect(store.maxTurns).toBe(12)
expect(store.phase).toBe('selecting')
expect(store.hand).toEqual([{ id: 'queen' }])
expect(store.myTeamIndex).toBe(1)
})
it('retombe sur les valeurs par défaut si les champs sont absents (sauf hand/items qui restent inchangés)', () => {
const store = useGameStore()
store.updateState({})
expect(store.players).toEqual([])
expect(store.currentTurn).toBe(0)
expect(store.maxTurns).toBe(12)
expect(store.phase).toBe('idle')
expect(store.myTeamIndex).toBe(0)
})
it('ne touche PAS à hand si state.hand est undefined (évite d\'effacer la main en cours)', () => {
const store = useGameStore()
store.updateState({ hand: [{ id: 'prince' }] })
store.updateState({ phase: 'waiting' }) // pas de champ hand cette fois
expect(store.hand).toEqual([{ id: 'prince' }])
})
it('met à jour les champs liés aux objets uniquement s\'ils sont fournis', () => {
const store = useGameStore()
store.updateState({
myItems: ['trebuchet'],
pendingBuyItemId: 'plow',
availableItems: ['medkit', 'royal_seal'],
firstPlayerName: 'Bob'
})
expect(store.myItems).toEqual(['trebuchet'])
expect(store.pendingBuyItemId).toBe('plow')
expect(store.availableItems).toEqual(['medkit', 'royal_seal'])
expect(store.firstPlayerName).toBe('Bob')
// Un second appel sans ces champs ne doit pas les réinitialiser
store.updateState({ phase: 'resolve' })
expect(store.myItems).toEqual(['trebuchet'])
expect(store.pendingBuyItemId).toBe('plow')
})
it('accepte explicitement pendingBuyItemId=null (achat annulé/résolu) sans le confondre avec "absent"', () => {
const store = useGameStore()
store.updateState({ pendingBuyItemId: 'plow' })
store.updateState({ pendingBuyItemId: null })
expect(store.pendingBuyItemId).toBeNull()
})
it('met à jour les champs liés aux bâtiments uniquement s\'ils sont fournis', () => {
const store = useGameStore()
store.updateState({
myBuildings: ['church'],
pendingBuyBuildingId: 'castle',
availableBuildings: ['castle']
})
expect(store.myBuildings).toEqual(['church'])
expect(store.pendingBuyBuildingId).toBe('castle')
expect(store.availableBuildings).toEqual(['castle'])
// Un second appel sans ces champs ne doit pas les réinitialiser
store.updateState({ phase: 'resolve' })
expect(store.myBuildings).toEqual(['church'])
expect(store.pendingBuyBuildingId).toBe('castle')
})
it('accepte explicitement pendingBuyBuildingId=null sans le confondre avec "absent"', () => {
const store = useGameStore()
store.updateState({ pendingBuyBuildingId: 'castle' })
store.updateState({ pendingBuyBuildingId: null })
expect(store.pendingBuyBuildingId).toBeNull()
})
it('met à jour les champs liés aux cartes Événement uniquement s\'ils sont fournis', () => {
const store = useGameStore()
store.updateState({
myEventHand: ['bountiful_harvest', 'epidemic'],
eventDeckSize: 15,
hasPendingEventDraw: true,
pendingEventPlayId: 'epidemic'
})
expect(store.myEventHand).toEqual(['bountiful_harvest', 'epidemic'])
expect(store.eventDeckSize).toBe(15)
expect(store.hasPendingEventDraw).toBe(true)
expect(store.pendingEventPlayId).toBe('epidemic')
// Un second appel sans ces champs ne doit pas les réinitialiser
store.updateState({ phase: 'resolve' })
expect(store.myEventHand).toEqual(['bountiful_harvest', 'epidemic'])
expect(store.eventDeckSize).toBe(15)
})
it('accepte explicitement pendingEventPlayId=null et hasPendingEventDraw=false sans les confondre avec "absent"', () => {
const store = useGameStore()
store.updateState({ pendingEventPlayId: 'epidemic', hasPendingEventDraw: true })
store.updateState({ pendingEventPlayId: null, hasPendingEventDraw: false })
expect(store.pendingEventPlayId).toBeNull()
expect(store.hasPendingEventDraw).toBe(false)
})
describe('gel du HUD pendant la phase "resolve"', () => {
it('ne met PAS à jour players/currentTurn immédiatement quand phase="resolve"', () => {
const store = useGameStore()
store.updateState({ players: [{ name: 'Alice', score: 0 }], currentTurn: 1, phase: 'selecting' })
store.updateState({ players: [{ name: 'Alice', score: 5 }], currentTurn: 1, phase: 'resolve' })
// Les valeurs affichées restent celles d'AVANT la résolution
expect(store.players).toEqual([{ name: 'Alice', score: 0 }])
expect(store.phase).toBe('resolve') // la phase, elle, est bien mise à jour immédiatement
})
it('applique les valeurs gelées au prochain game-state qui sort de la phase "resolve"', () => {
const store = useGameStore()
store.updateState({ players: [{ name: 'Alice', score: 0 }], currentTurn: 1, phase: 'selecting' })
store.updateState({ players: [{ name: 'Alice', score: 5 }], currentTurn: 1, phase: 'resolve' })
// Le tour suivant arrive (phase='selecting', currentTurn=2) : les
// valeurs gelées (score=5 du round-result) doivent s'appliquer ici,
// PAS les valeurs de ce nouveau game-state si elles diffèrent.
store.updateState({ players: [{ name: 'Alice', score: 999 }], currentTurn: 2, phase: 'selecting' })
expect(store.players).toEqual([{ name: 'Alice', score: 5 }])
expect(store.currentTurn).toBe(1)
})
it('applique directement les valeurs si phase="end" suit "resolve" (fin de partie)', () => {
const store = useGameStore()
store.updateState({ players: [{ name: 'Alice', score: 0 }], currentTurn: 12, phase: 'selecting' })
store.updateState({ players: [{ name: 'Alice', score: 42 }], currentTurn: 12, phase: 'resolve' })
store.updateState({ players: [{ name: 'Alice', score: 42 }], currentTurn: 13, phase: 'end' })
expect(store.players).toEqual([{ name: 'Alice', score: 42 }])
expect(store.phase).toBe('end')
})
it('n\'affecte pas les valeurs si la phase ne passe jamais par "resolve" (mise à jour directe classique)', () => {
const store = useGameStore()
store.updateState({ players: [{ name: 'Alice', score: 3 }], currentTurn: 2, phase: 'waiting' })
expect(store.players).toEqual([{ name: 'Alice', score: 3 }])
expect(store.currentTurn).toBe(2)
})
it('un second game-state en phase "resolve" (rediffusion) écrase le buffer avec les valeurs les plus récentes', () => {
const store = useGameStore()
store.updateState({ players: [{ name: 'Alice', score: 0 }], currentTurn: 1, phase: 'selecting' })
store.updateState({ players: [{ name: 'Alice', score: 5 }], currentTurn: 1, phase: 'resolve' })
store.updateState({ players: [{ name: 'Alice', score: 5 }], currentTurn: 1, phase: 'resolve' }) // rediffusion identique
store.updateState({ players: [{ name: 'Alice', score: 999 }], currentTurn: 2, phase: 'selecting' })
expect(store.players).toEqual([{ name: 'Alice', score: 5 }])
})
it('reset() vide aussi le buffer interne (pas de fuite de valeurs gelées vers une nouvelle partie)', () => {
const store = useGameStore()
store.updateState({ players: [{ name: 'Alice', score: 5 }], currentTurn: 3, phase: 'resolve' })
store.reset()
// Après reset, un nouveau game-state 'selecting' doit s'appliquer
// directement, sans que le buffer de l'ancienne partie ne persiste.
store.updateState({ players: [{ name: 'Bob', score: 0 }], currentTurn: 1, phase: 'selecting' })
expect(store.players).toEqual([{ name: 'Bob', score: 0 }])
expect(store.currentTurn).toBe(1)
})
})
})
describe('selectCard / clearSelection', () => {
it('selectCard définit la carte sélectionnée', () => {
const store = useGameStore()
const card = { id: 'knight', war: 5 }
store.selectCard(card)
expect(store.selectedCard).toEqual(card)
})
it('clearSelection remet selectedCard à null', () => {
const store = useGameStore()
store.selectCard({ id: 'knight' })
store.clearSelection()
expect(store.selectedCard).toBeNull()
})
})
describe('setGameOver', () => {
it('définit gameOverResults avec les résultats fournis', () => {
const store = useGameStore()
const results = [{ name: 'Alice', score: 42 }, { name: 'Bob', score: 30 }]
store.setGameOver(results)
expect(store.gameOverResults).toEqual(results)
})
})
describe('reset', () => {
it('remet tout l\'état à ses valeurs par défaut', () => {
const store = useGameStore()
store.setPlayer('Alice')
store.updateState({
players: [{ name: 'Alice' }],
currentTurn: 5,
phase: 'resolve',
hand: [{ id: 'queen' }],
myTeamIndex: 2,
myItems: ['trebuchet'],
pendingBuyItemId: 'plow',
availableItems: ['medkit'],
firstPlayerName: 'Bob',
myBuildings: ['church'],
pendingBuyBuildingId: 'castle',
availableBuildings: ['castle'],
myEventHand: ['epidemic'],
eventDeckSize: 10,
hasPendingEventDraw: true,
pendingEventPlayId: 'epidemic'
})
store.selectCard({ id: 'queen' })
store.setGameOver([{ name: 'Alice', score: 10 }])
store.reset()
expect(store.players).toEqual([])
expect(store.currentTurn).toBe(0)
expect(store.maxTurns).toBe(12)
expect(store.phase).toBe('idle')
expect(store.hand).toEqual([])
expect(store.myTeamIndex).toBe(0)
expect(store.selectedCard).toBeNull()
expect(store.myItems).toEqual([])
expect(store.pendingBuyItemId).toBeNull()
expect(store.availableItems).toEqual([])
expect(store.firstPlayerName).toBeNull()
expect(store.myBuildings).toEqual([])
expect(store.pendingBuyBuildingId).toBeNull()
expect(store.availableBuildings).toEqual([])
expect(store.myEventHand).toEqual([])
expect(store.eventDeckSize).toBe(0)
expect(store.hasPendingEventDraw).toBe(false)
expect(store.pendingEventPlayId).toBeNull()
expect(store.gameOverResults).toBeNull()
// playerName n'est PAS réinitialisé par reset() (comportement existant :
// le nom du joueur est conservé pour rejouer une partie sans le re-saisir)
expect(store.playerName).toBe('Alice')
})
})
})