- 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)
394 lines
15 KiB
JavaScript
394 lines
15 KiB
JavaScript
const { dealHands } = require('./cards')
|
|
const { ITEMS, getItemById } = require('./items')
|
|
|
|
const MAX_PLAYERS = 4
|
|
const MAX_TURNS = 12
|
|
const HAND_SIZE = 4
|
|
const ATTRS = ['food', 'war', 'health', 'diplo']
|
|
|
|
/**
|
|
* Système de score :
|
|
* Chaque joueur possède 4 jauges (food, war, health, diplo), toutes à 0 au départ.
|
|
* À chaque tour, on compare chaque joueur à son VOISIN DE GAUCHE (sens horaire) :
|
|
* 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, 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 }
|
|
|
|
// ─── Gestion des joueurs ────────────────────────────────────────────────────
|
|
|
|
addPlayer(socketId, name) {
|
|
if (this.players.length >= MAX_PLAYERS) return false
|
|
if (this.players.find(p => p.socketId === socketId)) return true
|
|
|
|
this.players.push({
|
|
socketId,
|
|
name,
|
|
teamIndex: this.players.length,
|
|
gauges: { food: 0, war: 0, health: 0, diplo: 0 },
|
|
hand: [],
|
|
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 ──────────────────────────────────────────────────────────────
|
|
|
|
startGame() {
|
|
const dealt = dealHands(this.players.length, HAND_SIZE)
|
|
this.players.forEach((p, i) => {
|
|
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 ───────────────────────────────────────────────────────────
|
|
|
|
playCard(socketId, cardId) {
|
|
if (this.phase !== 'selecting')
|
|
return { ok: false, error: 'Ce n\'est pas la phase de sélection' }
|
|
if (this.pendingPlays.has(socketId))
|
|
return { ok: false, error: 'Tu as déjà joué une carte ce tour' }
|
|
|
|
const player = this.players.find(p => p.socketId === socketId)
|
|
if (!player) return { ok: false, error: 'Joueur introuvable' }
|
|
|
|
const cardIndex = player.hand.findIndex(c => c.id === cardId)
|
|
if (cardIndex === -1) return { ok: false, error: 'Carte absente de ta main' }
|
|
|
|
const [card] = player.hand.splice(cardIndex, 1)
|
|
this.pendingPlays.set(socketId, { player, card })
|
|
|
|
return { ok: true }
|
|
}
|
|
|
|
allPlayersPlayed() {
|
|
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
|
|
|
|
// ── 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
|
|
}))
|
|
|
|
// Cartes jouées pour l'affichage
|
|
const playedCards = plays.map(({ player, card }) => ({
|
|
playerName: player.name,
|
|
teamIndex: player.teamIndex,
|
|
cardId: card?.id,
|
|
cardName: card?.name,
|
|
food: card?.food ?? 0,
|
|
war: card?.war ?? 0,
|
|
health: card?.health ?? 0,
|
|
diplo: card?.diplo ?? 0
|
|
}))
|
|
|
|
// ── 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 baseGains = plays.map((play, i) => {
|
|
const leftIndex = (i - 1 + n) % n
|
|
const leftCard = plays[leftIndex].card
|
|
|
|
const g = {}
|
|
ATTRS.forEach(attr => {
|
|
g[attr] = Math.max(0, (play.card?.[attr] ?? 0) - (leftCard?.[attr] ?? 0))
|
|
})
|
|
return g
|
|
})
|
|
|
|
// ── 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]
|
|
})
|
|
})
|
|
|
|
// Résumé textuel
|
|
const lines = plays.map(({ player }, i) => {
|
|
const g = gains[i]
|
|
const parts = ATTRS
|
|
.filter(a => g[a] > 0)
|
|
.map(a => `+${g[a]} ${a}`)
|
|
return `${player.name} : ${parts.length ? parts.join(', ') : 'rien'}`
|
|
})
|
|
|
|
this.pendingPlays.clear()
|
|
this.phase = 'resolve'
|
|
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
nextTurn() {
|
|
// Chaque joueur pioche une carte depuis son deck personnel
|
|
this.players.forEach(p => {
|
|
if (p.deck.length > 0) {
|
|
p.hand.push(p.deck.shift())
|
|
}
|
|
})
|
|
|
|
// 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() {
|
|
// Partie terminée quand plus personne n'a de carte en main ET plus de deck
|
|
const allEmpty = this.players.every(p => p.hand.length === 0 && p.deck.length === 0)
|
|
return allEmpty || this.currentTurn > this.maxTurns
|
|
}
|
|
|
|
// ─── Données publiques ───────────────────────────────────────────────────────
|
|
|
|
getPlayersInfo() {
|
|
return this.players.map(p => ({
|
|
name: p.name,
|
|
teamIndex: p.teamIndex,
|
|
gauges: { ...p.gauges },
|
|
score: ATTRS.reduce((sum, a) => sum + p.gauges[a], 0),
|
|
handSize: p.hand.length,
|
|
deckSize: p.deck.length,
|
|
items: [...p.items]
|
|
}))
|
|
}
|
|
|
|
getStateFor(socketId) {
|
|
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,
|
|
myItems: player ? [...player.items] : [],
|
|
pendingBuyItemId: this.pendingBuys.get(socketId) ?? null,
|
|
availableItems: [...this.availableItems],
|
|
firstPlayerName: firstPlayer ? firstPlayer.name : null
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = { Room }
|