Added Prosperity

This commit is contained in:
Brian Fertig 2026-05-29 10:29:13 -06:00
parent 57eeb3bfee
commit 2c95e76b00
9 changed files with 1206 additions and 94 deletions

View File

@ -10,8 +10,9 @@ import {
} from './DominionCards.js';
import {
legalActionIids, allCards, handCoinValue,
affordableSupply, canGain,
affordableSupply, canGain, cardCost,
} from './DominionLogic.js';
import { getExpansion } from './expansions/index.js';
function countOwned(state, seat, id) {
return allCards(state, seat).filter((c) => c.id === id).length;
@ -26,11 +27,17 @@ const TERMINAL_PRIORITY = {
workshop: 45, throneroom: 40, chapel: 35, cellar: 30, artisan: 25, vassal: 20,
};
// Base priorities merged with the active expansion's (expansion wins on overlap).
function terminalPriority(state) {
return { ...TERMINAL_PRIORITY, ...(getExpansion(state.expansion).ai?.terminalPriority ?? {}) };
}
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]));
const TP = terminalPriority(state);
// Play cantrips / villages first (they replace the action they cost).
const cantrips = legal.filter((iid) => getCard(byIid.get(iid).id).plus.actions >= 1);
@ -43,18 +50,19 @@ export function chooseAction(state, seat) {
return cantrips[0];
}
// Only terminals remain (one action left). Throne Room needs another action to be worth it.
// Only terminals remain (one action left). Throne-variants need 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);
return (TP[idb] ?? 0) - (TP[ida] ?? 0);
});
const best = terminals[0];
if (byIid.get(best).id === 'throneroom') {
const bestId = byIid.get(best).id;
if (bestId === 'throneroom' || bestId === 'kingscourt') {
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');
// Skip the multiplier; play the next-best terminal instead, if any.
const alt = terminals.find((iid) => byIid.get(iid).id !== bestId);
return alt ?? best;
}
}
@ -78,28 +86,37 @@ export function chooseBuy(state, seat, skill = 3) {
const coins = p.coins;
if (p.buys <= 0) return null;
const provincesLeft = state.supply.province ?? 0;
const colonyGame = state.supply.colony !== undefined;
const coloniesLeft = state.supply.colony ?? 0;
// Top-end victory: Colony (Prosperity) sits above Province.
if (colonyGame && coins >= 11 && coloniesLeft > 0) return 'colony';
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 ((colonyGame ? coloniesLeft : 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) {
// Engine building (mid coins; never skip Gold/Province). Expansion engine cards
// are considered first, then the base list.
const engineBuys = [...(getExpansion(state.expansion).ai?.engineBuys ?? []), ...ENGINE_BUYS];
// Base keeps its original ≤5 gate; expansions may have worthwhile $6 engine cards.
const engineCoinCap = state.expansion && state.expansion !== 'base' ? 6 : 5;
if (skill >= 3 && coins <= engineCoinCap) {
for (const { id, cap } of engineBuys) {
if (!state.kingdom.includes(id)) continue;
if (getCard(id).cost > coins) continue;
if (cardCost(state, id, { seat, phase: 'buy' }) > 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;
}
}
// Treasure economy. In Colony games Platinum is the premier economy buy.
if (colonyGame && coins >= 5 && (state.supply.platinum ?? 0) > 0) return 'platinum';
if (coins >= 6 && (state.supply.gold ?? 0) > 0) return 'gold';
if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver';
return null;
@ -125,12 +142,15 @@ function trashRank(id, copperKeep) {
return 0; // never auto-trash anything else
}
function bestGain(state, seat, maxCost, filterTreasure) {
const options = affordableSupply(state, maxCost, filterTreasure);
function bestGain(state, seat, maxCost, filterTreasure, opts = {}) {
let options = affordableSupply(state, maxCost, filterTreasure, !!opts.exact);
if (opts.exclude && opts.exclude.length) options = options.filter((id) => !opts.exclude.includes(id));
if (options.length === 0) return null;
// Gain value ordering.
const rank = (id) => {
if (id === 'colony') return 1100;
if (id === 'province') return 1000;
if (id === 'platinum') return 950;
if (id === 'gold') return 900;
if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850;
const def = getCard(id);
@ -143,6 +163,16 @@ function bestGain(state, seat, maxCost, filterTreasure) {
return options[0];
}
// Helpers an expansion's AI pending-resolvers operate through.
const AI_API = {
cardCost,
bestGain,
discardRank,
countOwned,
isType,
getCard,
};
export function resolvePending(state, skill = 3) {
const pend = state.pending;
if (!pend) return {};
@ -247,7 +277,10 @@ export function resolvePending(state, skill = 3) {
const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost);
return { iid: opts[0]?.iid ?? null };
}
default:
return {};
default: {
// Expansion-defined pending kind.
const fn = getExpansion(state.expansion).ai?.pending?.[pend.kind];
return fn ? fn(state, seat, pend, AI_API) : {};
}
}
}

View File

@ -11,12 +11,15 @@
// 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 = {}) => ({
// `sheet` is the Phaser texture key the `frame` indexes into. Base cards live on
// the 'dominion-cards' sheet; expansions ship their own (e.g. 'dominion-prosperity').
export 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 ?? '',
sheet: extra.sheet ?? 'dominion-cards',
});
function titleCase(id) {
@ -117,18 +120,23 @@ 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--) {
// Pick 10 distinct Kingdom ids from `pool` using the supplied rng (0..1).
export function chooseRandomKingdom(rand, pool = KINGDOM_POOL) {
const p = pool.slice();
for (let i = p.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
[p[i], p[j]] = [p[j], p[i]];
}
return pool.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost);
return p.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost);
}
export function kingdomFor(deckMode, rand) {
if (deckMode === 'random') return chooseRandomKingdom(rand);
const preset = KINGDOM_PRESETS[deckMode] ?? FIRST_GAME;
// Resolve a Kingdom for the chosen deck mode. `opts.pool`/`opts.presets` let an
// expansion supply its own card pool and recommended layouts; both default to
// the base set, so base play is unchanged.
export function kingdomFor(deckMode, rand, opts = {}) {
const pool = opts.pool ?? KINGDOM_POOL;
const presets = opts.presets ?? KINGDOM_PRESETS;
if (deckMode === 'random') return chooseRandomKingdom(rand, pool);
const preset = presets[deckMode] ?? presets[Object.keys(presets)[0]] ?? FIRST_GAME;
return preset.slice().sort((a, b) => getCard(a).cost - getCard(b).cost);
}

View File

@ -10,7 +10,7 @@ import { getCard, isType } from './DominionCards.js';
import {
createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures,
buyCard, endTurn, resolvePending, isGameOver, finalScores,
legalActionIids, canGain, emptyPileCount,
legalActionIids, canGain, emptyPileCount, buyCost, buyAllowed,
} from './DominionLogic.js';
import * as AI from './DominionAI.js';
@ -69,6 +69,7 @@ export default class DominionGame extends Phaser.Scene {
this.opponents = data.opponents ?? [];
this.cardBack = data.cardBack ?? null;
this.playfield = data.playfield ?? null;
this.expansion = data.expansion ?? 'base';
this.deckMode = data.deckMode ?? 'standard';
this.playerCount = this.opponents.length + 1;
@ -150,6 +151,7 @@ export default class DominionGame extends Phaser.Scene {
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
playerCount: this.playerCount,
deckMode: this.deckMode,
expansion: this.expansion,
});
// Show an empty hand first, then animate the deal.
const p0 = initialState.players[0];
@ -461,7 +463,12 @@ export default class DominionGame extends Phaser.Scene {
renderSupply() {
const gs = this.gs;
const base = ['copper', 'silver', 'gold', 'estate', 'duchy', 'province', 'curse'];
// Basic supply row; Prosperity inserts Platinum after Gold and Colony after Province.
const base = ['copper', 'silver', 'gold'];
if (gs.supply.platinum !== undefined) base.push('platinum');
base.push('estate', 'duchy', 'province');
if (gs.supply.colony !== undefined) base.push('colony');
base.push('curse');
this.layoutPileRow(base, 100);
const k = gs.kingdom;
this.layoutPileRow(k.slice(0, 5), 274);
@ -514,7 +521,7 @@ export default class DominionGame extends Phaser.Scene {
// 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;
const affordable = count > 0 && p.buys > 0 && p.coins >= buyCost(gs, id) && buyAllowed(gs, id);
if (affordable) {
hit.setInteractive({ useHandCursor: true });
hit.on('pointerup', () => this.humanBuy(id));
@ -790,9 +797,32 @@ export default class DominionGame extends Phaser.Scene {
}).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`, {
const colonyNote = gs.supply.colony !== undefined ? ` Colonies left: ${gs.supply.colony ?? 0}` : '';
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft}${colonyNote} Empty piles: ${emptyPileCount(gs)}/3`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud));
this.renderVpTokens();
}
// VP-token badges (Prosperity: Monument, Bishop, …). Drawn per seat near each
// portrait; only shown for seats that have accrued tokens.
renderVpTokens() {
const gs = this.gs;
gs.players.forEach((p, seat) => {
const n = p.vpTokens ?? 0;
if (n <= 0) return;
const pos = seat === 0 ? { x: 92 + 46, y: 928 - 46 } : (() => {
const s = this.oppSlot(seat - 1); return { x: s.x - s.r - 6, y: s.y - s.r - 2 };
})();
const g = this.add.graphics().setDepth(D.hud + 1);
g.fillStyle(0x000000, 0.82); g.fillCircle(pos.x, pos.y, 18);
g.lineStyle(2, COLORS.gold, 1); g.strokeCircle(pos.x, pos.y, 18);
this.dynamicLayer.add(g);
this.dynamicLayer.add(this.add.text(pos.x, pos.y, `${n}`, {
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.hud + 2));
});
}
updateControls() {
@ -831,8 +861,9 @@ export default class DominionGame extends Phaser.Scene {
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));
const sheetKey = def.sheet ?? 'dominion-cards';
if (this.textures.exists(sheetKey)) {
c.add(this.add.image(0, 0, sheetKey, 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 {
@ -1326,6 +1357,41 @@ export default class DominionGame extends Phaser.Scene {
return this.promptYesNo('You are under attack! Reveal Moat to block it?', (yes) => this.resolveHuman({ reveal: yes }));
case 'sentry':
return this.promptSentry(pend);
// ── Prosperity ────────────────────────────────────────────────────────
case 'bishopTrash':
return this.promptPickHand(pend, { filter: () => true, banner: 'Bishop: trash a card for +VP (half its cost).', allowSkip: false, key: 'iid' });
case 'bishopMayTrash':
return this.promptPickHand(pend, { filter: () => true, banner: 'Bishop: you may trash a card from your hand.', allowSkip: true, key: 'iid' });
case 'vaultDiscard':
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Vault: discard any number of cards for +1 Coin each.', confirm: 'Discard' });
case 'vaultMayDiscard':
return this.promptMultiHand(pend, { min: 0, max: 2, banner: 'Vault: discard 2 cards to draw 1 (or none).', confirm: 'Discard' });
case 'expandTrash':
return this.promptPickHand(pend, { filter: () => true, banner: 'Expand: trash a card (gain one costing up to 3 more).', allowSkip: false, key: 'iid' });
case 'forgeTrash':
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Forge: trash any number of cards; gain one costing their exact total.', confirm: 'Trash' });
case 'forgeGain':
return this.promptGain(pend, { exact: true, cost: pend.exactCost });
case 'kingsCourtChoose':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'action'), banner: "King's Court: choose an Action to play three times (or skip).", allowSkip: true, key: 'iid' });
case 'mintReveal':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Mint: reveal a Treasure to gain a copy (or skip).', allowSkip: true, key: 'iid' });
case 'anvilDiscard':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Anvil: discard a Treasure to gain a card costing up to 4 (or skip).', allowSkip: true, key: 'iid' });
case 'clerkTopdeck':
return this.promptPickHand(pend, { filter: () => true, banner: 'Clerk: put a card from your hand onto your deck.', allowSkip: false, key: 'iid' });
case 'tiaraTopdeck':
return this.promptYesNo(`Tiara: put the gained ${getCard(pend.cardId).name} onto your deck?`, (yes) => this.resolveHuman({ topdeck: yes }));
case 'crystalBall':
return this.promptCrystalBall(pend);
case 'investmentChoice':
return this.promptInvestment(pend);
case 'watchtowerReact':
return this.promptWatchtower(pend);
case 'warchestGain':
return this.promptGain(pend, { maxCost: 5, exclude: pend.named ?? [] });
default:
return this.resolveHuman({});
}
@ -1362,10 +1428,11 @@ export default class DominionGame extends Phaser.Scene {
}
// Single pick from the hand (immediate resolve). key = which choice field.
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid' }) {
// choiceExtra is merged into the resolved choice (e.g. Investment's mode).
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid', choiceExtra = {} }) {
this.promptBanner(banner);
if (allowSkip) {
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null }), { variant: 'ghost' });
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null, ...choiceExtra }), { variant: 'ghost' });
}
for (const hs of this.handSprites) {
const ok = filter(hs);
@ -1373,17 +1440,28 @@ export default class DominionGame extends Phaser.Scene {
hs.hit.setInteractive({ useHandCursor: true });
hs.hit.removeAllListeners('pointerup');
this.highlightFace(hs.face, COLORS.danger);
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid }));
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid, ...choiceExtra }));
}
}
// 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}.`);
// opts: { exact, cost } for an exact-cost gain (Forge); { maxCost, exclude } overrides.
promptGain(pend, opts = {}) {
const exact = !!opts.exact;
const cost = opts.cost ?? pend.exactCost;
const maxCost = opts.maxCost ?? pend.maxCost;
const exclude = opts.exclude ?? [];
const banner = exact
? `Gain a card costing exactly ${cost}.`
: `Gain a${pend.filterTreasure ? ' Treasure' : ''} card costing up to ${maxCost}.`;
this.promptBanner(banner);
let any = false;
for (const sp of this.supplySprites) {
if (!canGain(this.gs, sp.id, pend.maxCost, pend.filterTreasure)) continue;
if (exclude.includes(sp.id)) continue;
const ok = exact
? canGain(this.gs, sp.id, cost, false, true)
: canGain(this.gs, sp.id, maxCost, pend.filterTreasure);
if (!ok) continue;
any = true;
sp.hit.setInteractive({ useHandCursor: true });
sp.hit.removeAllListeners('pointerup');
@ -1392,7 +1470,42 @@ export default class DominionGame extends Phaser.Scene {
this.promptObjs.push(glow);
sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id }));
}
if (!any) this.resolveHuman({ id: null });
if (!any) this.resolveHuman(exact ? {} : { id: null });
}
// Crystal Ball: act on the revealed top card of your deck.
promptCrystalBall(pend) {
const def = getCard(pend.cardId);
this.promptBanner(`Crystal Ball reveals ${def.name}.`);
const playable = isType(pend.cardId, 'action') || isType(pend.cardId, 'treasure');
let x = CX - (playable ? 285 : 190);
this.promptButton(x, 'Trash', () => this.resolveHuman({ action: 'trash' }), { variant: 'ghost' }); x += 190;
this.promptButton(x, 'Discard', () => this.resolveHuman({ action: 'discard' }), { variant: 'ghost' }); x += 190;
if (playable) { this.promptButton(x, 'Play', () => this.resolveHuman({ action: 'play' }), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex }); x += 190; }
this.promptButton(x, 'Keep', () => this.resolveHuman({ action: 'keep' }), { variant: 'ghost' });
}
// Investment: choose +1 Coin, or trash a Treasure for VP.
promptInvestment(pend) {
this.promptBanner('Investment: choose a benefit.');
this.promptButton(CX - 150, '+1 Coin', () => this.resolveHuman({ mode: 'coin' }), { variant: 'ghost' });
this.promptButton(CX + 150, 'Trash a Treasure', () => {
this.clearPrompt();
this.promptPickHand(pend, {
filter: (c) => isType(c.id, 'treasure'),
banner: 'Investment: trash a Treasure (+1 VP, +1 VP per differently named Treasure in hand).',
allowSkip: false, key: 'iid',
choiceExtra: { mode: 'trash' },
});
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
}
// Watchtower: react to a just-gained card.
promptWatchtower(pend) {
this.promptBanner(`Watchtower: you gained ${getCard(pend.cardId).name}. Reveal Watchtower to…`);
this.promptButton(CX - 200, 'Trash it', () => this.resolveHuman({ action: 'trash' }), { variant: 'ghost' });
this.promptButton(CX, 'Top-deck it', () => this.resolveHuman({ action: 'topdeck' }), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
this.promptButton(CX + 200, 'Keep it', () => this.resolveHuman({ action: 'keep' }), { variant: 'ghost' });
}
promptYesNo(banner, cb) {

View File

@ -17,6 +17,7 @@ import {
getCard, CARDS, BASE_TREASURES,
kingdomFor, isType,
} from './DominionCards.js';
import { getExpansion, registerExpansionCards } from './expansions/index.js';
// Mulberry32 — seedable PRNG (mirrors the other games).
function rng(seed) {
@ -43,9 +44,11 @@ const HAND_SIZE = 5;
// ── State construction ──────────────────────────────────────────────────────
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard' } = {}) {
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard', expansion = 'base' } = {}) {
registerExpansionCards();
const rand = seed === undefined ? Math.random : rng(seed);
const kingdom = kingdomFor(deckMode, rand);
const exp = getExpansion(expansion);
const kingdom = kingdomFor(deckMode, rand, { pool: exp.kingdomPool ?? undefined, presets: exp.presets ?? undefined });
const state = {
playerCount,
@ -62,11 +65,17 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
winnerSeats: [],
seed: seed ?? null,
deckMode,
expansion,
_rand: rand,
_nextIid: 1,
// transient per-effect scratch
sentryLook: null,
libraryAside: null,
// expansion scratch (reset each turn / persisted across cloneState)
curseIsCopper: false,
tiaraTopdeckArmed: 0,
tiaraDoubleArmed: false,
_warchestNamed: [],
};
const victoryPile = playerCount <= 2 ? 8 : 12;
@ -80,17 +89,26 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
state.supply.province = victoryPile;
state.supply.curse = 10 * (playerCount - 1);
// Expansion basic cards (e.g. Prosperity's Platinum & Colony).
for (const id of exp.basics ?? []) {
state.supply[id] = exp.basicPileSize ? exp.basicPileSize(id, playerCount) : 10;
}
// 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;
}
// Expansion setup rules (e.g. Charlatan → Curses count as Coppers).
for (const rule of exp.setupRules ?? []) rule(state);
// 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,
vpTokens: 0,
merchantSilverBonus: 0,
firstSilverPlayed: false,
};
@ -127,6 +145,7 @@ export function cloneState(state) {
actions: p.actions,
buys: p.buys,
coins: p.coins,
vpTokens: p.vpTokens ?? 0,
merchantSilverBonus: p.merchantSilverBonus,
firstSilverPlayed: p.firstSilverPlayed,
})),
@ -142,10 +161,15 @@ export function cloneState(state) {
winnerSeats: state.winnerSeats.slice(),
seed: state.seed,
deckMode: state.deckMode,
expansion: state.expansion,
_rand: state._rand,
_nextIid: state._nextIid,
sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null,
libraryAside: state.libraryAside ? cloneInsts(state.libraryAside) : null,
curseIsCopper: state.curseIsCopper ?? false,
tiaraTopdeckArmed: state.tiaraTopdeckArmed ?? 0,
tiaraDoubleArmed: state.tiaraDoubleArmed ?? false,
_warchestNamed: (state._warchestNamed ?? []).slice(),
};
return out;
}
@ -182,9 +206,82 @@ function gain(state, seat, id, dest = 'discard') {
else if (dest === 'hand') p.hand.push(inst);
else p.discard.push(inst);
state.log.push({ kind: 'gain', seat, id, dest });
runGainTriggers(state, seat, inst);
return inst;
}
// While-in-play gain triggers (Hoard, Collection, Tiara) and reveal-from-hand
// gain reactions (Watchtower). No-op for the base game.
function runGainTriggers(state, seat, inst) {
const exp = getExpansion(state.expansion);
if (exp.id === 'base') return;
for (const key of Object.keys(exp.onGain ?? {})) {
exp.onGain[key](state, seat, inst, ENGINE_API);
if (state.pending) return; // a trigger asked for a decision
}
for (const key of Object.keys(exp.gainReactions ?? {})) {
exp.gainReactions[key](state, seat, inst, ENGINE_API);
if (state.pending) return;
}
}
function trashFromInPlay(state, seat, iid) {
const p = state.players[seat];
const idx = p.inPlay.findIndex((c) => c.iid === iid);
if (idx === -1) return null;
const [c] = p.inPlay.splice(idx, 1);
state.trash.push(c);
state.log.push({ kind: 'trash', seat, id: c.id });
return c;
}
function gainVP(state, seat, n) {
if (!n) return;
const p = state.players[seat];
p.vpTokens = (p.vpTokens ?? 0) + n;
state.log.push({ kind: 'vp', seat, n });
}
// A card's current cost: printed cost plus any active expansion cost modifiers
// (Quarry, Peddler, …), clamped at 0. Equals the printed cost for the base game.
export function cardCost(state, id, ctx = {}) {
const def = CARDS[id];
if (!def) return Infinity;
let cost = def.cost;
for (const mod of getExpansion(state.expansion).costModifiers ?? []) {
cost += mod(state, id, ctx);
}
return Math.max(0, cost);
}
// The surface expansion effect/resolver/task functions operate through. All
// helpers are hoisted function declarations, so referencing them here is safe.
const ENGINE_API = {
getCard, isType, CARDS,
cardCost,
emptyPileCount: (state) => emptyPileCount(state),
otherSeats: (state, seat) => otherSeats(state, seat),
draw: (state, seat, n) => drawInto(state, state.players[seat], n),
gain: (state, seat, id, dest) => gain(state, seat, id, dest),
reshuffle: (state, seat) => reshuffle(state, state.players[seat]),
trashFromHand: (state, seat, iid) => trashFrom(state, state.players[seat], iid),
trashFromInPlay: (state, seat, iid) => trashFromInPlay(state, seat, iid),
trashCard: (state, card) => {
state.trash.push({ iid: card.iid, id: card.id });
state.log.push({ kind: 'trash', seat: state.turn, id: card.id });
},
discardFromHand: (state, seat, iid) => discardFromHand(state, state.players[seat], iid),
gainVP: (state, seat, n) => gainVP(state, seat, n),
queueAttack: (state, seat, factory, source) => queueAttack(state, seat, factory, source),
queue: (state, tasks) => state.queue.unshift(...tasks),
setPending: (state, pend) => { state.pending = pend; },
log: (state, entry) => state.log.push(entry),
countOwned: (state, seat, id) => allCards(state, seat).filter((c) => c.id === id).length,
warchestName: (state, namerSeat, ownerSeat) =>
getExpansion(state.expansion).ai?.warchestName?.(state, namerSeat, ownerSeat, ENGINE_API) ?? null,
playTreasureEffect: (state, seat, card) => resolveTreasure(state, seat, card),
};
function trashFrom(state, p, iid) {
const idx = p.hand.findIndex((c) => c.iid === iid);
if (idx === -1) return null;
@ -214,6 +311,10 @@ function startTurn(state) {
state.phase = 'action';
state.pending = null;
state.queue = [];
// Reset per-turn expansion scratch.
state.tiaraTopdeckArmed = 0;
state.tiaraDoubleArmed = false;
state._warchestNamed = [];
state.turnsTaken[state.turn] += 1;
state.log.push({ kind: 'turnStart', seat: state.turn });
}
@ -286,6 +387,7 @@ export function finalScores(state) {
if (c.id === 'gardens') vp += Math.floor(cards.length / 10);
else if (def.vp !== undefined) vp += def.vp;
}
vp += p.vpTokens ?? 0;
return { seat: p.seat, vp, cards: cards.length };
});
}
@ -333,7 +435,8 @@ export function playAllTreasures(state) {
const next = cloneState(state);
const p = next.players[next.turn];
let idx;
while ((idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
// Stop if a treasure effect (Anvil, Crystal Ball, …) demands a decision.
while (!next.pending && (idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
applyTreasure(next, next.turn, idx);
}
return next;
@ -343,13 +446,37 @@ function applyTreasure(state, seat, handIdx) {
const p = state.players[seat];
const [card] = p.hand.splice(handIdx, 1);
p.inPlay.push(card);
resolveTreasure(state, seat, card);
state.log.push({ kind: 'playTreasure', seat, id: card.id });
runQueue(state);
}
// Apply a treasure's value + vanilla bonuses + any expansion on-play effect for
// an already-in-play card. Used by applyTreasure and by cards that play a
// treasure from elsewhere (e.g. Crystal Ball).
function resolveTreasure(state, seat, card) {
const p = state.players[seat];
const def = getCard(card.id);
p.coins += def.coin ?? 0;
let coin = def.coin ?? 0;
if (card.id === 'silver' && !p.firstSilverPlayed) {
p.coins += p.merchantSilverBonus;
coin += p.merchantSilverBonus;
p.firstSilverPlayed = true;
}
state.log.push({ kind: 'playTreasure', seat, id: card.id });
// Tiara (approximate): the first treasure played each turn while Tiara is in
// play produces double its coins. Single-shot for now.
if (state.tiaraDoubleArmed && coin > 0) {
coin *= 2;
state.tiaraDoubleArmed = false;
}
p.coins += coin;
// Vanilla bonuses some treasures carry (Collection/Tiara +Buy, etc.).
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);
// Expansion treasure on-play effect.
const fn = getExpansion(state.expansion).treasureEffects?.[card.id];
if (fn) fn(state, seat, ENGINE_API);
}
export function buyCard(state, id) {
@ -359,17 +486,35 @@ export function buyCard(state, 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 cost = cardCost(state, id, { seat: state.turn, phase: 'buy' });
if (p.coins < cost) return state;
const restrict = getExpansion(state.expansion).buyRestrictions?.[id];
if (restrict && !restrict(state, state.turn)) return state;
const next = cloneState(state);
const np = next.players[next.turn];
np.coins -= def.cost;
np.coins -= cost;
np.buys -= 1;
gain(next, next.turn, id, 'discard');
next.log.push({ kind: 'buy', seat: next.turn, id });
const onBuy = getExpansion(next.expansion).onBuy?.[id];
if (onBuy) onBuy(next, next.turn, ENGINE_API);
runQueue(next);
return next;
}
// Current cost to BUY a card now (printed + modifiers), for the UI/AI.
export function buyCost(state, id) {
return cardCost(state, id, { seat: state.turn, phase: 'buy' });
}
// Whether the active player could buy `id` right now (ignoring coins), honoring
// expansion buy restrictions (e.g. Grand Market with Copper in play).
export function buyAllowed(state, id) {
const restrict = getExpansion(state.expansion).buyRestrictions?.[id];
return !restrict || restrict(state, state.turn);
}
// ── Effect engine ─────────────────────────────────────────────────────────────
// Process queued tasks depth-first until the queue drains or a player decision
@ -421,8 +566,12 @@ function execTask(state, task) {
case 'bureaucratAttack':
bureaucratAttack(state, task.seat);
break;
default:
default: {
// Expansion-defined task type.
const fn = getExpansion(state.expansion).tasks?.[task.type];
if (fn) fn(state, task, ENGINE_API);
break;
}
}
}
@ -437,9 +586,9 @@ function applyEffect(state, seat, id) {
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);
// Card-specific: base table first, then the active expansion's effects.
const fn = SPECIAL[id] ?? getExpansion(state.expansion).effects?.[id];
if (fn) fn(state, seat, ENGINE_API);
}
// Other players' seats in turn order starting after `seat`.
@ -695,7 +844,7 @@ export function resolvePending(state, choice) {
const iid = choice?.iid;
const c = iid != null ? trashFrom(next, p, iid) : null;
if (c) {
const maxCost = getCard(c.id).cost + 2;
const maxCost = cardCost(next, c.id, { seat }) + 2;
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'remodel' };
}
break;
@ -707,7 +856,7 @@ export function resolvePending(state, choice) {
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;
const maxCost = cardCost(next, c.id, { seat }) + 3;
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'hand', filterTreasure: true, source: 'mine' };
}
break;
@ -820,8 +969,12 @@ export function resolvePending(state, choice) {
}
break;
}
default:
default: {
// Expansion-defined pending kind.
const fn = getExpansion(next.expansion).resolvers?.[pend.kind];
if (fn) fn(next, pend, choice, ENGINE_API);
break;
}
}
runQueue(next);
@ -830,21 +983,24 @@ export function resolvePending(state, choice) {
// ── Query helpers (used by the AI and UI) ──────────────────────────────────────
export function canGain(state, id, maxCost, filterTreasure = false) {
// `exactCost` (Forge) requires cost === maxCost rather than ≤.
export function canGain(state, id, maxCost, filterTreasure = false, exactCost = false) {
const def = CARDS[id];
if (!def) return false;
if ((state.supply[id] ?? 0) <= 0) return false;
if (def.cost > maxCost) return false;
const cost = cardCost(state, id, { seat: state.turn });
if (exactCost ? cost !== maxCost : 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 affordableSupply(state, maxCost, filterTreasure = false, exactCost = false) {
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure, exactCost));
}
export function supplyIds(state) {
return [...BASE_TREASURES, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
const basics = getExpansion(state.expansion).basics ?? [];
return [...BASE_TREASURES, ...basics, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
}
export function emptyPileCount(state) {

View File

@ -0,0 +1,61 @@
// Dominion — expansion registry.
//
// The base game's card effects live in DominionLogic's built-in tables. An
// expansion is pure data plus effect functions that operate through the engine
// `api` object the dispatcher passes in (see DominionLogic's ENGINE_API). This
// keeps base play untouched: the engine composes `base + activeExpansion` only
// when a non-base expansion is selected.
//
// Each expansion exposes any of:
// cards card defs to merge into the global CARDS map
// basics extra basic-supply pile ids active while this expansion is in play
// basicPileSize (id) -> pile size for those basics
// kingdomPool ids Random draws from
// presets { deckMode: [10 ids] } recommended layouts
// setupRules [ (state) => void ] applied once at game start
// effects { id: (state, seat, api) => void } Action on-play effects
// treasureEffects { id: (state, seat, api) => void } Treasure on-play effects
// onGain { id: (state, gainSeat, gainedId, api) => void } while-in-play gain triggers
// gainReactions { id: (state, gainSeat, gainedIid, api) => void } reveal-from-hand reactions
// onBuy { id: (state, seat, api) => void } when this card is bought
// costModifiers [ (state, id, ctx) => number ] delta applied to a card's cost
// buyRestrictions { id: (state, seat) => boolean } false = cannot buy right now
// tasks { type: (state, task, api) => void } custom queue task handlers
// resolvers { kind: (state, pend, choice, api) => void } custom pending resolution
// ai { terminalPriority, engineBuys, pending } AI hooks
import { CARDS } from '../DominionCards.js';
import { prosperity } from './prosperity.js';
const BASE = {
id: 'base',
name: 'Base Game',
cards: [],
basics: [],
// null → use the base KINGDOM_PRESETS / KINGDOM_POOL in DominionCards.
presets: null,
kingdomPool: null,
};
export const EXPANSIONS = {
base: BASE,
prosperity,
};
// Order shown in the setup screen.
export const EXPANSION_ORDER = ['base', 'prosperity'];
export function getExpansion(id) {
return EXPANSIONS[id] ?? BASE;
}
// Merge every expansion's card defs into the global CARDS map so getCard()
// resolves them everywhere. Idempotent; called from createInitialState.
let _registered = false;
export function registerExpansionCards() {
if (_registered) return;
for (const exp of Object.values(EXPANSIONS)) {
for (const c of exp.cards ?? []) CARDS[c.id] = c;
}
_registered = true;
}

View File

@ -0,0 +1,672 @@
// Dominion — Prosperity expansion (2nd edition, full 25-card set + Colony/Platinum).
//
// Pure data + effect functions. Effects never touch Phaser and never import the
// engine directly; they operate through the `api` object the dispatcher passes
// (see DominionLogic's ENGINE_API). State stays plain/cloneable — only data is
// written onto it.
//
// ── Spritesheet: public/assets/images/dominion-prosperity.png ───────────────────
// 270×390 per cell, same convention as the base sheet (art fills the top; the
// title/icon band is drawn at runtime over the lower portion). Frame index is
// flat, left→right, top→bottom. THIS ORDER MUST MATCH THE ART:
//
// 0 platinum 1 colony
// ── $3 ── 2 anvil 3 watchtower
// ── $4 ── 4 bishop 5 clerk 6 investment 7 monument
// 8 quarry 9 tiara 10 workersvillage
// ── $5 ── 11 charlatan 12 city 13 collection 14 crystalball
// 15 magnate 16 mint 17 rabble 18 vault 19 warchest
// ── $6 ── 20 grandmarket 21 hoard
// ── $7 ── 22 bank 23 expand 24 forge 25 kingscourt
// ── $8 ── 26 peddler
//
// NOTE: A handful of cards (Tiara, War Chest, Charlatan's setup rule, Clerk's
// start-of-turn reaction) have subtle official wording; the chosen interpretation
// is commented at each. Adjust freely against your physical copy.
import { def, isType } from '../DominionCards.js';
const SHEET = 'dominion-prosperity';
const d = (id, frame, cost, types, extra = {}) =>
def(id, frame, cost, types, { ...extra, sheet: SHEET });
// ── Card table ──────────────────────────────────────────────────────────────
const CARD_DEFS = [
// Basic cards (added to the supply whenever Prosperity is in play).
d('platinum', 0, 5, ['treasure'], { name: 'Platinum', coin: 5, text: 'Worth 5 Coins.' }),
d('colony', 1, 11, ['victory'], { name: 'Colony', vp: 10, text: 'Worth 10 Victory Points.' }),
// $3
d('anvil', 2, 3, ['treasure'], { name: 'Anvil', coin: 1, text: '+1 Coin. When you play this, you may discard a Treasure to gain a card costing up to 4 Coins.' }),
d('watchtower', 3, 3, ['action', 'reaction'], { name: 'Watchtower', text: 'Draw until you have 6 cards in hand. When you gain a card, you may reveal this from your hand, to either trash that card or put it onto your deck.' }),
// $4
d('bishop', 4, 4, ['action'], { name: 'Bishop', plus: { coins: 1 }, text: '+1 Coin, +1 VP. Trash a card from your hand. +VP equal to half its cost in Coins, rounded down. Each other player may trash a card from their hand.' }),
d('clerk', 5, 4, ['action', 'reaction', 'attack'], { name: 'Clerk', plus: { coins: 2 }, text: '+2 Coins. Each other player with 5 or more cards in hand puts one onto their deck. At the start of your turn, you may play this from your hand.' }),
d('investment', 6, 4, ['treasure'], { name: 'Investment', coin: 0, text: 'Trash this. Choose one: +1 Coin; or trash a Treasure from your hand, +1 VP, and reveal your hand for +1 VP per differently named Treasure in it.' }),
d('monument', 7, 4, ['action'], { name: 'Monument', plus: { coins: 2 }, text: '+2 Coins, +1 VP.' }),
d('quarry', 8, 4, ['treasure'], { name: 'Quarry', coin: 1, text: '+1 Coin. While this is in play, Action cards cost 2 Coins less, but not less than 0.' }),
d('tiara', 9, 4, ['treasure'], { name: 'Tiara', plus: { buys: 1 }, coin: 0, text: '+1 Buy. The next time you gain a card this turn, you may put it onto your deck. While this is in play, the first time you play a Treasure each turn, it produces double its Coins.' }),
d('workersvillage', 10, 4, ['action'], { name: "Worker's Village", plus: { cards: 1, actions: 2, buys: 1 }, text: '+1 Card, +2 Actions, +1 Buy.' }),
// $5
d('charlatan', 11, 5, ['action', 'attack'], { name: 'Charlatan', plus: { coins: 3 }, text: '+3 Coins. Each other player gains a Curse. (In games using this, Curses are also Coppers worth 1 Coin.)' }),
d('city', 12, 5, ['action'], { name: 'City', plus: { cards: 1, actions: 2 }, text: '+1 Card, +2 Actions. If there is 1 or more empty Supply pile, +1 Card. If 2 or more, +1 Buy and +1 Coin.' }),
d('collection', 13, 5, ['treasure'], { name: 'Collection', plus: { buys: 1 }, coin: 2, text: '+2 Coins, +1 Buy. While this is in play, when you gain an Action card, +1 VP.' }),
d('crystalball', 14, 5, ['treasure'], { name: 'Crystal Ball', coin: 1, text: '+1 Coin. When you play this, look at the top card of your deck. You may trash it, discard it, or play it if it is an Action or Treasure.' }),
d('magnate', 15, 5, ['treasure'], { name: 'Magnate', coin: 0, text: 'Reveal your hand. +1 Card per Treasure in it.' }),
d('mint', 16, 5, ['action'], { name: 'Mint', text: 'You may reveal a Treasure from your hand. Gain a copy of it. (When you buy this, trash all Treasures you have in play.)' }),
d('rabble', 17, 5, ['action', 'attack'], { name: 'Rabble', plus: { cards: 3 }, text: '+3 Cards. Each other player reveals the top 3 cards of their deck, discards the Actions and Treasures, and puts the rest back on top in any order.' }),
d('vault', 18, 5, ['action'], { name: 'Vault', plus: { cards: 2 }, text: '+2 Cards. Discard any number of cards for +1 Coin each. Each other player may discard 2 cards to draw a card.' }),
d('warchest', 19, 5, ['treasure'], { name: 'War Chest', coin: 0, text: 'The player to your left names a card. Gain a card costing up to 5 Coins that has not been named for this War Chest this turn.' }),
// $6
d('grandmarket', 20, 6, ['action'], { name: 'Grand Market', plus: { cards: 1, actions: 1, buys: 1, coins: 2 }, text: "+1 Card, +1 Action, +1 Buy, +2 Coins. You can't buy this if you have any Copper in play." }),
d('hoard', 21, 6, ['treasure'], { name: 'Hoard', coin: 2, text: '+2 Coins. While this is in play, when you gain a Victory card, gain a Gold.' }),
// $7
d('bank', 22, 7, ['treasure'], { name: 'Bank', coin: 0, text: 'When you play this, it is worth 1 Coin per Treasure you have in play (counting this).' }),
d('expand', 23, 7, ['action'], { name: 'Expand', text: 'Trash a card from your hand. Gain a card costing up to 3 Coins more than it.' }),
d('forge', 24, 7, ['action'], { name: 'Forge', text: 'Trash any number of cards from your hand. Gain a card costing exactly the total Coin cost of the trashed cards.' }),
d('kingscourt', 25, 7, ['action'], { name: "King's Court", text: 'You may play an Action card from your hand three times.' }),
// $8
d('peddler', 26, 8, ['action'], { name: 'Peddler', plus: { cards: 1, actions: 1, coins: 1 }, text: '+1 Card, +1 Action, +1 Coin. During your Buy phase, this costs 2 Coins less per Action card you have in play.' }),
];
// 25 Kingdom ids (everything except the two basics), in the frame order above.
const KINGDOM_POOL = CARD_DEFS.filter((c) => c.id !== 'platinum' && c.id !== 'colony').map((c) => c.id);
// ── Recommended Kingdoms ──────────────────────────────────────────────────────
// Themed 10-card layouts. Each uses only implemented Prosperity cards. Labels are
// shown as pills in the setup screen; tweak freely.
const PRESETS = {
'beginners': ['bishop', 'city', 'expand', 'forge', 'hoard', 'monument', 'rabble', 'vault', 'watchtower', 'workersvillage'],
'friendly-interactive':['bishop', 'charlatan', 'city', 'collection', 'grandmarket', 'hoard', 'magnate', 'monument', 'rabble', 'vault'],
'bigger-treasures': ['anvil', 'bank', 'crystalball', 'hoard', 'investment', 'magnate', 'mint', 'quarry', 'tiara', 'warchest'],
'the-king': ['bank', 'city', 'expand', 'kingscourt', 'monument', 'peddler', 'quarry', 'rabble', 'vault', 'watchtower'],
};
export const PROSPERITY_PRESET_LABELS = {
'beginners': 'Beginners',
'friendly-interactive':'Friendly Interactive',
'bigger-treasures': 'Bigger Treasures',
'the-king': 'The King',
};
// ── Setup rules ───────────────────────────────────────────────────────────────
// Charlatan: while it is in the Supply, Curses are also Coppers (worth 1 Coin).
function charlatanCurseIsCopper(state) {
if (!state.kingdom.includes('charlatan')) return;
state.curseIsCopper = true; // engine treats Curse as a $1 treasure when this is set
}
// ── Small helpers ───────────────────────────────────────────────────────────
const isTreasure = (id) => isType(id, 'treasure');
const isAction = (id) => isType(id, 'action');
const isVictory = (id) => isType(id, 'victory');
// ── Action effects (on-play) ──────────────────────────────────────────────────
const effects = {
bishop(state, seat, api) {
api.gainVP(state, seat, 1);
const p = state.players[seat];
// Always trash one (player may have to trash their only card; engine allows
// trashing nothing only if hand is empty).
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'bishopTrash' });
else queueBishopOthers(state, seat, api);
},
monument(state, seat, api) {
api.gainVP(state, seat, 1);
},
city(state, seat, api) {
const empties = api.emptyPileCount(state);
const p = state.players[seat];
if (empties >= 1) api.draw(state, seat, 1);
if (empties >= 2) { p.buys += 1; p.coins += 1; }
},
rabble(state, seat, api) {
api.queueAttack(state, seat, (o) => ({ type: 'rabbleAttack', seat: o }), 'rabble');
},
charlatan(state, seat, api) {
api.queueAttack(state, seat, (o) => ({ type: 'gainCurse', seat: o }), 'charlatan');
},
clerk(state, seat, api) {
// Each other player with 5+ cards topdecks one (their choice). Moat-gated.
api.queueAttack(state, seat, (o) => ({ type: 'clerkTopdeck', seat: o }), 'clerk');
},
vault(state, seat, api) {
const p = state.players[seat];
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'vaultDiscard' });
else queueVaultOthers(state, seat, api);
},
expand(state, seat, api) {
const p = state.players[seat];
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'expandTrash' });
},
forge(state, seat, api) {
api.setPending(state, { seat, kind: 'forgeTrash' });
},
kingscourt(state, seat, api) {
const p = state.players[seat];
if (p.hand.some((c) => isAction(c.id))) api.setPending(state, { seat, kind: 'kingsCourtChoose' });
},
mint(state, seat, api) {
const p = state.players[seat];
if (p.hand.some((c) => isTreasure(c.id))) api.setPending(state, { seat, kind: 'mintReveal' });
},
watchtower(state, seat, api) {
const p = state.players[seat];
let guard = 0;
while (p.hand.length < 6 && guard++ < 20) {
if (api.draw(state, seat, 1) === 0) break;
}
},
};
// ── Treasure effects (on-play) ──────────────────────────────────────────────
const treasureEffects = {
bank(state, seat, api) {
const p = state.players[seat];
const treasures = p.inPlay.filter((c) => isTreasure(c.id)).length; // Bank already in play
p.coins += treasures;
},
magnate(state, seat, api) {
const p = state.players[seat];
const treasures = p.hand.filter((c) => isTreasure(c.id)).length;
if (treasures > 0) api.draw(state, seat, treasures);
},
anvil(state, seat, api) {
const p = state.players[seat];
// May discard a Treasure (other than this Anvil, still in hand?) to gain up to $4.
if (p.hand.some((c) => isTreasure(c.id))) api.setPending(state, { seat, kind: 'anvilDiscard' });
},
crystalball(state, seat, api) {
const p = state.players[seat];
if (p.deck.length === 0) api.reshuffle(state, seat);
if (p.deck.length === 0) return;
const top = p.deck[0];
api.setPending(state, { seat, kind: 'crystalBall', cardIid: top.iid, cardId: top.id });
},
investment(state, seat, api) {
// Trash this Investment from play, then choose mode.
api.trashFromInPlay(state, seat, currentlyPlayed(state, seat, 'investment'));
api.setPending(state, { seat, kind: 'investmentChoice' });
},
collection(state, seat, api) {
// Coins/buys applied via def.plus in applyTreasure; gain trigger handled in onGain.
},
quarry() { /* cost reduction handled by costModifiers */ },
hoard() { /* gain trigger handled by onGain */ },
tiara(state, seat, api) {
// Arm "next gain may be topdecked" for the rest of this turn.
state.tiaraTopdeckArmed = (state.tiaraTopdeckArmed ?? 0) + 1;
// (Treasure-doubling for the first Treasure each turn is approximated: the
// engine doubles the next Treasure's coin output via state.tiaraDoubleArmed.)
state.tiaraDoubleArmed = true;
},
warchest(state, seat, api) {
// The player to your left names a card to deny; we gain the best legal card
// costing up to $5 that wasn't named for this War Chest this turn.
const left = (seat + 1) % state.playerCount;
state._warchestNamed ??= [];
const named = api.warchestName(state, left, seat); // AI/opponent names a card id
if (named) state._warchestNamed.push(named);
api.setPending(state, { seat, kind: 'warchestGain', named: state._warchestNamed.slice() });
},
};
function currentlyPlayed(state, seat, id) {
const p = state.players[seat];
// The just-played copy is the last matching card in play.
for (let i = p.inPlay.length - 1; i >= 0; i--) if (p.inPlay[i].id === id) return p.inPlay[i].iid;
return null;
}
// ── While-in-play gain triggers ───────────────────────────────────────────────
const onGain = {
hoard(state, gainSeat, gainedInst, api) {
// Active player's Hoard: when they gain a Victory card (by any means), gain a Gold.
if (gainSeat !== state.turn) return;
if (!isVictory(gainedInst.id)) return;
const hoards = state.players[gainSeat].inPlay.filter((c) => c.id === 'hoard').length;
for (let i = 0; i < hoards; i++) api.gain(state, gainSeat, 'gold', 'discard');
},
collection(state, gainSeat, gainedInst, api) {
if (gainSeat !== state.turn) return;
if (!isAction(gainedInst.id)) return;
const n = state.players[gainSeat].inPlay.filter((c) => c.id === 'collection').length;
if (n > 0) api.gainVP(state, gainSeat, n);
},
tiara(state, gainSeat, gainedInst, api) {
if (gainSeat !== state.turn) return;
if (!(state.tiaraTopdeckArmed > 0)) return;
// Offer to topdeck the just-gained card (only if it's somewhere we can move it).
state.tiaraTopdeckArmed -= 1;
api.setPending(state, { seat: gainSeat, kind: 'tiaraTopdeck', cardIid: gainedInst.iid, cardId: gainedInst.id });
},
};
// ── Reaction-on-gain (revealed from hand) ─────────────────────────────────────
const gainReactions = {
watchtower(state, gainSeat, gainedInst, api) {
const p = state.players[gainSeat];
if (!p.hand.some((c) => c.id === 'watchtower')) return;
api.setPending(state, { seat: gainSeat, kind: 'watchtowerReact', cardIid: gainedInst.iid, cardId: gainedInst.id });
},
};
// ── On-buy effects ────────────────────────────────────────────────────────────
const onBuy = {
mint(state, seat, api) {
const p = state.players[seat];
const treasures = p.inPlay.filter((c) => isTreasure(c.id));
p.inPlay = p.inPlay.filter((c) => !isTreasure(c.id));
for (const c of treasures) api.trashCard(state, c);
},
};
// ── Cost modifiers ────────────────────────────────────────────────────────────
function quarryMod(state, id, ctx) {
// Quarry: while in play, Action cards cost $2 less each Quarry.
if (!isAction(id)) return 0;
const seat = ctx.seat ?? state.turn;
const n = state.players[seat]?.inPlay.filter((c) => c.id === 'quarry').length ?? 0;
return -2 * n;
}
function peddlerMod(state, id, ctx) {
// Peddler: during the owner's Buy phase, costs $2 less per Action in play.
if (id !== 'peddler') return 0;
if (ctx.phase !== 'buy') return 0;
const seat = ctx.seat ?? state.turn;
const actions = state.players[seat]?.inPlay.filter((c) => isAction(c.id)).length ?? 0;
return -2 * actions;
}
// ── Buy restrictions ──────────────────────────────────────────────────────────
const buyRestrictions = {
grandmarket(state, seat) {
// Can't buy Grand Market with any Copper in play.
return !state.players[seat].inPlay.some((c) => c.id === 'copper');
},
};
// ── Custom queue tasks ────────────────────────────────────────────────────────
const tasks = {
gainCurse(state, task, api) {
api.gain(state, task.seat, 'curse', 'discard');
},
rabbleAttack(state, task, api) {
const seat = task.seat;
const p = state.players[seat];
const revealed = [];
for (let i = 0; i < 3; i++) {
if (p.deck.length === 0) api.reshuffle(state, seat);
if (p.deck.length === 0) break;
revealed.push(p.deck.shift());
}
api.log(state, { kind: 'reveal', seat, ids: revealed.map((c) => c.id) });
const back = [];
for (const c of revealed) {
if (isAction(c.id) || isTreasure(c.id)) p.discard.push(c);
else back.push(c);
}
// Put the rest back on top (victims rarely care about order vs the AI; keep order).
for (let i = back.length - 1; i >= 0; i--) p.deck.unshift(back[i]);
},
clerkTopdeck(state, task, api) {
const seat = task.seat;
const p = state.players[seat];
if (p.hand.length >= 5) api.setPending(state, { seat, kind: 'clerkTopdeck' });
},
bishopMayTrash(state, task, api) {
const seat = task.seat;
if (state.players[seat].hand.length > 0) api.setPending(state, { seat, kind: 'bishopMayTrash' });
},
vaultMayDiscard(state, task, api) {
const seat = task.seat;
if (state.players[seat].hand.length >= 2) api.setPending(state, { seat, kind: 'vaultMayDiscard' });
},
};
function queueBishopOthers(state, seat, api) {
api.queue(state, api.otherSeats(state, seat).map((o) => ({ type: 'bishopMayTrash', seat: o })));
}
function queueVaultOthers(state, seat, api) {
api.queue(state, api.otherSeats(state, seat).map((o) => ({ type: 'vaultMayDiscard', seat: o })));
}
// ── Pending resolution (applies a chosen action; engine resumes the queue) ─────
const resolvers = {
bishopTrash(state, pend, choice, api) {
const seat = pend.seat;
const iid = choice?.iid;
const c = iid != null ? api.trashFromHand(state, seat, iid) : null;
if (c) {
const half = Math.floor(api.cardCost(state, c.id, { seat }) / 2);
if (half > 0) api.gainVP(state, seat, half);
}
queueBishopOthers(state, seat, api);
},
bishopMayTrash(state, pend, choice, api) {
const seat = pend.seat;
if (choice?.iid != null) api.trashFromHand(state, seat, choice.iid);
},
vaultDiscard(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
for (const iid of iids) api.discardFromHand(state, seat, iid);
p.coins += iids.length;
queueVaultOthers(state, seat, api);
},
vaultMayDiscard(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
if (iids.length >= 2) {
api.discardFromHand(state, seat, iids[0]);
api.discardFromHand(state, seat, iids[1]);
api.draw(state, seat, 1);
}
},
expandTrash(state, pend, choice, api) {
const seat = pend.seat;
const c = choice?.iid != null ? api.trashFromHand(state, seat, choice.iid) : null;
if (c) {
const maxCost = api.cardCost(state, c.id, { seat }) + 3;
api.setPending(state, { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'expand' });
}
},
forgeTrash(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
let total = 0;
for (const iid of iids) {
const c = p.hand.find((h) => h.iid === iid);
if (c) total += api.cardCost(state, c.id, { seat });
}
for (const iid of iids) api.trashFromHand(state, seat, iid);
// Gain a card costing EXACTLY `total`.
api.setPending(state, { seat, kind: 'forgeGain', exactCost: total });
},
forgeGain(state, pend, choice, api) {
const seat = pend.seat;
const id = choice?.id;
if (id && api.cardCost(state, id, { seat }) === pend.exactCost && (state.supply[id] ?? 0) > 0) {
api.gain(state, seat, id, 'discard');
}
},
kingsCourtChoose(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const idx = choice?.iid != null ? p.hand.findIndex((c) => c.iid === choice.iid && isAction(c.id)) : -1;
if (idx !== -1) {
const [c] = p.hand.splice(idx, 1);
p.inPlay.push(c);
api.log(state, { kind: 'play', seat, id: c.id, throne: true });
api.queue(state, [
{ type: 'effect', seat, id: c.id },
{ type: 'effect', seat, id: c.id },
{ type: 'effect', seat, id: c.id },
]);
}
},
mintReveal(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const c = choice?.iid != null ? p.hand.find((h) => h.iid === choice.iid && isTreasure(h.id)) : null;
if (c) api.gain(state, seat, c.id, 'discard');
},
anvilDiscard(state, pend, choice, api) {
const seat = pend.seat;
if (choice?.iid != null) {
const c = api.discardFromHand(state, seat, choice.iid);
if (c) api.setPending(state, { seat, kind: 'gainFromSupply', maxCost: 4, dest: 'discard', source: 'anvil' });
}
},
crystalBall(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const idx = p.deck.findIndex((c) => c.iid === pend.cardIid);
if (idx !== 0) return; // top card moved unexpectedly
if (choice?.action === 'trash') {
const [c] = p.deck.splice(0, 1);
api.trashCard(state, c);
} else if (choice?.action === 'discard') {
const [c] = p.deck.splice(0, 1);
p.discard.push(c);
} else if (choice?.action === 'play' && (isAction(pend.cardId) || isTreasure(pend.cardId))) {
const [c] = p.deck.splice(0, 1);
p.inPlay.push(c);
api.log(state, { kind: 'play', seat, id: c.id });
if (isTreasure(c.id)) api.playTreasureEffect(state, seat, c);
else api.queue(state, [{ type: 'effect', seat, id: c.id }]);
}
// else: leave on top
},
investmentChoice(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
if (choice?.mode === 'coin') {
p.coins += 1;
} else {
// trash a Treasure from hand, +1 VP, +1 VP per differently named Treasure revealed.
if (choice?.iid != null) api.trashFromHand(state, seat, choice.iid);
api.gainVP(state, seat, 1);
const names = new Set(p.hand.filter((c) => isTreasure(c.id)).map((c) => c.id));
api.gainVP(state, seat, names.size);
}
},
tiaraTopdeck(state, pend, choice, api) {
if (!choice?.topdeck) return;
const seat = pend.seat;
const p = state.players[seat];
// Find the gained card in discard (default gain dest) or hand and move to deck top.
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); return; }
idx = p.hand.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.hand.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); }
},
watchtowerReact(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
if (choice?.action === 'trash') {
// Trash the just-gained card from wherever it landed (discard by default).
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); api.trashCard(state, c); return; }
idx = p.hand.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.hand.splice(idx, 1); api.trashCard(state, c); }
} else if (choice?.action === 'topdeck') {
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); }
}
},
clerkTopdeck(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
let iid = choice?.iid;
if (iid == null || !p.hand.some((c) => c.iid === iid)) iid = p.hand[0]?.iid; // must topdeck something
if (iid != null) {
const idx = p.hand.findIndex((c) => c.iid === iid);
const [c] = p.hand.splice(idx, 1);
p.deck.unshift(c);
api.log(state, { kind: 'topdeck', seat, id: c.id });
}
},
warchestGain(state, pend, choice, api) {
const seat = pend.seat;
const id = choice?.id;
const named = new Set(pend.named ?? []);
if (id && !named.has(id) && api.cardCost(state, id, { seat }) <= 5 && (state.supply[id] ?? 0) > 0) {
api.gain(state, seat, id, 'discard');
}
},
};
// ── AI hooks (layered on base; absent keys fall through to base defaults) ─────
const ai = {
terminalPriority: {
witch: 96, rabble: 88, charlatan: 86, militia: 80, clerk: 78,
kingscourt: 72, expand: 58, forge: 57, bishop: 52, mint: 48,
monument: 46, vault: 44, city: 20, watchtower: 18,
},
engineBuys: [
{ id: 'grandmarket', cap: 4 }, { id: 'kingscourt', cap: 2 }, { id: 'city', cap: 4 },
{ id: 'peddler', cap: 5 }, { id: 'monument', cap: 3 }, { id: 'collection', cap: 2 },
{ id: 'hoard', cap: 2 }, { id: 'vault', cap: 2 }, { id: 'rabble', cap: 1 },
{ id: 'bishop', cap: 1 }, { id: 'watchtower', cap: 1 }, { id: 'workersvillage', cap: 4 },
{ id: 'charlatan', cap: 1 },
],
pending: {
bishopTrash(state, seat, pend, api) {
const p = state.players[seat];
const c = pickToTrash(state, seat, api) ?? p.hand.slice().sort((a, b) => api.cardCost(state, a.id) - api.cardCost(state, b.id))[0];
return { iid: c?.iid ?? null };
},
bishopMayTrash(state, seat, pend, api) {
const junk = junkInHand(state, seat, api);
return { iid: junk?.iid ?? null };
},
vaultDiscard(state, seat, pend, api) {
const p = state.players[seat];
const junk = p.hand.filter((c) => isVictory(c.id) || c.id === 'curse' || c.id === 'copper');
return { iids: junk.map((c) => c.iid) };
},
vaultMayDiscard(state, seat, pend, api) {
const p = state.players[seat];
const junk = p.hand.filter((c) => isVictory(c.id) || c.id === 'curse').slice(0, 2);
return { iids: junk.length >= 2 ? junk.map((c) => c.iid) : [] };
},
expandTrash(state, seat, pend, api) {
const c = junkInHand(state, seat, api) ?? state.players[seat].hand.slice().sort((a, b) => api.cardCost(state, a.id) - api.cardCost(state, b.id))[0];
return { iid: c?.iid ?? null };
},
forgeTrash(state, seat, pend, api) {
// Trash Coppers/Estates/Curses to forge toward a $6-$8 card if total lands right.
const p = state.players[seat];
const junk = p.hand.filter((c) => c.id === 'copper' || c.id === 'estate' || c.id === 'curse');
return { iids: junk.map((c) => c.iid) };
},
forgeGain(state, seat, pend, api) {
const id = api.bestGain(state, seat, pend.exactCost, false, { exact: true });
return { id };
},
kingsCourtChoose(state, seat, pend, api) {
const p = state.players[seat];
const actions = p.hand.filter((c) => isAction(c.id) && c.id !== 'kingscourt')
.sort((a, b) => (ai.terminalPriority[b.id] ?? api.cardCost(state, b.id)) - (ai.terminalPriority[a.id] ?? api.cardCost(state, a.id)));
return { iid: actions[0]?.iid ?? null };
},
mintReveal(state, seat, pend, api) {
const p = state.players[seat];
const best = p.hand.filter((c) => isTreasure(c.id)).sort((a, b) => api.cardCost(state, b.id) - api.cardCost(state, a.id))[0];
return { iid: best?.iid ?? null };
},
anvilDiscard(state, seat, pend, api) {
// Discard a Copper to gain a $4 if a good $4 exists; else decline.
const p = state.players[seat];
const copper = p.hand.find((c) => c.id === 'copper');
return { iid: copper?.iid ?? null };
},
crystalBall(state, seat, pend, api) {
const id = pend.cardId;
if (id === 'curse' || id === 'estate') return { action: 'trash' };
if (isVictory(id)) return { action: 'discard' };
if (isAction(id) || isTreasure(id)) return { action: 'play' };
return { action: 'keep' };
},
investmentChoice(state, seat, pend, api) {
const p = state.players[seat];
const copper = p.hand.find((c) => c.id === 'copper');
if (copper) return { mode: 'trash', iid: copper.iid };
return { mode: 'coin' };
},
tiaraTopdeck(state, seat, pend, api) {
// Topdeck good non-victory gains; leave junk in discard.
const good = isAction(pend.cardId) || pend.cardId === 'gold' || pend.cardId === 'platinum';
return { topdeck: good };
},
watchtowerReact(state, seat, pend, api) {
if (pend.cardId === 'curse') return { action: 'trash' };
if (pend.cardId === 'copper' && api.countOwned(state, seat, 'copper') > 3) return { action: 'trash' };
return { action: 'keep' };
},
clerkTopdeck(state, seat, pend, api) {
const p = state.players[seat];
const ranked = p.hand.slice().sort((a, b) => api.discardRank(b.id) - api.discardRank(a.id));
return { iid: ranked[0]?.iid ?? null };
},
warchestGain(state, seat, pend, api) {
const id = api.bestGain(state, seat, 5, false, { exclude: pend.named });
return { id };
},
},
// The opponent to a War Chest player names a card to deny (we deny Province/Gold/best action).
warchestName(state, namerSeat, ownerSeat, api) {
if ((state.supply.province ?? 0) > 0) return 'province';
if ((state.supply.gold ?? 0) > 0) return 'gold';
return null;
},
};
function pickToTrash(state, seat, api) {
const p = state.players[seat];
return p.hand.find((c) => c.id === 'curse')
?? p.hand.find((c) => c.id === 'estate')
?? p.hand.find((c) => c.id === 'copper');
}
function junkInHand(state, seat, api) {
const p = state.players[seat];
return p.hand.find((c) => c.id === 'curse')
?? p.hand.find((c) => c.id === 'estate')
?? p.hand.find((c) => c.id === 'copper');
}
export const prosperity = {
id: 'prosperity',
name: 'Prosperity',
sheet: SHEET,
cards: CARD_DEFS,
basics: ['platinum', 'colony'],
basicPileSize: (id, playerCount) => {
if (id === 'platinum') return 12;
if (id === 'colony') return playerCount <= 2 ? 8 : 12;
return 10;
},
kingdomPool: KINGDOM_POOL,
presets: PRESETS,
presetLabels: PROSPERITY_PRESET_LABELS,
setupRules: [charlatanCurseIsCopper],
effects,
treasureEffects,
onGain,
gainReactions,
onBuy,
costModifiers: [quarryMod, peddlerMod],
buyRestrictions,
tasks,
resolvers,
ai,
};

