Added Prosperity
This commit is contained in:
parent
57eeb3bfee
commit
2c95e76b00
|
|
@ -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) : {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue