Files
regnum/e2e/tests/helpers.js
T
Kevin L'Hereec 1726f8f96b feat: cartes Événement (pioche partagée de 18 cartes, 6 effets, synergies, ciblage)
- Nouveau server/src/game/events.js : 6 événements (Récolte exceptionnelle,
  Épidémie, Levée des troupes, Accord royal, Complot découvert, Grande fête
  au château), deck PARTAGÉ de 18 cartes (3 exemplaires de chacun) mélangé
  au démarrage de la partie.
- Room.js : buyEventDraw() (coût fixe 1 point dans chacune des 4 jauges,
  effet immédiat contrairement aux objets/bâtiments — pas de conflit de
  stock possible), playEventCard() (choix de jouer une carte de sa main,
  max 1 par tour, avec ciblage optionnel), _resolvePendingEventPlays()
  (résolution de TOUS les événements du tour AVANT le calcul des gains de
  cartes personnage, défausse systématique, protection "Complot découvert"
  pour soi-même uniquement). Les 6 effets incluent leurs synergies avec les
  objets/bâtiments existants (Charrue, Trousse+Église pour Épidémie,
  Trébuchet, Sceau Royal, Château Royal → pioche bonus).
- IMPORTANT : le ciblage de joueur (Accord royal, variante Trousse
  d'Épidémie) transite par teamIndex (identifiant public) et non socketId
  (jamais exposé au client via getPlayersInfo/getStateFor) — le serveur
  résout teamIndex -> joueur réel au moment de la résolution.
- Nouvel event socket 'buy-event-draw' (effet immédiat) et 'play-event'
  (résolution différée), eventResults inclus dans round-result.
- Client : events.js (miroir), EventCardObject.js (carte épurée façon
  CardObject, badge rouge "!" pour les événements à effet négatif).
  BootScene génère les textures plein cadre (méthode _generateClippedTexture
  généralisée, réutilisée avec les personnages).
- GameScene : bouton de pioche à droite du bouton de confirmation (état
  grisé selon jauges/main pleine/pioche déjà tentée), seconde main de cartes
  événement (max 2) au-dessus de ce bouton, sélection d'une carte pour la
  jouer avec séquence de ciblage interactive (surbrillance des slots
  adversaires cliquables) pour Accord royal et la variante Trousse
  d'Épidémie.
- 61 nouveaux tests unitaires serveur (events.js + Room.js : pioche, choix
  de jouer, résolution des 6 effets avec/sans synergie, Complot découvert,
  ordre événements-avant-personnages, deck partagé).
- Nouveaux helpers et tests d'intégration E2E Playwright (e2e/tests/events.spec.js)
  pour la pioche, la limite de main à 2, et les 6 effets — certains
  scénarios restent probabilistes (pioche aléatoire dans le deck partagé) et
  peuvent nécessiter une stabilisation supplémentaire du timing.
2026-08-21 17:05:50 +02:00

380 lines
15 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.
*/
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')
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 })
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 }
})
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. */
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 = 12) {
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
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)
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
}