diff --git a/public/src/games/dominion/DominionAI.js b/public/src/games/dominion/DominionAI.js new file mode 100644 index 0000000..6e503e3 --- /dev/null +++ b/public/src/games/dominion/DominionAI.js @@ -0,0 +1,253 @@ +// Dominion — AI decisions (pure). Big Money with light engine sense, scaled by +// per-opponent skill (1–5). The scene drives an AI turn step by step: +// • repeatedly call chooseAction() until it returns null → play that action +// • play all treasures, then repeatedly call chooseBuy() → buy that card +// • whenever state.pending targets this AI, call resolvePending() +// Every function is read-only on state and returns a plain choice. + +import { + getCard, isType, +} from './DominionCards.js'; +import { + legalActionIids, allCards, handCoinValue, + affordableSupply, canGain, +} from './DominionLogic.js'; + +function countOwned(state, seat, id) { + return allCards(state, seat).filter((c) => c.id === id).length; +} + +// ── Action phase ────────────────────────────────────────────────────────────── + +// Higher = play first among non-cantrip terminals. +const TERMINAL_PRIORITY = { + witch: 95, councilroom: 90, smithy: 85, militia: 80, library: 75, + bandit: 70, bureaucrat: 65, mine: 60, remodel: 55, moneylender: 50, + workshop: 45, throneroom: 40, chapel: 35, cellar: 30, artisan: 25, vassal: 20, +}; + +export function chooseAction(state, seat) { + const legal = legalActionIids(state); + if (legal.length === 0) return null; + const p = state.players[seat]; + const byIid = new Map(p.hand.map((c) => [c.iid, c])); + + // Play cantrips / villages first (they replace the action they cost). + const cantrips = legal.filter((iid) => getCard(byIid.get(iid).id).plus.actions >= 1); + if (cantrips.length > 0) { + // Prefer the one drawing the most cards, then the cheapest, to keep the chain alive. + cantrips.sort((a, b) => { + const ca = getCard(byIid.get(a).id), cb = getCard(byIid.get(b).id); + return (cb.plus.cards - ca.plus.cards) || (ca.cost - cb.cost); + }); + return cantrips[0]; + } + + // Only terminals remain (one action left). Throne Room needs another action to be worth it. + const terminals = legal.slice(); + terminals.sort((a, b) => { + const ida = byIid.get(a).id, idb = byIid.get(b).id; + return (TERMINAL_PRIORITY[idb] ?? 0) - (TERMINAL_PRIORITY[ida] ?? 0); + }); + const best = terminals[0]; + if (byIid.get(best).id === 'throneroom') { + const hasOther = p.hand.some((c) => c.iid !== best && isType(c.id, 'action')); + if (!hasOther) { + // Skip Throne Room; play the next-best terminal instead, if any. + const alt = terminals.find((iid) => byIid.get(iid).id !== 'throneroom'); + return alt ?? best; + } + } + return best; +} + +// ── Buy phase ───────────────────────────────────────────────────────────────── + +// Engine/attack cards worth buying, best first, with an ownership cap each. +const ENGINE_BUYS = [ + { id: 'witch', cap: 1 }, { id: 'laboratory', cap: 4 }, { id: 'market', cap: 4 }, + { id: 'festival', cap: 3 }, { id: 'village', cap: 4 }, { id: 'smithy', cap: 2 }, + { id: 'councilroom', cap: 2 }, { id: 'mine', cap: 1 }, { id: 'militia', cap: 1 }, + { id: 'moneylender', cap: 1 }, { id: 'poacher', cap: 2 }, { id: 'merchant', cap: 2 }, + { id: 'sentry', cap: 2 }, { id: 'harbinger', cap: 1 }, { id: 'remodel', cap: 1 }, + { id: 'workshop', cap: 1 }, +]; + +export function chooseBuy(state, seat, skill = 3) { + const p = state.players[seat]; + const coins = p.coins; + if (p.buys <= 0) return null; + const provincesLeft = state.supply.province ?? 0; + + if (coins >= 8 && provincesLeft > 0) return 'province'; + + // Late-game greening — thresholds widen with skill (better players green sooner). + const greenAt = 3 + Math.round(skill / 2); + if (provincesLeft <= greenAt) { + if (coins >= 5 && (state.supply.duchy ?? 0) > 0) return 'duchy'; + if (coins >= 2 && provincesLeft <= 2 && (state.supply.estate ?? 0) > 0) return 'estate'; + } + + // Engine building (mid coins; never skip Gold/Province). + if (skill >= 3 && coins <= 5) { + for (const { id, cap } of ENGINE_BUYS) { + if (!state.kingdom.includes(id)) continue; + if (getCard(id).cost > coins) continue; + if ((state.supply[id] ?? 0) <= 0) continue; + if (countOwned(state, seat, id) >= cap) continue; + // Keep terminals roughly balanced against villages at higher skill. + return id; + } + } + + if (coins >= 6 && (state.supply.gold ?? 0) > 0) return 'gold'; + if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver'; + return null; +} + +// ── Pending decisions ────────────────────────────────────────────────────────── + +// Disposability: higher = discard / get rid of first. +function discardRank(id) { + if (id === 'curse') return 100; + if (isType(id, 'victory')) return 90; + if (id === 'copper') return 50; + if (isType(id, 'action')) return 30; + if (id === 'silver') return 10; + if (id === 'gold') return 5; + return 20; +} + +function trashRank(id, copperKeep) { + if (id === 'curse') return 100; + if (id === 'estate') return 80; + if (id === 'copper') return copperKeep ? 0 : 60; + return 0; // never auto-trash anything else +} + +function bestGain(state, seat, maxCost, filterTreasure) { + const options = affordableSupply(state, maxCost, filterTreasure); + if (options.length === 0) return null; + // Gain value ordering. + const rank = (id) => { + if (id === 'province') return 1000; + if (id === 'gold') return 900; + if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850; + const def = getCard(id); + if (isType(id, 'action')) return 400 + def.cost * 10; + if (id === 'silver') return 300; + if (isType(id, 'treasure')) return 200 + def.cost; + return def.cost; // victory/curse low unless flagged above + }; + options.sort((a, b) => rank(b) - rank(a)); + return options[0]; +} + +export function resolvePending(state, skill = 3) { + const pend = state.pending; + if (!pend) return {}; + const seat = pend.seat; + const p = state.players[seat]; + + switch (pend.kind) { + case 'cellarDiscard': { + // Dump dead cards (victory/curse) to dig for fresh ones. + const iids = p.hand.filter((c) => isType(c.id, 'victory') || c.id === 'curse').map((c) => c.iid); + return { iids }; + } + case 'chapelTrash': { + const copperCount = countOwned(state, seat, 'copper'); + const keepCoppers = copperCount <= 3; + const ranked = p.hand + .map((c) => ({ c, r: trashRank(c.id, keepCoppers) })) + .filter((x) => x.r > 0) + .sort((a, b) => b.r - a.r) + .slice(0, pend.max ?? 4); + return { iids: ranked.map((x) => x.c.iid) }; + } + case 'harbingerTopdeck': { + // Topdeck the strongest non-dead card from the discard. + const cand = p.discard + .filter((c) => !isType(c.id, 'victory') && c.id !== 'curse' && c.id !== 'copper') + .sort((a, b) => getCard(b.id).cost - getCard(a.id).cost); + return { iid: cand[0]?.iid ?? null }; + } + case 'vassalPlay': + return { play: true }; + case 'gainFromSupply': { + const id = bestGain(state, seat, pend.maxCost, pend.filterTreasure); + return { id }; + } + case 'artisanTopdeck': { + // Prefer topdecking an action we'll replay; else the most disposable card. + const action = p.hand.filter((c) => isType(c.id, 'action')) + .sort((a, b) => getCard(b.id).cost - getCard(a.id).cost)[0]; + if (action) return { iid: action.iid }; + const worst = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id))[0]; + return { iid: worst?.iid ?? null }; + } + case 'remodelTrash': { + const greening = (state.supply.province ?? 0) <= 4; + if (greening) { + const gold = p.hand.find((c) => c.id === 'gold'); + if (gold && canGain(state, 'province', getCard('gold').cost + 2)) return { iid: gold.iid }; + } + const estate = p.hand.find((c) => c.id === 'estate'); + if (estate) return { iid: estate.iid }; + const copper = p.hand.find((c) => c.id === 'copper'); + if (copper) return { iid: copper.iid }; + // Fall back to the cheapest card. + const cheapest = p.hand.slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost)[0]; + return { iid: cheapest?.iid ?? null }; + } + case 'mineTrash': { + const silver = p.hand.find((c) => c.id === 'silver'); + if (silver) return { iid: silver.iid }; + const copper = p.hand.find((c) => c.id === 'copper'); + if (copper) return { iid: copper.iid }; + return { iid: null }; + } + case 'moneylenderTrash': + return { confirm: p.hand.some((c) => c.id === 'copper') }; + case 'throneChoose': { + const actions = p.hand.filter((c) => isType(c.id, 'action') && c.id !== 'throneroom') + .sort((a, b) => (TERMINAL_PRIORITY[b.id] ?? getCard(b.id).cost) - (TERMINAL_PRIORITY[a.id] ?? getCard(a.id).cost)); + return { iid: actions[0]?.iid ?? null }; + } + case 'libraryKeep': + // Keep the action if we still have actions to play it; otherwise set aside. + return { keep: p.actions > 0 }; + case 'sentry': { + const look = pend.cards ?? []; + const trash = [], discard = [], top = []; + for (const c of look) { + if (c.id === 'curse' || c.id === 'estate' || (c.id === 'copper' && countOwned(state, seat, 'copper') > 3)) trash.push(c.iid); + else if (isType(c.id, 'victory')) discard.push(c.iid); + else top.push(c.iid); + } + return { trash, discard, top }; + } + case 'poacherDiscard': { + const ranked = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id)); + return { iids: ranked.slice(0, pend.count).map((c) => c.iid) }; + } + case 'discardDownTo': { + const ranked = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id)); + return { iids: ranked.slice(0, pend.count).map((c) => c.iid) }; + } + case 'moatReveal': + return { reveal: true }; + case 'banditTrash': { + // Keep the more valuable treasure; trash the cheaper of the options. + const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost); + return { iid: opts[0]?.iid ?? null }; + } + case 'bureaucratTopdeck': { + // Topdeck the cheapest victory card to lose the least. + const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost); + return { iid: opts[0]?.iid ?? null }; + } + default: + return {}; + } +} diff --git a/public/src/games/dominion/DominionCards.js b/public/src/games/dominion/DominionCards.js new file mode 100644 index 0000000..ce7b0b8 --- /dev/null +++ b/public/src/games/dominion/DominionCards.js @@ -0,0 +1,107 @@ +// Dominion — card definitions (pure data, no Phaser). +// +// Each card's `frame` is its index in the `dominion-cards` spritesheet +// (270×390 per cell, art in the top ~60%; the title/icon band is drawn at +// runtime). The art sheet must be laid out in this exact frame order: +// +// 0 copper 1 silver 2 gold 3 estate 4 duchy 5 province 6 curse +// 7 cellar … 32 artisan (Kingdom cards, in the order declared below) +// +// `plus` holds the vanilla bonuses (+cards/+actions/+buys/+coins) which the +// engine auto-applies and the UI renders as the icon summary. Everything +// beyond vanilla lives in DominionLogic's CARD_EFFECTS registry. + +const def = (id, frame, cost, types, extra = {}) => ({ + id, name: extra.name ?? titleCase(id), frame, cost, types, + plus: { cards: 0, actions: 0, buys: 0, coins: 0, ...(extra.plus ?? {}) }, + coin: extra.coin, // treasure value + vp: extra.vp, // fixed victory points (Gardens is dynamic → handled in logic) + text: extra.text ?? '', +}); + +function titleCase(id) { + return id.replace(/(^|\s)\w/g, (c) => c.toUpperCase()); +} + +// ── Card table ──────────────────────────────────────────────────────────────── +const LIST = [ + // Base cards + def('copper', 0, 0, ['treasure'], { coin: 1, text: 'Worth 1 Coin.' }), + def('silver', 1, 3, ['treasure'], { coin: 2, text: 'Worth 2 Coins.' }), + def('gold', 2, 6, ['treasure'], { coin: 3, text: 'Worth 3 Coins.' }), + def('estate', 3, 2, ['victory'], { vp: 1, text: 'Worth 1 Victory Point.' }), + def('duchy', 4, 5, ['victory'], { vp: 3, text: 'Worth 3 Victory Points.' }), + def('province', 5, 8, ['victory'], { vp: 6, text: 'Worth 6 Victory Points.' }), + def('curse', 6, 0, ['curse'], { vp: -1, text: 'Worth -1 Victory Point.' }), + + // Kingdom cards (2nd-edition base set) + def('cellar', 7, 2, ['action'], { name: 'Cellar', plus: { actions: 1 }, text: '+1 Action. Discard any number of cards, then draw that many.' }), + def('chapel', 8, 2, ['action'], { name: 'Chapel', text: 'Trash up to 4 cards from your hand.' }), + def('moat', 9, 2, ['action', 'reaction'],{ name: 'Moat', plus: { cards: 2 }, text: '+2 Cards. When another player plays an Attack card, you may first reveal this from your hand, to be unaffected by it.' }), + def('harbinger', 10, 3, ['action'], { name: 'Harbinger', plus: { cards: 1, actions: 1 }, text: '+1 Card, +1 Action. Look through your discard pile. You may put a card from it onto your deck.' }), + def('merchant', 11, 3, ['action'], { name: 'Merchant', plus: { cards: 1, actions: 1 }, text: '+1 Card, +1 Action. The first time you play a Silver this turn, +1 Coin.' }), + def('vassal', 12, 3, ['action'], { name: 'Vassal', plus: { coins: 2 }, text: '+2 Coins. Discard the top card of your deck. If it is an Action card, you may play it.' }), + def('village', 13, 3, ['action'], { name: 'Village', plus: { cards: 1, actions: 2 }, text: '+1 Card, +2 Actions.' }), + def('workshop', 14, 3, ['action'], { name: 'Workshop', text: 'Gain a card costing up to 4 Coins.' }), + def('bureaucrat', 15, 4, ['action', 'attack'], { name: 'Bureaucrat', text: 'Gain a Silver onto your deck. Each other player reveals a Victory card from their hand and puts it onto their deck (or reveals a hand with no Victory cards).' }), + def('gardens', 16, 4, ['victory'], { name: 'Gardens', text: 'Worth 1 Victory Point per 10 cards you have (round down).' }), + def('militia', 17, 4, ['action', 'attack'], { name: 'Militia', plus: { coins: 2 }, text: '+2 Coins. Each other player discards down to 3 cards in hand.' }), + def('moneylender',18, 4, ['action'], { name: 'Moneylender', text: 'You may trash a Copper from your hand for +3 Coins.' }), + def('poacher', 19, 4, ['action'], { name: 'Poacher', plus: { cards: 1, actions: 1, coins: 1 }, text: '+1 Card, +1 Action, +1 Coin. Discard a card per empty Supply pile.' }), + def('remodel', 20, 4, ['action'], { name: 'Remodel', text: 'Trash a card from your hand. Gain a card costing up to 2 Coins more than it.' }), + def('smithy', 21, 4, ['action'], { name: 'Smithy', plus: { cards: 3 }, text: '+3 Cards.' }), + def('throneroom', 22, 4, ['action'], { name: 'Throne Room', text: 'You may play an Action card from your hand twice.' }), + def('bandit', 23, 5, ['action', 'attack'], { name: 'Bandit', text: 'Gain a Gold. Each other player reveals the top 2 cards of their deck, trashes a revealed Treasure other than Copper, and discards the rest.' }), + def('councilroom',24, 5, ['action'], { name: 'Council Room', plus: { cards: 4, buys: 1 }, text: '+4 Cards, +1 Buy. Each other player draws a card.' }), + def('festival', 25, 5, ['action'], { name: 'Festival', plus: { actions: 2, buys: 1, coins: 2 }, text: '+2 Actions, +1 Buy, +2 Coins.' }), + def('laboratory', 26, 5, ['action'], { name: 'Laboratory', plus: { cards: 2, actions: 1 }, text: '+2 Cards, +1 Action.' }), + def('library', 27, 5, ['action'], { name: 'Library', text: 'Draw until you have 7 cards in hand, skipping any Action cards you choose to; set those aside, discarding them afterwards.' }), + def('market', 28, 5, ['action'], { name: 'Market', plus: { cards: 1, actions: 1, buys: 1, coins: 1 }, text: '+1 Card, +1 Action, +1 Buy, +1 Coin.' }), + def('mine', 29, 5, ['action'], { name: 'Mine', text: 'You may trash a Treasure from your hand. Gain a Treasure to your hand costing up to 3 Coins more than it.' }), + def('sentry', 30, 5, ['action'], { name: 'Sentry', plus: { cards: 1, actions: 1 }, text: '+1 Card, +1 Action. Look at the top 2 cards of your deck. Trash and/or discard any number of them. Put the rest back on top in any order.' }), + def('witch', 31, 5, ['action', 'attack'], { name: 'Witch', plus: { cards: 2 }, text: '+2 Cards. Each other player gains a Curse.' }), + def('artisan', 32, 6, ['action'], { name: 'Artisan', plus: {}, text: 'Gain a card to your hand costing up to 5 Coins. Put a card from your hand onto your deck.' }), +]; + +export const CARDS = Object.fromEntries(LIST.map((c) => [c.id, c])); + +export function getCard(id) { + const c = CARDS[id]; + if (!c) throw new Error(`Unknown Dominion card: ${id}`); + return c; +} + +export const BASE_TREASURES = ['copper', 'silver', 'gold']; +export const BASE_VICTORY = ['estate', 'duchy', 'province']; + +// The 26 Kingdom cards, in frame order — the pool Random mode draws from. +export const KINGDOM_POOL = LIST.filter((c) => c.frame >= 7).map((c) => c.id); + +// Standard mode: the official "First Game" recommended Kingdom. +export const FIRST_GAME = [ + 'cellar', 'market', 'merchant', 'militia', 'mine', + 'moat', 'remodel', 'smithy', 'village', 'workshop', +]; + +export function hasType(card, type) { + return card.types.includes(type); +} + +export function isType(id, type) { + return getCard(id).types.includes(type); +} + +// Pick 10 distinct Kingdom ids from the pool using the supplied rng (0..1). +export function chooseRandomKingdom(rand) { + const pool = KINGDOM_POOL.slice(); + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + return pool.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost); +} + +export function kingdomFor(deckMode, rand) { + if (deckMode === 'random') return chooseRandomKingdom(rand); + return FIRST_GAME.slice().sort((a, b) => getCard(a).cost - getCard(b).cost); +} diff --git a/public/src/games/dominion/DominionGame.js b/public/src/games/dominion/DominionGame.js new file mode 100644 index 0000000..d763cfd --- /dev/null +++ b/public/src/games/dominion/DominionGame.js @@ -0,0 +1,940 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { auth } from '../../services/auth.js'; +import { api } from '../../services/api.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { getCard, isType } from './DominionCards.js'; +import { + createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures, + buyCard, endTurn, resolvePending, isGameOver, finalScores, + legalActionIids, canGain, emptyPileCount, +} from './DominionLogic.js'; +import * as AI from './DominionAI.js'; + +const CX = GAME_WIDTH / 2; + +const D = { bg: -1, supply: 5, inplay: 6, hand: 10, hud: 15, portrait: 20, prompt: 40, modal: 80, popup: 95 }; + +const SUPPLY_W = 100, SUPPLY_H = 144; +const HAND_W = 132, HAND_H = 190; +const PLAY_W = 78, PLAY_H = 112; + +const AI_STEP_MS = 420; +const AI_PENDING_MS = 520; + +// Card-face colour styling by type. +function typeStyle(def) { + if (isType(def.id, 'curse')) return { art: 0x6a3d8f, border: 0x9b59b6, band: 0x1c1226 }; + if (isType(def.id, 'treasure')) { + const art = def.id === 'gold' ? 0xd4af37 : def.id === 'silver' ? 0xb8bcc2 : 0xb87333; + return { art, border: 0xc8a84b, band: 0x231b0e }; + } + if (isType(def.id, 'victory')) return { art: 0x2e7d4f, border: 0x3fa86a, band: 0x10241a }; + if (isType(def.id, 'attack')) return { art: 0xbb9a6a, border: 0xd0563b, band: 0x231a12 }; + if (isType(def.id, 'reaction')) return { art: 0x8fb0cf, border: 0x4a90d9, band: 0x141d27 }; + return { art: 0xcdbb8f, border: 0xc8a84b, band: 0x231f17 }; // action +} + +function typeLine(def) { + return def.types.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(' – '); +} + +export default class DominionGame extends Phaser.Scene { + constructor() { super('DominionGame'); } + + init(data) { + this.gameDef = data.game; + this.opponents = data.opponents ?? []; + this.cardBack = data.cardBack ?? null; + this.playfield = data.playfield ?? null; + this.deckMode = data.deckMode ?? 'standard'; + this.playerCount = this.opponents.length + 1; + + this.gs = null; + this.gameOver = false; + this.handSprites = []; + this.supplySprites = []; + this.portraits = []; + this.hoverVisible = false; + this.hoverTimer = null; + this.promptObjs = []; + this.selection = new Set(); + } + + create() { + new MusicPlayer(this, this.cache.json.get('music').tracks); + this.buildBackground(); + this.buildPortraits(); + this.buildHoverPopup(); + this.buildButtons(); + + this.input.on('pointermove', (p) => { + this.lastPointer = { x: p.x, y: p.y }; + if (this.hoverVisible) this.positionHover(p.x, p.y); + }); + + this.events.once('shutdown', () => { + this.portraits.forEach((pt) => pt?.destroy?.()); + }); + + this.gs = createInitialState({ + seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0, + playerCount: this.playerCount, + deckMode: this.deckMode, + }); + this.render(); + this.scheduleAdvance(300); + } + + // ── Static scaffolding ───────────────────────────────────────────────────── + + buildBackground() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(CX, GAME_HEIGHT / 2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg); + } else { + this.add.image(CX, GAME_HEIGHT / 2, 'bg-room').setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg); + } + this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(D.bg); + } + + // Opponent panels live on the right column; the human sits bottom-left. + oppSlot(i) { + const ys = this.opponents.length === 2 ? [300, 600] : [180, 430, 680]; + return { x: 1770, y: ys[i] ?? 180, r: 52 }; + } + + buildPortraits() { + createPlayerPortrait(this, 92, 928, 56, D.portrait, 'DominionGame'); + this.add.text(92, 928 + 56 + 14, this.seatName(0), { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.hud); + + this.opponents.forEach((opp, i) => { + const s = this.oppSlot(i); + const pt = createOpponentPortrait(this, opp, s.x, s.y, s.r, D.portrait); + this.portraits[i + 1] = pt; + this.add.text(s.x, s.y + s.r + 14, opp.name ?? `Player ${i + 2}`, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.hud); + }); + } + + buildButtons() { + this.btnEndAction = new Button(this, 1640, 778, 'End Action Phase', () => this.humanEndAction(), { + width: 240, height: 48, fontSize: 20, + }).setDepth(D.hud).setVisible(false); + this.btnPlayTreasure = new Button(this, 1640, 778, 'Play Treasures', () => this.humanPlayTreasures(), { + width: 240, height: 48, fontSize: 20, bg: COLORS.panel, bgHover: COLORS.gold, + }).setDepth(D.hud).setVisible(false); + this.btnEndTurn = new Button(this, 1640, 836, 'End Turn', () => this.humanEndTurn(), { + width: 240, height: 48, fontSize: 20, bg: COLORS.accent, bgHover: COLORS.gold, + textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, + }).setDepth(D.hud).setVisible(false); + + new Button(this, 92, 1028, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 130, height: 40, fontSize: 18, + }).setDepth(D.hud); + } + + seatName(seat) { + if (seat === 0) return auth.user?.username ?? 'You'; + return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`; + } + + seatSkill(seat) { + return this.opponents[seat - 1]?.skill ?? 3; + } + + // ── Render (dynamic layer) ─────────────────────────────────────────────────── + + render() { + if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; } + this.hideHover(); + this.dynamicLayer?.destroy(true); + this.dynamicLayer = this.add.container(0, 0); + this.handSprites = []; + this.supplySprites = []; + + this.renderSupply(); + this.renderInPlay(); + this.renderHand(); + this.renderCounts(); + this.renderHud(); + this.updateControls(); + } + + renderSupply() { + const gs = this.gs; + const base = ['copper', 'silver', 'gold', 'estate', 'duchy', 'province', 'curse']; + this.layoutPileRow(base, 100); + const k = gs.kingdom; + this.layoutPileRow(k.slice(0, 5), 274); + this.layoutPileRow(k.slice(5, 10), 446); + + // Trash indicator + const tx = 1480, ty = 100; + const t = this.add.text(tx, ty, `Trash\n${gs.trash.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center', + }).setOrigin(0.5).setDepth(D.supply); + this.dynamicLayer.add(t); + } + + layoutPileRow(ids, y) { + const gap = 16; + const totalW = ids.length * SUPPLY_W + (ids.length - 1) * gap; + const startX = 760 - totalW / 2 + SUPPLY_W / 2; + ids.forEach((id, i) => { + const x = startX + i * (SUPPLY_W + gap); + this.renderPile(id, x, y); + }); + } + + renderPile(id, x, y) { + const gs = this.gs; + const count = gs.supply[id] ?? 0; + const def = getCard(id); + const face = this.buildCardFace(SUPPLY_W, SUPPLY_H, def, { dimmed: count <= 0 }); + face.setPosition(x, y).setDepth(D.supply); + this.dynamicLayer.add(face); + + // count badge + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.82); + g.fillCircle(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, 15); + g.lineStyle(2, COLORS.accent, 1); + g.strokeCircle(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, 15); + g.setDepth(D.supply + 1); + const ct = this.add.text(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, `${count}`, { + fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.supply + 1); + this.dynamicLayer.add(g); + this.dynamicLayer.add(ct); + + const hit = this.add.rectangle(x, y, SUPPLY_W, SUPPLY_H, 0x000000, 0).setDepth(D.supply + 2); + this.dynamicLayer.add(hit); + this.attachHover(hit, def); + this.supplySprites.push({ id, x, y, hit, face }); + + // Normal buy wiring (human buy phase, no pending). + if (!gs.pending && gs.turn === 0 && gs.phase === 'buy') { + const p = gs.players[0]; + const affordable = count > 0 && p.buys > 0 && p.coins >= def.cost; + if (affordable) { + hit.setInteractive({ useHandCursor: true }); + hit.on('pointerup', () => this.humanBuy(id)); + this.markBuyable(face); + } + } + } + + markBuyable(face) { + const glow = this.add.rectangle(0, 0, SUPPLY_W + 6, SUPPLY_H + 6, COLORS.gold, 0); + glow.setStrokeStyle(3, COLORS.gold, 0.95); + face.addAt(glow, 0); + } + + renderInPlay() { + const gs = this.gs; + const p = gs.players[gs.turn]; + const cards = p.inPlay; + if (cards.length === 0) return; + const gap = 8; + const totalW = Math.min(cards.length, 12) * (PLAY_W + gap); + const startX = CX - totalW / 2 + PLAY_W / 2; + cards.slice(0, 12).forEach((c, i) => { + const def = getCard(c.id); + const face = this.buildCardFace(PLAY_W, PLAY_H, def); + face.setPosition(startX + i * (PLAY_W + gap), 610).setDepth(D.inplay); + this.dynamicLayer.add(face); + const hit = this.add.rectangle(startX + i * (PLAY_W + gap), 610, PLAY_W, PLAY_H, 0x000000, 0) + .setDepth(D.inplay + 1); + this.dynamicLayer.add(hit); + this.attachHover(hit, def); + }); + const lbl = this.add.text(CX, 540, `${this.seatName(gs.turn)} — in play`, { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.inplay); + this.dynamicLayer.add(lbl); + } + + renderHand() { + const gs = this.gs; + const hand = gs.players[0].hand; + const gap = Math.min(18, (1300 - hand.length * HAND_W) / Math.max(1, hand.length - 1)); + const step = HAND_W + Math.max(-HAND_W * 0.45, gap); + const totalW = (hand.length - 1) * step + HAND_W; + const startX = CX - totalW / 2 + HAND_W / 2; + const baseY = 968; + + const canPlayAction = !gs.pending && gs.turn === 0 && gs.phase === 'action' && gs.players[0].actions > 0; + const canPlayTreasure = !gs.pending && gs.turn === 0 && gs.phase === 'buy'; + const legalAct = new Set(legalActionIids(gs)); + + hand.forEach((c, i) => { + const def = getCard(c.id); + const x = startX + i * step; + const face = this.buildCardFace(HAND_W, HAND_H, def); + face.setPosition(x, baseY).setDepth(D.hand + i); + this.dynamicLayer.add(face); + const hit = this.add.rectangle(x, baseY, HAND_W, HAND_H, 0x000000, 0).setDepth(D.hand + i + 1); + this.dynamicLayer.add(hit); + this.attachHover(hit, def); + this.handSprites.push({ iid: c.iid, id: c.id, def, x, baseY, face, hit }); + + if (canPlayAction && legalAct.has(c.iid)) { + hit.setInteractive({ useHandCursor: true }); + hit.on('pointerup', () => this.humanPlayAction(c.iid)); + this.highlightFace(face, COLORS.accent); + } else if (canPlayTreasure && isType(c.id, 'treasure')) { + hit.setInteractive({ useHandCursor: true }); + hit.on('pointerup', () => this.humanPlayTreasure(c.iid)); + this.highlightFace(face, COLORS.gold); + } + }); + } + + highlightFace(face, color) { + const glow = this.add.rectangle(0, 0, HAND_W + 6, HAND_H + 6, color, 0); + glow.setStrokeStyle(3, color, 0.9); + face.addAt(glow, 0); + } + + renderCounts() { + const gs = this.gs; + // Human deck/discard + this.dynamicLayer.add(this.add.text(1380, 968, `Deck\n${gs.players[0].deck.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, align: 'center', + }).setOrigin(0.5).setDepth(D.hand)); + this.dynamicLayer.add(this.add.text(1500, 968, `Discard\n${gs.players[0].discard.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center', + }).setOrigin(0.5).setDepth(D.hand)); + + this.opponents.forEach((opp, i) => { + const seat = i + 1; + const p = gs.players[seat]; + const s = this.oppSlot(i); + const active = gs.turn === seat; + const txt = `Hand ${p.hand.length} Deck ${p.deck.length} Disc ${p.discard.length}`; + this.dynamicLayer.add(this.add.text(s.x, s.y + s.r + 38, txt, { + fontFamily: '"Julius Sans One"', fontSize: '15px', + color: active ? COLORS.goldHex : COLORS.mutedHex, align: 'center', + backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 8, y: 4 }, + }).setOrigin(0.5).setDepth(D.hud)); + }); + } + + renderHud() { + const gs = this.gs; + const p = gs.players[gs.turn]; + const turnName = this.seatName(gs.turn); + const phaseLbl = gs.phase === 'action' ? 'Action Phase' : gs.phase === 'buy' ? 'Buy Phase' : ''; + + this.dynamicLayer.add(this.add.text(CX, 700, `${turnName}'s turn — ${phaseLbl}`, { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.hud)); + + const me = gs.players[gs.turn]; + const stat = `Actions ${me.actions} Buys ${me.buys} Coins ${me.coins}`; + this.dynamicLayer.add(this.add.text(CX, 742, stat, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + backgroundColor: 'rgba(0,0,0,0.5)', padding: { x: 14, y: 6 }, + }).setOrigin(0.5).setDepth(D.hud)); + + const provLeft = gs.supply.province ?? 0; + this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft} Empty piles: ${emptyPileCount(gs)}/3`, { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.hud)); + } + + updateControls() { + const gs = this.gs; + const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver; + const action = humanTurn && gs.phase === 'action'; + const buy = humanTurn && gs.phase === 'buy'; + const hasTreasure = gs.players[0].hand.some((c) => isType(c.id, 'treasure')); + this.btnEndAction.setVisible(action); + this.btnPlayTreasure.setVisible(buy && hasTreasure); + this.btnEndTurn.setVisible(buy); + } + + // ── Card face builder ─────────────────────────────────────────────────────── + + buildCardFace(w, h, def, { faceDown = false, dimmed = false } = {}) { + const c = this.add.container(0, 0); + const style = typeStyle(def); + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.35); g.fillRoundedRect(-w / 2 + 2, -h / 2 + 3, w, h, 8); + g.fillStyle(0x14110b, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 8); + g.lineStyle(2, style.border, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 8); + c.add(g); + + if (faceDown) { + if (this.textures.exists('cardbacks')) { + c.add(this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(w - 6, h - 6)); + } else { + c.add(this.add.rectangle(0, 0, w - 8, h - 8, 0x2a2a40)); + } + if (dimmed) c.add(this.add.rectangle(0, 0, w, h, 0x000000, 0.5)); + return c; + } + + const artH = h * 0.58; + const artTop = -h / 2; + const bandTop = artTop + artH; + const fsTitle = Phaser.Math.Clamp(Math.round(h * 0.085), 9, 20); + const fsType = Phaser.Math.Clamp(Math.round(h * 0.05), 7, 12); + + if (this.textures.exists('dominion-cards')) { + c.add(this.add.image(0, 0, 'dominion-cards', def.frame).setDisplaySize(w - 6, h - 6)); + // legibility band over the lower portion + c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.9)); + } else { + c.add(this.add.rectangle(0, artTop + artH / 2, w - 8, artH - 4, style.art)); + c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.95)); + // placeholder: name in the art area for identification + c.add(this.add.text(0, artTop + artH / 2, def.name, { + fontFamily: 'Righteous', fontSize: `${fsTitle}px`, color: '#1a1208', + align: 'center', wordWrap: { width: w - 16 }, + }).setOrigin(0.5)); + } + + // Title in the band + c.add(this.add.text(0, bandTop + fsTitle * 0.9, def.name, { + fontFamily: 'Righteous', fontSize: `${fsTitle}px`, color: COLORS.textHex, + align: 'center', wordWrap: { width: w - 12 }, + }).setOrigin(0.5, 0.5)); + + // Type line (small) + c.add(this.add.text(0, h / 2 - fsType * 1.2, typeLine(def), { + fontFamily: '"Julius Sans One"', fontSize: `${fsType}px`, color: COLORS.mutedHex, + }).setOrigin(0.5)); + + // Icon row (between title and type line) + this.addIconRow(c, def, 0, bandTop + (h / 2 - bandTop) * 0.55, h); + + // Cost coin (top-left) + this.addCoinBadge(c, -w / 2 + 14, -h / 2 + 14, def.cost, Math.round(h * 0.075)); + + // VP badge (top-right) for victory cards + if (isType(def.id, 'victory')) { + const vpLabel = def.id === 'gardens' ? '★' : `${def.vp}`; + this.addVpBadge(c, w / 2 - 14, -h / 2 + 14, vpLabel, Math.round(h * 0.075)); + } + + if (dimmed) c.add(this.add.rectangle(0, 0, w, h, 0x000000, 0.5)); + return c; + } + + addIconRow(container, def, cx, cy, h) { + const tokens = []; + if (def.plus.cards) tokens.push(['cards', def.plus.cards, 0x2f6fb0]); + if (def.plus.actions) tokens.push(['action', def.plus.actions, 0x3f9b54]); + if (def.plus.buys) tokens.push(['buy', def.plus.buys, 0x8a5fb0]); + if (def.plus.coins) tokens.push(['$', def.plus.coins, 0xd4a017]); + if (def.coin !== undefined) tokens.push(['$', def.coin, 0xd4a017]); // treasure value + if (tokens.length === 0) return; + + const fs = Phaser.Math.Clamp(Math.round(h * 0.06), 8, 13); + const tw = fs * 4.2; + const gap = 4; + const totalW = tokens.length * tw + (tokens.length - 1) * gap; + let x = cx - totalW / 2 + tw / 2; + for (const [kind, val, color] of tokens) { + const pill = this.add.rectangle(x, cy, tw, fs * 1.7, color, 0.95); + pill.setStrokeStyle(1, 0x000000, 0.4); + container.add(pill); + const label = kind === '$' ? `+${val}$` + : kind === 'cards' ? `+${val}🂠` + : kind === 'action' ? `+${val}A` + : `+${val}B`; + container.add(this.add.text(x, cy, label, { + fontFamily: 'Righteous', fontSize: `${fs}px`, color: '#ffffff', + }).setOrigin(0.5)); + x += tw + gap; + } + } + + addCoinBadge(container, x, y, value, r) { + const g = this.add.graphics(); + g.fillStyle(0xd4a017, 1); g.fillCircle(x, y, r); + g.lineStyle(1.5, 0x6b5310, 1); g.strokeCircle(x, y, r); + container.add(g); + container.add(this.add.text(x, y, `${value}`, { + fontFamily: 'Righteous', fontSize: `${Math.round(r * 1.2)}px`, color: '#1a1208', + }).setOrigin(0.5)); + } + + addVpBadge(container, x, y, label, r) { + const g = this.add.graphics(); + g.fillStyle(0x2e7d4f, 1); g.fillCircle(x, y, r); + g.lineStyle(1.5, 0x14241a, 1); g.strokeCircle(x, y, r); + container.add(g); + container.add(this.add.text(x, y, `${label}`, { + fontFamily: 'Righteous', fontSize: `${Math.round(r * 1.1)}px`, color: '#ffffff', + }).setOrigin(0.5)); + } + + // ── Hover popup ─────────────────────────────────────────────────────────────── + + buildHoverPopup() { + this.hoverPopup = this.add.container(-9999, -9999).setDepth(D.popup).setVisible(false); + } + + attachHover(hitObj, def) { + hitObj.setInteractive({ useHandCursor: hitObj.input?.cursor === 'pointer' }); + hitObj.on('pointerover', () => { + if (this.hoverTimer) this.hoverTimer.remove(); + this.hoverTimer = this.time.delayedCall(500, () => this.showHover(def)); + }); + hitObj.on('pointerout', () => { + if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; } + this.hideHover(); + }); + } + + showHover(def) { + this.hoverPopup.removeAll(true); + const w = 360; + const pad = 16; + const title = this.add.text(0, 0, def.name, { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex, + }).setOrigin(0.5, 0); + const sub = this.add.text(0, 0, `${typeLine(def)} • Cost ${def.cost}`, { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5, 0); + const body = this.add.text(0, 0, def.text || '—', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + align: 'center', wordWrap: { width: w - pad * 2 }, + }).setOrigin(0.5, 0); + + title.setY(0); + sub.setY(title.height + 6); + body.setY(sub.y + sub.height + 12); + const h = body.y + body.height + pad; + + const g = this.add.graphics(); + g.fillStyle(0x0d1117, 0.97); g.fillRoundedRect(-w / 2, -pad, w, h + pad, 12); + g.lineStyle(2, COLORS.accent, 0.95); g.strokeRoundedRect(-w / 2, -pad, w, h + pad, 12); + + this.hoverPopup.add([g, title, sub, body]); + this.hoverPopup.setData('w', w); + this.hoverPopup.setData('h', h + pad); + this.hoverVisible = true; + this.hoverPopup.setVisible(true); + const p = this.lastPointer ?? { x: CX, y: GAME_HEIGHT / 2 }; + this.positionHover(p.x, p.y); + } + + positionHover(px, py) { + const w = this.hoverPopup.getData('w') ?? 360; + const h = this.hoverPopup.getData('h') ?? 200; + const x = Phaser.Math.Clamp(px + w / 2 + 24, w / 2 + 8, GAME_WIDTH - w / 2 - 8); + const y = Phaser.Math.Clamp(py, h / 2 + 8, GAME_HEIGHT - h / 2 - 8); + this.hoverPopup.setPosition(x, y); + } + + hideHover() { + this.hoverVisible = false; + this.hoverPopup.setVisible(false).setPosition(-9999, -9999); + } + + // ── Turn driver ─────────────────────────────────────────────────────────────── + + setState(s) { + this.gs = s; + this.clearPrompt(); + this.render(); + this.scheduleAdvance(10); + } + + scheduleAdvance(ms) { + this.time.delayedCall(ms, () => this.advance()); + } + + advance() { + if (this.gameOver) return; + const gs = this.gs; + if (isGameOver(gs)) { this.onGameOver(); return; } + + if (gs.pending) { + if (gs.pending.seat === 0) { + this.promptHuman(gs.pending); + } else { + const skill = this.seatSkill(gs.pending.seat); + this.time.delayedCall(AI_PENDING_MS, () => { + if (this.gameOver) return; + const choice = AI.resolvePending(this.gs, skill); + this.setState(resolvePending(this.gs, choice)); + }); + } + return; + } + + if (gs.turn === 0) { + // Human turn — wait for button / card clicks. + this.updateControls(); + return; + } + + // AI turn. + this.time.delayedCall(AI_STEP_MS, () => this.aiStep()); + } + + aiStep() { + if (this.gameOver) return; + const gs = this.gs; + if (gs.pending || gs.turn === 0) { this.advance(); return; } + const seat = gs.turn; + const skill = this.seatSkill(seat); + + if (gs.phase === 'action') { + const iid = AI.chooseAction(gs, seat); + if (iid != null) { + playSound(this, SFX.CARD_PLACE); + this.setState(playAction(gs, iid)); + } else { + this.setState(endActionPhase(gs)); + } + return; + } + + if (gs.phase === 'buy') { + if (gs.players[seat].hand.some((c) => isType(c.id, 'treasure'))) { + this.setState(playAllTreasures(gs)); + return; + } + const buy = AI.chooseBuy(gs, seat, skill); + if (buy) { + playSound(this, SFX.CARD_SHOW); + if (buy === 'province') this.portraits[seat]?.playEmotion?.('happy'); + this.setState(buyCard(gs, buy)); + } else { + this.setState(endTurn(gs)); + } + return; + } + } + + // ── Human actions (normal flow) ──────────────────────────────────────────── + + humanPlayAction(iid) { + if (this.gs.pending || this.gs.turn !== 0) return; + playSound(this, SFX.CARD_PLACE); + this.setState(playAction(this.gs, iid)); + } + + humanPlayTreasure(iid) { + if (this.gs.pending || this.gs.turn !== 0) return; + this.setState(playTreasure(this.gs, iid)); + } + + humanPlayTreasures() { + if (this.gs.pending || this.gs.turn !== 0) return; + playSound(this, SFX.CARD_PLACE); + this.setState(playAllTreasures(this.gs)); + } + + humanBuy(id) { + if (this.gs.pending || this.gs.turn !== 0) return; + playSound(this, SFX.CARD_SHOW); + this.setState(buyCard(this.gs, id)); + } + + humanEndAction() { + if (this.gs.pending || this.gs.turn !== 0) return; + this.setState(endActionPhase(this.gs)); + } + + humanEndTurn() { + if (this.gs.pending || this.gs.turn !== 0) return; + this.setState(endTurn(this.gs)); + } + + // ── Human decision prompts ────────────────────────────────────────────────── + + clearPrompt() { + this.promptObjs.forEach((o) => o.destroy?.()); + this.promptObjs = []; + this.selection.clear(); + } + + resolveHuman(choice) { + this.clearPrompt(); + this.setState(resolvePending(this.gs, choice)); + } + + promptBanner(text) { + const t = this.add.text(CX, 500, text, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'center', + backgroundColor: 'rgba(0,0,0,0.78)', padding: { x: 18, y: 10 }, wordWrap: { width: 1000 }, + }).setOrigin(0.5).setDepth(D.prompt); + this.promptObjs.push(t); + return t; + } + + promptButton(x, label, cb, opts = {}) { + const b = new Button(this, x, 560, label, cb, { width: 200, height: 46, fontSize: 20, ...opts }) + .setDepth(D.prompt); + this.promptObjs.push(b); + return b; + } + + promptHuman(pend) { + switch (pend.kind) { + case 'cellarDiscard': + return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Discard any number of cards, then draw that many.', confirm: 'Discard & Draw' }); + case 'chapelTrash': + return this.promptMultiHand(pend, { min: 0, max: pend.max, banner: `Trash up to ${pend.max} cards from your hand.`, confirm: 'Trash' }); + case 'poacherDiscard': + return this.promptMultiHand(pend, { min: pend.count, max: pend.count, banner: `Discard ${pend.count} card(s) — one per empty Supply pile.`, confirm: 'Discard' }); + case 'discardDownTo': + return this.promptMultiHand(pend, { min: pend.count, max: pend.count, banner: `Militia: discard down to 3 (choose ${pend.count}).`, confirm: 'Discard' }); + case 'remodelTrash': + return this.promptPickHand(pend, { filter: () => true, banner: 'Remodel: trash a card from your hand.', allowSkip: false }); + case 'mineTrash': + return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Mine: trash a Treasure (or skip).', allowSkip: true }); + case 'throneChoose': + return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'action'), banner: 'Throne Room: choose an Action to play twice (or skip).', allowSkip: true }); + case 'artisanTopdeck': + return this.promptPickHand(pend, { filter: () => true, banner: 'Artisan: put a card from your hand onto your deck.', allowSkip: false, key: 'iid' }); + case 'gainFromSupply': + return this.promptGain(pend); + case 'moneylenderTrash': + return this.promptYesNo('Moneylender: trash a Copper for +3 Coins?', (yes) => this.resolveHuman({ confirm: yes })); + case 'vassalPlay': + return this.promptYesNo(`Vassal revealed ${getCard(pend.cardId).name}. Play it?`, (yes) => this.resolveHuman({ play: yes })); + case 'libraryKeep': + return this.promptYesNo(`Library drew ${getCard(pend.cardId).name}. Keep it? (No sets it aside.)`, (yes) => this.resolveHuman({ keep: yes })); + case 'harbingerTopdeck': + return this.promptPickList(this.gs.players[0].discard, 'Harbinger: put a card from your discard onto your deck (or skip).', true, (iid) => this.resolveHuman({ iid })); + case 'banditTrash': + return this.promptPickList(pend.options, 'Bandit: choose a Treasure to trash.', false, (iid) => this.resolveHuman({ iid })); + case 'bureaucratTopdeck': + return this.promptPickList(pend.options, 'Bureaucrat: choose a Victory card to put on your deck.', false, (iid) => this.resolveHuman({ iid })); + case 'moatReveal': + return this.promptYesNo('You are under attack! Reveal Moat to block it?', (yes) => this.resolveHuman({ reveal: yes })); + case 'sentry': + return this.promptSentry(pend); + default: + return this.resolveHuman({}); + } + } + + // Multi-select over the hand (discard/trash N). + promptMultiHand(pend, { min, max, banner, confirm }) { + this.promptBanner(banner); + const update = () => { + const n = this.selection.size; + confirmBtn.setEnabled(n >= min && n <= max); + confirmBtn.setLabel(`${confirm} (${n})`); + }; + const confirmBtn = this.promptButton(CX, () => { + this.resolveHuman({ iids: [...this.selection] }); + }, () => {}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex }); + + for (const hs of this.handSprites) { + hs.hit.setInteractive({ useHandCursor: true }); + hs.hit.removeAllListeners('pointerup'); + hs.hit.on('pointerup', () => { + if (this.selection.has(hs.iid)) { + this.selection.delete(hs.iid); + hs.face.setY(hs.baseY); + } else { + if (this.selection.size >= max) return; + this.selection.add(hs.iid); + hs.face.setY(hs.baseY - 28); + } + update(); + }); + } + update(); + } + + // Single pick from the hand (immediate resolve). key = which choice field. + promptPickHand(pend, { filter, banner, allowSkip, key = 'iid' }) { + this.promptBanner(banner); + if (allowSkip) { + this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null }), { variant: 'ghost' }); + } + for (const hs of this.handSprites) { + const ok = filter(hs); + if (!ok) continue; + hs.hit.setInteractive({ useHandCursor: true }); + hs.hit.removeAllListeners('pointerup'); + this.highlightFace(hs.face, COLORS.danger); + hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid })); + } + } + + // Gain a card from the Supply (highlight eligible piles). + promptGain(pend) { + const treasureNote = pend.filterTreasure ? ' Treasure' : ''; + this.promptBanner(`Gain a${treasureNote} card costing up to ${pend.maxCost}.`); + let any = false; + for (const sp of this.supplySprites) { + if (!canGain(this.gs, sp.id, pend.maxCost, pend.filterTreasure)) continue; + any = true; + sp.hit.setInteractive({ useHandCursor: true }); + sp.hit.removeAllListeners('pointerup'); + const glow = this.add.rectangle(sp.x, sp.y, SUPPLY_W + 8, SUPPLY_H + 8, COLORS.gold, 0) + .setStrokeStyle(4, COLORS.gold, 0.95).setDepth(D.supply + 3); + this.promptObjs.push(glow); + sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id })); + } + if (!any) this.resolveHuman({ id: null }); + } + + promptYesNo(banner, cb) { + this.promptBanner(banner); + this.promptButton(CX - 120, 'Yes', () => cb(true), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex }); + this.promptButton(CX + 120, 'No', () => cb(false), { variant: 'ghost' }); + } + + // Modal list of cards to pick one (harbinger discard, bandit/bureaucrat options). + promptPickList(cards, banner, allowSkip, cb) { + const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) + .setDepth(D.modal).setInteractive(); + this.promptObjs.push(overlay); + this.promptObjs.push(this.add.text(CX, 360, banner, { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center', + wordWrap: { width: 1100 }, + }).setOrigin(0.5).setDepth(D.modal + 1)); + + const uniq = []; + const seen = new Set(); + for (const c of cards) { if (!seen.has(c.iid)) { seen.add(c.iid); uniq.push(c); } } + + const gap = 18; + const totalW = Math.min(uniq.length, 8) * (HAND_W + gap); + const startX = CX - totalW / 2 + (HAND_W + gap) / 2; + uniq.slice(0, 8).forEach((c, i) => { + const def = getCard(c.id); + const face = this.buildCardFace(HAND_W, HAND_H, def).setPosition(startX + i * (HAND_W + gap), 560).setDepth(D.modal + 1); + this.promptObjs.push(face); + const hit = this.add.rectangle(startX + i * (HAND_W + gap), 560, HAND_W, HAND_H, 0x000000, 0) + .setDepth(D.modal + 2).setInteractive({ useHandCursor: true }); + this.promptObjs.push(hit); + this.attachHover(hit, def); + hit.on('pointerup', () => cb(c.iid)); + }); + + if (allowSkip) { + this.promptObjs.push(new Button(this, CX, 760, 'Skip', () => cb(null), { variant: 'ghost', width: 180 }).setDepth(D.modal + 2)); + } + } + + // Sentry modal: per-card Trash / Discard / Keep over the top 2 cards. + promptSentry(pend) { + const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) + .setDepth(D.modal).setInteractive(); + this.promptObjs.push(overlay); + this.promptObjs.push(this.add.text(CX, 320, 'Sentry: choose what to do with the top 2 cards of your deck.', { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center', wordWrap: { width: 1100 }, + }).setOrigin(0.5).setDepth(D.modal + 1)); + + const cards = pend.cards ?? []; + const decisions = new Map(cards.map((c) => [c.iid, 'top'])); // default keep on top + const gap = 80; + const totalW = cards.length * (HAND_W + gap); + const startX = CX - totalW / 2 + (HAND_W + gap) / 2; + const labels = new Map(); + + cards.forEach((c, i) => { + const def = getCard(c.id); + const x = startX + i * (HAND_W + gap); + const face = this.buildCardFace(HAND_W, HAND_H, def).setPosition(x, 540).setDepth(D.modal + 1); + this.promptObjs.push(face); + const hit = this.add.rectangle(x, 540, HAND_W, HAND_H, 0x000000, 0).setDepth(D.modal + 2); + this.promptObjs.push(hit); + this.attachHover(hit, def); + + const lbl = this.add.text(x, 540 + HAND_H / 2 + 24, 'Keep on top', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.modal + 2); + this.promptObjs.push(lbl); + labels.set(c.iid, lbl); + + const cycle = ['top', 'discard', 'trash']; + const text = { top: 'Keep on top', discard: 'Discard', trash: 'Trash' }; + const colors = { top: COLORS.goldHex, discard: COLORS.mutedHex, trash: COLORS.dangerHex }; + hit.setInteractive({ useHandCursor: true }); + hit.on('pointerup', () => { + const cur = decisions.get(c.iid); + const nxt = cycle[(cycle.indexOf(cur) + 1) % cycle.length]; + decisions.set(c.iid, nxt); + lbl.setText(text[nxt]).setColor(colors[nxt]); + }); + }); + + this.promptObjs.push(new Button(this, CX, 800, 'Confirm', () => { + const trash = [], discard = [], top = []; + for (const c of cards) { + const d = decisions.get(c.iid); + if (d === 'trash') trash.push(c.iid); + else if (d === 'discard') discard.push(c.iid); + else top.push(c.iid); + } + this.resolveHuman({ trash, discard, top }); + }, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, width: 200 }).setDepth(D.modal + 2)); + } + + // ── Game over ─────────────────────────────────────────────────────────────── + + onGameOver() { + if (this.gameOver) return; + this.gameOver = true; + this.clearPrompt(); + this.hideHover(); + this.updateControls(); + + const scores = finalScores(this.gs); + const winners = new Set(this.gs.winnerSeats); + const youWon = winners.has(0); + playSound(this, youWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE); + + this.recordHistory(scores); + + const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7) + .setDepth(D.modal).setInteractive(); + const ordered = scores.slice().sort((a, b) => b.vp - a.vp); + const lines = ordered.map((s) => `${this.seatName(s.seat)}: ${s.vp} VP${winners.has(s.seat) ? ' ★' : ''}`); + const h = 220 + lines.length * 44; + const box = this.add.rectangle(CX, GAME_HEIGHT / 2, 660, h, COLORS.panel, 1) + .setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1); + const title = youWon ? (winners.size > 1 ? 'Tie game!' : 'You win!') : `${this.seatName(ordered[0].seat)} wins`; + this.add.text(CX, GAME_HEIGHT / 2 - h / 2 + 48, title, { + fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.modal + 2); + lines.forEach((line, i) => { + this.add.text(CX, GAME_HEIGHT / 2 - h / 2 + 116 + i * 44, line, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.modal + 2); + }); + + new Button(this, CX - 150, GAME_HEIGHT / 2 + h / 2 - 44, 'New game', () => this.scene.restart(), { + width: 260, bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, + }).setDepth(D.modal + 2); + new Button(this, CX + 150, GAME_HEIGHT / 2 + h / 2 - 44, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 260, + }).setDepth(D.modal + 2); + } + + async recordHistory(scores) { + const mine = scores.find((s) => s.seat === 0)?.vp ?? 0; + const others = scores.filter((s) => s.seat !== 0).map((s) => s.vp); + const winners = new Set(this.gs.winnerSeats); + let result; + if (winners.has(0) && winners.size === 1) result = 'win'; + else if (winners.has(0)) result = 'draw'; + else result = 'loss'; + try { + await api.post('/history/single-player', { + slug: 'dominion', score: mine, opponentScores: others, result, + }); + } catch (_) { /* offline / not signed in */ } + } +} diff --git a/public/src/games/dominion/DominionLogic.js b/public/src/games/dominion/DominionLogic.js new file mode 100644 index 0000000..e0ab2ce --- /dev/null +++ b/public/src/games/dominion/DominionLogic.js @@ -0,0 +1,864 @@ +// Dominion — pure interactive state engine. No Phaser imports. +// +// Deck-building card game. Each turn: Action phase (play action cards) → +// Buy phase (play treasures, buy cards) → Cleanup (discard everything, draw 5). +// Game ends when the Province pile empties OR any 3 Supply piles empty. +// +// Card effects vary wildly, and many require a player decision (discard, +// trash, gain, reveal, …). We model this with two channels carried in the +// state: +// • `queue` — a stack of effect "tasks" (plain data) processed depth-first. +// • `pending` — the single decision a specific player must make right now. +// `null` when nothing is awaited. +// The driver (the scene for the human, the AI for opponents) resolves a +// pending via `resolvePending`, which applies the choice and resumes the queue. + +import { + getCard, CARDS, BASE_TREASURES, + kingdomFor, isType, +} from './DominionCards.js'; + +// Mulberry32 — seedable PRNG (mirrors the other games). +function rng(seed) { + let a = (seed >>> 0) || 1; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function shuffleInPlace(arr, rand) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } +} + +const STARTING_COPPER = 7; +const STARTING_ESTATE = 3; +const HAND_SIZE = 5; + +// ── State construction ────────────────────────────────────────────────────── + +export function createInitialState({ seed, playerCount = 4, deckMode = 'standard' } = {}) { + const rand = seed === undefined ? Math.random : rng(seed); + const kingdom = kingdomFor(deckMode, rand); + + const state = { + playerCount, + players: [], + supply: {}, + kingdom, + trash: [], + turn: 0, + phase: 'action', + pending: null, + queue: [], + turnsTaken: Array(playerCount).fill(0), + log: [], + winnerSeats: [], + seed: seed ?? null, + deckMode, + _rand: rand, + _nextIid: 1, + // transient per-effect scratch + sentryLook: null, + libraryAside: null, + }; + + const victoryPile = playerCount <= 2 ? 8 : 12; + + // Base supply. + state.supply.copper = 60 - STARTING_COPPER * playerCount; + state.supply.silver = 40; + state.supply.gold = 30; + state.supply.estate = victoryPile; + state.supply.duchy = victoryPile; + state.supply.province = victoryPile; + state.supply.curse = 10 * (playerCount - 1); + + // Kingdom supply (Victory-type Kingdom cards, e.g. Gardens, use the Victory count). + for (const id of kingdom) { + state.supply[id] = isType(id, 'victory') ? victoryPile : 10; + } + + // Players + starting decks (7 Copper, 3 Estate), shuffled, draw 5. + for (let seat = 0; seat < playerCount; seat++) { + const p = { + seat, + deck: [], hand: [], discard: [], inPlay: [], + actions: 0, buys: 0, coins: 0, + merchantSilverBonus: 0, + firstSilverPlayed: false, + }; + for (let i = 0; i < STARTING_COPPER; i++) p.deck.push(mint(state, 'copper')); + for (let i = 0; i < STARTING_ESTATE; i++) p.deck.push(mint(state, 'estate')); + shuffleInPlace(p.deck, rand); + state.players.push(p); + drawInto(state, p, HAND_SIZE); + } + + startTurn(state); + return state; +} + +function mint(state, id) { + return { iid: state._nextIid++, id }; +} + +// ── Cloning ─────────────────────────────────────────────────────────────────── + +function cloneInsts(arr) { + return arr.map((c) => ({ iid: c.iid, id: c.id })); +} + +export function cloneState(state) { + const out = { + playerCount: state.playerCount, + players: state.players.map((p) => ({ + seat: p.seat, + deck: cloneInsts(p.deck), + hand: cloneInsts(p.hand), + discard: cloneInsts(p.discard), + inPlay: cloneInsts(p.inPlay), + actions: p.actions, + buys: p.buys, + coins: p.coins, + merchantSilverBonus: p.merchantSilverBonus, + firstSilverPlayed: p.firstSilverPlayed, + })), + supply: { ...state.supply }, + kingdom: state.kingdom.slice(), + trash: cloneInsts(state.trash), + turn: state.turn, + phase: state.phase, + pending: state.pending ? JSON.parse(JSON.stringify(state.pending)) : null, + queue: state.queue.map((t) => JSON.parse(JSON.stringify(t))), + turnsTaken: state.turnsTaken.slice(), + log: state.log.map((e) => ({ ...e })), + winnerSeats: state.winnerSeats.slice(), + seed: state.seed, + deckMode: state.deckMode, + _rand: state._rand, + _nextIid: state._nextIid, + sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null, + libraryAside: state.libraryAside ? cloneInsts(state.libraryAside) : null, + }; + return out; +} + +// ── Card movement helpers ───────────────────────────────────────────────────── + +function reshuffle(state, p) { + if (p.deck.length > 0 || p.discard.length === 0) return; + p.deck = p.discard; + p.discard = []; + shuffleInPlace(p.deck, state._rand); + state.log.push({ kind: 'shuffle', seat: p.seat }); +} + +// Draw `n` from the top (index 0) of the deck into the hand. +function drawInto(state, p, n) { + let drawn = 0; + for (let i = 0; i < n; i++) { + if (p.deck.length === 0) reshuffle(state, p); + if (p.deck.length === 0) break; + p.hand.push(p.deck.shift()); + drawn++; + } + if (drawn > 0) state.log.push({ kind: 'draw', seat: p.seat, n: drawn }); + return drawn; +} + +function gain(state, seat, id, dest = 'discard') { + if ((state.supply[id] ?? 0) <= 0) return null; + state.supply[id] -= 1; + const inst = mint(state, id); + const p = state.players[seat]; + if (dest === 'deck') p.deck.unshift(inst); + else if (dest === 'hand') p.hand.push(inst); + else p.discard.push(inst); + state.log.push({ kind: 'gain', seat, id, dest }); + return inst; +} + +function trashFrom(state, p, iid) { + const idx = p.hand.findIndex((c) => c.iid === iid); + if (idx === -1) return null; + const [c] = p.hand.splice(idx, 1); + state.trash.push(c); + state.log.push({ kind: 'trash', seat: p.seat, id: c.id }); + return c; +} + +function discardFromHand(state, p, iid) { + const idx = p.hand.findIndex((c) => c.iid === iid); + if (idx === -1) return null; + const [c] = p.hand.splice(idx, 1); + p.discard.push(c); + return c; +} + +// ── Turn lifecycle ────────────────────────────────────────────────────────── + +function startTurn(state) { + const p = state.players[state.turn]; + p.actions = 1; + p.buys = 1; + p.coins = 0; + p.merchantSilverBonus = 0; + p.firstSilverPlayed = false; + state.phase = 'action'; + state.pending = null; + state.queue = []; + state.turnsTaken[state.turn] += 1; + state.log.push({ kind: 'turnStart', seat: state.turn }); +} + +export function endActionPhase(state) { + if (state.phase !== 'action' || state.pending) return state; + const next = cloneState(state); + next.phase = 'buy'; + next.log.push({ kind: 'phase', phase: 'buy', seat: next.turn }); + return next; +} + +export function endTurn(state) { + if (state.pending) return state; + if (state.phase === 'gameOver') return state; + const next = cloneState(state); + const p = next.players[next.turn]; + + // Cleanup: discard hand + in-play, draw a fresh hand. + p.discard.push(...p.inPlay, ...p.hand); + p.inPlay = []; + p.hand = []; + drawInto(next, p, HAND_SIZE); + + next.log.push({ kind: 'turnEnd', seat: next.turn }); + + if (checkGameOver(next)) return next; + + next.turn = (next.turn + 1) % next.playerCount; + startTurn(next); + return next; +} + +function checkGameOver(state) { + const provinceEmpty = (state.supply.province ?? 0) <= 0; + const emptyPiles = Object.values(state.supply).filter((c) => c <= 0).length; + if (provinceEmpty || emptyPiles >= 3) { + state.phase = 'gameOver'; + state.pending = null; + state.queue = []; + const scores = finalScores(state); + const best = Math.max(...scores.map((s) => s.vp)); + // Tie-break: among top VP, fewest turns taken wins; still tied = shared. + const topVp = scores.filter((s) => s.vp === best); + const fewest = Math.min(...topVp.map((s) => state.turnsTaken[s.seat])); + state.winnerSeats = topVp.filter((s) => state.turnsTaken[s.seat] === fewest).map((s) => s.seat); + state.log.push({ kind: 'gameOver', winnerSeats: state.winnerSeats.slice() }); + return true; + } + return false; +} + +export function isGameOver(state) { + return state.phase === 'gameOver'; +} + +// ── Scoring ─────────────────────────────────────────────────────────────────── + +export function allCards(state, seat) { + const p = state.players[seat]; + return [...p.deck, ...p.hand, ...p.discard, ...p.inPlay]; +} + +export function finalScores(state) { + return state.players.map((p) => { + const cards = allCards(state, p.seat); + let vp = 0; + for (const c of cards) { + const def = getCard(c.id); + if (c.id === 'gardens') vp += Math.floor(cards.length / 10); + else if (def.vp !== undefined) vp += def.vp; + } + return { seat: p.seat, vp, cards: cards.length }; + }); +} + +// ── Playing cards ───────────────────────────────────────────────────────────── + +export function legalActionIids(state) { + if (state.phase !== 'action' || state.pending) return []; + const p = state.players[state.turn]; + if (p.actions <= 0) return []; + return p.hand.filter((c) => isType(c.id, 'action')).map((c) => c.iid); +} + +export function playAction(state, iid) { + if (state.phase !== 'action' || state.pending) return state; + const p = state.players[state.turn]; + if (p.actions <= 0) return state; + const idx = p.hand.findIndex((c) => c.iid === iid); + if (idx === -1 || !isType(p.hand[idx].id, 'action')) return state; + + const next = cloneState(state); + const np = next.players[next.turn]; + const [card] = np.hand.splice(idx, 1); + np.inPlay.push(card); + np.actions -= 1; + next.log.push({ kind: 'play', seat: next.turn, id: card.id }); + + next.queue.unshift({ type: 'effect', seat: next.turn, id: card.id }); + runQueue(next); + return next; +} + +export function playTreasure(state, iid) { + if (state.phase !== 'buy' || state.pending) return state; + const p = state.players[state.turn]; + const idx = p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'treasure')); + if (idx === -1) return state; + const next = cloneState(state); + applyTreasure(next, next.turn, idx); + return next; +} + +export function playAllTreasures(state) { + if (state.phase !== 'buy' || state.pending) return state; + const next = cloneState(state); + const p = next.players[next.turn]; + let idx; + while ((idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) { + applyTreasure(next, next.turn, idx); + } + return next; +} + +function applyTreasure(state, seat, handIdx) { + const p = state.players[seat]; + const [card] = p.hand.splice(handIdx, 1); + p.inPlay.push(card); + const def = getCard(card.id); + p.coins += def.coin ?? 0; + if (card.id === 'silver' && !p.firstSilverPlayed) { + p.coins += p.merchantSilverBonus; + p.firstSilverPlayed = true; + } + state.log.push({ kind: 'playTreasure', seat, id: card.id }); +} + +export function buyCard(state, id) { + if (state.phase !== 'buy' || state.pending) return state; + const p = state.players[state.turn]; + const def = CARDS[id]; + if (!def) return state; + if (p.buys <= 0) return state; + if ((state.supply[id] ?? 0) <= 0) return state; + if (p.coins < def.cost) return state; + + const next = cloneState(state); + const np = next.players[next.turn]; + np.coins -= def.cost; + np.buys -= 1; + gain(next, next.turn, id, 'discard'); + next.log.push({ kind: 'buy', seat: next.turn, id }); + return next; +} + +// ── Effect engine ───────────────────────────────────────────────────────────── + +// Process queued tasks depth-first until the queue drains or a player decision +// is required (`pending` set). Mutates `state`. +function runQueue(state) { + let guard = 0; + while (state.queue.length > 0 && !state.pending) { + if (++guard > 10000) break; + const task = state.queue.shift(); + execTask(state, task); + } +} + +function execTask(state, task) { + switch (task.type) { + case 'effect': + applyEffect(state, task.seat, task.id); + break; + case 'draw': + drawInto(state, state.players[task.seat], task.n); + break; + case 'gain': + gain(state, task.seat, task.id, task.dest ?? 'discard'); + break; + case 'discardDownTo': { + const p = state.players[task.seat]; + const excess = p.hand.length - task.n; + if (excess > 0) { + state.pending = { seat: task.seat, kind: 'discardDownTo', count: excess, source: 'militia' }; + } + break; + } + case 'moatThen': { + const p = state.players[task.seat]; + const hasMoat = p.hand.some((c) => c.id === 'moat'); + if (hasMoat) { + state.pending = { seat: task.seat, kind: 'moatReveal', attack: task.attack, source: task.source }; + } else { + state.queue.unshift(task.attack); + } + break; + } + case 'witchCurse': + gain(state, task.seat, 'curse', 'discard'); + break; + case 'banditAttack': + banditAttack(state, task.seat); + break; + case 'bureaucratAttack': + bureaucratAttack(state, task.seat); + break; + default: + break; + } +} + +// Apply a card's printed effect for the active player `seat`. +function applyEffect(state, seat, id) { + const def = getCard(id); + const p = state.players[seat]; + + // Vanilla bonuses. + if (def.plus.actions) p.actions += def.plus.actions; + if (def.plus.buys) p.buys += def.plus.buys; + if (def.plus.coins) p.coins += def.plus.coins; + if (def.plus.cards) drawInto(state, p, def.plus.cards); + + // Card-specific. + const fn = SPECIAL[id]; + if (fn) fn(state, seat); +} + +// Other players' seats in turn order starting after `seat`. +function otherSeats(state, seat) { + const out = []; + for (let i = 1; i < state.playerCount; i++) out.push((seat + i) % state.playerCount); + return out; +} + +// Queue an attack against every other player, each gated by a Moat check. +function queueAttack(state, seat, attackFactory, source) { + const tasks = otherSeats(state, seat).map((o) => ({ + type: 'moatThen', seat: o, source, attack: attackFactory(o), + })); + state.queue.unshift(...tasks); +} + +const SPECIAL = { + cellar(state, seat) { + state.pending = { seat, kind: 'cellarDiscard' }; + }, + chapel(state, seat) { + state.pending = { seat, kind: 'chapelTrash', max: 4 }; + }, + harbinger(state, seat) { + const p = state.players[seat]; + if (p.discard.length > 0) state.pending = { seat, kind: 'harbingerTopdeck' }; + }, + merchant(state, seat) { + state.players[seat].merchantSilverBonus += 1; + }, + vassal(state, seat) { + const p = state.players[seat]; + if (p.deck.length === 0) reshuffle(state, p); + if (p.deck.length === 0) return; + const top = p.deck.shift(); + p.discard.push(top); + state.log.push({ kind: 'vassalDiscard', seat, id: top.id }); + if (isType(top.id, 'action')) { + state.pending = { seat, kind: 'vassalPlay', cardIid: top.iid, cardId: top.id }; + } + }, + workshop(state, seat) { + state.pending = { seat, kind: 'gainFromSupply', maxCost: 4, dest: 'discard', source: 'workshop' }; + }, + bureaucrat(state, seat) { + gain(state, seat, 'silver', 'deck'); + queueAttack(state, seat, (o) => ({ type: 'bureaucratAttack', seat: o }), 'bureaucrat'); + }, + militia(state, seat) { + queueAttack(state, seat, (o) => ({ type: 'discardDownTo', seat: o, n: 3 }), 'militia'); + }, + moneylender(state, seat) { + const p = state.players[seat]; + if (p.hand.some((c) => c.id === 'copper')) { + state.pending = { seat, kind: 'moneylenderTrash' }; + } + }, + poacher(state, seat) { + const empties = Object.values(state.supply).filter((c) => c <= 0).length; + const p = state.players[seat]; + const count = Math.min(empties, p.hand.length); + if (count > 0) state.pending = { seat, kind: 'poacherDiscard', count }; + }, + remodel(state, seat) { + const p = state.players[seat]; + if (p.hand.length > 0) state.pending = { seat, kind: 'remodelTrash' }; + }, + throneroom(state, seat) { + const p = state.players[seat]; + if (p.hand.some((c) => isType(c.id, 'action'))) { + state.pending = { seat, kind: 'throneChoose' }; + } + }, + bandit(state, seat) { + gain(state, seat, 'gold', 'discard'); + queueAttack(state, seat, (o) => ({ type: 'banditAttack', seat: o }), 'bandit'); + }, + councilroom(state, seat) { + const tasks = otherSeats(state, seat).map((o) => ({ type: 'draw', seat: o, n: 1 })); + state.queue.unshift(...tasks); + }, + library(state, seat) { + state.libraryAside = []; + libraryStep(state, seat); + }, + mine(state, seat) { + const p = state.players[seat]; + if (p.hand.some((c) => isType(c.id, 'treasure'))) { + state.pending = { seat, kind: 'mineTrash' }; + } + }, + sentry(state, seat) { + const p = state.players[seat]; + const look = []; + for (let i = 0; i < 2; i++) { + if (p.deck.length === 0) reshuffle(state, p); + if (p.deck.length === 0) break; + look.push(p.deck.shift()); + } + if (look.length === 0) return; + state.sentryLook = look; + state.pending = { seat, kind: 'sentry', cards: look.map((c) => ({ iid: c.iid, id: c.id })) }; + }, + witch(state, seat) { + queueAttack(state, seat, (o) => ({ type: 'witchCurse', seat: o }), 'witch'); + }, + artisan(state, seat) { + state.pending = { seat, kind: 'gainFromSupply', maxCost: 5, dest: 'hand', source: 'artisan' }; + }, +}; + +// Library: draw until 7 in hand, pausing on each Action drawn to ask keep/skip. +function libraryStep(state, seat) { + const p = state.players[seat]; + while (p.hand.length < 7) { + if (p.deck.length === 0) reshuffle(state, p); + if (p.deck.length === 0) break; + const c = p.deck.shift(); + if (isType(c.id, 'action')) { + // Hold in hand provisionally; ask whether to set aside. + p.hand.push(c); + state.pending = { seat, kind: 'libraryKeep', cardIid: c.iid, cardId: c.id }; + return; + } + p.hand.push(c); + } + // Done — discard any set-aside cards. + if (state.libraryAside && state.libraryAside.length) { + p.discard.push(...state.libraryAside); + } + state.libraryAside = null; +} + +function banditAttack(state, seat) { + const p = state.players[seat]; + const revealed = []; + for (let i = 0; i < 2; i++) { + if (p.deck.length === 0) reshuffle(state, p); + if (p.deck.length === 0) break; + revealed.push(p.deck.shift()); + } + state.log.push({ kind: 'reveal', seat, ids: revealed.map((c) => c.id) }); + const trashable = revealed.filter((c) => isType(c.id, 'treasure') && c.id !== 'copper'); + if (trashable.length === 0) { + p.discard.push(...revealed); + } else if (trashable.length === 1) { + const t = trashable[0]; + state.trash.push(t); + state.log.push({ kind: 'trash', seat, id: t.id }); + p.discard.push(...revealed.filter((c) => c.iid !== t.iid)); + } else { + // Two trashable treasures — the victim chooses which to trash. + state._banditRevealed = revealed.map((c) => ({ iid: c.iid, id: c.id })); + state.pending = { + seat, kind: 'banditTrash', + options: trashable.map((c) => ({ iid: c.iid, id: c.id })), + revealed: revealed.map((c) => ({ iid: c.iid, id: c.id })), + }; + // Stash the actual instances so resolution can place them. + state._banditInsts = revealed; + } +} + +function bureaucratAttack(state, seat) { + const p = state.players[seat]; + const victories = p.hand.filter((c) => isType(c.id, 'victory')); + if (victories.length === 0) { + state.log.push({ kind: 'reveal', seat, ids: p.hand.map((c) => c.id) }); + return; + } + if (victories.length === 1) { + const v = victories[0]; + const idx = p.hand.findIndex((c) => c.iid === v.iid); + const [card] = p.hand.splice(idx, 1); + p.deck.unshift(card); + state.log.push({ kind: 'topdeck', seat, id: card.id }); + return; + } + state.pending = { + seat, kind: 'bureaucratTopdeck', + options: victories.map((c) => ({ iid: c.iid, id: c.id })), + }; +} + +// ── Decision resolution ───────────────────────────────────────────────────── + +export function resolvePending(state, choice) { + if (!state.pending) return state; + const next = cloneState(state); + const pend = next.pending; + const seat = pend.seat; + const p = next.players[seat]; + next.pending = null; + + switch (pend.kind) { + case 'cellarDiscard': { + const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id)); + for (const iid of iids) discardFromHand(next, p, iid); + drawInto(next, p, iids.length); + break; + } + case 'chapelTrash': { + const iids = (choice?.iids ?? []).slice(0, pend.max) + .filter((id) => p.hand.some((c) => c.iid === id)); + for (const iid of iids) trashFrom(next, p, iid); + break; + } + case 'harbingerTopdeck': { + const iid = choice?.iid; + const idx = iid != null ? p.discard.findIndex((c) => c.iid === iid) : -1; + if (idx !== -1) { + const [c] = p.discard.splice(idx, 1); + p.deck.unshift(c); + next.log.push({ kind: 'topdeck', seat, id: c.id }); + } + break; + } + case 'vassalPlay': { + if (choice?.play) { + const idx = p.discard.findIndex((c) => c.iid === pend.cardIid); + if (idx !== -1) { + const [c] = p.discard.splice(idx, 1); + p.inPlay.push(c); + next.log.push({ kind: 'play', seat, id: c.id }); + next.queue.unshift({ type: 'effect', seat, id: c.id }); + } + } + break; + } + case 'gainFromSupply': { + const id = choice?.id; + if (id && canGain(next, id, pend.maxCost, pend.filterTreasure)) { + gain(next, seat, id, pend.dest ?? 'discard'); + if (pend.source === 'artisan') { + // Then put a card from hand onto the deck. + if (p.hand.length > 0) next.pending = { seat, kind: 'artisanTopdeck' }; + } + } + break; + } + case 'artisanTopdeck': { + const iid = choice?.iid; + const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid) : -1; + if (idx !== -1) { + const [c] = p.hand.splice(idx, 1); + p.deck.unshift(c); + next.log.push({ kind: 'topdeck', seat, id: c.id }); + } + break; + } + case 'remodelTrash': { + const iid = choice?.iid; + const c = iid != null ? trashFrom(next, p, iid) : null; + if (c) { + const maxCost = getCard(c.id).cost + 2; + next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'remodel' }; + } + break; + } + case 'mineTrash': { + const iid = choice?.iid; + const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'treasure')) : -1; + if (idx !== -1) { + const [c] = p.hand.splice(idx, 1); + next.trash.push(c); + next.log.push({ kind: 'trash', seat, id: c.id }); + const maxCost = getCard(c.id).cost + 3; + next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'hand', filterTreasure: true, source: 'mine' }; + } + break; + } + case 'moneylenderTrash': { + if (choice?.confirm) { + const idx = p.hand.findIndex((c) => c.id === 'copper'); + if (idx !== -1) { + const [c] = p.hand.splice(idx, 1); + next.trash.push(c); + p.coins += 3; + next.log.push({ kind: 'trash', seat, id: c.id }); + } + } + break; + } + case 'throneChoose': { + const iid = choice?.iid; + const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'action')) : -1; + if (idx !== -1) { + const [c] = p.hand.splice(idx, 1); + p.inPlay.push(c); + next.log.push({ kind: 'play', seat, id: c.id, throne: true }); + next.queue.unshift({ type: 'effect', seat, id: c.id }, { type: 'effect', seat, id: c.id }); + } + break; + } + case 'libraryKeep': { + if (!choice?.keep) { + // Set the just-drawn action aside (remove from hand → aside pile). + const idx = p.hand.findIndex((c) => c.iid === pend.cardIid); + if (idx !== -1) { + const [c] = p.hand.splice(idx, 1); + (next.libraryAside ??= []).push(c); + } + } + libraryStep(next, seat); + break; + } + case 'sentry': { + const look = next.sentryLook ?? []; + const byIid = new Map(look.map((c) => [c.iid, c])); + const trashIds = new Set(choice?.trash ?? []); + const discardIds = new Set(choice?.discard ?? []); + // Trash & discard chosen cards. + for (const c of look) { + if (trashIds.has(c.iid)) { next.trash.push(c); next.log.push({ kind: 'trash', seat, id: c.id }); } + else if (discardIds.has(c.iid)) { p.discard.push(c); } + } + // Remaining go back on top in the given order (or default order). + const keep = look.filter((c) => !trashIds.has(c.iid) && !discardIds.has(c.iid)); + const order = (choice?.top ?? keep.map((c) => c.iid)); + const ordered = order.map((iid) => byIid.get(iid)).filter((c) => c && keep.includes(c)); + for (const c of keep) if (!ordered.includes(c)) ordered.push(c); + // Top of deck = index 0; push so the first listed ends up on top. + for (let i = ordered.length - 1; i >= 0; i--) p.deck.unshift(ordered[i]); + next.sentryLook = null; + break; + } + case 'poacherDiscard': { + const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id)); + const need = Math.min(pend.count, p.hand.length); + const chosen = iids.slice(0, need); + // If the chooser under-selected, auto-fill from hand. + while (chosen.length < need) { + const c = p.hand.find((h) => !chosen.includes(h.iid)); + if (!c) break; + chosen.push(c.iid); + } + for (const iid of chosen) discardFromHand(next, p, iid); + break; + } + case 'discardDownTo': { + const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id)); + const chosen = iids.slice(0, pend.count); + while (chosen.length < pend.count && chosen.length < p.hand.length) { + const c = p.hand.find((h) => !chosen.includes(h.iid)); + if (!c) break; + chosen.push(c.iid); + } + for (const iid of chosen) discardFromHand(next, p, iid); + break; + } + case 'moatReveal': { + if (choice?.reveal) { + next.log.push({ kind: 'moat', seat }); + } else { + next.queue.unshift(pend.attack); + } + break; + } + case 'banditTrash': { + const insts = state._banditInsts ?? []; + const chosenIid = choice?.iid ?? (pend.options[0] && pend.options[0].iid); + for (const c of insts) { + if (c.iid === chosenIid) { next.trash.push({ iid: c.iid, id: c.id }); next.log.push({ kind: 'trash', seat, id: c.id }); } + else p.discard.push({ iid: c.iid, id: c.id }); + } + delete next._banditInsts; + delete next._banditRevealed; + break; + } + case 'bureaucratTopdeck': { + const iid = choice?.iid ?? pend.options[0]?.iid; + const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid) : -1; + if (idx !== -1) { + const [c] = p.hand.splice(idx, 1); + p.deck.unshift(c); + next.log.push({ kind: 'topdeck', seat, id: c.id }); + } + break; + } + default: + break; + } + + runQueue(next); + return next; +} + +// ── Query helpers (used by the AI and UI) ────────────────────────────────────── + +export function canGain(state, id, maxCost, filterTreasure = false) { + const def = CARDS[id]; + if (!def) return false; + if ((state.supply[id] ?? 0) <= 0) return false; + if (def.cost > maxCost) return false; + if (filterTreasure && !def.types.includes('treasure')) return false; + return true; +} + +export function affordableSupply(state, maxCost, filterTreasure = false) { + return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure)); +} + +export function supplyIds(state) { + return [...BASE_TREASURES, 'estate', 'duchy', 'province', 'curse', ...state.kingdom]; +} + +export function emptyPileCount(state) { + return Object.values(state.supply).filter((c) => c <= 0).length; +} + +export function handCoinValue(state, seat) { + const p = state.players[seat]; + let coins = 0; + let firstSilver = false; + for (const c of p.hand) { + const def = getCard(c.id); + coins += def.coin ?? 0; + if (c.id === 'silver' && !firstSilver) { coins += p.merchantSilverBonus; firstSilver = true; } + } + return coins; +} diff --git a/public/src/main.js b/public/src/main.js index b91623e..56d680c 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -28,6 +28,7 @@ import CatanGame from './games/catan/CatanGame.js'; import NertsGame from './games/nerts/NertsGame.js'; import BingoGame from './games/bingo/BingoGame.js'; import BaccaratGame from './games/baccarat/BaccaratGame.js'; +import DominionGame from './games/dominion/DominionGame.js'; const config = { type: Phaser.AUTO, @@ -69,6 +70,7 @@ const config = { NertsGame, BingoGame, BaccaratGame, + DominionGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 2137caf..ed3ba67 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -13,10 +13,11 @@ export default class GameRoomScene extends Phaser.Scene { this.playfield = data.playfield ?? null; this.cardBack = data.cardBack ?? null; this.tilePlacement = data.tilePlacement ?? 'standard'; + this.deckMode = data.deckMode ?? 'standard'; } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, @@ -24,6 +25,7 @@ export default class GameRoomScene extends Phaser.Scene { playfield: this.playfield, cardBack: this.cardBack, tilePlacement: this.tilePlacement, + deckMode: this.deckMode, }); return; } diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index daaf33e..b2296e0 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -33,6 +33,7 @@ export default class OpponentSelectScene extends Phaser.Scene { this.cardBackTiles = []; this.selectedTilePlacement = 'standard'; this.selectedMatchVariant = 4; + this.selectedDeckMode = 'standard'; this._initializing = false; this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only) } @@ -113,6 +114,8 @@ export default class OpponentSelectScene extends Phaser.Scene { const isGoFish = this.gameDef.slug === 'gofish'; if (isGoFish) this.buildMatchVariantSection(340, 1013); + if (this.gameDef.slug === 'dominion') this.buildDeckModeSection(340, 1013); + this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [], 'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf)); @@ -525,6 +528,52 @@ export default class OpponentSelectScene extends Phaser.Scene { }); } + // ── Dominion: Kingdom deck mode toggle ───────────────────────────────────── + buildDeckModeSection(centerX, centerY) { + const options = [ + { id: 'standard', label: 'Standard' }, + { id: 'random', label: 'Random' }, + ]; + const pillW = 150, pillH = 40, pillGap = 12; + const totalW = options.length * pillW + (options.length - 1) * pillGap; + const labelY = centerY - 28; + const pillY = centerY + 10; + + const labelText = this.add.text(centerX, labelY, 'Kingdom', { + fontFamily: '"Julius Sans One"', + fontSize: '20px', + color: COLORS.mutedHex, + }).setOrigin(0.5); + const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72); + this.children.moveBelow(labelBg, labelText); + + this._deckModeBtns = []; + options.forEach((opt, i) => { + const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2; + const isSelected = this.selectedDeckMode === opt.id; + const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel) + .setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted) + .setInteractive({ useHandCursor: true }); + const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72); + this.children.moveBelow(pillBg, bg); + this.add.text(x, pillY, opt.label, { + fontFamily: '"Julius Sans One"', + fontSize: '16px', + color: COLORS.textHex, + }).setOrigin(0.5); + + const refresh = () => { + this._deckModeBtns.forEach(({ bg: b, id }) => + b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted) + ); + }; + bg.on('pointerup', () => { this.selectedDeckMode = opt.id; refresh(); }); + bg.on('pointerover', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.text); }); + bg.on('pointerout', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.muted); }); + this._deckModeBtns.push({ bg, id: opt.id }); + }); + } + // ── Go Fish: match variant toggle ───────────────────────────────────────── buildMatchVariantSection(centerX, centerY) { const options = [ @@ -692,6 +741,7 @@ export default class OpponentSelectScene extends Phaser.Scene { cardBack: this.selectedCardBack, tilePlacement: this.selectedTilePlacement, matchVariant: this.selectedMatchVariant, + deckMode: this.selectedDeckMode, }); } } diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index e6db1ce..a1a70d6 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -62,6 +62,11 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3'); this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 }); + + // Dominion card art. One 270×390 cell per card (art fills the top ~60%; + // the title/icon band is drawn at runtime). Optional — the scene falls back + // to procedural placeholders when the sheet is absent. + this.load.spritesheet('dominion-cards', '/assets/images/dominioncards.png', { frameWidth: 270, frameHeight: 390 }); } async create() { diff --git a/server/games/registry.js b/server/games/registry.js index f3d018e..e063d33 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -41,3 +41,4 @@ registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', c registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 }); registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 }); registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 }); +registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });