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