Files
regnum/e2e/tests/building-shop.spec.js
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

249 lines
9.8 KiB
JavaScript

import { test, expect } from '@playwright/test'
import {
installPhaserDebugHook, createRoom, joinRoom, startGame,
playFullTurn, getShopBuildingIds, getMyGauges, buyShopBuilding, hoverShopBuilding
} from './helpers.js'
/**
* Tests d'intégration E2E : boutique de bâtiments (Église, Château Royal).
*
* Couvre notamment la régression détectée manuellement : la carte détaillée
* du bâtiment (affichée au survol) s'étendait par défaut vers la DROITE
* depuis le jeton, comme pour la boutique d'objets à gauche du plateau —
* mais la boutique de bâtiments est sur le bord DROIT du canvas, donc la
* carte débordait hors du canvas et son bouton ACHETER devenait injoignable
* au clic (coordonnées calculées hors des limites du canvas). Corrigé via le
* paramètre `hAnchor='right'` sur ItemBoardToken pour cette boutique.
*/
test.describe('Boutique de bâtiments', () => {
test('la boutique affiche les 2 bâtiments (Église, Château) au démarrage de la partie', async ({ browser }) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const ids = await getShopBuildingIds(p1)
expect(ids.sort()).toEqual(['castle', 'church'].sort())
await ctx1.close()
await ctx2.close()
})
test('les jetons de bâtiment sont positionnés sur le bord DROIT du canvas', async ({ browser }) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const canvasWidth = await p1.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
return game.canvas.width
})
const tokenX = await p1.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
return scene.buildingTokens[0].x
})
// Doit être proche du bord droit (dans le dernier quart du canvas)
expect(tokenX).toBeGreaterThan(canvasWidth * 0.75)
await ctx1.close()
await ctx2.close()
})
test('la carte détaillée d\'un bâtiment reste entièrement visible dans le canvas au survol', async ({ browser }) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const canvas = p1.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const tokenPos = await p1.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const t = scene.buildingTokens.find(t => t.itemData.id === 'church')
return { x: t.x, y: t.y }
})
// Le survol (_scheduleShow, HOVER_SHOW_DELAY=80ms) puis le hide-timer
// (_scheduleHide, HOVER_HIDE_DELAY=100ms) sont tous deux de courts
// setTimeout côté navigateur ; sous charge (exécution juste après un
// autre test dans la même suite), le timing réel peut légèrement
// dépasser une seule attente fixe. On répète le mouvement de souris
// jusqu'à ce que la carte soit confirmée ouverte, plutôt que de se fier
// à un unique délai figé.
let expandedBounds = null
for (let attempt = 0; attempt < 5 && !expandedBounds; attempt++) {
await p1.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y + (attempt % 2)) // micro-mouvement pour re-déclencher pointerover si besoin
await p1.waitForTimeout(300)
expandedBounds = await p1.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const t = scene.buildingTokens.find(t => t.itemData.id === 'church')
if (!t._expanded) return null
const b = t._expanded.getBounds()
return { x: b.x, y: b.y, right: b.right, bottom: b.bottom }
})
}
expect(expandedBounds).not.toBeNull()
expect(expandedBounds.x).toBeGreaterThanOrEqual(0)
expect(expandedBounds.y).toBeGreaterThanOrEqual(0)
expect(expandedBounds.right).toBeLessThanOrEqual(box.width)
expect(expandedBounds.bottom).toBeLessThanOrEqual(box.height)
await ctx1.close()
await ctx2.close()
})
test('la carte détaillée affiche le coût réparti sur les 2 jauges et la description', async ({ browser }) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
const canvas = p1.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const tokenPos = await p1.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const t = scene.buildingTokens.find(t => t.itemData.id === 'church')
return { x: t.x, y: t.y }
})
await p1.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y)
await p1.waitForTimeout(300)
const texts = await p1.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game.scene.getScene('GameScene')
const t = scene.buildingTokens.find(t => t.itemData.id === 'church')
return t._expanded.list.filter(o => o.type === 'Text').map(o => o.text)
})
expect(texts).toContain('Église')
expect(texts.some(t => /3 Santé.*2 Diplomatie/.test(t))).toBe(true)
expect(texts.some(t => t.includes('Garantit +1 en Santé'))).toBe(true)
await ctx1.close()
await ctx2.close()
})
test('le bouton ACHETER n\'est pas interactif si UNE SEULE des 2 jauges requises est insuffisante', async ({ browser }) => {
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
// Jauges à 0 en tout début de partie : Église (3 santé + 2 diplo) n'est pas achetable
const btnCenter = await hoverShopBuilding(p1, 'church')
expect(btnCenter).toBeNull()
await ctx1.close()
await ctx2.close()
})
test('acheter un bâtiment le retire de la boutique pour TOUS les joueurs, débite les 2 jauges, et applique son effet', async ({ browser }) => {
test.setTimeout(60_000)
const ctx1 = await browser.newContext()
const ctx2 = await browser.newContext()
const p1 = await ctx1.newPage()
const p2 = await ctx2.newPage()
await installPhaserDebugHook(p1)
await installPhaserDebugHook(p2)
const roomId = await createRoom(p1, 'Alice')
await joinRoom(p2, 'Bob', roomId)
await startGame(p1)
await p1.waitForTimeout(2000)
// Accumuler assez de Santé ET de Diplomatie pour Alice (coût de l'Église :
// 3 santé + 2 diplo), pendant que Bob joue explicitement sa carte la plus
// FAIBLE sur ces deux mêmes attributs (mode 'min') — indispensable pour
// garantir un gain non-nul à chaque tour (gain = max(0, ma_valeur -
// valeur_voisin_gauche)), sinon le test devient flaky selon les cartes
// distribuées aléatoirement à chaque partie.
let gauges = await getMyGauges(p1)
let attempts = 0
while ((gauges.health < 3 || gauges.diplo < 2) && attempts < 12) {
await playFullTurn([p1, p2], [
['health', 'diplo'],
{ attr: ['health', 'diplo'], mode: 'min' }
])
gauges = await getMyGauges(p1)
attempts++
}
expect(gauges.health).toBeGreaterThanOrEqual(3)
expect(gauges.diplo).toBeGreaterThanOrEqual(2)
const idsBefore = await getShopBuildingIds(p1)
expect(idsBefore).toContain('church')
const bought = await buyShopBuilding(p1, 'church')
expect(bought).toBe(true)
// Jouer le tour pour déclencher la résolution de l'achat en attente
await playFullTurn([p1, p2])
const idsAfterP1 = await getShopBuildingIds(p1)
const idsAfterP2 = await getShopBuildingIds(p2)
expect(idsAfterP1).not.toContain('church')
expect(idsAfterP2).not.toContain('church')
expect(idsAfterP1).toContain('castle') // l'autre bâtiment reste disponible
// Le bâtiment apparaît dans "mes bâtiments" (posés à gauche de ma main)
const myBuildingsCount = await p1.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
return game.scene.getScene('GameScene').myBuildingTokens.length
})
expect(myBuildingsCount).toBe(1)
// Et sous le slot d'Alice depuis la perspective de Bob (adversaire)
const opponentBuildingsCount = await p2.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
return game.scene.getScene('GameScene').opponentBuildingTokens.length
})
expect(opponentBuildingsCount).toBe(1)
await ctx1.close()
await ctx2.close()
})
})