feat: add Dominion card game with AI and full UI

- Implement pure state engine (`DominionLogic.js`) with turn lifecycle, card effects queue, and pending decision resolution for complex interactions.
- Add card definitions and kingdom pool (`DominionCards.js`) supporting Standard and Random deck modes.
- Create AI logic (`DominionAI.js`) using Big Money/early engine strategies scaled by opponent skill.
- Build Phaser scene (`DominionGame.js`) with supply/hand rendering, turn drivers, modal prompts for pending decisions, and end-game scoring.
- Wire up game registry, routing, preload assets, and opponent selection UI to support the new mode.
This commit is contained in:
Brian Fertig 2026-05-25 17:27:24 -06:00
parent 0f347e3502
commit 5a4f8a1204
9 changed files with 2225 additions and 1 deletions

View File

@ -0,0 +1,253 @@
// Dominion — AI decisions (pure). Big Money with light engine sense, scaled by
// per-opponent skill (15). 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 {};
}
}

View File

@ -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);
}

View File

@ -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 */ }
}
}

View File

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

View File

@ -28,6 +28,7 @@ import CatanGame from './games/catan/CatanGame.js';
import NertsGame from './games/nerts/NertsGame.js'; import NertsGame from './games/nerts/NertsGame.js';
import BingoGame from './games/bingo/BingoGame.js'; import BingoGame from './games/bingo/BingoGame.js';
import BaccaratGame from './games/baccarat/BaccaratGame.js'; import BaccaratGame from './games/baccarat/BaccaratGame.js';
import DominionGame from './games/dominion/DominionGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -69,6 +70,7 @@ const config = {
NertsGame, NertsGame,
BingoGame, BingoGame,
BaccaratGame, BaccaratGame,
DominionGame,
], ],
}; };

View File

@ -13,10 +13,11 @@ export default class GameRoomScene extends Phaser.Scene {
this.playfield = data.playfield ?? null; this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null; this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? 'standard'; this.tilePlacement = data.tilePlacement ?? 'standard';
this.deckMode = data.deckMode ?? 'standard';
} }
create() { 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]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,
@ -24,6 +25,7 @@ export default class GameRoomScene extends Phaser.Scene {
playfield: this.playfield, playfield: this.playfield,
cardBack: this.cardBack, cardBack: this.cardBack,
tilePlacement: this.tilePlacement, tilePlacement: this.tilePlacement,
deckMode: this.deckMode,
}); });
return; return;
} }

View File

@ -33,6 +33,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.cardBackTiles = []; this.cardBackTiles = [];
this.selectedTilePlacement = 'standard'; this.selectedTilePlacement = 'standard';
this.selectedMatchVariant = 4; this.selectedMatchVariant = 4;
this.selectedDeckMode = 'standard';
this._initializing = false; this._initializing = false;
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only) 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'; const isGoFish = this.gameDef.slug === 'gofish';
if (isGoFish) this.buildMatchVariantSection(340, 1013); 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 ?? [], this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf)); '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 ───────────────────────────────────────── // ── Go Fish: match variant toggle ─────────────────────────────────────────
buildMatchVariantSection(centerX, centerY) { buildMatchVariantSection(centerX, centerY) {
const options = [ const options = [
@ -692,6 +741,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
cardBack: this.selectedCardBack, cardBack: this.selectedCardBack,
tilePlacement: this.selectedTilePlacement, tilePlacement: this.selectedTilePlacement,
matchVariant: this.selectedMatchVariant, matchVariant: this.selectedMatchVariant,
deckMode: this.selectedDeckMode,
}); });
} }
} }

View File

@ -62,6 +62,11 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3'); 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 }); 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() { async create() {

View File

@ -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: '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: '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: '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 });