import { Card } from '../cards/Card'; import { Action, ActionType, AttackAction, DefendAction } from '../rules/Action'; import { Phase } from '../rules/GameState'; import { Bot } from './Bot'; import { isTrump, PlayerView } from './PlayerView'; /** * The bot described in GAMEPLAY.md, implemented literally. * * - Opening a turn: the cheapest non-trump; a trump only if that is all it has. * - Adding to an attack: every eligible non-trump it holds. Trumps go in only * when the deck is empty and its hand is nothing but trumps. * - Defending: the cheapest card that does the job, non-trumps first, and a * take only when nothing in hand can beat the attack. * * Deliberately simple and entirely deterministic — it is the baseline a smarter * opponent will be measured against, and the workhorse of the soak tests. * * Two points GAMEPLAY.md leaves open, decided here: cards are added one per * decision, cheapest first (the rules core serialises additions anyway), and * when several attacks are unbeaten the lowest slot is answered first. */ export class BasicBot implements Bot { readonly name: string; constructor(name = 'basic') { this.name = name; } decide(view: PlayerView): Action { if (!view.onMove || view.legalActions.length === 0) { throw new Error(`${this.name} was asked to move out of turn`); } switch (view.phase) { case Phase.Attack: return this._openTurn(view); case Phase.Reinforce: return this._reinforce(view); case Phase.Defence: return this._defend(view); default: return view.legalActions[0]; } } /** Cheapest non-trump, or the cheapest trump when the hand is all trumps. */ private _openTurn(view: PlayerView): Action { const attacks = view.legalActions.filter(isAttack); return bestBy(attacks, (a) => cost(view, a.card)); } private _reinforce(view: PlayerView): Action { const attacks = view.legalActions.filter(isAttack); const nonTrump = attacks.filter((a) => !isTrump(view, a.card)); if (nonTrump.length > 0) { return bestBy(nonTrump, (a) => a.card.rank); } // Throwing trumps away is only worth it at the very end of a game, when // the deck is exhausted and there is nothing else left to throw. const holdingOnlyTrumps = view.hand.every((card) => isTrump(view, card)); if (attacks.length > 0 && view.deckRemaining === 0 && holdingOnlyTrumps) { return bestBy(attacks, (a) => a.card.rank); } return pass(view); } private _defend(view: PlayerView): Action { const defences = view.legalActions.filter(isDefend); if (defences.length === 0) { return take(view); } const firstSlot = Math.min(...defences.map((d) => d.slot)); const forSlot = defences.filter((d) => d.slot === firstSlot); return bestBy(forSlot, (d) => cost(view, d.card)); } } /** Ranks a card for disposal: non-trumps first, then by value. */ function cost(view: PlayerView, card: Card): number { return (isTrump(view, card) ? 100 : 0) + card.rank; } function isAttack(action: Action): action is AttackAction { return action.type === ActionType.Attack; } function isDefend(action: Action): action is DefendAction { return action.type === ActionType.Defend; } function pass(view: PlayerView): Action { const action = view.legalActions.find((a) => a.type === ActionType.Pass); if (!action) throw new Error('Expected passing to be legal'); return action; } function take(view: PlayerView): Action { const action = view.legalActions.find((a) => a.type === ActionType.Take); if (!action) throw new Error('Expected taking to be legal'); return action; } /** Lowest score wins; ties break on card id so the choice is reproducible. */ function bestBy(items: readonly T[], score: (item: T) => number): T { let best = items[0]; let bestScore = score(best); for (const item of items.slice(1)) { const s = score(item); if (s < bestScore || (s === bestScore && item.card.id < best.card.id)) { best = item; bestScore = s; } } return best; }