feat: cartes Événement (pioche partagée de 18 cartes, 6 effets, synergies, ciblage)
- Nouveau server/src/game/events.js : 6 événements (Récolte exceptionnelle, Épidémie, Levée des troupes, Accord royal, Complot découvert, Grande fête au château), deck PARTAGÉ de 18 cartes (3 exemplaires de chacun) mélangé au démarrage de la partie. - Room.js : buyEventDraw() (coût fixe 1 point dans chacune des 4 jauges, effet immédiat contrairement aux objets/bâtiments — pas de conflit de stock possible), playEventCard() (choix de jouer une carte de sa main, max 1 par tour, avec ciblage optionnel), _resolvePendingEventPlays() (résolution de TOUS les événements du tour AVANT le calcul des gains de cartes personnage, défausse systématique, protection "Complot découvert" pour soi-même uniquement). Les 6 effets incluent leurs synergies avec les objets/bâtiments existants (Charrue, Trousse+Église pour Épidémie, Trébuchet, Sceau Royal, Château Royal → pioche bonus). - IMPORTANT : le ciblage de joueur (Accord royal, variante Trousse d'Épidémie) transite par teamIndex (identifiant public) et non socketId (jamais exposé au client via getPlayersInfo/getStateFor) — le serveur résout teamIndex -> joueur réel au moment de la résolution. - Nouvel event socket 'buy-event-draw' (effet immédiat) et 'play-event' (résolution différée), eventResults inclus dans round-result. - Client : events.js (miroir), EventCardObject.js (carte épurée façon CardObject, badge rouge "!" pour les événements à effet négatif). BootScene génère les textures plein cadre (méthode _generateClippedTexture généralisée, réutilisée avec les personnages). - GameScene : bouton de pioche à droite du bouton de confirmation (état grisé selon jauges/main pleine/pioche déjà tentée), seconde main de cartes événement (max 2) au-dessus de ce bouton, sélection d'une carte pour la jouer avec séquence de ciblage interactive (surbrillance des slots adversaires cliquables) pour Accord royal et la variante Trousse d'Épidémie. - 61 nouveaux tests unitaires serveur (events.js + Room.js : pioche, choix de jouer, résolution des 6 effets avec/sans synergie, Complot découvert, ordre événements-avant-personnages, deck partagé). - Nouveaux helpers et tests d'intégration E2E Playwright (e2e/tests/events.spec.js) pour la pioche, la limite de main à 2, et les 6 effets — certains scénarios restent probabilistes (pioche aléatoire dans le deck partagé) et peuvent nécessiter une stabilisation supplémentaire du timing.
This commit is contained in:
1 parent
9139989fa7
commit
1726f8f96b
17 files changed
+1952
-16
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
@@ -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 }
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(' | ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
+264
-4
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
+39
-1
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user