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:
Kevin L'Hereec committed 2026-08-21 14:53:58 +02:00
1 parent da06ecaf2d
commit 6a1c800253
37 files changed
+3558 -188

No files matched your search

+208 -14
View File
@@ -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
}
}
}
+53
View File
@@ -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
View File
@@ -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