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/);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { BasicBot } from '@core/bot/BasicBot';
import { Match } from '@core/match/Match';
import { runMatch } from '@core/match/runMatch';
import { legalActions } from '@core/rules/DurakRules';
import { PlayerId } from '@core/rules/GameState';
import { Rng } from '@core/util/Rng';
import { assertInvariants } from '../../support/invariants';
/** Plays one seeded match, checking every reachable state along the way. */
function playChecked(players: PlayerId[], seed: number, pick?: (n: number, rng: Rng) => number) {
const match = new Match({ players, seed });
const bots = Object.fromEntries(players.map((p) => [p, new BasicBot(p)]));
const rng = new Rng(seed ^ 0x5f3759df);
assertInvariants(match.state);
let actions = 0;
while (!match.finished) {
const actor = match.currentActor;
expect(actor).not.toBeNull();
const options = legalActions(match.state, actor as PlayerId);
expect(options.length).toBeGreaterThan(0);
const action = pick
? options[pick(options.length, rng)]
: bots[actor as PlayerId].decide(match.viewFor(actor as PlayerId));
match.perform(action);
assertInvariants(match.state);
if (++actions > 4000) {
throw new Error(`Match ${seed} will not end`);
}
}
return { match, actions };
}
describe('soak: two basic bots', () => {
it('finishes 2000 seeded matches with every invariant intact', () => {
const outcomes = new Map<string, number>();
for (let seed = 0; seed < 2000; seed++) {
const { match } = playChecked(['you', 'bot'], seed);
const key = match.loser ?? 'draw';
outcomes.set(key, (outcomes.get(key) ?? 0) + 1);
}
// Every match must have produced a verdict.
const total = [...outcomes.values()].reduce((a, b) => a + b, 0);
expect(total).toBe(2000);
// Two identical bots: neither seat should be losing almost every game.
// A wide band — this is a smoke check on the rules, not a balance claim.
for (const seat of ['you', 'bot']) {
expect(outcomes.get(seat) ?? 0).toBeGreaterThan(2000 * 0.2);
}
});
it('finishes three-player matches too', () => {
for (let seed = 0; seed < 300; seed++) {
const { match } = playChecked(['a', 'b', 'c'], seed);
expect(match.finished).toBe(true);
if (match.loser !== null) {
expect(['a', 'b', 'c']).toContain(match.loser);
}
}
});
});
describe('soak: random legal play', () => {
it('survives 1000 matches of arbitrary legal moves', () => {
for (let seed = 0; seed < 1000; seed++) {
const { match } = playChecked(['you', 'bot'], seed, (n, rng) => rng.nextInt(n));
expect(match.finished).toBe(true);
}
});
it('survives random play at a crowded table', () => {
for (let seed = 0; seed < 200; seed++) {
const { match } = playChecked(['a', 'b', 'c', 'd'], seed, (n, rng) => rng.nextInt(n));
expect(match.finished).toBe(true);
}
});
});
describe('match determinism', () => {
it('replays a seed move for move', () => {
const first: string[] = [];
const second: string[] = [];
for (const trace of [first, second]) {
const match = new Match({ players: ['you', 'bot'], seed: 31337 });
runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') }, { trace });
}
expect(first).toEqual(second);
expect(first.length).toBeGreaterThan(10);
});
it('reports the same loser for the same seed', () => {
const play = () => {
const match = new Match({ players: ['you', 'bot'], seed: 555 });
return runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') });
};
expect(play()).toEqual(play());
});
});
describe('runMatch', () => {
it('stops at a seat it has no bot for', () => {
const match = new Match({ players: ['you', 'bot'], seed: 8 });
const result = runMatch(match, { bot: new BasicBot('bot') });
expect(match.finished).toBe(false);
expect(match.currentActor).toBe('you');
expect(result.loser).toBeNull();
});
it('refuses to loop forever', () => {
const match = new Match({ players: ['you', 'bot'], seed: 8 });
expect(() => runMatch(
match,
{ you: new BasicBot('you'), bot: new BasicBot('bot') },
{ maxActions: 3 },
)).toThrow(/did not finish/);
});
});