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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Card } from '@core/cards/Card';
|
||||
import { GameState, Phase } from '@core/rules/GameState';
|
||||
import { attackSeats, currentActor, legalActions, maxTableSlots, unbeatenSlots } from '@core/rules/DurakRules';
|
||||
|
||||
/**
|
||||
* Everything that must be true of any reachable state. Called after every single
|
||||
* action in the soak test, so a rule bug is caught at the move that caused it
|
||||
* rather than a hundred moves later.
|
||||
*
|
||||
* Throws with a description of the first violation.
|
||||
*/
|
||||
export function assertInvariants(state: GameState): void {
|
||||
const problems: string[] = [];
|
||||
|
||||
// Cards are conserved: all 36, each exactly once, somewhere.
|
||||
const seen: Card[] = [
|
||||
...state.deck,
|
||||
...state.discard,
|
||||
...state.players.flatMap((p) => p.hand),
|
||||
];
|
||||
for (const slot of state.table) {
|
||||
seen.push(slot.attack);
|
||||
if (slot.defence) seen.push(slot.defence);
|
||||
}
|
||||
if (seen.length !== 36) {
|
||||
problems.push(`${seen.length} cards in play, expected 36`);
|
||||
}
|
||||
if (new Set(seen).size !== seen.length) {
|
||||
problems.push('the same card exists in two places');
|
||||
}
|
||||
|
||||
// The trump card is the bottom of the deck until it is drawn.
|
||||
if (state.deck.length > 0 && state.deck[state.deck.length - 1] !== state.trumpCard) {
|
||||
problems.push('the trump card is not at the bottom of the deck');
|
||||
}
|
||||
if (state.trumpSuit !== state.trumpCard.suit) {
|
||||
problems.push('the trump suit does not match the trump card');
|
||||
}
|
||||
|
||||
// Table shape.
|
||||
if (state.table.length > maxTableSlots(state)) {
|
||||
problems.push(`${state.table.length} attacking cards exceeds the limit`);
|
||||
}
|
||||
for (const slot of state.table) {
|
||||
if (slot.defence && slot.defence === slot.attack) {
|
||||
problems.push('a card beat itself');
|
||||
}
|
||||
}
|
||||
|
||||
// Seats.
|
||||
if (state.attacker === state.defender) {
|
||||
problems.push('the attacker is also the defender');
|
||||
}
|
||||
if (state.phase !== Phase.Finished) {
|
||||
if (state.players[state.attacker].out) problems.push('the attacker has left the game');
|
||||
if (state.players[state.defender].out) problems.push('the defender has left the game');
|
||||
}
|
||||
|
||||
// A player who is out holds nothing; a player who holds nothing while the
|
||||
// deck still has cards should have refilled.
|
||||
for (const player of state.players) {
|
||||
if (player.out && player.hand.length > 0) {
|
||||
problems.push(`${player.id} is out but still holds cards`);
|
||||
}
|
||||
if (!player.out && player.hand.length === 0 && state.phase === Phase.Attack) {
|
||||
problems.push(`${player.id} is at the table with an empty hand`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase consistency.
|
||||
switch (state.phase) {
|
||||
case Phase.Attack:
|
||||
if (state.table.length !== 0) problems.push('a turn opened with cards already down');
|
||||
if (state.defenderTook) problems.push('a turn opened mid-take');
|
||||
break;
|
||||
case Phase.Defence:
|
||||
if (unbeatenSlots(state).length === 0) problems.push('defending with nothing to beat');
|
||||
if (state.defenderTook) problems.push('defending after declaring a take');
|
||||
break;
|
||||
case Phase.Reinforce:
|
||||
if (!state.defenderTook && unbeatenSlots(state).length > 0) {
|
||||
problems.push('adding cards while an attack is unanswered');
|
||||
}
|
||||
if (attackSeats(state).every((s) => state.passed[s])) {
|
||||
problems.push('every attacker has passed but the turn is still open');
|
||||
}
|
||||
break;
|
||||
case Phase.Finished:
|
||||
if (state.players.filter((p) => !p.out).length > 1) {
|
||||
problems.push('the game ended with more than one player left');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Whoever is on move must have something to do.
|
||||
const actor = currentActor(state);
|
||||
if (state.phase !== Phase.Finished) {
|
||||
if (actor === null) {
|
||||
problems.push('nobody is on move in an unfinished game');
|
||||
} else if (legalActions(state, actor).length === 0) {
|
||||
problems.push(`${actor} is on move with no legal action`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`Broken state: ${problems.join('; ')}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Not part of the suite — a measuring tool. Run with `npm run stats:bot`.
|
||||
*
|
||||
* Plays a large number of seeded matches between two BasicBots and prints how
|
||||
* they came out. Useful for sanity-checking a rules change (a sudden jump in
|
||||
* draws or in the first-attacker advantage means something moved) and as the
|
||||
* baseline any smarter bot has to beat.
|
||||
*/
|
||||
import { describe, it } from 'vitest';
|
||||
import { BasicBot } from '@core/bot/BasicBot';
|
||||
import { Match } from '@core/match/Match';
|
||||
import { runMatch } from '@core/match/runMatch';
|
||||
|
||||
const MATCHES = 5000;
|
||||
|
||||
describe('BasicBot vs BasicBot', () => {
|
||||
it(`plays ${MATCHES} matches`, () => {
|
||||
const tally: Record<string, number> = { you: 0, bot: 0, draw: 0 };
|
||||
let firstAttackerLost = 0;
|
||||
let actions = 0;
|
||||
let turns = 0;
|
||||
|
||||
for (let seed = 0; seed < MATCHES; seed++) {
|
||||
const match = new Match({ players: ['you', 'bot'], seed });
|
||||
const firstAttacker = match.state.players[match.state.attacker].id;
|
||||
const result = runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') });
|
||||
|
||||
tally[result.loser ?? 'draw'] += 1;
|
||||
if (result.loser === firstAttacker) firstAttackerLost += 1;
|
||||
actions += result.actions;
|
||||
turns += result.turns;
|
||||
}
|
||||
|
||||
const pct = (n: number) => `${((100 * n) / MATCHES).toFixed(1)}%`;
|
||||
console.log([
|
||||
`matches ${MATCHES}`,
|
||||
`seat 'you' lost ${tally.you} (${pct(tally.you)})`,
|
||||
`seat 'bot' lost ${tally.bot} (${pct(tally.bot)})`,
|
||||
`draws ${tally.draw} (${pct(tally.draw)})`,
|
||||
`first attacker lost ${pct(firstAttackerLost)}`,
|
||||
`average actions ${(actions / MATCHES).toFixed(1)}`,
|
||||
`average turns ${(turns / MATCHES).toFixed(1)}`,
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user