View File

@ -13,6 +13,7 @@ export default class GameRoomScene extends Phaser.Scene {
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? 'standard';
this.expansion = data.expansion ?? 'base';
this.deckMode = data.deckMode ?? 'standard';
this.wordLength = data.wordLength ?? 4;
}
@ -26,6 +27,7 @@ export default class GameRoomScene extends Phaser.Scene {
playfield: this.playfield,
cardBack: this.cardBack,
tilePlacement: this.tilePlacement,
expansion: this.expansion,
deckMode: this.deckMode,
wordLength: this.wordLength,
});

View File

@ -33,6 +33,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.cardBackTiles = [];
this.selectedTilePlacement = 'standard';
this.selectedMatchVariant = 4;
this.selectedExpansion = 'base';
this.selectedDeckMode = 'standard';
this.selectedWordLength = 4;
this._initializing = false;
@ -540,59 +541,121 @@ export default class OpponentSelectScene extends Phaser.Scene {
});
}
// ── Dominion: Kingdom deck mode toggle ─────────────────────────────────────
buildDeckModeSection(centerX, centerY) {
const options = [
{ id: 'standard', label: 'Standard' },
// ── Dominion: Expansion + Kingdom selection ────────────────────────────────
// Kingdom preset options per expansion. Each id must match a preset key in the
// corresponding expansion module (or the base KINGDOM_PRESETS), plus 'random'.
static DOMINION_EXPANSIONS = [
{ id: 'base', label: 'Base Game' },
{ id: 'prosperity', label: 'Prosperity' },
];
static DOMINION_KINGDOMS = {
base: [
{ id: 'standard', label: 'Standard' },
{ id: 'size-distortion', label: 'Size Distortion' },
{ id: 'deck-top', label: 'Deck Top' },
{ id: 'silver-gold', label: 'Silver & Gold' },
{ id: 'helpful-actions', label: 'Helpful Actions' },
{ id: 'random', label: 'Random' },
];
const pillW = 150, pillH = 40, pillGap = 12;
const cols = 3;
const rowW = cols * pillW + (cols - 1) * pillGap;
// Two rows — shift the whole section up so both rows sit above y=1080.
const labelY = centerY - 53;
const row0Y = centerY - 12;
const row1Y = centerY + 38;
],
prosperity: [
{ id: 'beginners', label: 'Beginners' },
{ id: 'friendly-interactive', label: 'Friendly' },
{ id: 'bigger-treasures', label: 'Bigger Treasures' },
{ id: 'the-king', label: 'The King' },
{ id: 'random', label: 'Random' },
],
};
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);
buildDeckModeSection(centerX, centerY) {
const C = OpponentSelectScene;
// Vertical budget in the left column (~y 930..1078).
const expLabelY = centerY - 80;
const expRowY = centerY - 52;
this._kingdomLabelY = centerY - 14;
this._kingdomRow0Y = centerY + 12;
this._kingdomRow1Y = centerY + 46;
this._kingdomCenterX = centerX;
const mkLabel = (y, text) => {
const t = this.add.text(centerX, y, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const bg = this.add.rectangle(centerX, y, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(bg, t);
};
// Expansion picker.
mkLabel(expLabelY, 'Expansion');
const expPillW = 150, expPillH = 36, expGap = 14;
const expRowW = C.DOMINION_EXPANSIONS.length * expPillW + (C.DOMINION_EXPANSIONS.length - 1) * expGap;
this._expansionBtns = [];
C.DOMINION_EXPANSIONS.forEach((opt, i) => {
const x = centerX - expRowW / 2 + i * (expPillW + expGap) + expPillW / 2;
const sel = this.selectedExpansion === opt.id;
const bg = this.add.rectangle(x, expRowY, expPillW, expPillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, expRowY, expPillW, expPillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, expRowY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
bg.on('pointerup', () => {
if (this.selectedExpansion === opt.id) return;
this.selectedExpansion = opt.id;
// Reset Kingdom to the first preset of the newly-chosen expansion.
this.selectedDeckMode = (C.DOMINION_KINGDOMS[opt.id] ?? C.DOMINION_KINGDOMS.base)[0].id;
this._expansionBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedExpansion ? COLORS.accent : COLORS.muted));
this.renderKingdomPills();
});
bg.on('pointerover', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._expansionBtns.push({ bg, id: opt.id });
});
// Kingdom label + (dynamic) pills.
mkLabel(this._kingdomLabelY, 'Kingdom');
this._kingdomObjs = [];
this.renderKingdomPills();
}
// (Re)build the Kingdom preset pills for the current expansion.
renderKingdomPills() {
const C = OpponentSelectScene;
(this._kingdomObjs ?? []).forEach((o) => o.destroy());
this._kingdomObjs = [];
this._deckModeBtns = [];
const options = C.DOMINION_KINGDOMS[this.selectedExpansion] ?? C.DOMINION_KINGDOMS.base;
if (!options.some((o) => o.id === this.selectedDeckMode)) this.selectedDeckMode = options[0].id;
const centerX = this._kingdomCenterX;
const pillW = 150, pillH = 34, pillGap = 12, cols = 3;
const rowW = cols * pillW + (cols - 1) * pillGap;
options.forEach((opt, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = centerX - rowW / 2 + col * (pillW + pillGap) + pillW / 2;
const y = row === 0 ? row0Y : row1Y;
const isSelected = this.selectedDeckMode === opt.id;
const col = i % cols, row = Math.floor(i / cols);
const x = centerX - rowW / 2 + col * (pillW + pillGap) + pillW / 2;
const y = row === 0 ? this._kingdomRow0Y : this._kingdomRow1Y;
const sel = this.selectedDeckMode === opt.id;
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
const label = this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => {
this._deckModeBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted)
);
};
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 });
this._kingdomObjs.push(bg, pillBg, label);
});
}
@ -809,6 +872,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
cardBack: this.selectedCardBack,
tilePlacement: this.selectedTilePlacement,
matchVariant: this.selectedMatchVariant,
expansion: this.selectedExpansion,
deckMode: this.selectedDeckMode,
wordLength: this.selectedWordLength,
});

View File

@ -69,6 +69,9 @@ export default class PreloadScene extends Phaser.Scene {
// 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 });
// Prosperity expansion art (frame order documented in expansions/prosperity.js).
// Optional — same procedural fallback applies when the sheet is absent.
this.load.spritesheet('dominion-prosperity', '/assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 });
}
async create() {