fix: cartes Événement illisibles + objets/bâtiments adversaires au-dessus de leur carte

Deux problèmes de lisibilité/placement signalés :

1. Le texte des cartes Événement (nom + description) restait illisible
   malgré le passage précédent de l'échelle 0.55 à 0.8 — le composant dédié
   EventCardObject affichait une grande carte réduite, écrasant
   systématiquement le texte quelle que soit l'échelle choisie.

   Fix : suppression complète d'EventCardObject, remplacé par le même
   pattern que les objets/bâtiments (ItemBoardToken, nouveau kind='event') :
   un petit jeton compact (46x46) toujours visible sur le plateau, avec la
   carte détaillée affichée en TAILLE RÉELLE (police 13-16px, jamais
   réduite) au survol de la souris, et un bouton "JOUER" (au lieu
   d'"ACHETER") pour confirmer. ItemBoardToken généralisé pour supporter ce
   3e kind (pas de badge de coût affiché — déjà payé à la pioche — mais
   badge "!" repris pour les événements à effet négatif type Épidémie).

   Extraction de client/src/game/objects/tokenLayout.js (ICON_SIZE) dans un
   module SANS dépendance à Phaser, pour que eventHandLayout.js (et ses
   tests Vitest) puisse l'importer sans jamais charger Phaser — qui échoue
   à l'import en environnement jsdom (canvas natif requis, absent de jsdom).

2. Les mini-cartes objets/bâtiments déjà achetés par un ADVERSAIRE étaient
   affichées SOUS son slot de jeu, visuellement en dessous/après sa carte
   jouée. Déplacées AU-DESSUS de son slot pour les mettre davantage en
   évidence, comme posées sur sa carte plutôt que cachées dessous.

- 43 tests unitaires client mis à jour (eventHandLayout.test.js migré vers
  ICON_SIZE au lieu de CARD_W/CARD_H/scale, désormais indépendant de Phaser).
- e2e/tests/helpers.js : playEventCard adapté au nouveau flux
  survol->bouton JOUER (au lieu d'un simple clic direct sur la carte) ;
  drawEventUntil corrigé pour ne jamais défausser une carte 'royal_accord'
  sans fournir ses 2 cibles de ciblage séquentiel obligatoire (le jeu sans
  cible bloquait silencieusement la séquence, sans jamais émettre
  'play-event', rendant la boucle de pioche très peu fiable).
- Vérification ciblée via un test E2E existant (pas de nouvelle suite E2E
  lourde, conformément à la demande de privilégier les tests unitaires) :
  le nouveau flux jeton->survol->JOUER->résolution fonctionne bout en bout
  (Récolte exceptionnelle +2 Nourriture confirmé après résolution).
This commit is contained in:
Kevin L'Hereec committed 2026-08-24 16:01:04 +02:00
1 parent 45eb9be60d
commit 1a91a0f876
7 files changed
+316 -309

No files matched your search

-136
View File
@@ -1,136 +0,0 @@
import Phaser from 'phaser'
import { CARD_W, CARD_H, RADIUS } from './cardLayout'
/**
* Carte Événement dans la "seconde main" du joueur (max 2 cartes, piochées
* séparément des cartes personnage). Reprend le style épuré de CardObject
* (portrait plein cadre, nom en bas) avec un liseré doré distinctif (au lieu
* de la couleur d'équipe, non pertinente ici) et un badge rouge "!" pour les
* événements à effet négatif (Épidémie), afin que le joueur identifie vite
* les risques avant de choisir de la jouer.
*
* Contrairement à CardObject, la sélection ici signifie "je choisis de jouer
* CETTE carte événement ce tour" (au maximum une par tour) — gérée par
* GameScene, qui peut aussi déclencher une UI de ciblage supplémentaire
* avant de confirmer réellement l'émission au serveur.
*/
export class EventCardObject extends Phaser.GameObjects.Container {
constructor(scene, x, y, eventData) {
super(scene, x, y)
this.eventData = eventData
this._selected = false
this._baseY = y
this._build()
scene.add.existing(this)
}
_build() {
const textureKey = `event-${this.eventData.id}`
const hasTexture = this.scene.textures.exists(textureKey)
// ── 1. Ombre légère ─────────────────────────────────────────────────────
const shadow = this.scene.add.graphics()
shadow.fillStyle(0x000000, 0.4)
shadow.fillRoundedRect(-CARD_W / 2 + 3, -CARD_H / 2 + 3, CARD_W, CARD_H, RADIUS)
this.add(shadow)
// ── 2. Image portrait plein cadre ─────────────────────────────────────────
if (hasTexture) {
const img = this.scene.add.image(0, 0, textureKey)
this.add(img)
} else {
const bg = this.scene.add.graphics()
bg.fillStyle(0x2a1810, 1)
bg.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS)
this.add(bg)
const initial = this.scene.add.text(0, -10, this.eventData.name[0], {
fontSize: '70px', fontFamily: 'Georgia', color: '#ffffff'
}).setOrigin(0.5).setAlpha(0.15)
this.add(initial)
}
// ── 3. Liseré doré (distinct de la couleur d'équipe des personnages) ──────
const border = this.scene.add.graphics()
border.lineStyle(1.5, 0xd4a732, 0.9)
border.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS)
this.add(border)
// ── 4. Bandeau + nom en bas ──────────────────────────────────────────────
// Un fond semi-opaque derrière le texte (au lieu du seul contour) garde
// le nom lisible quel que soit le portrait en arrière-plan, cohérent
// avec l'affichage renforcé demandé pour cette carte (échelle réduite
// en main : voir EVENT_CARD_SCALE dans eventHandLayout.js).
const nameBandH = 34
const nameBand = this.scene.add.graphics()
nameBand.fillStyle(0x000000, 0.55)
nameBand.fillRect(-CARD_W / 2 + 3, CARD_H / 2 - nameBandH, CARD_W - 6, nameBandH - 3)
this.add(nameBand)
this.add(this.scene.add.text(
0, CARD_H / 2 - nameBandH / 2 - 1,
this.eventData.name, {
fontSize: '15px',
fontFamily: 'Georgia, serif',
fontStyle: 'bold',
color: '#f5e6c8',
stroke: '#000000',
strokeThickness: 3,
align: 'center',
lineSpacing: 2,
wordWrap: { width: CARD_W - 10 }
}
).setOrigin(0.5, 0.5))
// ── 5. Badge "effet négatif" (coin haut-droit) ────────────────────────────
if (this.eventData.negative) {
const bx = CARD_W / 2 - 16, by = -CARD_H / 2 + 16
const ring = this.scene.add.graphics()
ring.fillStyle(0x1a0a00, 0.95)
ring.fillCircle(bx, by, 12)
this.add(ring)
const disk = this.scene.add.graphics()
disk.fillStyle(0xcc2222, 1)
disk.fillCircle(bx, by, 10)
this.add(disk)
this.add(this.scene.add.text(bx, by, '!', {
fontSize: '14px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff'
}).setOrigin(0.5))
}
// ── 6. Interactivité ──────────────────────────────────────────────────────
this.setSize(CARD_W, CARD_H)
this.setInteractive({ cursor: 'pointer' })
this.on('pointerover', this._onHover, this)
this.on('pointerout', this._onOut, this)
this.on('pointerdown', this._onDown, this)
}
_onHover() {
if (this._selected) return
this.scene.tweens.add({ targets: this, y: this._baseY - 18, duration: 120, ease: 'Power2' })
this.setDepth(10)
}
_onOut() {
if (this._selected) return
this.scene.tweens.add({ targets: this, y: this._baseY, duration: 120, ease: 'Power2' })
this.setDepth(0)
}
_onDown() {
if (this._selected) { this.deselect() }
else { this.select(); this.emit('select', this) }
}
select() {
this._selected = true
this.setDepth(20)
this.scene.tweens.add({ targets: this, y: this._baseY - 28, duration: 150, ease: 'Back.Out' })
}
deselect() {
this._selected = false
this.setDepth(0)
this.scene.tweens.add({ targets: this, y: this._baseY, duration: 150, ease: 'Power2' })
}
}
+91 -42
View File
@@ -1,4 +1,5 @@
import Phaser from 'phaser'
import { ICON_SIZE } from './tokenLayout'
const ATTR_COLORS = {
food: 0xf0c030,
@@ -14,9 +15,12 @@ const ATTR_LABELS = {
diplo: 'Diplomatie'
}
// Taille du jeton compact affiché en permanence sur le plateau (avant mise
// à l'échelle éventuelle via displayScale — voir constructeur).
export const ICON_SIZE = 46
// Re-export pour compatibilité : ICON_SIZE est désormais défini dans
// tokenLayout.js (module pur, sans dépendance Phaser) afin que les modules
// de calcul de géométrie (ex: eventHandLayout.js) puissent l'importer sans
// jamais charger Phaser — nécessaire pour rester testables en Vitest/jsdom,
// qui échoue à l'import de Phaser (canvas natif requis, absent de jsdom).
export { ICON_SIZE }
// Taille de la carte détaillée affichée au survol. Volontairement grande et
// avec des polices généreuses pour que la description de l'effet reste
@@ -49,29 +53,38 @@ const HOVER_SHOW_DELAY = 80
* dessus le plateau, avec un bouton "ACHETER" ; elle disparaît dès que la
* souris s'éloigne.
*
* status: 'available' | 'owned' | 'taken'
* affordable: true si le joueur a assez de points dans la jauge liée à cet
* objet pour l'acheter maintenant (n'a de sens que si status='available').
* Le bouton "ACHETER" n'est cliquable que si affordable est true — sinon
* il est grisé pour indiquer clairement pourquoi l'achat n'est pas possible.
* status: 'available' | 'owned' | 'taken' (objets/bâtiments), ou
* 'available' | 'disabled' (cartes Événement — voir kind='event' : le
* bouton "JOUER" est actif si affordable=true, grisé sinon, jamais
* "possédé/indisponible" puisqu'une carte événement en main appartient
* déjà entièrement au joueur qui la consulte).
* affordable: pour un objet/bâtiment, true si le joueur a assez de points
* pour l'ACHETER (bouton "ACHETER"). Pour un événement, true si la carte
* peut être JOUÉE maintenant (aucune autre carte événement déjà
* sélectionnée ce tour — bouton "JOUER").
* displayScale: échelle du JETON COMPACT uniquement (ex: 0.55 pour les mini
* cartes des adversaires, 0.7 pour les miennes, 1 pour la boutique). La
* carte détaillée affichée au survol n'hérite JAMAIS de cette échelle —
* elle reste toujours à sa taille réelle pour rester lisible, quel que
* soit le contexte d'où elle a été ouverte.
* kind: 'item' (défaut) ou 'building'. Un objet a un seul attribut
* (`itemData.attr`) et un coût scalaire (`itemData.cost`). Un bâtiment a
* DEUX attributs (`itemData.attrs`, tableau) et un coût réparti sur les
* deux (`itemData.cost`, objet `{attr: montant}`) — cette classe gère les
* deux formes via kind, plutôt que dupliquer tout le composant.
* kind: 'item' (défaut), 'building', ou 'event'.
* - 'item' : un seul attribut (`itemData.attr`), coût scalaire (`itemData.cost`).
* - 'building' : DEUX attributs (`itemData.attrs`, tableau), coût réparti
* sur les deux (`itemData.cost`, objet `{attr: montant}`).
* - 'event' : pas d'attribut/coût affiché (le coût de pioche est déjà payé
* à l'achat de la carte) ; bouton "JOUER" au lieu de "ACHETER", émet
* l'événement 'play' au lieu de 'buy'.
* Cette classe gère les trois formes via kind, plutôt que dupliquer tout
* le composant pour chacune.
* hAnchor: 'left' (défaut) ou 'right'. Détermine de quel côté du jeton
* compact la carte détaillée s'étend horizontalement au survol. 'left' =
* la carte s'étend vers la DROITE (jeton ancré sur son coin gauche) —
* adapté à la boutique d'objets sur le bord GAUCHE du canvas. 'right' =
* la carte s'étend vers la GAUCHE (jeton ancré sur son coin droit) —
* nécessaire pour la boutique de bâtiments sur le bord DROIT du canvas,
* sinon la carte déborderait hors du canvas et son bouton ACHETER
* deviendrait injoignable au clic.
* nécessaire pour la boutique de bâtiments (et la main d'événements,
* également sur le bord DROIT) sur le bord DROIT du canvas, sinon la
* carte déborderait hors du canvas et son bouton deviendrait injoignable
* au clic.
*/
export class ItemBoardToken extends Phaser.GameObjects.Container {
constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1, kind = 'item', hAnchor = 'left') {
@@ -89,14 +102,19 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
scene.add.existing(this)
}
/** Attributs concernés par cet objet/bâtiment, toujours sous forme de tableau. */
/** Attributs concernés par cet objet/bâtiment (tableau vide pour un événement). */
_attrs() {
if (this.kind === 'event') return []
return this.kind === 'building' ? this.itemData.attrs : [this.itemData.attr]
}
/** Couleur représentative : celle de l'unique attribut pour un objet, ou un
* mélange des deux couleurs d'attribut pour un bâtiment. */
/** Couleur représentative : celle de l'unique attribut pour un objet, un
* mélange des deux couleurs d'attribut pour un bâtiment, ou le doré
* "Événement" pour un événement. */
_attrColor() {
if (this.kind === 'event') {
return this.itemData.negative ? 0xcc2222 : 0xd4a732
}
const attrs = this._attrs()
if (attrs.length === 1) return ATTR_COLORS[attrs[0]] ?? 0x888888
const c1 = ATTR_COLORS[attrs[0]] ?? 0x888888
@@ -108,7 +126,8 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
}
/** Coût total affiché sur le badge : le coût lui-même pour un objet, la
* somme des coûts par attribut pour un bâtiment. */
* somme des coûts par attribut pour un bâtiment. Un événement n'affiche
* pas de badge de coût (déjà payé au moment de la pioche). */
_costTotal() {
if (this.kind === 'building') {
return Object.values(this.itemData.cost).reduce((sum, c) => sum + c, 0)
@@ -118,7 +137,9 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
/** Clé de texture portrait, préfixée différemment selon le type. */
_textureKey() {
return this.kind === 'building' ? `building-${this.itemData.id}` : `item-${this.itemData.id}`
if (this.kind === 'building') return `building-${this.itemData.id}`
if (this.kind === 'event') return `event-${this.itemData.id}`
return `item-${this.itemData.id}`
}
_buildCompact() {
@@ -155,15 +176,35 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
border.strokeRoundedRect(-S / 2, -S / 2, S, S, 8)
compact.add(border)
// Badge de coût (coin haut-droit) — coût total pour un bâtiment
const bx = S / 2 - 7, by = -S / 2 + 7
const costRing = this.scene.add.graphics()
costRing.fillStyle(0x1a0a00, 1)
costRing.fillCircle(bx, by, 9)
compact.add(costRing)
compact.add(this.scene.add.text(bx, by, String(this._costTotal()), {
fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff'
}).setOrigin(0.5))
if (this.kind === 'event') {
// Pas de badge de coût (déjà payé à la pioche) ; à la place, un badge
// "!" pour signaler un événement à effet négatif — cohérent avec
// utile pour repérer vite les événements à risque avant de les jouer.
if (this.itemData.negative) {
const bx = S / 2 - 7, by = -S / 2 + 7
const ring = this.scene.add.graphics()
ring.fillStyle(0x1a0a00, 1)
ring.fillCircle(bx, by, 9)
compact.add(ring)
const disk = this.scene.add.graphics()
disk.fillStyle(0xcc2222, 1)
disk.fillCircle(bx, by, 7)
compact.add(disk)
compact.add(this.scene.add.text(bx, by, '!', {
fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff'
}).setOrigin(0.5))
}
} else {
// Badge de coût (coin haut-droit) — coût total pour un bâtiment
const bx = S / 2 - 7, by = -S / 2 + 7
const costRing = this.scene.add.graphics()
costRing.fillStyle(0x1a0a00, 1)
costRing.fillCircle(bx, by, 9)
compact.add(costRing)
compact.add(this.scene.add.text(bx, by, String(this._costTotal()), {
fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff'
}).setOrigin(0.5))
}
if (this.status === 'owned') {
const check = this.scene.add.graphics()
@@ -315,7 +356,10 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
border.strokeRoundedRect(-EXPANDED_W / 2, -cardH / 2, EXPANDED_W, cardH, 12)
c.add(border)
if (!isAvailable) {
// Bande de statut "POSSÉDÉ"/"INDISPONIBLE" — ne concerne que les objets
// et bâtiments (stock unique partagé). Une carte Événement en main
// n'a pas cette notion : elle appartient déjà entièrement au joueur.
if (this.kind !== 'event' && !isAvailable) {
const bandColor = this.status === 'owned' ? 0x2e7d32 : 0x555555
const bandText = this.status === 'owned' ? '✔ POSSÉDÉ' : 'INDISPONIBLE'
const band = this.scene.add.graphics()
@@ -327,35 +371,40 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
}).setOrigin(0.5))
}
// ── Bouton ACHETER : cliquable uniquement si affordable, sinon grisé ─────
// Distinct du reste de la carte (qui ne fait qu'afficher les infos) pour
// que le joueur voie explicitement s'il PEUT acheter maintenant ou non,
// au lieu de deviner en cliquant n'importe où sur la carte.
// ── Bouton d'action : "ACHETER" (objet/bâtiment) ou "JOUER" (événement) ──
// Cliquable uniquement si affordable, sinon grisé. Distinct du reste de
// la carte (qui ne fait qu'afficher les infos) pour que le joueur voie
// explicitement s'il PEUT agir maintenant ou non, au lieu de deviner en
// cliquant n'importe où sur la carte.
if (isAvailable) {
const isEvent = this.kind === 'event'
const btnY = cardH / 2 - BUY_BTN_H / 2 - 8
const btnW = EXPANDED_W - 20
const canBuy = this.affordable
const canAct = this.affordable
const actionLabel = isEvent ? 'JOUER' : 'ACHETER'
const disabledLabel = isEvent ? 'INDISPONIBLE' : 'INSUFFISANT'
const eventName = isEvent ? 'play' : 'buy'
const btnBg = this.scene.add.graphics()
btnBg.fillStyle(canBuy ? 0x2e7d32 : 0x3a3a3a, 1)
btnBg.fillStyle(canAct ? 0x2e7d32 : 0x3a3a3a, 1)
btnBg.fillRoundedRect(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H, 6)
btnBg.lineStyle(2, canBuy ? 0x7CFC9A : 0x666666, 1)
btnBg.lineStyle(2, canAct ? 0x7CFC9A : 0x666666, 1)
btnBg.strokeRoundedRect(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H, 6)
c.add(btnBg)
const btnText = this.scene.add.text(0, btnY, canBuy ? 'ACHETER' : 'INSUFFISANT', {
const btnText = this.scene.add.text(0, btnY, canAct ? actionLabel : disabledLabel, {
fontSize: '12px', fontFamily: 'Georgia', fontStyle: 'bold',
color: canBuy ? '#ffffff' : '#999999'
color: canAct ? '#ffffff' : '#999999'
}).setOrigin(0.5)
c.add(btnText)
if (canBuy) {
if (canAct) {
btnBg.setInteractive(
new Phaser.Geom.Rectangle(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H),
Phaser.Geom.Rectangle.Contains
)
btnBg.input.cursor = 'pointer'
btnBg.on('pointerdown', () => this.emit('buy', this.itemData))
btnBg.on('pointerdown', () => this.emit(eventName, this.itemData))
btnBg.on('pointerover', () => { this._cancelHide(); btnBg.setScale(1.03) })
btnBg.on('pointerout', () => { this._scheduleHide(); btnBg.setScale(1) })
}
+28 -21
View File
@@ -1,6 +1,6 @@
/**
* Calculs purs de géométrie pour la zone "cartes Événement" (bouton de
* pioche + main de cartes événement, max 2), extraits de GameScene pour
* pioche + main de jetons événement, max 2), extraits de GameScene pour
* être testables unitairement sans dépendre de Phaser/Canvas.
*
* Placement choisi : bas-DROITE de l'écran, clairement séparé du bouton
@@ -8,14 +8,19 @@
* (centrée en bas) pour éviter toute confusion entre "jouer une carte
* personnage" et "jouer une carte événement" — un chevauchement précédent
* plaçait le bouton de pioche à ~25px du bord du bouton de confirmation.
*
* Les cartes événement utilisent le même composant que les objets/bâtiments
* (ItemBoardToken) : un petit jeton compact (ICON_SIZE, voir ItemBoardToken.js)
* toujours visible, avec la carte détaillée affichée en taille réelle au
* survol — pas de grande carte réduite à l'échelle, dont le texte devenait
* illisible.
*/
import { CARD_W, CARD_H } from './cardLayout'
// Échelle appliquée aux cartes événement en main. Volontairement proche de 1
// (0.8, pas 0.55 comme l'ancien placement) pour que le nom de l'événement
// (police 12px de base) reste réellement lisible une fois mis à l'échelle.
export const EVENT_CARD_SCALE = 0.8
// Import depuis tokenLayout.js (pas ItemBoardToken.js) : ce module reste
// ainsi totalement indépendant de Phaser, testable en Vitest/jsdom sans
// jamais déclencher l'import de `phaser` (qui échoue en environnement de
// test faute de canvas natif — voir tokenLayout.js).
import { ICON_SIZE } from './tokenLayout'
// Marge depuis les bords droit/bas du canvas.
const MARGIN_RIGHT = 20
@@ -25,38 +30,40 @@ const MARGIN_BOTTOM = 60 // au-dessus de BOTTOM_MARGIN (barre Windows) + marge d
const DRAW_BUTTON_H = 30
const GAP_BUTTON_HAND = 14
// Espacement entre 2 jetons événement adjacents (même logique que les
// colonnes d'objets/bâtiments : ICON_SIZE + marge).
const HAND_SPACING = ICON_SIZE + 14
/**
* Position du bouton "Piocher un Événement", ancré en bas à droite du
* canvas, au-dessus de la main de cartes événement elle-même.
* canvas, au-dessus de la main de jetons événement elle-même.
*/
export function eventDrawButtonPosition(canvasWidth, canvasHeight, buttonWidth = 190) {
const x = canvasWidth - MARGIN_RIGHT - buttonWidth / 2
const y = canvasHeight - MARGIN_BOTTOM - (CARD_H * EVENT_CARD_SCALE) - GAP_BUTTON_HAND - DRAW_BUTTON_H / 2
const y = canvasHeight - MARGIN_BOTTOM - ICON_SIZE - GAP_BUTTON_HAND - DRAW_BUTTON_H / 2
return { x, y }
}
/**
* Géométrie de la main de cartes événement (jusqu'à 2), positionnée juste
* sous le bouton de pioche, alignée sur le bord droit du canvas (les cartes
* Géométrie de la main de jetons événement (jusqu'à 2), positionnée juste
* sous le bouton de pioche, alignée sur le bord droit du canvas (les jetons
* s'empilent horizontalement vers la GAUCHE depuis le bord droit).
*/
export function eventHandGeometry(canvasWidth, canvasHeight, cardCount) {
const cardW = CARD_W * EVENT_CARD_SCALE
const cardH = CARD_H * EVENT_CARD_SCALE
const spacing = cardW + 10
const spacing = HAND_SPACING
const y = canvasHeight - MARGIN_BOTTOM - ICON_SIZE / 2
const y = canvasHeight - MARGIN_BOTTOM - cardH / 2
// Ancrées sur le bord droit : la dernière carte (index cardCount-1) a son
// bord droit à `canvasWidth - MARGIN_RIGHT`, les précédentes s'étendent
// Ancrés sur le bord droit : le dernier jeton (index cardCount-1) a son
// bord droit à `canvasWidth - MARGIN_RIGHT`, les précédents s'étendent
// vers la gauche.
const rightEdge = canvasWidth - MARGIN_RIGHT
const lastCardCenterX = rightEdge - cardW / 2
const startX = lastCardCenterX - Math.max(cardCount - 1, 0) * spacing
const lastCenterX = rightEdge - ICON_SIZE / 2
const startX = lastCenterX - Math.max(cardCount - 1, 0) * spacing
return { startX, y, spacing, scale: EVENT_CARD_SCALE }
return { startX, y, spacing }
}
/** Position (x, y) de la carte d'index `i` dans la main d'événements. */
/** Position (x, y) du jeton d'index `i` dans la main d'événements. */
export function eventCardPosition(canvasWidth, canvasHeight, cardCount, index) {
const { startX, y, spacing } = eventHandGeometry(canvasWidth, canvasHeight, cardCount)
return { x: startX + index * spacing, y }
+10
View File
@@ -0,0 +1,10 @@
/**
* Taille du jeton compact utilisé par ItemBoardToken (objets, bâtiments,
* cartes Événement). Extraite dans son propre module SANS dépendance à
* Phaser (contrairement à ItemBoardToken.js qui importe `phaser`), pour
* pouvoir être importée par des modules de calcul de géométrie purs (ex:
* eventHandLayout.js) et testée unitairement avec Vitest sans échouer à
* charger Phaser dans l'environnement de test (jsdom n'a pas de canvas
* natif, ce que Phaser requiert dès son import).
*/
export const ICON_SIZE = 46
+93 -48
View File
@@ -2,8 +2,7 @@ import Phaser from 'phaser'
import { CardObject } from '../objects/CardObject'
import { CARD_W, CARD_H } from '../objects/cardLayout'
import { ItemBoardToken, ICON_SIZE } from '../objects/ItemBoardToken'
import { EventCardObject } from '../objects/EventCardObject'
import { eventDrawButtonPosition, eventHandGeometry, EVENT_CARD_SCALE } from '../objects/eventHandLayout'
import { eventDrawButtonPosition, eventHandGeometry } from '../objects/eventHandLayout'
import { ITEMS, getItemById } from '../items'
import { BUILDINGS, getBuildingById } from '../buildings'
import { EVENTS, getEventById, DRAW_COST } from '../events'
@@ -64,9 +63,13 @@ export class GameScene extends Phaser.Scene {
this.myBuildingTokens = [] // ItemBoardToken des bâtiments que JE possède, à côté de ma main
this.opponentBuildingTokens = [] // ItemBoardToken des bâtiments possédés par les autres joueurs
// ── Cartes Événement (seconde main, max 2, sous ma main de personnages) ────
this.eventCards = [] // EventCardObject actuellement affichées (ma main d'événements)
this.selectedEventCard = null // EventCardObject choisie pour être jouée ce tour (avant confirmation)
// ── Cartes Événement (seconde main, max 2, bas-droite du plateau) ──────────
// Même pattern que les objets/bâtiments (ItemBoardToken) : petit jeton
// compact toujours visible, carte détaillée en taille réelle au survol
// (bien plus lisible que l'ancien affichage en grande carte réduite à
// 0.8, dont le texte restait écrasé).
this.eventCards = [] // ItemBoardToken (kind='event') actuellement affichées (ma main)
this.selectedEventCard = null // ItemBoardToken choisie pour être jouée ce tour (avant confirmation)
this._lastEventHandKey = null // pour ne reconstruire qu'au vrai changement de main d'événements
this._pendingEventTargets = {} // cibles choisies pour l'événement en cours de sélection/ciblage
}
@@ -257,10 +260,11 @@ export class GameScene extends Phaser.Scene {
this.slotObjects.push(label)
// Mini-cartes objets/bâtiments "posées devant" cet adversaire, juste
// sous son slot de jeu — comme sur ma propre table à côté de ma main.
// On ne le fait pas pour MON propre slot : mes objets/bâtiments sont
// déjà affichés à côté de ma main (plus pertinent puisque je vois ma
// main, pas mon slot).
// AU-DESSUS de son slot de jeu (comme si elles étaient posées en
// évidence sur sa carte plutôt que cachées en dessous, plus proche du
// bord de l'écran). On ne le fait pas pour MON propre slot : mes
// objets/bâtiments sont déjà affichés à côté de ma main (plus
// pertinent puisque je vois ma main, pas mon slot).
const isMe = player?.teamIndex === this.store.myTeamIndex
if (!isMe) {
const ownedItems = player?.items ?? []
@@ -271,17 +275,17 @@ export class GameScene extends Phaser.Scene {
if (!item) return
// displayScale=0.55 réduit uniquement le jeton compact ; la carte
// détaillée au survol reste à sa taille normale (voir ItemBoardToken).
const token = new ItemBoardToken(this, startTokenX + k * tokenGap, slotY + 128, item, 'owned', false, 0.55)
const token = new ItemBoardToken(this, startTokenX + k * tokenGap, slotY - 128, item, 'owned', false, 0.55)
this.opponentItemTokens.push(token)
})
// Bâtiments possédés, sur une seconde rangée sous les objets
// Bâtiments possédés, sur une rangée encore au-dessus des objets
const ownedBuildings = player?.buildings ?? []
const startBuildingX = x - (ownedBuildings.length - 1) * tokenGap / 2
ownedBuildings.forEach((buildingId, k) => {
const building = getBuildingById(buildingId)
if (!building) return
const token = new ItemBoardToken(this, startBuildingX + k * tokenGap, slotY + 152, building, 'owned', false, 0.55, 'building')
const token = new ItemBoardToken(this, startBuildingX + k * tokenGap, slotY - 152, building, 'owned', false, 0.55, 'building')
this.opponentBuildingTokens.push(token)
})
}
@@ -733,10 +737,12 @@ export class GameScene extends Phaser.Scene {
/**
* (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), en
* bas-droite du canvas, à une échelle (0.8, voir eventHandLayout.js)
* suffisamment proche de 1 pour que le nom de l'événement reste lisible
* — un placement précédent utilisait 0.55, rendant le texte (12px de
* base) quasi illisible une fois réduit.
* bas-droite du canvas. Même composant que les objets/bâtiments
* (ItemBoardToken, kind='event') : un petit jeton compact toujours
* visible, avec la carte détaillée (nom + description en taille réelle,
* bien plus lisible) affichée au survol et un bouton "JOUER" — l'ancien
* Le précédent composant dédié affichait une grande carte réduite à
* l'échelle 0.8, ce qui écrasait le texte au point de le rendre illisible.
*/
_buildEventHand(eventIds) {
this.eventCards.forEach(c => c.destroy())
@@ -746,16 +752,24 @@ export class GameScene extends Phaser.Scene {
if (!eventIds || eventIds.length === 0) return
const { width, height } = this.scale
const { startX, y, spacing, scale } = eventHandGeometry(width, height, eventIds.length)
const { startX, y, spacing } = eventHandGeometry(width, height, eventIds.length)
eventIds.forEach((eventId, i) => {
const eventData = getEventById(eventId)
if (!eventData) return
const card = new EventCardObject(this, startX + i * spacing, y, eventData)
card.setScale(scale)
card.on('select', this._onEventCardSelected, this)
this.eventCards.push(card)
// affordable=true : le bouton "JOUER" est activé tant qu'aucune autre
// carte événement n'a déjà été choisie ce tour (voir
// _refreshEventHandAffordability, appelé après chaque reconstruction
// et à chaque game-state pour refléter pendingEventPlayId).
// hAnchor='right' : la main est ancrée sur le bord DROIT du canvas
// (voir eventHandLayout.js), la carte détaillée doit donc s'étendre
// vers la GAUCHE au survol pour ne jamais déborder hors du canvas.
const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', true, 1, 'event', 'right')
token.on('play', (data) => this._onEventCardPlay(token, data))
this.eventCards.push(token)
})
this._refreshEventHandAffordability()
}
_layoutEventHand() {
@@ -764,51 +778,71 @@ export class GameScene extends Phaser.Scene {
const { startX, y, spacing } = eventHandGeometry(width, height, this.eventCards.length)
this.eventCards.forEach((card, i) => {
card.setPosition(startX + i * spacing, y)
card._baseY = y
})
}
/**
* Sélection d'une carte événement pour la jouer ce tour (une seule
* autorisée). Si l'événement nécessite un ciblage, ouvre la séquence de
* ciblage (clic sur les slots adversaires) avant d'émettre 'play-event' —
* sinon l'émission est immédiate, sans bouton de confirmation séparé
* (même pattern que l'achat d'objet/bâtiment : action -> résolution différée
* côté serveur).
* Le bouton "JOUER" de chaque carte événement en main doit être désactivé
* dès qu'une carte a déjà été choisie ce tour (une seule autorisée) ou
* qu'une séquence de ciblage est en cours — reconstruit les jetons pour
* refléter l'état courant (ItemBoardToken.affordable est fixé à la
* construction, pas réactif après coup).
*/
_refreshEventHandAffordability() {
const locked = !!this.selectedEventCard || !!this._targetingState
if (!locked) return // déjà construits avec affordable=true par défaut
const eventIds = this.eventCards.map(c => c.itemData.id)
const { width, height } = this.scale
const { startX, y, spacing } = eventHandGeometry(width, height, eventIds.length)
this.eventCards.forEach(c => c.destroy())
this.eventCards = eventIds.map((eventId, i) => {
const eventData = getEventById(eventId)
const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', false, 1, 'event', 'right')
token.on('play', (data) => this._onEventCardPlay(token, data))
return token
})
}
/**
* Clic sur le bouton "JOUER" d'une carte événement de ma main (une seule
* autorisée par tour). Si l'événement nécessite un ciblage, ouvre la
* séquence de ciblage (clic sur les slots adversaires) avant d'émettre
* 'play-event' — sinon l'émission est immédiate.
*
* Choix pragmatique : le ciblage d'Accord royal (spécifié "au moment de
* piocher") est en réalité demandé ici, au moment de la SÉLECTION pour
* jouer, plutôt qu'immédiatement après le tirage. Cela évite un bug réel
* de correspondance d'index côté client si le joueur possède 2 exemplaires
* piocher") est en réalité demandé ici, au moment de JOUER la carte,
* plutôt qu'immédiatement après le tirage. Cela évite un bug réel de
* correspondance d'index côté client si le joueur possède 2 exemplaires
* du même événement en main (le tableau `myEventHand` ne porte aucun
* identifiant d'instance, seulement des ids répétables) tout en envoyant
* les cibles au serveur exactement au même endroit du flux qu'attendu.
*/
_onEventCardSelected(card) {
_onEventCardPlay(token, eventData) {
if (this.selectedEventCard) return // déjà une carte sélectionnée ce tour
if (this._targetingState) return // ciblage déjà en cours
const event = card.eventData
const needsTarget = event.needsTargetOnDraw ||
(event.needsTargetOnPlay && this._epidemicNeedsTarget())
const needsTarget = eventData.needsTargetOnDraw ||
(eventData.needsTargetOnPlay && this._epidemicNeedsTarget())
if (!needsTarget) {
this._confirmPlayEvent(card, {})
this._confirmPlayEvent(token, {})
return
}
if (event.id === 'royal_accord') {
if (eventData.id === 'royal_accord') {
// 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'], {
this._startTargeting(token, ['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, ['targetTeamIndex'], {
} else if (eventData.id === 'epidemic') {
this._startTargeting(token, ['targetTeamIndex'], {
targetTeamIndex: 'Choisissez l\'adversaire qui perdra 2 Santé (Trousse du Médecin)'
})
}
@@ -830,9 +864,10 @@ export class GameScene extends Phaser.Scene {
* avance jusqu'à ce que toutes les clés de `keys` soient renseignées, puis
* émet 'play-event' avec les cibles collectées.
*/
_startTargeting(card, keys, instructions) {
this._targetingState = { card, keys: [...keys], instructions, collected: {} }
_startTargeting(token, keys, instructions) {
this._targetingState = { token, keys: [...keys], instructions, collected: {} }
this._highlightOpponentSlots(true)
this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes pendant le ciblage
this.phaseText.setText(instructions[keys[0]])
}
@@ -875,7 +910,7 @@ export class GameScene extends Phaser.Scene {
_onTargetSlotClicked(targetTeamIndex) {
if (!this._targetingState || targetTeamIndex === null || targetTeamIndex === undefined) return
const { card, keys, instructions, collected } = this._targetingState
const { token, keys, instructions, collected } = this._targetingState
const currentKey = keys[0]
collected[currentKey] = targetTeamIndex
keys.shift()
@@ -884,7 +919,7 @@ export class GameScene extends Phaser.Scene {
this._highlightOpponentSlots(false)
const targets = this._targetingState.collected
this._targetingState = null
this._confirmPlayEvent(card, targets)
this._confirmPlayEvent(token, targets)
} else {
this.phaseText.setText(instructions[keys[0]])
}
@@ -897,12 +932,12 @@ export class GameScene extends Phaser.Scene {
this._targetingState = null
}
_confirmPlayEvent(card, targets) {
card.select()
this.selectedEventCard = card
_confirmPlayEvent(token, targets) {
this.selectedEventCard = token
this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes
this.socket.emit('play-event', {
roomId: this.roomId,
eventId: card.eventData.id,
eventId: token.itemData.id,
targets
})
}
@@ -1131,6 +1166,16 @@ export class GameScene extends Phaser.Scene {
this._lastEventHandKey = myEventHandKey
this._buildEventHand(state.myEventHand ?? [])
}
// Synchroniser l'état "carte déjà choisie ce tour" avec le serveur
// (utile après reconnexion, ou si l'action locale a précédé la
// confirmation serveur) pour maintenir le bouton JOUER des autres
// cartes grisé de façon cohérente.
if (state.pendingEventPlayId && !this.selectedEventCard) {
this.selectedEventCard = true
this._refreshEventHandAffordability()
} else if (!state.pendingEventPlayId && this.selectedEventCard) {
this.selectedEventCard = null
}
this._refreshEventDrawButtonState(me?.gauges, (state.myEventHand ?? []).length, !!state.hasPendingEventDraw)
if (state.phase === 'selecting') {
+32 -47
View File
@@ -2,10 +2,9 @@ import { describe, it, expect } from 'vitest'
import {
eventDrawButtonPosition,
eventHandGeometry,
eventCardPosition,
EVENT_CARD_SCALE
eventCardPosition
} from '../../src/game/objects/eventHandLayout'
import { CARD_W, CARD_H } from '../../src/game/objects/cardLayout'
import { ICON_SIZE } from '../../src/game/objects/tokenLayout'
// Dimensions de canvas typiques utilisées dans les tests (cohérentes avec
// GameFactory.js : width = innerWidth, height = innerHeight - 60).
@@ -13,13 +12,6 @@ const CANVAS_W = 1920
const CANVAS_H = 1080
describe('game/objects/eventHandLayout', () => {
describe('EVENT_CARD_SCALE', () => {
it('reste suffisamment proche de 1 pour garder le texte lisible (pas 0.55 comme l\'ancien placement)', () => {
expect(EVENT_CARD_SCALE).toBeGreaterThanOrEqual(0.7)
expect(EVENT_CARD_SCALE).toBeLessThanOrEqual(1)
})
})
describe('eventDrawButtonPosition', () => {
it('positionne le bouton dans le quart bas-droit du canvas', () => {
const { x, y } = eventDrawButtonPosition(CANVAS_W, CANVAS_H)
@@ -36,13 +28,12 @@ describe('game/objects/eventHandLayout', () => {
expect(y + buttonHeight / 2).toBeLessThanOrEqual(CANVAS_H)
})
it('est positionné strictement au-dessus de la main de cartes événement (pas de chevauchement)', () => {
it('est positionné strictement au-dessus de la main de jetons événement (pas de chevauchement)', () => {
const buttonHeight = 30
const { y: btnY } = eventDrawButtonPosition(CANVAS_W, CANVAS_H)
const { y: handY } = eventHandGeometry(CANVAS_W, CANVAS_H, 1)
const cardH = CARD_H * EVENT_CARD_SCALE
const buttonBottom = btnY + buttonHeight / 2
const handTop = handY - cardH / 2
const handTop = handY - ICON_SIZE / 2
expect(buttonBottom).toBeLessThanOrEqual(handTop)
})
@@ -74,22 +65,20 @@ describe('game/objects/eventHandLayout', () => {
expect(y).toBeGreaterThan(CANVAS_H / 2)
})
it('la dernière carte est ancrée sur le bord droit du canvas (avec la marge)', () => {
const { startX, spacing, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
const cardW = CARD_W * scale
const lastCardCenterX = startX + spacing // index 1 = dernière carte pour cardCount=2
const lastCardRightEdge = lastCardCenterX + cardW / 2
expect(lastCardRightEdge).toBeLessThanOrEqual(CANVAS_W)
expect(lastCardRightEdge).toBeGreaterThan(CANVAS_W - 40) // proche du bord (marge ~20px)
it('le dernier jeton est ancré sur le bord droit du canvas (avec la marge)', () => {
const { startX, spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
const lastCenterX = startX + spacing // index 1 = dernier jeton pour cardCount=2
const lastRightEdge = lastCenterX + ICON_SIZE / 2
expect(lastRightEdge).toBeLessThanOrEqual(CANVAS_W)
expect(lastRightEdge).toBeGreaterThan(CANVAS_W - 40) // proche du bord (marge ~20px)
})
it('avec 1 carte, elle est ancrée sur le bord droit (pas de décalage vers la gauche)', () => {
it('avec 1 jeton, il est ancré sur le bord droit (pas de décalage vers la gauche)', () => {
const { startX } = eventHandGeometry(CANVAS_W, CANVAS_H, 1)
const { startX: startXTwo } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
// La position de la DERNIÈRE carte doit être identique quel que soit
// le nombre de cartes (ancrage à droite stable) — avec 1 carte,
// startX doit correspondre à la position de la dernière carte du cas à 2.
const { spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
const { startX: startXTwo, spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
// La position du DERNIER jeton doit être identique quel que soit le
// nombre de jetons (ancrage à droite stable) — avec 1 jeton, startX
// doit correspondre à la position du dernier jeton du cas à 2.
expect(startX).toBeCloseTo(startXTwo + spacing, 5)
})
@@ -100,27 +89,24 @@ describe('game/objects/eventHandLayout', () => {
expect(Number.isFinite(spacing)).toBe(true)
})
it('l\'espacement entre cartes est toujours positif et couvre au moins la largeur d\'une carte', () => {
const { spacing, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
expect(spacing).toBeGreaterThan(CARD_W * scale)
it('l\'espacement entre jetons est toujours positif et couvre au moins la largeur d\'un jeton', () => {
const { spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
expect(spacing).toBeGreaterThan(ICON_SIZE)
})
it('les 2 cartes ne se chevauchent jamais (distance entre centres >= largeur d\'une carte)', () => {
const { spacing, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
const cardW = CARD_W * scale
expect(spacing).toBeGreaterThanOrEqual(cardW)
it('les 2 jetons ne se chevauchent jamais (distance entre centres >= largeur d\'un jeton)', () => {
const { spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
expect(spacing).toBeGreaterThanOrEqual(ICON_SIZE)
})
it('reste entièrement dans le canvas sur une petite résolution (1280x720)', () => {
const { startX, y, spacing, scale } = eventHandGeometry(1280, 720, 2)
const cardW = CARD_W * scale
const cardH = CARD_H * scale
const firstCardLeftEdge = startX - cardW / 2
const lastCardRightEdge = startX + spacing + cardW / 2
expect(firstCardLeftEdge).toBeGreaterThanOrEqual(0)
expect(lastCardRightEdge).toBeLessThanOrEqual(1280)
expect(y + cardH / 2).toBeLessThanOrEqual(720)
expect(y - cardH / 2).toBeGreaterThanOrEqual(0)
const { startX, y, spacing } = eventHandGeometry(1280, 720, 2)
const firstLeftEdge = startX - ICON_SIZE / 2
const lastRightEdge = startX + spacing + ICON_SIZE / 2
expect(firstLeftEdge).toBeGreaterThanOrEqual(0)
expect(lastRightEdge).toBeLessThanOrEqual(1280)
expect(y + ICON_SIZE / 2).toBeLessThanOrEqual(720)
expect(y - ICON_SIZE / 2).toBeGreaterThanOrEqual(0)
})
})
@@ -133,7 +119,7 @@ describe('game/objects/eventHandLayout', () => {
expect(pos1).toEqual({ x: startX + spacing, y })
})
it('les positions de 2 cartes sont distinctes', () => {
it('les positions de 2 jetons sont distinctes', () => {
const pos0 = eventCardPosition(CANVAS_W, CANVAS_H, 2, 0)
const pos1 = eventCardPosition(CANVAS_W, CANVAS_H, 2, 1)
expect(pos0.x).not.toBe(pos1.x)
@@ -142,13 +128,12 @@ describe('game/objects/eventHandLayout', () => {
describe('non-régression : séparation avec la légende d\'attributs (bas-gauche)', () => {
it('la zone événement (bas-droite) ne recouvre pas la zone légende (bas-gauche, x proche de 0)', () => {
const { startX, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
const cardW = CARD_W * scale
const firstCardLeftEdge = startX - cardW / 2
const { startX } = eventHandGeometry(CANVAS_W, CANVAS_H, 2)
const firstLeftEdge = startX - ICON_SIZE / 2
// La légende occupe le coin bas-gauche (x ancré à 10px du bord gauche,
// largeur de texte ~180px max) — la zone événement doit rester dans
// la moitié droite du canvas pour ne jamais s'en approcher.
expect(firstCardLeftEdge).toBeGreaterThan(CANVAS_W / 2)
expect(firstLeftEdge).toBeGreaterThan(CANVAS_W / 2)
})
})
})
+62 -15
View File
@@ -325,6 +325,13 @@ export async function clickEventDrawButton(page) {
* puisse faire progresser les tours pendant l'accumulation.
*/
export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) {
// Événements sans ciblage requis (au pire un ciblage optionnel côté
// Épidémie, déclenché seulement avec la Trousse — jamais possédée dans un
// scénario de test frais). Utilisés pour vider un slot de la main sans
// jamais déclencher une séquence de ciblage qui resterait bloquée faute
// de clic sur un slot adverse (playEventCard sans targetTeamIndexes).
const SAFE_TO_DISCARD = new Set(['bountiful_harvest', 'troop_levy', 'plot_uncovered', 'castle_feast', 'epidemic'])
for (let attempt = 0; attempt < maxDraws; attempt++) {
const hand = await getMyEventHand(page)
if (hand.includes(eventId)) return true
@@ -334,11 +341,30 @@ export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) {
// 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])
// libérer un slot et pouvoir continuer à piocher.
// IMPORTANT : ne JAMAIS choisir 'royal_accord' pour cette défausse —
// il exige un ciblage séquentiel à 2 étapes (gain + perte) ; le jouer
// sans fournir targetTeamIndexes laisserait la séquence de ciblage
// bloquée côté client (aucun 'play-event' jamais émis), et donc la
// main jamais libérée, causant l'échec systématique observé.
const discardable = hand.find(id => SAFE_TO_DISCARD.has(id)) ?? hand[0]
if (discardable === 'royal_accord') {
// Les 2 cartes en main sont 'royal_accord' (cas rare) : impossible
// de la défausser sans ciblage séquentiel complet. On cible le
// premier adversaire réel (n'importe quel autre teamIndex que le
// mien) pour les deux étapes — le ciblage doit obligatoirement
// pointer sur un adversaire pour que le clic sur le slot déclenche
// réellement _onTargetSlotClicked (les highlights ne sont posés que
// sur les slots adverses, jamais sur le mien).
const myTeamIndex = await page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
return game?.scene?.getScene('GameScene')?.store?.myTeamIndex ?? 0
})
const opponentTeamIndex = myTeamIndex === 0 ? 1 : 0
await playEventCard(page, discardable, [opponentTeamIndex, opponentTeamIndex])
} else {
await playEventCard(page, discardable)
}
await playFullTurn([page, ...otherPages])
continue
}
@@ -360,28 +386,49 @@ export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) {
}
/**
* 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`
* Joue une carte de ma main d'événements ce tour. Les cartes événement
* utilisent désormais le même composant que les objets/bâtiments
* (ItemBoardToken, kind='event') : un jeton compact toujours visible, dont
* il faut survoler pour révéler la carte détaillée, puis cliquer sur son
* bouton "JOUER" — plus un simple clic direct sur une grande carte comme
* l'ancien EventCardObject.
*
* 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.
* sont effectués automatiquement après le clic sur "JOUER".
*/
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 tokenPos = 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 }
const token = scene?.eventCards?.find(c => c.itemData.id === eventId)
return token ? { x: token.x, y: token.y } : null
}, eventId)
if (!cardPos) return false
if (!tokenPos) return false
await page.mouse.click(box.x + cardPos.x, box.y + cardPos.y)
await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y)
await page.waitForTimeout(300)
const btnCenter = await page.evaluate((eventId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.eventCards?.find(c => c.itemData.id === eventId)
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 (une autre carte déjà jouée ce tour)
const worldPoint = btnBg.getWorldTransformMatrix().transformPoint(
btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY
)
return { x: worldPoint.x, y: worldPoint.y }
}, eventId)
if (!btnCenter) return false
await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y)
await page.waitForTimeout(250)
for (const teamIndex of targetTeamIndexes) {