Files
regnum/e2e/tests/events.spec.js
T
Kevin L'Hereec 44d4eb7e96 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é).
2026-08-24 13:24:37 +02:00

379 lines
13 KiB
JavaScript

import { test, expect } from '@playwright/test'
import {
installPhaserDebugHook, createRoom, joinRoom, startGame,
playCard, playFullTurn, getMyGauges, getMyEventHand,
clickEventDrawButton, drawEventUntil, playEventCard
} from './helpers.js'
/**
* Tests d'intégration E2E : cartes Événement (pioche partagée de 18 cartes,
* main limitée à 2, choix de jouer une carte, résolution avant les gains de
* cartes personnage, synergies avec objets/bâtiments, ciblage de joueurs).
*/
test.describe('Cartes Événement', () => {
test('le bouton de pioche est grisé/non cliquable tant que les jauges sont à 0', async ({ browser }) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const drew = await clickEventDrawButton(p1)
expect(drew).toBe(false)
await ctx1.close()
await ctx2.close()
})
test('acheter une pioche débite 1 point de chacune des 4 jauges et ajoute une carte à la main', 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()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
// Accumuler au moins 1 point sur les 4 jauges pour P1 : P1 maximise la
// somme des 4 attributs, P2 joue explicitement sa carte la plus FAIBLE
// sur ces mêmes attributs (mode 'min') — indispensable pour garantir un
// gain non-nul (gain = max(0, ma_valeur - valeur_voisin_gauche)), sinon
// la boucle peut ne jamais converger selon les cartes distribuées.
let gauges = await getMyGauges(p1)
let attempts = 0
while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 12) {
await playFullTurn([p1, p2], [
['food', 'war', 'health', 'diplo'],
{ attr: ['food', 'war', 'health', 'diplo'], mode: 'min' }
])
gauges = await getMyGauges(p1)
attempts++
}
expect(['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1)).toBe(true)
const before = { ...gauges }
const drew = await clickEventDrawButton(p1)
expect(drew).toBe(true)
const after = await getMyGauges(p1)
expect(after.food).toBe(before.food - 1)
expect(after.war).toBe(before.war - 1)
expect(after.health).toBe(before.health - 1)
expect(after.diplo).toBe(before.diplo - 1)
const hand = await getMyEventHand(p1)
expect(hand.length).toBe(1)
await ctx1.close()
await ctx2.close()
})
test('la main d\'événements est limitée à 2 cartes (le bouton se désactive ensuite)', async ({ browser }) => {
test.setTimeout(150_000)
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
async function ensureGaugesAndDraw() {
let gauges = await getMyGauges(p1)
let attempts = 0
while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 12) {
await playFullTurn([p1, p2], [
['food', 'war', 'health', 'diplo'],
{ attr: ['food', 'war', 'health', 'diplo'], mode: 'min' }
])
gauges = await getMyGauges(p1)
attempts++
}
return clickEventDrawButton(p1)
}
expect(await ensureGaugesAndDraw()).toBe(true)
// La pioche est immédiate (pas de coût d'un tour), mais un joueur ne peut
// tenter qu'UNE pioche par tour : il faut jouer le tour pour pouvoir
// retenter la pioche suivante.
await playFullTurn([p1, p2])
expect(await ensureGaugesAndDraw()).toBe(true)
const hand = await getMyEventHand(p1)
expect(hand.length).toBe(2)
// Une 3e tentative doit être refusée (main pleine)
await playFullTurn([p1, p2])
let gauges = await getMyGauges(p1)
let attempts = 0
while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 12) {
await playFullTurn([p1, p2], [
['food', 'war', 'health', 'diplo'],
{ attr: ['food', 'war', 'health', 'diplo'], mode: 'min' }
])
gauges = await getMyGauges(p1)
attempts++
}
const thirdDraw = await clickEventDrawButton(p1)
expect(thirdDraw).toBe(false)
await ctx1.close()
await ctx2.close()
})
test('jouer "Récolte exceptionnelle" donne +2 Nourriture, résolu avant les gains de carte personnage', async ({ browser }) => {
test.setTimeout(210_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)
const drawn = await drawEventUntil(p1, [p2], 'bountiful_harvest')
test.skip(!drawn, 'Récolte exceptionnelle non piochée dans la limite de tentatives (probabiliste)')
const foodBefore = (await getMyGauges(p1)).food
await playEventCard(p1, 'bountiful_harvest')
// Neutraliser le gain de carte personnage sur food en jouant la carte
// MINIMISANT food pour les deux joueurs, afin d'isoler l'effet événement.
await playFullTurn([p1, p2], [
{ attr: 'food', mode: 'min' },
{ attr: 'food', mode: 'min' }
])
const foodAfter = (await getMyGauges(p1)).food
expect(foodAfter).toBeGreaterThanOrEqual(foodBefore + 2)
expect(errors).toEqual([])
await ctx1.close()
await ctx2.close()
})
test('jouer "Épidémie" fait perdre 1 Santé à tous les joueurs (effet de base, sans objet/bâtiment)', async ({ browser }) => {
test.setTimeout(210_000)
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
// Accumuler un peu de Santé pour Alice afin que la perte soit visible
// sans clamp à 0 : Alice maximise health, Bob joue explicitement sa
// carte la plus FAIBLE en health (mode 'min') pour garantir un gain
// non-nul à chaque tour.
let healthGauge = (await getMyGauges(p1)).health
let healthAttempts = 0
while (healthGauge < 3 && healthAttempts < 8) {
await playFullTurn([p1, p2], [
['health'],
{ attr: 'health', mode: 'min' }
])
healthGauge = (await getMyGauges(p1)).health
healthAttempts++
}
const drawn = await drawEventUntil(p1, [p2], 'epidemic')
test.skip(!drawn, 'Épidémie non piochée dans la limite de tentatives (probabiliste)')
const p1HealthBefore = (await getMyGauges(p1)).health
await playEventCard(p1, 'epidemic')
await playFullTurn([p1, p2], [
{ attr: 'health', mode: 'min' },
{ attr: 'health', mode: 'min' }
])
const p1HealthAfter = (await getMyGauges(p1)).health
// Le lanceur lui-même perd aussi 1 Santé avec l'effet de base (tout le
// monde affecté), avant tout gain de carte personnage neutralisé.
expect(p1HealthAfter).toBeLessThanOrEqual(p1HealthBefore)
await ctx1.close()
await ctx2.close()
})
test('jouer "Accord royal" avec ciblage : le joueur choisi en gain reçoit +1 Diplomatie', async ({ browser }) => {
// Timeout plus généreux que les autres tests d'événement : ce scénario
// nécessite 3 joueurs (pour avoir 2 cibles distinctes disponibles), ce
// qui multiplie par ~1.5 le coût de chaque tour (3 playCard au lieu de 2)
// et peut nécessiter plusieurs cycles d'accumulation de jauge avant de
// pouvoir à la fois piocher ET rencontrer l'événement recherché.
test.setTimeout(240_000)
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const ctx3 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
const p3 = await ctx3.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
await installPhaserDebugHook(p3)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await joinRoom(p3, 'Carl', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const drawn = await drawEventUntil(p1, [p2, p3], 'royal_accord')
test.skip(!drawn, 'Accord royal non piochée dans la limite de tentatives (probabiliste)')
const bobDiploBefore = (await getMyGauges(p2)).diplo
// Le ciblage d'Accord royal est séquentiel et exige LES DEUX étapes
// (gain puis perte) avant que la carte ne soit réellement confirmée et
// envoyée au serveur (voir GameScene._onTargetSlotClicked/_startTargeting)
// — fournir une seule cible laisse la séquence de ciblage bloquée en
// attente de la seconde, sans jamais émettre 'play-event'.
// Cible teamIndex 1 (Bob) pour le gain, teamIndex 2 (Carl) pour la perte.
await playEventCard(p1, 'royal_accord', [1, 2])
await playFullTurn([p1, p2, p3])
const bobDiploAfter = (await getMyGauges(p2)).diplo
expect(bobDiploAfter).toBeGreaterThanOrEqual(bobDiploBefore + 1)
await ctx1.close()
await ctx2.close()
await ctx3.close()
})
test('jouer "Complot découvert" ne modifie aucune jauge par lui-même', async ({ browser }) => {
test.setTimeout(210_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)
const drawn = await drawEventUntil(p1, [p2], 'plot_uncovered')
test.skip(!drawn, 'Complot découvert non piochée dans la limite de tentatives (probabiliste)')
await playEventCard(p1, 'plot_uncovered')
await playFullTurn([p1, p2], [
{ attr: 'food', mode: 'min' },
{ attr: 'food', mode: 'min' }
])
expect(errors).toEqual([])
await ctx1.close()
await ctx2.close()
})
test('jouer "Grande fête au château" donne +1 Nourriture et +1 Diplomatie', async ({ browser }) => {
test.setTimeout(210_000)
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const drawn = await drawEventUntil(p1, [p2], 'castle_feast')
test.skip(!drawn, 'Grande fête non piochée dans la limite de tentatives (probabiliste)')
const before = await getMyGauges(p1)
await playEventCard(p1, 'castle_feast')
await playFullTurn([p1, p2], [
{ attr: ['food', 'diplo'], mode: 'min' },
{ attr: ['food', 'diplo'], mode: 'min' }
])
const after = await getMyGauges(p1)
expect(after.food).toBeGreaterThanOrEqual(before.food + 1)
expect(after.diplo).toBeGreaterThanOrEqual(before.diplo + 1)
await ctx1.close()
await ctx2.close()
})
test('les résultats des cartes Événement jouées apparaissent dans le résumé du tour', async ({ browser }) => {
test.setTimeout(210_000)
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const drawn = await drawEventUntil(p1, [p2], 'bountiful_harvest')
test.skip(!drawn, 'Récolte exceptionnelle non piochée dans la limite de tentatives (probabiliste)')
await playEventCard(p1, 'bountiful_harvest')
// Jouer la carte personnage du tour (via le helper standard, déjà
// défensif face aux états de scène non encore initialisés) pour
// déclencher la résolution et observer le résumé affiché juste après.
await playCard(p1)
await playFullTurn([p2])
await p1.waitForTimeout(500)
const readyText = await p1.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
return game?.scene?.getScene('GameScene')?.readyText?.text ?? ''
})
expect(readyText).toContain('Récolte exceptionnelle')
await ctx1.close()
await ctx2.close()
})
})