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
@@ -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