diff --git a/client/public/buildings/chateau.png b/client/public/buildings/chateau.png new file mode 100644 index 0000000..8027cf5 Binary files /dev/null and b/client/public/buildings/chateau.png differ diff --git a/client/public/buildings/eglise.png b/client/public/buildings/eglise.png new file mode 100644 index 0000000..1e48e33 Binary files /dev/null and b/client/public/buildings/eglise.png differ diff --git a/client/src/game/buildings.js b/client/src/game/buildings.js new file mode 100644 index 0000000..5ddf843 --- /dev/null +++ b/client/src/game/buildings.js @@ -0,0 +1,31 @@ +/** + * Regnum - Définition des bâtiments achetables (miroir du serveur, pour + * l'affichage) — voir server/src/game/buildings.js pour la logique. + * + * Contrairement aux objets, un bâtiment coûte des points sur DEUX jauges à + * la fois (cost = { attr1, attr2 }) et garantit un minimum sur chacune, + * avec un bonus de synergie si l'objet correspondant est aussi possédé. + */ + +export const BUILDINGS = [ + { + id: 'church', + name: 'Église', + attrs: ['health', 'diplo'], + cost: { health: 3, diplo: 2 }, + image: 'eglise.png', + 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 }, + image: 'chateau.png', + 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é.' + } +] + +export function getBuildingById(id) { + return BUILDINGS.find(b => b.id === id) ?? null +} diff --git a/client/src/game/objects/ItemBoardToken.js b/client/src/game/objects/ItemBoardToken.js index c17c426..b82c1c5 100644 --- a/client/src/game/objects/ItemBoardToken.js +++ b/client/src/game/objects/ItemBoardToken.js @@ -7,6 +7,13 @@ const ATTR_COLORS = { diplo: 0x3399ee } +const ATTR_LABELS = { + food: 'Nourriture', + war: 'Guerre', + health: 'Santé', + diplo: 'Diplomatie' +} + // Taille du jeton compact affiché en permanence sur le plateau (avant mise // à l'échelle éventuelle via displayScale — voir constructeur). export const ICON_SIZE = 46 @@ -52,13 +59,28 @@ const HOVER_SHOW_DELAY = 80 * carte détaillée affichée au survol n'hérite JAMAIS de cette échelle — * elle reste toujours à sa taille réelle pour rester lisible, quel que * soit le contexte d'où elle a été ouverte. + * kind: 'item' (défaut) ou 'building'. Un objet a un seul attribut + * (`itemData.attr`) et un coût scalaire (`itemData.cost`). Un bâtiment a + * DEUX attributs (`itemData.attrs`, tableau) et un coût réparti sur les + * deux (`itemData.cost`, objet `{attr: montant}`) — cette classe gère les + * deux formes via kind, plutôt que dupliquer tout le composant. + * hAnchor: 'left' (défaut) ou 'right'. Détermine de quel côté du jeton + * compact la carte détaillée s'étend horizontalement au survol. 'left' = + * la carte s'étend vers la DROITE (jeton ancré sur son coin gauche) — + * adapté à la boutique d'objets sur le bord GAUCHE du canvas. 'right' = + * la carte s'étend vers la GAUCHE (jeton ancré sur son coin droit) — + * nécessaire pour la boutique de bâtiments sur le bord DROIT du canvas, + * sinon la carte déborderait hors du canvas et son bouton ACHETER + * deviendrait injoignable au clic. */ export class ItemBoardToken extends Phaser.GameObjects.Container { - constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1) { + constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1, kind = 'item', hAnchor = 'left') { super(scene, x, y) this.itemData = itemData this.status = status this.affordable = affordable + this.kind = kind + this.hAnchor = hAnchor this._displayScale = displayScale this._expanded = null this._hideTimer = null @@ -67,8 +89,40 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { scene.add.existing(this) } + /** Attributs concernés par cet objet/bâtiment, toujours sous forme de tableau. */ + _attrs() { + return this.kind === 'building' ? this.itemData.attrs : [this.itemData.attr] + } + + /** Couleur représentative : celle de l'unique attribut pour un objet, ou un + * mélange des deux couleurs d'attribut pour un bâtiment. */ + _attrColor() { + const attrs = this._attrs() + if (attrs.length === 1) return ATTR_COLORS[attrs[0]] ?? 0x888888 + const c1 = ATTR_COLORS[attrs[0]] ?? 0x888888 + const c2 = ATTR_COLORS[attrs[1]] ?? 0x888888 + const r = Math.round((((c1 >> 16) & 0xff) + ((c2 >> 16) & 0xff)) / 2) + const g = Math.round((((c1 >> 8) & 0xff) + ((c2 >> 8) & 0xff)) / 2) + const b = Math.round(((c1 & 0xff) + (c2 & 0xff)) / 2) + return (r << 16) | (g << 8) | b + } + + /** Coût total affiché sur le badge : le coût lui-même pour un objet, la + * somme des coûts par attribut pour un bâtiment. */ + _costTotal() { + if (this.kind === 'building') { + return Object.values(this.itemData.cost).reduce((sum, c) => sum + c, 0) + } + return this.itemData.cost + } + + /** Clé de texture portrait, préfixée différemment selon le type. */ + _textureKey() { + return this.kind === 'building' ? `building-${this.itemData.id}` : `item-${this.itemData.id}` + } + _buildCompact() { - const attrColor = ATTR_COLORS[this.itemData.attr] ?? 0x888888 + const attrColor = this._attrColor() const dimmed = this.status === 'taken' const S = ICON_SIZE @@ -89,7 +143,7 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { bg.fillRoundedRect(-S / 2, -S / 2, S, S, 8) compact.add(bg) - const textureKey = `item-${this.itemData.id}` + const textureKey = this._textureKey() if (this.scene.textures.exists(textureKey)) { const img = this.scene.add.image(0, -3, textureKey) img.setDisplaySize(S - 10, S - 16) @@ -101,13 +155,13 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { border.strokeRoundedRect(-S / 2, -S / 2, S, S, 8) compact.add(border) - // Badge de coût (coin haut-droit) + // Badge de coût (coin haut-droit) — coût total pour un bâtiment const bx = S / 2 - 7, by = -S / 2 + 7 const costRing = this.scene.add.graphics() costRing.fillStyle(0x1a0a00, 1) costRing.fillCircle(bx, by, 9) compact.add(costRing) - compact.add(this.scene.add.text(bx, by, String(this.itemData.cost), { + compact.add(this.scene.add.text(bx, by, String(this._costTotal()), { fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff' }).setOrigin(0.5)) @@ -180,27 +234,33 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { * displayScale du jeton d'où elle a été ouverte (jeton boutique à taille * normale, ou mini-jeton "posé sur la table" pour moi/les adversaires). * - * Elle est ancrée sur le coin HAUT-GAUCHE du jeton compact (dont la taille - * visuelle dépend de displayScale) plutôt que centrée dessus, pour rester - * dans les limites du canvas quel que soit l'emplacement du jeton : elle - * s'étend uniquement vers la droite et vers le bas, jamais vers la gauche - * (où se trouve le bord du canvas pour la colonne boutique) ni vers le - * haut (où pourrait se trouver le bord supérieur du canvas). + * Elle est ancrée sur un coin (haut-gauche ou haut-droit selon hAnchor) du + * jeton compact (dont la taille visuelle dépend de displayScale) plutôt + * que centrée dessus, pour rester dans les limites du canvas quel que + * soit l'emplacement du jeton : elle s'étend toujours vers le bas, et + * horizontalement à l'opposé du bord de canvas le plus proche (vers la + * droite pour la boutique d'objets à gauche, vers la gauche pour la + * boutique de bâtiments à droite — voir hAnchor). */ _showExpanded() { if (this._expanded) return const isAvailable = this.status === 'available' - const cardH = isAvailable ? EXPANDED_H_WITH_BTN : EXPANDED_H + // Un bâtiment affiche une ligne de coût détaillé supplémentaire (2 jauges + // distinctes) : la carte doit être un peu plus haute pour lui faire de + // la place sans écraser la description. + const buildingExtraH = this.kind === 'building' ? 18 : 0 + const cardH = (isAvailable ? EXPANDED_H_WITH_BTN : EXPANDED_H) + buildingExtraH // Demi-taille visuelle réelle du jeton compact (après displayScale) : - // sert à aligner le coin haut-gauche de la carte détaillée sur le coin - // haut-gauche du jeton compact tel qu'il apparaît réellement à l'écran. + // sert à aligner le coin du jeton (gauche ou droit selon hAnchor) sur le + // coin correspondant de la carte détaillée, tel qu'il apparaît réellement + // à l'écran. const iconHalf = (ICON_SIZE * this._displayScale) / 2 - const dx = EXPANDED_W / 2 - iconHalf + const dx = (this.hAnchor === 'right' ? -1 : 1) * (EXPANDED_W / 2 - iconHalf) const dy = cardH / 2 - iconHalf - const attrColor = ATTR_COLORS[this.itemData.attr] ?? 0x888888 + const attrColor = this._attrColor() const c = this.scene.add.container(dx, dy) const shadow = this.scene.add.graphics() @@ -213,7 +273,7 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { bg.fillRoundedRect(-EXPANDED_W / 2, -cardH / 2, EXPANDED_W, cardH, 12) c.add(bg) - const textureKey = `item-${this.itemData.id}` + const textureKey = this._textureKey() const imgY = -cardH / 2 if (this.scene.textures.exists(textureKey)) { const img = this.scene.add.image(0, imgY, textureKey).setOrigin(0.5, 0) @@ -231,7 +291,21 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { color: '#f5e6c8', align: 'center' }).setOrigin(0.5, 0)) - c.add(this.scene.add.text(0, imgY + EXPANDED_IMAGE_H + 36, this.itemData.description, { + // Ligne de coût détaillé par attribut, utile surtout pour un bâtiment + // (2 jauges distinctes) — le badge de coin haut-droit n'affiche que le + // total, insuffisant pour comprendre la répartition réelle du paiement. + let descY = imgY + EXPANDED_IMAGE_H + 34 + if (this.kind === 'building') { + const costLabel = Object.entries(this.itemData.cost) + .map(([attr, cost]) => `${cost} ${ATTR_LABELS[attr] ?? attr}`) + .join(' + ') + c.add(this.scene.add.text(0, descY, costLabel, { + fontSize: '11px', fontFamily: 'Georgia, serif', fontStyle: 'bold', color: '#d4a732' + }).setOrigin(0.5, 0)) + descY += 18 + } + + c.add(this.scene.add.text(0, descY, this.itemData.description, { fontSize: '13px', fontFamily: 'Georgia, serif', color: '#e8d9c0', align: 'center', lineSpacing: 4, wordWrap: { width: EXPANDED_W - 20 } }).setOrigin(0.5, 0)) diff --git a/client/src/game/scenes/BootScene.js b/client/src/game/scenes/BootScene.js index 6b5f13f..fb4f7e5 100644 --- a/client/src/game/scenes/BootScene.js +++ b/client/src/game/scenes/BootScene.js @@ -2,6 +2,7 @@ import Phaser from 'phaser' import { CARD_W, CARD_H, RADIUS } from '../objects/cardLayout' import { ITEM_CARD_W, ITEM_IMAGE_H, ITEM_RADIUS } from '../objects/itemLayout' import { ITEMS } from '../items' +import { BUILDINGS } from '../buildings' const CARD_IMAGES = [ { id: 'queen', file: 'reine.png' }, @@ -52,6 +53,11 @@ export class BootScene extends Phaser.Scene { ITEMS.forEach(({ id, image }) => { this.load.image(`raw-item-${id}`, `/items/${image}`) }) + + // Charger les images brutes des bâtiments + BUILDINGS.forEach(({ id, image }) => { + this.load.image(`raw-building-${id}`, `/buildings/${image}`) + }) } create() { @@ -63,7 +69,12 @@ export class BootScene extends Phaser.Scene { // Pour chaque objet, générer une texture portrait (zone haute uniquement, // le bas de la carte objet est réservé au texte d'effet côté ItemObject) ITEMS.forEach(({ id }) => { - this._generateItemTexture(id) + this._generatePortraitTexture(`raw-item-${id}`, `item-${id}`) + }) + + // Pour chaque bâtiment, même traitement (même layout de carte que les objets) + BUILDINGS.forEach(({ id }) => { + this._generatePortraitTexture(`raw-building-${id}`, `building-${id}`) }) this.scene.start('GameScene') @@ -133,15 +144,13 @@ export class BootScene extends Phaser.Scene { } /** - * Crée une texture `item-{id}` de dimensions ITEM_CARD_W × ITEM_IMAGE_H - * (uniquement la zone portrait en haut de la carte objet, coins arrondis - * en haut seulement — le bas de la carte est une zone de texte gérée par - * ItemObject, pas par cette texture). + * Crée une texture `outKey` de dimensions ITEM_CARD_W × ITEM_IMAGE_H + * (uniquement la zone portrait en haut d'une carte objet/bâtiment, coins + * arrondis en haut seulement — le bas de la carte est une zone de texte + * gérée par ItemBoardToken, pas par cette texture). Réutilisée pour les + * objets ET les bâtiments, qui partagent le même layout de carte. */ - _generateItemTexture(id) { - const rawKey = `raw-item-${id}` - const outKey = `item-${id}` - + _generatePortraitTexture(rawKey, outKey) { if (!this.textures.exists(rawKey)) return if (this.textures.exists(outKey)) return diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index 7f61b43..416a4a8 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -3,6 +3,7 @@ import { CardObject } from '../objects/CardObject' import { CARD_W, CARD_H } from '../objects/cardLayout' import { ItemBoardToken, ICON_SIZE } from '../objects/ItemBoardToken' import { ITEMS, getItemById } from '../items' +import { BUILDINGS, getBuildingById } from '../buildings' // Marge depuis le bas du canvas pour éviter la barre Windows const BOTTOM_MARGIN = 60 @@ -50,10 +51,15 @@ export class GameScene extends Phaser.Scene { this._resizeTimer = null // debounce du redimensionnement du canvas // ── Objets sur le plateau (jetons permanents, pas de panneau modal) ──────── - this.itemTokens = [] // ItemBoardToken (boutique) actuellement affichés + this.itemTokens = [] // ItemBoardToken (boutique objets, à gauche) actuellement affichés this._shopState = null // { myItems, pendingBuyItemId, availableItems, firstPlayerName } this.myItemTokens = [] // ItemBoardToken des objets que JE possède, à côté de ma main this.opponentItemTokens = [] // ItemBoardToken des objets possédés par les autres joueurs + + // ── Bâtiments sur le plateau (même principe, boutique à DROITE) ──────────── + this.buildingTokens = [] // ItemBoardToken (boutique bâtiments, à droite) + this.myBuildingTokens = [] // ItemBoardToken des bâtiments que JE possède, à côté de ma main + this.opponentBuildingTokens = [] // ItemBoardToken des bâtiments possédés par les autres joueurs } create() { @@ -64,6 +70,7 @@ export class GameScene extends Phaser.Scene { this._buildTable() this._buildPlayButton() this._buildItemTokens() + this._buildBuildingTokens() this._setupSocketListeners() // Le canvas Phaser (mode Scale.RESIZE) se redimensionne quand le HUD Vue @@ -186,8 +193,9 @@ export class GameScene extends Phaser.Scene { // Bouton de confirmation + compteur this._layoutPlayButton() - // Bande de jetons d'objet en haut du plateau + // Colonnes de jetons objets (gauche) et bâtiments (droite) this._layoutItemTokens() + this._layoutBuildingTokens() // Main du joueur (repositionnement simple, sans reconstruction) this._repositionHand() @@ -196,12 +204,14 @@ export class GameScene extends Phaser.Scene { _buildSlots(playerCount) { const { width, height } = this.scale - // Détruire les anciens slots (et les mini-cartes objets des adversaires) + // Détruire les anciens slots (et les mini-cartes objets/bâtiments des adversaires) this.slotObjects.forEach(o => o.destroy()) this.slotObjects = [] this.playSlots = [] this.opponentItemTokens.forEach(t => t.destroy()) this.opponentItemTokens = [] + this.opponentBuildingTokens.forEach(t => t.destroy()) + this.opponentBuildingTokens = [] const slotY = height / 2 - 60 const spacing = CARD_W + 30 @@ -227,10 +237,11 @@ export class GameScene extends Phaser.Scene { }).setOrigin(0.5) this.slotObjects.push(label) - // Mini-cartes objets "posées devant" cet adversaire, juste sous son - // slot de jeu — comme sur ma propre table à côté de ma main. On ne le - // fait pas pour MON propre slot : mes objets sont déjà affichés à côté - // de ma main (plus pertinent puisque je vois ma main, pas mon slot). + // Mini-cartes objets/bâtiments "posées devant" cet adversaire, juste + // sous son slot de jeu — comme sur ma propre table à côté de ma main. + // On ne le fait pas pour MON propre slot : mes objets/bâtiments sont + // déjà affichés à côté de ma main (plus pertinent puisque je vois ma + // main, pas mon slot). const isMe = player?.teamIndex === this.store.myTeamIndex if (!isMe) { const ownedItems = player?.items ?? [] @@ -244,6 +255,16 @@ export class GameScene extends Phaser.Scene { const token = new ItemBoardToken(this, startTokenX + k * tokenGap, slotY + 128, item, 'owned', false, 0.55) this.opponentItemTokens.push(token) }) + + // Bâtiments possédés, sur une seconde rangée sous les objets + const ownedBuildings = player?.buildings ?? [] + const startBuildingX = x - (ownedBuildings.length - 1) * tokenGap / 2 + ownedBuildings.forEach((buildingId, k) => { + const building = getBuildingById(buildingId) + if (!building) return + const token = new ItemBoardToken(this, startBuildingX + k * tokenGap, slotY + 152, building, 'owned', false, 0.55, 'building') + this.opponentBuildingTokens.push(token) + }) } this.playSlots.push({ x, y: slotY }) @@ -496,6 +517,122 @@ export class GameScene extends Phaser.Scene { }) } + // ─── Bâtiments sur le plateau (miroir des objets, boutique à DROITE) ─────── + + _buildingTokensX() { + return this.scale.width - 70 + } + + _buildingTokensStartY() { + return 130 + } + + _buildingTokensSpacing() { + return ICON_SIZE + 60 + } + + /** + * (Re)construit la colonne à DROITE avec uniquement les bâtiments encore + * disponibles à l'achat — même logique que _buildItemTokens (disparition + * complète une fois acheté, les restants remontent pour combler le vide). + */ + _buildBuildingTokens() { + this.buildingTokens.forEach(t => t.destroy()) + this.buildingTokens = [] + + const x = this._buildingTokensX() + const startY = this._buildingTokensStartY() + const spacing = this._buildingTokensSpacing() + + const state = this._shopState ?? { myBuildings: [], availableBuildings: BUILDINGS.map(b => b.id) } + + const stillAvailable = BUILDINGS.filter(b => + state.availableBuildings.includes(b.id) && !state.myBuildings.includes(b.id) + ) + + if (stillAvailable.length > 0) { + if (!this.buildingTokensLabel) { + this.buildingTokensLabel = this.add.text(x, startY - ICON_SIZE / 2 - 16, 'BÂTIMENTS\n(survoler)', { + fontSize: '9px', fontFamily: 'Georgia', fontStyle: 'bold', + color: '#6a5040', align: 'center' + }).setOrigin(0.5, 1) + } else { + this.buildingTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16) + this.buildingTokensLabel.setVisible(true) + } + } else if (this.buildingTokensLabel) { + this.buildingTokensLabel.setVisible(false) + } + + const me = this.store.players.find(p => p.teamIndex === this.store.myTeamIndex) + const myGauges = me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 } + + stillAvailable.forEach((building, i) => { + const affordable = Object.entries(building.cost) + .every(([attr, cost]) => (myGauges[attr] ?? 0) >= cost) + + // hAnchor='right' : cette boutique est sur le bord DROIT du canvas, + // la carte détaillée doit donc s'étendre vers la GAUCHE au survol + // pour ne jamais déborder hors du canvas (sinon son bouton ACHETER + // devient injoignable au clic, cf. bug corrigé). + const token = new ItemBoardToken(this, x, startY + i * spacing, building, 'available', affordable, 1, 'building', 'right') + token.on('buy', (data) => this._onBuyBuilding(data)) + this.buildingTokens.push(token) + }) + } + + /** Recale la colonne de jetons bâtiments verticalement sur le bord droit du canvas. */ + _layoutBuildingTokens() { + const x = this._buildingTokensX() + const startY = this._buildingTokensStartY() + const spacing = this._buildingTokensSpacing() + if (this.buildingTokensLabel) this.buildingTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16) + this.buildingTokens.forEach((token, i) => token.setPosition(x, startY + i * spacing)) + } + + _onBuyBuilding(buildingData) { + this.socket.emit('buy-building', { roomId: this.roomId, buildingId: buildingData.id }) + } + + /** + * (Re)construit les mini-cartes des bâtiments que JE possède, affichées + * juste à GAUCHE de ma main (symétrique de mes objets à droite) — posés + * devant moi sur la table, de l'autre côté de mes cartes personnage. + */ + _buildMyBuildingTokens() { + this.myBuildingTokens.forEach(t => t.destroy()) + this.myBuildingTokens = [] + + const myBuildingIds = this._shopState?.myBuildings ?? [] + if (myBuildingIds.length === 0) return + + myBuildingIds.forEach((buildingId) => { + const buildingData = BUILDINGS.find(b => b.id === buildingId) + if (!buildingData) return + const token = new ItemBoardToken(this, 0, 0, buildingData, 'owned', false, 0.7, 'building') + this.myBuildingTokens.push(token) + }) + + this._layoutMyBuildingTokens() + } + + /** Positionne mes mini-cartes bâtiments juste à gauche de ma main de cartes. */ + _layoutMyBuildingTokens() { + if (this.myBuildingTokens.length === 0) return + + const { handY, spacing, startX } = this._handGeometry(this.handCards.length) + const handLeftEdge = startX - CARD_W / 2 + const gap = 18 + const tokenGap = ICON_SIZE * 0.7 + 10 + + // Placés en partant du bord gauche de la main vers la gauche (ordre + // inversé pour que le premier bâtiment acheté reste le plus proche + // de la main, symétrique à mes objets qui partent du bord droit). + this.myBuildingTokens.forEach((token, i) => { + token.setPosition(handLeftEdge - gap - ICON_SIZE * 0.7 / 2 - i * tokenGap, handY) + }) + } + // ─── Main du joueur ────────────────────────────────────────────────────────── /** @@ -531,6 +668,7 @@ export class GameScene extends Phaser.Scene { }) this._layoutMyItemTokens() + this._layoutMyBuildingTokens() } _onCardSelected(card) { @@ -676,8 +814,11 @@ export class GameScene extends Phaser.Scene { // au survol en plein milieu. const newShopState = { myItems: state.myItems ?? [], - pendingBuyItemId: state.pendingBuyItemId ?? null, - availableItems: state.availableItems ?? [], + myBuildings: state.myBuildings ?? [], + pendingBuyItemId: state.pendingBuyItemId ?? null, + pendingBuyBuildingId: state.pendingBuyBuildingId ?? null, + availableItems: state.availableItems ?? [], + availableBuildings: state.availableBuildings ?? [], firstPlayerName: state.firstPlayerName ?? null } const me = (state.players ?? []).find(p => p.teamIndex === state.myTeamIndex) @@ -686,18 +827,28 @@ export class GameScene extends Phaser.Scene { JSON.stringify(this._shopState.myItems) !== JSON.stringify(newShopState.myItems) || JSON.stringify(this._shopState.availableItems) !== JSON.stringify(newShopState.availableItems) || this._lastMyGaugesKey !== myGaugesKey + const buildingShopChanged = !this._shopState || + JSON.stringify(this._shopState.myBuildings) !== JSON.stringify(newShopState.myBuildings) || + JSON.stringify(this._shopState.availableBuildings) !== JSON.stringify(newShopState.availableBuildings) || + this._lastMyGaugesKey !== myGaugesKey this._shopState = newShopState this._lastMyGaugesKey = myGaugesKey if (shopChanged) this._buildItemTokens() + if (buildingShopChanged) this._buildBuildingTokens() - // Reconstruire mes mini-cartes objets uniquement si MA liste d'objets - // possédés a changé (indépendant de shopChanged qui réagit aussi aux + // Reconstruire mes mini-cartes objets/bâtiments uniquement si MA liste + // possédée a changé (indépendant de shopChanged qui réagit aussi aux // objets encore disponibles / jauges, non pertinents ici). const myItemsKey = JSON.stringify(newShopState.myItems) if (this._lastMyItemsKey !== myItemsKey) { this._lastMyItemsKey = myItemsKey this._buildMyItemTokens() } + const myBuildingsKey = JSON.stringify(newShopState.myBuildings) + if (this._lastMyBuildingsKey !== myBuildingsKey) { + this._lastMyBuildingsKey = myBuildingsKey + this._buildMyBuildingTokens() + } if (state.phase === 'selecting') { this._canPlay = true @@ -708,11 +859,11 @@ export class GameScene extends Phaser.Scene { this.readyText.setText('') this._deactivateBtn() // (Re)construire les slots si le nombre de joueurs a changé (premier - // tour), OU si la liste d'objets possédés par un adversaire a changé - // (leurs mini-cartes objets, affichées sous leur slot, doivent alors - // être mises à jour). + // tour), OU si la liste d'objets/bâtiments possédés par un adversaire + // a changé (leurs mini-cartes, affichées sous leur slot, doivent + // alors être mises à jour). const opponentItemsKey = JSON.stringify( - state.players.map(p => p.items ?? []) + state.players.map(p => [p.items ?? [], p.buildings ?? []]) ) if (this.playSlots.length !== state.players.length || this._lastOpponentItemsKey !== opponentItemsKey) { this._lastOpponentItemsKey = opponentItemsKey @@ -751,14 +902,17 @@ export class GameScene extends Phaser.Scene { } }) - this.socket.on('round-result', ({ playedCards, gains, boughtItems }) => { + this.socket.on('round-result', ({ playedCards, gains, boughtItems, boughtBuildings }) => { this.phaseText.setText('Résolution du tour...') this.readyText.setText('') this._showPlayedCards(playedCards, gains) - if (boughtItems && boughtItems.length > 0) { - const names = boughtItems.map(b => `${b.playerName} → ${b.itemName}`).join(' | ') - this.readyText.setText(`🛒 ${names}`) + const boughtLabels = [ + ...(boughtItems ?? []).map(b => `${b.playerName} → ${b.itemName}`), + ...(boughtBuildings ?? []).map(b => `${b.playerName} → ${b.buildingName}`) + ] + if (boughtLabels.length > 0) { + this.readyText.setText(`🛒 ${boughtLabels.join(' | ')}`) } }) @@ -853,6 +1007,7 @@ export class GameScene extends Phaser.Scene { }) this._layoutMyItemTokens() + this._layoutMyBuildingTokens() } // ─── Utilitaires ───────────────────────────────────────────────────────────── diff --git a/e2e/tests/building-shop.spec.js b/e2e/tests/building-shop.spec.js new file mode 100644 index 0000000..cdec3c6 --- /dev/null +++ b/e2e/tests/building-shop.spec.js @@ -0,0 +1,248 @@ +import { test, expect } from '@playwright/test' +import { + installPhaserDebugHook, createRoom, joinRoom, startGame, + playFullTurn, getShopBuildingIds, getMyGauges, buyShopBuilding, hoverShopBuilding +} from './helpers.js' + +/** + * Tests d'intégration E2E : boutique de bâtiments (Église, Château Royal). + * + * Couvre notamment la régression détectée manuellement : la carte détaillée + * du bâtiment (affichée au survol) s'étendait par défaut vers la DROITE + * depuis le jeton, comme pour la boutique d'objets à gauche du plateau — + * mais la boutique de bâtiments est sur le bord DROIT du canvas, donc la + * carte débordait hors du canvas et son bouton ACHETER devenait injoignable + * au clic (coordonnées calculées hors des limites du canvas). Corrigé via le + * paramètre `hAnchor='right'` sur ItemBoardToken pour cette boutique. + */ +test.describe('Boutique de bâtiments', () => { + test('la boutique affiche les 2 bâtiments (Église, Château) au démarrage de la partie', async ({ browser }) => { + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + const ids = await getShopBuildingIds(p1) + expect(ids.sort()).toEqual(['castle', 'church'].sort()) + + await ctx1.close() + await ctx2.close() + }) + + test('les jetons de bâtiment sont positionnés sur le bord DROIT du canvas', async ({ browser }) => { + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + const canvasWidth = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + return game.canvas.width + }) + const tokenX = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + return scene.buildingTokens[0].x + }) + // Doit être proche du bord droit (dans le dernier quart du canvas) + expect(tokenX).toBeGreaterThan(canvasWidth * 0.75) + + await ctx1.close() + await ctx2.close() + }) + + test('la carte détaillée d\'un bâtiment reste entièrement visible dans le canvas au survol', async ({ browser }) => { + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + const canvas = p1.locator('#phaser-container canvas') + const box = await canvas.boundingBox() + + const tokenPos = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const t = scene.buildingTokens.find(t => t.itemData.id === 'church') + return { x: t.x, y: t.y } + }) + + // Le survol (_scheduleShow, HOVER_SHOW_DELAY=80ms) puis le hide-timer + // (_scheduleHide, HOVER_HIDE_DELAY=100ms) sont tous deux de courts + // setTimeout côté navigateur ; sous charge (exécution juste après un + // autre test dans la même suite), le timing réel peut légèrement + // dépasser une seule attente fixe. On répète le mouvement de souris + // jusqu'à ce que la carte soit confirmée ouverte, plutôt que de se fier + // à un unique délai figé. + let expandedBounds = null + for (let attempt = 0; attempt < 5 && !expandedBounds; attempt++) { + await p1.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y + (attempt % 2)) // micro-mouvement pour re-déclencher pointerover si besoin + await p1.waitForTimeout(300) + expandedBounds = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const t = scene.buildingTokens.find(t => t.itemData.id === 'church') + if (!t._expanded) return null + const b = t._expanded.getBounds() + return { x: b.x, y: b.y, right: b.right, bottom: b.bottom } + }) + } + expect(expandedBounds).not.toBeNull() + + expect(expandedBounds.x).toBeGreaterThanOrEqual(0) + expect(expandedBounds.y).toBeGreaterThanOrEqual(0) + expect(expandedBounds.right).toBeLessThanOrEqual(box.width) + expect(expandedBounds.bottom).toBeLessThanOrEqual(box.height) + + await ctx1.close() + await ctx2.close() + }) + + test('la carte détaillée affiche le coût réparti sur les 2 jauges et la description', async ({ browser }) => { + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + const canvas = p1.locator('#phaser-container canvas') + const box = await canvas.boundingBox() + const tokenPos = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const t = scene.buildingTokens.find(t => t.itemData.id === 'church') + return { x: t.x, y: t.y } + }) + await p1.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y) + await p1.waitForTimeout(300) + + const texts = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const t = scene.buildingTokens.find(t => t.itemData.id === 'church') + return t._expanded.list.filter(o => o.type === 'Text').map(o => o.text) + }) + + expect(texts).toContain('Église') + expect(texts.some(t => /3 Santé.*2 Diplomatie/.test(t))).toBe(true) + expect(texts.some(t => t.includes('Garantit +1 en Santé'))).toBe(true) + + await ctx1.close() + await ctx2.close() + }) + + test('le bouton ACHETER n\'est pas interactif si UNE SEULE des 2 jauges requises est insuffisante', async ({ browser }) => { + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + // Jauges à 0 en tout début de partie : Église (3 santé + 2 diplo) n'est pas achetable + const btnCenter = await hoverShopBuilding(p1, 'church') + expect(btnCenter).toBeNull() + + await ctx1.close() + await ctx2.close() + }) + + test('acheter un bâtiment le retire de la boutique pour TOUS les joueurs, débite les 2 jauges, et applique son effet', async ({ browser }) => { + test.setTimeout(60_000) + + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + // Accumuler assez de Santé ET de Diplomatie pour Alice (coût de l'Église : + // 3 santé + 2 diplo), pendant que Bob joue explicitement sa carte la plus + // FAIBLE sur ces deux mêmes attributs (mode 'min') — indispensable pour + // garantir un gain non-nul à chaque tour (gain = max(0, ma_valeur - + // valeur_voisin_gauche)), sinon le test devient flaky selon les cartes + // distribuées aléatoirement à chaque partie. + let gauges = await getMyGauges(p1) + let attempts = 0 + while ((gauges.health < 3 || gauges.diplo < 2) && attempts < 12) { + await playFullTurn([p1, p2], [ + ['health', 'diplo'], + { attr: ['health', 'diplo'], mode: 'min' } + ]) + gauges = await getMyGauges(p1) + attempts++ + } + expect(gauges.health).toBeGreaterThanOrEqual(3) + expect(gauges.diplo).toBeGreaterThanOrEqual(2) + + const idsBefore = await getShopBuildingIds(p1) + expect(idsBefore).toContain('church') + + const bought = await buyShopBuilding(p1, 'church') + expect(bought).toBe(true) + + // Jouer le tour pour déclencher la résolution de l'achat en attente + await playFullTurn([p1, p2]) + + const idsAfterP1 = await getShopBuildingIds(p1) + const idsAfterP2 = await getShopBuildingIds(p2) + expect(idsAfterP1).not.toContain('church') + expect(idsAfterP2).not.toContain('church') + expect(idsAfterP1).toContain('castle') // l'autre bâtiment reste disponible + + // Le bâtiment apparaît dans "mes bâtiments" (posés à gauche de ma main) + const myBuildingsCount = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + return game.scene.getScene('GameScene').myBuildingTokens.length + }) + expect(myBuildingsCount).toBe(1) + + // Et sous le slot d'Alice depuis la perspective de Bob (adversaire) + const opponentBuildingsCount = await p2.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + return game.scene.getScene('GameScene').opponentBuildingTokens.length + }) + expect(opponentBuildingsCount).toBe(1) + + await ctx1.close() + await ctx2.close() + }) +}) diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js index dc3e005..07ca141 100644 --- a/e2e/tests/helpers.js +++ b/e2e/tests/helpers.js @@ -70,12 +70,21 @@ export async function startGame(page) { /** * Joue une carte de la main du joueur et confirme. Sélectionne dynamiquement - * la meilleure carte pour l'attribut donné (ou la première carte si aucun - * attribut n'est précisé), en lisant l'état réel de la scène — jamais de - * coordonnées fixes, pour rester robuste face aux changements de layout. - * Ne fait rien si la main est vide (fin de partie). + * la carte MAXIMISANT (mode='max', défaut) ou MINIMISANT (mode='min') la + * SOMME des attributs donnés — `attr` accepte une chaîne ('war') ou un + * tableau (['health', 'diplo']). Sans argument, joue la première carte de la + * main. Lit l'état réel de la scène — jamais de coordonnées fixes, pour + * rester robuste face aux changements de layout. Ne fait rien si la main est + * vide (fin de partie). + * + * Le mode 'min' est indispensable pour fiabiliser les tests d'accumulation + * de jauge : le gain d'un tour = max(0, ma_valeur - valeur_du_voisin_de_gauche), + * donc pour garantir un gain non-nul sur un attribut donné, il ne suffit pas + * que l'adversaire joue "un autre attribut" (sa carte peut avoir une valeur + * élevée sur l'attribut visé par pur hasard) — il faut explicitement qu'il + * joue sa carte la plus FAIBLE sur cet attribut précis. */ -export async function playCard(page, attr = null) { +export async function playCard(page, attr = null, mode = 'max') { const canvas = page.locator('#phaser-container canvas') const box = await canvas.boundingBox() @@ -86,19 +95,23 @@ export async function playCard(page, attr = null) { }) if (handLen === 0) return false - const cardPos = await page.evaluate((attr) => { + const attrs = attr === null ? null : (Array.isArray(attr) ? attr : [attr]) + + const cardPos = await page.evaluate(({ attrs, mode }) => { const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] const scene = game.scene.getScene('GameScene') let idx = 0 - if (attr) { - let bestVal = -1 + if (attrs) { + let bestVal = mode === 'min' ? Infinity : -1 scene.handCards.forEach((c, i) => { - if (c.cardData[attr] > bestVal) { bestVal = c.cardData[attr]; idx = i } + const sum = attrs.reduce((s, a) => s + (c.cardData[a] ?? 0), 0) + const better = mode === 'min' ? sum < bestVal : sum > bestVal + if (better) { bestVal = sum; idx = i } }) } const b = scene.handCards[idx].getBounds() return { x: b.centerX, y: b.centerY } - }, attr) + }, { attrs, mode }) await page.mouse.click(box.x + cardPos.x, box.y + cardPos.y) await page.waitForTimeout(200) @@ -118,10 +131,19 @@ export async function playCard(page, attr = null) { * Joue un tour complet pour une liste de pages (dans l'ordre donné), puis * attend la résolution + le début du tour suivant (délai serveur fixe de * 3.5s dans index.js + marge de sécurité). + * + * attrsByPage[i] peut être : null (première carte), une chaîne/tableau + * d'attributs (mode 'max' implicite), ou { attr, mode } pour choisir + * explicitement 'max' ou 'min' (voir playCard). */ export async function playFullTurn(pages, attrsByPage = []) { for (let i = 0; i < pages.length; i++) { - await playCard(pages[i], attrsByPage[i] ?? null) + const spec = attrsByPage[i] ?? null + if (spec && typeof spec === 'object' && !Array.isArray(spec)) { + await playCard(pages[i], spec.attr, spec.mode ?? 'max') + } else { + await playCard(pages[i], spec) + } } await pages[0].waitForTimeout(4200) } @@ -192,3 +214,60 @@ export async function getShopItemIds(page) { return scene.itemTokens.map(t => t.itemData.id) }) } + +/** + * Survole un jeton de bâtiment de la boutique (colonne à droite du plateau) + * par son id, et retourne le centre du bouton ACHETER si disponible et + * achetable. Retourne null si le jeton n'existe pas (déjà acheté) ou si le + * bouton n'est pas interactif (jauge insuffisante sur au moins un attribut). + */ +export async function hoverShopBuilding(page, buildingId) { + const canvas = page.locator('#phaser-container canvas') + const box = await canvas.boundingBox() + + const tokenPos = await page.evaluate((buildingId) => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const token = scene.buildingTokens.find(t => t.itemData.id === buildingId) + return token ? { x: token.x, y: token.y } : null + }, buildingId) + + if (!tokenPos) return null + + await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y) + await page.waitForTimeout(300) + + return page.evaluate((buildingId) => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const token = scene.buildingTokens.find(t => t.itemData.id === buildingId) + if (!token || !token._expanded) return null + const btnBg = token._expanded.list.find(o => o.type === 'Graphics' && o.input) + if (!btnBg) return null // bouton non interactif (jauge insuffisante) + const worldPoint = btnBg.getWorldTransformMatrix().transformPoint( + btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY + ) + return { x: worldPoint.x, y: worldPoint.y } + }, buildingId) +} + +/** Clique sur le bouton ACHETER d'un bâtiment actuellement affiché en survol. */ +export async function buyShopBuilding(page, buildingId) { + const btnCenter = await hoverShopBuilding(page, buildingId) + if (!btnCenter) return false + + const canvas = page.locator('#phaser-container canvas') + const box = await canvas.boundingBox() + await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y) + await page.waitForTimeout(400) + return true +} + +/** Lit l'état de la boutique de bâtiments (ids encore affichés dans la colonne à droite). */ +export async function getShopBuildingIds(page) { + return page.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + return scene.buildingTokens.map(t => t.itemData.id) + }) +} diff --git a/e2e/tests/item-shop.spec.js b/e2e/tests/item-shop.spec.js index 546cb88..9da7963 100644 --- a/e2e/tests/item-shop.spec.js +++ b/e2e/tests/item-shop.spec.js @@ -66,12 +66,14 @@ test.describe('Boutique d\'objets', () => { await p1.waitForTimeout(2000) // Accumuler assez de guerre pour P1 en jouant sa meilleure carte de guerre - // à chaque tour, pendant que P2 joue sur un autre attribut pour ne pas - // rivaliser sur la jauge visée. + // à chaque tour, pendant que P2 joue explicitement sa carte la plus + // FAIBLE en guerre (mode 'min') — indispensable pour garantir un gain + // non-nul à chaque tour (gain = max(0, ma_valeur - valeur_voisin_gauche)), + // sinon le test devient flaky selon les cartes distribuées aléatoirement. let gauges = await getMyGauges(p1) let attempts = 0 - while (gauges.war < 3 && attempts < 6) { - await playFullTurn([p1, p2], ['war', 'health']) + while (gauges.war < 3 && attempts < 12) { + await playFullTurn([p1, p2], ['war', { attr: 'war', mode: 'min' }]) gauges = await getMyGauges(p1) attempts++ } diff --git a/server/src/game/Room.js b/server/src/game/Room.js index 7683a7f..2abada0 100644 --- a/server/src/game/Room.js +++ b/server/src/game/Room.js @@ -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 } } diff --git a/server/src/game/buildings.js b/server/src/game/buildings.js new file mode 100644 index 0000000..17f62a7 --- /dev/null +++ b/server/src/game/buildings.js @@ -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 } diff --git a/server/src/index.js b/server/src/index.js index 7757454..ab38f2b 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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 diff --git a/server/test/game/Room.test.js b/server/test/game/Room.test.js index 1610566..d7a2df5 100644 --- a/server/test/game/Room.test.js +++ b/server/test/game/Room.test.js @@ -576,4 +576,254 @@ describe('Room - données publiques (getPlayersInfo / getStateFor)', () => { assert.deepEqual(state.myItems, []) assert.equal(state.pendingBuyItemId, null) }) + + test('getStateFor expose aussi les champs liés aux bâtiments', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.health = 5 + room.players[0].gauges.diplo = 5 + room.buyBuilding('s0', 'church') + + const state0 = room.getStateFor('s0') + assert.equal(state0.pendingBuyBuildingId, 'church') + assert.deepEqual(state0.myBuildings, []) + assert.ok(state0.availableBuildings.includes('church')) + + const state1 = room.getStateFor('s1') + assert.equal(state1.pendingBuyBuildingId, null) + }) +}) + +describe('Room - buyBuilding (validations)', () => { + test('accepte un achat si les DEUX jauges requises sont suffisantes', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.health = 5 + room.players[0].gauges.diplo = 5 + const result = room.buyBuilding('s0', 'church') + assert.equal(result.ok, true) + assert.equal(room.pendingBuildingBuys.get('s0'), 'church') + }) + + test('refuse si UNE SEULE des deux jauges requises est insuffisante', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.health = 5 + room.players[0].gauges.diplo = 0 // insuffisant (Église coûte 2 diplo) + const result = room.buyBuilding('s0', 'church') + 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.buyBuilding('s0', 'church') + assert.equal(result.ok, false) + }) + + test('refuse un bâtiment inconnu', () => { + const room = makeStartedRoom(2) + const result = room.buyBuilding('s0', 'batiment-inexistant') + assert.equal(result.ok, false) + assert.match(result.error, /inconnu/) + }) + + test('refuse un bâtiment déjà retiré du stock', () => { + const room = makeStartedRoom(2) + room.availableBuildings = room.availableBuildings.filter(id => id !== 'church') + room.players[0].gauges.health = 10 + room.players[0].gauges.diplo = 10 + const result = room.buyBuilding('s0', 'church') + assert.equal(result.ok, false) + assert.match(result.error, /déjà été acheté/) + }) + + test('refuse un bâtiment déjà possédé par ce joueur', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('church') + room.players[0].gauges.health = 10 + room.players[0].gauges.diplo = 10 + const result = room.buyBuilding('s0', 'church') + assert.equal(result.ok, false) + assert.match(result.error, /possèdes déjà/) + }) + + test('refuse une seconde tentative d\'achat de bâtiment le même tour', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.health = 10 + room.players[0].gauges.diplo = 10 + room.players[0].gauges.food = 10 + room.players[0].gauges.war = 10 + room.buyBuilding('s0', 'church') + const result = room.buyBuilding('s0', 'castle') + assert.equal(result.ok, false) + assert.match(result.error, /déjà tenté/) + }) + + test('refuse un joueur inconnu', () => { + const room = makeStartedRoom(2) + const result = room.buyBuilding('ghost', 'church') + assert.equal(result.ok, false) + }) +}) + +describe('Room - résolution des achats de bâtiments (coût multi-jauges, priorité, stock unique)', () => { + test('un achat de bâtiment déduit le coût sur les DEUX jauges concernées', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.health = 10 + room.players[0].gauges.diplo = 10 + room.buyBuilding('s0', 'church') + + setHandCardValues(room, 's0', { health: 0, diplo: 0 }) + setHandCardValues(room, 's1', { health: 0, diplo: 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(room.players[0].buildings.includes('church'), true) + assert.equal(room.availableBuildings.includes('church'), false) + assert.equal(result.boughtBuildings.length, 1) + assert.equal(result.boughtBuildings[0].buildingId, 'church') + // Église coûte 3 health + 2 diplo ; le +1 garanti de base s'applique + // DÉJÀ ce tour (même règle que les objets), donc jauge finale = 10 - coût + garanti + assert.equal(room.players[0].gauges.health, 10 - 3 + 1) + assert.equal(room.players[0].gauges.diplo, 10 - 2 + 1) + }) + + test('en cas de conflit sur le même bâtiment, le "premier joueur" du tour gagne', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.food = 10 + room.players[0].gauges.war = 10 + room.players[1].gauges.food = 10 + room.players[1].gauges.war = 10 + room.firstPlayerIndex = 0 // s0 prioritaire + + room.buyBuilding('s0', 'castle') + room.buyBuilding('s1', 'castle') + + setHandCardValues(room, 's0', { food: 0, war: 0 }) + setHandCardValues(room, 's1', { food: 0, war: 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.boughtBuildings.length, 1) + assert.equal(result.boughtBuildings[0].playerName, 'P0') + assert.equal(room.players[0].buildings.includes('castle'), true) + assert.equal(room.players[1].buildings.includes('castle'), false) + // Le perdant ne paie rien (jauges inchangées, hors gain de carte neutralisé) + assert.equal(room.players[1].gauges.food, 10) + assert.equal(room.players[1].gauges.war, 10) + }) + + test('chaque bâtiment ne peut être acheté qu\'une seule fois pour toute la partie', () => { + const room = makeStartedRoom(2) + room.players[0].gauges.food = 10 + room.players[0].gauges.war = 10 + room.buyBuilding('s0', 'castle') + 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.food = 10 + room.players[1].gauges.war = 10 + const secondAttempt = room.buyBuilding('s1', 'castle') + assert.equal(secondAttempt.ok, false) + }) +}) + +describe('Room - resolveRound : effets permanents des bâtiments (+ synergie objets)', () => { + test('un bâtiment garantit +1 sur CHACUN de ses deux attributs, même sans synergie', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('church') // possession directe, sans passer par un achat + setHandCardValues(room, 's0', { health: 0, diplo: 0 }) + setHandCardValues(room, 's1', { health: 6, diplo: 6 }) // gain de base = 0 pour s0 + + 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, 1) + assert.equal(result.gains[0].diplo, 1) + }) + + test('le bâtiment ne réduit jamais un gain de base déjà supérieur au minimum garanti', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('church') + setHandCardValues(room, 's0', { health: 6, diplo: 6 }) + setHandCardValues(room, 's1', { health: 0, diplo: 0 }) // gain de base = 6 pour s0 + + 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, 6) + assert.equal(result.gains[0].diplo, 6) + }) + + test('synergie : +1 supplémentaire sur Santé si Église ET Trousse du Médecin sont possédées', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('church') + room.players[0].items.push('medkit') // synergie sur l'attribut 'health' + setHandCardValues(room, 's0', { health: 0, diplo: 0 }) + setHandCardValues(room, 's1', { health: 6, diplo: 0 }) + + room.playCard('s0', room.players[0].hand[0].id) + room.playCard('s1', room.players[1].hand[0].id) + const result = room.resolveRound() + + // baseGuaranteed(1) + synergie(1) = 2, ET la Trousse elle-même garantit + // déjà 2 (effectValue) sur health via _applyItemEffects → le max des deux + // effets doit donner au moins 2. + assert.ok(result.gains[0].health >= 2, `attendu >= 2, obtenu ${result.gains[0].health}`) + // Sans synergie sur diplo (pas de royal_seal), seul le +1 de base s'applique + assert.equal(result.gains[0].diplo, 1) + }) + + test('synergie : +1 supplémentaire sur Guerre si Château ET Trébuchet sont possédés', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('castle') + room.players[0].items.push('trebuchet') // synergie sur l'attribut 'war' + setHandCardValues(room, 's0', { food: 0, war: 2 }) + setHandCardValues(room, 's1', { food: 0, war: 0 }) + + 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 war = 2-0 = 2, doublé par Trébuchet = 4, le Château garantit + // seulement max(4, 1+1=2) = 4 → le doublement de l'objet reste dominant ici + assert.equal(result.gains[0].war, 4) + // Sur food (pas de synergie, pas de Charrue), seul le +1 garanti du Château s'applique + assert.equal(result.gains[0].food, 1) + }) + + test('sans l\'objet de synergie, le bâtiment seul ne garantit que baseGuaranteed (pas de bonus)', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('castle') // pas de Trébuchet/Charrue possédés + setHandCardValues(room, 's0', { food: 0, war: 0 }) + setHandCardValues(room, 's1', { food: 6, war: 6 }) + + 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].food, 1) + assert.equal(result.gains[0].war, 1) + }) + + test('possession simultanée des 2 bâtiments : chacun garantit son propre plancher indépendamment', () => { + const room = makeStartedRoom(2) + room.players[0].buildings.push('church', 'castle') + setHandCardValues(room, 's0', { food: 0, war: 0, health: 0, diplo: 0 }) + setHandCardValues(room, 's1', { food: 6, war: 6, health: 6, diplo: 6 }) + + 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].food, 1) + assert.equal(result.gains[0].war, 1) + assert.equal(result.gains[0].health, 1) + assert.equal(result.gains[0].diplo, 1) + }) }) diff --git a/server/test/game/buildings.test.js b/server/test/game/buildings.test.js new file mode 100644 index 0000000..fca6f35 --- /dev/null +++ b/server/test/game/buildings.test.js @@ -0,0 +1,73 @@ +const { test, describe } = require('node:test') +const assert = require('node:assert/strict') + +const { BUILDINGS, getBuildingById } = require('../../src/game/buildings') + +describe('buildings.js', () => { + test('expose exactement 2 bâtiments', () => { + assert.equal(BUILDINGS.length, 2) + }) + + test('chaque bâtiment a un id unique', () => { + const ids = BUILDINGS.map(b => b.id) + assert.equal(new Set(ids).size, ids.length) + }) + + test('chaque bâtiment couvre exactement 2 attributs valides', () => { + const validAttrs = ['food', 'war', 'health', 'diplo'] + for (const b of BUILDINGS) { + assert.equal(b.attrs.length, 2, `${b.id} doit couvrir 2 attributs`) + b.attrs.forEach(a => assert.ok(validAttrs.includes(a), `${b.id} a un attribut invalide: ${a}`)) + } + }) + + test('le coût est défini pour chacun des 2 attributs et est positif', () => { + for (const b of BUILDINGS) { + b.attrs.forEach(attr => { + assert.ok(typeof b.cost[attr] === 'number', `${b.id}.cost.${attr} manquant`) + assert.ok(b.cost[attr] > 0, `${b.id}.cost.${attr} doit être positif`) + }) + } + }) + + test('baseGuaranteed est un nombre positif pour chaque bâtiment', () => { + for (const b of BUILDINGS) { + assert.ok(typeof b.baseGuaranteed === 'number' && b.baseGuaranteed > 0) + } + }) + + test('synergyItems référence un objet existant pour chaque attribut', () => { + const { getItemById } = require('../../src/game/items') + for (const b of BUILDINGS) { + b.attrs.forEach(attr => { + const itemId = b.synergyItems?.[attr] + assert.ok(itemId, `${b.id}.synergyItems.${attr} manquant`) + assert.ok(getItemById(itemId), `${b.id}.synergyItems.${attr} référence un objet inconnu: ${itemId}`) + }) + } + }) + + test('les 2 bâtiments couvrent ensemble les 4 attributs sans chevauchement', () => { + const allAttrs = BUILDINGS.flatMap(b => b.attrs) + assert.equal(new Set(allAttrs).size, 4) + assert.equal(allAttrs.length, 4) + }) + + describe('getBuildingById', () => { + test('retourne le bâtiment correspondant à un id existant', () => { + const church = getBuildingById('church') + assert.ok(church) + assert.equal(church.name, 'Église') + assert.deepEqual(church.attrs, ['health', 'diplo']) + }) + + test('retourne null pour un id inconnu', () => { + assert.equal(getBuildingById('does-not-exist'), null) + }) + + test('retourne null pour undefined/null', () => { + assert.equal(getBuildingById(undefined), null) + assert.equal(getBuildingById(null), null) + }) + }) +})