Детерминированный конечный автомат: apply(state, action) -> {state, events}.
Весь набор правил из GAMEPLAY.md проверяется headless-тестами.
- карты: Suit/Rank/Card (флайвейт, 36 штук), колода
- GameState + клонирование и сериализация в JSON
- действия (attack/defend/take/pass) и события для слоя отображения
- раздача, выбор первого атакующего, лимиты подкидывания,
взятие карт, добор, выбывание игроков, определение дурака
- 64 теста, strict typecheck
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
5.8 KiB
TypeScript
135 lines
5.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { Card } from '@core/cards/Card';
|
|
import { Rank } from '@core/cards/Rank';
|
|
import { Suit } from '@core/cards/Suit';
|
|
import { GameEventType } from '@core/rules/GameEvent';
|
|
import { Phase } from '@core/rules/GameState';
|
|
import { beats, createGame, HAND_SIZE } from '@core/rules/DurakRules';
|
|
|
|
const PLAYERS = ['you', 'bot'];
|
|
|
|
describe('createGame', () => {
|
|
it('deals six cards each and leaves the rest as the deck', () => {
|
|
const { state } = createGame({ players: PLAYERS, seed: 1 });
|
|
|
|
expect(state.players.map((p) => p.hand.length)).toEqual([HAND_SIZE, HAND_SIZE]);
|
|
expect(state.deck).toHaveLength(36 - 2 * HAND_SIZE);
|
|
expect(state.discard).toHaveLength(0);
|
|
expect(state.table).toHaveLength(0);
|
|
expect(state.phase).toBe(Phase.Attack);
|
|
expect(state.turnNumber).toBe(1);
|
|
});
|
|
|
|
it('uses every card exactly once', () => {
|
|
const { state } = createGame({ players: ['a', 'b', 'c'], seed: 77 });
|
|
const all = [...state.deck, ...state.players.flatMap((p) => p.hand)];
|
|
expect(all).toHaveLength(36);
|
|
expect(new Set(all).size).toBe(36);
|
|
});
|
|
|
|
it('puts the trump card at the bottom of the deck', () => {
|
|
const { state } = createGame({ players: PLAYERS, seed: 5 });
|
|
expect(state.deck[state.deck.length - 1]).toBe(state.trumpCard);
|
|
expect(state.trumpSuit).toBe(state.trumpCard.suit);
|
|
});
|
|
|
|
it('announces the deal and the trump before the first turn', () => {
|
|
const { events } = createGame({ players: PLAYERS, seed: 5 });
|
|
const types = events.map((e) => e.type);
|
|
expect(types).toEqual([
|
|
GameEventType.Dealt,
|
|
GameEventType.Dealt,
|
|
GameEventType.TrumpRevealed,
|
|
GameEventType.TurnStarted,
|
|
]);
|
|
});
|
|
|
|
it('is fully determined by its seed', () => {
|
|
const a = createGame({ players: PLAYERS, seed: 12345 });
|
|
const b = createGame({ players: PLAYERS, seed: 12345 });
|
|
expect(a.state.players.map((p) => p.hand.map((c) => c.code)))
|
|
.toEqual(b.state.players.map((p) => p.hand.map((c) => c.code)));
|
|
expect(a.state.attacker).toBe(b.state.attacker);
|
|
});
|
|
|
|
it('rejects impossible tables', () => {
|
|
expect(() => createGame({ players: ['solo'], seed: 1 })).toThrow(RangeError);
|
|
expect(() => createGame({ players: ['a', 'a'], seed: 1 })).toThrow(RangeError);
|
|
expect(() => createGame({ players: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], seed: 1 }))
|
|
.toThrow(RangeError);
|
|
});
|
|
|
|
it('seats the defender directly after the attacker', () => {
|
|
for (let seed = 0; seed < 50; seed++) {
|
|
const { state } = createGame({ players: ['a', 'b', 'c'], seed });
|
|
expect(state.defender).toBe((state.attacker + 1) % 3);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('first attacker', () => {
|
|
/** Finds a seed whose deal satisfies `predicate`, so the rule can be tested on a real deal. */
|
|
function findDeal(predicate: (s: ReturnType<typeof createGame>['state']) => boolean) {
|
|
for (let seed = 0; seed < 20000; seed++) {
|
|
const { state } = createGame({ players: PLAYERS, seed });
|
|
if (predicate(state)) return state;
|
|
}
|
|
throw new Error('No matching deal found');
|
|
}
|
|
|
|
it('is the holder of the lowest trump', () => {
|
|
// Any deal where exactly one side holds trumps makes the rule unambiguous.
|
|
for (let seed = 0; seed < 200; seed++) {
|
|
const { state } = createGame({ players: PLAYERS, seed });
|
|
const lowest = state.players.map((p) => {
|
|
const trumps = p.hand.filter((c) => c.suit === state.trumpSuit);
|
|
return trumps.length ? Math.min(...trumps.map((c) => c.rank)) : Infinity;
|
|
});
|
|
if (lowest[0] === Infinity && lowest[1] === Infinity) continue;
|
|
expect(state.attacker).toBe(lowest[0] < lowest[1] ? 0 : 1);
|
|
}
|
|
});
|
|
|
|
it('falls back to the lowest card when nobody holds a trump', () => {
|
|
const state = findDeal((s) =>
|
|
s.players.every((p) => p.hand.every((c) => c.suit !== s.trumpSuit)));
|
|
|
|
const lowest = state.players.map((p) => Math.min(...p.hand.map((c) => c.rank)));
|
|
if (lowest[0] !== lowest[1]) {
|
|
expect(state.attacker).toBe(lowest[0] < lowest[1] ? 0 : 1);
|
|
}
|
|
// On a tie either seat is correct; the rule says "random".
|
|
expect(state.attacker).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('never picks a seat that is not at the table', () => {
|
|
for (let seed = 0; seed < 500; seed++) {
|
|
const { state } = createGame({ players: ['a', 'b', 'c'], seed });
|
|
expect(state.attacker).toBeLessThan(3);
|
|
expect(state.attacker).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('beats', () => {
|
|
const trump = Suit.Spades;
|
|
it('beats a lower card of the same suit', () => {
|
|
expect(beats(Card.of(Suit.Hearts, Rank.Ten), Card.of(Suit.Hearts, Rank.Nine), trump)).toBe(true);
|
|
expect(beats(Card.of(Suit.Hearts, Rank.Nine), Card.of(Suit.Hearts, Rank.Ten), trump)).toBe(false);
|
|
});
|
|
|
|
it('does not beat another suit', () => {
|
|
expect(beats(Card.of(Suit.Clubs, Rank.Ace), Card.of(Suit.Hearts, Rank.Six), trump)).toBe(false);
|
|
});
|
|
|
|
it('lets any trump beat any non-trump', () => {
|
|
expect(beats(Card.of(Suit.Spades, Rank.Six), Card.of(Suit.Hearts, Rank.Ace), trump)).toBe(true);
|
|
});
|
|
|
|
it('only lets a higher trump beat a trump', () => {
|
|
expect(beats(Card.of(Suit.Spades, Rank.Seven), Card.of(Suit.Spades, Rank.Six), trump)).toBe(true);
|
|
expect(beats(Card.of(Suit.Spades, Rank.Six), Card.of(Suit.Spades, Rank.Seven), trump)).toBe(false);
|
|
expect(beats(Card.of(Suit.Hearts, Rank.Ace), Card.of(Suit.Spades, Rank.Six), trump)).toBe(false);
|
|
});
|
|
});
|