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)
This commit is contained in:
1 parent
da06ecaf2d
commit
6a1c800253
37 files changed
+3558
-188
No files matched your search
+3
-1
@@ -4,7 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "node --test",
|
||||
"test:watch": "node --test --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.0",
|
||||
|
||||
+208
-14
@@ -1,4 +1,5 @@
|
||||
const { dealHands } = require('./cards')
|
||||
const { ITEMS, getItemById } = require('./items')
|
||||
|
||||
const MAX_PLAYERS = 4
|
||||
const MAX_TURNS = 12
|
||||
@@ -12,16 +13,28 @@ const ATTRS = ['food', 'war', 'health', 'diplo']
|
||||
* gain = max(0, ma_valeur_attr - valeur_attr_voisin_gauche)
|
||||
* Ce gain s'ajoute à la jauge correspondante du joueur.
|
||||
* Le score total est la somme des 4 jauges.
|
||||
*
|
||||
* Objets (voir items.js) :
|
||||
* Chaque objet est acheté avec des points de la jauge à laquelle il est lié
|
||||
* (payé immédiatement à l'achat) et procure un effet PERMANENT appliqué à
|
||||
* chaque résolution de tour tant que le joueur le possède. Chaque objet
|
||||
* n'existe qu'en un seul exemplaire pour toute la partie (stock unique).
|
||||
* Si plusieurs joueurs tentent d'acheter le MÊME objet le même tour, seul le
|
||||
* joueur le plus proche du "premier joueur" (ordre qui tourne chaque tour)
|
||||
* l'obtient ; les autres conservent leurs points.
|
||||
*/
|
||||
|
||||
class Room {
|
||||
constructor(id) {
|
||||
this.id = id
|
||||
this.players = [] // { socketId, name, teamIndex, gauges:{food,war,health,diplo}, hand }
|
||||
this.players = [] // { socketId, name, teamIndex, gauges:{food,war,health,diplo}, hand, items:[] }
|
||||
this.phase = 'lobby' // lobby | selecting | waiting | resolve | end
|
||||
this.currentTurn = 0
|
||||
this.maxTurns = MAX_TURNS
|
||||
this.pendingPlays = new Map() // socketId -> { player, card }
|
||||
this.pendingBuys = new Map() // socketId -> itemId (achats en attente de résolution ce tour)
|
||||
this.availableItems = [] // ids des objets pas encore achetés
|
||||
this.firstPlayerIndex = 0 // index (dans this.players) du joueur prioritaire ce tour
|
||||
}
|
||||
|
||||
get playerCount() { return this.players.length }
|
||||
@@ -38,13 +51,39 @@ class Room {
|
||||
teamIndex: this.players.length,
|
||||
gauges: { food: 0, war: 0, health: 0, diplo: 0 },
|
||||
hand: [],
|
||||
deck: [] // cartes restantes à piocher
|
||||
deck: [], // cartes restantes à piocher
|
||||
items: [] // ids des objets possédés (effets permanents)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
removePlayer(socketId) {
|
||||
// Nettoyer les tentatives d'achat/de carte en attente du joueur qui part :
|
||||
// sinon _resolvePendingBuys() traiterait un socketId fantôme (findIndex
|
||||
// renverrait -1, ce qui le classerait à tort en tête de priorité et
|
||||
// bloquerait l'achat d'un autre joueur réellement éligible).
|
||||
this.pendingBuys.delete(socketId)
|
||||
this.pendingPlays.delete(socketId)
|
||||
|
||||
// Conserver l'identité du "premier joueur" (priorité d'achat) à travers
|
||||
// la suppression : firstPlayerIndex est un index positionnel qui décale
|
||||
// silencieusement de cible si on ne le recale pas après un filter().
|
||||
const firstPlayerSocketId = this.players[this.firstPlayerIndex]?.socketId
|
||||
|
||||
this.players = this.players.filter(p => p.socketId !== socketId)
|
||||
|
||||
if (this.players.length === 0) {
|
||||
this.firstPlayerIndex = 0
|
||||
} else if (firstPlayerSocketId === socketId) {
|
||||
// Le "premier joueur" est justement celui qui part : reste sur le
|
||||
// même index (le joueur suivant a glissé à cette place), en bornant
|
||||
// à la nouvelle taille du tableau.
|
||||
this.firstPlayerIndex = this.firstPlayerIndex % this.players.length
|
||||
} else {
|
||||
// Retrouver la nouvelle position du même joueur après le filter().
|
||||
const newIndex = this.players.findIndex(p => p.socketId === firstPlayerSocketId)
|
||||
this.firstPlayerIndex = newIndex !== -1 ? newIndex : 0
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Démarrage ──────────────────────────────────────────────────────────────
|
||||
@@ -55,10 +94,14 @@ class Room {
|
||||
p.hand = dealt[i].hand
|
||||
p.deck = dealt[i].deck
|
||||
p.gauges = { food: 0, war: 0, health: 0, diplo: 0 }
|
||||
p.items = []
|
||||
})
|
||||
this.currentTurn = 1
|
||||
this.phase = 'selecting'
|
||||
this.pendingPlays.clear()
|
||||
this.pendingBuys.clear()
|
||||
this.availableItems = ITEMS.map(i => i.id)
|
||||
this.firstPlayerIndex = 0
|
||||
}
|
||||
|
||||
// ─── Phase de jeu ───────────────────────────────────────────────────────────
|
||||
@@ -85,11 +128,53 @@ class Room {
|
||||
return this.pendingPlays.size === this.players.length
|
||||
}
|
||||
|
||||
// ─── Achat d'objets ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tente un achat d'objet pour un joueur, pendant la phase 'selecting' (avant
|
||||
* résolution). L'achat est différé (pendingBuys) jusqu'à resolveRound() afin
|
||||
* de départager les conflits (plusieurs joueurs visant le même objet le même
|
||||
* tour) selon la priorité du "premier joueur" tournant.
|
||||
*/
|
||||
buyItem(socketId, itemId) {
|
||||
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' }
|
||||
|
||||
const item = getItemById(itemId)
|
||||
if (!item) return { ok: false, error: 'Objet inconnu' }
|
||||
|
||||
if (!this.availableItems.includes(itemId))
|
||||
return { ok: false, error: 'Cet objet a déjà été acheté par un autre joueur' }
|
||||
|
||||
if (player.items.includes(itemId))
|
||||
return { ok: false, error: 'Tu possèdes déjà cet objet' }
|
||||
|
||||
if (this.pendingBuys.has(socketId))
|
||||
return { ok: false, error: 'Tu as déjà tenté un achat ce tour' }
|
||||
|
||||
if (player.gauges[item.attr] < item.cost)
|
||||
return { ok: false, error: `Pas assez de points (${item.attr}) pour acheter ${item.name}` }
|
||||
|
||||
this.pendingBuys.set(socketId, itemId)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ─── Résolution du tour ─────────────────────────────────────────────────────
|
||||
|
||||
resolveRound() {
|
||||
const n = this.players.length
|
||||
// Construire un tableau ordonné des cartes jouées (même ordre que this.players)
|
||||
|
||||
// ── 1. Résoudre les achats en attente (priorité au "premier joueur") ──────
|
||||
// Choix de design assumé : un objet acheté CE tour bénéficie déjà au
|
||||
// résultat de CE même tour (son effet est appliqué juste après, à
|
||||
// l'étape 4, sur les gains calculés à l'étape 3). L'achat n'est donc
|
||||
// pas différé au tour suivant — c'est intentionnel, pas un oubli.
|
||||
const boughtItems = this._resolvePendingBuys()
|
||||
|
||||
// ── 2. Construire un tableau ordonné des cartes jouées ─────────────────────
|
||||
const plays = this.players.map(p => ({
|
||||
player: p,
|
||||
card: this.pendingPlays.get(p.socketId)?.card ?? null
|
||||
@@ -107,10 +192,10 @@ class Room {
|
||||
diplo: card?.diplo ?? 0
|
||||
}))
|
||||
|
||||
// Gains par joueur et par attribut
|
||||
// ── 3. Gains de base par joueur et par attribut ────────────────────────────
|
||||
// Règle : je compare mon attribut à celui du VOISIN DE GAUCHE (index - 1, modulo n)
|
||||
// gain = max(0, moi - voisin_gauche)
|
||||
const gains = plays.map((play, i) => {
|
||||
const baseGains = plays.map((play, i) => {
|
||||
const leftIndex = (i - 1 + n) % n
|
||||
const leftCard = plays[leftIndex].card
|
||||
|
||||
@@ -121,7 +206,10 @@ class Room {
|
||||
return g
|
||||
})
|
||||
|
||||
// Appliquer les gains aux jauges
|
||||
// ── 4. Appliquer les effets permanents des objets possédés ─────────────────
|
||||
const gains = this._applyItemEffects(baseGains)
|
||||
|
||||
// Appliquer les gains finaux aux jauges
|
||||
plays.forEach(({ player }, i) => {
|
||||
ATTRS.forEach(attr => {
|
||||
player.gauges[attr] += gains[i][attr]
|
||||
@@ -140,7 +228,101 @@ class Room {
|
||||
this.pendingPlays.clear()
|
||||
this.phase = 'resolve'
|
||||
|
||||
return { playedCards, gains, message: lines.join(' | ') }
|
||||
return { playedCards, gains, boughtItems, message: lines.join(' | ') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les tentatives d'achat en attente (this.pendingBuys) : en cas de
|
||||
* conflit sur un même objet (stock unique), seul le joueur le plus proche
|
||||
* du "premier joueur" (this.firstPlayerIndex, dans l'ordre this.players)
|
||||
* l'obtient. Déduit le coût de la jauge du gagnant et retire l'objet du
|
||||
* stock disponible. Retourne la liste des achats effectivement réalisés.
|
||||
*/
|
||||
_resolvePendingBuys() {
|
||||
if (this.pendingBuys.size === 0) return []
|
||||
|
||||
const n = this.players.length
|
||||
// Ordre de priorité : du premier joueur du tour jusqu'au précédent, en boucle
|
||||
const priorityOrder = []
|
||||
for (let k = 0; k < n; k++) {
|
||||
priorityOrder.push((this.firstPlayerIndex + k) % n)
|
||||
}
|
||||
|
||||
// Regrouper les tentatives par objet visé
|
||||
const attemptsByItem = new Map() // itemId -> [socketId, ...]
|
||||
this.pendingBuys.forEach((itemId, socketId) => {
|
||||
if (!attemptsByItem.has(itemId)) attemptsByItem.set(itemId, [])
|
||||
attemptsByItem.get(itemId).push(socketId)
|
||||
})
|
||||
|
||||
const bought = []
|
||||
|
||||
attemptsByItem.forEach((socketIds, itemId) => {
|
||||
if (!this.availableItems.includes(itemId)) return // déjà pris entre-temps (ne devrait pas arriver)
|
||||
|
||||
// Trier les prétendants selon l'ordre de priorité du tour
|
||||
const winnerSocketId = socketIds
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const idxA = priorityOrder.indexOf(this.players.findIndex(p => p.socketId === a))
|
||||
const idxB = priorityOrder.indexOf(this.players.findIndex(p => p.socketId === b))
|
||||
return idxA - idxB
|
||||
})[0]
|
||||
|
||||
const winner = this.players.find(p => p.socketId === winnerSocketId)
|
||||
const item = getItemById(itemId)
|
||||
if (!winner || !item) return
|
||||
|
||||
// Sécurité : re-vérifier que le gagnant a toujours assez de points
|
||||
if (winner.gauges[item.attr] < item.cost) return
|
||||
|
||||
winner.gauges[item.attr] -= item.cost
|
||||
winner.items.push(itemId)
|
||||
this.availableItems = this.availableItems.filter(id => id !== itemId)
|
||||
|
||||
bought.push({ playerName: winner.name, teamIndex: winner.teamIndex, itemId, itemName: item.name })
|
||||
})
|
||||
|
||||
this.pendingBuys.clear()
|
||||
return bought
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique les effets permanents des objets possédés sur les gains de base
|
||||
* d'un tour, et retourne un nouveau tableau de gains (même forme/ordre que
|
||||
* baseGains, un objet { food, war, health, diplo } par joueur).
|
||||
*/
|
||||
_applyItemEffects(baseGains) {
|
||||
const gains = baseGains.map(g => ({ ...g }))
|
||||
|
||||
this.players.forEach((player, i) => {
|
||||
player.items.forEach(itemId => {
|
||||
const item = getItemById(itemId)
|
||||
if (!item) return
|
||||
|
||||
if (item.effect === 'double_gain') {
|
||||
gains[i][item.attr] = baseGains[i][item.attr] * 2
|
||||
}
|
||||
|
||||
if (item.effect === 'guaranteed_gain') {
|
||||
gains[i][item.attr] = Math.max(baseGains[i][item.attr], item.effectValue)
|
||||
}
|
||||
|
||||
if (item.effect === 'copy_best_in_game') {
|
||||
// Design voulu (Sceau Royal) : "Le meilleur" = le gain le plus élevé
|
||||
// sur cet attribut parmi TOUS les autres joueurs de la partie ce
|
||||
// tour-ci — pas seulement les voisins immédiats. Effet volontairement
|
||||
// plus fort qu'une simple comparaison de voisinage.
|
||||
const bestOther = this.players.reduce((max, other, j) => {
|
||||
if (j === i) return max
|
||||
return Math.max(max, baseGains[j][item.attr])
|
||||
}, 0)
|
||||
gains[i][item.attr] = Math.max(baseGains[i][item.attr], bestOther)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return gains
|
||||
}
|
||||
|
||||
// ─── Tour suivant ────────────────────────────────────────────────────────────
|
||||
@@ -153,12 +335,18 @@ class Room {
|
||||
}
|
||||
})
|
||||
|
||||
// Le "premier joueur" (priorité d'achat) tourne à chaque tour
|
||||
if (this.players.length > 0) {
|
||||
this.firstPlayerIndex = (this.firstPlayerIndex + 1) % this.players.length
|
||||
}
|
||||
|
||||
this.currentTurn++
|
||||
if (this.isGameOver()) {
|
||||
this.phase = 'end'
|
||||
return
|
||||
}
|
||||
this.phase = 'selecting'
|
||||
this.pendingBuys.clear()
|
||||
}
|
||||
|
||||
isGameOver() {
|
||||
@@ -176,7 +364,8 @@ class Room {
|
||||
gauges: { ...p.gauges },
|
||||
score: ATTRS.reduce((sum, a) => sum + p.gauges[a], 0),
|
||||
handSize: p.hand.length,
|
||||
deckSize: p.deck.length
|
||||
deckSize: p.deck.length,
|
||||
items: [...p.items]
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -184,14 +373,19 @@ class Room {
|
||||
const player = this.players.find(p => p.socketId === socketId)
|
||||
const hasPlayed = this.pendingPlays.has(socketId)
|
||||
const personalPhase = this.phase === 'selecting' && hasPlayed ? 'waiting' : this.phase
|
||||
const firstPlayer = this.players[this.firstPlayerIndex]
|
||||
|
||||
return {
|
||||
players: this.getPlayersInfo(),
|
||||
currentTurn: this.currentTurn,
|
||||
maxTurns: this.maxTurns,
|
||||
phase: personalPhase,
|
||||
hand: player ? player.hand : [],
|
||||
myTeamIndex: player ? player.teamIndex : 0
|
||||
players: this.getPlayersInfo(),
|
||||
currentTurn: this.currentTurn,
|
||||
maxTurns: this.maxTurns,
|
||||
phase: personalPhase,
|
||||
hand: player ? player.hand : [],
|
||||
myTeamIndex: player ? player.teamIndex : 0,
|
||||
myItems: player ? [...player.items] : [],
|
||||
pendingBuyItemId: this.pendingBuys.get(socketId) ?? null,
|
||||
availableItems: [...this.availableItems],
|
||||
firstPlayerName: firstPlayer ? firstPlayer.name : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Regnum - Définition des objets achetables
|
||||
*
|
||||
* Chaque objet est lié à un attribut (food, war, health, diplo), coûte des
|
||||
* points de la jauge correspondante à l'achat, et procure un effet PERMANENT
|
||||
* une fois acheté (appliqué à chaque résolution de tour tant que le joueur
|
||||
* le possède).
|
||||
*
|
||||
* Stock : chaque objet n'existe qu'en UN SEUL exemplaire pour toute la partie.
|
||||
* Une fois acheté par un joueur, plus personne d'autre ne peut l'acheter.
|
||||
*/
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
id: 'trebuchet',
|
||||
name: 'Trébuchet',
|
||||
attr: 'war',
|
||||
cost: 3,
|
||||
effect: 'double_gain',
|
||||
description: 'Double le gain de Guerre obtenu à chaque tour.'
|
||||
},
|
||||
{
|
||||
id: 'plow',
|
||||
name: 'Charrue',
|
||||
attr: 'food',
|
||||
cost: 3,
|
||||
effect: 'double_gain',
|
||||
description: 'Double le gain de Nourriture obtenu à chaque tour.'
|
||||
},
|
||||
{
|
||||
id: 'medkit',
|
||||
name: 'Trousse du Médecin',
|
||||
attr: 'health',
|
||||
cost: 3,
|
||||
effect: 'guaranteed_gain',
|
||||
effectValue: 2,
|
||||
description: 'Garantit +2 en Santé à chaque tour, quel que soit le résultat de la comparaison.'
|
||||
},
|
||||
{
|
||||
id: 'royal_seal',
|
||||
name: 'Sceau Royal',
|
||||
attr: 'diplo',
|
||||
cost: 3,
|
||||
effect: 'copy_best_in_game',
|
||||
description: 'Copie à chaque tour le meilleur gain de Diplomatie parmi tous les autres joueurs.'
|
||||
}
|
||||
]
|
||||
|
||||
function getItemById(id) {
|
||||
return ITEMS.find(i => i.id === id) ?? null
|
||||
}
|
||||
|
||||
module.exports = { ITEMS, getItemById }
|
||||
+24
-7
@@ -58,6 +58,24 @@ io.on('connection', (socket) => {
|
||||
socket.emit('game-state', room.getStateFor(socket.id))
|
||||
})
|
||||
|
||||
// ─── Acheter un objet ────────────────────────────────────────────────────────
|
||||
socket.on('buy-item', ({ roomId, itemId }) => {
|
||||
const room = rooms.get(roomId)
|
||||
if (!room) return
|
||||
|
||||
const result = room.buyItem(socket.id, itemId)
|
||||
if (!result.ok) {
|
||||
socket.emit('error', { message: result.error })
|
||||
return
|
||||
}
|
||||
|
||||
// L'achat est différé (résolu avec le tour) : on renvoie juste l'état à
|
||||
// jour à chaque joueur pour refléter la tentative en attente.
|
||||
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)
|
||||
@@ -87,19 +105,18 @@ io.on('connection', (socket) => {
|
||||
const roundResult = room.resolveRound()
|
||||
console.log(`[${roomId}] Tour ${room.currentTurn} résolu`)
|
||||
|
||||
// Révéler les cartes jouées et les gains par attribut
|
||||
// Révéler les cartes jouées, les gains par attribut, et les objets achetés
|
||||
io.to(roomId).emit('round-result', {
|
||||
playedCards: roundResult.playedCards,
|
||||
gains: roundResult.gains,
|
||||
boughtItems: roundResult.boughtItems,
|
||||
message: roundResult.message
|
||||
})
|
||||
|
||||
// Mise à jour scores
|
||||
io.to(roomId).emit('game-state', {
|
||||
players: room.getPlayersInfo(),
|
||||
currentTurn: room.currentTurn,
|
||||
maxTurns: room.maxTurns,
|
||||
phase: 'resolve'
|
||||
// Mise à jour scores (état personnalisé par joueur : items, achat en
|
||||
// attente, objets encore disponibles, etc.)
|
||||
room.players.forEach(player => {
|
||||
io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId))
|
||||
})
|
||||
|
||||
// 3 secondes d'affichage puis tour suivant / fin
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
const { test, describe } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { RoomManager } = require('../../src/game/RoomManager')
|
||||
const { Room } = require('../../src/game/Room')
|
||||
|
||||
describe('RoomManager', () => {
|
||||
test('getOrCreate crée une nouvelle Room si elle n\'existe pas', () => {
|
||||
const manager = new RoomManager()
|
||||
const room = manager.getOrCreate('abc')
|
||||
assert.ok(room instanceof Room)
|
||||
assert.equal(room.id, 'abc')
|
||||
})
|
||||
|
||||
test('getOrCreate retourne la même instance pour un id déjà créé', () => {
|
||||
const manager = new RoomManager()
|
||||
const room1 = manager.getOrCreate('abc')
|
||||
const room2 = manager.getOrCreate('abc')
|
||||
assert.equal(room1, room2)
|
||||
})
|
||||
|
||||
test('get retourne null pour un id inconnu', () => {
|
||||
const manager = new RoomManager()
|
||||
assert.equal(manager.get('unknown'), null)
|
||||
})
|
||||
|
||||
test('get retourne la room existante après getOrCreate', () => {
|
||||
const manager = new RoomManager()
|
||||
manager.getOrCreate('xyz')
|
||||
assert.ok(manager.get('xyz') instanceof Room)
|
||||
})
|
||||
|
||||
test('delete retire la room de la map', () => {
|
||||
const manager = new RoomManager()
|
||||
manager.getOrCreate('to-delete')
|
||||
manager.delete('to-delete')
|
||||
assert.equal(manager.get('to-delete'), null)
|
||||
})
|
||||
|
||||
test('delete sur un id inconnu ne lève pas d\'erreur', () => {
|
||||
const manager = new RoomManager()
|
||||
assert.doesNotThrow(() => manager.delete('never-existed'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
const { test, describe } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { CHARACTERS, TEAM_COLORS, getDeckForTeam, dealHands } = require('../../src/game/cards')
|
||||
|
||||
describe('cards.js', () => {
|
||||
test('expose exactement 12 personnages', () => {
|
||||
assert.equal(CHARACTERS.length, 12)
|
||||
})
|
||||
|
||||
test('chaque personnage a un id unique', () => {
|
||||
const ids = CHARACTERS.map(c => c.id)
|
||||
assert.equal(new Set(ids).size, ids.length)
|
||||
})
|
||||
|
||||
test('chaque personnage a les 4 attributs définis entre 1 et 6', () => {
|
||||
for (const c of CHARACTERS) {
|
||||
for (const attr of ['food', 'war', 'health', 'diplo']) {
|
||||
assert.ok(Number.isInteger(c[attr]), `${c.id}.${attr} doit être un entier`)
|
||||
assert.ok(c[attr] >= 1 && c[attr] <= 6, `${c.id}.${attr} doit être entre 1 et 6`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('getDeckForTeam retourne les 12 personnages avec la couleur d\'équipe', () => {
|
||||
const deck = getDeckForTeam(2)
|
||||
assert.equal(deck.length, 12)
|
||||
for (const card of deck) {
|
||||
assert.equal(card.teamIndex, 2)
|
||||
assert.deepEqual(card.teamColor, TEAM_COLORS[2])
|
||||
}
|
||||
})
|
||||
|
||||
test('getDeckForTeam ne mute pas les CHARACTERS d\'origine', () => {
|
||||
const before = JSON.parse(JSON.stringify(CHARACTERS))
|
||||
getDeckForTeam(0)
|
||||
assert.deepEqual(CHARACTERS, before)
|
||||
})
|
||||
|
||||
describe('dealHands', () => {
|
||||
test('distribue une main de taille handSize et le reste au deck, pour chaque joueur', () => {
|
||||
const result = dealHands(3, 4)
|
||||
assert.equal(result.length, 3)
|
||||
for (const { hand, deck } of result) {
|
||||
assert.equal(hand.length, 4)
|
||||
assert.equal(deck.length, 12 - 4)
|
||||
// Pas de doublons entre hand et deck (même deck de 12 cartes)
|
||||
const allIds = [...hand, ...deck].map(c => c.id)
|
||||
assert.equal(new Set(allIds).size, 12)
|
||||
}
|
||||
})
|
||||
|
||||
test('chaque joueur reçoit un deck mélangé indépendamment (decks non systématiquement identiques)', () => {
|
||||
// Avec un shuffle aléatoire, la probabilité que 4 joueurs aient exactement
|
||||
// la même main de 4 cartes dans le même ordre est infinitésimale.
|
||||
const result = dealHands(4, 4)
|
||||
const handsAsStrings = result.map(r => r.hand.map(c => c.id).join(','))
|
||||
assert.ok(new Set(handsAsStrings).size > 1, 'les mains devraient différer entre joueurs')
|
||||
})
|
||||
|
||||
test('gère handSize par défaut (4) si non fourni', () => {
|
||||
const result = dealHands(2)
|
||||
assert.equal(result[0].hand.length, 4)
|
||||
})
|
||||
|
||||
test('gère playerCount = 0 sans erreur', () => {
|
||||
const result = dealHands(0, 4)
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test, describe } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { ITEMS, getItemById } = require('../../src/game/items')
|
||||
|
||||
describe('items.js', () => {
|
||||
test('expose exactement 4 objets', () => {
|
||||
assert.equal(ITEMS.length, 4)
|
||||
})
|
||||
|
||||
test('chaque objet a un id unique', () => {
|
||||
const ids = ITEMS.map(i => i.id)
|
||||
assert.equal(new Set(ids).size, ids.length)
|
||||
})
|
||||
|
||||
test('chaque objet est lié à un attribut valide et a un coût positif', () => {
|
||||
const validAttrs = ['food', 'war', 'health', 'diplo']
|
||||
for (const item of ITEMS) {
|
||||
assert.ok(validAttrs.includes(item.attr), `${item.id}.attr doit être un attribut valide`)
|
||||
assert.ok(item.cost > 0, `${item.id}.cost doit être positif`)
|
||||
}
|
||||
})
|
||||
|
||||
test('chaque objet a un effect reconnu par Room._applyItemEffects', () => {
|
||||
const knownEffects = ['double_gain', 'guaranteed_gain', 'copy_best_in_game']
|
||||
for (const item of ITEMS) {
|
||||
assert.ok(knownEffects.includes(item.effect), `${item.id}.effect '${item.effect}' inconnu`)
|
||||
}
|
||||
})
|
||||
|
||||
test('guaranteed_gain (Trousse du Médecin) définit bien effectValue', () => {
|
||||
const medkit = getItemById('medkit')
|
||||
assert.ok(medkit)
|
||||
assert.equal(medkit.effect, 'guaranteed_gain')
|
||||
assert.equal(typeof medkit.effectValue, 'number')
|
||||
})
|
||||
|
||||
test('aucun attribut n\'est couvert par 2 objets différents (pas de conflit de cumul)', () => {
|
||||
const attrs = ITEMS.map(i => i.attr)
|
||||
assert.equal(new Set(attrs).size, attrs.length)
|
||||
})
|
||||
|
||||
describe('getItemById', () => {
|
||||
test('retourne l\'objet correspondant à un id existant', () => {
|
||||
const trebuchet = getItemById('trebuchet')
|
||||
assert.ok(trebuchet)
|
||||
assert.equal(trebuchet.name, 'Trébuchet')
|
||||
assert.equal(trebuchet.attr, 'war')
|
||||
})
|
||||
|
||||
test('retourne null pour un id inconnu', () => {
|
||||
assert.equal(getItemById('does-not-exist'), null)
|
||||
})
|
||||
|
||||
test('retourne null pour undefined/null', () => {
|
||||
assert.equal(getItemById(undefined), null)
|
||||
assert.equal(getItemById(null), null)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user