fertig-classic-games/public/src/games/dominion/DominionLogic.js

865 lines
29 KiB
JavaScript

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