From fcad0f5d88e31dddb8f09d079fc27d6d115d67da Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Tue, 25 Aug 2026 17:24:54 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20le=20HUD=20mettait=20=C3=A0=20jour=20les?= =?UTF-8?q?=20compteurs=20des=20joueurs=20trop=20t=C3=B4t=20pendant=20la?= =?UTF-8?q?=20r=C3=A9solution=20du=20tour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/stores/game.js | 37 +++++++++- client/test/unit/game.store.test.js | 67 ++++++++++++++++++ e2e/tests/hud-resolution-timing.spec.js | 90 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 e2e/tests/hud-resolution-timing.spec.js diff --git a/client/src/stores/game.js b/client/src/stores/game.js index 8cbb07a..e031eb9 100644 --- a/client/src/stores/game.js +++ b/client/src/stores/game.js @@ -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 { diff --git a/client/test/unit/game.store.test.js b/client/test/unit/game.store.test.js index d4197b0..bea56d7 100644 --- a/client/test/unit/game.store.test.js +++ b/client/test/unit/game.store.test.js @@ -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', () => { diff --git a/e2e/tests/hud-resolution-timing.spec.js b/e2e/tests/hud-resolution-timing.spec.js new file mode 100644 index 0000000..2b52c08 --- /dev/null +++ b/e2e/tests/hud-resolution-timing.spec.js @@ -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() + }) +})