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

+67 -44
View File
@@ -39,8 +39,8 @@ export async function installPhaserDebugHook(page) {
/** Récupère la scène GameScene active dans le dernier jeu Phaser créé sur la page. */
export async function getGameScene(page) {
return page.evaluateHandle(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
return game.scene.getScene('GameScene')
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
return game?.scene?.getScene('GameScene')
})
}
@@ -98,8 +98,9 @@ export async function playCard(page, attr = null, mode = 'max') {
const attrs = attr === null ? null : (Array.isArray(attr) ? attr : [attr])
const cardPos = await page.evaluate(({ attrs, mode }) => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
if (!scene?.handCards?.length) return null
let idx = 0
if (attrs) {
let bestVal = mode === 'min' ? Infinity : -1
@@ -112,16 +113,18 @@ export async function playCard(page, attr = null, mode = 'max') {
const b = scene.handCards[idx].getBounds()
return { x: b.centerX, y: b.centerY }
}, { attrs, mode })
if (!cardPos) return false
await page.mouse.click(box.x + cardPos.x, box.y + cardPos.y)
await page.waitForTimeout(200)
const btnPos = await page.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const b = scene.playBtn.getBounds()
return { x: b.centerX, y: b.centerY }
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const b = scene?.playBtn?.getBounds()
return b ? { x: b.centerX, y: b.centerY } : null
})
if (!btnPos) return false
await page.mouse.click(box.x + btnPos.x, box.y + btnPos.y)
await page.waitForTimeout(200)
return true
@@ -159,9 +162,9 @@ export async function hoverShopItem(page, itemId) {
const box = await canvas.boundingBox()
const tokenPos = await page.evaluate((itemId) => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const token = scene.itemTokens.find(t => t.itemData.id === itemId)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.itemTokens?.find(t => t.itemData.id === itemId)
return token ? { x: token.x, y: token.y } : null
}, itemId)
@@ -171,9 +174,9 @@ export async function hoverShopItem(page, itemId) {
await page.waitForTimeout(300)
return page.evaluate((itemId) => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const token = scene.itemTokens.find(t => t.itemData.id === itemId)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.itemTokens?.find(t => t.itemData.id === itemId)
if (!token || !token._expanded) return null
const btnBg = token._expanded.list.find(o => o.type === 'Graphics' && o.input)
if (!btnBg) return null // bouton non interactif (jauge insuffisante)
@@ -199,9 +202,9 @@ export async function buyShopItem(page, itemId) {
/** Lit les jauges du joueur local depuis le store Pinia exposé par la scène. */
export async function getMyGauges(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const me = scene.store.players.find(p => p.teamIndex === scene.store.myTeamIndex)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const me = scene?.store?.players?.find(p => p.teamIndex === scene.store.myTeamIndex)
return me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 }
})
}
@@ -209,9 +212,9 @@ export async function getMyGauges(page) {
/** Lit l'état de la boutique (ids d'objets encore affichés dans la colonne). */
export async function getShopItemIds(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
return scene.itemTokens.map(t => t.itemData.id)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.itemTokens?.map(t => t.itemData.id) ?? []
})
}
@@ -226,9 +229,9 @@ export async function hoverShopBuilding(page, buildingId) {
const box = await canvas.boundingBox()
const tokenPos = await page.evaluate((buildingId) => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const token = scene.buildingTokens.find(t => t.itemData.id === buildingId)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.buildingTokens?.find(t => t.itemData.id === buildingId)
return token ? { x: token.x, y: token.y } : null
}, buildingId)
@@ -238,9 +241,9 @@ export async function hoverShopBuilding(page, buildingId) {
await page.waitForTimeout(300)
return page.evaluate((buildingId) => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const token = scene.buildingTokens.find(t => t.itemData.id === buildingId)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.buildingTokens?.find(t => t.itemData.id === buildingId)
if (!token || !token._expanded) return null
const btnBg = token._expanded.list.find(o => o.type === 'Graphics' && o.input)
if (!btnBg) return null // bouton non interactif (jauge insuffisante)
@@ -266,20 +269,27 @@ export async function buyShopBuilding(page, buildingId) {
/** Lit l'état de la boutique de bâtiments (ids encore affichés dans la colonne à droite). */
export async function getShopBuildingIds(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
return scene.buildingTokens.map(t => t.itemData.id)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.buildingTokens?.map(t => t.itemData.id) ?? []
})
}
// ── Cartes Événement ─────────────────────────────────────────────────────────
/** Lit ma main d'événements actuelle (ids, max 2) depuis le store Pinia exposé par la scène. */
/**
* Lit ma main d'événements actuelle (ids, max 2) depuis le store Pinia
* exposé par la scène. Défensif face au timing : `GameScene.create()` (qui
* assigne `this.store`) peut ne pas encore avoir tourné juste après un
* changement de scène/tour, notamment sous charge avec plusieurs pages
* Playwright simultanées — plutôt que planter, on retourne un tableau vide
* (état "pas encore prêt", cohérent avec drawEventUntil qui retentera).
*/
export async function getMyEventHand(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
return scene.store.myEventHand ?? []
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.store?.myEventHand ?? []
})
}
@@ -288,9 +298,9 @@ export async function clickEventDrawButton(page) {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const btnInfo = await page.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const btn = scene.eventDrawBtn
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const btn = scene?.eventDrawBtn
if (!btn || !btn.input || !btn.input.enabled) return null
const b = btn.getBounds()
return { x: b.centerX, y: b.centerY }
@@ -314,11 +324,24 @@ export async function clickEventDrawButton(page) {
* des 4 jauges) — l'appelant doit fournir `otherPages` pour que playFullTurn
* puisse faire progresser les tours pendant l'accumulation.
*/
export async function drawEventUntil(page, otherPages, eventId, maxDraws = 12) {
export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) {
for (let attempt = 0; attempt < maxDraws; attempt++) {
const hand = await getMyEventHand(page)
if (hand.includes(eventId)) return true
if (hand.length >= 2) return false // main pleine sans l'event recherché : abandon
if (hand.length >= 2) {
// Main pleine SANS l'événement recherché : au lieu d'abandonner (ce qui
// rendait ce helper très peu fiable — 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), jouer l'une des 2 cartes en main pour
// libérer un slot et pouvoir continuer à piocher. On joue la première
// carte sans ciblage particulier ; si elle nécessite un ciblage
// (royal_accord/epidemic), on tente sans cible plutôt que de bloquer
// le nettoyage de la main.
await playEventCard(page, hand[0])
await playFullTurn([page, ...otherPages])
continue
}
const gauges = await getMyGauges(page)
const affordable = ['food', 'war', 'health', 'diplo'].every(a => (gauges[a] ?? 0) >= 1)
@@ -349,9 +372,9 @@ export async function playEventCard(page, eventId, targetTeamIndexes = []) {
const box = await canvas.boundingBox()
const cardPos = await page.evaluate((eventId) => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const card = scene.eventCards.find(c => c.eventData.id === eventId)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const card = scene?.eventCards?.find(c => c.eventData.id === eventId)
if (!card) return null
const b = card.getBounds()
return { x: b.centerX, y: b.centerY }
@@ -363,9 +386,9 @@ export async function playEventCard(page, eventId, targetTeamIndexes = []) {
for (const teamIndex of targetTeamIndexes) {
const slotPos = await page.evaluate((teamIndex) => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const idx = scene.store.players.findIndex(p => p.teamIndex === teamIndex)
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const idx = scene?.store?.players?.findIndex(p => p.teamIndex === teamIndex) ?? -1
if (idx === -1) return null
const slot = scene.playSlots[idx]
return slot ? { x: slot.x, y: slot.y } : null