- Nouveau server/src/game/buildings.js : 2 bâtiments, chacun couvrant 2 attributs, coût réparti sur les deux jauges, garantit +1 par tour sur chaque attribut, avec bonus de synergie +1 si l'objet correspondant est aussi possédé (Église+Trousse/Sceau, Château+Charrue/Trébuchet). - Room.js : buyBuilding() (validation coût multi-jauges), résolution des achats en attente avec la même priorité/stock unique que les objets, _applyBuildingEffects() (garantie de base + synergie, jamais de recul par rapport aux gains déjà boostés par un objet). getStateFor/getPlayersInfo exposent buildings/myBuildings/availableBuildings/pendingBuyBuildingId. - Nouvel event socket 'buy-building', boughtBuildings inclus dans round-result. - Client : buildings.js (miroir), BootScene génère les textures portrait des bâtiments (méthode _generatePortraitTexture généralisée, réutilisée avec les objets). ItemBoardToken généralisé (kind='item'|'building', hAnchor 'left'|'right') pour afficher indifféremment un objet ou un bâtiment, avec coût détaillé par jauge affiché sur la carte agrandie pour les bâtiments. - GameScene : boutique de bâtiments en colonne à DROITE du plateau (symétrique à la boutique d'objets à gauche), mes bâtiments possédés affichés à GAUCHE de ma main (symétrique à mes objets à droite), bâtiments des adversaires affichés sous leurs objets au niveau de leur slot. - Fix : la carte détaillée héritait par défaut d'un ancrage "s'étend vers la droite" adapté à la boutique d'objets (bord gauche du canvas) mais cassait la boutique de bâtiments (bord droit) — le bouton ACHETER se retrouvait hors du canvas et donc injoignable au clic. Ajout du paramètre hAnchor. - 26 nouveaux tests unitaires serveur (buildings.js + Room.js : validations d'achat multi-jauges, priorité/stock unique, effets de base et synergie). - 6 nouveaux tests E2E Playwright (boutique bâtiments : affichage, position, carte détaillée dans les limites du canvas, coût affiché, achat complet). - Fix flakiness des tests E2E d'achat (item-shop + building-shop) : le mode 'min' de playCard garantit désormais que l'adversaire joue sa carte la plus faible sur l'attribut ciblé, au lieu de simplement jouer un autre attribut (insuffisant, le gain dépend de la comparaison au voisin gauche).
830 lines
31 KiB
JavaScript
830 lines
31 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)
|
|
})
|
|
|
|
test('getStateFor expose aussi les champs liés aux bâtiments', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].gauges.health = 5
|
|
room.players[0].gauges.diplo = 5
|
|
room.buyBuilding('s0', 'church')
|
|
|
|
const state0 = room.getStateFor('s0')
|
|
assert.equal(state0.pendingBuyBuildingId, 'church')
|
|
assert.deepEqual(state0.myBuildings, [])
|
|
assert.ok(state0.availableBuildings.includes('church'))
|
|
|
|
const state1 = room.getStateFor('s1')
|
|
assert.equal(state1.pendingBuyBuildingId, null)
|
|
})
|
|
})
|
|
|
|
describe('Room - buyBuilding (validations)', () => {
|
|
test('accepte un achat si les DEUX jauges requises sont suffisantes', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].gauges.health = 5
|
|
room.players[0].gauges.diplo = 5
|
|
const result = room.buyBuilding('s0', 'church')
|
|
assert.equal(result.ok, true)
|
|
assert.equal(room.pendingBuildingBuys.get('s0'), 'church')
|
|
})
|
|
|
|
test('refuse si UNE SEULE des deux jauges requises est insuffisante', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].gauges.health = 5
|
|
room.players[0].gauges.diplo = 0 // insuffisant (Église coûte 2 diplo)
|
|
const result = room.buyBuilding('s0', 'church')
|
|
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.buyBuilding('s0', 'church')
|
|
assert.equal(result.ok, false)
|
|
})
|
|
|
|
test('refuse un bâtiment inconnu', () => {
|
|
const room = makeStartedRoom(2)
|
|
const result = room.buyBuilding('s0', 'batiment-inexistant')
|
|
assert.equal(result.ok, false)
|
|
assert.match(result.error, /inconnu/)
|
|
})
|
|
|
|
test('refuse un bâtiment déjà retiré du stock', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.availableBuildings = room.availableBuildings.filter(id => id !== 'church')
|
|
room.players[0].gauges.health = 10
|
|
room.players[0].gauges.diplo = 10
|
|
const result = room.buyBuilding('s0', 'church')
|
|
assert.equal(result.ok, false)
|
|
assert.match(result.error, /déjà été acheté/)
|
|
})
|
|
|
|
test('refuse un bâtiment déjà possédé par ce joueur', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].buildings.push('church')
|
|
room.players[0].gauges.health = 10
|
|
room.players[0].gauges.diplo = 10
|
|
const result = room.buyBuilding('s0', 'church')
|
|
assert.equal(result.ok, false)
|
|
assert.match(result.error, /possèdes déjà/)
|
|
})
|
|
|
|
test('refuse une seconde tentative d\'achat de bâtiment le même tour', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].gauges.health = 10
|
|
room.players[0].gauges.diplo = 10
|
|
room.players[0].gauges.food = 10
|
|
room.players[0].gauges.war = 10
|
|
room.buyBuilding('s0', 'church')
|
|
const result = room.buyBuilding('s0', 'castle')
|
|
assert.equal(result.ok, false)
|
|
assert.match(result.error, /déjà tenté/)
|
|
})
|
|
|
|
test('refuse un joueur inconnu', () => {
|
|
const room = makeStartedRoom(2)
|
|
const result = room.buyBuilding('ghost', 'church')
|
|
assert.equal(result.ok, false)
|
|
})
|
|
})
|
|
|
|
describe('Room - résolution des achats de bâtiments (coût multi-jauges, priorité, stock unique)', () => {
|
|
test('un achat de bâtiment déduit le coût sur les DEUX jauges concernées', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].gauges.health = 10
|
|
room.players[0].gauges.diplo = 10
|
|
room.buyBuilding('s0', 'church')
|
|
|
|
setHandCardValues(room, 's0', { health: 0, diplo: 0 })
|
|
setHandCardValues(room, 's1', { health: 0, diplo: 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(room.players[0].buildings.includes('church'), true)
|
|
assert.equal(room.availableBuildings.includes('church'), false)
|
|
assert.equal(result.boughtBuildings.length, 1)
|
|
assert.equal(result.boughtBuildings[0].buildingId, 'church')
|
|
// Église coûte 3 health + 2 diplo ; le +1 garanti de base s'applique
|
|
// DÉJÀ ce tour (même règle que les objets), donc jauge finale = 10 - coût + garanti
|
|
assert.equal(room.players[0].gauges.health, 10 - 3 + 1)
|
|
assert.equal(room.players[0].gauges.diplo, 10 - 2 + 1)
|
|
})
|
|
|
|
test('en cas de conflit sur le même bâtiment, le "premier joueur" du tour gagne', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].gauges.food = 10
|
|
room.players[0].gauges.war = 10
|
|
room.players[1].gauges.food = 10
|
|
room.players[1].gauges.war = 10
|
|
room.firstPlayerIndex = 0 // s0 prioritaire
|
|
|
|
room.buyBuilding('s0', 'castle')
|
|
room.buyBuilding('s1', 'castle')
|
|
|
|
setHandCardValues(room, 's0', { food: 0, war: 0 })
|
|
setHandCardValues(room, 's1', { food: 0, war: 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.boughtBuildings.length, 1)
|
|
assert.equal(result.boughtBuildings[0].playerName, 'P0')
|
|
assert.equal(room.players[0].buildings.includes('castle'), true)
|
|
assert.equal(room.players[1].buildings.includes('castle'), false)
|
|
// Le perdant ne paie rien (jauges inchangées, hors gain de carte neutralisé)
|
|
assert.equal(room.players[1].gauges.food, 10)
|
|
assert.equal(room.players[1].gauges.war, 10)
|
|
})
|
|
|
|
test('chaque bâtiment ne peut être acheté qu\'une seule fois pour toute la partie', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].gauges.food = 10
|
|
room.players[0].gauges.war = 10
|
|
room.buyBuilding('s0', 'castle')
|
|
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.food = 10
|
|
room.players[1].gauges.war = 10
|
|
const secondAttempt = room.buyBuilding('s1', 'castle')
|
|
assert.equal(secondAttempt.ok, false)
|
|
})
|
|
})
|
|
|
|
describe('Room - resolveRound : effets permanents des bâtiments (+ synergie objets)', () => {
|
|
test('un bâtiment garantit +1 sur CHACUN de ses deux attributs, même sans synergie', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].buildings.push('church') // possession directe, sans passer par un achat
|
|
setHandCardValues(room, 's0', { health: 0, diplo: 0 })
|
|
setHandCardValues(room, 's1', { health: 6, diplo: 6 }) // gain de base = 0 pour s0
|
|
|
|
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, 1)
|
|
assert.equal(result.gains[0].diplo, 1)
|
|
})
|
|
|
|
test('le bâtiment ne réduit jamais un gain de base déjà supérieur au minimum garanti', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].buildings.push('church')
|
|
setHandCardValues(room, 's0', { health: 6, diplo: 6 })
|
|
setHandCardValues(room, 's1', { health: 0, diplo: 0 }) // gain de base = 6 pour s0
|
|
|
|
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, 6)
|
|
assert.equal(result.gains[0].diplo, 6)
|
|
})
|
|
|
|
test('synergie : +1 supplémentaire sur Santé si Église ET Trousse du Médecin sont possédées', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].buildings.push('church')
|
|
room.players[0].items.push('medkit') // synergie sur l'attribut 'health'
|
|
setHandCardValues(room, 's0', { health: 0, diplo: 0 })
|
|
setHandCardValues(room, 's1', { health: 6, diplo: 0 })
|
|
|
|
room.playCard('s0', room.players[0].hand[0].id)
|
|
room.playCard('s1', room.players[1].hand[0].id)
|
|
const result = room.resolveRound()
|
|
|
|
// baseGuaranteed(1) + synergie(1) = 2, ET la Trousse elle-même garantit
|
|
// déjà 2 (effectValue) sur health via _applyItemEffects → le max des deux
|
|
// effets doit donner au moins 2.
|
|
assert.ok(result.gains[0].health >= 2, `attendu >= 2, obtenu ${result.gains[0].health}`)
|
|
// Sans synergie sur diplo (pas de royal_seal), seul le +1 de base s'applique
|
|
assert.equal(result.gains[0].diplo, 1)
|
|
})
|
|
|
|
test('synergie : +1 supplémentaire sur Guerre si Château ET Trébuchet sont possédés', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].buildings.push('castle')
|
|
room.players[0].items.push('trebuchet') // synergie sur l'attribut 'war'
|
|
setHandCardValues(room, 's0', { food: 0, war: 2 })
|
|
setHandCardValues(room, 's1', { food: 0, war: 0 })
|
|
|
|
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 war = 2-0 = 2, doublé par Trébuchet = 4, le Château garantit
|
|
// seulement max(4, 1+1=2) = 4 → le doublement de l'objet reste dominant ici
|
|
assert.equal(result.gains[0].war, 4)
|
|
// Sur food (pas de synergie, pas de Charrue), seul le +1 garanti du Château s'applique
|
|
assert.equal(result.gains[0].food, 1)
|
|
})
|
|
|
|
test('sans l\'objet de synergie, le bâtiment seul ne garantit que baseGuaranteed (pas de bonus)', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].buildings.push('castle') // pas de Trébuchet/Charrue possédés
|
|
setHandCardValues(room, 's0', { food: 0, war: 0 })
|
|
setHandCardValues(room, 's1', { food: 6, war: 6 })
|
|
|
|
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].food, 1)
|
|
assert.equal(result.gains[0].war, 1)
|
|
})
|
|
|
|
test('possession simultanée des 2 bâtiments : chacun garantit son propre plancher indépendamment', () => {
|
|
const room = makeStartedRoom(2)
|
|
room.players[0].buildings.push('church', 'castle')
|
|
setHandCardValues(room, 's0', { food: 0, war: 0, health: 0, diplo: 0 })
|
|
setHandCardValues(room, 's1', { food: 6, war: 6, health: 6, diplo: 6 })
|
|
|
|
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].food, 1)
|
|
assert.equal(result.gains[0].war, 1)
|
|
assert.equal(result.gains[0].health, 1)
|
|
assert.equal(result.gains[0].diplo, 1)
|
|
})
|
|
})
|