Files
ftcandClaude Opus 5 c60fbcbb94 M2: бот и headless-прогон матчей
- PlayerView: проекция состояния, в которой боту видно только то,
  что видно живому игроку (своя рука, стол, размеры чужих рук, счётчики)
- BasicBot: политика из GAMEPLAY.md, полностью детерминированная
- Match: обёртка с состоянием и подпиской на события (её же возьмёт UI)
- runMatch: прогон матча без движка и без таймеров
- soak-тесты: 3500 полных матчей (боты и случайные легальные ходы,
  2-4 игрока), инварианты состояния проверяются после каждого действия
- npm run stats:bot — распределение исходов самоигры

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:43:06 +03:00

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