feat: bâtiments achetables (Église, Château Royal) avec synergie objets

- Nouveau server/src/game/buildings.js : 2 bâtiments, chacun couvrant 2
  attributs, coût réparti sur les deux jauges, garantit +1 par tour sur
  chaque attribut, avec bonus de synergie +1 si l'objet correspondant est
  aussi possédé (Église+Trousse/Sceau, Château+Charrue/Trébuchet).
- Room.js : buyBuilding() (validation coût multi-jauges), résolution des
  achats en attente avec la même priorité/stock unique que les objets,
  _applyBuildingEffects() (garantie de base + synergie, jamais de recul par
  rapport aux gains déjà boostés par un objet). getStateFor/getPlayersInfo
  exposent buildings/myBuildings/availableBuildings/pendingBuyBuildingId.
- Nouvel event socket 'buy-building', boughtBuildings inclus dans round-result.
- Client : buildings.js (miroir), BootScene génère les textures portrait des
  bâtiments (méthode _generatePortraitTexture généralisée, réutilisée avec
  les objets). ItemBoardToken généralisé (kind='item'|'building', hAnchor
  'left'|'right') pour afficher indifféremment un objet ou un bâtiment, avec
  coût détaillé par jauge affiché sur la carte agrandie pour les bâtiments.
- GameScene : boutique de bâtiments en colonne à DROITE du plateau
  (symétrique à la boutique d'objets à gauche), mes bâtiments possédés
  affichés à GAUCHE de ma main (symétrique à mes objets à droite), bâtiments
  des adversaires affichés sous leurs objets au niveau de leur slot.
- Fix : la carte détaillée héritait par défaut d'un ancrage "s'étend vers la
  droite" adapté à la boutique d'objets (bord gauche du canvas) mais cassait
  la boutique de bâtiments (bord droit) — le bouton ACHETER se retrouvait
  hors du canvas et donc injoignable au clic. Ajout du paramètre hAnchor.
- 26 nouveaux tests unitaires serveur (buildings.js + Room.js : validations
  d'achat multi-jauges, priorité/stock unique, effets de base et synergie).
- 6 nouveaux tests E2E Playwright (boutique bâtiments : affichage, position,
  carte détaillée dans les limites du canvas, coût affiché, achat complet).
- Fix flakiness des tests E2E d'achat (item-shop + building-shop) : le mode
  'min' de playCard garantit désormais que l'adversaire joue sa carte la
  plus faible sur l'attribut ciblé, au lieu de simplement jouer un autre
  attribut (insuffisant, le gain dépend de la comparaison au voisin gauche).
This commit is contained in:
Kevin L'Hereec committed 2026-08-21 16:19:18 +02:00
1 parent 11cba0467b
commit 9139989fa7
14 files changed
+1222 -89

No files matched your search

+176 -23
View File
@@ -1,5 +1,6 @@
const { dealHands } = require('./cards')
const { ITEMS, getItemById } = require('./items')
const { BUILDINGS, getBuildingById } = require('./buildings')
const MAX_PLAYERS = 4
const MAX_TURNS = 12
@@ -22,6 +23,12 @@ const ATTRS = ['food', 'war', 'health', 'diplo']
* 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.
*
* Bâtiments (voir buildings.js) :
* Même principe de stock unique et de priorité d'achat que les objets, mais
* un bâtiment coûte des points sur DEUX jauges à la fois et garantit un
* minimum sur ses deux attributs à chaque tour, avec un bonus de synergie
* (+1 supplémentaire) si l'objet correspondant est aussi possédé.
*/
class Room {
@@ -33,7 +40,9 @@ class Room {
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.pendingBuildingBuys = new Map() // socketId -> buildingId (idem, pour les bâtiments)
this.availableItems = [] // ids des objets pas encore achetés
this.availableBuildings = [] // ids des bâtiments pas encore achetés
this.firstPlayerIndex = 0 // index (dans this.players) du joueur prioritaire ce tour
}
@@ -51,8 +60,9 @@ class Room {
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)
deck: [], // cartes restantes à piocher
items: [], // ids des objets possédés (effets permanents)
buildings: [] // ids des bâtiments possédés (effets permanents)
})
return true
}
@@ -63,6 +73,7 @@ class Room {
// 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.pendingBuildingBuys.delete(socketId)
this.pendingPlays.delete(socketId)
// Conserver l'identité du "premier joueur" (priorité d'achat) à travers
@@ -91,16 +102,19 @@ class Room {
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 = []
p.hand = dealt[i].hand
p.deck = dealt[i].deck
p.gauges = { food: 0, war: 0, health: 0, diplo: 0 }
p.items = []
p.buildings = []
})
this.currentTurn = 1
this.phase = 'selecting'
this.pendingPlays.clear()
this.pendingBuys.clear()
this.availableItems = ITEMS.map(i => i.id)
this.pendingBuildingBuys.clear()
this.availableItems = ITEMS.map(i => i.id)
this.availableBuildings = BUILDINGS.map(b => b.id)
this.firstPlayerIndex = 0
}
@@ -162,17 +176,54 @@ class Room {
return { ok: true }
}
/**
* Tente un achat de bâtiment pour un joueur. Même principe différé que
* buyItem (résolu à resolveRound), mais le coût est réparti sur DEUX jauges
* à la fois (building.cost = { attr1: n, attr2: m }) — il faut donc avoir
* suffisamment de points sur CHACUNE des deux jauges concernées.
*/
buyBuilding(socketId, buildingId) {
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 building = getBuildingById(buildingId)
if (!building) return { ok: false, error: 'Bâtiment inconnu' }
if (!this.availableBuildings.includes(buildingId))
return { ok: false, error: 'Ce bâtiment a déjà été acheté par un autre joueur' }
if (player.buildings.includes(buildingId))
return { ok: false, error: 'Tu possèdes déjà ce bâtiment' }
if (this.pendingBuildingBuys.has(socketId))
return { ok: false, error: 'Tu as déjà tenté un achat de bâtiment ce tour' }
const missing = Object.entries(building.cost)
.filter(([attr, cost]) => player.gauges[attr] < cost)
if (missing.length > 0) {
const details = Object.entries(building.cost).map(([attr, cost]) => `${cost} ${attr}`).join(' + ')
return { ok: false, error: `Pas assez de points (il faut ${details}) pour acheter ${building.name}` }
}
this.pendingBuildingBuys.set(socketId, buildingId)
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, à
// Choix de design assumé : un objet/bâtiment 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()
const boughtItems = this._resolvePendingBuys()
const boughtBuildings = this._resolvePendingBuildingBuys()
// ── 2. Construire un tableau ordonné des cartes jouées ─────────────────────
const plays = this.players.map(p => ({
@@ -206,8 +257,13 @@ class Room {
return g
})
// ── 4. Appliquer les effets permanents des objets possédés ─────────────────
const gains = this._applyItemEffects(baseGains)
// ── 4. Appliquer les effets permanents des objets ET des bâtiments ─────────
// (bâtiments après les objets : leur bonus de synergie lit directement
// baseGains, pas le résultat intermédiaire des objets, donc l'ordre entre
// les deux n'a pas d'incidence sur le résultat — seule la lecture finale
// via Math.max compte).
const gainsAfterItems = this._applyItemEffects(baseGains)
const gains = this._applyBuildingEffects(baseGains, gainsAfterItems)
// Appliquer les gains finaux aux jauges
plays.forEach(({ player }, i) => {
@@ -228,7 +284,21 @@ class Room {
this.pendingPlays.clear()
this.phase = 'resolve'
return { playedCards, gains, boughtItems, message: lines.join(' | ') }
return { playedCards, gains, boughtItems, boughtBuildings, message: lines.join(' | ') }
}
/**
* Ordre de priorité d'achat pour ce tour : indices (dans this.players) du
* "premier joueur" jusqu'au précédent, en boucle. Utilisé pour départager
* les conflits d'achat (objets ET bâtiments) sur un même stock unique.
*/
_buyPriorityOrder() {
const n = this.players.length
const order = []
for (let k = 0; k < n; k++) {
order.push((this.firstPlayerIndex + k) % n)
}
return order
}
/**
@@ -241,12 +311,7 @@ class Room {
_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)
}
const priorityOrder = this._buyPriorityOrder()
// Regrouper les tentatives par objet visé
const attemptsByItem = new Map() // itemId -> [socketId, ...]
@@ -325,6 +390,89 @@ class Room {
return gains
}
/**
* Résout les tentatives d'achat de bâtiment en attente, même logique de
* priorité/stock unique que _resolvePendingBuys, mais le coût est débité
* sur PLUSIEURS jauges à la fois (building.cost = { attr: montant, ... }).
*/
_resolvePendingBuildingBuys() {
if (this.pendingBuildingBuys.size === 0) return []
const priorityOrder = this._buyPriorityOrder()
const attemptsByBuilding = new Map() // buildingId -> [socketId, ...]
this.pendingBuildingBuys.forEach((buildingId, socketId) => {
if (!attemptsByBuilding.has(buildingId)) attemptsByBuilding.set(buildingId, [])
attemptsByBuilding.get(buildingId).push(socketId)
})
const bought = []
attemptsByBuilding.forEach((socketIds, buildingId) => {
if (!this.availableBuildings.includes(buildingId)) return
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 building = getBuildingById(buildingId)
if (!winner || !building) return
// Sécurité : re-vérifier que le gagnant a toujours assez de points sur
// CHACUNE des jauges requises (peuvent avoir changé depuis la tentative).
const stillAffordable = Object.entries(building.cost)
.every(([attr, cost]) => winner.gauges[attr] >= cost)
if (!stillAffordable) return
Object.entries(building.cost).forEach(([attr, cost]) => {
winner.gauges[attr] -= cost
})
winner.buildings.push(buildingId)
this.availableBuildings = this.availableBuildings.filter(id => id !== buildingId)
bought.push({ playerName: winner.name, teamIndex: winner.teamIndex, buildingId, buildingName: building.name })
})
this.pendingBuildingBuys.clear()
return bought
}
/**
* Applique les effets permanents des bâtiments possédés : garantit un
* minimum (`baseGuaranteed`) sur chacun de leurs deux attributs, avec un
* bonus de synergie supplémentaire (+1) si l'objet lié à cet attribut est
* AUSSI possédé par le même joueur.
*
* Reçoit à la fois `baseGains` (gains bruts avant tout effet) et
* `gainsAfterItems` (résultat de _applyItemEffects) : le bâtiment ne doit
* jamais RÉDUIRE un gain déjà boosté par un objet — il ne fait que relever
* le plancher via Math.max, jamais un plafond.
*/
_applyBuildingEffects(baseGains, gainsAfterItems) {
const gains = gainsAfterItems.map(g => ({ ...g }))
this.players.forEach((player, i) => {
player.buildings.forEach(buildingId => {
const building = getBuildingById(buildingId)
if (!building) return
building.attrs.forEach(attr => {
const synergyItemId = building.synergyItems?.[attr]
const hasSynergy = synergyItemId && player.items.includes(synergyItemId)
const guaranteed = building.baseGuaranteed + (hasSynergy ? 1 : 0)
gains[i][attr] = Math.max(gains[i][attr], guaranteed)
})
})
})
return gains
}
// ─── Tour suivant ────────────────────────────────────────────────────────────
nextTurn() {
@@ -347,6 +495,7 @@ class Room {
}
this.phase = 'selecting'
this.pendingBuys.clear()
this.pendingBuildingBuys.clear()
}
isGameOver() {
@@ -365,7 +514,8 @@ class Room {
score: ATTRS.reduce((sum, a) => sum + p.gauges[a], 0),
handSize: p.hand.length,
deckSize: p.deck.length,
items: [...p.items]
items: [...p.items],
buildings: [...p.buildings]
}))
}
@@ -383,8 +533,11 @@ class Room {
hand: player ? player.hand : [],
myTeamIndex: player ? player.teamIndex : 0,
myItems: player ? [...player.items] : [],
pendingBuyItemId: this.pendingBuys.get(socketId) ?? null,
availableItems: [...this.availableItems],
myBuildings: player ? [...player.buildings] : [],
pendingBuyItemId: this.pendingBuys.get(socketId) ?? null,
pendingBuyBuildingId: this.pendingBuildingBuys.get(socketId) ?? null,
availableItems: [...this.availableItems],
availableBuildings: [...this.availableBuildings],
firstPlayerName: firstPlayer ? firstPlayer.name : null
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Regnum - Définition des bâtiments achetables
*
* Contrairement aux objets (items.js, un seul attribut, coût simple), un
* bâtiment :
* - coûte des points sur DEUX jauges à la fois (payées simultanément à l'achat)
* - garantit un minimum de +1 sur CHACUN de ses deux attributs à chaque tour
* - procure un bonus de SYNERGIE : +1 supplémentaire sur un attribut donné
* si le joueur possède également l'objet (item) lié à ce même attribut
* (ex: Église + Trousse du Médecin → bonus Santé cumulé)
*
* Stock : comme pour les objets, chaque bâtiment n'existe qu'en UN SEUL
* exemplaire pour toute la partie.
*/
const BUILDINGS = [
{
id: 'church',
name: 'Église',
attrs: ['health', 'diplo'],
cost: { health: 3, diplo: 2 },
baseGuaranteed: 1,
// attr -> id de l'objet dont la possession déclenche le bonus de synergie sur cet attr
synergyItems: { health: 'medkit', diplo: 'royal_seal' },
description: 'Garantit +1 en Santé et +1 en Diplomatie à chaque tour. +1 supplémentaire sur chaque attribut si l\'objet correspondant (Trousse du Médecin / Sceau Royal) est aussi possédé.'
},
{
id: 'castle',
name: 'Château Royal',
attrs: ['food', 'war'],
cost: { food: 3, war: 2 },
baseGuaranteed: 1,
synergyItems: { food: 'plow', war: 'trebuchet' },
description: 'Garantit +1 en Nourriture et +1 en Guerre à chaque tour. +1 supplémentaire sur chaque attribut si l\'objet correspondant (Charrue / Trébuchet) est aussi possédé.'
}
]
function getBuildingById(id) {
return BUILDINGS.find(b => b.id === id) ?? null
}
module.exports = { BUILDINGS, getBuildingById }
+22 -5
View File
@@ -83,6 +83,22 @@ io.on('connection', (socket) => {
})
})
// ─── Acheter un bâtiment ─────────────────────────────────────────────────────
socket.on('buy-building', ({ roomId, buildingId }) => {
const room = rooms.get(roomId)
if (!room) return
const result = room.buyBuilding(socket.id, buildingId)
if (!result.ok) {
socket.emit('error', { message: result.error })
return
}
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)
@@ -112,12 +128,13 @@ io.on('connection', (socket) => {
const roundResult = room.resolveRound()
console.log(`[${roomId}] Tour ${room.currentTurn} résolu`)
// Révéler les cartes jouées, les gains par attribut, et les objets achetés
// Révéler les cartes jouées, les gains par attribut, et les objets/bâtiments achetés
io.to(roomId).emit('round-result', {
playedCards: roundResult.playedCards,
gains: roundResult.gains,
boughtItems: roundResult.boughtItems,
message: roundResult.message
playedCards: roundResult.playedCards,
gains: roundResult.gains,
boughtItems: roundResult.boughtItems,
boughtBuildings: roundResult.boughtBuildings,
message: roundResult.message
})
// Mise à jour scores (état personnalisé par joueur : items, achat en