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 }