865 lines
29 KiB
JavaScript
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;
|
|
}
|