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:
ftc
2026-09-13 10:43:06 +03:00
co-authored by Claude Opus 5
parent fddab60527
commit c60fbcbb94
19 changed files with 927 additions and 13 deletions
+188
View File
@@ -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/);
});
});