- PlayerView: проекция состояния, в которой боту видно только то, что видно живому игроку (своя рука, стол, размеры чужих рук, счётчики) - BasicBot: политика из GAMEPLAY.md, полностью детерминированная - Match: обёртка с состоянием и подпиской на события (её же возьмёт UI) - runMatch: прогон матча без движка и без таймеров - soak-тесты: 3500 полных матчей (боты и случайные легальные ходы, 2-4 игрока), инварианты состояния проверяются после каждого действия - npm run stats:bot — распределение исходов самоигры Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
4.3 KiB
TypeScript
120 lines
4.3 KiB
TypeScript
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<T extends AttackAction | DefendAction>(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;
|
|
}
|