fix: bugs critiques du système d'événements + stabilisation E2E

Trois bugs fonctionnels réels découverts et corrigés en stabilisant les
tests E2E des cartes Événement :

1. client/src/stores/game.js — le store Pinia n'exposait JAMAIS les champs
   liés aux bâtiments (myBuildings, pendingBuyBuildingId, availableBuildings)
   ni aux événements (myEventHand, eventDeckSize, hasPendingEventDraw,
   pendingEventPlayId) : updateState() ne les lisait pas depuis le
   'game-state' serveur. Conséquence concrète : GameScene._epidemicNeedsTarget()
   lisait this.store.myBuildings (toujours vide) pour décider si la
   variante Église d'Épidémie s'applique — le ciblage de la variante Trousse
   ne fonctionnait donc jamais correctement dès qu'un joueur possédait
   aussi le Château. Corrigé + 4 nouveaux tests unitaires Vitest.

2. client/src/game/scenes/GameScene.js — GameScene._onEventCardSelected
   envoyait encore les clés historiques gainTargetSocketId/
   loseTargetSocketId/targetSocketId au serveur, alors que Room.js avait été
   migré vers gainTargetTeamIndex/loseTargetTeamIndex/targetTeamIndex
   (socketId n'est jamais exposé au client). Conséquence : le ciblage
   d'Accord royal et de la variante Trousse d'Épidémie était intégralement
   cassé en jeu réel — les clés ne correspondant à rien côté serveur, aucune
   cible n'était jamais appliquée, silencieusement.

3. e2e/tests/helpers.js — drawEventUntil abandonnait immédiatement dès que
   la main d'événements atteignait 2 cartes sans contenir l'id recherché,
   rendant les tests E2E d'événements extrêmement flaky (avec seulement 6
   types d'événements et une main de 2, l'échec dès les 2 premières pioches
   est fréquent, pas rare). Corrigé : joue désormais une carte pour libérer
   un slot et continue à piocher jusqu'à maxDraws (20).

Stabilisation supplémentaire :
- Toutes les fonctions d'introspection Phaser dans helpers.js sécurisées
  (optional chaining) contre une scène pas encore initialisée après un
  changement de tour/navigation sous charge (plusieurs pages Playwright
  simultanées).
- Timeouts des tests events.spec.js augmentés (150-240s) pour laisser le
  temps aux boucles d'accumulation de jauge + pioche aléatoire de converger.
- playwright.config.js : retries=1 même en local, pour absorber la
  variance résiduelle des tests basés sur un tirage aléatoire, tout en
  gardant une détection fiable des vraies régressions (qui échoueraient de
  façon reproductible sur les deux tentatives).
- events.spec.js : test 'Accord royal' fournit désormais les 2 cibles
  requises par le ciblage séquentiel (gain + perte) au lieu d'une seule,
  ce qui laissait la séquence de ciblage bloquée sans jamais émettre
  'play-event'.

Résultat : 166 tests unitaires serveur + 28 tests unitaires client
toujours au vert ; suite events.spec.js (9 tests E2E) passe intégralement
(certains via retry probabiliste documenté, comportement attendu vu la
pioche aléatoire dans le deck partagé).
This commit is contained in:
Kevin L'Hereec committed 2026-08-24 13:24:37 +02:00
1 parent 1726f8f96b
commit 44d4eb7e96
6 files changed
+244 -106

No files matched your search

