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:
1 parent
11cba0467b
commit
9139989fa7
14 files changed
+1222
-89
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+90
-11
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
+176
-23
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user