/** * 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. */ export async function playCard(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) 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 } /** * 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) { 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. 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) 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) } /** * Sélectionne (clique) une carte de ma main d'événements pour la jouer ce * tour. 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. */ export async function playEventCard(page, eventId, targetTeamIndexes = []) { const canvas = page.locator('#phaser-container canvas') 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) if (!card) return null const b = card.getBounds() return { x: b.centerX, y: b.centerY } }, eventId) if (!cardPos) return false await page.mouse.click(box.x + cardPos.x, box.y + cardPos.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 }