Детерминированный конечный автомат: 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>
483 lines
17 KiB
TypeScript
483 lines
17 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { Card } from '@core/cards/Card';
|
|
import { Actions, ActionType, IllegalActionError } from '@core/rules/Action';
|
|
import { GameEventType } from '@core/rules/GameEvent';
|
|
import { Phase } from '@core/rules/GameState';
|
|
import {
|
|
apply, currentActor, legalActions, maxTableSlots, whyCannotAdd,
|
|
} from '@core/rules/DurakRules';
|
|
import { handOf, makeState, tableOf } from '../../support/fixtures';
|
|
|
|
const YOU = 'you';
|
|
const BOT = 'bot';
|
|
|
|
/** Action codes a player may take, as readable strings, for set comparisons. */
|
|
function legalCodes(state: Parameters<typeof legalActions>[0], player: string): string[] {
|
|
return legalActions(state, player).map((a) => {
|
|
switch (a.type) {
|
|
case ActionType.Attack: return `attack ${a.card.code}`;
|
|
case ActionType.Defend: return `defend ${a.slot} ${a.card.code}`;
|
|
case ActionType.Take: return 'take';
|
|
case ActionType.Pass: return 'pass';
|
|
}
|
|
}).sort();
|
|
}
|
|
|
|
describe('opening a turn', () => {
|
|
it('lets the attacker play any card and offers no way to skip', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6C 7D KH', [BOT]: '8C 9D QH' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
});
|
|
|
|
expect(currentActor(state)).toBe(YOU);
|
|
expect(legalCodes(state, YOU)).toEqual(['attack 6♣', 'attack 7♦', 'attack K♥'].sort());
|
|
expect(legalCodes(state, YOU)).not.toContain('pass');
|
|
});
|
|
|
|
it('gives the defender no say until a card is down', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6C', [BOT]: '8C' },
|
|
trump: 'S',
|
|
deck: '10C',
|
|
attacker: YOU,
|
|
});
|
|
expect(legalActions(state, BOT)).toEqual([]);
|
|
});
|
|
|
|
it('moves to the defence once a card is played', () => {
|
|
const start = makeState({
|
|
hands: { [YOU]: '6C 7D', [BOT]: '8C 9D' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
});
|
|
|
|
const { state, events } = apply(start, Actions.attack(YOU, Card.parse('6C')));
|
|
|
|
expect(state.phase).toBe(Phase.Defence);
|
|
expect(currentActor(state)).toBe(BOT);
|
|
expect(tableOf(state)).toEqual(['6♣/-']);
|
|
expect(handOf(state, YOU)).toEqual(['7♦']);
|
|
expect(events.map((e) => e.type)).toEqual([GameEventType.Attacked]);
|
|
});
|
|
|
|
it('leaves the source state untouched', () => {
|
|
const start = makeState({
|
|
hands: { [YOU]: '6C 7D', [BOT]: '8C 9D' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
});
|
|
const before = JSON.stringify(handOf(start, YOU));
|
|
|
|
apply(start, Actions.attack(YOU, Card.parse('6C')));
|
|
|
|
expect(JSON.stringify(handOf(start, YOU))).toBe(before);
|
|
expect(start.table).toHaveLength(0);
|
|
expect(start.phase).toBe(Phase.Attack);
|
|
});
|
|
});
|
|
|
|
describe('defending', () => {
|
|
const position = () => makeState({
|
|
hands: { [YOU]: '9D KH', [BOT]: '7C 8C 6S QH' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
|
|
it('offers every card that beats the attack, plus taking', () => {
|
|
expect(legalCodes(position(), BOT).sort()).toEqual(['defend 0 6♠', 'take'].sort());
|
|
});
|
|
|
|
it('accepts a higher card of the same suit', () => {
|
|
const state = makeState({
|
|
// The attacker keeps a seven and the defender keeps a spare card, so
|
|
// the turn waits in Reinforce instead of settling the moment the
|
|
// defence lands.
|
|
hands: { [YOU]: '7H', [BOT]: '8D AH' },
|
|
trump: 'S',
|
|
deck: '10C',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
const result = apply(state, Actions.defend(BOT, Card.parse('8D'), 0));
|
|
|
|
expect(tableOf(result.state)).toEqual(['7♦/8♦']);
|
|
expect(result.state.phase).toBe(Phase.Reinforce);
|
|
});
|
|
|
|
it('refuses a card that does not beat the attack', () => {
|
|
const state = position();
|
|
expect(() => apply(state, Actions.defend(BOT, Card.parse('7C'), 0)))
|
|
.toThrow(IllegalActionError);
|
|
});
|
|
|
|
it('refuses a card that is not in hand', () => {
|
|
const state = position();
|
|
expect(() => apply(state, Actions.defend(BOT, Card.parse('AS'), 0)))
|
|
.toThrow(/not in hand/);
|
|
});
|
|
|
|
it('refuses a slot that is already beaten', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: 'KH', [BOT]: '9D 10D' },
|
|
trump: 'S',
|
|
deck: '10C',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }, { attack: '6H' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
expect(() => apply(state, Actions.defend(BOT, Card.parse('9D'), 0)))
|
|
.toThrow(/already beaten/);
|
|
});
|
|
|
|
it('requires a trump to beat a trump', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: 'KH', [BOT]: 'AH 7S' },
|
|
trump: 'S',
|
|
deck: '10C',
|
|
attacker: YOU,
|
|
table: [{ attack: '6S' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
expect(legalCodes(state, BOT).sort()).toEqual(['defend 0 7♠', 'take'].sort());
|
|
});
|
|
});
|
|
|
|
describe('adding cards to an attack', () => {
|
|
it('only allows ranks already on the table', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '7H 9C KD', [BOT]: 'AH AD' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '9D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
expect(legalCodes(state, YOU).sort()).toEqual(['attack 7♥', 'attack 9♣', 'pass'].sort());
|
|
expect(whyCannotAdd(state, Card.parse('KD'))).toMatch(/rank/);
|
|
});
|
|
|
|
it('counts the defender cards as well as the attacking ones', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '9H', [BOT]: 'AH AD' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '9D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
// The nine is addable only because the defender used a nine.
|
|
expect(whyCannotAdd(state, Card.parse('9H'))).toBeNull();
|
|
});
|
|
|
|
it('stops at six attacking cards', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6H', [BOT]: 'AH AD AC AS KH KD' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
turnNumber: 3,
|
|
table: [
|
|
{ attack: '6C', defence: '7C' }, { attack: '6D', defence: '7D' },
|
|
{ attack: '6S', defence: '7S' }, { attack: '7H', defence: '8H' },
|
|
{ attack: '8C', defence: '9C' }, { attack: '8D', defence: '9D' },
|
|
],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
expect(maxTableSlots(state)).toBe(6);
|
|
expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/already holds/);
|
|
expect(legalCodes(state, YOU)).toEqual(['pass']);
|
|
});
|
|
|
|
it('stops at five attacking cards on the very first turn', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6H', [BOT]: 'AH AD AC AS KH KD' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
turnNumber: 1,
|
|
table: [
|
|
{ attack: '6C', defence: '7C' }, { attack: '6D', defence: '7D' },
|
|
{ attack: '6S', defence: '7S' }, { attack: '7H', defence: '8H' },
|
|
{ attack: '8C', defence: '9C' },
|
|
],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
expect(maxTableSlots(state)).toBe(5);
|
|
expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/already holds/);
|
|
});
|
|
|
|
it('never leaves the defender more to beat than they hold', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6H 6D', [BOT]: 'AH' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: YOU,
|
|
table: [{ attack: '6C' }],
|
|
phase: Phase.Reinforce,
|
|
defenderTook: true,
|
|
});
|
|
|
|
// One unbeaten card already, and the defender holds exactly one card.
|
|
expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/does not hold enough/);
|
|
});
|
|
|
|
it('reopens the decision for everyone once a card is added', () => {
|
|
const state = makeState({
|
|
hands: { a: '6H', b: 'AH AD', c: '6D' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: 'a',
|
|
defender: 'b',
|
|
table: [{ attack: '6C', defence: '7C' }],
|
|
phase: Phase.Reinforce,
|
|
passed: ['c'],
|
|
});
|
|
|
|
const after = apply(state, Actions.attack('a', Card.parse('6H'))).state;
|
|
expect(after.passed.every((p) => p === false)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resolving a turn', () => {
|
|
it('discards the table after a successful defence and refills hands', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '9H', [BOT]: 'AH' },
|
|
trump: 'S',
|
|
deck: 'AC 10C 10D JC JD QC QD KC KD',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
const { state: after, events } = apply(state, Actions.pass(YOU));
|
|
|
|
expect(after.table).toHaveLength(0);
|
|
expect(after.discard.map((c) => c.code)).toEqual(['7♦', '8♦']);
|
|
expect(after.players.map((p) => p.hand.length)).toEqual([6, 6]);
|
|
expect(events.map((e) => e.type)).toContain(GameEventType.TableDiscarded);
|
|
});
|
|
|
|
it('refills the attacker first, then the defender', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '', [BOT]: '' },
|
|
trump: 'S',
|
|
// Only three cards plus the trump are left, so the order is visible.
|
|
deck: '10C 10D JC',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
const after = apply(state, Actions.pass(YOU)).state;
|
|
|
|
expect(handOf(after, YOU)).toEqual(['10♣', '10♦', 'J♣', '6♠']);
|
|
expect(handOf(after, BOT)).toEqual([]);
|
|
});
|
|
|
|
it('hands the attack to the defender after a successful defence', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '9H', [BOT]: 'AH' },
|
|
trump: 'S',
|
|
deck: '10C 10D JC JD QC QD KC KD',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
const after = apply(state, Actions.pass(YOU)).state;
|
|
|
|
expect(after.players[after.attacker].id).toBe(BOT);
|
|
expect(after.players[after.defender].id).toBe(YOU);
|
|
expect(after.turnNumber).toBe(3);
|
|
expect(after.phase).toBe(Phase.Attack);
|
|
});
|
|
|
|
it('gives the table to a defender who took, and skips their turn', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '9H', [BOT]: 'AH' },
|
|
trump: 'S',
|
|
deck: '10C 10D JC JD QC QD KC KD',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
|
|
// The attacker holds nothing of rank seven, so there is no decision left
|
|
// to make and the take resolves the whole turn in this one call.
|
|
const after = apply(state, Actions.take(BOT)).state;
|
|
expect(after.phase).toBe(Phase.Attack);
|
|
expect(handOf(after, BOT)).toContain('7♦');
|
|
expect(after.players[after.attacker].id).toBe(YOU);
|
|
expect(after.players[after.defender].id).toBe(BOT);
|
|
expect(after.discard).toHaveLength(0);
|
|
});
|
|
|
|
it('lets attackers add to a table that is being taken', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '7H 9C', [BOT]: 'AH AD AC' },
|
|
trump: 'S',
|
|
deck: '10C 10D JC JD QC QD KC KD',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
|
|
const took = apply(state, Actions.take(BOT)).state;
|
|
expect(took.phase).toBe(Phase.Reinforce);
|
|
expect(legalCodes(took, YOU).sort()).toEqual(['attack 7♥', 'pass'].sort());
|
|
|
|
const added = apply(took, Actions.attack(YOU, Card.parse('7H'))).state;
|
|
// Still being taken — the defender does not get to answer the new card.
|
|
expect(added.phase).toBe(Phase.Attack);
|
|
expect(handOf(added, BOT)).toEqual(expect.arrayContaining(['7♦', '7♥']));
|
|
});
|
|
});
|
|
|
|
describe('ending the game', () => {
|
|
it('declares the last player holding cards the fool', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '', [BOT]: 'AH' },
|
|
trump: 'S',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
const { state: after, events } = apply(state, Actions.pass(YOU));
|
|
|
|
expect(after.phase).toBe(Phase.Finished);
|
|
expect(after.loser).toBe(BOT);
|
|
expect(events.map((e) => e.type)).toContain(GameEventType.GameOver);
|
|
expect(events.map((e) => e.type)).toContain(GameEventType.PlayerOut);
|
|
});
|
|
|
|
it('calls it a draw when both players run out at once', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '', [BOT]: '' },
|
|
trump: 'S',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
const after = apply(state, Actions.pass(YOU)).state;
|
|
|
|
expect(after.phase).toBe(Phase.Finished);
|
|
expect(after.loser).toBeNull();
|
|
});
|
|
|
|
it('refuses any further action once it is over', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '', [BOT]: 'AH' },
|
|
trump: 'S',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
const finished = apply(state, Actions.pass(YOU)).state;
|
|
|
|
expect(currentActor(finished)).toBeNull();
|
|
expect(legalActions(finished, BOT)).toEqual([]);
|
|
expect(() => apply(finished, Actions.pass(BOT))).toThrow(/game is over/);
|
|
});
|
|
});
|
|
|
|
describe('turn order with more than two players', () => {
|
|
it('polls attackers starting from the attacker, skipping the defender', () => {
|
|
const state = makeState({
|
|
hands: { a: '6H', b: 'AH AD', c: '6D' },
|
|
trump: 'S',
|
|
deck: '10C 10D',
|
|
attacker: 'a',
|
|
defender: 'b',
|
|
table: [{ attack: '6C', defence: '7C' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
expect(currentActor(state)).toBe('a');
|
|
const afterA = apply(state, Actions.pass('a')).state;
|
|
expect(currentActor(afterA)).toBe('c');
|
|
});
|
|
|
|
it('passes the attack past a defender who took', () => {
|
|
const state = makeState({
|
|
hands: { a: 'KH', b: 'AH', c: 'QD' },
|
|
trump: 'S',
|
|
deck: '10C 10D JC JD QC KC',
|
|
attacker: 'a',
|
|
defender: 'b',
|
|
table: [{ attack: '7D' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
|
|
const after = apply(state, Actions.take('b')).state;
|
|
expect(after.players[after.attacker].id).toBe('c');
|
|
expect(after.players[after.defender].id).toBe('a');
|
|
});
|
|
|
|
it('skips a seat that has left the game', () => {
|
|
const state = makeState({
|
|
hands: { a: 'KH', b: 'AH', c: '' },
|
|
trump: 'S',
|
|
deck: '10C 10D JC JD QC KC',
|
|
attacker: 'a',
|
|
defender: 'b',
|
|
out: ['c'],
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
|
|
const after = apply(state, Actions.pass('a')).state;
|
|
expect(after.players[after.attacker].id).toBe('b');
|
|
expect(after.players[after.defender].id).toBe('a');
|
|
});
|
|
});
|
|
|
|
describe('rejecting out-of-turn play', () => {
|
|
it('refuses an action from the player who is not on move', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6C', [BOT]: '8C' },
|
|
trump: 'S',
|
|
deck: '10C',
|
|
attacker: YOU,
|
|
});
|
|
expect(() => apply(state, Actions.attack(BOT, Card.parse('8C')))).toThrow(IllegalActionError);
|
|
});
|
|
|
|
it('refuses a pass while a card still needs beating', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6C', [BOT]: '8C' },
|
|
trump: 'S',
|
|
deck: '10C',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D' }],
|
|
phase: Phase.Defence,
|
|
});
|
|
expect(() => apply(state, Actions.pass(BOT))).toThrow(/cannot pass/);
|
|
});
|
|
|
|
it('refuses a take when there is nothing to beat', () => {
|
|
const state = makeState({
|
|
hands: { [YOU]: '6C', [BOT]: '8C' },
|
|
trump: 'S',
|
|
deck: '10C',
|
|
attacker: YOU,
|
|
table: [{ attack: '7D', defence: '8D' }],
|
|
phase: Phase.Reinforce,
|
|
});
|
|
expect(() => apply(state, Actions.take(BOT))).toThrow(IllegalActionError);
|
|
});
|
|
});
|