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,188 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ActionType } from '@core/rules/Action';
|
||||
import { Phase } from '@core/rules/GameState';
|
||||
import { projectView } from '@core/bot/PlayerView';
|
||||
import { BasicBot } from '@core/bot/BasicBot';
|
||||
import { makeState } from '../../support/fixtures';
|
||||
|
||||
const YOU = 'you';
|
||||
const BOT = 'bot';
|
||||
|
||||
const bot = new BasicBot();
|
||||
|
||||
function decideFor(state: Parameters<typeof projectView>[0], player: string) {
|
||||
return bot.decide(projectView(state, player));
|
||||
}
|
||||
|
||||
describe('BasicBot opening a turn', () => {
|
||||
it('leads with its cheapest non-trump', () => {
|
||||
const state = makeState({
|
||||
hands: { [BOT]: '9C 6D KH 7S', [YOU]: 'AH AD' },
|
||||
trump: 'S',
|
||||
deck: '10C 10D',
|
||||
attacker: BOT,
|
||||
});
|
||||
|
||||
const action = decideFor(state, BOT);
|
||||
expect(action.type).toBe(ActionType.Attack);
|
||||
expect(action).toMatchObject({ card: expect.objectContaining({ code: '6♦' }) });
|
||||
});
|
||||
|
||||
it('keeps its trumps back even when they are cheaper', () => {
|
||||
const state = makeState({
|
||||
hands: { [BOT]: '6S 9C', [YOU]: 'AH AD' },
|
||||
trump: 'S',
|
||||
deck: '10C 10D',
|
||||
attacker: BOT,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT)).toMatchObject({
|
||||
card: expect.objectContaining({ code: '9♣' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('leads with its cheapest trump when that is all it holds', () => {
|
||||
const state = makeState({
|
||||
hands: { [BOT]: 'KS 7S', [YOU]: 'AH AD' },
|
||||
trump: 'S',
|
||||
deck: '10C 10D',
|
||||
attacker: BOT,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT)).toMatchObject({
|
||||
card: expect.objectContaining({ code: '7♠' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BasicBot defending', () => {
|
||||
it('beats with the cheapest card that works', () => {
|
||||
const state = makeState({
|
||||
hands: { [YOU]: 'AC', [BOT]: '10D 8D QD 6S' },
|
||||
trump: 'S',
|
||||
deck: '10C 10H',
|
||||
attacker: YOU,
|
||||
table: [{ attack: '7D' }],
|
||||
phase: Phase.Defence,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT)).toMatchObject({
|
||||
type: ActionType.Defend,
|
||||
card: expect.objectContaining({ code: '8♦' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers any non-trump over a cheap trump', () => {
|
||||
const state = makeState({
|
||||
hands: { [YOU]: 'AC', [BOT]: 'AD 6S' },
|
||||
trump: 'S',
|
||||
deck: '10C 10H',
|
||||
attacker: YOU,
|
||||
table: [{ attack: '7D' }],
|
||||
phase: Phase.Defence,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT)).toMatchObject({
|
||||
card: expect.objectContaining({ code: 'A♦' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('spends a trump when nothing else beats the attack', () => {
|
||||
const state = makeState({
|
||||
hands: { [YOU]: 'AC', [BOT]: '6D KS 7S' },
|
||||
trump: 'S',
|
||||
deck: '10C 10H',
|
||||
attacker: YOU,
|
||||
table: [{ attack: '7D' }],
|
||||
phase: Phase.Defence,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT)).toMatchObject({
|
||||
card: expect.objectContaining({ code: '7♠' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('takes when it cannot beat the attack', () => {
|
||||
const state = makeState({
|
||||
hands: { [YOU]: 'AC', [BOT]: '6D 6H' },
|
||||
trump: 'S',
|
||||
deck: '10C 10H',
|
||||
attacker: YOU,
|
||||
table: [{ attack: '7D' }],
|
||||
phase: Phase.Defence,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT).type).toBe(ActionType.Take);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BasicBot adding to an attack', () => {
|
||||
it('throws in every eligible non-trump, cheapest first', () => {
|
||||
const state = makeState({
|
||||
hands: { [BOT]: '7H 7C KD', [YOU]: 'AH AD AC' },
|
||||
trump: 'S',
|
||||
deck: '10C 10D',
|
||||
attacker: BOT,
|
||||
table: [{ attack: '7D', defence: '9D' }],
|
||||
phase: Phase.Reinforce,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT)).toMatchObject({
|
||||
type: ActionType.Attack,
|
||||
card: expect.objectContaining({ code: '7♣' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('holds its trumps back while the deck still has cards', () => {
|
||||
const state = makeState({
|
||||
hands: { [BOT]: '7S KD', [YOU]: 'AH AD AC' },
|
||||
trump: 'S',
|
||||
deck: '10C 10D',
|
||||
attacker: BOT,
|
||||
table: [{ attack: '7D', defence: '9D' }],
|
||||
phase: Phase.Reinforce,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT).type).toBe(ActionType.Pass);
|
||||
});
|
||||
|
||||
it('holds its trumps back at the end if it still has other cards', () => {
|
||||
const state = makeState({
|
||||
hands: { [BOT]: '7S KD', [YOU]: 'AH AD AC' },
|
||||
trump: 'S',
|
||||
attacker: BOT,
|
||||
table: [{ attack: '7D', defence: '9D' }],
|
||||
phase: Phase.Reinforce,
|
||||
});
|
||||
|
||||
expect(state.deck).toHaveLength(0);
|
||||
expect(decideFor(state, BOT).type).toBe(ActionType.Pass);
|
||||
});
|
||||
|
||||
it('throws in a trump once the deck is empty and trumps are all it has', () => {
|
||||
const state = makeState({
|
||||
hands: { [BOT]: '7S KS', [YOU]: 'AH AD AC' },
|
||||
trump: 'S',
|
||||
attacker: BOT,
|
||||
table: [{ attack: '7D', defence: '9D' }],
|
||||
phase: Phase.Reinforce,
|
||||
});
|
||||
|
||||
expect(decideFor(state, BOT)).toMatchObject({
|
||||
type: ActionType.Attack,
|
||||
card: expect.objectContaining({ code: '7♠' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BasicBot contract', () => {
|
||||
it('refuses to move out of turn', () => {
|
||||
const state = makeState({
|
||||
hands: { [YOU]: '6C', [BOT]: '8C' },
|
||||
trump: 'S',
|
||||
deck: '10C',
|
||||
attacker: YOU,
|
||||
});
|
||||
expect(() => decideFor(state, BOT)).toThrow(/out of turn/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user