Files
regnum/e2e/tests/helpers.js
T
Kevin L'Hereec 9139989fa7 feat: bâtiments achetables (Église, Château Royal) avec synergie objets
- Nouveau server/src/game/buildings.js : 2 bâtiments, chacun couvrant 2
  attributs, coût réparti sur les deux jauges, garantit +1 par tour sur
  chaque attribut, avec bonus de synergie +1 si l'objet correspondant est
  aussi possédé (Église+Trousse/Sceau, Château+Charrue/Trébuchet).
- Room.js : buyBuilding() (validation coût multi-jauges), résolution des
  achats en attente avec la même priorité/stock unique que les objets,
  _applyBuildingEffects() (garantie de base + synergie, jamais de recul par
  rapport aux gains déjà boostés par un objet). getStateFor/getPlayersInfo
  exposent buildings/myBuildings/availableBuildings/pendingBuyBuildingId.
- Nouvel event socket 'buy-building', boughtBuildings inclus dans round-result.
- Client : buildings.js (miroir), BootScene génère les textures portrait des
  bâtiments (méthode _generatePortraitTexture généralisée, réutilisée avec
  les objets). ItemBoardToken généralisé (kind='item'|'building', hAnchor
  'left'|'right') pour afficher indifféremment un objet ou un bâtiment, avec
  coût détaillé par jauge affiché sur la carte agrandie pour les bâtiments.
- GameScene : boutique de bâtiments en colonne à DROITE du plateau
  (symétrique à la boutique d'objets à gauche), mes bâtiments possédés
  affichés à GAUCHE de ma main (symétrique à mes objets à droite), bâtiments
  des adversaires affichés sous leurs objets au niveau de leur slot.
- Fix : la carte détaillée héritait par défaut d'un ancrage "s'étend vers la
  droite" adapté à la boutique d'objets (bord gauche du canvas) mais cassait
  la boutique de bâtiments (bord droit) — le bouton ACHETER se retrouvait
  hors du canvas et donc injoignable au clic. Ajout du paramètre hAnchor.
- 26 nouveaux tests unitaires serveur (buildings.js + Room.js : validations
  d'achat multi-jauges, priorité/stock unique, effets de base et synergie).
- 6 nouveaux tests E2E Playwright (boutique bâtiments : affichage, position,
  carte détaillée dans les limites du canvas, coût affiché, achat complet).
- Fix flakiness des tests E2E d'achat (item-shop + building-shop) : le mode
  'min' de playCard garantit désormais que l'adversaire joue sa carte la
  plus faible sur l'attribut ciblé, au lieu de simplement jouer un autre
  attribut (insuffisant, le gain dépend de la comparaison au voisin gauche).
2026-08-21 16:19:18 +02:00

274 lines
11 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)
})
}