Files
regnum/server/test/game/Room.test.js
T
Kevin L'Hereec 6a1c800253 feat: système d'objets achetables (boutique + effets permanents) et refonte visuelle
- Nouveaux personnages illustrés (12 PNG remplacés dans client/public/cards)
- 4 objets achetables (Trébuchet, Charrue, Trousse du Médecin, Sceau Royal)
  avec effets permanents (double_gain, guaranteed_gain, copy_best_in_game),
  stock unique par partie, priorité d'achat tournante entre joueurs
- Boutique en colonne à gauche du plateau : icônes compactes, carte détaillée
  agrandie au survol (taille découplée du displayScale du jeton), bouton
  ACHETER actif/grisé selon la jauge du joueur, retrait immédiat de la
  colonne une fois l'objet acheté (les autres remontent pour combler)
- Objets achetés affichés "posés sur la table" : à côté de la main pour soi,
  sous le slot de jeu pour les adversaires
- Icône du premier joueur (priorité d'achat) dans le HUD Vue
- Refonte des cartes personnage (CardObject) : portrait plein cadre épuré,
  valeurs d'attribut discrètes aux 4 coins
- Fix removePlayer : nettoyage de pendingBuys/pendingPlays et recalage de
  firstPlayerIndex à la déconnexion d'un joueur
- 77 tests unitaires serveur (node:test) : cards, items, Room, RoomManager
- README mis à jour avec les règles complètes du jeu
- .gitignore : exclusion de .idea/ et .serena/ (artefacts d'outils locaux)
2026-08-21 14:53:58 +02:00

580 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]
}
describe('Room - gestion des joueurs', () => {
test('addPlayer ajoute un joueur avec jauges à 0 et teamIndex incrémental', () => {
const room = new Room('R1')
assert.equal(room.addPlayer('s1', 'Alice'), true)
assert.equal(room.addPlayer('s2', 'Bob'), true)
assert.equal(room.players.length, 2)
assert.equal(room.players[0].teamIndex, 0)
assert.equal(room.players[1].teamIndex, 1)
assert.deepEqual(room.players[0].gauges, { food: 0, war: 0, health: 0, diplo: 0 })
assert.deepEqual(room.players[0].items, [])
})
test('addPlayer refuse un 5e joueur (MAX_PLAYERS = 4)', () => {
const room = new Room('R2')
for (let i = 0; i < 4; i++) assert.equal(room.addPlayer(`s${i}`, `P${i}`), true)
assert.equal(room.addPlayer('s4', 'Trop'), false)
assert.equal(room.players.length, 4)
})
test('addPlayer est idempotent pour un socketId déjà présent (reconnexion)', () => {
const room = new Room('R3')
room.addPlayer('s1', 'Alice')
const result = room.addPlayer('s1', 'Alice')
assert.equal(result, true)
assert.equal(room.players.length, 1)
})
test('removePlayer retire le joueur de la liste', () => {
const room = new Room('R4')
room.addPlayer('s1', 'Alice')
room.addPlayer('s2', 'Bob')
room.removePlayer('s1')
assert.equal(room.players.length, 1)
assert.equal(room.players[0].socketId, 's2')
})
test('removePlayer nettoie les achats/cartes en attente du joueur parti', () => {
const room = makeStartedRoom(3)
room.players[0].gauges.food = 10
room.buyItem('s0', 'plow')
room.playCard('s1', room.players[1].hand[0].id)
room.removePlayer('s0')
assert.equal(room.pendingBuys.has('s0'), false)
// s1 avait joué une carte, removePlayer ne doit pas y toucher (ce n'est pas lui qui part)
assert.equal(room.pendingPlays.has('s1'), true)
})
test('removePlayer conserve l\'identité du "premier joueur" après un décalage d\'index', () => {
const room = makeStartedRoom(4)
// firstPlayerIndex pointe sur s0 ; on retire s0's voisin s1 (index 1, avant s0 dans la liste)
room.firstPlayerIndex = 2 // pointe sur s2
room.removePlayer('s1') // s1 est retiré, s2 glisse à l'index 1
const firstPlayer = room.players[room.firstPlayerIndex]
assert.equal(firstPlayer.socketId, 's2', 'le premier joueur doit rester s2 malgré le décalage')
})
test('removePlayer garde firstPlayerIndex borné si c\'est le premier joueur lui-même qui part', () => {
const room = makeStartedRoom(3)
room.firstPlayerIndex = 2 // dernier joueur (s2)
room.removePlayer('s2')
assert.ok(room.firstPlayerIndex < room.players.length, 'index doit rester dans les bornes')
})
test('removePlayer ramène firstPlayerIndex à 0 si tous les joueurs partent', () => {
const room = makeStartedRoom(2)
room.removePlayer('s0')
room.removePlayer('s1')
assert.equal(room.players.length, 0)
assert.equal(room.firstPlayerIndex, 0)
})
})
describe('Room - startGame', () => {
test('distribue une main de 4 cartes et remet les jauges/items à zéro', () => {
const room = makeStartedRoom(3)
assert.equal(room.currentTurn, 1)
assert.equal(room.phase, 'selecting')
for (const p of room.players) {
assert.equal(p.hand.length, 4)
assert.equal(p.deck.length, 8)
assert.deepEqual(p.gauges, { food: 0, war: 0, health: 0, diplo: 0 })
assert.deepEqual(p.items, [])
}
})
test('réinitialise le stock d\'objets disponibles et la priorité d\'achat', () => {
const room = makeStartedRoom(2)
assert.equal(room.availableItems.length, 4)
assert.equal(room.firstPlayerIndex, 0)
})
})
describe('Room - playCard', () => {
test('retire la carte de la main et l\'enregistre en pendingPlays', () => {
const room = makeStartedRoom(2)
const card = room.players[0].hand[0]
const result = room.playCard('s0', card.id)
assert.equal(result.ok, true)
assert.equal(room.players[0].hand.length, 3)
assert.equal(room.pendingPlays.get('s0').card.id, card.id)
})
test('refuse de jouer hors phase selecting', () => {
const room = makeStartedRoom(2)
room.phase = 'resolve'
const result = room.playCard('s0', room.players[0].hand[0].id)
assert.equal(result.ok, false)
})
test('refuse un second coup dans le même tour', () => {
const room = makeStartedRoom(2)
const [c1, c2] = room.players[0].hand
room.playCard('s0', c1.id)
const result = room.playCard('s0', c2.id)
assert.equal(result.ok, false)
})
test('refuse une carte absente de la main', () => {
const room = makeStartedRoom(2)
const result = room.playCard('s0', 'carte-inexistante')
assert.equal(result.ok, false)
})
test('refuse un joueur inconnu', () => {
const room = makeStartedRoom(2)
const result = room.playCard('ghost', 'whatever')
assert.equal(result.ok, false)
})
test('allPlayersPlayed reflète le nombre de coups joués', () => {
const room = makeStartedRoom(2)
assert.equal(room.allPlayersPlayed(), false)
room.playCard('s0', room.players[0].hand[0].id)
assert.equal(room.allPlayersPlayed(), false)
room.playCard('s1', room.players[1].hand[0].id)
assert.equal(room.allPlayersPlayed(), true)
})
})
describe('Room - buyItem (validations)', () => {
test('accepte un achat si la jauge est suffisante', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 5
const result = room.buyItem('s0', 'trebuchet')
assert.equal(result.ok, true)
assert.equal(room.pendingBuys.get('s0'), 'trebuchet')
})
test('refuse si jauge insuffisante', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 1
const result = room.buyItem('s0', 'trebuchet')
assert.equal(result.ok, false)
assert.match(result.error, /Pas assez de points/)
})
test('refuse hors phase selecting', () => {
const room = makeStartedRoom(2)
room.phase = 'resolve'
const result = room.buyItem('s0', 'trebuchet')
assert.equal(result.ok, false)
})
test('refuse un objet inconnu', () => {
const room = makeStartedRoom(2)
const result = room.buyItem('s0', 'objet-inexistant')
assert.equal(result.ok, false)
assert.match(result.error, /inconnu/)
})
test('refuse un objet déjà retiré du stock (availableItems)', () => {
const room = makeStartedRoom(2)
room.availableItems = room.availableItems.filter(id => id !== 'trebuchet')
room.players[0].gauges.war = 10
const result = room.buyItem('s0', 'trebuchet')
assert.equal(result.ok, false)
assert.match(result.error, /déjà été acheté/)
})
test('refuse un objet déjà possédé par ce joueur', () => {
const room = makeStartedRoom(2)
room.players[0].items.push('trebuchet')
room.players[0].gauges.war = 10
const result = room.buyItem('s0', 'trebuchet')
assert.equal(result.ok, false)
assert.match(result.error, /possèdes déjà/)
})
test('refuse une seconde tentative d\'achat le même tour', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 10
room.players[0].gauges.food = 10
room.buyItem('s0', 'trebuchet')
const result = room.buyItem('s0', 'plow')
assert.equal(result.ok, false)
assert.match(result.error, /déjà tenté/)
})
test('refuse un joueur inconnu', () => {
const room = makeStartedRoom(2)
const result = room.buyItem('ghost', 'trebuchet')
assert.equal(result.ok, false)
})
})
describe('Room - resolveRound : calcul des gains de base', () => {
test('le gain = max(0, ma_valeur - valeur_voisin_gauche)', () => {
const room = makeStartedRoom(2)
// Index 0 : voisin de gauche = index 1 (n=2, (0-1+2)%2 = 1)
setHandCardValues(room, 's0', { war: 5 })
setHandCardValues(room, 's1', { war: 2 })
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
const result = room.resolveRound()
// s0 vs voisin gauche s1 : max(0, 5-2) = 3
assert.equal(result.gains[0].war, 3)
// s1 vs voisin gauche s0 : max(0, 2-5) = 0
assert.equal(result.gains[1].war, 0)
})
test('un gain négatif est clampé à 0, jamais retiré de la jauge', () => {
const room = makeStartedRoom(2)
setHandCardValues(room, 's0', { food: 1 })
setHandCardValues(room, 's1', { food: 6 })
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.resolveRound()
assert.equal(room.players[0].gauges.food, 0)
})
test('applique bien les gains cumulés aux jauges des joueurs', () => {
const room = makeStartedRoom(2)
setHandCardValues(room, 's0', { health: 4 })
setHandCardValues(room, 's1', { health: 1 })
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.resolveRound()
assert.equal(room.players[0].gauges.health, 3)
})
test('vide pendingPlays et passe en phase resolve', () => {
const room = makeStartedRoom(2)
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.resolveRound()
assert.equal(room.pendingPlays.size, 0)
assert.equal(room.phase, 'resolve')
})
test('gère un joueur n\'ayant pas joué de carte (card=null) sans planter', () => {
const room = makeStartedRoom(2)
room.playCard('s0', room.players[0].hand[0].id)
// s1 ne joue rien
const result = room.resolveRound()
assert.equal(result.playedCards[1].cardId, undefined)
assert.equal(result.gains[1].food, 0)
})
})
describe('Room - resolveRound : effets permanents des objets', () => {
test('double_gain (Trébuchet) double le gain de guerre', () => {
const room = makeStartedRoom(2)
room.players[0].items.push('trebuchet')
setHandCardValues(room, 's0', { war: 4 })
setHandCardValues(room, 's1', { war: 1 })
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
const result = room.resolveRound()
// gain de base = 4-1 = 3, doublé par Trébuchet = 6
assert.equal(result.gains[0].war, 6)
})
test('guaranteed_gain (Trousse) garantit un minimum même si le gain de base est plus faible', () => {
const room = makeStartedRoom(2)
room.players[0].items.push('medkit')
setHandCardValues(room, 's0', { health: 1 })
setHandCardValues(room, 's1', { health: 6 }) // gain de base = 0
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
const result = room.resolveRound()
const medkit = require('../../src/game/items').getItemById('medkit')
assert.equal(result.gains[0].health, medkit.effectValue)
})
test('guaranteed_gain n\'écrase pas un gain de base déjà supérieur au minimum', () => {
const room = makeStartedRoom(2)
room.players[0].items.push('medkit')
setHandCardValues(room, 's0', { health: 6 })
setHandCardValues(room, 's1', { health: 1 }) // gain de base = 5, > effectValue (2)
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
const result = room.resolveRound()
assert.equal(result.gains[0].health, 5)
})
test('copy_best_in_game (Sceau Royal) copie le meilleur gain diplo de tous les autres joueurs', () => {
const room = makeStartedRoom(3)
room.players[0].items.push('royal_seal')
setHandCardValues(room, 's0', { diplo: 0 })
setHandCardValues(room, 's1', { diplo: 0 })
setHandCardValues(room, 's2', { diplo: 0 })
// Forcer un gros gain diplo pour s1 en jouant une carte à forte diplo
// contre le voisin de gauche de s1 (s0, diplo=0 car sa carte a diplo=0)
room.players[1].hand[0].diplo = 6
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.playCard('s2', room.players[2].hand[0].id)
const result = room.resolveRound()
// s1 vs voisin gauche s0 (diplo 0) : gain de base = 6
assert.equal(result.gains[1].diplo, 6)
// s0 possède le Sceau : copie le meilleur gain diplo parmi s1/s2 = 6
assert.equal(result.gains[0].diplo, 6)
})
test('copy_best_in_game ne dégrade jamais le gain de base du porteur', () => {
const room = makeStartedRoom(3)
room.players[0].items.push('royal_seal')
setHandCardValues(room, 's0', { diplo: 6 })
setHandCardValues(room, 's1', { diplo: 0 })
setHandCardValues(room, 's2', { diplo: 0 })
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.playCard('s2', room.players[2].hand[0].id)
const result = room.resolveRound()
// s0 a déjà le meilleur gain de base possible, le Sceau ne doit pas le réduire
assert.ok(result.gains[0].diplo >= 6)
})
})
describe('Room - résolution des achats en attente (priorité, stock unique)', () => {
test('un achat en attente est débité de la jauge et ajouté aux items du joueur', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 5
room.buyItem('s0', 'trebuchet')
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
const result = room.resolveRound()
assert.equal(room.players[0].items.includes('trebuchet'), true)
assert.equal(room.availableItems.includes('trebuchet'), false)
assert.equal(result.boughtItems.length, 1)
assert.equal(result.boughtItems[0].itemId, 'trebuchet')
})
test('déduit uniquement le coût de l\'objet, pas plus', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 10
room.buyItem('s0', 'trebuchet')
setHandCardValues(room, 's0', { war: 0 })
setHandCardValues(room, 's1', { war: 0 })
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.resolveRound()
const trebuchet = require('../../src/game/items').getItemById('trebuchet')
assert.equal(room.players[0].gauges.war, 10 - trebuchet.cost)
})
test('en cas de conflit sur le même objet, le "premier joueur" du tour gagne', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.food = 10
room.players[1].gauges.food = 10
room.firstPlayerIndex = 0 // s0 est prioritaire
room.buyItem('s0', 'plow')
room.buyItem('s1', 'plow')
// Neutraliser l'attribut testé (food) sur les cartes jouées pour isoler
// le coût d'achat du gain de carte normal (sinon la comparaison de gain
// voisin-de-gauche ajouterait aussi des points à cette même jauge).
setHandCardValues(room, 's0', { food: 0 })
setHandCardValues(room, 's1', { food: 0 })
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
const result = room.resolveRound()
assert.equal(result.boughtItems.length, 1)
assert.equal(result.boughtItems[0].playerName, 'P0')
assert.equal(room.players[0].items.includes('plow'), true)
assert.equal(room.players[1].items.includes('plow'), false)
// Le perdant ne paie rien
assert.equal(room.players[1].gauges.food, 10)
})
test('en cas de conflit, la priorité suit firstPlayerIndex même si ce n\'est pas le joueur 0', () => {
const room = makeStartedRoom(3)
room.players[0].gauges.food = 10
room.players[1].gauges.food = 10
room.players[2].gauges.food = 10
room.firstPlayerIndex = 2 // s2 est prioritaire ce tour
room.buyItem('s0', 'plow')
room.buyItem('s1', 'plow')
room.buyItem('s2', 'plow')
room.players.forEach(p => room.playCard(p.socketId, p.hand[0].id))
const result = room.resolveRound()
assert.equal(result.boughtItems[0].playerName, 'P2')
})
test('chaque objet ne peut être acheté qu\'une seule fois pour toute la partie', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 10
room.buyItem('s0', 'trebuchet')
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.resolveRound()
room.nextTurn()
room.players[1].gauges.war = 10
const secondAttempt = room.buyItem('s1', 'trebuchet')
assert.equal(secondAttempt.ok, false)
})
test('pas d\'achat en attente -> boughtItems vide, aucune jauge modifiée', () => {
const room = makeStartedRoom(2)
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
const result = room.resolveRound()
assert.deepEqual(result.boughtItems, [])
})
})
describe('Room - nextTurn', () => {
test('chaque joueur pioche une carte de son deck', () => {
const room = makeStartedRoom(2)
room.playCard('s0', room.players[0].hand[0].id)
room.playCard('s1', room.players[1].hand[0].id)
room.resolveRound()
const deckSizeBefore = room.players[0].deck.length
const handSizeBefore = room.players[0].hand.length
room.nextTurn()
assert.equal(room.players[0].deck.length, deckSizeBefore - 1)
assert.equal(room.players[0].hand.length, handSizeBefore + 1)
})
test('fait tourner firstPlayerIndex de façon circulaire', () => {
const room = makeStartedRoom(3)
assert.equal(room.firstPlayerIndex, 0)
room.nextTurn()
assert.equal(room.firstPlayerIndex, 1)
room.nextTurn()
assert.equal(room.firstPlayerIndex, 2)
room.nextTurn()
assert.equal(room.firstPlayerIndex, 0)
})
test('incrémente currentTurn et repasse en phase selecting', () => {
const room = makeStartedRoom(2)
room.nextTurn()
assert.equal(room.currentTurn, 2)
assert.equal(room.phase, 'selecting')
})
test('vide pendingBuys au tour suivant', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 1 // insuffisant, mais peu importe pour ce test
room.pendingBuys.set('s0', 'trebuchet')
room.nextTurn()
assert.equal(room.pendingBuys.size, 0)
})
test('passe en phase end quand currentTurn dépasse maxTurns', () => {
const room = makeStartedRoom(2)
room.currentTurn = room.maxTurns
room.nextTurn()
assert.equal(room.phase, 'end')
})
test('isGameOver devient vrai quand mains ET decks sont vides', () => {
const room = makeStartedRoom(2)
room.players.forEach(p => { p.hand = []; p.deck = [] })
assert.equal(room.isGameOver(), true)
})
test('isGameOver est faux tant qu\'il reste des cartes en main ou en deck', () => {
const room = makeStartedRoom(2)
assert.equal(room.isGameOver(), false)
})
})
describe('Room - données publiques (getPlayersInfo / getStateFor)', () => {
test('getPlayersInfo calcule le score comme la somme des 4 jauges', () => {
const room = makeStartedRoom(1)
room.players[0].gauges = { food: 1, war: 2, health: 3, diplo: 4 }
const info = room.getPlayersInfo()
assert.equal(info[0].score, 10)
})
test('getPlayersInfo retourne des copies (pas de référence directe aux gauges/items internes)', () => {
const room = makeStartedRoom(1)
const info = room.getPlayersInfo()
info[0].gauges.food = 999
info[0].items.push('hack')
assert.equal(room.players[0].gauges.food, 0)
assert.deepEqual(room.players[0].items, [])
})
test('getStateFor renvoie phase="waiting" pour un joueur ayant déjà joué, "selecting" pour les autres', () => {
const room = makeStartedRoom(2)
room.playCard('s0', room.players[0].hand[0].id)
assert.equal(room.getStateFor('s0').phase, 'waiting')
assert.equal(room.getStateFor('s1').phase, 'selecting')
})
test('getStateFor expose pendingBuyItemId propre à chaque joueur', () => {
const room = makeStartedRoom(2)
room.players[0].gauges.war = 5
room.buyItem('s0', 'trebuchet')
assert.equal(room.getStateFor('s0').pendingBuyItemId, 'trebuchet')
assert.equal(room.getStateFor('s1').pendingBuyItemId, null)
})
test('getStateFor expose firstPlayerName correspondant à firstPlayerIndex', () => {
const room = makeStartedRoom(3)
room.firstPlayerIndex = 1
assert.equal(room.getStateFor('s0').firstPlayerName, 'P1')
})
test('getStateFor gère un socketId inconnu sans planter (spectateur/déconnecté)', () => {
const state = makeStartedRoom(2).getStateFor('ghost')
assert.deepEqual(state.hand, [])
assert.deepEqual(state.myItems, [])
assert.equal(state.pendingBuyItemId, null)
})
})