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>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user