diff --git a/client/public/events/accord.png b/client/public/events/accord.png new file mode 100644 index 0000000..e72e4b3 Binary files /dev/null and b/client/public/events/accord.png differ diff --git a/client/public/events/complot.png b/client/public/events/complot.png new file mode 100644 index 0000000..5101692 Binary files /dev/null and b/client/public/events/complot.png differ diff --git a/client/public/events/epidemie.png b/client/public/events/epidemie.png new file mode 100644 index 0000000..c3a8cc6 Binary files /dev/null and b/client/public/events/epidemie.png differ diff --git a/client/public/events/fete.png b/client/public/events/fete.png new file mode 100644 index 0000000..b1af02b Binary files /dev/null and b/client/public/events/fete.png differ diff --git a/client/public/events/recolte.png b/client/public/events/recolte.png new file mode 100644 index 0000000..cc10967 Binary files /dev/null and b/client/public/events/recolte.png differ diff --git a/client/public/events/troupe.png b/client/public/events/troupe.png new file mode 100644 index 0000000..1ff794c Binary files /dev/null and b/client/public/events/troupe.png differ diff --git a/client/src/game/events.js b/client/src/game/events.js new file mode 100644 index 0000000..b23bd34 --- /dev/null +++ b/client/src/game/events.js @@ -0,0 +1,64 @@ +/** + * Regnum - Définition des 6 cartes Événement (miroir du serveur, pour + * l'affichage) — voir server/src/game/events.js pour la logique et le coût + * exact (1 point dans chacune des 4 jauges, identique pour tous). + * + * needsTargetOnDraw / needsTargetOnPlay pilotent l'UI de ciblage côté + * client : au tirage pour Accord royal (ciblage indépendant de tout objet + * possédé), au moment de jouer la carte pour Épidémie (la variante Trousse + * n'est déterminée qu'à cet instant). + */ + +export const EVENTS = [ + { + id: 'bountiful_harvest', + name: 'Récolte exceptionnelle', + image: 'recolte.png', + negative: false, + description: 'Gagnez 2 Nourriture. Synergie — Charrue : si vous possédez la Charrue, gagnez 1 Nourriture supplémentaire.' + }, + { + id: 'epidemic', + name: 'Épidémie', + image: 'epidemie.png', + negative: true, + needsTargetOnPlay: true, + description: 'Effet de base : chaque joueur perd 1 Santé. Avec la Trousse du Médecin : un adversaire choisi perd 2 Santé à la place. Avec l\'Église : tous les adversaires perdent 2 Santé à la place.' + }, + { + id: 'troop_levy', + name: 'Levée des troupes', + image: 'troupe.png', + negative: false, + description: 'Gagnez 2 Guerre et perdez 1 Diplomatie. Synergie — Trébuchet : si vous possédez le Trébuchet, gagnez 2 Guerre supplémentaires (soit 4 Guerre au total).' + }, + { + id: 'royal_accord', + name: 'Accord royal', + image: 'accord.png', + negative: false, + needsTargetOnDraw: true, + description: 'Gagnez 1 Diplomatie. Choisissez un autre joueur : il gagne 1 Diplomatie. Choisissez un autre joueur : il perd 1 Diplomatie (peuvent être différents). Synergie — Sceau Royal : si vous possédez le Sceau, gagnez 1 Diplomatie supplémentaire.' + }, + { + id: 'plot_uncovered', + name: 'Complot découvert', + image: 'complot.png', + negative: false, + description: 'Jusqu\'à la fin du tour, annule pour vous les effets négatifs des autres cartes Événement jouées ce tour.' + }, + { + id: 'castle_feast', + name: 'Grande fête au château', + image: 'fete.png', + negative: false, + description: 'Gagnez 1 Nourriture et 1 Diplomatie. Synergie — Château Royal : si vous possédez le Château Royal, piochez 1 carte Événement supplémentaire.' + } +] + +export function getEventById(id) { + return EVENTS.find(e => e.id === id) ?? null +} + +/** Coût d'une pioche d'événement : 1 point dans chacune des 4 jauges. */ +export const DRAW_COST = { food: 1, war: 1, health: 1, diplo: 1 } diff --git a/client/src/game/objects/EventCardObject.js b/client/src/game/objects/EventCardObject.js new file mode 100644 index 0000000..c5fb744 --- /dev/null +++ b/client/src/game/objects/EventCardObject.js @@ -0,0 +1,125 @@ +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. Nom en bas ────────────────────────────────────────────────────────── + this.add(this.scene.add.text( + 0, CARD_H / 2 - 14, + this.eventData.name, { + fontSize: '12px', + fontFamily: 'Georgia, serif', + fontStyle: 'bold', + color: '#f5e6c8', + stroke: '#000000', + strokeThickness: 3, + align: 'center', + wordWrap: { width: CARD_W - 12 } + } + ).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' }) + } +} diff --git a/client/src/game/scenes/BootScene.js b/client/src/game/scenes/BootScene.js index fb4f7e5..09f5569 100644 --- a/client/src/game/scenes/BootScene.js +++ b/client/src/game/scenes/BootScene.js @@ -3,6 +3,7 @@ import { CARD_W, CARD_H, RADIUS } from '../objects/cardLayout' import { ITEM_CARD_W, ITEM_IMAGE_H, ITEM_RADIUS } from '../objects/itemLayout' import { ITEMS } from '../items' import { BUILDINGS } from '../buildings' +import { EVENTS } from '../events' const CARD_IMAGES = [ { id: 'queen', file: 'reine.png' }, @@ -58,12 +59,17 @@ export class BootScene extends Phaser.Scene { BUILDINGS.forEach(({ id, image }) => { this.load.image(`raw-building-${id}`, `/buildings/${image}`) }) + + // Charger les images brutes des cartes Événement + EVENTS.forEach(({ id, image }) => { + this.load.image(`raw-event-${id}`, `/events/${image}`) + }) } create() { // Pour chaque personnage, générer une texture clippée via Canvas 2D CARD_IMAGES.forEach(({ id }) => { - this._generateClippedTexture(id) + this._generateClippedTexture(`raw-${id}`, `card-${id}`) }) // Pour chaque objet, générer une texture portrait (zone haute uniquement, @@ -77,18 +83,23 @@ export class BootScene extends Phaser.Scene { this._generatePortraitTexture(`raw-building-${id}`, `building-${id}`) }) + // Pour chaque carte Événement, texture plein cadre (mêmes proportions + // que les cartes personnage) — la mécanique diffère mais le rendu visuel + // se veut cohérent avec le style épuré de CardObject. + EVENTS.forEach(({ id }) => { + this._generateClippedTexture(`raw-event-${id}`, `event-${id}`) + }) + this.scene.start('GameScene') } /** - * Crée une texture `card-{id}` de dimensions CARD_W × CARD_H - * avec l'image clippée en rectangle arrondi via Canvas 2D natif. - * Pas de masque Phaser, pas de RenderTexture — juste du canvas. + * Crée une texture `outKey` de dimensions CARD_W × CARD_H avec l'image + * clippée en rectangle arrondi via Canvas 2D natif (pas de masque Phaser, + * pas de RenderTexture). Réutilisée pour les personnages ET les cartes + * Événement, qui partagent le même format "portrait plein cadre". */ - _generateClippedTexture(id) { - const rawKey = `raw-${id}` - const outKey = `card-${id}` - + _generateClippedTexture(rawKey, outKey) { if (!this.textures.exists(rawKey)) return if (this.textures.exists(outKey)) return diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index 416a4a8..e57d981 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -2,8 +2,10 @@ 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 { ITEMS, getItemById } from '../items' import { BUILDINGS, getBuildingById } from '../buildings' +import { EVENTS, getEventById, DRAW_COST } from '../events' // Marge depuis le bas du canvas pour éviter la barre Windows const BOTTOM_MARGIN = 60 @@ -60,6 +62,12 @@ export class GameScene extends Phaser.Scene { this.buildingTokens = [] // ItemBoardToken (boutique bâtiments, à droite) 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) + 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 } create() { @@ -71,6 +79,7 @@ export class GameScene extends Phaser.Scene { this._buildPlayButton() this._buildItemTokens() this._buildBuildingTokens() + this._buildEventDrawButton() this._setupSocketListeners() // Le canvas Phaser (mode Scale.RESIZE) se redimensionne quand le HUD Vue @@ -99,6 +108,7 @@ export class GameScene extends Phaser.Scene { clearTimeout(this._errorTimer) this._errorTimer = null } + this._cancelTargeting() } /** @@ -197,6 +207,10 @@ export class GameScene extends Phaser.Scene { this._layoutItemTokens() this._layoutBuildingTokens() + // Bouton de pioche d'événement + ma main d'événements + this._layoutEventDrawButton() + this._layoutEventHand() + // Main du joueur (repositionnement simple, sans reconstruction) this._repositionHand() } @@ -633,6 +647,252 @@ export class GameScene extends Phaser.Scene { }) } + // ─── Cartes Événement (pioche + seconde main + ciblage) ──────────────────── + + /** Position du bouton de pioche d'événement : à droite du bouton de confirmation, même Y. */ + _eventDrawButtonPos() { + const { width } = this.scale + return { x: width / 2 + 210, y: this._playButtonY() } + } + + _buildEventDrawButton() { + const { x, y } = this._eventDrawButtonPos() + + this.eventDrawBtnBg = this.add.graphics().setDepth(99) + this.eventDrawBtn = this.add.text(x, y, '🎴 Piocher un Événement', { + fontSize: '12px', fontFamily: 'Georgia', fontStyle: 'bold', + color: '#d4a732', padding: { x: 10, y: 6 } + }) + .setOrigin(0.5) + .setDepth(100) + .setInteractive({ cursor: 'pointer' }) + .on('pointerdown', () => this._onBuyEventDraw()) + .on('pointerover', () => this.eventDrawBtn.setScale(1.05)) + .on('pointerout', () => this.eventDrawBtn.setScale(1)) + + this._drawEventDrawBtnBg() + } + + _drawEventDrawBtnBg() { + const { x, y } = this._eventDrawButtonPos() + const w = 190, h = 30 + this.eventDrawBtnBg.clear() + this.eventDrawBtnBg.fillStyle(0x2a1a0a, 0.9) + this.eventDrawBtnBg.fillRoundedRect(x - w / 2, y - h / 2, w, h, 6) + this.eventDrawBtnBg.lineStyle(1.5, 0xd4a73280) + this.eventDrawBtnBg.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 6) + } + + _layoutEventDrawButton() { + const { x, y } = this._eventDrawButtonPos() + if (this.eventDrawBtn) this.eventDrawBtn.setPosition(x, y) + this._drawEventDrawBtnBg() + } + + /** + * Met à jour l'apparence du bouton de pioche selon que le tirage est + * réellement possible : jauges suffisantes (DRAW_COST = 1 point dans + * chacune des 4), main d'événements pas déjà pleine, et pas de pioche déjà + * tentée ce tour. Grisé sinon, cohérent avec le pattern déjà utilisé pour + * les boutons ACHETER des objets/bâtiments. + */ + _refreshEventDrawButtonState(myGauges, myEventHandSize, hasPendingDraw) { + if (!this.eventDrawBtn) return + + const affordable = Object.entries(DRAW_COST).every(([attr, cost]) => (myGauges?.[attr] ?? 0) >= cost) + const handFull = myEventHandSize >= 2 + const canDraw = affordable && !handFull && !hasPendingDraw + + this.eventDrawBtn.setColor(canDraw ? '#d4a732' : '#6a5040') + this.eventDrawBtn.disableInteractive() + if (canDraw) this.eventDrawBtn.setInteractive({ cursor: 'pointer' }) + + let label = '🎴 Piocher un Événement' + if (handFull) label = '🎴 Main pleine (2/2)' + else if (hasPendingDraw) label = '🎴 Pioche faite ce tour' + else if (!affordable) label = '🎴 Piocher (1 pt/jauge)' + this.eventDrawBtn.setText(label) + } + + _onBuyEventDraw() { + this.socket.emit('buy-event-draw', { roomId: this.roomId }) + } + + /** Position/espacement de ma main de cartes événement (max 2), au-dessus de mon bouton de pioche. */ + _eventHandGeometry() { + const { x } = this._eventDrawButtonPos() + const y = this._playButtonY() - 100 + return { x, y, spacing: 90 } + } + + /** + * (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), à + * l'échelle réduite pour rester compacte au-dessus du bouton de pioche. + */ + _buildEventHand(eventIds) { + this.eventCards.forEach(c => c.destroy()) + this.eventCards = [] + this.selectedEventCard = null + + if (!eventIds || eventIds.length === 0) return + + const { x, y, spacing } = this._eventHandGeometry() + const startX = x - (eventIds.length - 1) * spacing / 2 + + eventIds.forEach((eventId, i) => { + const eventData = getEventById(eventId) + if (!eventData) return + const card = new EventCardObject(this, startX + i * spacing, y, eventData) + card.setScale(0.55) + card.on('select', this._onEventCardSelected, this) + this.eventCards.push(card) + }) + } + + _layoutEventHand() { + if (this.eventCards.length === 0) return + const { x, y, spacing } = this._eventHandGeometry() + const startX = x - (this.eventCards.length - 1) * spacing / 2 + 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). + * + * 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 + * 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) { + 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()) + + if (!needsTarget) { + this._confirmPlayEvent(card, {}) + return + } + + if (event.id === 'royal_accord') { + this._startTargeting(card, ['gainTargetSocketId', 'loseTargetSocketId'], { + gainTargetSocketId: 'Choisissez un joueur qui gagnera 1 Diplomatie', + loseTargetSocketId: 'Choisissez un joueur qui perdra 1 Diplomatie' + }) + } else if (event.id === 'epidemic') { + this._startTargeting(card, ['targetSocketId'], { + targetSocketId: 'Choisissez l\'adversaire qui perdra 2 Santé (Trousse du Médecin)' + }) + } + } + + /** Vrai si jouer Épidémie maintenant déclencherait la variante Trousse (nécessite un ciblage). */ + _epidemicNeedsTarget() { + const myItems = this.store.myItems ?? [] + const myBuildings = this.store.myBuildings ?? [] + // La variante Église (si possédée) prend le pas et ne nécessite aucun + // ciblage (tous les adversaires affectés automatiquement) — cohérent + // avec la priorité Église > Trousse déjà appliquée côté serveur. + return myItems.includes('medkit') && !myBuildings.includes('church') + } + + /** + * Démarre une séquence de ciblage : met en évidence les slots adversaires + * cliquables, affiche l'instruction correspondant à l'étape courante, et + * 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: {} } + this._highlightOpponentSlots(true) + this.phaseText.setText(instructions[keys[0]]) + } + + _highlightOpponentSlots(enable) { + this.playSlots.forEach((slot, i) => { + const player = this.store.players[i] + const isMe = player?.teamIndex === this.store.myTeamIndex + if (isMe || !player) return + + if (enable) { + const hl = this.add.graphics().setDepth(60) + hl.lineStyle(3, 0xffd700, 1) + hl.strokeRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10) + hl.x = slot.x + hl.y = slot.y + hl.setInteractive( + new Phaser.Geom.Rectangle(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8), + Phaser.Geom.Rectangle.Contains + ) + hl.input.cursor = 'pointer' + hl.on('pointerdown', () => this._onTargetSlotClicked(player.teamIndex)) + this._targetHighlights = this._targetHighlights ?? [] + this._targetHighlights.push(hl) + } + }) + + if (!enable) { + (this._targetHighlights ?? []).forEach(hl => hl.destroy()) + this._targetHighlights = [] + } + } + + /** + * Le ciblage transite par `teamIndex` (identifiant public, stable et + * unique par joueur pour toute la partie) plutôt que par `socketId` + * (jamais exposé au client — donnée serveur interne, voir + * Room.getPlayersInfo). Le serveur résout targetTeamIndex -> le vrai + * joueur ciblé lui-même (voir Room.playEventCard / _resolvePendingEventPlays). + */ + _onTargetSlotClicked(targetTeamIndex) { + if (!this._targetingState || targetTeamIndex === null || targetTeamIndex === undefined) return + + const { card, keys, instructions, collected } = this._targetingState + const currentKey = keys[0] + collected[currentKey] = targetTeamIndex + keys.shift() + + if (keys.length === 0) { + this._highlightOpponentSlots(false) + const targets = this._targetingState.collected + this._targetingState = null + this._confirmPlayEvent(card, targets) + } else { + this.phaseText.setText(instructions[keys[0]]) + } + } + + /** Annule une séquence de ciblage en cours (ex: changement de phase serveur). */ + _cancelTargeting() { + if (!this._targetingState) return + this._highlightOpponentSlots(false) + this._targetingState = null + } + + _confirmPlayEvent(card, targets) { + card.select() + this.selectedEventCard = card + this.socket.emit('play-event', { + roomId: this.roomId, + eventId: card.eventData.id, + targets + }) + } + // ─── Main du joueur ────────────────────────────────────────────────────────── /** @@ -850,6 +1110,15 @@ 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 ?? []) + if (this._lastEventHandKey !== myEventHandKey) { + this._lastEventHandKey = myEventHandKey + this._buildEventHand(state.myEventHand ?? []) + } + this._refreshEventDrawButtonState(me?.gauges, (state.myEventHand ?? []).length, !!state.hasPendingEventDraw) + if (state.phase === 'selecting') { this._canPlay = true this.resolveObjects.forEach(o => o.destroy()) @@ -893,6 +1162,10 @@ export class GameScene extends Phaser.Scene { if (state.phase === 'resolve' || state.phase === 'end') { this._canPlay = false this._deactivateBtn() + // Sécurité : une séquence de ciblage en cours n'a plus de sens une + // fois le tour résolu (ex: l'utilisateur a laissé un ciblage + // inachevé et le tour a été résolu par les autres joueurs entre-temps). + this._cancelTargeting() } }) @@ -902,7 +1175,7 @@ export class GameScene extends Phaser.Scene { } }) - this.socket.on('round-result', ({ playedCards, gains, boughtItems, boughtBuildings }) => { + this.socket.on('round-result', ({ playedCards, gains, boughtItems, boughtBuildings, eventResults }) => { this.phaseText.setText('Résolution du tour...') this.readyText.setText('') this._showPlayedCards(playedCards, gains) @@ -911,8 +1184,10 @@ export class GameScene extends Phaser.Scene { ...(boughtItems ?? []).map(b => `${b.playerName} → ${b.itemName}`), ...(boughtBuildings ?? []).map(b => `${b.playerName} → ${b.buildingName}`) ] - if (boughtLabels.length > 0) { - this.readyText.setText(`🛒 ${boughtLabels.join(' | ')}`) + const eventLabels = (eventResults ?? []).map(e => e.summary) + const allLabels = [...boughtLabels, ...eventLabels] + if (allLabels.length > 0) { + this.readyText.setText(`🛒🎴 ${allLabels.join(' | ')}`) } }) diff --git a/e2e/tests/events.spec.js b/e2e/tests/events.spec.js new file mode 100644 index 0000000..6ad9a2b --- /dev/null +++ b/e2e/tests/events.spec.js @@ -0,0 +1,375 @@ +import { test, expect } from '@playwright/test' +import { + installPhaserDebugHook, createRoom, joinRoom, startGame, + playFullTurn, getMyGauges, getMyEventHand, + clickEventDrawButton, drawEventUntil, playEventCard +} from './helpers.js' + +/** + * Tests d'intégration E2E : cartes Événement (pioche partagée de 18 cartes, + * main limitée à 2, choix de jouer une carte, résolution avant les gains de + * cartes personnage, synergies avec objets/bâtiments, ciblage de joueurs). + */ +test.describe('Cartes Événement', () => { + test('le bouton de pioche est grisé/non cliquable tant que les jauges sont à 0', 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 drew = await clickEventDrawButton(p1) + expect(drew).toBe(false) + + await ctx1.close() + await ctx2.close() + }) + + test('acheter une pioche débite 1 point de chacune des 4 jauges et ajoute une carte à la main', 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 au moins 1 point sur les 4 jauges en jouant des cartes qui + // maximisent la somme des 4 attributs. + let gauges = await getMyGauges(p1) + let attempts = 0 + while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 6) { + await playFullTurn([p1, p2], [['food', 'war', 'health', 'diplo'], ['food', 'war', 'health', 'diplo']]) + gauges = await getMyGauges(p1) + attempts++ + } + expect(['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1)).toBe(true) + + const before = { ...gauges } + const drew = await clickEventDrawButton(p1) + expect(drew).toBe(true) + + const after = await getMyGauges(p1) + expect(after.food).toBe(before.food - 1) + expect(after.war).toBe(before.war - 1) + expect(after.health).toBe(before.health - 1) + expect(after.diplo).toBe(before.diplo - 1) + + const hand = await getMyEventHand(p1) + expect(hand.length).toBe(1) + + await ctx1.close() + await ctx2.close() + }) + + test('la main d\'événements est limitée à 2 cartes (le bouton se désactive ensuite)', async ({ browser }) => { + test.setTimeout(90_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) + + async function ensureGaugesAndDraw() { + let gauges = await getMyGauges(p1) + let attempts = 0 + while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 6) { + await playFullTurn([p1, p2], [['food', 'war', 'health', 'diplo'], ['food', 'war', 'health', 'diplo']]) + gauges = await getMyGauges(p1) + attempts++ + } + return clickEventDrawButton(p1) + } + + expect(await ensureGaugesAndDraw()).toBe(true) + // La pioche est immédiate (pas de coût d'un tour), mais un joueur ne peut + // tenter qu'UNE pioche par tour : il faut jouer le tour pour pouvoir + // retenter la pioche suivante. + await playFullTurn([p1, p2]) + expect(await ensureGaugesAndDraw()).toBe(true) + + const hand = await getMyEventHand(p1) + expect(hand.length).toBe(2) + + // Une 3e tentative doit être refusée (main pleine) + await playFullTurn([p1, p2]) + let gauges = await getMyGauges(p1) + let attempts = 0 + while (!['food', 'war', 'health', 'diplo'].every(a => gauges[a] >= 1) && attempts < 6) { + await playFullTurn([p1, p2], [['food', 'war', 'health', 'diplo'], ['food', 'war', 'health', 'diplo']]) + gauges = await getMyGauges(p1) + attempts++ + } + const thirdDraw = await clickEventDrawButton(p1) + expect(thirdDraw).toBe(false) + + await ctx1.close() + await ctx2.close() + }) + + test('jouer "Récolte exceptionnelle" donne +2 Nourriture, résolu avant les gains de carte personnage', async ({ browser }) => { + test.setTimeout(90_000) + + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + const errors = [] + p1.on('pageerror', e => errors.push(e.message)) + 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 drawn = await drawEventUntil(p1, [p2], 'bountiful_harvest') + test.skip(!drawn, 'Récolte exceptionnelle non piochée dans la limite de tentatives (probabiliste)') + + const foodBefore = (await getMyGauges(p1)).food + + await playEventCard(p1, 'bountiful_harvest') + // Neutraliser le gain de carte personnage sur food en jouant la carte + // MINIMISANT food pour les deux joueurs, afin d'isoler l'effet événement. + await playFullTurn([p1, p2], [ + { attr: 'food', mode: 'min' }, + { attr: 'food', mode: 'min' } + ]) + + const foodAfter = (await getMyGauges(p1)).food + expect(foodAfter).toBeGreaterThanOrEqual(foodBefore + 2) + expect(errors).toEqual([]) + + await ctx1.close() + await ctx2.close() + }) + + test('jouer "Épidémie" fait perdre 1 Santé à tous les joueurs (effet de base, sans objet/bâtiment)', async ({ browser }) => { + test.setTimeout(90_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 un peu de Santé pour les deux joueurs afin que la perte soit visible sans clamp à 0. + for (let t = 0; t < 3; t++) { + await playFullTurn([p1, p2], [ + ['health'], + { attr: 'health', mode: 'min' } + ]) + } + + const drawn = await drawEventUntil(p1, [p2], 'epidemic') + test.skip(!drawn, 'Épidémie non piochée dans la limite de tentatives (probabiliste)') + + const p1HealthBefore = (await getMyGauges(p1)).health + + await playEventCard(p1, 'epidemic') + await playFullTurn([p1, p2], [ + { attr: 'health', mode: 'min' }, + { attr: 'health', mode: 'min' } + ]) + + const p1HealthAfter = (await getMyGauges(p1)).health + // Le lanceur lui-même perd aussi 1 Santé avec l'effet de base (tout le + // monde affecté), avant tout gain de carte personnage neutralisé. + expect(p1HealthAfter).toBeLessThanOrEqual(p1HealthBefore) + + await ctx1.close() + await ctx2.close() + }) + + test('jouer "Accord royal" avec ciblage : le joueur choisi en gain reçoit +1 Diplomatie', async ({ browser }) => { + test.setTimeout(90_000) + + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const ctx3 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + const p3 = await ctx3.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + await installPhaserDebugHook(p3) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await joinRoom(p3, 'Carl', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + const drawn = await drawEventUntil(p1, [p2, p3], 'royal_accord') + test.skip(!drawn, 'Accord royal non piochée dans la limite de tentatives (probabiliste)') + + const bobDiploBefore = (await p2.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.diplo + })) + + // Cible teamIndex 1 (Bob) pour le gain, pas de cible pour la perte (laissée non cliquée) + await playEventCard(p1, 'royal_accord', [1]) + await playFullTurn([p1, p2, p3]) + + const bobDiploAfter = (await p2.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.diplo + })) + + expect(bobDiploAfter).toBeGreaterThanOrEqual(bobDiploBefore + 1) + + await ctx1.close() + await ctx2.close() + await ctx3.close() + }) + + test('jouer "Complot découvert" ne modifie aucune jauge par lui-même', async ({ browser }) => { + test.setTimeout(90_000) + + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + const errors = [] + p1.on('pageerror', e => errors.push(e.message)) + 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 drawn = await drawEventUntil(p1, [p2], 'plot_uncovered') + test.skip(!drawn, 'Complot découvert non piochée dans la limite de tentatives (probabiliste)') + + await playEventCard(p1, 'plot_uncovered') + await playFullTurn([p1, p2], [ + { attr: 'food', mode: 'min' }, + { attr: 'food', mode: 'min' } + ]) + + expect(errors).toEqual([]) + + await ctx1.close() + await ctx2.close() + }) + + test('jouer "Grande fête au château" donne +1 Nourriture et +1 Diplomatie', async ({ browser }) => { + test.setTimeout(90_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) + + const drawn = await drawEventUntil(p1, [p2], 'castle_feast') + test.skip(!drawn, 'Grande fête non piochée dans la limite de tentatives (probabiliste)') + + const before = await getMyGauges(p1) + + await playEventCard(p1, 'castle_feast') + await playFullTurn([p1, p2], [ + { attr: ['food', 'diplo'], mode: 'min' }, + { attr: ['food', 'diplo'], mode: 'min' } + ]) + + const after = await getMyGauges(p1) + expect(after.food).toBeGreaterThanOrEqual(before.food + 1) + expect(after.diplo).toBeGreaterThanOrEqual(before.diplo + 1) + + await ctx1.close() + await ctx2.close() + }) + + test('les résultats des cartes Événement jouées apparaissent dans le résumé du tour', async ({ browser }) => { + test.setTimeout(90_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) + + const drawn = await drawEventUntil(p1, [p2], 'bountiful_harvest') + test.skip(!drawn, 'Récolte exceptionnelle non piochée dans la limite de tentatives (probabiliste)') + + await playEventCard(p1, 'bountiful_harvest') + + // Jouer manuellement pour observer le texte affiché juste après résolution, + // sans attendre le waitForTimeout complet de playFullTurn. + const canvas1 = p1.locator('#phaser-container canvas') + const box1 = await canvas1.boundingBox() + const cardPos = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const b = scene.handCards[0].getBounds() + return { x: b.centerX, y: b.centerY } + }) + await p1.mouse.click(box1.x + cardPos.x, box1.y + cardPos.y) + await p1.waitForTimeout(200) + const btnPos = await p1.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 p1.mouse.click(box1.x + btnPos.x, box1.y + btnPos.y) + + await playFullTurn([p2]) + await p1.waitForTimeout(500) + + const readyText = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + return game.scene.getScene('GameScene').readyText.text + }) + expect(readyText).toContain('Récolte exceptionnelle') + + await ctx1.close() + await ctx2.close() + }) +}) diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js index 07ca141..98a8142 100644 --- a/e2e/tests/helpers.js +++ b/e2e/tests/helpers.js @@ -271,3 +271,109 @@ export async function getShopBuildingIds(page) { return scene.buildingTokens.map(t => t.itemData.id) }) } + +// ── Cartes Événement ───────────────────────────────────────────────────────── + +/** Lit ma main d'événements actuelle (ids, max 2) depuis le store Pinia exposé par la scène. */ +export async function getMyEventHand(page) { + return page.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + return scene.store.myEventHand ?? [] + }) +} + +/** Clique sur le bouton de pioche d'événement (si actuellement cliquable). */ +export async function clickEventDrawButton(page) { + const canvas = page.locator('#phaser-container canvas') + const box = await canvas.boundingBox() + const btnInfo = await page.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const btn = scene.eventDrawBtn + if (!btn || !btn.input || !btn.input.enabled) return null + const b = btn.getBounds() + return { x: b.centerX, y: b.centerY } + }) + if (!btnInfo) return false + await page.mouse.click(box.x + btnInfo.x, box.y + btnInfo.y) + await page.waitForTimeout(400) + return true +} + +/** + * Pioche des cartes événement jusqu'à obtenir l'id demandé (ou jusqu'à + * atteindre maxDraws tentatives). Le deck partagé est mélangé aléatoirement + * (18 cartes, 3 exemplaires de chacun des 6 événements) donc aucune pioche + * spécifique n'est garantie à coup sûr — mais avec un nombre de tentatives + * suffisant sur un deck de cette taille, la probabilité de ne jamais tomber + * sur l'événement recherché reste négligeable en pratique de test. + * + * IMPORTANT : cette fonction gère l'espacement en tours nécessaire pour + * reconstituer les jauges entre deux pioches (coût de 1 point dans chacune + * des 4 jauges) — l'appelant doit fournir `otherPages` pour que playFullTurn + * puisse faire progresser les tours pendant l'accumulation. + */ +export async function drawEventUntil(page, otherPages, eventId, maxDraws = 12) { + for (let attempt = 0; attempt < maxDraws; attempt++) { + const hand = await getMyEventHand(page) + if (hand.includes(eventId)) return true + if (hand.length >= 2) return false // main pleine sans l'event recherché : abandon + + const gauges = await getMyGauges(page) + const affordable = ['food', 'war', 'health', 'diplo'].every(a => (gauges[a] ?? 0) >= 1) + + if (affordable) { + const drew = await clickEventDrawButton(page) + if (drew) continue // retente immédiatement la vérification de main sans jouer de tour + } + + // Jauges insuffisantes : jouer un tour complet (toutes les cartes au + // hasard, peu importe l'attribut) pour accumuler au moins 1 point sur + // chaque jauge avant de retenter. + await playFullTurn([page, ...otherPages]) + } + return (await getMyEventHand(page)).includes(eventId) +} + +/** + * 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` + * (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. + */ +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 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 } + }, eventId) + if (!cardPos) return false + + await page.mouse.click(box.x + cardPos.x, box.y + cardPos.y) + await page.waitForTimeout(250) + + for (const teamIndex of targetTeamIndexes) { + const slotPos = await page.evaluate((teamIndex) => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const idx = scene.store.players.findIndex(p => p.teamIndex === teamIndex) + if (idx === -1) return null + const slot = scene.playSlots[idx] + return slot ? { x: slot.x, y: slot.y } : null + }, teamIndex) + if (!slotPos) continue + await page.mouse.click(box.x + slotPos.x, box.y + slotPos.y) + await page.waitForTimeout(250) + } + + return true +} diff --git a/server/src/game/Room.js b/server/src/game/Room.js index 2abada0..f006ada 100644 --- a/server/src/game/Room.js +++ b/server/src/game/Room.js @@ -1,6 +1,7 @@ const { dealHands } = require('./cards') const { ITEMS, getItemById } = require('./items') const { BUILDINGS, getBuildingById } = require('./buildings') +const { getEventById, buildEventDeck, DRAW_COST } = require('./events') const MAX_PLAYERS = 4 const MAX_TURNS = 12 @@ -29,8 +30,25 @@ const ATTRS = ['food', 'war', 'health', 'diplo'] * un bâtiment coûte des points sur DEUX jauges à la fois et garantit un * minimum sur ses deux attributs à chaque tour, avec un bonus de synergie * (+1 supplémentaire) si l'objet correspondant est aussi possédé. + * + * Événements (voir events.js) : + * Deck PARTAGÉ de 18 cartes (3 exemplaires de chacun des 6 événements), + * mélangé au démarrage de la partie. En phase 'selecting', un joueur peut + * acheter une pioche (coût fixe : 1 point dans CHACUNE des 4 jauges), au + * maximum une tentative par tour, tant que sa "main d'événements" (cartes + * piochées mais pas encore jouées) contient moins de EVENT_HAND_MAX cartes. + * Le joueur choisit ensuite, tour par tour, s'il joue UNE de ses cartes + * événement (au maximum) en la sélectionnant explicitement — contrairement + * aux objets/bâtiments, jouer un événement n'est jamais automatique. + * Les événements du tour sont résolus EN PREMIER dans resolveRound(), avant + * le calcul des gains des cartes personnage (dont les jauges reflètent donc + * déjà les effets des événements de ce même tour). Une carte événement est + * toujours défaussée après résolution (jouée ou consommée par un ciblage + * requis mais non fourni), et l'effet ne persiste jamais au tour suivant. */ +const EVENT_HAND_MAX = 2 + class Room { constructor(id) { this.id = id @@ -44,6 +62,10 @@ class Room { this.availableItems = [] // ids des objets pas encore achetés this.availableBuildings = [] // ids des bâtiments pas encore achetés this.firstPlayerIndex = 0 // index (dans this.players) du joueur prioritaire ce tour + + this.eventDeck = [] // ids d'événements restants dans le deck partagé + this.pendingEventDraws = new Set() // socketIds ayant déjà tenté une pioche ce tour + this.pendingEventPlays = new Map() // socketId -> { eventId, targets } carte choisie pour être jouée ce tour } get playerCount() { return this.players.length } @@ -62,7 +84,8 @@ class Room { hand: [], deck: [], // cartes restantes à piocher items: [], // ids des objets possédés (effets permanents) - buildings: [] // ids des bâtiments possédés (effets permanents) + buildings: [], // ids des bâtiments possédés (effets permanents) + eventHand: [] // ids d'événements piochés mais pas encore joués (max EVENT_HAND_MAX) }) return true } @@ -75,6 +98,8 @@ class Room { this.pendingBuys.delete(socketId) this.pendingBuildingBuys.delete(socketId) this.pendingPlays.delete(socketId) + this.pendingEventDraws.delete(socketId) + this.pendingEventPlays.delete(socketId) // Conserver l'identité du "premier joueur" (priorité d'achat) à travers // la suppression : firstPlayerIndex est un index positionnel qui décale @@ -107,14 +132,18 @@ class Room { p.gauges = { food: 0, war: 0, health: 0, diplo: 0 } p.items = [] p.buildings = [] + p.eventHand = [] }) this.currentTurn = 1 this.phase = 'selecting' this.pendingPlays.clear() this.pendingBuys.clear() this.pendingBuildingBuys.clear() + this.pendingEventDraws.clear() + this.pendingEventPlays.clear() this.availableItems = ITEMS.map(i => i.id) this.availableBuildings = BUILDINGS.map(b => b.id) + this.eventDeck = buildEventDeck() this.firstPlayerIndex = 0 } @@ -212,6 +241,72 @@ class Room { return { ok: true } } + // ─── Cartes Événement ─────────────────────────────────────────────────────── + + /** + * Achète une pioche d'événement : contrairement aux objets/bâtiments, il + * n'y a pas de conflit de stock unique possible (le deck partagé de 18 + * cartes ne cible jamais une carte précise), donc l'effet est appliqué + * IMMÉDIATEMENT (coût débité, carte piochée ajoutée à la main d'événements) + * plutôt que différé à resolveRound(). + */ + buyEventDraw(socketId) { + if (this.phase !== 'selecting') + return { ok: false, error: 'Ce n\'est pas la phase de sélection' } + + const player = this.players.find(p => p.socketId === socketId) + if (!player) return { ok: false, error: 'Joueur introuvable' } + + if (this.pendingEventDraws.has(socketId)) + return { ok: false, error: 'Tu as déjà tenté une pioche d\'événement ce tour' } + + if (player.eventHand.length >= EVENT_HAND_MAX) + return { ok: false, error: `Ta main d'événements est pleine (max ${EVENT_HAND_MAX})` } + + if (this.eventDeck.length === 0) + return { ok: false, error: 'Le deck d\'événements est épuisé' } + + const missing = Object.entries(DRAW_COST).filter(([attr, cost]) => player.gauges[attr] < cost) + if (missing.length > 0) + return { ok: false, error: 'Pas assez de points pour piocher un événement (il faut 1 point dans chaque jauge)' } + + Object.entries(DRAW_COST).forEach(([attr, cost]) => { player.gauges[attr] -= cost }) + const drawnId = this.eventDeck.shift() + player.eventHand.push(drawnId) + this.pendingEventDraws.add(socketId) + + return { ok: true, eventId: drawnId } + } + + /** + * Choisit de jouer une carte événement de sa main ce tour (résolution + * différée à resolveRound(), en même temps que celle des autres joueurs, + * et AVANT le calcul des gains des cartes personnage). `targets` dépend de + * l'événement, et référence les cibles par TEAMINDEX (identifiant public, + * jamais par socketId — donnée serveur interne non exposée au client via + * getPlayersInfo/getStateFor) : + * - royal_accord : { gainTargetTeamIndex, loseTargetTeamIndex } + * - epidemic : { targetTeamIndex } (utilisé seulement si la variante + * Trousse du Médecin s'applique — déterminée à la résolution, pas ici) + * Une seule carte événement jouable par tour et par joueur. + */ + playEventCard(socketId, eventId, targets = {}) { + if (this.phase !== 'selecting') + return { ok: false, error: 'Ce n\'est pas la phase de sélection' } + + const player = this.players.find(p => p.socketId === socketId) + if (!player) return { ok: false, error: 'Joueur introuvable' } + + if (!player.eventHand.includes(eventId)) + return { ok: false, error: 'Tu ne possèdes pas cette carte événement' } + + if (this.pendingEventPlays.has(socketId)) + return { ok: false, error: 'Tu as déjà choisi de jouer un événement ce tour' } + + this.pendingEventPlays.set(socketId, { eventId, targets }) + return { ok: true } + } + // ─── Résolution du tour ───────────────────────────────────────────────────── resolveRound() { @@ -225,6 +320,14 @@ class Room { const boughtItems = this._resolvePendingBuys() const boughtBuildings = this._resolvePendingBuildingBuys() + // ── 1bis. Résoudre les cartes Événement AVANT les gains personnage ───────── + // Choix de design validé : les événements du tour modifient les jauges + // directement (comme les achats), donc les cartes personnage sont ensuite + // comparées avec des jauges déjà à jour — mais le gain voisin-de-gauche + // lui-même ne dépend que des VALEURS DE CARTE jouées, pas des jauges, donc + // cet ordre n'affecte que le score cumulé, jamais le calcul du gain brut. + const eventResults = this._resolvePendingEventPlays() + // ── 2. Construire un tableau ordonné des cartes jouées ───────────────────── const plays = this.players.map(p => ({ player: p, @@ -284,7 +387,7 @@ class Room { this.pendingPlays.clear() this.phase = 'resolve' - return { playedCards, gains, boughtItems, boughtBuildings, message: lines.join(' | ') } + return { playedCards, gains, boughtItems, boughtBuildings, eventResults, message: lines.join(' | ') } } /** @@ -473,6 +576,157 @@ class Room { return gains } + /** + * Résout toutes les cartes Événement jouées ce tour (this.pendingEventPlays), + * dans l'ordre suivant : + * 1. Défausser chaque carte jouée de la main du joueur (elle est toujours + * consommée, que son effet s'applique intégralement ou non). + * 2. Déterminer l'ensemble des joueurs protégés par "Complot découvert" + * joué CE tour (protection uniquement pour soi-même — validé). + * 3. Appliquer l'effet de chaque autre carte jouée, en respectant la + * protection ci-dessus pour les effets négatifs qui ciblent le porteur + * du Complot. + * Retourne un tableau de résumés { playerName, eventId, eventName, summary } + * pour l'affichage côté client. + */ + _resolvePendingEventPlays() { + if (this.pendingEventPlays.size === 0) return [] + + // Snapshot des entrées avant toute défausse/mutation, pour un traitement + // stable même si plusieurs joueurs jouent le même id d'événement. + const entries = [...this.pendingEventPlays.entries()].map(([socketId, play]) => ({ + socketId, + player: this.players.find(p => p.socketId === socketId), + eventId: play.eventId, + targets: play.targets ?? {} + })).filter(e => e.player) + + // Défausser chaque carte jouée de la main de son joueur, immédiatement + // et inconditionnellement (règle : toujours consommée après résolution). + entries.forEach(({ player, eventId }) => { + const idx = player.eventHand.indexOf(eventId) + if (idx !== -1) player.eventHand.splice(idx, 1) + }) + + // Joueurs protégés par "Complot découvert" ce tour (protection pour + // soi-même uniquement, jamais collective). + const protectedSocketIds = new Set( + entries.filter(e => e.eventId === 'plot_uncovered').map(e => e.socketId) + ) + + const results = [] + const extraDraws = [] // socketIds ayant droit à une pioche bonus (Grande fête + Château) + + entries.forEach(({ player, eventId, targets, socketId }) => { + const event = getEventById(eventId) + if (!event) return + + let summary = '' + + switch (eventId) { + case 'bountiful_harvest': { + // Récolte exceptionnelle : +2 Nourriture, +1 supplémentaire avec la Charrue. + const bonus = player.items.includes('plow') ? 1 : 0 + const gain = 2 + bonus + player.gauges.food += gain + summary = `${player.name} : +${gain} Nourriture (Récolte exceptionnelle${bonus ? ' + Charrue' : ''})` + break + } + + case 'epidemic': { + // Épidémie : effet de base (tout le monde -1 Santé), sauf variante + // Trousse (un adversaire choisi perd 2 Santé à la place) ou Église + // (tous les adversaires perdent 2 Santé à la place) — déterminée + // au moment de jouer la carte, selon ce que le lanceur possède. + if (player.buildings.includes('church')) { + const affected = this.players.filter(p => p.socketId !== socketId) + affected.forEach(p => { + if (!protectedSocketIds.has(p.socketId)) p.gauges.health = Math.max(0, p.gauges.health - 2) + }) + summary = `${player.name} déclenche Épidémie (Église) : tous les adversaires -2 Santé` + } else if (player.items.includes('medkit') && targets.targetTeamIndex !== undefined) { + const target = this.players.find(p => p.teamIndex === targets.targetTeamIndex && p.socketId !== socketId) + if (target && !protectedSocketIds.has(target.socketId)) { + target.gauges.health = Math.max(0, target.gauges.health - 2) + summary = `${player.name} déclenche Épidémie (Trousse) : ${target.name} -2 Santé` + } else { + summary = `${player.name} déclenche Épidémie (Trousse), cible invalide ou protégée` + } + } else { + this.players.forEach(p => { + if (!protectedSocketIds.has(p.socketId)) p.gauges.health = Math.max(0, p.gauges.health - 1) + }) + summary = `${player.name} déclenche Épidémie : tous les joueurs -1 Santé` + } + break + } + + case 'troop_levy': { + // Levée des troupes : +2 Guerre / -1 Diplomatie (auto-infligé par + // sa propre carte), +2 Guerre suppl. avec le Trébuchet (soit +4 + // Guerre au total). Note : "Complot découvert" protège des effets + // négatifs des AUTRES cartes événement qui ciblent le joueur — ne + // s'applique pas ici puisqu'un seul événement est jouable par tour + // et par joueur (impossible de jouer les deux la même main). + const warBonus = player.items.includes('trebuchet') ? 2 : 0 + const warGain = 2 + warBonus + player.gauges.war += warGain + player.gauges.diplo = Math.max(0, player.gauges.diplo - 1) + summary = `${player.name} : +${warGain} Guerre, -1 Diplomatie (Levée des troupes${warBonus ? ' + Trébuchet' : ''})` + break + } + + case 'royal_accord': { + // Accord royal : +1 Diplo pour soi (+1 suppl. avec le Sceau), un + // joueur choisi gagne 1 Diplo, un joueur choisi (peut être le même + // ou différent) en perd 1. + const selfBonus = player.items.includes('royal_seal') ? 1 : 0 + player.gauges.diplo += 1 + selfBonus + + const gainTarget = this.players.find(p => p.teamIndex === targets.gainTargetTeamIndex && p.socketId !== socketId) + if (gainTarget) gainTarget.gauges.diplo += 1 + + const loseTarget = this.players.find(p => p.teamIndex === targets.loseTargetTeamIndex && p.socketId !== socketId) + if (loseTarget && !protectedSocketIds.has(loseTarget.socketId)) { + loseTarget.gauges.diplo = Math.max(0, loseTarget.gauges.diplo - 1) + } + + summary = `${player.name} : +${1 + selfBonus} Diplomatie (Accord royal${selfBonus ? ' + Sceau' : ''})` + + (gainTarget ? `, ${gainTarget.name} +1 Diplomatie` : '') + + (loseTarget ? `, ${loseTarget.name} -1 Diplomatie` : '') + break + } + + case 'plot_uncovered': { + // Complot découvert : protection déjà calculée en amont (protectedSocketIds). + summary = `${player.name} joue Complot découvert (protégé des effets négatifs ce tour)` + break + } + + case 'castle_feast': { + // Grande fête au château : +1 Nourriture, +1 Diplomatie, pioche + // bonus si le Château Royal est possédé (place toujours disponible + // puisque jouer cette carte libère un slot dans la main). + player.gauges.food += 1 + player.gauges.diplo += 1 + let bonusDrawn = null + if (player.buildings.includes('castle') && this.eventDeck.length > 0) { + bonusDrawn = this.eventDeck.shift() + player.eventHand.push(bonusDrawn) + } + summary = `${player.name} : +1 Nourriture, +1 Diplomatie (Grande fête)` + + (bonusDrawn ? `, pioche bonus (Château Royal)` : '') + break + } + } + + results.push({ playerName: player.name, teamIndex: player.teamIndex, eventId, eventName: event.name, summary }) + }) + + this.pendingEventPlays.clear() + return results + } + // ─── Tour suivant ──────────────────────────────────────────────────────────── nextTurn() { @@ -496,6 +750,7 @@ class Room { this.phase = 'selecting' this.pendingBuys.clear() this.pendingBuildingBuys.clear() + this.pendingEventDraws.clear() } isGameOver() { @@ -515,7 +770,8 @@ class Room { handSize: p.hand.length, deckSize: p.deck.length, items: [...p.items], - buildings: [...p.buildings] + buildings: [...p.buildings], + eventHandSize: p.eventHand.length })) } @@ -538,7 +794,11 @@ class Room { pendingBuyBuildingId: this.pendingBuildingBuys.get(socketId) ?? null, availableItems: [...this.availableItems], availableBuildings: [...this.availableBuildings], - firstPlayerName: firstPlayer ? firstPlayer.name : null + firstPlayerName: firstPlayer ? firstPlayer.name : null, + myEventHand: player ? [...player.eventHand] : [], + eventDeckSize: this.eventDeck.length, + hasPendingEventDraw: this.pendingEventDraws.has(socketId), + pendingEventPlayId: this.pendingEventPlays.get(socketId)?.eventId ?? null } } } diff --git a/server/src/game/events.js b/server/src/game/events.js new file mode 100644 index 0000000..4450f1c --- /dev/null +++ b/server/src/game/events.js @@ -0,0 +1,96 @@ +/** + * Regnum - Définition des 6 cartes Événement et de leur pioche partagée + * + * La pioche est un deck PARTAGÉ pour toute la partie : 18 cartes (3 + * exemplaires de chacun des 6 événements), mélangées au démarrage. Quand un + * joueur achète une pioche, une carte est retirée du deck commun (les autres + * joueurs ne peuvent plus la piocher). + * + * Chaque définition d'événement porte : + * - id, name, description : affichage + * - cost: { food, war, health, diplo } — coût de la PIOCHE (identique pour + * tous les événements : 1 point dans chacune des 4 jauges, payé à + * l'achat, pas au moment de jouer la carte) + * - needsTargetOnDraw: true si le joueur doit choisir une/des cible(s) dès + * le tirage (Accord royal — le ciblage ne dépend d'aucun objet possédé, + * donc peut être figé immédiatement) + * - needsTargetOnPlay: true si le ciblage dépend d'un état qui ne peut être + * connu qu'au moment de jouer la carte (Épidémie — la variante Trousse + * n'est déterminée qu'à cet instant, donc le ciblage doit avoir lieu à + * ce moment-là, pas au tirage) + * - resolve(ctx) : logique d'effet, voir Room._resolveEventCard pour le + * contrat exact de `ctx` (player, allPlayers, targets, hasNegatedByComplot) + */ + +const DRAW_COST = { food: 1, war: 1, health: 1, diplo: 1 } + +const EVENTS = [ + { + id: 'bountiful_harvest', + name: 'Récolte exceptionnelle', + cost: DRAW_COST, + negative: false, + description: 'Gagnez 2 Nourriture. Synergie — Charrue : si vous possédez la Charrue, gagnez 1 Nourriture supplémentaire.' + }, + { + id: 'epidemic', + name: 'Épidémie', + cost: DRAW_COST, + negative: true, + needsTargetOnPlay: true, // la cible (si variante Trousse) n'est connue qu'au moment de jouer + description: 'Effet de base : chaque joueur perd 1 Santé. Avec la Trousse du Médecin : un adversaire choisi perd 2 Santé à la place. Avec l\'Église : tous les adversaires perdent 2 Santé à la place.' + }, + { + id: 'troop_levy', + name: 'Levée des troupes', + cost: DRAW_COST, + negative: false, // le porteur y gagne toujours plus qu'il n'y perd + description: 'Gagnez 2 Guerre et perdez 1 Diplomatie. Synergie — Trébuchet : si vous possédez le Trébuchet, gagnez 2 Guerre supplémentaires (soit 4 Guerre au total).' + }, + { + id: 'royal_accord', + name: 'Accord royal', + cost: DRAW_COST, + negative: false, + needsTargetOnDraw: true, // choix des 2 joueurs ciblés dès le tirage (pas de dépendance à un objet) + description: 'Gagnez 1 Diplomatie. Choisissez un autre joueur : il gagne 1 Diplomatie. Choisissez un autre joueur : il perd 1 Diplomatie (les deux joueurs choisis peuvent être différents). Synergie — Sceau Royal : si vous possédez le Sceau, gagnez 1 Diplomatie supplémentaire.' + }, + { + id: 'plot_uncovered', + name: 'Complot découvert', + cost: DRAW_COST, + negative: false, + description: 'Jusqu\'à la fin du tour, annule pour vous les effets négatifs des autres cartes Événement jouées ce tour.' + }, + { + id: 'castle_feast', + name: 'Grande fête au château', + cost: DRAW_COST, + negative: false, + description: 'Gagnez 1 Nourriture et 1 Diplomatie. Synergie — Château Royal : si vous possédez le Château Royal, piochez 1 carte Événement supplémentaire.' + } +] + +function getEventById(id) { + return EVENTS.find(e => e.id === id) ?? null +} + +function shuffle(arr) { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]] + } + return a +} + +/** Construit et mélange le deck partagé de 18 cartes (3 exemplaires de chaque événement). */ +function buildEventDeck() { + const deck = [] + EVENTS.forEach(e => { + for (let i = 0; i < 3; i++) deck.push(e.id) + }) + return shuffle(deck) +} + +module.exports = { EVENTS, getEventById, buildEventDeck, DRAW_COST } diff --git a/server/src/index.js b/server/src/index.js index ab38f2b..29f0b0d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -99,6 +99,42 @@ io.on('connection', (socket) => { }) }) + // ─── Piocher une carte Événement ───────────────────────────────────────────── + socket.on('buy-event-draw', ({ roomId }) => { + const room = rooms.get(roomId) + if (!room) return + + const result = room.buyEventDraw(socket.id) + if (!result.ok) { + socket.emit('error', { message: result.error }) + return + } + + // Contrairement aux objets/bâtiments, la pioche est immédiate (pas de + // conflit de stock possible) : chaque joueur reçoit son état à jour tout + // de suite (seul le joueur ayant pioché voit la nouvelle carte dans + // myEventHand, les autres voient juste ses jauges diminuer). + room.players.forEach(player => { + io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId)) + }) + }) + + // ─── Choisir de jouer une carte Événement ce tour ──────────────────────────── + socket.on('play-event', ({ roomId, eventId, targets }) => { + const room = rooms.get(roomId) + if (!room) return + + const result = room.playEventCard(socket.id, eventId, targets) + if (!result.ok) { + socket.emit('error', { message: result.error }) + return + } + + room.players.forEach(player => { + io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId)) + }) + }) + // ─── Jouer une carte ───────────────────────────────────────────────────────── socket.on('play-card', ({ roomId, cardId }) => { const room = rooms.get(roomId) @@ -128,12 +164,14 @@ io.on('connection', (socket) => { const roundResult = room.resolveRound() console.log(`[${roomId}] Tour ${room.currentTurn} résolu`) - // Révéler les cartes jouées, les gains par attribut, et les objets/bâtiments achetés + // Révéler les cartes jouées, les gains par attribut, les objets/bâtiments + // achetés, et les résultats des cartes Événement jouées ce tour. io.to(roomId).emit('round-result', { playedCards: roundResult.playedCards, gains: roundResult.gains, boughtItems: roundResult.boughtItems, boughtBuildings: roundResult.boughtBuildings, + eventResults: roundResult.eventResults, message: roundResult.message }) diff --git a/server/test/game/Room.events.test.js b/server/test/game/Room.events.test.js new file mode 100644 index 0000000..55fff9a --- /dev/null +++ b/server/test/game/Room.events.test.js @@ -0,0 +1,525 @@ +const { test, describe } = require('node:test') +const assert = require('node:assert/strict') + +const { Room } = require('../../src/game/Room') + +/** Aide : crée une Room avec n joueurs déjà ajoutés et la partie démarrée. */ +function makeStartedRoom(n) { + const room = new Room('T') + for (let i = 0; i < n; i++) { + room.addPlayer(`s${i}`, `P${i}`) + } + room.startGame() + return room +} + +/** Aide : force la carte en tête de main d'un joueur à des valeurs contrôlées. */ +function setHandCardValues(room, socketId, values) { + const player = room.players.find(p => p.socketId === socketId) + Object.assign(player.hand[0], values) + return player.hand[0] +} + +/** Aide : joue les cartes en tête de main de tous les joueurs, avec attributs + * neutralisés (0 partout) pour isoler les effets d'événements des gains + * de carte personnage normaux. */ +function playNeutralCardsAndResolve(room) { + room.players.forEach(p => { + setHandCardValues(room, p.socketId, { food: 0, war: 0, health: 0, diplo: 0 }) + }) + room.players.forEach(p => room.playCard(p.socketId, p.hand[0].id)) + return room.resolveRound() +} + +/** Aide : force le deck d'événement à ne contenir qu'un seul id, en tête. */ +function forceNextDraw(room, eventId) { + room.eventDeck.unshift(eventId) +} + +describe('Room - buyEventDraw', () => { + test('accepte une pioche si les 4 jauges ont au moins 1 point', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + const result = room.buyEventDraw('s0') + assert.equal(result.ok, true) + assert.ok(result.eventId) + }) + + test('débite 1 point de chacune des 4 jauges', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 5, war: 5, health: 5, diplo: 5 } + room.buyEventDraw('s0') + assert.deepEqual(room.players[0].gauges, { food: 4, war: 4, health: 4, diplo: 4 }) + }) + + test('ajoute la carte piochée à la main d\'événements du joueur', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + const result = room.buyEventDraw('s0') + assert.deepEqual(room.players[0].eventHand, [result.eventId]) + }) + + test('retire la carte piochée du deck partagé', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + const before = room.eventDeck.length + room.buyEventDraw('s0') + assert.equal(room.eventDeck.length, before - 1) + }) + + test('refuse si une seule des 4 jauges est insuffisante', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 0, diplo: 1 } + const result = room.buyEventDraw('s0') + assert.equal(result.ok, false) + }) + + test('refuse hors phase selecting', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + room.phase = 'resolve' + const result = room.buyEventDraw('s0') + assert.equal(result.ok, false) + }) + + test('refuse une seconde tentative de pioche le même tour', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 5, war: 5, health: 5, diplo: 5 } + room.buyEventDraw('s0') + const second = room.buyEventDraw('s0') + assert.equal(second.ok, false) + assert.match(second.error, /déjà tenté/) + }) + + test('refuse si la main d\'événements est déjà pleine (max 2)', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 10, war: 10, health: 10, diplo: 10 } + room.players[0].eventHand = ['bountiful_harvest', 'epidemic'] // déjà 2 + const result = room.buyEventDraw('s0') + assert.equal(result.ok, false) + assert.match(result.error, /pleine/) + }) + + test('refuse si le deck partagé est épuisé', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + room.eventDeck = [] + const result = room.buyEventDraw('s0') + assert.equal(result.ok, false) + assert.match(result.error, /épuisé/) + }) + + test('refuse un joueur inconnu', () => { + const room = makeStartedRoom(2) + const result = room.buyEventDraw('ghost') + assert.equal(result.ok, false) + }) + + test('le deck est PARTAGÉ : une pioche par un joueur réduit ce que les autres peuvent piocher', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + room.players[1].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + const before = room.eventDeck.length + room.buyEventDraw('s0') + room.buyEventDraw('s1') + assert.equal(room.eventDeck.length, before - 2) + }) +}) + +describe('Room - playEventCard', () => { + test('accepte de jouer une carte présente dans la main d\'événements', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + const result = room.playEventCard('s0', 'bountiful_harvest') + assert.equal(result.ok, true) + }) + + test('refuse une carte absente de la main d\'événements', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['epidemic'] + const result = room.playEventCard('s0', 'bountiful_harvest') + assert.equal(result.ok, false) + }) + + test('refuse une seconde carte événement jouée le même tour (max 1 par tour)', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest', 'epidemic'] + room.playEventCard('s0', 'bountiful_harvest') + const second = room.playEventCard('s0', 'epidemic') + assert.equal(second.ok, false) + assert.match(second.error, /déjà choisi/) + }) + + test('refuse hors phase selecting', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.phase = 'resolve' + const result = room.playEventCard('s0', 'bountiful_harvest') + assert.equal(result.ok, false) + }) + + test('refuse un joueur inconnu', () => { + const room = makeStartedRoom(2) + const result = room.playEventCard('ghost', 'bountiful_harvest') + assert.equal(result.ok, false) + }) +}) + +describe('Room - résolution des événements : ordre et défausse', () => { + test('les événements sont résolus AVANT le calcul des gains de cartes personnage', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.playEventCard('s0', 'bountiful_harvest') + // gain de base de carte = 0 (cartes neutralisées), donc si food augmente + // c'est uniquement grâce à l'événement, avant même le calcul des gains. + const result = playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.food, 2) + assert.equal(result.gains[0].food, 0) // le gain de carte lui-même reste 0 + }) + + test('la carte jouée est retirée de la main d\'événements (toujours défaussée)', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.playEventCard('s0', 'bountiful_harvest') + playNeutralCardsAndResolve(room) + assert.deepEqual(room.players[0].eventHand, []) + }) + + test('pendingEventPlays est vidé après résolution', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.playEventCard('s0', 'bountiful_harvest') + playNeutralCardsAndResolve(room) + assert.equal(room.pendingEventPlays.size, 0) + }) + + test('un tour sans carte événement jouée ne modifie rien (eventResults vide)', () => { + const room = makeStartedRoom(2) + const result = playNeutralCardsAndResolve(room) + assert.deepEqual(result.eventResults, []) + }) +}) + +describe('Room - effet: Récolte exceptionnelle', () => { + test('gagne 2 Nourriture sans la Charrue', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.playEventCard('s0', 'bountiful_harvest') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.food, 2) + }) + + test('gagne 3 Nourriture (2+1) avec la Charrue', () => { + const room = makeStartedRoom(2) + room.players[0].items.push('plow') + room.players[0].eventHand = ['bountiful_harvest'] + room.playEventCard('s0', 'bountiful_harvest') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.food, 3) + }) + + test('n\'affecte pas les jauges des autres joueurs', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.playEventCard('s0', 'bountiful_harvest') + playNeutralCardsAndResolve(room) + assert.equal(room.players[1].gauges.food, 0) + }) +}) + +describe('Room - effet: Épidémie', () => { + test('effet de base : TOUS les joueurs (y compris le lanceur) perdent 1 Santé', () => { + const room = makeStartedRoom(3) + room.players.forEach(p => { p.gauges.health = 5 }) + room.players[0].eventHand = ['epidemic'] + room.playEventCard('s0', 'epidemic') + playNeutralCardsAndResolve(room) + room.players.forEach(p => assert.equal(p.gauges.health, 4)) + }) + + test('variante Trousse : un adversaire choisi perd 2 Santé, les autres ne perdent rien', () => { + const room = makeStartedRoom(3) + room.players.forEach(p => { p.gauges.health = 5 }) + room.players[0].items.push('medkit') + room.players[0].eventHand = ['epidemic'] + room.playEventCard('s0', 'epidemic', { targetTeamIndex: 1 }) + playNeutralCardsAndResolve(room) + // Le lanceur n'est pas affecté par sa PROPRE variante Trousse de l'Épidémie, + // mais possède la Trousse : son effet permanent (guaranteed_gain +2 Santé, + // indépendant de l'événement) s'applique quand même normalement ce tour. + assert.equal(room.players[0].gauges.health, 7) // 5 + 2 (effet permanent de la Trousse) + assert.equal(room.players[1].gauges.health, 3) // cible : -2 + assert.equal(room.players[2].gauges.health, 5) // non ciblé : inchangé + }) + + test('variante Église : TOUS les adversaires perdent 2 Santé, le lanceur n\'est pas affecté', () => { + const room = makeStartedRoom(3) + room.players.forEach(p => { p.gauges.health = 5 }) + room.players[0].buildings.push('church') + room.players[0].eventHand = ['epidemic'] + room.playEventCard('s0', 'epidemic') + playNeutralCardsAndResolve(room) + // Le lanceur possède l'Église : son effet permanent (+1 Santé garanti, + // indépendant de l'événement) s'applique aussi normalement ce tour. + assert.equal(room.players[0].gauges.health, 6) // 5 + 1 (effet permanent de l'Église) + assert.equal(room.players[1].gauges.health, 3) + assert.equal(room.players[2].gauges.health, 3) + }) + + test('la variante Église est prioritaire sur la variante Trousse si les deux sont possédées', () => { + const room = makeStartedRoom(3) + room.players.forEach(p => { p.gauges.health = 5 }) + room.players[0].items.push('medkit') + room.players[0].buildings.push('church') + room.players[0].eventHand = ['epidemic'] + room.playEventCard('s0', 'epidemic', { targetTeamIndex: 1 }) + playNeutralCardsAndResolve(room) + // Église s'applique : tous les adversaires -2, pas seulement s1 + assert.equal(room.players[1].gauges.health, 3) + assert.equal(room.players[2].gauges.health, 3) + }) + + test('la Santé ne devient jamais négative (clampée à 0)', () => { + const room = makeStartedRoom(2) + room.players.forEach(p => { p.gauges.health = 0 }) + room.players[0].eventHand = ['epidemic'] + room.playEventCard('s0', 'epidemic') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.health, 0) + assert.equal(room.players[1].gauges.health, 0) + }) + + test('Complot découvert protège son porteur de l\'effet de base d\'Épidémie jouée par un autre', () => { + const room = makeStartedRoom(2) + room.players.forEach(p => { p.gauges.health = 5 }) + room.players[0].eventHand = ['epidemic'] + room.players[1].eventHand = ['plot_uncovered'] + room.playEventCard('s0', 'epidemic') + room.playEventCard('s1', 'plot_uncovered') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.health, 4) // lanceur d'Épidémie, non protégé + assert.equal(room.players[1].gauges.health, 5) // protégé par son propre Complot découvert + }) +}) + +describe('Room - effet: Levée des troupes', () => { + test('gagne 2 Guerre et perd 1 Diplomatie sans le Trébuchet', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.diplo = 5 + room.players[0].eventHand = ['troop_levy'] + room.playEventCard('s0', 'troop_levy') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.war, 2) + assert.equal(room.players[0].gauges.diplo, 4) + }) + + test('gagne 4 Guerre (2+2) avec le Trébuchet, perd toujours 1 Diplomatie', () => { + const room = makeStartedRoom(2) + room.players[0].items.push('trebuchet') + room.players[0].gauges.diplo = 5 + room.players[0].eventHand = ['troop_levy'] + room.playEventCard('s0', 'troop_levy') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.war, 4) + assert.equal(room.players[0].gauges.diplo, 4) + }) + + test('la Diplomatie ne devient jamais négative (clampée à 0)', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.diplo = 0 + room.players[0].eventHand = ['troop_levy'] + room.playEventCard('s0', 'troop_levy') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.diplo, 0) + }) +}) + +describe('Room - effet: Accord royal', () => { + test('gagne 1 Diplomatie sans le Sceau Royal', () => { + const room = makeStartedRoom(3) + room.players[0].eventHand = ['royal_accord'] + room.playEventCard('s0', 'royal_accord') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.diplo, 1) + }) + + test('gagne 2 Diplomatie (1+1) avec le Sceau Royal', () => { + const room = makeStartedRoom(3) + room.players[0].items.push('royal_seal') + room.players[0].eventHand = ['royal_accord'] + room.playEventCard('s0', 'royal_accord') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.diplo, 2) + }) + + test('le joueur ciblé en gain reçoit +1 Diplomatie', () => { + const room = makeStartedRoom(3) + room.players[0].eventHand = ['royal_accord'] + room.playEventCard('s0', 'royal_accord', { gainTargetTeamIndex: 1 }) + playNeutralCardsAndResolve(room) + assert.equal(room.players[1].gauges.diplo, 1) + }) + + test('le joueur ciblé en perte reçoit -1 Diplomatie', () => { + const room = makeStartedRoom(3) + room.players[2].gauges.diplo = 3 + room.players[0].eventHand = ['royal_accord'] + room.playEventCard('s0', 'royal_accord', { loseTargetTeamIndex: 2 }) + playNeutralCardsAndResolve(room) + assert.equal(room.players[2].gauges.diplo, 2) + }) + + test('les deux cibles (gain/perte) peuvent être différentes simultanément', () => { + const room = makeStartedRoom(3) + room.players[2].gauges.diplo = 3 + room.players[0].eventHand = ['royal_accord'] + room.playEventCard('s0', 'royal_accord', { gainTargetTeamIndex: 1, loseTargetTeamIndex: 2 }) + playNeutralCardsAndResolve(room) + assert.equal(room.players[1].gauges.diplo, 1) + assert.equal(room.players[2].gauges.diplo, 2) + }) + + test('un joueur ne peut pas se cibler lui-même (targets ignorées si == lanceur)', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['royal_accord'] + room.playEventCard('s0', 'royal_accord', { gainTargetTeamIndex: 0, loseTargetTeamIndex: 0 }) + playNeutralCardsAndResolve(room) + // Seul le +1 de base doit s'appliquer, pas de double comptage via l'auto-ciblage + assert.equal(room.players[0].gauges.diplo, 1) + }) + + test('Complot découvert protège son porteur de la perte de Diplomatie ciblée', () => { + const room = makeStartedRoom(3) + room.players[1].gauges.diplo = 3 + room.players[0].eventHand = ['royal_accord'] + room.players[1].eventHand = ['plot_uncovered'] + room.playEventCard('s0', 'royal_accord', { loseTargetTeamIndex: 1 }) + room.playEventCard('s1', 'plot_uncovered') + playNeutralCardsAndResolve(room) + assert.equal(room.players[1].gauges.diplo, 3) // protégé, pas de -1 + }) +}) + +describe('Room - effet: Complot découvert', () => { + test('ne modifie aucune jauge par lui-même', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['plot_uncovered'] + room.playEventCard('s0', 'plot_uncovered') + const before = { ...room.players[0].gauges } + playNeutralCardsAndResolve(room) + assert.deepEqual(room.players[0].gauges, before) + }) +}) + +describe('Room - effet: Grande fête au château', () => { + test('gagne 1 Nourriture et 1 Diplomatie sans le Château Royal', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['castle_feast'] + room.playEventCard('s0', 'castle_feast') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].gauges.food, 1) + assert.equal(room.players[0].gauges.diplo, 1) + assert.equal(room.players[0].eventHand.length, 0) // pas de pioche bonus sans le Château + }) + + test('pioche 1 carte événement supplémentaire avec le Château Royal', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('castle') + room.players[0].eventHand = ['castle_feast'] + const deckBefore = room.eventDeck.length + room.playEventCard('s0', 'castle_feast') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].eventHand.length, 1) // 1 carte piochée en bonus + assert.equal(room.eventDeck.length, deckBefore - 1) + }) + + test('ne pioche rien si le deck partagé est épuisé, même avec le Château', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('castle') + room.players[0].eventHand = ['castle_feast'] + room.eventDeck = [] + room.playEventCard('s0', 'castle_feast') + playNeutralCardsAndResolve(room) + assert.equal(room.players[0].eventHand.length, 0) + }) +}) + +describe('Room - eventResults (résumé retourné par resolveRound)', () => { + test('contient une entrée par carte événement jouée, avec playerName et eventName', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.playEventCard('s0', 'bountiful_harvest') + const result = playNeutralCardsAndResolve(room) + assert.equal(result.eventResults.length, 1) + assert.equal(result.eventResults[0].playerName, 'P0') + assert.equal(result.eventResults[0].eventName, 'Récolte exceptionnelle') + assert.ok(result.eventResults[0].summary.length > 0) + }) + + test('contient une entrée par joueur si plusieurs jouent un événement le même tour', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['bountiful_harvest'] + room.players[1].eventHand = ['castle_feast'] + room.playEventCard('s0', 'bountiful_harvest') + room.playEventCard('s1', 'castle_feast') + const result = playNeutralCardsAndResolve(room) + assert.equal(result.eventResults.length, 2) + }) +}) + +describe('Room - getStateFor : champs liés aux événements', () => { + test('expose myEventHand propre à chaque joueur', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['epidemic'] + assert.deepEqual(room.getStateFor('s0').myEventHand, ['epidemic']) + assert.deepEqual(room.getStateFor('s1').myEventHand, []) + }) + + test('expose eventDeckSize (taille du deck partagé)', () => { + const room = makeStartedRoom(2) + assert.equal(room.getStateFor('s0').eventDeckSize, 18) + }) + + test('expose hasPendingEventDraw après une pioche ce tour', () => { + const room = makeStartedRoom(2) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + room.buyEventDraw('s0') + assert.equal(room.getStateFor('s0').hasPendingEventDraw, true) + assert.equal(room.getStateFor('s1').hasPendingEventDraw, false) + }) + + test('expose pendingEventPlayId après avoir choisi de jouer une carte', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['epidemic'] + room.playEventCard('s0', 'epidemic') + assert.equal(room.getStateFor('s0').pendingEventPlayId, 'epidemic') + assert.equal(room.getStateFor('s1').pendingEventPlayId, null) + }) +}) + +describe('Room - removePlayer nettoie l\'état événements', () => { + test('nettoie pendingEventDraws et pendingEventPlays du joueur qui part', () => { + const room = makeStartedRoom(3) + room.players[0].gauges = { food: 1, war: 1, health: 1, diplo: 1 } + room.buyEventDraw('s0') + room.players[1].eventHand = ['epidemic'] + room.playEventCard('s1', 'epidemic') + + room.removePlayer('s0') + + assert.equal(room.pendingEventDraws.has('s0'), false) + // s1 n'est pas parti, son event play doit rester intact + assert.equal(room.pendingEventPlays.has('s1'), true) + }) +}) + +describe('Room - startGame réinitialise l\'état événements', () => { + test('reconstruit un deck de 18 cartes et vide les mains d\'événements', () => { + const room = makeStartedRoom(2) + room.players[0].eventHand = ['epidemic'] + room.eventDeck = ['bountiful_harvest'] + room.startGame() + assert.equal(room.eventDeck.length, 18) + assert.deepEqual(room.players[0].eventHand, []) + }) +}) diff --git a/server/test/game/events.test.js b/server/test/game/events.test.js new file mode 100644 index 0000000..857bb4a --- /dev/null +++ b/server/test/game/events.test.js @@ -0,0 +1,61 @@ +const { test, describe } = require('node:test') +const assert = require('node:assert/strict') + +const { EVENTS, getEventById, buildEventDeck, DRAW_COST } = require('../../src/game/events') + +describe('events.js', () => { + test('expose exactement 6 événements', () => { + assert.equal(EVENTS.length, 6) + }) + + test('chaque événement a un id unique', () => { + const ids = EVENTS.map(e => e.id) + assert.equal(new Set(ids).size, ids.length) + }) + + test('DRAW_COST demande 1 point dans chacune des 4 jauges', () => { + assert.deepEqual(DRAW_COST, { food: 1, war: 1, health: 1, diplo: 1 }) + }) + + test('chaque événement utilise DRAW_COST comme coût de pioche', () => { + for (const e of EVENTS) { + assert.deepEqual(e.cost, DRAW_COST) + } + }) + + describe('getEventById', () => { + test('retourne l\'événement correspondant à un id existant', () => { + const epidemic = getEventById('epidemic') + assert.ok(epidemic) + assert.equal(epidemic.name, 'Épidémie') + }) + + test('retourne null pour un id inconnu', () => { + assert.equal(getEventById('does-not-exist'), null) + }) + + test('retourne null pour undefined/null', () => { + assert.equal(getEventById(undefined), null) + assert.equal(getEventById(null), null) + }) + }) + + describe('buildEventDeck', () => { + test('contient exactement 18 cartes (3 exemplaires de chacun des 6 événements)', () => { + const deck = buildEventDeck() + assert.equal(deck.length, 18) + }) + + test('contient exactement 3 exemplaires de chaque id', () => { + const deck = buildEventDeck() + const counts = {} + deck.forEach(id => { counts[id] = (counts[id] ?? 0) + 1 }) + EVENTS.forEach(e => assert.equal(counts[e.id], 3, `${e.id} devrait apparaître 3 fois`)) + }) + + test('mélange le deck (l\'ordre n\'est pas systématiquement identique entre deux appels)', () => { + const decks = Array.from({ length: 5 }, () => buildEventDeck().join(',')) + assert.ok(new Set(decks).size > 1, 'les decks mélangés devraient différer entre les appels') + }) + }) +})