fix: une carte Événement jouée pouvait rester affichée comme jouable en main

Cause : this.selectedEventCard gardait une référence DIRECTE vers un objet
Phaser (ItemBoardToken), régulièrement détruit et recréé par
_refreshEventHandAffordability()/_buildEventHand() à chaque game-state.
Cette référence devenait alors obsolète (pointant vers un objet détruit),
et l'état de verrouillage "carte en cours de jeu" était géré par deux
chemins de mutation distincts (reconstruction de la main basée sur
myEventHand seul, ET synchronisation séparée sur pendingEventPlayId) qui
pouvaient se désynchroniser selon l'ordre exact des événements réseau :
une carte déjà envoyée au serveur (play-event émis) pouvait alors
réapparaître visuellement "jouable" dans la main après une reconstruction
intermédiaire qui ignorait le verrouillage réel.

Fix : refactor complet du pilotage de la main d'événements côté GameScene :

- this.selectedEventCard (référence Phaser) remplacé par
  this.selectedEventId (simple eventId, jamais périmé même après
  destruction/recréation des jetons).
- Fusion de _buildEventHand + _refreshEventHandAffordability en une seule
  fonction _renderEventHand(eventIds), SEULE responsable de construire
  this.eventCards — élimine le second chemin de mutation qui pouvait
  diverger.
- Dans le listener 'game-state', selectedEventId est désormais TOUJOURS
  resynchronisé sur state.pendingEventPlayId (source de vérité serveur)
  AVANT de décider si la main doit être reconstruite, et la clé de diff
  inclut maintenant ce verrouillage en plus de la composition de la main —
  auparavant, un changement de pendingEventPlayId seul (main inchangée) ne
  déclenchait aucune reconstruction, laissant l'ancien état affiché.
- _cancelTargeting() reconstruit désormais aussi la main pour lever tout
  verrouillage visuel resté affiché après une annulation.

Fix connexe dans ItemBoardToken.destroy() : le minuteur d'OUVERTURE
(_showTimer) n'était jamais annulé à la destruction (seul _hideTimer
l'était) — un jeton détruit pendant qu'un survol venait de démarrer
pouvait déclencher _showExpanded() sur un objet déjà détruit une fois le
délai écoulé, une source de bug supplémentaire pour des cartes "fantômes".

54 tests unitaires client + 166 tests unitaires serveur toujours au vert.
This commit is contained in:
Kevin L'Hereec committed 2026-08-24 16:51:03 +02:00
1 parent 33a3c20dcb
commit 34d1bbe37f
2 files changed
+81 -76

No files matched your search

