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.
This commit is contained in:
Kevin L'Hereec committed 2026-08-25 17:24:54 +02:00
1 parent 29cc728f09
commit fcad0f5d88
3 files changed
+192 -2

No files matched your search

+35 -2
View File
@@ -31,6 +31,12 @@ export const useGameStore = defineStore('game', () => {
// ── Fin de partie ────────────────────────────────────────────────────────────
const gameOverResults = ref(null) // liste des joueurs (avec score) une fois la partie terminée, sinon null
// ── Buffer interne : gel du HUD pendant la phase 'resolve' ──────────────────
// Non exposé dans le return (détail d'implémentation de updateState),
// voir le commentaire dans updateState() pour le pourquoi.
const _pendingPlayers = ref(null)
const _pendingCurrentTurn = ref(null)
function setGameOver(results) {
gameOverResults.value = results
}
@@ -40,8 +46,33 @@ export const useGameStore = defineStore('game', () => {
}
function updateState(state) {
players.value = state.players ?? []
currentTurn.value = state.currentTurn ?? 0
// Les jauges/scores affichés dans le HUD (players) ne doivent PAS sauter
// à leur nouvelle valeur dès que le serveur résout le tour : à cet
// instant précis, l'animation de révélation des cartes + des gains
// (+X sur chaque carte) démarre tout juste sur le plateau Phaser (voir
// GameScene._showPlayedCards, déclenchée par l'event 'round-result' émis
// au même moment que ce game-state en phase 'resolve'). Sans ce gel, le
// HUD affichait déjà les valeurs finales AVANT que le joueur ait vu
// l'animation qui les justifie, donnant l'impression que les compteurs
// se mettaient à jour "au mauvais moment" (trop tôt, désynchronisés de
// ce qui se passe visuellement sur le plateau).
//
// Le gel dure toute la phase 'resolve' (~3.5s côté serveur avant
// nextTurn) : players/currentTurn reçus pendant cette phase sont stockés
// dans un buffer, appliqué au prochain game-state dont la phase N'EST
// PLUS 'resolve' (typiquement 'selecting' du tour suivant, ou 'end').
if (state.phase === 'resolve') {
_pendingPlayers.value = state.players ?? []
_pendingCurrentTurn.value = state.currentTurn ?? currentTurn.value
} else {
// Sortie de la phase 'resolve' (ou jamais entré dedans) : appliquer
// en priorité le buffer s'il existe (valeurs déjà connues depuis le
// round-result), sinon les valeurs de cet état directement.
players.value = _pendingPlayers.value ?? state.players ?? []
currentTurn.value = _pendingCurrentTurn.value ?? state.currentTurn ?? 0
_pendingPlayers.value = null
_pendingCurrentTurn.value = null
}
maxTurns.value = state.maxTurns ?? 12
phase.value = state.phase ?? 'idle'
myTeamIndex.value = state.myTeamIndex ?? 0
@@ -83,6 +114,8 @@ export const useGameStore = defineStore('game', () => {
hasPendingEventDraw.value = false
pendingEventPlayId.value = null
gameOverResults.value = null
_pendingPlayers.value = null
_pendingCurrentTurn.value = null
}
return {
+67
View File
@@ -155,6 +155,73 @@ describe('stores/game (Pinia)', () => {
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', () => {
+90
View File
@@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test'
import {
installPhaserDebugHook, createRoom, joinRoom, startGame,
selectHandCard, confirmSelectedCard
} from './helpers.js'
/**
* Régression : le HUD Vue (jauges/scores en haut de l'écran) sautait à sa
* nouvelle valeur DÈS que le serveur résolvait le tour ('round-result' +
* 'game-state' en phase 'resolve' émis au même instant), en même temps que
* l'animation de révélation des cartes/gains démarrait tout juste sur le
* plateau Phaser (qui dure ~3.5s avant le tour suivant) — donnant
* l'impression que les compteurs se mettaient à jour "au mauvais moment".
*
* Fix : le store Pinia (client/src/stores/game.js) gèle désormais
* players/currentTurn pendant toute la phase 'resolve', et ne les applique
* qu'au game-state suivant dont la phase n'est plus 'resolve' (typiquement
* 'selecting' du tour suivant).
*/
test.describe('Synchronisation du HUD avec la résolution du tour', () => {
test('le score du HUD reste figé pendant la phase resolve, puis se met à jour au tour suivant', async ({ browser }) => {
test.setTimeout(60_000)
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
const errors = []
p1.on('pageerror', e => errors.push(e.message))
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
// Score initial affiché dans le HUD Vue (panneau du joueur local, premier panneau).
const scoreBefore = await p1.locator('.player-panel').first().locator('.total-score').textContent()
// Alice maximise "war", Bob joue explicitement sa carte la plus FAIBLE
// en guerre (mode 'min') — indispensable pour garantir un gain non-nul
// (gain = max(0, ma_valeur - valeur_voisin_gauche)), sinon le score ne
// change pas du tout et le test ne serait pas discriminant (il passerait
// aussi bien avec ou sans le fix, faute de changement à observer).
await selectHandCard(p1, 'war')
await confirmSelectedCard(p1)
await selectHandCard(p2, 'war', 'min')
await confirmSelectedCard(p2)
// Attendre ACTIVEMENT la phase 'resolve' plutôt qu'un délai fixe : sous
// charge, le round-result peut mettre plus ou moins de temps à arriver,
// et un délai fixe trop court laissait parfois passer la fenêtre
// d'observation (le tour suivant ayant déjà démarré entretemps).
await expect.poll(async () => {
return p1.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
return game?.scene?.getScene('GameScene')?.store?.phase
})
}, { timeout: 3000, intervals: [100] }).toBe('resolve')
// PENDANT la phase resolve (avant les ~3.5s de délai serveur), le score
// du HUD doit rester identique à sa valeur d'AVANT le tour — même si le
// serveur a déjà calculé et appliqué le nouveau score en interne.
const scoreDuringResolve = await p1.locator('.player-panel').first().locator('.total-score').textContent()
expect(scoreDuringResolve).toBe(scoreBefore)
// Attendre la fin du délai serveur (3.5s) + un peu de marge : le tour
// suivant démarre, phase repasse à 'selecting', le score doit alors se
// mettre à jour (ou au minimum ne plus être bloqué en 'resolve').
await p1.waitForTimeout(4200)
const phaseAfter = await p1.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
return game?.scene?.getScene('GameScene')?.store?.phase
})
expect(phaseAfter).not.toBe('resolve')
// Le score doit désormais avoir changé (le gain a bien été appliqué) —
// confirme que le test observe une vraie différence, pas un cas où le
// score n'aurait de toute façon jamais bougé.
const scoreAfter = await p1.locator('.player-panel').first().locator('.total-score').textContent()
expect(scoreAfter).not.toBe(scoreBefore)
expect(errors).toEqual([])
await ctx1.close()
await ctx2.close()
})
})