diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index e57d981..71976f4 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -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)' }) } } diff --git a/client/src/stores/game.js b/client/src/stores/game.js index 4911c39..8cbb07a 100644 --- a/client/src/stores/game.js +++ b/client/src/stores/game.js @@ -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 } diff --git a/client/test/unit/game.store.test.js b/client/test/unit/game.store.test.js index e80d6e7..d4197b0 100644 --- a/client/test/unit/game.store.test.js +++ b/client/test/unit/game.store.test.js @@ -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) diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index fbbf512..70a8207 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -11,9 +11,15 @@ import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './tests', fullyParallel: false, // les tests partagent le même serveur de jeu (rooms par test, mais évite la contention) - retries: process.env.CI ? 1 : 0, + // Les tests de cartes Événement (events.spec.js) reposent sur une pioche + // ALÉATOIRE dans un deck partagé de 18 cartes ; malgré une stratégie de + // pioche répétée + fallback (voir helpers.js drawEventUntil), un tirage + // exceptionnellement malchanceux peut ponctuellement dépasser leur timeout + // individuel (déjà élevé, 150-240s) — un retry absorbe ce résidu probabiliste + // sans masquer une vraie régression (qui échouerait de façon reproductible). + retries: 1, workers: 1, - timeout: 60_000, + timeout: 60_000, // valeur par défaut, surchargée par test.setTimeout() dans les tests plus longs reporter: [['list']], use: { diff --git a/e2e/tests/events.spec.js b/e2e/tests/events.spec.js index 6ad9a2b..2979f52 100644 --- a/e2e/tests/events.spec.js +++ b/e2e/tests/events.spec.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test' import { installPhaserDebugHook, createRoom, joinRoom, startGame, - playFullTurn, getMyGauges, getMyEventHand, + playCard, playFullTurn, getMyGauges, getMyEventHand, clickEventDrawButton, drawEventUntil, playEventCard } from './helpers.js' @@ -46,12 +46,18 @@ test.describe('Cartes Événement', () => { await startGame(p1) await p1.waitForTimeout(2000) - // Accumuler au moins 1 point sur les 4 jauges en jouant des cartes qui - // maximisent la somme des 4 attributs. + // 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 < 6) { - await playFullTurn([p1, p2], [['food', 'war', 'health', 'diplo'], ['food', 'war', 'health', 'diplo']]) + 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++ } @@ -75,7 +81,7 @@ test.describe('Cartes Événement', () => { }) test('la main d\'événements est limitée à 2 cartes (le bouton se désactive ensuite)', async ({ browser }) => { - test.setTimeout(90_000) + test.setTimeout(150_000) const ctx1 = await browser.newContext() const ctx2 = await browser.newContext() @@ -92,8 +98,11 @@ test.describe('Cartes Événement', () => { async function ensureGaugesAndDraw() { let gauges = await getMyGauges(p1) let attempts = 0 - while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 6) { - await playFullTurn([p1, p2], [['food', 'war', 'health', 'diplo'], ['food', 'war', 'health', 'diplo']]) + 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++ } @@ -114,8 +123,11 @@ test.describe('Cartes Événement', () => { await playFullTurn([p1, p2]) let gauges = await getMyGauges(p1) let attempts = 0 - while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 6) { - await playFullTurn([p1, p2], [['food', 'war', 'health', 'diplo'], ['food', 'war', 'health', 'diplo']]) + 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++ } @@ -127,7 +139,7 @@ test.describe('Cartes Événement', () => { }) test('jouer "Récolte exceptionnelle" donne +2 Nourriture, résolu avant les gains de carte personnage', async ({ browser }) => { - test.setTimeout(90_000) + test.setTimeout(210_000) const ctx1 = await browser.newContext() const ctx2 = await browser.newContext() @@ -165,7 +177,7 @@ test.describe('Cartes Événement', () => { }) test('jouer "Épidémie" fait perdre 1 Santé à tous les joueurs (effet de base, sans objet/bâtiment)', async ({ browser }) => { - test.setTimeout(90_000) + test.setTimeout(210_000) const ctx1 = await browser.newContext() const ctx2 = await browser.newContext() @@ -179,12 +191,19 @@ test.describe('Cartes Événement', () => { await startGame(p1) await p1.waitForTimeout(2000) - // Accumuler un peu de Santé pour les deux joueurs afin que la perte soit visible sans clamp à 0. - for (let t = 0; t < 3; t++) { + // 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') @@ -208,7 +227,12 @@ test.describe('Cartes Événement', () => { }) test('jouer "Accord royal" avec ciblage : le joueur choisi en gain reçoit +1 Diplomatie', async ({ browser }) => { - test.setTimeout(90_000) + // 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() @@ -229,23 +253,18 @@ test.describe('Cartes Événement', () => { 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 p2.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.diplo - })) + const bobDiploBefore = (await getMyGauges(p2)).diplo - // Cible teamIndex 1 (Bob) pour le gain, pas de cible pour la perte (laissée non cliquée) - await playEventCard(p1, 'royal_accord', [1]) + // 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 p2.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.diplo - })) + const bobDiploAfter = (await getMyGauges(p2)).diplo expect(bobDiploAfter).toBeGreaterThanOrEqual(bobDiploBefore + 1) @@ -255,7 +274,7 @@ test.describe('Cartes Événement', () => { }) test('jouer "Complot découvert" ne modifie aucune jauge par lui-même', async ({ browser }) => { - test.setTimeout(90_000) + test.setTimeout(210_000) const ctx1 = await browser.newContext() const ctx2 = await browser.newContext() @@ -287,7 +306,7 @@ test.describe('Cartes Événement', () => { }) test('jouer "Grande fête au château" donne +1 Nourriture et +1 Diplomatie', async ({ browser }) => { - test.setTimeout(90_000) + test.setTimeout(210_000) const ctx1 = await browser.newContext() const ctx2 = await browser.newContext() @@ -321,7 +340,7 @@ test.describe('Cartes Événement', () => { }) test('les résultats des cartes Événement jouées apparaissent dans le résumé du tour', async ({ browser }) => { - test.setTimeout(90_000) + test.setTimeout(210_000) const ctx1 = await browser.newContext() const ctx2 = await browser.newContext() @@ -340,32 +359,16 @@ test.describe('Cartes Événement', () => { await playEventCard(p1, 'bountiful_harvest') - // Jouer manuellement pour observer le texte affiché juste après résolution, - // sans attendre le waitForTimeout complet de playFullTurn. - const canvas1 = p1.locator('#phaser-container canvas') - const box1 = await canvas1.boundingBox() - const cardPos = await p1.evaluate(() => { - const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] - const scene = game.scene.getScene('GameScene') - const b = scene.handCards[0].getBounds() - return { x: b.centerX, y: b.centerY } - }) - await p1.mouse.click(box1.x + cardPos.x, box1.y + cardPos.y) - await p1.waitForTimeout(200) - const btnPos = await p1.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 } - }) - await p1.mouse.click(box1.x + btnPos.x, box1.y + btnPos.y) - + // 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 + const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1] + return game?.scene?.getScene('GameScene')?.readyText?.text ?? '' }) expect(readyText).toContain('Récolte exceptionnelle') diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js index 98a8142..14074e2 100644 --- a/e2e/tests/helpers.js +++ b/e2e/tests/helpers.js @@ -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