@@ -434,7 +434,14 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
}
destroy(fromScene) {
// Annuler les DEUX minuteurs (pas seulement le hide) : si ce jeton est
// détruit (ex: reconstruction de la main d'événements après un
// game-state) pendant qu'un survol venait de démarrer, _showTimer
// resterait actif et appellerait _showExpanded() sur un objet déjà
// détruit une fois écoulé — pouvant faire réapparaître une carte
// "fantôme" après reconstruction, ou lever une erreur silencieuse.
this._cancelHide()
this._cancelShow()
super.destroy(fromScene)
}
}
+74 -76
View File
@@ -68,10 +68,17 @@ export class GameScene extends Phaser.Scene {
// 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
this.eventCards = [] // ItemBoardToken (kind='event') actuellement affichées (ma main)
this._lastEventHandKey = null // clé combinée (main + carte verrouillée) : ne reconstruit qu'au vrai changement
// IMPORTANT : l'état "carte en cours de jeu" est piloté par un simple id
// (eventId), JAMAIS par une référence vers un objet Phaser — les jetons
// ItemBoardToken sont détruits/recréés à chaque rendu de la main
// (_renderEventHand), donc garder une référence directe finissait par
// pointer vers un objet déjà détruit, désynchronisant l'affichage de
// l'état réel (carte visuellement encore "jouable" après avoir déjà été
// envoyée au serveur). `selectedEventId` reste valide quel que soit le
// nombre de fois où les jetons sont reconstruits.
this.selectedEventId = null
}
create() {
@@ -736,43 +743,49 @@ export class GameScene extends Phaser.Scene {
}
/**
* (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), en
* 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.
* Reconstruit intégralement ma main de cartes événement (max
* EVENT_HAND_MAX=2), en bas-droite du canvas, à partir de l'état courant :
* `eventIds` (myEventHand reçu du serveur) et `this.selectedEventId`/
* `this._targetingState` (verrouillage local en attente de confirmation
* serveur). C'est la SEULE fonction qui construit `this.eventCards` — pas
* de fonction séparée pour "juste changer l'affordance" comme auparavant,
* ce qui créait un second chemin de mutation pouvant désynchroniser
* l'affichage de l'état réel après un enchaînement rapide d'actions
* (carte visuellement encore "jouable" alors qu'elle avait déjà été
* envoyée au serveur, puis restée affichée si un game-state intermédiaire
* reconstruisait la main sans repropager le verrouillage correctement).
*
* 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".
*/
_buildEventHand(eventIds) {
_renderEventHand(eventIds) {
this.eventCards.forEach(c => c.destroy())
this.eventCards = []
this.selectedEventCard = null
if (!eventIds || eventIds.length === 0) return
const locked = this.selectedEventId !== null || !!this._targetingState
const { width, height } = this.scale
const { startX, y, spacing } = eventHandGeometry(width, height, eventIds.length)
eventIds.forEach((eventId, i) => {
const eventData = getEventById(eventId)
if (!eventData) return
// 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).
// affordable=false si CETTE main contient déjà une carte verrouillée
// (une autre en cours d'envoi/ciblage) — recalculé à chaque rendu à
// partir de l'état courant, jamais mémorisé sur le jeton lui-même.
// 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.
// vAnchor='top' : la main est aussi ancrée en BAS du canvas — sans ce
// paramètre, la carte détaillée s'étend vers le bas par défaut et
// déborde largement sous le canvas, la rendant invisible/injoignable.
const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', true, 1, 'event', 'right', 'top')
token.on('play', (data) => this._onEventCardPlay(token, data))
const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', !locked, 1, 'event', 'right', 'top')
token.on('play', (data) => this._onEventCardPlay(eventId, data))
this.eventCards.push(token)
})
this._refreshEventHandAffordability()
}
_layoutEventHand() {
@@ -784,35 +797,12 @@ export class GameScene extends Phaser.Scene {
})
}
/**
* 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', 'top')
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.
* autorisée par tour, identifiée par `eventId` — jamais une référence
* d'objet Phaser, voir this.selectedEventId). 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 JOUER la carte,
@@ -822,15 +812,15 @@ export class GameScene extends Phaser.Scene {
* 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.
*/
_onEventCardPlay(token, eventData) {
if (this.selectedEventCard) return // déjà une carte sélectionnée ce tour
_onEventCardPlay(eventId, eventData) {
if (this.selectedEventId !== null) return // déjà une carte sélectionnée ce tour
if (this._targetingState) return // ciblage déjà en cours
const needsTarget = eventData.needsTargetOnDraw ||
(eventData.needsTargetOnPlay && this._epidemicNeedsTarget())
if (!needsTarget) {
this._confirmPlayEvent(token, {})
this._confirmPlayEvent(eventId, {})
return
}
@@ -867,10 +857,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(token, keys, instructions) {
this._targetingState = { token, keys: [...keys], instructions, collected: {} }
_startTargeting(eventId, keys, instructions) {
this._targetingState = { eventId, keys: [...keys], instructions, collected: {} }
this._highlightOpponentSlots(true)
this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes pendant le ciblage
this._renderEventHand(this.store.myEventHand) // grise le bouton JOUER des autres cartes pendant le ciblage
this.phaseText.setText(instructions[keys[0]])
}
@@ -913,7 +903,7 @@ export class GameScene extends Phaser.Scene {
_onTargetSlotClicked(targetTeamIndex) {
if (!this._targetingState || targetTeamIndex === null || targetTeamIndex === undefined) return
const { token, keys, instructions, collected } = this._targetingState
const { eventId, keys, instructions, collected } = this._targetingState
const currentKey = keys[0]
collected[currentKey] = targetTeamIndex
keys.shift()
@@ -922,25 +912,30 @@ export class GameScene extends Phaser.Scene {
this._highlightOpponentSlots(false)
const targets = this._targetingState.collected
this._targetingState = null
this._confirmPlayEvent(token, targets)
this._confirmPlayEvent(eventId, targets)
} else {
this.phaseText.setText(instructions[keys[0]])
}
}
/** Annule une séquence de ciblage en cours (ex: changement de phase serveur). */
/**
* Annule une séquence de ciblage en cours (ex: changement de phase
* serveur). Reconstruit la main pour lever le verrouillage visuel si
* aucune carte n'a par ailleurs été confirmée entre-temps.
*/
_cancelTargeting() {
if (!this._targetingState) return
this._highlightOpponentSlots(false)
this._targetingState = null
if (this.store?.myEventHand) this._renderEventHand(this.store.myEventHand)
}
_confirmPlayEvent(token, targets) {
this.selectedEventCard = token
this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes
_confirmPlayEvent(eventId, targets) {
this.selectedEventId = eventId
this._renderEventHand(this.store.myEventHand) // grise le bouton JOUER des autres cartes
this.socket.emit('play-event', {
roomId: this.roomId,
eventId: token.itemData.id,
eventId,
targets
})
}
@@ -1162,24 +1157,27 @@ export class GameScene extends Phaser.Scene {
this._buildMyBuildingTokens()
}
// Reconstruire ma main d'événements uniquement si elle a réellement
// changé (pioche, ou carte jouée/défaussée après résolution).
const myEventHandKey = JSON.stringify(state.myEventHand ?? [])
// Le serveur est la source de vérité pour "une carte a-t-elle déjà été
// choisie ce tour" (pendingEventPlayId, non-null tant que le tour
// n'est pas résolu). On synchronise TOUJOURS selectedEventId dessus
// avant de décider de reconstruire la main, pour éviter le bug où la
// main était reconstruite avec l'ancien état de verrouillage local
// (obsolète après une résolution de tour ou une reconnexion) : la
// carte réapparaissait alors "jouable" alors qu'elle avait déjà été
// envoyée au serveur, ou restait grisée après que le tour ait tourné.
this.selectedEventId = state.pendingEventPlayId ?? null
// Reconstruire ma main d'événements si SA COMPOSITION a changé
// (pioche, ou carte jouée/défaussée après résolution) OU si l'état de
// verrouillage (carte en cours d'envoi/ciblage) a changé — les deux
// affectent le rendu (nombre de cartes, bouton JOUER actif/grisé).
const myEventHand = state.myEventHand ?? []
const myEventHandKey = JSON.stringify([myEventHand, this.selectedEventId, !!this._targetingState])
if (this._lastEventHandKey !== myEventHandKey) {
this._lastEventHandKey = myEventHandKey
this._buildEventHand(state.myEventHand ?? [])
this._renderEventHand(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)
this._refreshEventDrawButtonState(me?.gauges, myEventHand.length, !!state.hasPendingEventDraw)
if (state.phase === 'selecting') {
this._canPlay = true