// Dominion — pure interactive state engine. No Phaser imports. // // Deck-building card game. Each turn: Action phase (play action cards) → // Buy phase (play treasures, buy cards) → Cleanup (discard everything, draw 5). // Game ends when the Province pile empties OR any 3 Supply piles empty. // // Card effects vary wildly, and many require a player decision (discard, // trash, gain, reveal, …). We model this with two channels carried in the // state: // • `queue` — a stack of effect "tasks" (plain data) processed depth-first. // • `pending` — the single decision a specific player must make right now. // `null` when nothing is awaited. // The driver (the scene for the human, the AI for opponents) resolves a // pending via `resolvePending`, which applies the choice and resumes the queue. import { getCard, CARDS, BASE_TREASURES, kingdomFor, isType, } from './DominionCards.js'; // Mulberry32 — seedable PRNG (mirrors the other games). function rng(seed) { let a = (seed >>> 0) || 1; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function shuffleInPlace(arr, rand) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } } const STARTING_COPPER = 7; const STARTING_ESTATE = 3; const HAND_SIZE = 5; // ── State construction ────────────────────────────────────────────────────── export function createInitialState({ seed, playerCount = 4, deckMode = 'standard' } = {}) { const rand = seed === undefined ? Math.random : rng(seed); const kingdom = kingdomFor(deckMode, rand); const state = { playerCount, players: [], supply: {}, kingdom, trash: [], turn: 0, phase: 'action', pending: null, queue: [], turnsTaken: Array(playerCount).fill(0), log: [], winnerSeats: [], seed: seed ?? null, deckMode, _rand: rand, _nextIid: 1, // transient per-effect scratch sentryLook: null, libraryAside: null, }; const victoryPile = playerCount <= 2 ? 8 : 12; // Base supply. state.supply.copper = 60 - STARTING_COPPER * playerCount; state.supply.silver = 40; state.supply.gold = 30; state.supply.estate = victoryPile; state.supply.duchy = victoryPile; state.supply.province = victoryPile; state.supply.curse = 10 * (playerCount - 1); // Kingdom supply (Victory-type Kingdom cards, e.g. Gardens, use the Victory count). for (const id of kingdom) { state.supply[id] = isType(id, 'victory') ? victoryPile : 10; } // Players + starting decks (7 Copper, 3 Estate), shuffled, draw 5. for (let seat = 0; seat < playerCount; seat++) { const p = { seat, deck: [], hand: [], discard: [], inPlay: [], actions: 0, buys: 0, coins: 0, merchantSilverBonus: 0, firstSilverPlayed: false, }; for (let i = 0; i < STARTING_COPPER; i++) p.deck.push(mint(state, 'copper')); for (let i = 0; i < STARTING_ESTATE; i++) p.deck.push(mint(state, 'estate')); shuffleInPlace(p.deck, rand); state.players.push(p); drawInto(state, p, HAND_SIZE); } startTurn(state); return state; } function mint(state, id) { return { iid: state._nextIid++, id }; } // ── Cloning ─────────────────────────────────────────────────────────────────── function cloneInsts(arr) { return arr.map((c) => ({ iid: c.iid, id: c.id })); } export function cloneState(state) { const out = { playerCount: state.playerCount, players: state.players.map((p) => ({ seat: p.seat, deck: cloneInsts(p.deck), hand: cloneInsts(p.hand), discard: cloneInsts(p.discard), inPlay: cloneInsts(p.inPlay), actions: p.actions, buys: p.buys, coins: p.coins, merchantSilverBonus: p.merchantSilverBonus, firstSilverPlayed: p.firstSilverPlayed, })), supply: { ...state.supply }, kingdom: state.kingdom.slice(), trash: cloneInsts(state.trash), turn: state.turn, phase: state.phase, pending: state.pending ? JSON.parse(JSON.stringify(state.pending)) : null, queue: state.queue.map((t) => JSON.parse(JSON.stringify(t))), turnsTaken: state.turnsTaken.slice(), log: state.log.map((e) => ({ ...e })), winnerSeats: state.winnerSeats.slice(), seed: state.seed, deckMode: state.deckMode, _rand: state._rand, _nextIid: state._nextIid, sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null, libraryAside: state.libraryAside ? cloneInsts(state.libraryAside) : null, }; return out; } // ── Card movement helpers ───────────────────────────────────────────────────── function reshuffle(state, p) { if (p.deck.length > 0 || p.discard.length === 0) return; p.deck = p.discard; p.discard = []; shuffleInPlace(p.deck, state._rand); state.log.push({ kind: 'shuffle', seat: p.seat }); } // Draw `n` from the top (index 0) of the deck into the hand. function drawInto(state, p, n) { let drawn = 0; for (let i = 0; i < n; i++) { if (p.deck.length === 0) reshuffle(state, p); if (p.deck.length === 0) break; p.hand.push(p.deck.shift()); drawn++; } if (drawn > 0) state.log.push({ kind: 'draw', seat: p.seat, n: drawn }); return drawn; } function gain(state, seat, id, dest = 'discard') { if ((state.supply[id] ?? 0) <= 0) return null; state.supply[id] -= 1; const inst = mint(state, id); const p = state.players[seat]; if (dest === 'deck') p.deck.unshift(inst); else if (dest === 'hand') p.hand.push(inst); else p.discard.push(inst); state.log.push({ kind: 'gain', seat, id, dest }); return inst; } function trashFrom(state, p, iid) { const idx = p.hand.findIndex((c) => c.iid === iid); if (idx === -1) return null; const [c] = p.hand.splice(idx, 1); state.trash.push(c); state.log.push({ kind: 'trash', seat: p.seat, id: c.id }); return c; } function discardFromHand(state, p, iid) { const idx = p.hand.findIndex((c) => c.iid === iid); if (idx === -1) return null; const [c] = p.hand.splice(idx, 1); p.discard.push(c); return c; } // ── Turn lifecycle ────────────────────────────────────────────────────────── function startTurn(state) { const p = state.players[state.turn]; p.actions = 1; p.buys = 1; p.coins = 0; p.merchantSilverBonus = 0; p.firstSilverPlayed = false; state.phase = 'action'; state.pending = null; state.queue = []; state.turnsTaken[state.turn] += 1; state.log.push({ kind: 'turnStart', seat: state.turn }); } export function endActionPhase(state) { if (state.phase !== 'action' || state.pending) return state; const next = cloneState(state); next.phase = 'buy'; next.log.push({ kind: 'phase', phase: 'buy', seat: next.turn }); return next; } export function endTurn(state) { if (state.pending) return state; if (state.phase === 'gameOver') return state; const next = cloneState(state); const p = next.players[next.turn]; // Cleanup: discard hand + in-play, draw a fresh hand. p.discard.push(...p.inPlay, ...p.hand); p.inPlay = []; p.hand = []; drawInto(next, p, HAND_SIZE); next.log.push({ kind: 'turnEnd', seat: next.turn }); if (checkGameOver(next)) return next; next.turn = (next.turn + 1) % next.playerCount; startTurn(next); return next; } function checkGameOver(state) { const provinceEmpty = (state.supply.province ?? 0) <= 0; const emptyPiles = Object.values(state.supply).filter((c) => c <= 0).length; if (provinceEmpty || emptyPiles >= 3) { state.phase = 'gameOver'; state.pending = null; state.queue = []; const scores = finalScores(state); const best = Math.max(...scores.map((s) => s.vp)); // Tie-break: among top VP, fewest turns taken wins; still tied = shared. const topVp = scores.filter((s) => s.vp === best); const fewest = Math.min(...topVp.map((s) => state.turnsTaken[s.seat])); state.winnerSeats = topVp.filter((s) => state.turnsTaken[s.seat] === fewest).map((s) => s.seat); state.log.push({ kind: 'gameOver', winnerSeats: state.winnerSeats.slice() }); return true; } return false; } export function isGameOver(state) { return state.phase === 'gameOver'; } // ── Scoring ─────────────────────────────────────────────────────────────────── export function allCards(state, seat) { const p = state.players[seat]; return [...p.deck, ...p.hand, ...p.discard, ...p.inPlay]; } export function finalScores(state) { return state.players.map((p) => { const cards = allCards(state, p.seat); let vp = 0; for (const c of cards) { const def = getCard(c.id); if (c.id === 'gardens') vp += Math.floor(cards.length / 10); else if (def.vp !== undefined) vp += def.vp; } return { seat: p.seat, vp, cards: cards.length }; }); } // ── Playing cards ───────────────────────────────────────────────────────────── export function legalActionIids(state) { if (state.phase !== 'action' || state.pending) return []; const p = state.players[state.turn]; if (p.actions <= 0) return []; return p.hand.filter((c) => isType(c.id, 'action')).map((c) => c.iid); } export function playAction(state, iid) { if (state.phase !== 'action' || state.pending) return state; const p = state.players[state.turn]; if (p.actions <= 0) return state; const idx = p.hand.findIndex((c) => c.iid === iid); if (idx === -1 || !isType(p.hand[idx].id, 'action')) return state; const next = cloneState(state); const np = next.players[next.turn]; const [card] = np.hand.splice(idx, 1); np.inPlay.push(card); np.actions -= 1; next.log.push({ kind: 'play', seat: next.turn, id: card.id }); next.queue.unshift({ type: 'effect', seat: next.turn, id: card.id }); runQueue(next); return next; } export function playTreasure(state, iid) { if (state.phase !== 'buy' || state.pending) return state; const p = state.players[state.turn]; const idx = p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'treasure')); if (idx === -1) return state; const next = cloneState(state); applyTreasure(next, next.turn, idx); return next; } export function playAllTreasures(state) { if (state.phase !== 'buy' || state.pending) return state; const next = cloneState(state); const p = next.players[next.turn]; let idx; while ((idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) { applyTreasure(next, next.turn, idx); } return next; } function applyTreasure(state, seat, handIdx) { const p = state.players[seat]; const [card] = p.hand.splice(handIdx, 1); p.inPlay.push(card); const def = getCard(card.id); p.coins += def.coin ?? 0; if (card.id === 'silver' && !p.firstSilverPlayed) { p.coins += p.merchantSilverBonus; p.firstSilverPlayed = true; } state.log.push({ kind: 'playTreasure', seat, id: card.id }); } export function buyCard(state, id) { if (state.phase !== 'buy' || state.pending) return state; const p = state.players[state.turn]; const def = CARDS[id]; if (!def) return state; if (p.buys <= 0) return state; if ((state.supply[id] ?? 0) <= 0) return state; if (p.coins < def.cost) return state; const next = cloneState(state); const np = next.players[next.turn]; np.coins -= def.cost; np.buys -= 1; gain(next, next.turn, id, 'discard'); next.log.push({ kind: 'buy', seat: next.turn, id }); return next; } // ── Effect engine ───────────────────────────────────────────────────────────── // Process queued tasks depth-first until the queue drains or a player decision // is required (`pending` set). Mutates `state`. function runQueue(state) { let guard = 0; while (state.queue.length > 0 && !state.pending) { if (++guard > 10000) break; const task = state.queue.shift(); execTask(state, task); } } function execTask(state, task) { switch (task.type) { case 'effect': applyEffect(state, task.seat, task.id); break; case 'draw': drawInto(state, state.players[task.seat], task.n); break; case 'gain': gain(state, task.seat, task.id, task.dest ?? 'discard'); break; case 'discardDownTo': { const p = state.players[task.seat]; const excess = p.hand.length - task.n; if (excess > 0) { state.pending = { seat: task.seat, kind: 'discardDownTo', count: excess, source: 'militia' }; } break; } case 'moatThen': { const p = state.players[task.seat]; const hasMoat = p.hand.some((c) => c.id === 'moat'); if (hasMoat) { state.pending = { seat: task.seat, kind: 'moatReveal', attack: task.attack, source: task.source }; } else { state.queue.unshift(task.attack); } break; } case 'witchCurse': gain(state, task.seat, 'curse', 'discard'); break; case 'banditAttack': banditAttack(state, task.seat); break; case 'bureaucratAttack': bureaucratAttack(state, task.seat); break; default: break; } } // Apply a card's printed effect for the active player `seat`. function applyEffect(state, seat, id) { const def = getCard(id); const p = state.players[seat]; // Vanilla bonuses. if (def.plus.actions) p.actions += def.plus.actions; if (def.plus.buys) p.buys += def.plus.buys; if (def.plus.coins) p.coins += def.plus.coins; if (def.plus.cards) drawInto(state, p, def.plus.cards); // Card-specific. const fn = SPECIAL[id]; if (fn) fn(state, seat); } // Other players' seats in turn order starting after `seat`. function otherSeats(state, seat) { const out = []; for (let i = 1; i < state.playerCount; i++) out.push((seat + i) % state.playerCount); return out; } // Queue an attack against every other player, each gated by a Moat check. function queueAttack(state, seat, attackFactory, source) { const tasks = otherSeats(state, seat).map((o) => ({ type: 'moatThen', seat: o, source, attack: attackFactory(o), })); state.queue.unshift(...tasks); } const SPECIAL = { cellar(state, seat) { state.pending = { seat, kind: 'cellarDiscard' }; }, chapel(state, seat) { state.pending = { seat, kind: 'chapelTrash', max: 4 }; }, harbinger(state, seat) { const p = state.players[seat]; if (p.discard.length > 0) state.pending = { seat, kind: 'harbingerTopdeck' }; }, merchant(state, seat) { state.players[seat].merchantSilverBonus += 1; }, vassal(state, seat) { const p = state.players[seat]; if (p.deck.length === 0) reshuffle(state, p); if (p.deck.length === 0) return; const top = p.deck.shift(); p.discard.push(top); state.log.push({ kind: 'vassalDiscard', seat, id: top.id }); if (isType(top.id, 'action')) { state.pending = { seat, kind: 'vassalPlay', cardIid: top.iid, cardId: top.id }; } }, workshop(state, seat) { state.pending = { seat, kind: 'gainFromSupply', maxCost: 4, dest: 'discard', source: 'workshop' }; }, bureaucrat(state, seat) { gain(state, seat, 'silver', 'deck'); queueAttack(state, seat, (o) => ({ type: 'bureaucratAttack', seat: o }), 'bureaucrat'); }, militia(state, seat) { queueAttack(state, seat, (o) => ({ type: 'discardDownTo', seat: o, n: 3 }), 'militia'); }, moneylender(state, seat) { const p = state.players[seat]; if (p.hand.some((c) => c.id === 'copper')) { state.pending = { seat, kind: 'moneylenderTrash' }; } }, poacher(state, seat) { const empties = Object.values(state.supply).filter((c) => c <= 0).length; const p = state.players[seat]; const count = Math.min(empties, p.hand.length); if (count > 0) state.pending = { seat, kind: 'poacherDiscard', count }; }, remodel(state, seat) { const p = state.players[seat]; if (p.hand.length > 0) state.pending = { seat, kind: 'remodelTrash' }; }, throneroom(state, seat) { const p = state.players[seat]; if (p.hand.some((c) => isType(c.id, 'action'))) { state.pending = { seat, kind: 'throneChoose' }; } }, bandit(state, seat) { gain(state, seat, 'gold', 'discard'); queueAttack(state, seat, (o) => ({ type: 'banditAttack', seat: o }), 'bandit'); }, councilroom(state, seat) { const tasks = otherSeats(state, seat).map((o) => ({ type: 'draw', seat: o, n: 1 })); state.queue.unshift(...tasks); }, library(state, seat) { state.libraryAside = []; libraryStep(state, seat); }, mine(state, seat) { const p = state.players[seat]; if (p.hand.some((c) => isType(c.id, 'treasure'))) { state.pending = { seat, kind: 'mineTrash' }; } }, sentry(state, seat) { const p = state.players[seat]; const look = []; for (let i = 0; i < 2; i++) { if (p.deck.length === 0) reshuffle(state, p); if (p.deck.length === 0) break; look.push(p.deck.shift()); } if (look.length === 0) return; state.sentryLook = look; state.pending = { seat, kind: 'sentry', cards: look.map((c) => ({ iid: c.iid, id: c.id })) }; }, witch(state, seat) { queueAttack(state, seat, (o) => ({ type: 'witchCurse', seat: o }), 'witch'); }, artisan(state, seat) { state.pending = { seat, kind: 'gainFromSupply', maxCost: 5, dest: 'hand', source: 'artisan' }; }, }; // Library: draw until 7 in hand, pausing on each Action drawn to ask keep/skip. function libraryStep(state, seat) { const p = state.players[seat]; while (p.hand.length < 7) { if (p.deck.length === 0) reshuffle(state, p); if (p.deck.length === 0) break; const c = p.deck.shift(); if (isType(c.id, 'action')) { // Hold in hand provisionally; ask whether to set aside. p.hand.push(c); state.pending = { seat, kind: 'libraryKeep', cardIid: c.iid, cardId: c.id }; return; } p.hand.push(c); } // Done — discard any set-aside cards. if (state.libraryAside && state.libraryAside.length) { p.discard.push(...state.libraryAside); } state.libraryAside = null; } function banditAttack(state, seat) { const p = state.players[seat]; const revealed = []; for (let i = 0; i < 2; i++) { if (p.deck.length === 0) reshuffle(state, p); if (p.deck.length === 0) break; revealed.push(p.deck.shift()); } state.log.push({ kind: 'reveal', seat, ids: revealed.map((c) => c.id) }); const trashable = revealed.filter((c) => isType(c.id, 'treasure') && c.id !== 'copper'); if (trashable.length === 0) { p.discard.push(...revealed); } else if (trashable.length === 1) { const t = trashable[0]; state.trash.push(t); state.log.push({ kind: 'trash', seat, id: t.id }); p.discard.push(...revealed.filter((c) => c.iid !== t.iid)); } else { // Two trashable treasures — the victim chooses which to trash. state._banditRevealed = revealed.map((c) => ({ iid: c.iid, id: c.id })); state.pending = { seat, kind: 'banditTrash', options: trashable.map((c) => ({ iid: c.iid, id: c.id })), revealed: revealed.map((c) => ({ iid: c.iid, id: c.id })), }; // Stash the actual instances so resolution can place them. state._banditInsts = revealed; } } function bureaucratAttack(state, seat) { const p = state.players[seat]; const victories = p.hand.filter((c) => isType(c.id, 'victory')); if (victories.length === 0) { state.log.push({ kind: 'reveal', seat, ids: p.hand.map((c) => c.id) }); return; } if (victories.length === 1) { const v = victories[0]; const idx = p.hand.findIndex((c) => c.iid === v.iid); const [card] = p.hand.splice(idx, 1); p.deck.unshift(card); state.log.push({ kind: 'topdeck', seat, id: card.id }); return; } state.pending = { seat, kind: 'bureaucratTopdeck', options: victories.map((c) => ({ iid: c.iid, id: c.id })), }; } // ── Decision resolution ───────────────────────────────────────────────────── export function resolvePending(state, choice) { if (!state.pending) return state; const next = cloneState(state); const pend = next.pending; const seat = pend.seat; const p = next.players[seat]; next.pending = null; switch (pend.kind) { case 'cellarDiscard': { const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id)); for (const iid of iids) discardFromHand(next, p, iid); drawInto(next, p, iids.length); break; } case 'chapelTrash': { const iids = (choice?.iids ?? []).slice(0, pend.max) .filter((id) => p.hand.some((c) => c.iid === id)); for (const iid of iids) trashFrom(next, p, iid); break; } case 'harbingerTopdeck': { const iid = choice?.iid; const idx = iid != null ? p.discard.findIndex((c) => c.iid === iid) : -1; if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.deck.unshift(c); next.log.push({ kind: 'topdeck', seat, id: c.id }); } break; } case 'vassalPlay': { if (choice?.play) { const idx = p.discard.findIndex((c) => c.iid === pend.cardIid); if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.inPlay.push(c); next.log.push({ kind: 'play', seat, id: c.id }); next.queue.unshift({ type: 'effect', seat, id: c.id }); } } break; } case 'gainFromSupply': { const id = choice?.id; if (id && canGain(next, id, pend.maxCost, pend.filterTreasure)) { gain(next, seat, id, pend.dest ?? 'discard'); if (pend.source === 'artisan') { // Then put a card from hand onto the deck. if (p.hand.length > 0) next.pending = { seat, kind: 'artisanTopdeck' }; } } break; } case 'artisanTopdeck': { const iid = choice?.iid; const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid) : -1; if (idx !== -1) { const [c] = p.hand.splice(idx, 1); p.deck.unshift(c); next.log.push({ kind: 'topdeck', seat, id: c.id }); } break; } case 'remodelTrash': { const iid = choice?.iid; const c = iid != null ? trashFrom(next, p, iid) : null; if (c) { const maxCost = getCard(c.id).cost + 2; next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'remodel' }; } break; } case 'mineTrash': { const iid = choice?.iid; const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'treasure')) : -1; if (idx !== -1) { const [c] = p.hand.splice(idx, 1); next.trash.push(c); next.log.push({ kind: 'trash', seat, id: c.id }); const maxCost = getCard(c.id).cost + 3; next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'hand', filterTreasure: true, source: 'mine' }; } break; } case 'moneylenderTrash': { if (choice?.confirm) { const idx = p.hand.findIndex((c) => c.id === 'copper'); if (idx !== -1) { const [c] = p.hand.splice(idx, 1); next.trash.push(c); p.coins += 3; next.log.push({ kind: 'trash', seat, id: c.id }); } } break; } case 'throneChoose': { const iid = choice?.iid; const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'action')) : -1; if (idx !== -1) { const [c] = p.hand.splice(idx, 1); p.inPlay.push(c); next.log.push({ kind: 'play', seat, id: c.id, throne: true }); next.queue.unshift({ type: 'effect', seat, id: c.id }, { type: 'effect', seat, id: c.id }); } break; } case 'libraryKeep': { if (!choice?.keep) { // Set the just-drawn action aside (remove from hand → aside pile). const idx = p.hand.findIndex((c) => c.iid === pend.cardIid); if (idx !== -1) { const [c] = p.hand.splice(idx, 1); (next.libraryAside ??= []).push(c); } } libraryStep(next, seat); break; } case 'sentry': { const look = next.sentryLook ?? []; const byIid = new Map(look.map((c) => [c.iid, c])); const trashIds = new Set(choice?.trash ?? []); const discardIds = new Set(choice?.discard ?? []); // Trash & discard chosen cards. for (const c of look) { if (trashIds.has(c.iid)) { next.trash.push(c); next.log.push({ kind: 'trash', seat, id: c.id }); } else if (discardIds.has(c.iid)) { p.discard.push(c); } } // Remaining go back on top in the given order (or default order). const keep = look.filter((c) => !trashIds.has(c.iid) && !discardIds.has(c.iid)); const order = (choice?.top ?? keep.map((c) => c.iid)); const ordered = order.map((iid) => byIid.get(iid)).filter((c) => c && keep.includes(c)); for (const c of keep) if (!ordered.includes(c)) ordered.push(c); // Top of deck = index 0; push so the first listed ends up on top. for (let i = ordered.length - 1; i >= 0; i--) p.deck.unshift(ordered[i]); next.sentryLook = null; break; } case 'poacherDiscard': { const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id)); const need = Math.min(pend.count, p.hand.length); const chosen = iids.slice(0, need); // If the chooser under-selected, auto-fill from hand. while (chosen.length < need) { const c = p.hand.find((h) => !chosen.includes(h.iid)); if (!c) break; chosen.push(c.iid); } for (const iid of chosen) discardFromHand(next, p, iid); break; } case 'discardDownTo': { const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id)); const chosen = iids.slice(0, pend.count); while (chosen.length < pend.count && chosen.length < p.hand.length) { const c = p.hand.find((h) => !chosen.includes(h.iid)); if (!c) break; chosen.push(c.iid); } for (const iid of chosen) discardFromHand(next, p, iid); break; } case 'moatReveal': { if (choice?.reveal) { next.log.push({ kind: 'moat', seat }); } else { next.queue.unshift(pend.attack); } break; } case 'banditTrash': { const insts = state._banditInsts ?? []; const chosenIid = choice?.iid ?? (pend.options[0] && pend.options[0].iid); for (const c of insts) { if (c.iid === chosenIid) { next.trash.push({ iid: c.iid, id: c.id }); next.log.push({ kind: 'trash', seat, id: c.id }); } else p.discard.push({ iid: c.iid, id: c.id }); } delete next._banditInsts; delete next._banditRevealed; break; } case 'bureaucratTopdeck': { const iid = choice?.iid ?? pend.options[0]?.iid; const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid) : -1; if (idx !== -1) { const [c] = p.hand.splice(idx, 1); p.deck.unshift(c); next.log.push({ kind: 'topdeck', seat, id: c.id }); } break; } default: break; } runQueue(next); return next; } // ── Query helpers (used by the AI and UI) ────────────────────────────────────── export function canGain(state, id, maxCost, filterTreasure = false) { const def = CARDS[id]; if (!def) return false; if ((state.supply[id] ?? 0) <= 0) return false; if (def.cost > maxCost) return false; if (filterTreasure && !def.types.includes('treasure')) return false; return true; } export function affordableSupply(state, maxCost, filterTreasure = false) { return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure)); } export function supplyIds(state) { return [...BASE_TREASURES, 'estate', 'duchy', 'province', 'curse', ...state.kingdom]; } export function emptyPileCount(state) { return Object.values(state.supply).filter((c) => c <= 0).length; } export function handCoinValue(state, seat) { const p = state.players[seat]; let coins = 0; let firstSilver = false; for (const c of p.hand) { const def = getCard(c.id); coins += def.coin ?? 0; if (c.id === 'silver' && !firstSilver) { coins += p.merchantSilverBonus; firstSilver = true; } } return coins; }