Files
regnum/e2e/tests/helpers.js
T
Kevin L'Hereec a5ecc23498 fix: bouton de confirmation désactivé après avoir sélectionné une carte, si une autre action réseau survient entretemps
Cause : le listener 'game-state' de GameScene se déclenche à CHAQUE action
de N'IMPORTE QUEL joueur dans la salle (jouer une carte, acheter un objet
ou un bâtiment, piocher/jouer un événement) — pas seulement au vrai
changement de tour. À chaque déclenchement, dès que phase==='selecting',
_deactivateBtn() était appelé INCONDITIONNELLEMENT, même si le joueur avait
déjà sélectionné une carte de sa main (this.selectedCard non-null) sans
encore l'avoir confirmée.

Conséquence concrète : sélectionner une carte, PUIS effectuer n'importe
quelle autre action (typiquement piocher une carte Événement) désactivait
visuellement le bouton "CONFIRMER MA CARTE" (fond terne, texte grisé) alors
que la carte restait réellement sélectionnée en mémoire ET visuellement
surélevée sur le plateau (CardObject._selected reste true, jamais
désélectionnée par ce chemin). Un premier clic sur la carte la
DÉSÉLECTIONNAIT alors (CardObject._onDown bascule select/deselect selon
_selected, déjà à true), obligeant à cliquer une seconde fois pour la
vraiment resélectionner — exactement le symptôme rapporté.

Fix : ne réinitialiser visuellement le bouton (_deactivateBtn()) que si
aucune carte n'est actuellement sélectionnée (),
respectant l'état réel de sélection au lieu de le réinitialiser à chaque
game-state reçu.

Nouveau test E2E de régression (e2e/tests/card-selection-persistence.spec.js)
reproduisant précisément le scénario : sélectionner une carte, piocher un
événement, vérifier que selectedCard ET le pulse-tween du bouton restent
actifs, puis confirmer directement sans recliquer sur la carte. Confirmé
discriminant : échoue de façon reproductible (2/2 tentatives) quand le fix
est retiré isolément, passe systématiquement avec le fix en place.

Ajout des helpers e2e/tests/helpers.js: selectHandCard() et
confirmSelectedCard() (extraits de playCard(), qui reste inchangée en
façade — les 5 usages existants de playCard() dans la suite E2E restent
compatibles sans modification).

54 tests unitaires client + 166 tests unitaires serveur + suite
game-flow.spec.js (4 tests, non liés à ce fix) toujours au vert.
2026-08-25 15:01:55 +02:00

472 lines
20 KiB
JavaScript

/**
* Fonctions d'aide partagées par les tests E2E Regnum.
*
* Toutes les interactions avec le canvas Phaser passent par un hook de debug
* injecté via `page.addInitScript` (pas de modification du code source de
* l'application) qui expose chaque instance `Phaser.Game` créée sur
* `window.__ALL_GAMES__`. Cela permet d'introspecter l'état réel de la scène
* (position des cartes/boutons, contenu de la main, etc.) plutôt que de
* deviner des coordonnées fixes à l'écran — ce qui serait fragile face aux
* changements de mise en page (voir historique des bugs de resize corrigés).
*/
/** Script à injecter via page.addInitScript avant toute navigation. */
export function phaserDebugHookInitScript() {
window.__ALL_GAMES__ = []
const patchWhenReady = () => {
if (window.Phaser && window.Phaser.Game && !window.Phaser.Game.__patched__) {
const OrigGame = window.Phaser.Game
function PatchedGame(config) {
const inst = new OrigGame(config)
window.__ALL_GAMES__.push(inst)
return inst
}
PatchedGame.prototype = OrigGame.prototype
PatchedGame.__patched__ = true
window.Phaser.Game = PatchedGame
} else {
setTimeout(patchWhenReady, 5)
}
}
patchWhenReady()
}
/** Installe le hook de debug Phaser sur une page (à appeler avant page.goto). */
export async function installPhaserDebugHook(page) {
await page.addInitScript(phaserDebugHookInitScript)
}
/** 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')
})
}
/** Crée une salle (P1) et retourne son roomId. */
export async function createRoom(page, playerName) {
await page.goto('/')
await page.fill('input[placeholder="Ton nom de joueur"]', playerName)
await page.click('button:has-text("Créer une partie")')
await page.waitForURL('**/lobby/**')
return page.url().split('/lobby/')[1]
}
/** Rejoint une salle existante. */
export async function joinRoom(page, playerName, roomId) {
await page.goto('/')
await page.fill('input[placeholder="Ton nom de joueur"]', playerName)
await page.fill('input[placeholder="Code de la salle"]', roomId)
await page.click('button:has-text("Rejoindre")')
await page.waitForURL('**/lobby/**')
}
/** Démarre la partie depuis le lobby (doit être appelé par l'hôte, P1). */
export async function startGame(page) {
await page.click('button:has-text("Lancer la partie")')
await page.waitForURL('**/game/**')
}
/**
* Joue une carte de la main du joueur et confirme. Sélectionne dynamiquement
* la carte MAXIMISANT (mode='max', défaut) ou MINIMISANT (mode='min') la
* SOMME des attributs donnés — `attr` accepte une chaîne ('war') ou un
* tableau (['health', 'diplo']). Sans argument, joue la première carte de la
* main. Lit l'état réel de la scène — jamais de coordonnées fixes, pour
* rester robuste face aux changements de layout. Ne fait rien si la main est
* vide (fin de partie).
*
* Le mode 'min' est indispensable pour fiabiliser les tests d'accumulation
* de jauge : le gain d'un tour = max(0, ma_valeur - valeur_du_voisin_de_gauche),
* donc pour garantir un gain non-nul sur un attribut donné, il ne suffit pas
* que l'adversaire joue "un autre attribut" (sa carte peut avoir une valeur
* élevée sur l'attribut visé par pur hasard) — il faut explicitement qu'il
* joue sa carte la plus FAIBLE sur cet attribut précis.
*/
/**
* Clique sur une carte de la main (sans confirmer) pour la sélectionner.
* Séparé de playCard() pour pouvoir tester des scénarios où d'autres
* actions ont lieu ENTRE la sélection et la confirmation (ex: piocher un
* événement pendant qu'une carte personnage est déjà sélectionnée — voir
* la régression "il faut recliquer sur la carte pour la re-sélectionner").
* Retourne false si la main est vide.
*/
export async function selectHandCard(page, attr = null, mode = 'max') {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const handLen = await page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.handCards?.length ?? 0
})
if (handLen === 0) return false
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')
if (!scene?.handCards?.length) return null
let idx = 0
if (attrs) {
let bestVal = mode === 'min' ? Infinity : -1
scene.handCards.forEach((c, i) => {
const sum = attrs.reduce((s, a) => s + (c.cardData[a] ?? 0), 0)
const better = mode === 'min' ? sum < bestVal : sum > bestVal
if (better) { bestVal = sum; idx = i }
})
}
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)
return true
}
/** Clique sur le bouton "CONFIRMER MA CARTE" (suppose une carte déjà sélectionnée). */
export async function confirmSelectedCard(page) {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
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 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
}
/** Sélectionne une carte de la main puis la confirme immédiatement (comportement historique de playCard). */
export async function playCard(page, attr = null, mode = 'max') {
const selected = await selectHandCard(page, attr, mode)
if (!selected) return false
return confirmSelectedCard(page)
}
/**
* Joue un tour complet pour une liste de pages (dans l'ordre donné), puis
* attend la résolution + le début du tour suivant (délai serveur fixe de
* 3.5s dans index.js + marge de sécurité).
*
* attrsByPage[i] peut être : null (première carte), une chaîne/tableau
* d'attributs (mode 'max' implicite), ou { attr, mode } pour choisir
* explicitement 'max' ou 'min' (voir playCard).
*/
export async function playFullTurn(pages, attrsByPage = []) {
for (let i = 0; i < pages.length; i++) {
const spec = attrsByPage[i] ?? null
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
await playCard(pages[i], spec.attr, spec.mode ?? 'max')
} else {
await playCard(pages[i], spec)
}
}
await pages[0].waitForTimeout(4200)
}
/**
* Survole un jeton d'objet de la boutique (colonne à gauche du plateau) par
* son id, et retourne le centre du bouton ACHETER si l'objet est disponible
* et achetable. Retourne null si le jeton n'existe pas (déjà acheté) ou si le
* bouton n'est pas interactif (jauge insuffisante).
*/
export async function hoverShopItem(page, itemId) {
const canvas = page.locator('#phaser-container canvas')
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)
return token ? { x: token.x, y: token.y } : null
}, itemId)
if (!tokenPos) return null
await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y)
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)
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)
const worldPoint = btnBg.getWorldTransformMatrix().transformPoint(
btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY
)
return { x: worldPoint.x, y: worldPoint.y }
}, itemId)
}
/** Clique sur le bouton ACHETER d'un objet actuellement affiché en survol. */
export async function buyShopItem(page, itemId) {
const btnCenter = await hoverShopItem(page, itemId)
if (!btnCenter) return false
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y)
await page.waitForTimeout(400)
return true
}
/** 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)
return me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 }
})
}
/** 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) ?? []
})
}
/**
* Survole un jeton de bâtiment de la boutique (colonne à droite du plateau)
* par son id, et retourne le centre du bouton ACHETER si disponible et
* achetable. Retourne null si le jeton n'existe pas (déjà acheté) ou si le
* bouton n'est pas interactif (jauge insuffisante sur au moins un attribut).
*/
export async function hoverShopBuilding(page, buildingId) {
const canvas = page.locator('#phaser-container canvas')
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)
return token ? { x: token.x, y: token.y } : null
}, buildingId)
if (!tokenPos) return null
await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y)
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)
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)
const worldPoint = btnBg.getWorldTransformMatrix().transformPoint(
btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY
)
return { x: worldPoint.x, y: worldPoint.y }
}, buildingId)
}
/** Clique sur le bouton ACHETER d'un bâtiment actuellement affiché en survol. */
export async function buyShopBuilding(page, buildingId) {
const btnCenter = await hoverShopBuilding(page, buildingId)
if (!btnCenter) return false
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y)
await page.waitForTimeout(400)
return true
}
/** 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) ?? []
})
}
// ── Cartes Événement ─────────────────────────────────────────────────────────
/**
* 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 ?? []
})
}
/** Clique sur le bouton de pioche d'événement (si actuellement cliquable). */
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
if (!btn || !btn.input || !btn.input.enabled) return null
const b = btn.getBounds()
return { x: b.centerX, y: b.centerY }
})
if (!btnInfo) return false
await page.mouse.click(box.x + btnInfo.x, box.y + btnInfo.y)
await page.waitForTimeout(400)
return true
}
/**
* Pioche des cartes événement jusqu'à obtenir l'id demandé (ou jusqu'à
* atteindre maxDraws tentatives). Le deck partagé est mélangé aléatoirement
* (18 cartes, 3 exemplaires de chacun des 6 événements) donc aucune pioche
* spécifique n'est garantie à coup sûr — mais avec un nombre de tentatives
* suffisant sur un deck de cette taille, la probabilité de ne jamais tomber
* sur l'événement recherché reste négligeable en pratique de test.
*
* IMPORTANT : cette fonction gère l'espacement en tours nécessaire pour
* reconstituer les jauges entre deux pioches (coût de 1 point dans chacune
* 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 = 20) {
// Événements sans ciblage requis (au pire un ciblage optionnel côté
// Épidémie, déclenché seulement avec la Trousse — jamais possédée dans un
// scénario de test frais). Utilisés pour vider un slot de la main sans
// jamais déclencher une séquence de ciblage qui resterait bloquée faute
// de clic sur un slot adverse (playEventCard sans targetTeamIndexes).
const SAFE_TO_DISCARD = new Set(['bountiful_harvest', 'troop_levy', 'plot_uncovered', 'castle_feast', 'epidemic'])
for (let attempt = 0; attempt < maxDraws; attempt++) {
const hand = await getMyEventHand(page)
if (hand.includes(eventId)) return true
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.
// IMPORTANT : ne JAMAIS choisir 'royal_accord' pour cette défausse —
// il exige un ciblage séquentiel à 2 étapes (gain + perte) ; le jouer
// sans fournir targetTeamIndexes laisserait la séquence de ciblage
// bloquée côté client (aucun 'play-event' jamais émis), et donc la
// main jamais libérée, causant l'échec systématique observé.
const discardable = hand.find(id => SAFE_TO_DISCARD.has(id)) ?? hand[0]
if (discardable === 'royal_accord') {
// Les 2 cartes en main sont 'royal_accord' (cas rare) : impossible
// de la défausser sans ciblage séquentiel complet. On cible le
// premier adversaire réel (n'importe quel autre teamIndex que le
// mien) pour les deux étapes — le ciblage doit obligatoirement
// pointer sur un adversaire pour que le clic sur le slot déclenche
// réellement _onTargetSlotClicked (les highlights ne sont posés que
// sur les slots adverses, jamais sur le mien).
const myTeamIndex = await page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
return game?.scene?.getScene('GameScene')?.store?.myTeamIndex ?? 0
})
const opponentTeamIndex = myTeamIndex === 0 ? 1 : 0
await playEventCard(page, discardable, [opponentTeamIndex, opponentTeamIndex])
} else {
await playEventCard(page, discardable)
}
await playFullTurn([page, ...otherPages])
continue
}
const gauges = await getMyGauges(page)
const affordable = ['food', 'war', 'health', 'diplo'].every(a => (gauges[a] ?? 0) >= 1)
if (affordable) {
const drew = await clickEventDrawButton(page)
if (drew) continue // retente immédiatement la vérification de main sans jouer de tour
}
// Jauges insuffisantes : jouer un tour complet (toutes les cartes au
// hasard, peu importe l'attribut) pour accumuler au moins 1 point sur
// chaque jauge avant de retenter.
await playFullTurn([page, ...otherPages])
}
return (await getMyEventHand(page)).includes(eventId)
}
/**
* Joue une carte de ma main d'événements ce tour. Les cartes événement
* utilisent désormais le même composant que les objets/bâtiments
* (ItemBoardToken, kind='event') : un jeton compact toujours visible, dont
* il faut survoler pour révéler la carte détaillée, puis cliquer sur son
* bouton "JOUER" — plus un simple clic direct sur une grande carte comme
* l'ancien EventCardObject.
*
* Si l'événement nécessite un ciblage, fournir `targetTeamIndexes`
* (tableau, dans l'ordre attendu par GameScene._startTargeting : pour
* royal_accord [gainTargetTeamIndex, loseTargetTeamIndex], pour epidemic
* [targetTeamIndex]) — les clics correspondants sur les slots adversaires
* sont effectués automatiquement après le clic sur "JOUER".
*/
export async function playEventCard(page, eventId, targetTeamIndexes = []) {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const tokenPos = await page.evaluate((eventId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.eventCards?.find(c => c.itemData.id === eventId)
return token ? { x: token.x, y: token.y } : null
}, eventId)
if (!tokenPos) return false
await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y)
await page.waitForTimeout(300)
const btnCenter = await page.evaluate((eventId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.eventCards?.find(c => c.itemData.id === eventId)
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 (une autre carte déjà jouée ce tour)
const worldPoint = btnBg.getWorldTransformMatrix().transformPoint(
btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY
)
return { x: worldPoint.x, y: worldPoint.y }
}, eventId)
if (!btnCenter) return false
await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y)
await page.waitForTimeout(250)
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) ?? -1
if (idx === -1) return null
const slot = scene.playSlots[idx]
return slot ? { x: slot.x, y: slot.y } : null
}, teamIndex)
if (!slotPos) continue
await page.mouse.click(box.x + slotPos.x, box.y + slotPos.y)
await page.waitForTimeout(250)
}
return true
}