From 34d1bbe37fc51f7be13578cc0449a4f40dbfbb4d Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Mon, 24 Aug 2026 16:51:03 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20une=20carte=20=C3=89v=C3=A9nement=20jou?= =?UTF-8?q?=C3=A9e=20pouvait=20rester=20affich=C3=A9e=20comme=20jouable=20?= =?UTF-8?q?en=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/game/objects/ItemBoardToken.js | 7 + client/src/game/scenes/GameScene.js | 150 +++++++++++----------- 2 files changed, 81 insertions(+), 76 deletions(-) diff --git a/client/src/game/objects/ItemBoardToken.js b/client/src/game/objects/ItemBoardToken.js index a579d7d..3e8f765 100644 --- a/client/src/game/objects/ItemBoardToken.js +++ b/client/src/game/objects/ItemBoardToken.js @@ -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) } } diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index a3aeff2..20967ec 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -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