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:
Kevin L'Hereec committed 2026-08-21 17:05:50 +02:00
1 parent 9139989fa7
commit 1726f8f96b
17 files changed
+1952 -16

No files matched your search

+264 -4
View File
@@ -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
}
}
}
+96
View File
@@ -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
View File
@@ -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
})
+525
View File
@@ -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, [])
})
})
+61
View File
@@ -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')
})
})
})