Files
regnum/server/test/game/Room.events.test.js
T
Kevin L'Hereec 1726f8f96b 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.
2026-08-21 17:05:50 +02:00

526 lines
21 KiB
JavaScript

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, [])
})
})