+10 -5
View File
@@ -789,13 +789,18 @@ export class GameScene extends Phaser.Scene {
}
if (event.id === 'royal_accord') {
this._startTargeting(card, ['gainTargetSocketId', 'loseTargetSocketId'], {
gainTargetSocketId: 'Choisissez un joueur qui gagnera 1 Diplomatie',
loseTargetSocketId: 'Choisissez un joueur qui perdra 1 Diplomatie'
// IMPORTANT : ces clés doivent correspondre exactement au contrat
// attendu par le serveur (Room.playEventCard / _resolvePendingEventPlays,
// server/src/game/Room.js) — le ciblage transite par teamIndex
// (identifiant public), jamais par socketId (donnée serveur interne
// jamais exposée au client).
this._startTargeting(card, ['gainTargetTeamIndex', 'loseTargetTeamIndex'], {
gainTargetTeamIndex: 'Choisissez un joueur qui gagnera 1 Diplomatie',
loseTargetTeamIndex: 'Choisissez un joueur qui perdra 1 Diplomatie'
})
} else if (event.id === 'epidemic') {
this._startTargeting(card, ['targetSocketId'], {
targetSocketId: 'Choisissez l\'adversaire qui perdra 2 Santé (Trousse du Médecin)'
this._startTargeting(card, ['targetTeamIndex'], {
targetTeamIndex: 'Choisissez l\'adversaire qui perdra 2 Santé (Trousse du Médecin)'
})
}
}
+27
View File
@@ -17,6 +17,17 @@ export const useGameStore = defineStore('game', () => {
const availableItems = ref([]) // ids des objets encore en stock (non achetés)
const firstPlayerName = ref(null) // nom du joueur prioritaire pour l'achat ce tour
// ── Bâtiments (achat, effets permanents) ────────────────────────────────────
const myBuildings = ref([]) // ids des bâtiments possédés par ce joueur
const pendingBuyBuildingId = ref(null) // id du bâtiment en attente d'achat ce tour (ou null)
const availableBuildings = ref([]) // ids des bâtiments encore en stock (non achetés)
// ── Cartes Événement (pioche, seconde main) ─────────────────────────────────
const myEventHand = ref([]) // ids des cartes événement piochées mais pas encore jouées (max 2)
const eventDeckSize = ref(0) // nombre de cartes restantes dans le deck partagé
const hasPendingEventDraw = ref(false) // true si une pioche a déjà été tentée ce tour
const pendingEventPlayId = ref(null) // id de la carte événement choisie pour être jouée ce tour (ou null)
// ── Fin de partie ────────────────────────────────────────────────────────────
const gameOverResults = ref(null) // liste des joueurs (avec score) une fois la partie terminée, sinon null
@@ -39,6 +50,13 @@ export const useGameStore = defineStore('game', () => {
if (state.pendingBuyItemId !== undefined) pendingBuyItemId.value = state.pendingBuyItemId
if (state.availableItems !== undefined) availableItems.value = state.availableItems
if (state.firstPlayerName !== undefined) firstPlayerName.value = state.firstPlayerName
if (state.myBuildings !== undefined) myBuildings.value = state.myBuildings
if (state.pendingBuyBuildingId !== undefined) pendingBuyBuildingId.value = state.pendingBuyBuildingId
if (state.availableBuildings !== undefined) availableBuildings.value = state.availableBuildings
if (state.myEventHand !== undefined) myEventHand.value = state.myEventHand
if (state.eventDeckSize !== undefined) eventDeckSize.value = state.eventDeckSize
if (state.hasPendingEventDraw !== undefined) hasPendingEventDraw.value = state.hasPendingEventDraw
if (state.pendingEventPlayId !== undefined) pendingEventPlayId.value = state.pendingEventPlayId
}
function selectCard(card) { selectedCard.value = card }
@@ -57,6 +75,13 @@ export const useGameStore = defineStore('game', () => {
pendingBuyItemId.value = null
availableItems.value = []
firstPlayerName.value = null
myBuildings.value = []
pendingBuyBuildingId.value = null
availableBuildings.value = []
myEventHand.value = []
eventDeckSize.value = 0
hasPendingEventDraw.value = false
pendingEventPlayId.value = null
gameOverResults.value = null
}
@@ -64,6 +89,8 @@ export const useGameStore = defineStore('game', () => {
playerName, players, currentTurn, maxTurns,
phase, hand, myTeamIndex, selectedCard,
myItems, pendingBuyItemId, availableItems, firstPlayerName,
myBuildings, pendingBuyBuildingId, availableBuildings,
myEventHand, eventDeckSize, hasPendingEventDraw, pendingEventPlayId,
gameOverResults,
setPlayer, updateState, selectCard, clearSelection, reset, setGameOver
}
+75 -1
View File
@@ -21,6 +21,13 @@ describe('stores/game (Pinia)', () => {
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()
})
@@ -95,6 +102,59 @@ describe('stores/game (Pinia)', () => {
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('selectCard / clearSelection', () => {
@@ -135,7 +195,14 @@ describe('stores/game (Pinia)', () => {
myItems: ['trebuchet'],
pendingBuyItemId: 'plow',
availableItems: ['medkit'],
firstPlayerName: 'Bob'
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 }])
@@ -153,6 +220,13 @@ describe('stores/game (Pinia)', () => {
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)